17 Commits

Author SHA1 Message Date
3958da1983 improve handling of images from immich albums
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone/tag Build is passing
2026-02-02 09:02:06 +11:00
76c98f63dd fix immich handling [CI SKIP]
All checks were successful
continuous-integration/drone/tag Build is passing
2026-02-02 08:51:58 +11:00
7a75083cf3 filter out unsupported images [CI SKIP]
All checks were successful
continuous-integration/drone/tag Build is passing
2026-02-02 08:48:20 +11:00
806d701535 add more immich control options
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-02-01 21:24:24 +11:00
3644001dbc [CI SKIP] update deb format
All checks were successful
continuous-integration/drone/tag Build is passing
2026-02-01 19:35:52 +11:00
f4fd1b1b07 re-enable amd64 builds
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone/tag Build is passing
2026-02-01 16:15:22 +11:00
a150958960 try
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-01 13:44:16 +11:00
819d349bae try arm again
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-01 07:30:53 +11:00
0ae6c3f593 fix drone
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-31 21:21:09 +11:00
945e3212cf fix qmake
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-31 21:20:43 +11:00
3547207eca retry arm build
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-31 21:18:24 +11:00
6669e6722a change ubuntu mirror
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-31 21:09:42 +11:00
7524745b18 try arm builds
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-31 17:12:49 +11:00
a9c5139d55 add mqtt control
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-01-31 16:50:34 +11:00
7cc6056e7e fix image location
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-31 14:07:58 +11:00
7516eb4444 fix build
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-31 14:07:32 +11:00
ef2403b3cd code
Some checks failed
continuous-integration/drone Build is failing
2026-01-31 14:02:01 +11:00
27 changed files with 1369 additions and 54 deletions

95
.drone.yml Normal file
View 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
View 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/*

View File

@@ -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
```

View File

@@ -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:

View File

@@ -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 keys 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:

View 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

View File

@@ -0,0 +1 @@
#include "/usr/lib/x86_64-linux-gnu/qt5/mkspecs/linux-g++/qplatformdefs.h"

View 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

View File

@@ -0,0 +1 @@
#include "/usr/lib/x86_64-linux-gnu/qt5/mkspecs/linux-g++/qplatformdefs.h"

11
mkspecs/qt-arm64.conf Normal file
View 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
View 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

View File

@@ -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"

View File

@@ -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 &currentConfig) {
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();
}
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)
{

View File

@@ -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;

View File

@@ -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());

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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
View 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
View 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

View File

@@ -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;

View File

@@ -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