Compare commits
17 Commits
7a0bb14df4
...
v0.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
| 3958da1983 | |||
| 76c98f63dd | |||
| 7a75083cf3 | |||
| 806d701535 | |||
| 3644001dbc | |||
| f4fd1b1b07 | |||
| a150958960 | |||
| 819d349bae | |||
| 0ae6c3f593 | |||
| 945e3212cf | |||
| 3547207eca | |||
| 6669e6722a | |||
| 7524745b18 | |||
| a9c5139d55 | |||
| 7cc6056e7e | |||
| 7516eb4444 | |||
| ef2403b3cd |
95
.drone.yml
Normal file
95
.drone.yml
Normal file
@@ -0,0 +1,95 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build
|
||||
|
||||
steps:
|
||||
- name: build-deb-amd64
|
||||
image: cache.coadcorp.com/library/buildpack-deps:jammy
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
commands:
|
||||
- apt-get update
|
||||
- apt-get install -y --no-install-recommends build-essential qt5-qmake qtbase5-dev qtbase5-dev-tools libexif-dev qt5-image-formats-plugins libmosquitto-dev dpkg-dev fakeroot ca-certificates
|
||||
- ARCH=amd64 BUILD_DIR=build-amd64 bash sbin/build_deb.sh "${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}"
|
||||
- ls -la dist
|
||||
|
||||
- name: build-deb-armhf
|
||||
image: cache.coadcorp.com/library/buildpack-deps:bullseye
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
ARM_CFLAGS: -march=armv6 -mfpu=vfp -mfloat-abi=hard -marm
|
||||
commands:
|
||||
- dpkg --add-architecture armhf
|
||||
- |
|
||||
cat > /etc/apt/sources.list <<'EOF'
|
||||
deb [arch=amd64] http://deb.debian.org/debian bullseye main contrib non-free
|
||||
deb [arch=amd64] http://deb.debian.org/debian bullseye-updates main contrib non-free
|
||||
deb [arch=amd64] http://security.debian.org/debian-security bullseye-security main contrib non-free
|
||||
deb [arch=armhf] http://deb.debian.org/debian bullseye main contrib non-free
|
||||
deb [arch=armhf] http://deb.debian.org/debian bullseye-updates main contrib non-free
|
||||
deb [arch=armhf] http://security.debian.org/debian-security bullseye-security main contrib non-free
|
||||
EOF
|
||||
- apt-get update
|
||||
- apt-get install -y --no-install-recommends build-essential qt5-qmake qtbase5-dev qtbase5-dev-tools libexif-dev qt5-image-formats-plugins libmosquitto-dev dpkg-dev fakeroot ca-certificates gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf qtbase5-dev:armhf libqt5core5a:armhf libqt5gui5:armhf libqt5widgets5:armhf libqt5network5:armhf libexif-dev:armhf libmosquitto-dev:armhf qt5-image-formats-plugins:armhf
|
||||
- ARCH=armhf BUILD_DIR=build-armhf QMAKESPEC=$PWD/mkspecs/linux-armhf-g++ QMAKE_QTCONF=$PWD/mkspecs/qt-armhf.conf QMAKE_CFLAGS="$ARM_CFLAGS" QMAKE_CXXFLAGS="$ARM_CFLAGS" bash sbin/build_deb.sh "${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}"
|
||||
- ls -la dist
|
||||
|
||||
- name: build-deb-arm64
|
||||
image: cache.coadcorp.com/library/buildpack-deps:bullseye
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
ARM64_CFLAGS: -march=armv8-a
|
||||
commands:
|
||||
- dpkg --add-architecture arm64
|
||||
- |
|
||||
cat > /etc/apt/sources.list <<'EOF'
|
||||
deb [arch=amd64] http://deb.debian.org/debian bullseye main contrib non-free
|
||||
deb [arch=amd64] http://deb.debian.org/debian bullseye-updates main contrib non-free
|
||||
deb [arch=amd64] http://security.debian.org/debian-security bullseye-security main contrib non-free
|
||||
deb [arch=arm64] http://deb.debian.org/debian bullseye main contrib non-free
|
||||
deb [arch=arm64] http://deb.debian.org/debian bullseye-updates main contrib non-free
|
||||
deb [arch=arm64] http://security.debian.org/debian-security bullseye-security main contrib non-free
|
||||
EOF
|
||||
- apt-get update
|
||||
- apt-get install -y --no-install-recommends build-essential qt5-qmake qtbase5-dev qtbase5-dev-tools libexif-dev qt5-image-formats-plugins libmosquitto-dev dpkg-dev fakeroot ca-certificates gcc-aarch64-linux-gnu g++-aarch64-linux-gnu qtbase5-dev:arm64 libqt5core5a:arm64 libqt5gui5:arm64 libqt5widgets5:arm64 libqt5network5:arm64 libexif-dev:arm64 libmosquitto-dev:arm64 qt5-image-formats-plugins:arm64
|
||||
- ARCH=arm64 BUILD_DIR=build-arm64 QMAKESPEC=$PWD/mkspecs/linux-arm64-g++ QMAKE_QTCONF=$PWD/mkspecs/qt-arm64.conf QMAKE_CFLAGS="$ARM64_CFLAGS" QMAKE_CXXFLAGS="$ARM64_CFLAGS" bash sbin/build_deb.sh "${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}"
|
||||
- ls -la dist
|
||||
|
||||
- name: build-deps-image
|
||||
image: cache.coadcorp.com/plugins/docker
|
||||
settings:
|
||||
registry: registry.coadcorp.com
|
||||
repo: slide/build-deps
|
||||
dockerfile: Dockerfile.builddeps
|
||||
username:
|
||||
from_secret: REGISTRY_USER
|
||||
password:
|
||||
from_secret: REGISTRY_PASS
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA}
|
||||
when:
|
||||
event:
|
||||
- promote
|
||||
target:
|
||||
- build-deps
|
||||
|
||||
- name: gitea-release
|
||||
image: cache.coadcorp.com/plugins/gitea-release
|
||||
depends_on:
|
||||
- build-deb-amd64
|
||||
- build-deb-armhf
|
||||
- build-deb-arm64
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: GITEA_TOKEN
|
||||
base_url: https://git.coadcorp.com
|
||||
files:
|
||||
- dist/*.deb
|
||||
draft: false
|
||||
prerelease: false
|
||||
title: ${DRONE_TAG}
|
||||
note: Automated release for ${DRONE_TAG}
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
17
Dockerfile.builddeps
Normal file
17
Dockerfile.builddeps
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM cache.coadcorp.com/library/ubuntu:22.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
qt5-qmake \
|
||||
qtbase5-dev \
|
||||
qtbase5-dev-tools \
|
||||
libexif-dev \
|
||||
qt5-image-formats-plugins \
|
||||
libmosquitto-dev \
|
||||
dpkg-dev \
|
||||
fakeroot \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
21
INSTALL.md
21
INSTALL.md
@@ -6,6 +6,8 @@ This project depends on the following dynamically linked libraries:
|
||||
|
||||
* qt5
|
||||
* libexif
|
||||
* qt5-image-formats-plugins
|
||||
* libmosquitto1
|
||||
|
||||
### OSX
|
||||
|
||||
@@ -13,28 +15,29 @@ This project depends on the following dynamically linked libraries:
|
||||
brew install qt libexif
|
||||
```
|
||||
|
||||
### Raspbian Stretch
|
||||
### Debian / Ubuntu / Raspberry Pi OS (runtime dependencies)
|
||||
|
||||
```
|
||||
brew install qt5 libexif12
|
||||
sudo apt-get install -y qt5-image-formats-plugins libmosquitto1
|
||||
```
|
||||
|
||||
## Extract binaries
|
||||
### Raspberry Pi Zero / Raspbian (additional image format support)
|
||||
|
||||
```
|
||||
tar xf slide_<arch>_<version>.tar.gz
|
||||
sudo apt-get install -y qt5-image-formats-plugins libmosquitto1 libmng1
|
||||
```
|
||||
|
||||
## Move binary to executable folder
|
||||
## Install .deb package
|
||||
|
||||
### OSX
|
||||
Use apt so dependencies are resolved automatically:
|
||||
|
||||
```
|
||||
mv slide_<version>/slide.app/Contents/MacOS/slide /usr/local/bin/
|
||||
sudo apt-get install ./slide_<version>_<arch>.deb
|
||||
```
|
||||
|
||||
### Linux
|
||||
If you must use dpkg, install runtime dependencies first:
|
||||
|
||||
```
|
||||
mv slide_<version>/slide /usr/bin/
|
||||
sudo apt-get install -y qt5-image-formats-plugins libmosquitto1
|
||||
sudo dpkg -i slide_<version>_<arch>.deb
|
||||
```
|
||||
|
||||
3
Makefile
3
Makefile
@@ -7,7 +7,7 @@ all: build
|
||||
|
||||
.PHONY: install-deps-deb
|
||||
install-deps-deb:
|
||||
apt install qt5-qmake libexif12 qt5-default libexif-dev qt5-image-formats-plugins
|
||||
apt install qt5-qmake libexif12 qt5-default libexif-dev qt5-image-formats-plugins libmosquitto-dev
|
||||
|
||||
check-deps-deb:
|
||||
dpkg -l | grep qt5-qmake
|
||||
@@ -15,6 +15,7 @@ check-deps-deb:
|
||||
dpkg -l | grep libexif-dev
|
||||
dpkg -l | grep qt5-default
|
||||
dpkg -l | grep qt5-image-formats-plugins
|
||||
dpkg -l | grep libmosquitto-dev
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
|
||||
68
README.md
68
README.md
@@ -23,7 +23,7 @@ slide [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color overlay_
|
||||
|
||||
* `image_folder`: where to search for images (.jpg files)
|
||||
* `-i imageFile,...`: comma delimited list of full paths to image files to display
|
||||
* `-c path_to_config_json`: the path to an optional slide.options.json file containing configuration parameters
|
||||
* `-c path_to_config_json`: path to a JSON config file, or a directory containing `slide.options.json`
|
||||
* `-t` how many seconds to display each picture for
|
||||
* `-r` for recursive traversal of `image_folder`
|
||||
* `-s` for shuffle instead of random image rotation
|
||||
@@ -55,7 +55,7 @@ slide [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color overlay_
|
||||
To exit the application, press escape. If you're using a touch display, touch all 4 corners at the same time.
|
||||
|
||||
## Configuration file
|
||||
Slide supports loading configuration from a JSON formatted file called `slide.options.json`. This file can be specified by the `-c` command line option, we will also attempt to read `~/.config/slide/slide.options.json` and `/etc/slide/slide.options.json` in that order. The first file to load is used and its options will override command line parameters.
|
||||
Slide supports loading configuration from a JSON formatted file called `slide.options.json`. This file can be specified by the `-c` command line option (file path or directory), and we will also attempt to read `~/.config/slide/slide.options.json` and `/etc/slide/slide.options.json` in that order. The first file to load is used and its options will override command line parameters.
|
||||
The file format is:
|
||||
```
|
||||
{
|
||||
@@ -117,6 +117,7 @@ Supported keys and values in the JSON configuration are:
|
||||
* `opacity` : the same as the command line `-o` argument
|
||||
* `blur` : the same as the command line `-b` argument
|
||||
* `debug` : set to true to enable verbose output from the program
|
||||
* `mqtt` : MQTT playback control (see below)
|
||||
* `immich` : connect to an Immich server instead of a local path (see below)
|
||||
* `scheduler` : this entry is an array of possible path values and associated settings. This key lets you manage display times/settings for a collection of paths. In the example above the top entry shows ONLY files from a Redit feed between 2 and 4pm, ONLY files from the `show_peak_times` folder from 8am to 10am and then 4pm to 7pm. At all other times it alternates displaying files in the `always_show_1` and `always_show_2` folder.
|
||||
* `exclusive` : When set to `true` only this entry will be used when it is in its valid time window.
|
||||
@@ -124,9 +125,60 @@ Supported keys and values in the JSON configuration are:
|
||||
* `path` : the path to image files
|
||||
* `stretch` : as above
|
||||
|
||||
### MQTT control
|
||||
|
||||
Add an `mqtt` block to control playback remotely. Publish one of the commands below to the configured topic.
|
||||
|
||||
Example:
|
||||
```
|
||||
{
|
||||
"mqtt": {
|
||||
"host": "mqtt.local",
|
||||
"port": 1883,
|
||||
"topic": "slide/control",
|
||||
"immichTopic": "slide/immich",
|
||||
"clientId": "slide-frame",
|
||||
"username": "slide",
|
||||
"password": "secret",
|
||||
"keepAlive": 30,
|
||||
"qos": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Commands:
|
||||
* `play` / `resume` — resume slideshow
|
||||
* `pause` — pause slideshow
|
||||
* `next` / `next-image` — advance to next image
|
||||
* `next-folder` — jump to next configured path (if multiple paths are configured)
|
||||
* `restart` / `reset` — recreate the selector and restart playback
|
||||
If `immichTopic` is not set, it defaults to `<topic>/immich`.
|
||||
|
||||
Immich control topic (`immichTopic`):
|
||||
* `album:<id>` or `albumIds:id1,id2` — filter to one or more album IDs
|
||||
* `person:<id>` or `personIds:id1,id2` — filter to one or more person IDs
|
||||
* `user:<id>` / `userId:<id>` / `ownerId:<id>` — show all assets owned by a user (clears album/person filters)
|
||||
* `extensions:jpg,jpeg,png` — filter by file extension (useful for RAW exclusion)
|
||||
* `reset` / `clear` — clear album/person/user filters
|
||||
* JSON payloads are also accepted, for example:
|
||||
```
|
||||
{"albumIds":["..."],"personIds":["..."],"order":"desc","size":"fullsize","userId":"...","extensions":["jpg","jpeg"]}
|
||||
```
|
||||
|
||||
### Immich configuration (lightweight + low power)
|
||||
|
||||
Immich uses an API key and a `/api` base path. This integration requests the asset search endpoint and downloads thumbnail images into a local cache before displaying them. That keeps bandwidth and power usage low while still letting `slide` do its normal scaling and transitions.
|
||||
Immich uses an API key and a `/api` base path. This integration requests the asset search endpoint and downloads the configured image size into a local cache before displaying them. That keeps bandwidth and power usage low while still letting `slide` do its normal scaling and transitions. If you have RAW/HEIC images, set `size` to `preview`/`thumbnail` or use `extensions` to limit results to JPEG/PNG; `slide` will also fall back to `preview` if a `fullsize` download isn't readable.
|
||||
|
||||
#### Getting an Immich API key
|
||||
|
||||
In the Immich web UI, go to Settings and find **API Keys** (menu labels can vary by version), then create a new key and copy it.
|
||||
|
||||
#### Required API key permissions
|
||||
|
||||
`slide` uses Immich search plus the asset view/download endpoints, so the API key should include:
|
||||
* `asset.read` — required by Immich search endpoints (used to retrieve asset metadata).
|
||||
* `asset.view` — required for the viewAsset endpoint (thumbnail/preview/fullsize).
|
||||
* `asset.download` — required if you set `size` to `original` (download endpoint).
|
||||
|
||||
Example (single source):
|
||||
```
|
||||
@@ -139,6 +191,7 @@ Example (single source):
|
||||
"order": "desc",
|
||||
"pageSize": 200,
|
||||
"maxAssets": 1000,
|
||||
"refreshSeconds": 300,
|
||||
"cachePath": "~/.cache/slide/immich",
|
||||
"cacheMaxMB": 512,
|
||||
"includeArchived": false
|
||||
@@ -166,14 +219,20 @@ Example (scheduler entry):
|
||||
Immich settings:
|
||||
* `url`: base Immich server URL (the integration appends `/api` automatically if missing).
|
||||
* `apiKey`: Immich API key (needs `asset.view`, and `asset.download` if `size` is `original`).
|
||||
* `userId`: optional user id to retrieve all assets owned by that user via the assets endpoint.
|
||||
* `albumId` or `albumIds`: optional album filters.
|
||||
* `personId` or `personIds`: optional person filters.
|
||||
* `extensions` / `allowedExtensions`: optional list of file extensions to include (for example `["jpg","jpeg","png"]`).
|
||||
* `size`: `"fullsize"`, `"preview"`, `"thumbnail"`, or `"original"` (original uses the download endpoint).
|
||||
* `order`: `"asc"` or `"desc"` ordering for asset search.
|
||||
* `pageSize`: assets fetched per page.
|
||||
* `maxAssets`: cap on total assets fetched (0 means no cap).
|
||||
* `cachePath`: local cache directory for downloaded thumbnails.
|
||||
* `refreshSeconds`: refresh interval for reloading Immich assets (0 disables).
|
||||
* `cachePath`: local cache directory for downloaded images.
|
||||
* `cacheMaxMB`: maximum cache size in MB (0 disables cleanup).
|
||||
* `includeArchived`: include archived assets in search results.
|
||||
If you omit `albumId`/`albumIds`/`personIds`, Immich returns all assets visible to the API key’s user.
|
||||
If `userId` is set, album/person filters are ignored and all assets for that user are fetched.
|
||||
When `immich` is set on an entry, `path` and `imageList` are ignored.
|
||||
|
||||
## Folder Options file
|
||||
@@ -205,6 +264,7 @@ See the `Configuration File` section for details of each setting.
|
||||
* qt5
|
||||
* qt5-image-formats-plugins
|
||||
* libexif
|
||||
* libmosquitto-dev
|
||||
|
||||
Ubuntu/Raspbian:
|
||||
|
||||
|
||||
21
mkspecs/linux-arm64-g++/qmake.conf
Normal file
21
mkspecs/linux-arm64-g++/qmake.conf
Normal file
@@ -0,0 +1,21 @@
|
||||
exists(/usr/lib/x86_64-linux-gnu/qt5/mkspecs/linux-g++/qmake.conf) {
|
||||
include(/usr/lib/x86_64-linux-gnu/qt5/mkspecs/linux-g++/qmake.conf)
|
||||
} else: exists(/usr/share/qt5/mkspecs/linux-g++/qmake.conf) {
|
||||
include(/usr/share/qt5/mkspecs/linux-g++/qmake.conf)
|
||||
} else: exists($$[QT_HOST_DATA]/mkspecs/linux-g++/qmake.conf) {
|
||||
include($$[QT_HOST_DATA]/mkspecs/linux-g++/qmake.conf)
|
||||
} else {
|
||||
include($$[QT_INSTALL_DATA]/mkspecs/linux-g++/qmake.conf)
|
||||
}
|
||||
|
||||
QMAKE_CC = aarch64-linux-gnu-gcc
|
||||
QMAKE_CXX = aarch64-linux-gnu-g++
|
||||
QMAKE_LINK = aarch64-linux-gnu-g++
|
||||
QMAKE_AR = aarch64-linux-gnu-ar cqs
|
||||
QMAKE_STRIP = aarch64-linux-gnu-strip
|
||||
|
||||
QMAKE_INCDIR_QT = /usr/include/aarch64-linux-gnu/qt5
|
||||
QMAKE_LIBDIR_QT = /usr/lib/aarch64-linux-gnu
|
||||
QMAKE_INCDIR += /usr/include/aarch64-linux-gnu/qt5
|
||||
QMAKE_LIBDIR += /usr/lib/aarch64-linux-gnu
|
||||
QMAKE_LFLAGS += -Wl,-rpath-link,/usr/lib/aarch64-linux-gnu
|
||||
1
mkspecs/linux-arm64-g++/qplatformdefs.h
Normal file
1
mkspecs/linux-arm64-g++/qplatformdefs.h
Normal file
@@ -0,0 +1 @@
|
||||
#include "/usr/lib/x86_64-linux-gnu/qt5/mkspecs/linux-g++/qplatformdefs.h"
|
||||
21
mkspecs/linux-armhf-g++/qmake.conf
Normal file
21
mkspecs/linux-armhf-g++/qmake.conf
Normal file
@@ -0,0 +1,21 @@
|
||||
exists(/usr/lib/x86_64-linux-gnu/qt5/mkspecs/linux-g++/qmake.conf) {
|
||||
include(/usr/lib/x86_64-linux-gnu/qt5/mkspecs/linux-g++/qmake.conf)
|
||||
} else: exists(/usr/share/qt5/mkspecs/linux-g++/qmake.conf) {
|
||||
include(/usr/share/qt5/mkspecs/linux-g++/qmake.conf)
|
||||
} else: exists($$[QT_HOST_DATA]/mkspecs/linux-g++/qmake.conf) {
|
||||
include($$[QT_HOST_DATA]/mkspecs/linux-g++/qmake.conf)
|
||||
} else {
|
||||
include($$[QT_INSTALL_DATA]/mkspecs/linux-g++/qmake.conf)
|
||||
}
|
||||
|
||||
QMAKE_CC = arm-linux-gnueabihf-gcc
|
||||
QMAKE_CXX = arm-linux-gnueabihf-g++
|
||||
QMAKE_LINK = arm-linux-gnueabihf-g++
|
||||
QMAKE_AR = arm-linux-gnueabihf-ar cqs
|
||||
QMAKE_STRIP = arm-linux-gnueabihf-strip
|
||||
|
||||
QMAKE_INCDIR_QT = /usr/include/arm-linux-gnueabihf/qt5
|
||||
QMAKE_LIBDIR_QT = /usr/lib/arm-linux-gnueabihf
|
||||
QMAKE_INCDIR += /usr/include/arm-linux-gnueabihf/qt5
|
||||
QMAKE_LIBDIR += /usr/lib/arm-linux-gnueabihf
|
||||
QMAKE_LFLAGS += -Wl,-rpath-link,/usr/lib/arm-linux-gnueabihf
|
||||
1
mkspecs/linux-armhf-g++/qplatformdefs.h
Normal file
1
mkspecs/linux-armhf-g++/qplatformdefs.h
Normal file
@@ -0,0 +1 @@
|
||||
#include "/usr/lib/x86_64-linux-gnu/qt5/mkspecs/linux-g++/qplatformdefs.h"
|
||||
11
mkspecs/qt-arm64.conf
Normal file
11
mkspecs/qt-arm64.conf
Normal file
@@ -0,0 +1,11 @@
|
||||
[Paths]
|
||||
Prefix=/usr
|
||||
Headers=/usr/include/aarch64-linux-gnu/qt5
|
||||
Libraries=/usr/lib/aarch64-linux-gnu
|
||||
ArchData=/usr/lib/aarch64-linux-gnu/qt5
|
||||
Data=/usr/lib/aarch64-linux-gnu/qt5
|
||||
Plugins=/usr/lib/aarch64-linux-gnu/qt5/plugins
|
||||
HostPrefix=/usr
|
||||
HostData=/usr/lib/x86_64-linux-gnu/qt5
|
||||
HostBinaries=/usr/lib/qt5/bin
|
||||
HostLibraries=/usr/lib/x86_64-linux-gnu
|
||||
11
mkspecs/qt-armhf.conf
Normal file
11
mkspecs/qt-armhf.conf
Normal file
@@ -0,0 +1,11 @@
|
||||
[Paths]
|
||||
Prefix=/usr
|
||||
Headers=/usr/include/arm-linux-gnueabihf/qt5
|
||||
Libraries=/usr/lib/arm-linux-gnueabihf
|
||||
ArchData=/usr/lib/arm-linux-gnueabihf/qt5
|
||||
Data=/usr/lib/arm-linux-gnueabihf/qt5
|
||||
Plugins=/usr/lib/arm-linux-gnueabihf/qt5/plugins
|
||||
HostPrefix=/usr
|
||||
HostData=/usr/lib/x86_64-linux-gnu/qt5
|
||||
HostBinaries=/usr/lib/qt5/bin
|
||||
HostLibraries=/usr/lib/x86_64-linux-gnu
|
||||
@@ -5,15 +5,49 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VERSION="${1:-${VERSION:-0.0.0}}"
|
||||
VERSION="${VERSION#v}"
|
||||
# Debian versions must start with a digit; fall back to 0.0.0+<sha/tag>.
|
||||
if [[ ! "$VERSION" =~ ^[0-9] ]]; then
|
||||
VERSION="0.0.0+${VERSION}"
|
||||
fi
|
||||
ARCH="${ARCH:-$(dpkg --print-architecture)}"
|
||||
|
||||
PACKAGE_NAME="slide"
|
||||
BUILD_DIR="$ROOT_DIR/build"
|
||||
BUILD_DIR="${BUILD_DIR:-$ROOT_DIR/build-$ARCH}"
|
||||
DIST_DIR="$ROOT_DIR/dist"
|
||||
STAGE_DIR="$BUILD_DIR/deb"
|
||||
QMAKE_BIN="${QMAKE_BIN:-qmake}"
|
||||
MAKE_JOBS="${MAKE_JOBS:-2}"
|
||||
|
||||
QMAKE_ARGS=()
|
||||
if [[ -n "${QMAKESPEC:-}" ]]; then
|
||||
QMAKE_ARGS+=("-spec" "$QMAKESPEC")
|
||||
fi
|
||||
if [[ -n "${QMAKE_QTCONF:-}" ]]; then
|
||||
QMAKE_ARGS+=("-qtconf" "$QMAKE_QTCONF")
|
||||
fi
|
||||
if [[ -n "${QMAKE_CC:-}" ]]; then
|
||||
QMAKE_ARGS+=("QMAKE_CC=$QMAKE_CC")
|
||||
fi
|
||||
if [[ -n "${QMAKE_CXX:-}" ]]; then
|
||||
QMAKE_ARGS+=("QMAKE_CXX=$QMAKE_CXX")
|
||||
fi
|
||||
if [[ -n "${QMAKE_LINK:-}" ]]; then
|
||||
QMAKE_ARGS+=("QMAKE_LINK=$QMAKE_LINK")
|
||||
fi
|
||||
if [[ -n "${QMAKE_CFLAGS:-}" ]]; then
|
||||
QMAKE_ARGS+=("QMAKE_CFLAGS=$QMAKE_CFLAGS")
|
||||
fi
|
||||
if [[ -n "${QMAKE_CXXFLAGS:-}" ]]; then
|
||||
QMAKE_ARGS+=("QMAKE_CXXFLAGS=$QMAKE_CXXFLAGS")
|
||||
fi
|
||||
if [[ -n "${QMAKE_LFLAGS:-}" ]]; then
|
||||
QMAKE_ARGS+=("QMAKE_LFLAGS=$QMAKE_LFLAGS")
|
||||
fi
|
||||
|
||||
PACKAGE_NAME="slide"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
make build
|
||||
mkdir -p "$BUILD_DIR"
|
||||
"$QMAKE_BIN" "${QMAKE_ARGS[@]}" "$ROOT_DIR/src/slide.pro" -o "$BUILD_DIR/Makefile"
|
||||
make -C "$BUILD_DIR" -j"$MAKE_JOBS"
|
||||
|
||||
rm -rf "$STAGE_DIR"
|
||||
mkdir -p "$STAGE_DIR/DEBIAN" "$STAGE_DIR/usr/local/bin" "$DIST_DIR"
|
||||
@@ -27,9 +61,9 @@ Section: graphics
|
||||
Priority: optional
|
||||
Architecture: ${ARCH}
|
||||
Maintainer: slide build
|
||||
Depends: libqt5core5a, libqt5gui5, libqt5widgets5, libqt5network5, libexif12, qt5-image-formats-plugins
|
||||
Depends: libqt5core5a, libqt5gui5, libqt5widgets5, libqt5network5, libexif12, qt5-image-formats-plugins, libmosquitto1
|
||||
Description: Lightweight slideshow for photo frames
|
||||
Simple, lightweight slideshow designed for low power devices.
|
||||
EOF
|
||||
|
||||
dpkg-deb --build "$STAGE_DIR" "$DIST_DIR/${PACKAGE_NAME}_${VERSION}_${ARCH}.deb"
|
||||
dpkg-deb --build -Zgzip "$STAGE_DIR" "$DIST_DIR/${PACKAGE_NAME}_${VERSION}_${ARCH}.deb"
|
||||
|
||||
@@ -71,6 +71,19 @@ std::vector<std::string> ParseJSONStrings(QJsonObject jsonDoc, const char *key)
|
||||
return values;
|
||||
}
|
||||
|
||||
std::vector<std::string> NormalizeExtensions(const std::vector<std::string> &values) {
|
||||
std::vector<std::string> normalized;
|
||||
for (const auto &value : values)
|
||||
{
|
||||
std::string s = value;
|
||||
for (auto &c : s)
|
||||
c = static_cast<char>(::tolower(c));
|
||||
if (!s.empty())
|
||||
normalized.push_back(s);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) {
|
||||
ImmichConfig config;
|
||||
|
||||
@@ -81,6 +94,13 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) {
|
||||
if(!apiKey.empty())
|
||||
config.apiKey = apiKey;
|
||||
|
||||
std::string userId = ParseJSONString(immichJson, "userId");
|
||||
if(!userId.empty())
|
||||
config.userId = userId;
|
||||
std::string ownerId = ParseJSONString(immichJson, "ownerId");
|
||||
if(!ownerId.empty() && config.userId.empty())
|
||||
config.userId = ownerId;
|
||||
|
||||
std::string size = ParseJSONString(immichJson, "size");
|
||||
if(!size.empty())
|
||||
config.size = size;
|
||||
@@ -101,8 +121,24 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) {
|
||||
if(albumIds.size() > 0)
|
||||
config.albumIds = albumIds;
|
||||
|
||||
std::string personId = ParseJSONString(immichJson, "personId");
|
||||
if(!personId.empty())
|
||||
config.personIds.push_back(personId);
|
||||
|
||||
std::vector<std::string> personIds = ParseJSONStrings(immichJson, "personIds");
|
||||
if(personIds.size() > 0)
|
||||
config.personIds = personIds;
|
||||
|
||||
std::vector<std::string> allowedExtensions = ParseJSONStrings(immichJson, "extensions");
|
||||
if(allowedExtensions.size() == 0)
|
||||
allowedExtensions = ParseJSONStrings(immichJson, "allowedExtensions");
|
||||
if(allowedExtensions.size() > 0)
|
||||
config.allowedExtensions = NormalizeExtensions(allowedExtensions);
|
||||
|
||||
SetJSONInt(config.pageSize, immichJson, "pageSize");
|
||||
SetJSONInt(config.maxAssets, immichJson, "maxAssets");
|
||||
SetJSONInt(config.refreshSeconds, immichJson, "refreshSeconds");
|
||||
SetJSONInt(config.refreshSeconds, immichJson, "refreshIntervalSeconds");
|
||||
SetJSONInt(config.cacheMaxMB, immichJson, "cacheMaxMB");
|
||||
SetJSONBool(config.includeArchived, immichJson, "includeArchived");
|
||||
|
||||
@@ -112,6 +148,46 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) {
|
||||
return config;
|
||||
}
|
||||
|
||||
MqttConfig ParseMqttConfigObject(QJsonObject mqttJson) {
|
||||
MqttConfig config;
|
||||
|
||||
std::string host = ParseJSONString(mqttJson, "host");
|
||||
if(!host.empty())
|
||||
config.host = host;
|
||||
|
||||
std::string topic = ParseJSONString(mqttJson, "topic");
|
||||
if(!topic.empty())
|
||||
config.topic = topic;
|
||||
|
||||
std::string immichTopic = ParseJSONString(mqttJson, "immichTopic");
|
||||
if(!immichTopic.empty())
|
||||
config.immichTopic = immichTopic;
|
||||
|
||||
std::string clientId = ParseJSONString(mqttJson, "clientId");
|
||||
if(!clientId.empty())
|
||||
config.clientId = clientId;
|
||||
|
||||
std::string username = ParseJSONString(mqttJson, "username");
|
||||
if(!username.empty())
|
||||
config.username = username;
|
||||
|
||||
std::string password = ParseJSONString(mqttJson, "password");
|
||||
if(!password.empty())
|
||||
config.password = password;
|
||||
|
||||
SetJSONInt(config.port, mqttJson, "port");
|
||||
SetJSONInt(config.keepAlive, mqttJson, "keepAlive");
|
||||
SetJSONInt(config.qos, mqttJson, "qos");
|
||||
|
||||
if(config.immichTopic.empty() && !config.topic.empty())
|
||||
config.immichTopic = config.topic + "/immich";
|
||||
|
||||
if(!config.host.empty() && !config.topic.empty())
|
||||
config.enabled = true;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
Config loadConfiguration(const std::string &configFilePath, const Config ¤tConfig) {
|
||||
if(configFilePath.empty())
|
||||
{
|
||||
@@ -200,28 +276,36 @@ QString getAppConfigFilePath(const std::string &configPath) {
|
||||
std::string systemConfigFolder = "/etc/slide";
|
||||
QString baseConfigFilename("slide.options.json");
|
||||
|
||||
QDir directory(userConfigFolder.c_str());
|
||||
QString jsonFile = "";
|
||||
if (!configPath.empty())
|
||||
{
|
||||
directory.setPath(configPath.c_str());
|
||||
jsonFile = directory.filePath(baseConfigFilename);
|
||||
}
|
||||
if(!directory.exists(jsonFile))
|
||||
QFileInfo configInfo(QString::fromStdString(configPath));
|
||||
if (configInfo.exists() && configInfo.isFile())
|
||||
{
|
||||
directory.setPath(userConfigFolder.c_str());
|
||||
jsonFile = directory.filePath(baseConfigFilename);
|
||||
}
|
||||
if(!directory.exists(jsonFile))
|
||||
{
|
||||
directory.setPath(systemConfigFolder.c_str());
|
||||
jsonFile = directory.filePath(baseConfigFilename);
|
||||
return configInfo.absoluteFilePath();
|
||||
}
|
||||
|
||||
if(directory.exists(jsonFile))
|
||||
QDir directory(configInfo.isDir() ? configInfo.absoluteFilePath()
|
||||
: QString::fromStdString(configPath));
|
||||
QString jsonFile = directory.filePath(baseConfigFilename);
|
||||
if (directory.exists(jsonFile))
|
||||
{
|
||||
return jsonFile;
|
||||
}
|
||||
}
|
||||
|
||||
QDir userDir(userConfigFolder.c_str());
|
||||
QString userFile = userDir.filePath(baseConfigFilename);
|
||||
if (userDir.exists(userFile))
|
||||
{
|
||||
return userFile;
|
||||
}
|
||||
|
||||
QDir systemDir(systemConfigFolder.c_str());
|
||||
QString systemFile = systemDir.filePath(baseConfigFilename);
|
||||
if (systemDir.exists(systemFile))
|
||||
{
|
||||
return systemFile;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -327,6 +411,11 @@ AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) {
|
||||
loadedConfig.overlay = overlayString;
|
||||
}
|
||||
|
||||
if(jsonDoc.contains("mqtt") && jsonDoc["mqtt"].isObject())
|
||||
{
|
||||
loadedConfig.mqtt = ParseMqttConfigObject(jsonDoc["mqtt"].toObject());
|
||||
}
|
||||
|
||||
loadedConfig.paths = parsePathEntry(jsonDoc, baseRecursive, baseShuffle, baseSorted);
|
||||
if(loadedConfig.paths.count() <= 0)
|
||||
{
|
||||
|
||||
@@ -10,11 +10,15 @@ struct ImmichConfig {
|
||||
bool enabled = false;
|
||||
std::string url = "";
|
||||
std::string apiKey = "";
|
||||
std::string userId = "";
|
||||
std::vector<std::string> albumIds;
|
||||
std::vector<std::string> personIds;
|
||||
std::vector<std::string> allowedExtensions;
|
||||
std::string size = "fullsize";
|
||||
std::string order = "desc";
|
||||
int pageSize = 200;
|
||||
int maxAssets = 1000;
|
||||
int refreshSeconds = 300;
|
||||
std::string cachePath = "";
|
||||
int cacheMaxMB = 512;
|
||||
bool includeArchived = false;
|
||||
@@ -25,6 +29,8 @@ struct ImmichConfig {
|
||||
return false;
|
||||
if (url != b.url || apiKey != b.apiKey)
|
||||
return false;
|
||||
if (userId != b.userId)
|
||||
return false;
|
||||
if (size != b.size || order != b.order)
|
||||
return false;
|
||||
if (pageSize != b.pageSize || maxAssets != b.maxAssets)
|
||||
@@ -40,6 +46,22 @@ struct ImmichConfig {
|
||||
if (albumIds[i] != b.albumIds[i])
|
||||
return false;
|
||||
}
|
||||
if (personIds.size() != b.personIds.size())
|
||||
return false;
|
||||
for (size_t i = 0; i < personIds.size(); ++i)
|
||||
{
|
||||
if (personIds[i] != b.personIds[i])
|
||||
return false;
|
||||
}
|
||||
if (allowedExtensions.size() != b.allowedExtensions.size())
|
||||
return false;
|
||||
for (size_t i = 0; i < allowedExtensions.size(); ++i)
|
||||
{
|
||||
if (allowedExtensions[i] != b.allowedExtensions[i])
|
||||
return false;
|
||||
}
|
||||
if (refreshSeconds != b.refreshSeconds)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -49,6 +71,38 @@ struct ImmichConfig {
|
||||
}
|
||||
};
|
||||
|
||||
struct MqttConfig {
|
||||
bool enabled = false;
|
||||
std::string host = "localhost";
|
||||
int port = 1883;
|
||||
std::string topic = "slide/control";
|
||||
std::string immichTopic = "";
|
||||
std::string clientId = "slide";
|
||||
std::string username = "";
|
||||
std::string password = "";
|
||||
int keepAlive = 30;
|
||||
int qos = 0;
|
||||
|
||||
bool operator==(const MqttConfig &b) const
|
||||
{
|
||||
return enabled == b.enabled &&
|
||||
host == b.host &&
|
||||
port == b.port &&
|
||||
topic == b.topic &&
|
||||
immichTopic == b.immichTopic &&
|
||||
clientId == b.clientId &&
|
||||
username == b.username &&
|
||||
password == b.password &&
|
||||
keepAlive == b.keepAlive &&
|
||||
qos == b.qos;
|
||||
}
|
||||
|
||||
bool operator!=(const MqttConfig &b) const
|
||||
{
|
||||
return !operator==(b);
|
||||
}
|
||||
};
|
||||
|
||||
// configuration options that apply to an image/folder of images
|
||||
struct Config {
|
||||
public:
|
||||
@@ -106,6 +160,7 @@ struct AppConfig : public Config {
|
||||
std::string overlay = "";
|
||||
QString overlayHexRGB = "#FFFFFF";
|
||||
QVector<PathEntry> paths;
|
||||
MqttConfig mqtt;
|
||||
|
||||
bool debugMode = false;
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ const ImageDetails ShuffleImageSelector::getNextImage(const ImageDisplayOptions
|
||||
|
||||
void ShuffleImageSelector::reloadImagesIfNoneLeft()
|
||||
{
|
||||
if (images.size() == 0 || current_image_shuffle >= images.size())
|
||||
if (images.size() == 0 || current_image_shuffle >= images.size() || pathTraverser->shouldReloadImages())
|
||||
{
|
||||
current_image_shuffle = 0;
|
||||
images = pathTraverser->getImages();
|
||||
@@ -305,7 +305,7 @@ const ImageDetails SortedImageSelector::getNextImage(const ImageDisplayOptions &
|
||||
|
||||
void SortedImageSelector::reloadImagesIfEmpty()
|
||||
{
|
||||
if (images.size() == 0)
|
||||
if (images.size() == 0 || pathTraverser->shouldReloadImages())
|
||||
{
|
||||
images = pathTraverser->getImages();
|
||||
std::sort(images.begin(), images.end());
|
||||
|
||||
@@ -21,6 +21,8 @@ ImageSwitcher::ImageSwitcher(MainWindow& w, unsigned int timeoutMsec, std::uniqu
|
||||
|
||||
void ImageSwitcher::updateImage()
|
||||
{
|
||||
if (paused)
|
||||
return;
|
||||
if(reloadConfigIfNeeded)
|
||||
{
|
||||
reloadConfigIfNeeded(window, this);
|
||||
@@ -60,6 +62,7 @@ void ImageSwitcher::setConfigFileReloader(std::function<void(MainWindow &w, Imag
|
||||
void ImageSwitcher::setRotationTime(unsigned int timeoutMsecIn)
|
||||
{
|
||||
timeout = timeoutMsecIn;
|
||||
if (!paused)
|
||||
timer.start(timeout);
|
||||
}
|
||||
|
||||
@@ -67,3 +70,56 @@ void ImageSwitcher::setImageSelector(std::unique_ptr<ImageSelector>& selectorIn)
|
||||
{
|
||||
selector = std::move(selectorIn);
|
||||
}
|
||||
|
||||
void ImageSwitcher::pause()
|
||||
{
|
||||
paused = true;
|
||||
timer.stop();
|
||||
timerNoContent.stop();
|
||||
}
|
||||
|
||||
void ImageSwitcher::resume()
|
||||
{
|
||||
if (!paused)
|
||||
return;
|
||||
paused = false;
|
||||
timer.start(timeout);
|
||||
scheduleImageUpdate();
|
||||
}
|
||||
|
||||
void ImageSwitcher::stepOnce()
|
||||
{
|
||||
bool wasPaused = paused;
|
||||
if (wasPaused)
|
||||
paused = false;
|
||||
updateImage();
|
||||
if (wasPaused)
|
||||
{
|
||||
paused = true;
|
||||
timer.stop();
|
||||
timerNoContent.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void ImageSwitcher::restart(std::unique_ptr<ImageSelector>& selectorIn)
|
||||
{
|
||||
paused = false;
|
||||
timerNoContent.stop();
|
||||
setImageSelector(selectorIn);
|
||||
timer.start(timeout);
|
||||
scheduleImageUpdate();
|
||||
}
|
||||
|
||||
bool ImageSwitcher::skipToNextFolder()
|
||||
{
|
||||
auto *listSelector = dynamic_cast<ListImageSelector*>(selector.get());
|
||||
if (!listSelector)
|
||||
return false;
|
||||
stepOnce();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ImageSwitcher::isPaused() const
|
||||
{
|
||||
return paused;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ public:
|
||||
void setConfigFileReloader(std::function<void(MainWindow &w, ImageSwitcher *switcher)> reloadConfigIfNeededIn);
|
||||
void setRotationTime(unsigned int timeoutMsec);
|
||||
void setImageSelector(std::unique_ptr<ImageSelector>& selector);
|
||||
void pause();
|
||||
void resume();
|
||||
void stepOnce();
|
||||
void restart(std::unique_ptr<ImageSelector>& selector);
|
||||
bool skipToNextFolder();
|
||||
bool isPaused() const;
|
||||
|
||||
public slots:
|
||||
void updateImage();
|
||||
@@ -30,6 +36,7 @@ private:
|
||||
const unsigned int timeoutNoContent = 5 * 1000; // 5 sec
|
||||
QTimer timerNoContent;
|
||||
std::function<void(MainWindow &w, ImageSwitcher *switcher)> reloadConfigIfNeeded;
|
||||
bool paused = false;
|
||||
};
|
||||
|
||||
#endif // IMAGESWITCHER_H
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <QEventLoop>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QBuffer>
|
||||
#include <QImageReader>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
@@ -12,10 +14,25 @@
|
||||
#include <QSaveFile>
|
||||
#include <QTimer>
|
||||
#include <QUrlQuery>
|
||||
#include <QFileInfo>
|
||||
|
||||
namespace {
|
||||
const int kMetadataTimeoutMs = 15000;
|
||||
const int kAssetTimeoutMs = 30000;
|
||||
QString DetectImageExtension(const QByteArray &data)
|
||||
{
|
||||
QBuffer buffer;
|
||||
buffer.setData(data);
|
||||
if (!buffer.open(QIODevice::ReadOnly))
|
||||
return "";
|
||||
QImageReader reader(&buffer);
|
||||
if (!reader.canRead())
|
||||
return "";
|
||||
QByteArray fmt = reader.format().toLower();
|
||||
if (fmt == "jpeg")
|
||||
fmt = "jpg";
|
||||
return QString::fromUtf8(fmt);
|
||||
}
|
||||
}
|
||||
|
||||
ImmichClient::ImmichClient(const ImmichConfig &configIn)
|
||||
@@ -93,18 +110,41 @@ QByteArray ImmichClient::getBytes(const QUrl &url, QString *contentType, int tim
|
||||
|
||||
QVector<ImmichAsset> ImmichClient::fetchAssets()
|
||||
{
|
||||
QVector<ImmichAsset> assets;
|
||||
if (!config.enabled)
|
||||
{
|
||||
Log("Immich config is missing url or apiKey.");
|
||||
return assets;
|
||||
return QVector<ImmichAsset>();
|
||||
}
|
||||
|
||||
if (!config.userId.empty() && config.albumIds.empty() && config.personIds.empty())
|
||||
return fetchAssetsByUser();
|
||||
|
||||
if (!config.userId.empty() && (!config.albumIds.empty() || !config.personIds.empty()))
|
||||
{
|
||||
Log("Immich userId is set but album/person filters are also set; ignoring userId for search filters.");
|
||||
}
|
||||
|
||||
return fetchAssetsBySearch();
|
||||
}
|
||||
|
||||
QVector<ImmichAsset> ImmichClient::fetchAssetsBySearch()
|
||||
{
|
||||
QVector<ImmichAsset> assets;
|
||||
|
||||
int pageSize = config.pageSize > 0 ? config.pageSize : 200;
|
||||
int maxAssets = config.maxAssets;
|
||||
bool triedZero = false;
|
||||
int page = 1;
|
||||
|
||||
if (ShouldLog())
|
||||
{
|
||||
Log("Immich search: size=", config.size, ", order=", config.order,
|
||||
", pageSize=", pageSize, ", maxAssets=", maxAssets,
|
||||
", albumIds=", config.albumIds.size(),
|
||||
", personIds=", config.personIds.size(),
|
||||
", allowedExtensions=", config.allowedExtensions.size());
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
QJsonObject body;
|
||||
@@ -121,6 +161,13 @@ QVector<ImmichAsset> ImmichClient::fetchAssets()
|
||||
ids.append(QString::fromStdString(id));
|
||||
body["albumIds"] = ids;
|
||||
}
|
||||
if (config.personIds.size() > 0)
|
||||
{
|
||||
QJsonArray ids;
|
||||
for (const auto &id : config.personIds)
|
||||
ids.append(QString::fromStdString(id));
|
||||
body["personIds"] = ids;
|
||||
}
|
||||
|
||||
QByteArray response = postJson(apiUrl("/search/metadata"), body, nullptr, kMetadataTimeoutMs);
|
||||
if (response.isEmpty())
|
||||
@@ -134,6 +181,7 @@ QVector<ImmichAsset> ImmichClient::fetchAssets()
|
||||
QJsonObject assetsObj = root["assets"].toObject();
|
||||
QJsonArray items = assetsObj["items"].toArray();
|
||||
int total = assetsObj["total"].toInt();
|
||||
Log("Immich page ", page, ": ", items.size(), " assets (total ", total, ")");
|
||||
if (items.isEmpty())
|
||||
{
|
||||
if (total > 0 && page == 1 && !triedZero)
|
||||
@@ -154,6 +202,11 @@ QVector<ImmichAsset> ImmichClient::fetchAssets()
|
||||
ImmichAsset asset;
|
||||
asset.id = id;
|
||||
asset.originalFileName = item["originalFileName"].toString();
|
||||
if (!extensionAllowed(asset.originalFileName))
|
||||
{
|
||||
Log("Immich skip by extension: ", asset.originalFileName.toStdString());
|
||||
continue;
|
||||
}
|
||||
assets.append(asset);
|
||||
if (maxAssets > 0 && assets.size() >= maxAssets)
|
||||
return assets;
|
||||
@@ -167,12 +220,99 @@ QVector<ImmichAsset> ImmichClient::fetchAssets()
|
||||
return assets;
|
||||
}
|
||||
|
||||
QVector<ImmichAsset> ImmichClient::fetchAssetsByUser()
|
||||
{
|
||||
QVector<ImmichAsset> assets;
|
||||
|
||||
int pageSize = config.pageSize > 0 ? config.pageSize : 200;
|
||||
int maxAssets = config.maxAssets;
|
||||
int skip = 0;
|
||||
|
||||
if (ShouldLog())
|
||||
{
|
||||
Log("Immich assets: userId=", config.userId,
|
||||
", pageSize=", pageSize,
|
||||
", maxAssets=", maxAssets,
|
||||
", includeArchived=", config.includeArchived);
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
QUrl url = apiUrl("/assets");
|
||||
QUrlQuery query;
|
||||
query.addQueryItem("take", QString::number(pageSize));
|
||||
query.addQueryItem("skip", QString::number(skip));
|
||||
query.addQueryItem("userId", QString::fromStdString(config.userId));
|
||||
if (!config.includeArchived)
|
||||
query.addQueryItem("isArchived", "false");
|
||||
url.setQuery(query);
|
||||
|
||||
QByteArray response = getBytes(url, nullptr, kMetadataTimeoutMs);
|
||||
if (response.isEmpty())
|
||||
break;
|
||||
|
||||
QJsonDocument doc = QJsonDocument::fromJson(response);
|
||||
if (!doc.isArray())
|
||||
break;
|
||||
|
||||
QJsonArray items = doc.array();
|
||||
Log("Immich user assets skip ", skip, ": ", items.size(), " assets");
|
||||
if (items.isEmpty())
|
||||
break;
|
||||
|
||||
for (const auto &value : items)
|
||||
{
|
||||
QJsonObject item = value.toObject();
|
||||
QString id = item["id"].toString();
|
||||
if (id.isEmpty())
|
||||
continue;
|
||||
ImmichAsset asset;
|
||||
asset.id = id;
|
||||
asset.originalFileName = item["originalFileName"].toString();
|
||||
if (!extensionAllowed(asset.originalFileName))
|
||||
{
|
||||
Log("Immich skip by extension: ", asset.originalFileName.toStdString());
|
||||
continue;
|
||||
}
|
||||
assets.append(asset);
|
||||
if (maxAssets > 0 && assets.size() >= maxAssets)
|
||||
return assets;
|
||||
}
|
||||
|
||||
if (items.size() < pageSize)
|
||||
break;
|
||||
skip += pageSize;
|
||||
}
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
bool ImmichClient::extensionAllowed(const QString &filename) const
|
||||
{
|
||||
if (config.allowedExtensions.empty())
|
||||
return true;
|
||||
QString ext = QFileInfo(filename).suffix().toLower();
|
||||
if (ext.isEmpty())
|
||||
return false;
|
||||
for (const auto &allowed : config.allowedExtensions)
|
||||
{
|
||||
if (ext == QString::fromStdString(allowed))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ImmichClient::downloadAsset(const QString &assetId, QByteArray &data, QString &contentType)
|
||||
{
|
||||
return downloadAssetWithSize(assetId, QString::fromStdString(config.size), data, contentType);
|
||||
}
|
||||
|
||||
bool ImmichClient::downloadAssetWithSize(const QString &assetId, const QString &sizeOverride, QByteArray &data, QString &contentType)
|
||||
{
|
||||
if (!config.enabled)
|
||||
return false;
|
||||
|
||||
QString size = QString::fromStdString(config.size).trimmed().toLower();
|
||||
QString size = sizeOverride.trimmed().toLower();
|
||||
if (size.isEmpty())
|
||||
size = "fullsize";
|
||||
|
||||
@@ -194,6 +334,7 @@ bool ImmichClient::downloadAsset(const QString &assetId, QByteArray &data, QStri
|
||||
url.setQuery(query);
|
||||
}
|
||||
|
||||
Log("Immich download asset ", assetId.toStdString(), " (", size.toStdString(), ")");
|
||||
QByteArray payload = getBytes(url, &contentType, kAssetTimeoutMs);
|
||||
if (payload.isEmpty())
|
||||
return false;
|
||||
@@ -236,6 +377,11 @@ QString ImmichAssetCache::findExisting(const QString &assetId) const
|
||||
QStringList matches = dir.entryList(QStringList() << (assetId + "_*"), QDir::Files, QDir::Time);
|
||||
if (matches.isEmpty())
|
||||
return "";
|
||||
for (const auto &match : matches)
|
||||
{
|
||||
if (match.endsWith(".skip"))
|
||||
return dir.filePath(match);
|
||||
}
|
||||
return dir.filePath(matches.first());
|
||||
}
|
||||
|
||||
@@ -297,15 +443,84 @@ QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &a
|
||||
|
||||
QString existing = findExisting(assetId);
|
||||
if (!existing.isEmpty())
|
||||
{
|
||||
if (existing.endsWith(".skip"))
|
||||
{
|
||||
Log("Immich skip marker: ", assetId.toStdString());
|
||||
return "";
|
||||
}
|
||||
QFileInfo info(existing);
|
||||
if (info.size() <= 0)
|
||||
{
|
||||
QFile::remove(existing);
|
||||
}
|
||||
else if (existing.endsWith(".img"))
|
||||
{
|
||||
QImageReader reader(existing);
|
||||
if (!reader.canRead())
|
||||
{
|
||||
Log("Immich cache invalid: ", existing.toStdString());
|
||||
QFile::remove(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("Immich cache hit: ", assetId.toStdString());
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("Immich cache hit: ", assetId.toStdString());
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
QByteArray data;
|
||||
QString contentType;
|
||||
if (!client.downloadAsset(assetId, data, contentType))
|
||||
return "";
|
||||
|
||||
QString detectedExt = DetectImageExtension(data);
|
||||
if (detectedExt.isEmpty())
|
||||
{
|
||||
Log("Immich download not an image for asset: ", assetId.toStdString());
|
||||
QByteArray fallbackData;
|
||||
QString fallbackType;
|
||||
if (client.downloadAssetWithSize(assetId, "preview", fallbackData, fallbackType))
|
||||
{
|
||||
QString fallbackExt = DetectImageExtension(fallbackData);
|
||||
if (!fallbackExt.isEmpty())
|
||||
{
|
||||
Log("Immich fallback to preview succeeded for asset: ", assetId.toStdString());
|
||||
data = fallbackData;
|
||||
contentType = fallbackType;
|
||||
detectedExt = fallbackExt;
|
||||
}
|
||||
}
|
||||
if (detectedExt.isEmpty())
|
||||
{
|
||||
QString skipName = assetId + "_unsupported.skip";
|
||||
QDir dir(cacheDirPath);
|
||||
QFile skipFile(dir.filePath(skipName));
|
||||
if (skipFile.open(QIODevice::WriteOnly))
|
||||
{
|
||||
skipFile.write("unsupported");
|
||||
skipFile.close();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
QString safeName = sanitizeFileName(assetName);
|
||||
QString extension = extensionForContentType(contentType);
|
||||
QString extension = detectedExt;
|
||||
if (extension.isEmpty())
|
||||
extension = extensionForContentType(contentType);
|
||||
if (extension == "img")
|
||||
{
|
||||
QString suffix = QFileInfo(assetName).suffix().toLower();
|
||||
if (!suffix.isEmpty())
|
||||
extension = suffix;
|
||||
}
|
||||
QString filename = assetId + "_" + safeName + "." + extension;
|
||||
|
||||
QDir dir(cacheDirPath);
|
||||
@@ -318,6 +533,8 @@ QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &a
|
||||
if (!file.commit())
|
||||
return "";
|
||||
|
||||
Log("Immich cached asset: ", assetId.toStdString(), " -> ", filePath.toStdString());
|
||||
|
||||
if (cacheMaxBytes > 0)
|
||||
{
|
||||
if (!cacheSizeKnown)
|
||||
|
||||
@@ -21,8 +21,12 @@ class ImmichClient {
|
||||
explicit ImmichClient(const ImmichConfig &config);
|
||||
QVector<ImmichAsset> fetchAssets();
|
||||
bool downloadAsset(const QString &assetId, QByteArray &data, QString &contentType);
|
||||
bool downloadAssetWithSize(const QString &assetId, const QString &sizeOverride, QByteArray &data, QString &contentType);
|
||||
|
||||
private:
|
||||
QVector<ImmichAsset> fetchAssetsBySearch();
|
||||
QVector<ImmichAsset> fetchAssetsByUser();
|
||||
bool extensionAllowed(const QString &filename) const;
|
||||
QUrl apiUrl(const QString &path) const;
|
||||
QNetworkRequest makeRequest(const QUrl &url) const;
|
||||
QByteArray postJson(const QUrl &url, const QJsonObject &body, QString *contentType, int timeoutMs);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "immichpathtraverser.h"
|
||||
#include "logger.h"
|
||||
#include <QDateTime>
|
||||
|
||||
ImmichPathTraverser::ImmichPathTraverser(const ImmichConfig &configIn)
|
||||
: PathTraverser(""),
|
||||
@@ -25,10 +26,16 @@ void ImmichPathTraverser::loadAssets()
|
||||
assetNames.insert(asset.id, asset.originalFileName);
|
||||
}
|
||||
Log("Immich assets loaded: ", assetIds.size());
|
||||
lastRefresh = QDateTime::currentDateTime();
|
||||
}
|
||||
|
||||
QStringList ImmichPathTraverser::getImages() const
|
||||
{
|
||||
if (refreshDue())
|
||||
{
|
||||
Log("Immich refresh due, reloading assets.");
|
||||
const_cast<ImmichPathTraverser*>(this)->loadAssets();
|
||||
}
|
||||
return assetIds;
|
||||
}
|
||||
|
||||
@@ -45,3 +52,17 @@ ImageDisplayOptions ImmichPathTraverser::UpdateOptionsForImage(const std::string
|
||||
Q_UNUSED(filename);
|
||||
return options;
|
||||
}
|
||||
|
||||
bool ImmichPathTraverser::refreshDue() const
|
||||
{
|
||||
if (config.refreshSeconds <= 0)
|
||||
return false;
|
||||
if (!lastRefresh.isValid())
|
||||
return true;
|
||||
return lastRefresh.secsTo(QDateTime::currentDateTime()) >= config.refreshSeconds;
|
||||
}
|
||||
|
||||
bool ImmichPathTraverser::shouldReloadImages() const
|
||||
{
|
||||
return refreshDue();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "immichclient.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QDateTime>
|
||||
|
||||
class ImmichPathTraverser : public PathTraverser
|
||||
{
|
||||
@@ -14,15 +15,18 @@ class ImmichPathTraverser : public PathTraverser
|
||||
QStringList getImages() const override;
|
||||
virtual const std::string getImagePath(const std::string image) const override;
|
||||
virtual ImageDisplayOptions UpdateOptionsForImage(const std::string& filename, const ImageDisplayOptions& options) const override;
|
||||
bool shouldReloadImages() const override;
|
||||
|
||||
private:
|
||||
void loadAssets();
|
||||
bool refreshDue() const;
|
||||
|
||||
ImmichConfig config;
|
||||
mutable ImmichClient client;
|
||||
mutable ImmichAssetCache cache;
|
||||
QStringList assetIds;
|
||||
QHash<QString, QString> assetNames;
|
||||
QDateTime lastRefresh;
|
||||
};
|
||||
|
||||
#endif // IMMICHPATHTRAVERSER_H
|
||||
|
||||
349
src/main.cpp
349
src/main.cpp
@@ -3,6 +3,7 @@
|
||||
#include "imageswitcher.h"
|
||||
#include "pathtraverser.h"
|
||||
#include "immichpathtraverser.h"
|
||||
#include "mqttcontroller.h"
|
||||
#include "overlay.h"
|
||||
#include "appconfig.h"
|
||||
#include "logger.h"
|
||||
@@ -18,6 +19,9 @@
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <memory>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
|
||||
void usage(std::string programName) {
|
||||
std::cerr << "Usage: " << programName << " [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color #rrggbb] [-a aspect('l','p','a', 'm')] [-o background_opacity(0..255)] [-b blur_radius] -p image_folder [-r] [-s] [-S] [-v] [--verbose] [--stretch] [-c config_file_path]" << std::endl;
|
||||
@@ -242,6 +246,307 @@ void ReloadConfigIfNeeded(AppConfig &appConfig, MainWindow &w, ImageSwitcher *sw
|
||||
}
|
||||
}
|
||||
|
||||
static bool ParseBooleanString(const QString &value, bool &outValue)
|
||||
{
|
||||
QString v = value.trimmed().toLower();
|
||||
if (v == "true" || v == "1" || v == "yes" || v == "on")
|
||||
{
|
||||
outValue = true;
|
||||
return true;
|
||||
}
|
||||
if (v == "false" || v == "0" || v == "no" || v == "off")
|
||||
{
|
||||
outValue = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static std::vector<std::string> SplitCsv(const QString &value)
|
||||
{
|
||||
std::vector<std::string> output;
|
||||
QStringList parts = value.split(',', Qt::SkipEmptyParts);
|
||||
for (const auto &part : parts)
|
||||
{
|
||||
QString trimmed = part.trimmed();
|
||||
if (!trimmed.isEmpty())
|
||||
output.push_back(trimmed.toStdString());
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload)
|
||||
{
|
||||
bool changed = false;
|
||||
QString trimmed = payload.trimmed();
|
||||
if (trimmed.isEmpty())
|
||||
return false;
|
||||
|
||||
if (trimmed.startsWith("{"))
|
||||
{
|
||||
QJsonDocument doc = QJsonDocument::fromJson(trimmed.toUtf8());
|
||||
if (!doc.isObject())
|
||||
return false;
|
||||
QJsonObject obj = doc.object();
|
||||
|
||||
if (obj.contains("albumId") && obj["albumId"].isString())
|
||||
{
|
||||
config.albumIds = { obj["albumId"].toString().toStdString() };
|
||||
config.userId.clear();
|
||||
config.allowedExtensions.clear();
|
||||
changed = true;
|
||||
}
|
||||
if (obj.contains("albumIds") && obj["albumIds"].isArray())
|
||||
{
|
||||
config.albumIds.clear();
|
||||
QJsonArray arr = obj["albumIds"].toArray();
|
||||
for (const auto &value : arr)
|
||||
{
|
||||
if (value.isString())
|
||||
config.albumIds.push_back(value.toString().toStdString());
|
||||
}
|
||||
config.userId.clear();
|
||||
config.allowedExtensions.clear();
|
||||
changed = true;
|
||||
}
|
||||
if (obj.contains("personId") && obj["personId"].isString())
|
||||
{
|
||||
config.personIds = { obj["personId"].toString().toStdString() };
|
||||
config.userId.clear();
|
||||
config.allowedExtensions.clear();
|
||||
changed = true;
|
||||
}
|
||||
if (obj.contains("personIds") && obj["personIds"].isArray())
|
||||
{
|
||||
config.personIds.clear();
|
||||
QJsonArray arr = obj["personIds"].toArray();
|
||||
for (const auto &value : arr)
|
||||
{
|
||||
if (value.isString())
|
||||
config.personIds.push_back(value.toString().toStdString());
|
||||
}
|
||||
config.userId.clear();
|
||||
config.allowedExtensions.clear();
|
||||
changed = true;
|
||||
}
|
||||
if (obj.contains("userId") && obj["userId"].isString())
|
||||
{
|
||||
config.userId = obj["userId"].toString().toStdString();
|
||||
config.albumIds.clear();
|
||||
config.personIds.clear();
|
||||
config.allowedExtensions.clear();
|
||||
changed = true;
|
||||
}
|
||||
if (obj.contains("ownerId") && obj["ownerId"].isString())
|
||||
{
|
||||
config.userId = obj["ownerId"].toString().toStdString();
|
||||
config.albumIds.clear();
|
||||
config.personIds.clear();
|
||||
config.allowedExtensions.clear();
|
||||
changed = true;
|
||||
}
|
||||
if (obj.contains("order") && obj["order"].isString())
|
||||
{
|
||||
config.order = obj["order"].toString().toStdString();
|
||||
changed = true;
|
||||
}
|
||||
if (obj.contains("size") && obj["size"].isString())
|
||||
{
|
||||
config.size = obj["size"].toString().toStdString();
|
||||
changed = true;
|
||||
}
|
||||
if (obj.contains("pageSize") && obj["pageSize"].isDouble())
|
||||
{
|
||||
config.pageSize = (int)obj["pageSize"].toDouble();
|
||||
changed = true;
|
||||
}
|
||||
if (obj.contains("maxAssets") && obj["maxAssets"].isDouble())
|
||||
{
|
||||
config.maxAssets = (int)obj["maxAssets"].toDouble();
|
||||
changed = true;
|
||||
}
|
||||
if (obj.contains("refreshSeconds") && obj["refreshSeconds"].isDouble())
|
||||
{
|
||||
config.refreshSeconds = (int)obj["refreshSeconds"].toDouble();
|
||||
changed = true;
|
||||
}
|
||||
if (obj.contains("includeArchived"))
|
||||
{
|
||||
if (obj["includeArchived"].isBool())
|
||||
{
|
||||
config.includeArchived = obj["includeArchived"].toBool();
|
||||
changed = true;
|
||||
}
|
||||
else if (obj["includeArchived"].isString())
|
||||
{
|
||||
bool parsed = false;
|
||||
bool boolValue = false;
|
||||
parsed = ParseBooleanString(obj["includeArchived"].toString(), boolValue);
|
||||
if (parsed)
|
||||
{
|
||||
config.includeArchived = boolValue;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (obj.contains("reset") && obj["reset"].isBool() && obj["reset"].toBool())
|
||||
{
|
||||
config.albumIds.clear();
|
||||
config.personIds.clear();
|
||||
config.userId.clear();
|
||||
config.allowedExtensions.clear();
|
||||
changed = true;
|
||||
}
|
||||
if (obj.contains("extensions") && obj["extensions"].isArray())
|
||||
{
|
||||
config.allowedExtensions.clear();
|
||||
QJsonArray arr = obj["extensions"].toArray();
|
||||
for (const auto &value : arr)
|
||||
{
|
||||
if (value.isString())
|
||||
config.allowedExtensions.push_back(value.toString().toLower().toStdString());
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (obj.contains("allowedExtensions") && obj["allowedExtensions"].isArray())
|
||||
{
|
||||
config.allowedExtensions.clear();
|
||||
QJsonArray arr = obj["allowedExtensions"].toArray();
|
||||
for (const auto &value : arr)
|
||||
{
|
||||
if (value.isString())
|
||||
config.allowedExtensions.push_back(value.toString().toLower().toStdString());
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
QString key;
|
||||
QString value;
|
||||
int idx = trimmed.indexOf('=');
|
||||
if (idx < 0)
|
||||
idx = trimmed.indexOf(':');
|
||||
if (idx < 0)
|
||||
idx = trimmed.indexOf(' ');
|
||||
|
||||
if (idx >= 0)
|
||||
{
|
||||
key = trimmed.left(idx).trimmed().toLower();
|
||||
value = trimmed.mid(idx + 1).trimmed();
|
||||
}
|
||||
else
|
||||
{
|
||||
key = trimmed.toLower();
|
||||
}
|
||||
|
||||
if (key == "reset" || key == "clear" || key == "all")
|
||||
{
|
||||
config.albumIds.clear();
|
||||
config.personIds.clear();
|
||||
config.userId.clear();
|
||||
config.allowedExtensions.clear();
|
||||
return true;
|
||||
}
|
||||
if (key == "album" || key == "albumid")
|
||||
{
|
||||
config.albumIds = { value.toStdString() };
|
||||
config.userId.clear();
|
||||
config.allowedExtensions.clear();
|
||||
return true;
|
||||
}
|
||||
if (key == "albums" || key == "albumids")
|
||||
{
|
||||
config.albumIds = SplitCsv(value);
|
||||
config.userId.clear();
|
||||
config.allowedExtensions.clear();
|
||||
return true;
|
||||
}
|
||||
if (key == "person" || key == "personid")
|
||||
{
|
||||
config.personIds = { value.toStdString() };
|
||||
config.userId.clear();
|
||||
config.allowedExtensions.clear();
|
||||
return true;
|
||||
}
|
||||
if (key == "persons" || key == "personids")
|
||||
{
|
||||
config.personIds = SplitCsv(value);
|
||||
config.userId.clear();
|
||||
config.allowedExtensions.clear();
|
||||
return true;
|
||||
}
|
||||
if (key == "user" || key == "userid" || key == "ownerid")
|
||||
{
|
||||
config.userId = value.toStdString();
|
||||
config.albumIds.clear();
|
||||
config.personIds.clear();
|
||||
config.allowedExtensions.clear();
|
||||
return true;
|
||||
}
|
||||
if (key == "extensions" || key == "allowedextensions")
|
||||
{
|
||||
config.allowedExtensions = SplitCsv(value);
|
||||
for (auto &ext : config.allowedExtensions)
|
||||
{
|
||||
for (auto &c : ext)
|
||||
c = static_cast<char>(::tolower(c));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (key == "order")
|
||||
{
|
||||
config.order = value.toStdString();
|
||||
return true;
|
||||
}
|
||||
if (key == "size")
|
||||
{
|
||||
config.size = value.toStdString();
|
||||
return true;
|
||||
}
|
||||
if (key == "includearchived")
|
||||
{
|
||||
bool boolValue = false;
|
||||
if (ParseBooleanString(value, boolValue))
|
||||
{
|
||||
config.includeArchived = boolValue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (key == "pagesize")
|
||||
{
|
||||
bool ok = false;
|
||||
int parsed = value.toInt(&ok);
|
||||
if (ok)
|
||||
{
|
||||
config.pageSize = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (key == "maxassets")
|
||||
{
|
||||
bool ok = false;
|
||||
int parsed = value.toInt(&ok);
|
||||
if (ok)
|
||||
{
|
||||
config.maxAssets = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (key == "refreshseconds")
|
||||
{
|
||||
bool ok = false;
|
||||
int parsed = value.toInt(&ok);
|
||||
if (ok)
|
||||
{
|
||||
config.refreshSeconds = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QApplication a(argc, argv);
|
||||
@@ -275,6 +580,50 @@ int main(int argc, char *argv[])
|
||||
w.setImageSwitcher(&switcher);
|
||||
std::function<void(MainWindow &w, ImageSwitcher *switcher)> reloader = [&appConfig](MainWindow &w, ImageSwitcher *switcher) { ReloadConfigIfNeeded(appConfig, w, switcher); };
|
||||
switcher.setConfigFileReloader(reloader);
|
||||
|
||||
std::unique_ptr<MqttController> mqttController;
|
||||
if (appConfig.mqtt.enabled)
|
||||
{
|
||||
mqttController = std::unique_ptr<MqttController>(new MqttController(appConfig.mqtt, &a));
|
||||
QObject::connect(mqttController.get(), &MqttController::play, [&switcher]() { switcher.resume(); });
|
||||
QObject::connect(mqttController.get(), &MqttController::pause, [&switcher]() { switcher.pause(); });
|
||||
QObject::connect(mqttController.get(), &MqttController::nextImage, [&switcher]() { switcher.stepOnce(); });
|
||||
QObject::connect(mqttController.get(), &MqttController::nextFolder, [&switcher]() {
|
||||
if (!switcher.skipToNextFolder())
|
||||
switcher.stepOnce();
|
||||
});
|
||||
QObject::connect(mqttController.get(), &MqttController::restart, [&appConfig, &switcher]() {
|
||||
std::unique_ptr<ImageSelector> newSelector = GetSelectorForApp(appConfig);
|
||||
switcher.restart(newSelector);
|
||||
});
|
||||
QObject::connect(mqttController.get(), &MqttController::immichControl, [&appConfig, &switcher](const QString &payload) {
|
||||
bool updated = false;
|
||||
for (int i = 0; i < appConfig.paths.count(); ++i)
|
||||
{
|
||||
if (!appConfig.paths[i].immich.enabled)
|
||||
continue;
|
||||
ImmichConfig newConfig = appConfig.paths[i].immich;
|
||||
if (ApplyImmichPayload(newConfig, payload))
|
||||
{
|
||||
appConfig.paths[i].immich = newConfig;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated)
|
||||
{
|
||||
Log("MQTT immich update applied.");
|
||||
std::unique_ptr<ImageSelector> newSelector = GetSelectorForApp(appConfig);
|
||||
switcher.setImageSelector(newSelector);
|
||||
if (!switcher.isPaused())
|
||||
switcher.scheduleImageUpdate();
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("MQTT immich update ignored: ", payload.toStdString());
|
||||
}
|
||||
});
|
||||
mqttController->start();
|
||||
}
|
||||
switcher.start();
|
||||
return a.exec();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include <QGraphicsPixmapItem>
|
||||
#include <QApplication>
|
||||
#include <QScreen>
|
||||
#include <QTransform>
|
||||
|
||||
MainWindow::MainWindow(QWidget *parent) :
|
||||
QMainWindow(parent),
|
||||
@@ -170,16 +171,26 @@ void MainWindow::updateImage()
|
||||
return;
|
||||
|
||||
QLabel *label = this->findChild<QLabel*>("image");
|
||||
const QPixmap* oldImage = label->pixmap();
|
||||
if (oldImage != NULL && transitionSeconds > 0)
|
||||
QPixmap oldImage = label->pixmap(Qt::ReturnByValue);
|
||||
if (!oldImage.isNull() && transitionSeconds > 0)
|
||||
{
|
||||
QPalette palette;
|
||||
palette.setBrush(QPalette::Background, *oldImage);
|
||||
palette.setBrush(QPalette::Window, oldImage);
|
||||
this->setPalette(palette);
|
||||
}
|
||||
|
||||
QPixmap p;
|
||||
p.load( currentImage.filename.c_str() );
|
||||
if (p.isNull())
|
||||
{
|
||||
Log("Error: failed to load image: ", currentImage.filename);
|
||||
warn("Failed to load image.");
|
||||
if (switcher != nullptr)
|
||||
{
|
||||
switcher->scheduleImageUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Log("size:", p.width(), "x", p.height(), "(window:", width(), ",", height(), ")");
|
||||
|
||||
@@ -213,7 +224,7 @@ void MainWindow::updateImage()
|
||||
|
||||
label->setPixmap(background);
|
||||
|
||||
if (oldImage != NULL && transitionSeconds > 0)
|
||||
if (!oldImage.isNull() && transitionSeconds > 0)
|
||||
{
|
||||
auto effect = new QGraphicsOpacityEffect(label);
|
||||
effect->setOpacity(0.0);
|
||||
@@ -273,9 +284,9 @@ QPixmap MainWindow::getBlurredBackground(const QPixmap& originalSize, const QPix
|
||||
|
||||
QPixmap MainWindow::getRotatedPixmap(const QPixmap& p)
|
||||
{
|
||||
QMatrix matrix;
|
||||
matrix.rotate(currentImage.rotation);
|
||||
return p.transformed(matrix);
|
||||
QTransform transform;
|
||||
transform.rotate(currentImage.rotation);
|
||||
return p.transformed(transform);
|
||||
}
|
||||
|
||||
QPixmap MainWindow::getScaledPixmap(const QPixmap& p)
|
||||
@@ -325,12 +336,12 @@ void MainWindow::drawBackground(const QPixmap& originalSize, const QPixmap& scal
|
||||
QPixmap background = blur(originalSize.scaledToHeight(height()));
|
||||
QRect rect((background.width() - width())/2, 0, width(), height());
|
||||
background = background.copy(rect);
|
||||
palette.setBrush(QPalette::Background, background);
|
||||
palette.setBrush(QPalette::Window, background);
|
||||
} else {
|
||||
QPixmap background = blur(originalSize.scaledToHeight(height()));
|
||||
QRect rect((background.width() - width())/2, 0, width(), height());
|
||||
background = background.copy(rect);
|
||||
palette.setBrush(QPalette::Background, background);
|
||||
palette.setBrush(QPalette::Window, background);
|
||||
}
|
||||
this->setPalette(palette);
|
||||
}
|
||||
|
||||
177
src/mqttcontroller.cpp
Normal file
177
src/mqttcontroller.cpp
Normal file
@@ -0,0 +1,177 @@
|
||||
#include "mqttcontroller.h"
|
||||
#include "logger.h"
|
||||
|
||||
#include <QMetaObject>
|
||||
|
||||
#include <mosquitto.h>
|
||||
|
||||
namespace {
|
||||
bool g_mqttInitialized = false;
|
||||
}
|
||||
|
||||
MqttController::MqttController(const MqttConfig &configIn, QObject *parent)
|
||||
: QObject(parent),
|
||||
config(configIn)
|
||||
{
|
||||
if (!g_mqttInitialized)
|
||||
{
|
||||
mosquitto_lib_init();
|
||||
g_mqttInitialized = true;
|
||||
}
|
||||
controlTopic = QString::fromStdString(config.topic);
|
||||
immichTopic = QString::fromStdString(config.immichTopic);
|
||||
}
|
||||
|
||||
MqttController::~MqttController()
|
||||
{
|
||||
if (client)
|
||||
{
|
||||
mosquitto_disconnect(client);
|
||||
mosquitto_loop_stop(client, true);
|
||||
mosquitto_destroy(client);
|
||||
client = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void MqttController::start()
|
||||
{
|
||||
if (!config.enabled)
|
||||
{
|
||||
Log("MQTT disabled or missing host/topic.");
|
||||
return;
|
||||
}
|
||||
|
||||
QString clientId = QString::fromStdString(config.clientId);
|
||||
if (clientId.isEmpty())
|
||||
clientId = "slide";
|
||||
|
||||
client = mosquitto_new(clientId.toUtf8().constData(), true, this);
|
||||
if (!client)
|
||||
{
|
||||
Log("MQTT: failed to create client.");
|
||||
return;
|
||||
}
|
||||
|
||||
mosquitto_connect_callback_set(client, &MqttController::HandleConnect);
|
||||
mosquitto_message_callback_set(client, &MqttController::HandleMessage);
|
||||
|
||||
if (!config.username.empty())
|
||||
{
|
||||
mosquitto_username_pw_set(client, config.username.c_str(),
|
||||
config.password.empty() ? nullptr : config.password.c_str());
|
||||
}
|
||||
|
||||
mosquitto_reconnect_delay_set(client, 2, 30, true);
|
||||
|
||||
int rc = mosquitto_connect_async(client, config.host.c_str(), config.port, config.keepAlive);
|
||||
if (rc != MOSQ_ERR_SUCCESS)
|
||||
{
|
||||
Log("MQTT connect failed: ", mosquitto_strerror(rc));
|
||||
return;
|
||||
}
|
||||
|
||||
rc = mosquitto_loop_start(client);
|
||||
if (rc != MOSQ_ERR_SUCCESS)
|
||||
{
|
||||
Log("MQTT loop start failed: ", mosquitto_strerror(rc));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void MqttController::HandleConnect(struct mosquitto *mosq, void *userdata, int rc)
|
||||
{
|
||||
auto *self = static_cast<MqttController *>(userdata);
|
||||
if (!self || !mosq)
|
||||
return;
|
||||
if (rc != 0)
|
||||
{
|
||||
Log("MQTT connect error: ", mosquitto_strerror(rc));
|
||||
return;
|
||||
}
|
||||
self->connected = true;
|
||||
self->subscribe();
|
||||
}
|
||||
|
||||
void MqttController::subscribe()
|
||||
{
|
||||
if (!client || !connected)
|
||||
return;
|
||||
int rc = mosquitto_subscribe(client, nullptr, config.topic.c_str(), config.qos);
|
||||
if (rc != MOSQ_ERR_SUCCESS)
|
||||
{
|
||||
Log("MQTT subscribe failed: ", mosquitto_strerror(rc));
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("MQTT subscribed to ", config.topic);
|
||||
}
|
||||
if (!config.immichTopic.empty() && config.immichTopic != config.topic)
|
||||
{
|
||||
rc = mosquitto_subscribe(client, nullptr, config.immichTopic.c_str(), config.qos);
|
||||
if (rc != MOSQ_ERR_SUCCESS)
|
||||
{
|
||||
Log("MQTT subscribe (immich) failed: ", mosquitto_strerror(rc));
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("MQTT subscribed to ", config.immichTopic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MqttController::HandleMessage(struct mosquitto *mosq, void *userdata, const struct mosquitto_message *message)
|
||||
{
|
||||
Q_UNUSED(mosq);
|
||||
auto *self = static_cast<MqttController *>(userdata);
|
||||
if (!self || !message || !message->payload)
|
||||
return;
|
||||
|
||||
QString payload = QString::fromUtf8(static_cast<const char *>(message->payload), message->payloadlen);
|
||||
QString topic = QString::fromUtf8(message->topic);
|
||||
QMetaObject::invokeMethod(self, "handleMessage", Qt::QueuedConnection,
|
||||
Q_ARG(QString, topic),
|
||||
Q_ARG(QString, payload));
|
||||
}
|
||||
|
||||
void MqttController::handleMessage(const QString &topic, const QString &payload)
|
||||
{
|
||||
QString cmd = payload.trimmed().toLower();
|
||||
if (cmd.isEmpty())
|
||||
return;
|
||||
|
||||
Log("MQTT message on ", topic.toStdString(), ": ", cmd.toStdString());
|
||||
|
||||
if (!immichTopic.isEmpty() && topic == immichTopic)
|
||||
{
|
||||
emit immichControl(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd == "play" || cmd == "resume")
|
||||
{
|
||||
emit play();
|
||||
return;
|
||||
}
|
||||
if (cmd == "pause")
|
||||
{
|
||||
emit pause();
|
||||
return;
|
||||
}
|
||||
if (cmd == "next" || cmd == "skip" || cmd == "next-image")
|
||||
{
|
||||
emit nextImage();
|
||||
return;
|
||||
}
|
||||
if (cmd == "next-folder" || cmd == "folder-next" || cmd == "skip-folder")
|
||||
{
|
||||
emit nextFolder();
|
||||
return;
|
||||
}
|
||||
if (cmd == "restart" || cmd == "reset")
|
||||
{
|
||||
emit restart();
|
||||
return;
|
||||
}
|
||||
|
||||
Log("MQTT unknown command: ", cmd.toStdString());
|
||||
}
|
||||
45
src/mqttcontroller.h
Normal file
45
src/mqttcontroller.h
Normal file
@@ -0,0 +1,45 @@
|
||||
#ifndef MQTTCONTROLLER_H
|
||||
#define MQTTCONTROLLER_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
#include "appconfig.h"
|
||||
|
||||
struct mosquitto;
|
||||
struct mosquitto_message;
|
||||
|
||||
class MqttController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit MqttController(const MqttConfig &config, QObject *parent = nullptr);
|
||||
~MqttController();
|
||||
|
||||
void start();
|
||||
|
||||
signals:
|
||||
void play();
|
||||
void pause();
|
||||
void nextImage();
|
||||
void nextFolder();
|
||||
void restart();
|
||||
void immichControl(const QString &payload);
|
||||
|
||||
private slots:
|
||||
void handleMessage(const QString &topic, const QString &payload);
|
||||
|
||||
private:
|
||||
static void HandleConnect(struct mosquitto *mosq, void *userdata, int rc);
|
||||
static void HandleMessage(struct mosquitto *mosq, void *userdata, const struct mosquitto_message *message);
|
||||
|
||||
void subscribe();
|
||||
|
||||
MqttConfig config;
|
||||
QString controlTopic;
|
||||
QString immichTopic;
|
||||
struct mosquitto *client = nullptr;
|
||||
bool connected = false;
|
||||
};
|
||||
|
||||
#endif // MQTTCONTROLLER_H
|
||||
@@ -17,6 +17,7 @@ class PathTraverser
|
||||
virtual QStringList getImages() const = 0;
|
||||
virtual const std::string getImagePath(const std::string image) const = 0;
|
||||
virtual ImageDisplayOptions UpdateOptionsForImage(const std::string& filename, const ImageDisplayOptions& baseOptions) const = 0;
|
||||
virtual bool shouldReloadImages() const { return false; }
|
||||
|
||||
protected:
|
||||
const std::string path;
|
||||
|
||||
@@ -37,6 +37,7 @@ SOURCES += \
|
||||
pathtraverser.cpp \
|
||||
immichpathtraverser.cpp \
|
||||
immichclient.cpp \
|
||||
mqttcontroller.cpp \
|
||||
overlay.cpp \
|
||||
imageselector.cpp \
|
||||
appconfig.cpp \
|
||||
@@ -49,6 +50,7 @@ HEADERS += \
|
||||
pathtraverser.h \
|
||||
immichpathtraverser.h \
|
||||
immichclient.h \
|
||||
mqttcontroller.h \
|
||||
overlay.h \
|
||||
imageswitcher.h \
|
||||
imagestructs.h \
|
||||
@@ -62,3 +64,4 @@ target.path = /usr/local/bin/
|
||||
INSTALLS += target
|
||||
|
||||
unix|win32: LIBS += -lexif
|
||||
unix|win32: LIBS += -lmosquitto
|
||||
|
||||
Reference in New Issue
Block a user