Compare commits
35 Commits
8bb97ed926
...
v0.0.10
| Author | SHA1 | Date | |
|---|---|---|---|
| 85ef89fa4b | |||
| e5f5934eb6 | |||
| 80286da166 | |||
| 6f2b8fe90c | |||
| bc672256fb | |||
| 86b19d5513 | |||
| 3958da1983 | |||
| 76c98f63dd | |||
| 7a75083cf3 | |||
| 806d701535 | |||
| 3644001dbc | |||
| f4fd1b1b07 | |||
| a150958960 | |||
| 819d349bae | |||
| 0ae6c3f593 | |||
| 945e3212cf | |||
| 3547207eca | |||
| 6669e6722a | |||
| 7524745b18 | |||
| a9c5139d55 | |||
| 7cc6056e7e | |||
| 7516eb4444 | |||
| ef2403b3cd | |||
| 7a0bb14df4 | |||
|
|
3cb2d9cb3e | ||
|
|
5317215c13 | ||
|
|
8d7e82bea5 | ||
|
|
4813941a5c | ||
|
|
190ff6a508 | ||
|
|
cf45a045ff | ||
|
|
87315a7bda | ||
|
|
88f2a02652 | ||
|
|
b45e7dbda9 | ||
|
|
d7a3088712 | ||
|
|
9532178b4c |
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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ make
|
|||||||
.git
|
.git
|
||||||
build
|
build
|
||||||
.vscode
|
.vscode
|
||||||
|
test_config/slide.options.json
|
||||||
|
|||||||
8
CHANGELOG.md
Normal file
8
CHANGELOG.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
# [Unreleased]
|
||||||
|
|
||||||
|
- Nothing yet.
|
||||||
|
|
||||||
|
# [0.0.9] - 2026-02-01
|
||||||
|
- Fix sidecar handling
|
||||||
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
|
* qt5
|
||||||
* libexif
|
* libexif
|
||||||
|
* qt5-image-formats-plugins
|
||||||
|
* libmosquitto1
|
||||||
|
|
||||||
### OSX
|
### OSX
|
||||||
|
|
||||||
@@ -13,28 +15,29 @@ This project depends on the following dynamically linked libraries:
|
|||||||
brew install qt libexif
|
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
|
.PHONY: install-deps-deb
|
||||||
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:
|
check-deps-deb:
|
||||||
dpkg -l | grep qt5-qmake
|
dpkg -l | grep qt5-qmake
|
||||||
@@ -15,6 +15,7 @@ check-deps-deb:
|
|||||||
dpkg -l | grep libexif-dev
|
dpkg -l | grep libexif-dev
|
||||||
dpkg -l | grep qt5-default
|
dpkg -l | grep qt5-default
|
||||||
dpkg -l | grep qt5-image-formats-plugins
|
dpkg -l | grep qt5-image-formats-plugins
|
||||||
|
dpkg -l | grep libmosquitto-dev
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
146
README.md
146
README.md
@@ -18,22 +18,25 @@ This project is maintained by myself during my spare time. If you like and use i
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
slide [-t rotation_seconds] [-a aspect] [-o background_opacity(0..255)] [-b blur_radius] [-p image_folder|-i imageFile,...] [-r] [-O overlay_string] [-v] [--verbose] [--stretch] [-c path_to_config_json]
|
slide [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color overlay_color(#rrggbb)] [-a aspect] [-o background_opacity(0..255)] [-b blur_radius] [-p image_folder|-i imageFile,...] [-r] [-O overlay_string] [-v] [--verbose] [--stretch] [-c path_to_config_json]
|
||||||
```
|
```
|
||||||
|
|
||||||
* `image_folder`: where to search for images (.jpg files)
|
* `image_folder`: where to search for images (.jpg files)
|
||||||
* `-i imageFile,...`: comma delimited list of full paths to image files to display
|
* `-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
|
* `-t` how many seconds to display each picture for
|
||||||
* `-r` for recursive traversal of `image_folder`
|
* `-r` for recursive traversal of `image_folder`
|
||||||
* `-s` for shuffle instead of random image rotation
|
* `-s` for shuffle instead of random image rotation
|
||||||
* `-S` for sorted rotation (files ordered by name, first images then subfolders)
|
* `-S` for sorted rotation (files ordered by name, first images then subfolders)
|
||||||
* `rotation_seconds(default=30)`: time until next random image is chosen from the given folder
|
* `rotation_seconds(default=30)`: time until next random image is chosen from the given folder
|
||||||
* `aspect(default=a)`: the required aspect ratio of the picture to display. Valid values are 'a' (all), 'l' (landscape), 'p' (portrait) and 'm' (monitor). Monitor will match the aspect ratio of the display we are running on.
|
* `aspect(default=a)`: the required aspect ratio of the picture to display. Valid values are 'a' (all), 'l' (landscape), 'p' (portrait) and 'm' (monitor). Monitor will match the aspect ratio of the display we are running on.
|
||||||
|
* `transition_seconds(default=1)`: time of image transition animation. Default is 1 second, and transition animation will be disabled if the value is set to 0
|
||||||
|
* `aspect(default=a)`: the required aspect ratio of the picture to display. Valid values are 'a' (all), 'l' (landscape) and 'p' (portrait)
|
||||||
* `background_opacity(default=150)`: opacity of the background filling image between 0 (black background) and 255
|
* `background_opacity(default=150)`: opacity of the background filling image between 0 (black background) and 255
|
||||||
* `blur_radius(default=20)`: blur radius of the background filling image
|
* `blur_radius(default=20)`: blur radius of the background filling image
|
||||||
* `-v` or `--verbose`: Verbose debug output when running, plus a thumbnail of the original image in the bottom left of the screen
|
* `-v` or `--verbose`: Verbose debug output when running
|
||||||
* `--stretch`: When in aspect mode 'l','p' or 'm' crop the image rather than leaving a blurred background. For example, in landscape mode this will make images as wide as the screen and crop the top and bottom to fit.
|
* `--stretch`: When in aspect mode 'l','p' or 'm' crop the image rather than leaving a blurred background. For example, in landscape mode this will make images as wide as the screen and crop the top and bottom to fit.
|
||||||
|
* `-h` or `--overlay-color` the color of the overlay text, in the form of 3 or 6 digits hex rgb string prefixed by `#`, for example `#00FF00` or `#0F0` for color 🟢
|
||||||
* `-O` is used to create a overlay string.
|
* `-O` is used to create a overlay string.
|
||||||
* It defines overlays for all four edges in the order `top-left;top-right;bottom-left;bottom-right`
|
* It defines overlays for all four edges in the order `top-left;top-right;bottom-left;bottom-right`
|
||||||
* All edges overlays are separated by `;`
|
* All edges overlays are separated by `;`
|
||||||
@@ -49,11 +52,10 @@ slide [-t rotation_seconds] [-a aspect] [-o background_opacity(0..255)] [-b blur
|
|||||||
* `<dir>`directory of the current image
|
* `<dir>`directory of the current image
|
||||||
* `<path>`path to the current image without filename
|
* `<path>`path to the current image without filename
|
||||||
* Example: `slide -p ./images -O "20|60|Time: <time>;;;Picture taken at <exifdatetime>"`
|
* Example: `slide -p ./images -O "20|60|Time: <time>;;;Picture taken at <exifdatetime>"`
|
||||||
|
|
||||||
To exit the application, press escape. If you're using a touch display, touch all 4 corners at the same time.
|
To exit the application, press escape. If you're using a touch display, touch all 4 corners at the same time.
|
||||||
|
|
||||||
## Configuration file
|
## 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:
|
The file format is:
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
@@ -67,6 +69,7 @@ The file format is:
|
|||||||
"rotationSeconds" : 300,
|
"rotationSeconds" : 300,
|
||||||
"opacity" : 200,
|
"opacity" : 200,
|
||||||
"debug" : false,
|
"debug" : false,
|
||||||
|
"debugThumbnail" : false,
|
||||||
"scheduler" : [
|
"scheduler" : [
|
||||||
{
|
{
|
||||||
"exclusive" : true,
|
"exclusive" : true,
|
||||||
@@ -109,18 +112,144 @@ Supported keys and values in the JSON configuration are:
|
|||||||
* `overlay` : the same as the overlay command line argument
|
* `overlay` : the same as the overlay command line argument
|
||||||
* `shuffle` : set to true to enable shuffle mode for file display
|
* `shuffle` : set to true to enable shuffle mode for file display
|
||||||
* `recursive` : set to true to enable recursive mode for file display
|
* `recursive` : set to true to enable recursive mode for file display
|
||||||
* `sorted` : set to true to enable shuffle mode for file display
|
* `sorted` : set to true to enable sorted mode for file display
|
||||||
* `stretch` : set to true to enable, the same as the `--stretch` command line argument
|
* `stretch` : set to true to enable, the same as the `--stretch` command line argument
|
||||||
* `rotationSeconds` : the same as the `-t` command line argument
|
* `rotationSeconds` : the same as the `-t` command line argument
|
||||||
* `opacity` : the same as the command line `-o` argument
|
* `opacity` : the same as the command line `-o` argument
|
||||||
* `blur` : the same as the command line `-b` argument
|
* `blur` : the same as the command line `-b` argument
|
||||||
* `debug` : set to true to enable verbose output from the program
|
* `debug` : set to true to enable verbose output from the program
|
||||||
|
* `debugThumbnail` : set to true to draw a small thumbnail of the source image in the bottom left
|
||||||
|
* `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.
|
* `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.
|
* `exclusive` : When set to `true` only this entry will be used when it is in its valid time window.
|
||||||
* `times` : times is a JSON array of start and end times in which it is valid to display this image. The time is in the format HH:MM:SS and is based on the systems local time. If `start` isn't defined then it defaults to the start of the day, if `end` isn't defined it defaults to the end of the day.
|
* `times` : times is a JSON array of start and end times in which it is valid to display this image. The time is in the format HH:MM:SS and is based on the systems local time. If `start` isn't defined then it defaults to the start of the day, if `end` isn't defined it defaults to the end of the day.
|
||||||
* `path` : the path to image files
|
* `path` : the path to image files
|
||||||
* `stretch` : as above
|
* `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 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. Immich metadata (EXIF original time / created time) is stored alongside cached assets so `<exifdatetime>` continues to work even if the cached image doesn't contain EXIF data.
|
||||||
|
|
||||||
|
#### 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).
|
||||||
|
|
||||||
|
#### Finding an Immich user ID
|
||||||
|
|
||||||
|
You can use a user ID to show all assets owned by that user.
|
||||||
|
|
||||||
|
Common ways to find it:
|
||||||
|
* **Admin UI**: In Immich, open the Admin/Users page, click the user, and copy the UUID shown in the user details or URL.
|
||||||
|
* **API (non-admin)**: Call the `users/me` endpoint with the API key and read the `id` field:
|
||||||
|
```
|
||||||
|
curl -H "x-api-key: IMMICH_API_KEY" http://immich.local:2283/api/users/me
|
||||||
|
```
|
||||||
|
|
||||||
|
Example (single source):
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"immich": {
|
||||||
|
"url": "http://immich.local:2283",
|
||||||
|
"apiKey": "IMMICH_API_KEY",
|
||||||
|
"albumId": "b7f3c8b2-2e3f-4b32-9dc9-8c3f8b0a3ef7",
|
||||||
|
"size": "fullsize",
|
||||||
|
"order": "desc",
|
||||||
|
"pageSize": 200,
|
||||||
|
"maxAssets": 1000,
|
||||||
|
"refreshSeconds": 300,
|
||||||
|
"skipRetrySeconds": 3600,
|
||||||
|
"cachePath": "~/.cache/slide/immich",
|
||||||
|
"cacheMaxMB": 512,
|
||||||
|
"includeArchived": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example (scheduler entry):
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"scheduler": [
|
||||||
|
{
|
||||||
|
"exclusive": true,
|
||||||
|
"immich": {
|
||||||
|
"url": "http://immich.local:2283",
|
||||||
|
"apiKey": "IMMICH_API_KEY",
|
||||||
|
"albumIds": ["b7f3c8b2-2e3f-4b32-9dc9-8c3f8b0a3ef7"],
|
||||||
|
"size": "fullsize"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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 (see “Finding an Immich user ID” above).
|
||||||
|
* `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).
|
||||||
|
* `refreshSeconds`: refresh interval for reloading Immich assets (0 disables).
|
||||||
|
* `skipRetrySeconds`: retry interval for assets marked unsupported (0 disables retry).
|
||||||
|
* `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
|
## Folder Options file
|
||||||
When using the default or recursive folder mode we support having per folder display options. The options are stored in a file called "options.json" in the images folder and support a subset of the applications configuration settings:
|
When using the default or recursive folder mode we support having per folder display options. The options are stored in a file called "options.json" in the images folder and support a subset of the applications configuration settings:
|
||||||
```
|
```
|
||||||
@@ -150,6 +279,11 @@ See the `Configuration File` section for details of each setting.
|
|||||||
* qt5
|
* qt5
|
||||||
* qt5-image-formats-plugins
|
* qt5-image-formats-plugins
|
||||||
* libexif
|
* libexif
|
||||||
|
* libmosquitto-dev
|
||||||
|
|
||||||
|
## Home Assistant
|
||||||
|
|
||||||
|
See `doc/homeassistant.md` for a ready-to-use MQTT dashboard and scripts.
|
||||||
|
|
||||||
Ubuntu/Raspbian:
|
Ubuntu/Raspbian:
|
||||||
|
|
||||||
|
|||||||
273
doc/homeassistant.md
Normal file
273
doc/homeassistant.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# Home Assistant MQTT Dashboard for slide
|
||||||
|
|
||||||
|
This guide adds a Home Assistant dashboard to control `slide` via MQTT (play/pause/next, plus Immich filters).
|
||||||
|
|
||||||
|
Default MQTT topics used by `slide`:
|
||||||
|
|
||||||
|
- Control: `slide/control`
|
||||||
|
- Immich control: `slide/immich`
|
||||||
|
|
||||||
|
If you changed topics in your `slide` config, update the YAML below to match.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Helpers
|
||||||
|
|
||||||
|
Add these helpers in `configuration.yaml` (or your helpers include file):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
input_text:
|
||||||
|
slide_album_id:
|
||||||
|
name: Slide Album ID
|
||||||
|
slide_person_id:
|
||||||
|
name: Slide Person ID
|
||||||
|
slide_user_id:
|
||||||
|
name: Slide User ID
|
||||||
|
slide_extensions:
|
||||||
|
name: Slide Extensions (csv)
|
||||||
|
initial: "jpg,jpeg,png"
|
||||||
|
slide_refresh_seconds:
|
||||||
|
name: Slide Refresh Seconds
|
||||||
|
initial: "300"
|
||||||
|
|
||||||
|
input_select:
|
||||||
|
slide_size:
|
||||||
|
name: Slide Size
|
||||||
|
options:
|
||||||
|
- fullsize
|
||||||
|
- preview
|
||||||
|
- thumbnail
|
||||||
|
- original
|
||||||
|
initial: fullsize
|
||||||
|
slide_order:
|
||||||
|
name: Slide Order
|
||||||
|
options:
|
||||||
|
- desc
|
||||||
|
- asc
|
||||||
|
initial: desc
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Scripts
|
||||||
|
|
||||||
|
Add these scripts in `scripts.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
slide_mqtt_play:
|
||||||
|
alias: Slide Play
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: slide/control
|
||||||
|
payload: "play"
|
||||||
|
|
||||||
|
slide_mqtt_pause:
|
||||||
|
alias: Slide Pause
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: slide/control
|
||||||
|
payload: "pause"
|
||||||
|
|
||||||
|
slide_mqtt_next:
|
||||||
|
alias: Slide Next Image
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: slide/control
|
||||||
|
payload: "next"
|
||||||
|
|
||||||
|
slide_mqtt_next_folder:
|
||||||
|
alias: Slide Next Folder
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: slide/control
|
||||||
|
payload: "next-folder"
|
||||||
|
|
||||||
|
slide_mqtt_restart:
|
||||||
|
alias: Slide Restart
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: slide/control
|
||||||
|
payload: "restart"
|
||||||
|
|
||||||
|
slide_mqtt_reset_filters:
|
||||||
|
alias: Slide Reset Filters
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: slide/immich
|
||||||
|
payload: "reset"
|
||||||
|
|
||||||
|
slide_mqtt_set_album:
|
||||||
|
alias: Slide Set Album
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: slide/immich
|
||||||
|
payload: "album:{{ states('input_text.slide_album_id') }}"
|
||||||
|
|
||||||
|
slide_mqtt_set_person:
|
||||||
|
alias: Slide Set Person
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: slide/immich
|
||||||
|
payload: "person:{{ states('input_text.slide_person_id') }}"
|
||||||
|
|
||||||
|
slide_mqtt_set_user:
|
||||||
|
alias: Slide Set User
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: slide/immich
|
||||||
|
payload: "user:{{ states('input_text.slide_user_id') }}"
|
||||||
|
|
||||||
|
slide_mqtt_set_extensions:
|
||||||
|
alias: Slide Set Extensions
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: slide/immich
|
||||||
|
payload: "extensions:{{ states('input_text.slide_extensions') }}"
|
||||||
|
|
||||||
|
slide_mqtt_set_refresh:
|
||||||
|
alias: Slide Set Refresh Seconds
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: slide/immich
|
||||||
|
payload: "refreshSeconds:{{ states('input_text.slide_refresh_seconds') }}"
|
||||||
|
|
||||||
|
slide_mqtt_set_size:
|
||||||
|
alias: Slide Set Size
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: slide/immich
|
||||||
|
payload: "size:{{ states('input_select.slide_size') }}"
|
||||||
|
|
||||||
|
slide_mqtt_set_order:
|
||||||
|
alias: Slide Set Order
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: slide/immich
|
||||||
|
payload: "order:{{ states('input_select.slide_order') }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Lovelace Dashboard
|
||||||
|
|
||||||
|
Add this view in `ui-lovelace.yaml` (or a raw YAML dashboard):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
title: Slide
|
||||||
|
views:
|
||||||
|
- title: Slide Control
|
||||||
|
path: slide
|
||||||
|
icon: mdi:image-frame
|
||||||
|
cards:
|
||||||
|
- type: grid
|
||||||
|
columns: 3
|
||||||
|
cards:
|
||||||
|
- type: button
|
||||||
|
name: Play
|
||||||
|
icon: mdi:play
|
||||||
|
tap_action:
|
||||||
|
action: call-service
|
||||||
|
service: script.slide_mqtt_play
|
||||||
|
- type: button
|
||||||
|
name: Pause
|
||||||
|
icon: mdi:pause
|
||||||
|
tap_action:
|
||||||
|
action: call-service
|
||||||
|
service: script.slide_mqtt_pause
|
||||||
|
- type: button
|
||||||
|
name: Next
|
||||||
|
icon: mdi:skip-next
|
||||||
|
tap_action:
|
||||||
|
action: call-service
|
||||||
|
service: script.slide_mqtt_next
|
||||||
|
- type: button
|
||||||
|
name: Next Folder
|
||||||
|
icon: mdi:folder-arrow-right
|
||||||
|
tap_action:
|
||||||
|
action: call-service
|
||||||
|
service: script.slide_mqtt_next_folder
|
||||||
|
- type: button
|
||||||
|
name: Restart
|
||||||
|
icon: mdi:restart
|
||||||
|
tap_action:
|
||||||
|
action: call-service
|
||||||
|
service: script.slide_mqtt_restart
|
||||||
|
- type: button
|
||||||
|
name: Reset Filters
|
||||||
|
icon: mdi:filter-remove
|
||||||
|
tap_action:
|
||||||
|
action: call-service
|
||||||
|
service: script.slide_mqtt_reset_filters
|
||||||
|
|
||||||
|
- type: entities
|
||||||
|
title: Immich Filters
|
||||||
|
entities:
|
||||||
|
- entity: input_text.slide_album_id
|
||||||
|
- entity: input_text.slide_person_id
|
||||||
|
- entity: input_text.slide_user_id
|
||||||
|
- entity: input_text.slide_extensions
|
||||||
|
- entity: input_text.slide_refresh_seconds
|
||||||
|
- entity: input_select.slide_size
|
||||||
|
- entity: input_select.slide_order
|
||||||
|
|
||||||
|
- type: grid
|
||||||
|
columns: 3
|
||||||
|
cards:
|
||||||
|
- type: button
|
||||||
|
name: Set Album
|
||||||
|
icon: mdi:album
|
||||||
|
tap_action:
|
||||||
|
action: call-service
|
||||||
|
service: script.slide_mqtt_set_album
|
||||||
|
- type: button
|
||||||
|
name: Set Person
|
||||||
|
icon: mdi:account
|
||||||
|
tap_action:
|
||||||
|
action: call-service
|
||||||
|
service: script.slide_mqtt_set_person
|
||||||
|
- type: button
|
||||||
|
name: Set User
|
||||||
|
icon: mdi:account-cog
|
||||||
|
tap_action:
|
||||||
|
action: call-service
|
||||||
|
service: script.slide_mqtt_set_user
|
||||||
|
- type: button
|
||||||
|
name: Set Extensions
|
||||||
|
icon: mdi:file-image
|
||||||
|
tap_action:
|
||||||
|
action: call-service
|
||||||
|
service: script.slide_mqtt_set_extensions
|
||||||
|
- type: button
|
||||||
|
name: Set Refresh
|
||||||
|
icon: mdi:clock-sync
|
||||||
|
tap_action:
|
||||||
|
action: call-service
|
||||||
|
service: script.slide_mqtt_set_refresh
|
||||||
|
- type: button
|
||||||
|
name: Set Size/Order
|
||||||
|
icon: mdi:image-size-select-large
|
||||||
|
tap_action:
|
||||||
|
action: call-service
|
||||||
|
service: script.slide_mqtt_set_size
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- If you changed the MQTT topics in `slide`’s config, update all `topic:` entries to match.
|
||||||
|
- `extensions` filters by file extension (e.g., `jpg,jpeg,png`) to avoid RAW/HEIC if your display environment can’t decode them.
|
||||||
|
- `refreshSeconds` controls how often `slide` refreshes the Immich asset list.
|
||||||
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
|
||||||
69
sbin/build_deb.sh
Normal file
69
sbin/build_deb.sh
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
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)}"
|
||||||
|
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"
|
||||||
|
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"
|
||||||
|
|
||||||
|
install -m 0755 "$BUILD_DIR/slide" "$STAGE_DIR/usr/local/bin/slide"
|
||||||
|
|
||||||
|
cat > "$STAGE_DIR/DEBIAN/control" <<EOF
|
||||||
|
Package: ${PACKAGE_NAME}
|
||||||
|
Version: ${VERSION}
|
||||||
|
Section: graphics
|
||||||
|
Priority: optional
|
||||||
|
Architecture: ${ARCH}
|
||||||
|
Maintainer: slide build
|
||||||
|
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 -Zgzip "$STAGE_DIR" "$DIST_DIR/${PACKAGE_NAME}_${VERSION}_${ARCH}.deb"
|
||||||
@@ -48,7 +48,154 @@ void SetJSONBool(bool &value, QJsonObject jsonDoc, const char *key) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SetJSONInt(int &value, QJsonObject jsonDoc, const char *key) {
|
||||||
|
if(jsonDoc.contains(key) && jsonDoc[key].isDouble())
|
||||||
|
{
|
||||||
|
value = (int)jsonDoc[key].toDouble();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> ParseJSONStrings(QJsonObject jsonDoc, const char *key) {
|
||||||
|
std::vector<std::string> values;
|
||||||
|
if(jsonDoc.contains(key) && jsonDoc[key].isArray())
|
||||||
|
{
|
||||||
|
QJsonArray jsonArray = jsonDoc[key].toArray();
|
||||||
|
foreach (const QJsonValue & value, jsonArray)
|
||||||
|
{
|
||||||
|
if (value.isString())
|
||||||
|
{
|
||||||
|
values.push_back(value.toString().toStdString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
|
std::string url = ParseJSONString(immichJson, "url");
|
||||||
|
std::string apiKey = ParseJSONString(immichJson, "apiKey");
|
||||||
|
if(!url.empty())
|
||||||
|
config.url = url;
|
||||||
|
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;
|
||||||
|
|
||||||
|
std::string order = ParseJSONString(immichJson, "order");
|
||||||
|
if(!order.empty())
|
||||||
|
config.order = order;
|
||||||
|
|
||||||
|
std::string cachePath = ParseJSONString(immichJson, "cachePath");
|
||||||
|
if(!cachePath.empty())
|
||||||
|
config.cachePath = cachePath;
|
||||||
|
|
||||||
|
std::string albumId = ParseJSONString(immichJson, "albumId");
|
||||||
|
if(!albumId.empty())
|
||||||
|
config.albumIds.push_back(albumId);
|
||||||
|
|
||||||
|
std::vector<std::string> albumIds = ParseJSONStrings(immichJson, "albumIds");
|
||||||
|
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.skipRetrySeconds, immichJson, "skipRetrySeconds");
|
||||||
|
SetJSONInt(config.skipRetrySeconds, immichJson, "skipRetryIntervalSeconds");
|
||||||
|
SetJSONInt(config.cacheMaxMB, immichJson, "cacheMaxMB");
|
||||||
|
SetJSONBool(config.includeArchived, immichJson, "includeArchived");
|
||||||
|
|
||||||
|
if(!config.url.empty() && !config.apiKey.empty())
|
||||||
|
config.enabled = true;
|
||||||
|
|
||||||
|
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) {
|
Config loadConfiguration(const std::string &configFilePath, const Config ¤tConfig) {
|
||||||
|
if(configFilePath.empty())
|
||||||
|
{
|
||||||
|
return currentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
QString jsonFile(configFilePath.c_str());
|
QString jsonFile(configFilePath.c_str());
|
||||||
QDir directory;
|
QDir directory;
|
||||||
if(!directory.exists(jsonFile))
|
if(!directory.exists(jsonFile))
|
||||||
@@ -130,28 +277,36 @@ QString getAppConfigFilePath(const std::string &configPath) {
|
|||||||
std::string userConfigFolder = "~/.config/slide/";
|
std::string userConfigFolder = "~/.config/slide/";
|
||||||
std::string systemConfigFolder = "/etc/slide";
|
std::string systemConfigFolder = "/etc/slide";
|
||||||
QString baseConfigFilename("slide.options.json");
|
QString baseConfigFilename("slide.options.json");
|
||||||
|
|
||||||
QDir directory(userConfigFolder.c_str());
|
|
||||||
QString jsonFile = "";
|
|
||||||
if (!configPath.empty())
|
if (!configPath.empty())
|
||||||
{
|
|
||||||
directory.setPath(configPath.c_str());
|
|
||||||
jsonFile = directory.filePath(baseConfigFilename);
|
|
||||||
}
|
|
||||||
if(!directory.exists(jsonFile))
|
|
||||||
{
|
|
||||||
directory.setPath(userConfigFolder.c_str());
|
|
||||||
jsonFile = directory.filePath(baseConfigFilename);
|
|
||||||
}
|
|
||||||
if(!directory.exists(jsonFile))
|
|
||||||
{
|
{
|
||||||
directory.setPath(systemConfigFolder.c_str());
|
QFileInfo configInfo(QString::fromStdString(configPath));
|
||||||
jsonFile = directory.filePath(baseConfigFilename);
|
if (configInfo.exists() && configInfo.isFile())
|
||||||
|
{
|
||||||
|
return configInfo.absoluteFilePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
QDir directory(configInfo.isDir() ? configInfo.absoluteFilePath()
|
||||||
|
: QString::fromStdString(configPath));
|
||||||
|
QString jsonFile = directory.filePath(baseConfigFilename);
|
||||||
|
if (directory.exists(jsonFile))
|
||||||
|
{
|
||||||
|
return jsonFile;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(directory.exists(jsonFile))
|
QDir userDir(userConfigFolder.c_str());
|
||||||
|
QString userFile = userDir.filePath(baseConfigFilename);
|
||||||
|
if (userDir.exists(userFile))
|
||||||
{
|
{
|
||||||
return jsonFile;
|
return userFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
QDir systemDir(systemConfigFolder.c_str());
|
||||||
|
QString systemFile = systemDir.filePath(baseConfigFilename);
|
||||||
|
if (systemDir.exists(systemFile))
|
||||||
|
{
|
||||||
|
return systemFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
@@ -179,6 +334,11 @@ QVector<PathEntry> parsePathEntry(QJsonObject &jsonMainDoc, bool baseRecursive,
|
|||||||
|
|
||||||
SetJSONBool(entry.baseDisplayOptions.fitAspectAxisToWindow, schedulerJson, "stretch");
|
SetJSONBool(entry.baseDisplayOptions.fitAspectAxisToWindow, schedulerJson, "stretch");
|
||||||
|
|
||||||
|
if(schedulerJson.contains("immich") && schedulerJson["immich"].isObject())
|
||||||
|
{
|
||||||
|
entry.immich = ParseImmichConfigObject(schedulerJson["immich"].toObject());
|
||||||
|
}
|
||||||
|
|
||||||
std::string pathString = ParseJSONString(schedulerJson, "path");
|
std::string pathString = ParseJSONString(schedulerJson, "path");
|
||||||
if(!pathString.empty()) {
|
if(!pathString.empty()) {
|
||||||
entry.path = pathString;
|
entry.path = pathString;
|
||||||
@@ -218,6 +378,11 @@ QVector<PathEntry> parsePathEntry(QJsonObject &jsonMainDoc, bool baseRecursive,
|
|||||||
}
|
}
|
||||||
|
|
||||||
AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) {
|
AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) {
|
||||||
|
if(commandLineConfig.configPath.empty())
|
||||||
|
{
|
||||||
|
return commandLineConfig;
|
||||||
|
}
|
||||||
|
|
||||||
QString jsonFile = getAppConfigFilePath(commandLineConfig.configPath);
|
QString jsonFile = getAppConfigFilePath(commandLineConfig.configPath);
|
||||||
QDir directory;
|
QDir directory;
|
||||||
if(!directory.exists(jsonFile))
|
if(!directory.exists(jsonFile))
|
||||||
@@ -241,6 +406,7 @@ AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) {
|
|||||||
SetJSONBool(baseShuffle, jsonDoc, "shuffle");
|
SetJSONBool(baseShuffle, jsonDoc, "shuffle");
|
||||||
SetJSONBool(baseSorted, jsonDoc, "sorted");
|
SetJSONBool(baseSorted, jsonDoc, "sorted");
|
||||||
SetJSONBool(loadedConfig.debugMode, jsonDoc, "debug");
|
SetJSONBool(loadedConfig.debugMode, jsonDoc, "debug");
|
||||||
|
SetJSONBool(loadedConfig.debugThumbnail, jsonDoc, "debugThumbnail");
|
||||||
|
|
||||||
std::string overlayString = ParseJSONString(jsonDoc, "overlay");
|
std::string overlayString = ParseJSONString(jsonDoc, "overlay");
|
||||||
if(!overlayString.empty())
|
if(!overlayString.empty())
|
||||||
@@ -248,6 +414,11 @@ AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) {
|
|||||||
loadedConfig.overlay = overlayString;
|
loadedConfig.overlay = overlayString;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(jsonDoc.contains("mqtt") && jsonDoc["mqtt"].isObject())
|
||||||
|
{
|
||||||
|
loadedConfig.mqtt = ParseMqttConfigObject(jsonDoc["mqtt"].toObject());
|
||||||
|
}
|
||||||
|
|
||||||
loadedConfig.paths = parsePathEntry(jsonDoc, baseRecursive, baseShuffle, baseSorted);
|
loadedConfig.paths = parsePathEntry(jsonDoc, baseRecursive, baseShuffle, baseSorted);
|
||||||
if(loadedConfig.paths.count() <= 0)
|
if(loadedConfig.paths.count() <= 0)
|
||||||
{
|
{
|
||||||
@@ -255,6 +426,10 @@ AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) {
|
|||||||
entry.recursive = baseRecursive;
|
entry.recursive = baseRecursive;
|
||||||
entry.sorted = baseSorted;
|
entry.sorted = baseSorted;
|
||||||
entry.shuffle = baseShuffle;
|
entry.shuffle = baseShuffle;
|
||||||
|
if(jsonDoc.contains("immich") && jsonDoc["immich"].isObject())
|
||||||
|
{
|
||||||
|
entry.immich = ParseImmichConfigObject(jsonDoc["immich"].toObject());
|
||||||
|
}
|
||||||
std::string pathString = ParseJSONString(jsonDoc, "path");
|
std::string pathString = ParseJSONString(jsonDoc, "path");
|
||||||
if(!pathString.empty())
|
if(!pathString.empty())
|
||||||
{
|
{
|
||||||
@@ -272,6 +447,10 @@ AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Config getConfigurationForFolder(const std::string &folderPath, const Config ¤tConfig) {
|
Config getConfigurationForFolder(const std::string &folderPath, const Config ¤tConfig) {
|
||||||
|
if(folderPath.empty())
|
||||||
|
{
|
||||||
|
return currentConfig;
|
||||||
|
}
|
||||||
QDir directory(folderPath.c_str());
|
QDir directory(folderPath.c_str());
|
||||||
QString jsonFile = directory.filePath(QString("options.json"));
|
QString jsonFile = directory.filePath(QString("options.json"));
|
||||||
if(directory.exists(jsonFile))
|
if(directory.exists(jsonFile))
|
||||||
@@ -279,4 +458,4 @@ Config getConfigurationForFolder(const std::string &folderPath, const Config &cu
|
|||||||
return loadConfiguration(jsonFile.toStdString(), currentConfig );
|
return loadConfiguration(jsonFile.toStdString(), currentConfig );
|
||||||
}
|
}
|
||||||
return currentConfig;
|
return currentConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
109
src/appconfig.h
109
src/appconfig.h
@@ -4,11 +4,113 @@
|
|||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include "imagestructs.h"
|
#include "imagestructs.h"
|
||||||
#include <QVector>
|
#include <QVector>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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;
|
||||||
|
int skipRetrySeconds = 3600;
|
||||||
|
std::string cachePath = "";
|
||||||
|
int cacheMaxMB = 512;
|
||||||
|
bool includeArchived = false;
|
||||||
|
|
||||||
|
bool operator==(const ImmichConfig &b) const
|
||||||
|
{
|
||||||
|
if (enabled != b.enabled)
|
||||||
|
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)
|
||||||
|
return false;
|
||||||
|
if (cachePath != b.cachePath || cacheMaxMB != b.cacheMaxMB)
|
||||||
|
return false;
|
||||||
|
if (includeArchived != b.includeArchived)
|
||||||
|
return false;
|
||||||
|
if (albumIds.size() != b.albumIds.size())
|
||||||
|
return false;
|
||||||
|
for (size_t i = 0; i < albumIds.size(); ++i)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
if (skipRetrySeconds != b.skipRetrySeconds)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator!=(const ImmichConfig &b) const
|
||||||
|
{
|
||||||
|
return !operator==(b);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
// configuration options that apply to an image/folder of images
|
||||||
struct Config {
|
struct Config {
|
||||||
public:
|
public:
|
||||||
unsigned int rotationSeconds = 30;
|
unsigned int rotationSeconds = 30;
|
||||||
|
unsigned int transitionTime = 1;
|
||||||
int blurRadius = -1;
|
int blurRadius = -1;
|
||||||
int backgroundOpacity = -1;
|
int backgroundOpacity = -1;
|
||||||
ImageDisplayOptions baseDisplayOptions;
|
ImageDisplayOptions baseDisplayOptions;
|
||||||
@@ -20,6 +122,7 @@ struct PathEntry {
|
|||||||
std::string imageList = "";
|
std::string imageList = "";
|
||||||
bool exclusive = false; // only use this entry when it is valid, skip others
|
bool exclusive = false; // only use this entry when it is valid, skip others
|
||||||
|
|
||||||
|
ImmichConfig immich;
|
||||||
bool recursive = false;
|
bool recursive = false;
|
||||||
bool shuffle = false;
|
bool shuffle = false;
|
||||||
bool sorted = false;
|
bool sorted = false;
|
||||||
@@ -39,6 +142,8 @@ struct PathEntry {
|
|||||||
return true;
|
return true;
|
||||||
if (b.path != path || b.imageList != imageList)
|
if (b.path != path || b.imageList != imageList)
|
||||||
return true;
|
return true;
|
||||||
|
if (b.immich != immich)
|
||||||
|
return true;
|
||||||
if (b.baseDisplayOptions.timeWindows.count() != baseDisplayOptions.timeWindows.count())
|
if (b.baseDisplayOptions.timeWindows.count() != baseDisplayOptions.timeWindows.count())
|
||||||
return true;
|
return true;
|
||||||
for(int i = 0; i < baseDisplayOptions.timeWindows.count(); ++i)
|
for(int i = 0; i < baseDisplayOptions.timeWindows.count(); ++i)
|
||||||
@@ -58,8 +163,10 @@ struct AppConfig : public Config {
|
|||||||
std::string overlay = "";
|
std::string overlay = "";
|
||||||
QString overlayHexRGB = "#FFFFFF";
|
QString overlayHexRGB = "#FFFFFF";
|
||||||
QVector<PathEntry> paths;
|
QVector<PathEntry> paths;
|
||||||
|
MqttConfig mqtt;
|
||||||
|
|
||||||
bool debugMode = false;
|
bool debugMode = false;
|
||||||
|
bool debugThumbnail = false;
|
||||||
|
|
||||||
static const std::string valid_aspects;
|
static const std::string valid_aspects;
|
||||||
public:
|
public:
|
||||||
@@ -82,4 +189,4 @@ Config getConfigurationForFolder(const std::string &folderPath, const Config &cu
|
|||||||
ImageAspectScreenFilter parseAspectFromString(char aspect);
|
ImageAspectScreenFilter parseAspectFromString(char aspect);
|
||||||
QString getAppConfigFilePath(const std::string &configPath);
|
QString getAppConfigFilePath(const std::string &configPath);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ const ImageDetails ShuffleImageSelector::getNextImage(const ImageDisplayOptions
|
|||||||
|
|
||||||
void ShuffleImageSelector::reloadImagesIfNoneLeft()
|
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;
|
current_image_shuffle = 0;
|
||||||
images = pathTraverser->getImages();
|
images = pathTraverser->getImages();
|
||||||
@@ -305,7 +305,7 @@ const ImageDetails SortedImageSelector::getNextImage(const ImageDisplayOptions &
|
|||||||
|
|
||||||
void SortedImageSelector::reloadImagesIfEmpty()
|
void SortedImageSelector::reloadImagesIfEmpty()
|
||||||
{
|
{
|
||||||
if (images.size() == 0)
|
if (images.size() == 0 || pathTraverser->shouldReloadImages())
|
||||||
{
|
{
|
||||||
images = pathTraverser->getImages();
|
images = pathTraverser->getImages();
|
||||||
std::sort(images.begin(), images.end());
|
std::sort(images.begin(), images.end());
|
||||||
@@ -371,4 +371,4 @@ const ImageDetails ListImageSelector::getNextImage(const ImageDisplayOptions& ba
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
while(true);
|
while(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ ImageSwitcher::ImageSwitcher(MainWindow& w, unsigned int timeoutMsec, std::uniqu
|
|||||||
|
|
||||||
void ImageSwitcher::updateImage()
|
void ImageSwitcher::updateImage()
|
||||||
{
|
{
|
||||||
|
if (paused)
|
||||||
|
return;
|
||||||
if(reloadConfigIfNeeded)
|
if(reloadConfigIfNeeded)
|
||||||
{
|
{
|
||||||
reloadConfigIfNeeded(window, this);
|
reloadConfigIfNeeded(window, this);
|
||||||
@@ -60,10 +62,64 @@ void ImageSwitcher::setConfigFileReloader(std::function<void(MainWindow &w, Imag
|
|||||||
void ImageSwitcher::setRotationTime(unsigned int timeoutMsecIn)
|
void ImageSwitcher::setRotationTime(unsigned int timeoutMsecIn)
|
||||||
{
|
{
|
||||||
timeout = timeoutMsecIn;
|
timeout = timeoutMsecIn;
|
||||||
timer.start(timeout);
|
if (!paused)
|
||||||
|
timer.start(timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ImageSwitcher::setImageSelector(std::unique_ptr<ImageSelector>& selectorIn)
|
void ImageSwitcher::setImageSelector(std::unique_ptr<ImageSelector>& selectorIn)
|
||||||
{
|
{
|
||||||
selector = std::move(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 setConfigFileReloader(std::function<void(MainWindow &w, ImageSwitcher *switcher)> reloadConfigIfNeededIn);
|
||||||
void setRotationTime(unsigned int timeoutMsec);
|
void setRotationTime(unsigned int timeoutMsec);
|
||||||
void setImageSelector(std::unique_ptr<ImageSelector>& selector);
|
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:
|
public slots:
|
||||||
void updateImage();
|
void updateImage();
|
||||||
@@ -30,6 +36,7 @@ private:
|
|||||||
const unsigned int timeoutNoContent = 5 * 1000; // 5 sec
|
const unsigned int timeoutNoContent = 5 * 1000; // 5 sec
|
||||||
QTimer timerNoContent;
|
QTimer timerNoContent;
|
||||||
std::function<void(MainWindow &w, ImageSwitcher *switcher)> reloadConfigIfNeeded;
|
std::function<void(MainWindow &w, ImageSwitcher *switcher)> reloadConfigIfNeeded;
|
||||||
|
bool paused = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // IMAGESWITCHER_H
|
#endif // IMAGESWITCHER_H
|
||||||
|
|||||||
698
src/immichclient.cpp
Normal file
698
src/immichclient.cpp
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
#include "immichclient.h"
|
||||||
|
#include "logger.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QEventLoop>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QBuffer>
|
||||||
|
#include <QImageReader>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QSaveFile>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QUrlQuery>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QFileInfo>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
const int kMetadataTimeoutMs = 15000;
|
||||||
|
const int kAssetTimeoutMs = 30000;
|
||||||
|
QString ReadJsonString(const QJsonObject &obj, const char *key)
|
||||||
|
{
|
||||||
|
auto value = obj.value(key);
|
||||||
|
if (!value.isString())
|
||||||
|
return "";
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ExtractAssetDateTime(const QJsonObject &item)
|
||||||
|
{
|
||||||
|
QJsonObject exifInfo = item.value("exifInfo").toObject();
|
||||||
|
const char *exifKeys[] = {"dateTimeOriginal", "dateTimeDigitized", "dateTime", "createDate"};
|
||||||
|
for (const auto *key : exifKeys)
|
||||||
|
{
|
||||||
|
QString value = ReadJsonString(exifInfo, key);
|
||||||
|
if (!value.isEmpty())
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *fallbackKeys[] = {"localDateTime", "fileCreatedAt", "createdAt", "fileModifiedAt", "updatedAt"};
|
||||||
|
for (const auto *key : fallbackKeys)
|
||||||
|
{
|
||||||
|
QString value = ReadJsonString(item, key);
|
||||||
|
if (!value.isEmpty())
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
: config(configIn)
|
||||||
|
{}
|
||||||
|
|
||||||
|
QUrl ImmichClient::apiUrl(const QString &path) const
|
||||||
|
{
|
||||||
|
QString base = QString::fromStdString(config.url).trimmed();
|
||||||
|
if (base.endsWith("/"))
|
||||||
|
base.chop(1);
|
||||||
|
if (!base.endsWith("/api"))
|
||||||
|
base += "/api";
|
||||||
|
return QUrl(base + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
QNetworkRequest ImmichClient::makeRequest(const QUrl &url) const
|
||||||
|
{
|
||||||
|
QNetworkRequest request(url);
|
||||||
|
request.setRawHeader("x-api-key", QByteArray::fromStdString(config.apiKey));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImmichClient::waitForReply(QNetworkReply *reply, QByteArray &data, QString *contentType, int timeoutMs)
|
||||||
|
{
|
||||||
|
QEventLoop loop;
|
||||||
|
QTimer timer;
|
||||||
|
timer.setSingleShot(true);
|
||||||
|
QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||||
|
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||||
|
timer.start(timeoutMs);
|
||||||
|
loop.exec();
|
||||||
|
|
||||||
|
if (!timer.isActive())
|
||||||
|
{
|
||||||
|
reply->abort();
|
||||||
|
reply->deleteLater();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType)
|
||||||
|
*contentType = reply->header(QNetworkRequest::ContentTypeHeader).toString();
|
||||||
|
data = reply->readAll();
|
||||||
|
bool ok = reply->error() == QNetworkReply::NoError;
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
Log("Immich request failed: ", reply->errorString().toStdString());
|
||||||
|
}
|
||||||
|
reply->deleteLater();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray ImmichClient::postJson(const QUrl &url, const QJsonObject &body, QString *contentType, int timeoutMs)
|
||||||
|
{
|
||||||
|
QNetworkRequest request = makeRequest(url);
|
||||||
|
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||||
|
request.setRawHeader("Accept", "application/json");
|
||||||
|
QNetworkReply *reply = manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
|
||||||
|
QByteArray data;
|
||||||
|
if (!waitForReply(reply, data, contentType, timeoutMs))
|
||||||
|
return QByteArray();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray ImmichClient::getBytes(const QUrl &url, QString *contentType, int timeoutMs)
|
||||||
|
{
|
||||||
|
QNetworkRequest request = makeRequest(url);
|
||||||
|
request.setRawHeader("Accept", "*/*");
|
||||||
|
QNetworkReply *reply = manager.get(request);
|
||||||
|
QByteArray data;
|
||||||
|
if (!waitForReply(reply, data, contentType, timeoutMs))
|
||||||
|
return QByteArray();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<ImmichAsset> ImmichClient::fetchAssets()
|
||||||
|
{
|
||||||
|
if (!config.enabled)
|
||||||
|
{
|
||||||
|
Log("Immich config is missing url or apiKey.");
|
||||||
|
return QVector<ImmichAsset>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.userId.empty() && config.albumIds.empty() && config.personIds.empty())
|
||||||
|
return fetchAssetsBySearch();
|
||||||
|
|
||||||
|
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;
|
||||||
|
QString userFilterKey;
|
||||||
|
QByteArray firstResponse;
|
||||||
|
QJsonArray items;
|
||||||
|
int total = 0;
|
||||||
|
|
||||||
|
if (ShouldLog())
|
||||||
|
{
|
||||||
|
Log("Immich search: size=", config.size, ", order=", config.order,
|
||||||
|
", pageSize=", pageSize, ", maxAssets=", maxAssets,
|
||||||
|
", userId=", config.userId,
|
||||||
|
", albumIds=", config.albumIds.size(),
|
||||||
|
", personIds=", config.personIds.size(),
|
||||||
|
", allowedExtensions=", config.allowedExtensions.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
auto fetchPage = [&](int pageIndex, const QString &userKey) -> QByteArray {
|
||||||
|
QJsonObject body;
|
||||||
|
body["page"] = pageIndex;
|
||||||
|
body["size"] = pageSize;
|
||||||
|
body["type"] = "IMAGE";
|
||||||
|
body["order"] = QString::fromStdString(config.order);
|
||||||
|
if (config.includeArchived)
|
||||||
|
body["withArchived"] = true;
|
||||||
|
if (config.albumIds.size() > 0)
|
||||||
|
{
|
||||||
|
QJsonArray ids;
|
||||||
|
for (const auto &id : config.albumIds)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (!config.userId.empty() && !userKey.isEmpty())
|
||||||
|
{
|
||||||
|
body[userKey] = QString::fromStdString(config.userId);
|
||||||
|
}
|
||||||
|
return postJson(apiUrl("/search/metadata"), body, nullptr, kMetadataTimeoutMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
auto parseSearch = [&](const QByteArray &response, QJsonArray &outItems, int &outTotal) -> bool {
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(response);
|
||||||
|
if (!doc.isObject())
|
||||||
|
return false;
|
||||||
|
QJsonObject root = doc.object();
|
||||||
|
if (root.contains("error") || root.contains("statusCode"))
|
||||||
|
return false;
|
||||||
|
if (!root.contains("assets"))
|
||||||
|
return false;
|
||||||
|
QJsonObject assetsObj = root["assets"].toObject();
|
||||||
|
outItems = assetsObj["items"].toArray();
|
||||||
|
outTotal = assetsObj["total"].toInt();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
QStringList userKeyCandidates;
|
||||||
|
if (!config.userId.empty())
|
||||||
|
userKeyCandidates << "ownerId" << "userId";
|
||||||
|
userKeyCandidates << "";
|
||||||
|
|
||||||
|
bool firstResponseReady = false;
|
||||||
|
for (const auto &candidate : userKeyCandidates)
|
||||||
|
{
|
||||||
|
QByteArray response = fetchPage(page, candidate);
|
||||||
|
if (response.isEmpty())
|
||||||
|
continue;
|
||||||
|
if (!parseSearch(response, items, total))
|
||||||
|
continue;
|
||||||
|
userFilterKey = candidate;
|
||||||
|
firstResponse = response;
|
||||||
|
firstResponseReady = true;
|
||||||
|
if (!config.userId.empty() && userFilterKey.isEmpty())
|
||||||
|
{
|
||||||
|
Log("Immich search user filter not accepted by server; falling back to search without userId.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstResponseReady)
|
||||||
|
return assets;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (firstResponseReady)
|
||||||
|
{
|
||||||
|
firstResponseReady = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
QByteArray response = fetchPage(page, userFilterKey);
|
||||||
|
if (response.isEmpty())
|
||||||
|
break;
|
||||||
|
if (!parseSearch(response, items, total))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log("Immich page ", page, ": ", items.size(), " assets (total ", total, ")");
|
||||||
|
if (items.isEmpty())
|
||||||
|
{
|
||||||
|
if (total > 0 && page == 1 && !triedZero)
|
||||||
|
{
|
||||||
|
triedZero = true;
|
||||||
|
page = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
asset.exifDateTime = ExtractAssetDateTime(item);
|
||||||
|
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;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<ImmichAsset> ImmichClient::fetchAssetsByUser()
|
||||||
|
{
|
||||||
|
QVector<ImmichAsset> assets;
|
||||||
|
|
||||||
|
int pageSize = config.pageSize > 0 ? config.pageSize : 200;
|
||||||
|
int maxAssets = config.maxAssets;
|
||||||
|
int skip = 0;
|
||||||
|
QString endpointPath = "/assets";
|
||||||
|
QByteArray initialResponse;
|
||||||
|
bool endpointResolved = false;
|
||||||
|
|
||||||
|
if (ShouldLog())
|
||||||
|
{
|
||||||
|
Log("Immich assets: userId=", config.userId,
|
||||||
|
", pageSize=", pageSize,
|
||||||
|
", maxAssets=", maxAssets,
|
||||||
|
", includeArchived=", config.includeArchived);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto fetchPage = [&](const QString &path, int offset) -> QByteArray {
|
||||||
|
QUrl url = apiUrl(path);
|
||||||
|
QUrlQuery query;
|
||||||
|
query.addQueryItem("take", QString::number(pageSize));
|
||||||
|
query.addQueryItem("skip", QString::number(offset));
|
||||||
|
query.addQueryItem("userId", QString::fromStdString(config.userId));
|
||||||
|
if (!config.includeArchived)
|
||||||
|
query.addQueryItem("isArchived", "false");
|
||||||
|
url.setQuery(query);
|
||||||
|
return getBytes(url, nullptr, kMetadataTimeoutMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
initialResponse = fetchPage(endpointPath, skip);
|
||||||
|
if (initialResponse.isEmpty())
|
||||||
|
{
|
||||||
|
endpointPath = "/asset";
|
||||||
|
initialResponse = fetchPage(endpointPath, skip);
|
||||||
|
}
|
||||||
|
if (initialResponse.isEmpty())
|
||||||
|
{
|
||||||
|
Log("Immich user assets endpoint not available; falling back to metadata search (userId filter may be ignored).");
|
||||||
|
return fetchAssetsBySearch();
|
||||||
|
}
|
||||||
|
endpointResolved = true;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
QByteArray response;
|
||||||
|
if (endpointResolved)
|
||||||
|
{
|
||||||
|
response = initialResponse;
|
||||||
|
endpointResolved = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
response = fetchPage(endpointPath, skip);
|
||||||
|
}
|
||||||
|
if (response.isEmpty())
|
||||||
|
break;
|
||||||
|
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(response);
|
||||||
|
if (!doc.isArray())
|
||||||
|
{
|
||||||
|
Log("Immich user assets response was not an array; falling back to metadata search.");
|
||||||
|
return fetchAssetsBySearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
asset.exifDateTime = ExtractAssetDateTime(item);
|
||||||
|
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 = sizeOverride.trimmed().toLower();
|
||||||
|
if (size.isEmpty())
|
||||||
|
size = "fullsize";
|
||||||
|
|
||||||
|
QUrl url;
|
||||||
|
if (size == "original" || size == "download")
|
||||||
|
{
|
||||||
|
url = apiUrl("/assets/" + assetId + "/download");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (size != "fullsize" && size != "preview" && size != "thumbnail")
|
||||||
|
{
|
||||||
|
Log("Immich size '", size.toStdString(), "' not recognized. Defaulting to fullsize.");
|
||||||
|
size = "fullsize";
|
||||||
|
}
|
||||||
|
url = apiUrl("/assets/" + assetId + "/thumbnail");
|
||||||
|
QUrlQuery query;
|
||||||
|
query.addQueryItem("size", size);
|
||||||
|
url.setQuery(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log("Immich download asset ", assetId.toStdString(), " (", size.toStdString(), ")");
|
||||||
|
QByteArray payload = getBytes(url, &contentType, kAssetTimeoutMs);
|
||||||
|
if (payload.isEmpty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
data = payload;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImmichAssetCache::ImmichAssetCache(const ImmichConfig &config)
|
||||||
|
{
|
||||||
|
QString rawPath = QString::fromStdString(config.cachePath);
|
||||||
|
cacheDirPath = resolveCachePath(rawPath);
|
||||||
|
if (config.cacheMaxMB > 0)
|
||||||
|
cacheMaxBytes = static_cast<qint64>(config.cacheMaxMB) * 1024 * 1024;
|
||||||
|
skipRetrySeconds = config.skipRetrySeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ImmichAssetCache::resolveCachePath(const QString &rawPath) const
|
||||||
|
{
|
||||||
|
if (rawPath.isEmpty())
|
||||||
|
{
|
||||||
|
return QDir::homePath() + "/.cache/slide/immich";
|
||||||
|
}
|
||||||
|
if (rawPath.startsWith("~"))
|
||||||
|
{
|
||||||
|
return QDir::homePath() + rawPath.mid(1);
|
||||||
|
}
|
||||||
|
return rawPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImmichAssetCache::ensureCacheDir() const
|
||||||
|
{
|
||||||
|
QDir dir(cacheDirPath);
|
||||||
|
if (!dir.exists())
|
||||||
|
dir.mkpath(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ImmichAssetCache::findExisting(const QString &assetId) const
|
||||||
|
{
|
||||||
|
QDir dir(cacheDirPath);
|
||||||
|
QStringList matches = dir.entryList(QStringList() << (assetId + "_*"), QDir::Files, QDir::Time);
|
||||||
|
if (matches.isEmpty())
|
||||||
|
return "";
|
||||||
|
QString skipFile;
|
||||||
|
for (const auto &match : matches)
|
||||||
|
{
|
||||||
|
if (match.endsWith(".skip"))
|
||||||
|
{
|
||||||
|
if (skipFile.isEmpty())
|
||||||
|
skipFile = dir.filePath(match);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (match.endsWith(".exif"))
|
||||||
|
continue;
|
||||||
|
return dir.filePath(match);
|
||||||
|
}
|
||||||
|
return skipFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ImmichAssetCache::sanitizeFileName(const QString &name) const
|
||||||
|
{
|
||||||
|
QString safe = name;
|
||||||
|
safe.replace(QRegularExpression("[^A-Za-z0-9_.-]"), "_");
|
||||||
|
if (safe.isEmpty())
|
||||||
|
safe = "asset";
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ImmichAssetCache::extensionForContentType(const QString &contentType) const
|
||||||
|
{
|
||||||
|
if (contentType.contains("jpeg", Qt::CaseInsensitive))
|
||||||
|
return "jpg";
|
||||||
|
if (contentType.contains("png", Qt::CaseInsensitive))
|
||||||
|
return "png";
|
||||||
|
if (contentType.contains("webp", Qt::CaseInsensitive))
|
||||||
|
return "webp";
|
||||||
|
if (contentType.contains("gif", Qt::CaseInsensitive))
|
||||||
|
return "gif";
|
||||||
|
return "img";
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 ImmichAssetCache::calculateCacheSize() const
|
||||||
|
{
|
||||||
|
QDir dir(cacheDirPath);
|
||||||
|
QFileInfoList files = dir.entryInfoList(QDir::Files, QDir::Time);
|
||||||
|
qint64 total = 0;
|
||||||
|
for (const auto &info : files)
|
||||||
|
total += info.size();
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImmichAssetCache::enforceCacheLimit()
|
||||||
|
{
|
||||||
|
if (cacheMaxBytes <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
QDir dir(cacheDirPath);
|
||||||
|
QFileInfoList files = dir.entryInfoList(QDir::Files, QDir::Time);
|
||||||
|
qint64 total = 0;
|
||||||
|
for (const auto &info : files)
|
||||||
|
total += info.size();
|
||||||
|
|
||||||
|
for (int i = files.size() - 1; i >= 0 && total > cacheMaxBytes; --i)
|
||||||
|
{
|
||||||
|
total -= files[i].size();
|
||||||
|
QFile::remove(files[i].filePath());
|
||||||
|
}
|
||||||
|
cacheSizeBytes = total;
|
||||||
|
cacheSizeKnown = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &assetName, ImmichClient &client)
|
||||||
|
{
|
||||||
|
ensureCacheDir();
|
||||||
|
|
||||||
|
QString existing = findExisting(assetId);
|
||||||
|
if (!existing.isEmpty())
|
||||||
|
{
|
||||||
|
if (existing.endsWith(".skip"))
|
||||||
|
{
|
||||||
|
if (skipRetrySeconds > 0)
|
||||||
|
{
|
||||||
|
QFileInfo info(existing);
|
||||||
|
if (info.exists() && info.lastModified().secsTo(QDateTime::currentDateTime()) >= skipRetrySeconds)
|
||||||
|
{
|
||||||
|
Log("Immich skip expired, retrying: ", assetId.toStdString());
|
||||||
|
QFile::remove(existing);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log("Immich skip marker: ", assetId.toStdString());
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
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 = 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);
|
||||||
|
QString filePath = dir.filePath(filename);
|
||||||
|
|
||||||
|
QSaveFile file(filePath);
|
||||||
|
if (!file.open(QIODevice::WriteOnly))
|
||||||
|
return "";
|
||||||
|
file.write(data);
|
||||||
|
if (!file.commit())
|
||||||
|
return "";
|
||||||
|
|
||||||
|
Log("Immich cached asset: ", assetId.toStdString(), " -> ", filePath.toStdString());
|
||||||
|
|
||||||
|
if (cacheMaxBytes > 0)
|
||||||
|
{
|
||||||
|
if (!cacheSizeKnown)
|
||||||
|
{
|
||||||
|
cacheSizeBytes = calculateCacheSize();
|
||||||
|
cacheSizeKnown = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cacheSizeBytes += data.size();
|
||||||
|
}
|
||||||
|
if (cacheSizeBytes > cacheMaxBytes)
|
||||||
|
enforceCacheLimit();
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
62
src/immichclient.h
Normal file
62
src/immichclient.h
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#ifndef IMMICHCLIENT_H
|
||||||
|
#define IMMICHCLIENT_H
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QString>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
#include "appconfig.h"
|
||||||
|
|
||||||
|
struct ImmichAsset {
|
||||||
|
QString id;
|
||||||
|
QString originalFileName;
|
||||||
|
QString exifDateTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ImmichClient {
|
||||||
|
public:
|
||||||
|
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);
|
||||||
|
QByteArray getBytes(const QUrl &url, QString *contentType, int timeoutMs);
|
||||||
|
bool waitForReply(QNetworkReply *reply, QByteArray &data, QString *contentType, int timeoutMs);
|
||||||
|
|
||||||
|
ImmichConfig config;
|
||||||
|
QNetworkAccessManager manager;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ImmichAssetCache {
|
||||||
|
public:
|
||||||
|
explicit ImmichAssetCache(const ImmichConfig &config);
|
||||||
|
QString getCachedPath(const QString &assetId, const QString &assetName, ImmichClient &client);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString resolveCachePath(const QString &rawPath) const;
|
||||||
|
QString findExisting(const QString &assetId) const;
|
||||||
|
QString sanitizeFileName(const QString &name) const;
|
||||||
|
QString extensionForContentType(const QString &contentType) const;
|
||||||
|
void ensureCacheDir() const;
|
||||||
|
void enforceCacheLimit();
|
||||||
|
qint64 calculateCacheSize() const;
|
||||||
|
|
||||||
|
QString cacheDirPath;
|
||||||
|
qint64 cacheMaxBytes = 0;
|
||||||
|
bool cacheSizeKnown = false;
|
||||||
|
qint64 cacheSizeBytes = 0;
|
||||||
|
int skipRetrySeconds = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // IMMICHCLIENT_H
|
||||||
108
src/immichpathtraverser.cpp
Normal file
108
src/immichpathtraverser.cpp
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
#include "immichpathtraverser.h"
|
||||||
|
#include "logger.h"
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QSaveFile>
|
||||||
|
|
||||||
|
ImmichPathTraverser::ImmichPathTraverser(const ImmichConfig &configIn)
|
||||||
|
: PathTraverser(""),
|
||||||
|
config(configIn),
|
||||||
|
client(configIn),
|
||||||
|
cache(configIn)
|
||||||
|
{
|
||||||
|
loadAssets();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImmichPathTraverser::~ImmichPathTraverser() {}
|
||||||
|
|
||||||
|
void ImmichPathTraverser::loadAssets()
|
||||||
|
{
|
||||||
|
assetIds.clear();
|
||||||
|
assetNames.clear();
|
||||||
|
assetDateTimes.clear();
|
||||||
|
QVector<ImmichAsset> assets = client.fetchAssets();
|
||||||
|
for (const auto &asset : assets)
|
||||||
|
{
|
||||||
|
if (asset.id.isEmpty())
|
||||||
|
continue;
|
||||||
|
assetIds.append(asset.id);
|
||||||
|
assetNames.insert(asset.id, asset.originalFileName);
|
||||||
|
if (!asset.exifDateTime.isEmpty())
|
||||||
|
assetDateTimes.insert(asset.id, asset.exifDateTime);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string ImmichPathTraverser::getImagePath(const std::string image) const
|
||||||
|
{
|
||||||
|
QString assetId = QString::fromStdString(image);
|
||||||
|
QString name = assetNames.value(assetId);
|
||||||
|
QString path = cache.getCachedPath(assetId, name, client);
|
||||||
|
if (!path.isEmpty())
|
||||||
|
{
|
||||||
|
QString dateTime = assetDateTimes.value(assetId);
|
||||||
|
if (!dateTime.isEmpty())
|
||||||
|
writeExifSidecar(path, dateTime);
|
||||||
|
}
|
||||||
|
return path.toStdString();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageDisplayOptions ImmichPathTraverser::UpdateOptionsForImage(const std::string& filename, const ImageDisplayOptions& options) const
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImmichPathTraverser::writeExifSidecar(const QString &imagePath, const QString &dateTime) const
|
||||||
|
{
|
||||||
|
if (imagePath.isEmpty() || dateTime.isEmpty())
|
||||||
|
return;
|
||||||
|
if (imagePath.endsWith(".exif") || imagePath.endsWith(".skip"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
QString sidecarPath = imagePath + ".exif";
|
||||||
|
QFileInfo info(sidecarPath);
|
||||||
|
if (info.exists())
|
||||||
|
{
|
||||||
|
QFile existing(sidecarPath);
|
||||||
|
if (existing.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||||
|
{
|
||||||
|
QString current = QString::fromUtf8(existing.readAll()).trimmed();
|
||||||
|
existing.close();
|
||||||
|
if (current == dateTime.trimmed())
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QSaveFile file(sidecarPath);
|
||||||
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
|
||||||
|
return;
|
||||||
|
file.write(dateTime.trimmed().toUtf8());
|
||||||
|
file.commit();
|
||||||
|
}
|
||||||
34
src/immichpathtraverser.h
Normal file
34
src/immichpathtraverser.h
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#ifndef IMMICHPATHTRAVERSER_H
|
||||||
|
#define IMMICHPATHTRAVERSER_H
|
||||||
|
|
||||||
|
#include "pathtraverser.h"
|
||||||
|
#include "immichclient.h"
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QDateTime>
|
||||||
|
|
||||||
|
class ImmichPathTraverser : public PathTraverser
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
ImmichPathTraverser(const ImmichConfig &config);
|
||||||
|
virtual ~ImmichPathTraverser();
|
||||||
|
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();
|
||||||
|
void writeExifSidecar(const QString &imagePath, const QString &dateTime) const;
|
||||||
|
bool refreshDue() const;
|
||||||
|
|
||||||
|
ImmichConfig config;
|
||||||
|
mutable ImmichClient client;
|
||||||
|
mutable ImmichAssetCache cache;
|
||||||
|
QStringList assetIds;
|
||||||
|
QHash<QString, QString> assetNames;
|
||||||
|
QHash<QString, QString> assetDateTimes;
|
||||||
|
QDateTime lastRefresh;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // IMMICHPATHTRAVERSER_H
|
||||||
390
src/main.cpp
390
src/main.cpp
@@ -2,12 +2,13 @@
|
|||||||
#include "imageselector.h"
|
#include "imageselector.h"
|
||||||
#include "imageswitcher.h"
|
#include "imageswitcher.h"
|
||||||
#include "pathtraverser.h"
|
#include "pathtraverser.h"
|
||||||
|
#include "immichpathtraverser.h"
|
||||||
|
#include "mqttcontroller.h"
|
||||||
#include "overlay.h"
|
#include "overlay.h"
|
||||||
#include "appconfig.h"
|
#include "appconfig.h"
|
||||||
#include "logger.h"
|
#include "logger.h"
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QNetworkAccessManager>
|
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <sys/file.h>
|
#include <sys/file.h>
|
||||||
@@ -18,9 +19,12 @@
|
|||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonArray>
|
||||||
|
|
||||||
void usage(std::string programName) {
|
void usage(std::string programName) {
|
||||||
std::cerr << "Usage: " << programName << " [-t rotation_seconds] [-a aspect('l','p','a', 'm')] [-o background_opacity(0..255)] [-b blur_radius] -p image_folder [-r] [-s] [-v] [--verbose] [--stretch] [-c config_file_path]" << std::endl;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool parseCommandLine(AppConfig &appConfig, int argc, char *argv[]) {
|
bool parseCommandLine(AppConfig &appConfig, int argc, char *argv[]) {
|
||||||
@@ -34,7 +38,7 @@ bool parseCommandLine(AppConfig &appConfig, int argc, char *argv[]) {
|
|||||||
{"overlay-color", required_argument, 0, 'h'},
|
{"overlay-color", required_argument, 0, 'h'},
|
||||||
};
|
};
|
||||||
int option_index = 0;
|
int option_index = 0;
|
||||||
while ((opt = getopt_long(argc, argv, "b:p:t:o:O:a:i:c:h:rsSv", long_options, &option_index)) != -1) {
|
while ((opt = getopt_long(argc, argv, "b:p:t:T:o:O:a:i:c:h:rsSv", long_options, &option_index)) != -1) {
|
||||||
switch (opt) {
|
switch (opt) {
|
||||||
case 0:
|
case 0:
|
||||||
/* If this option set a flag, do nothing else now. */
|
/* If this option set a flag, do nothing else now. */
|
||||||
@@ -61,6 +65,9 @@ bool parseCommandLine(AppConfig &appConfig, int argc, char *argv[]) {
|
|||||||
case 't':
|
case 't':
|
||||||
appConfig.rotationSeconds = atoi(optarg);
|
appConfig.rotationSeconds = atoi(optarg);
|
||||||
break;
|
break;
|
||||||
|
case 'T':
|
||||||
|
appConfig.transitionTime =atoi(optarg);
|
||||||
|
break;
|
||||||
case 'b':
|
case 'b':
|
||||||
appConfig.blurRadius = atoi(optarg);
|
appConfig.blurRadius = atoi(optarg);
|
||||||
break;
|
break;
|
||||||
@@ -129,6 +136,8 @@ void ConfigureWindowFromSettings(MainWindow &w, const AppConfig &appConfig)
|
|||||||
w.setBackgroundOpacity(appConfig.backgroundOpacity);
|
w.setBackgroundOpacity(appConfig.backgroundOpacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.setTransitionTime(appConfig.transitionTime);
|
||||||
|
|
||||||
if (!appConfig.overlayHexRGB.isEmpty())
|
if (!appConfig.overlayHexRGB.isEmpty())
|
||||||
{
|
{
|
||||||
QRegularExpression hexRGBMatcher("^#([0-9A-Fa-f]{3}){1,2}$");
|
QRegularExpression hexRGBMatcher("^#([0-9A-Fa-f]{3}){1,2}$");
|
||||||
@@ -147,13 +156,18 @@ void ConfigureWindowFromSettings(MainWindow &w, const AppConfig &appConfig)
|
|||||||
std::unique_ptr<Overlay> o = std::unique_ptr<Overlay>(new Overlay(appConfig.overlay));
|
std::unique_ptr<Overlay> o = std::unique_ptr<Overlay>(new Overlay(appConfig.overlay));
|
||||||
w.setOverlay(o);
|
w.setOverlay(o);
|
||||||
}
|
}
|
||||||
|
w.setDebugThumbnail(appConfig.debugThumbnail);
|
||||||
w.setBaseOptions(appConfig.baseDisplayOptions);
|
w.setBaseOptions(appConfig.baseDisplayOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::unique_ptr<ImageSelector> GetSelectorForConfig(const PathEntry& path)
|
std::unique_ptr<ImageSelector> GetSelectorForConfig(const PathEntry& path)
|
||||||
{
|
{
|
||||||
std::unique_ptr<PathTraverser> pathTraverser;
|
std::unique_ptr<PathTraverser> pathTraverser;
|
||||||
if (!path.imageList.empty())
|
if (path.immich.enabled)
|
||||||
|
{
|
||||||
|
pathTraverser = std::unique_ptr<PathTraverser>(new ImmichPathTraverser(path.immich));
|
||||||
|
}
|
||||||
|
else if (!path.imageList.empty())
|
||||||
{
|
{
|
||||||
pathTraverser = std::unique_ptr<PathTraverser>(new ImageListPathTraverser(path.imageList));
|
pathTraverser = std::unique_ptr<PathTraverser>(new ImageListPathTraverser(path.imageList));
|
||||||
}
|
}
|
||||||
@@ -205,6 +219,11 @@ std::unique_ptr<ImageSelector> GetSelectorForApp(const AppConfig& appConfig)
|
|||||||
|
|
||||||
void ReloadConfigIfNeeded(AppConfig &appConfig, MainWindow &w, ImageSwitcher *switcher)
|
void ReloadConfigIfNeeded(AppConfig &appConfig, MainWindow &w, ImageSwitcher *switcher)
|
||||||
{
|
{
|
||||||
|
if(appConfig.configPath.empty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
QString jsonFile = getAppConfigFilePath(appConfig.configPath);
|
QString jsonFile = getAppConfigFilePath(appConfig.configPath);
|
||||||
QDir directory;
|
QDir directory;
|
||||||
if(!directory.exists(jsonFile))
|
if(!directory.exists(jsonFile))
|
||||||
@@ -228,6 +247,322 @@ 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("skipRetrySeconds") && obj["skipRetrySeconds"].isDouble())
|
||||||
|
{
|
||||||
|
config.skipRetrySeconds = (int)obj["skipRetrySeconds"].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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key == "skipretryseconds")
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
int parsed = value.toInt(&ok);
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
config.skipRetrySeconds = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
int main(int argc, char *argv[])
|
||||||
{
|
{
|
||||||
QApplication a(argc, argv);
|
QApplication a(argc, argv);
|
||||||
@@ -251,11 +586,8 @@ int main(int argc, char *argv[])
|
|||||||
Log( "Rotation Time: ", appConfig.rotationSeconds );
|
Log( "Rotation Time: ", appConfig.rotationSeconds );
|
||||||
Log( "Overlay input: ", appConfig.overlay );
|
Log( "Overlay input: ", appConfig.overlay );
|
||||||
|
|
||||||
QNetworkAccessManager webCtrl;
|
|
||||||
|
|
||||||
MainWindow w;
|
MainWindow w;
|
||||||
ConfigureWindowFromSettings(w, appConfig);
|
ConfigureWindowFromSettings(w, appConfig);
|
||||||
w.setNetworkManager(&webCtrl);
|
|
||||||
w.show();
|
w.show();
|
||||||
|
|
||||||
std::unique_ptr<ImageSelector> selector = GetSelectorForApp(appConfig);
|
std::unique_ptr<ImageSelector> selector = GetSelectorForApp(appConfig);
|
||||||
@@ -264,6 +596,50 @@ int main(int argc, char *argv[])
|
|||||||
w.setImageSwitcher(&switcher);
|
w.setImageSwitcher(&switcher);
|
||||||
std::function<void(MainWindow &w, ImageSwitcher *switcher)> reloader = [&appConfig](MainWindow &w, ImageSwitcher *switcher) { ReloadConfigIfNeeded(appConfig, w, switcher); };
|
std::function<void(MainWindow &w, ImageSwitcher *switcher)> reloader = [&appConfig](MainWindow &w, ImageSwitcher *switcher) { ReloadConfigIfNeeded(appConfig, w, switcher); };
|
||||||
switcher.setConfigFileReloader(reloader);
|
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();
|
switcher.start();
|
||||||
return a.exec();
|
return a.exec();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,7 @@
|
|||||||
#include <QGraphicsPixmapItem>
|
#include <QGraphicsPixmapItem>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QScreen>
|
#include <QScreen>
|
||||||
#include <QNetworkReply>
|
#include <QTransform>
|
||||||
#include <sstream>
|
|
||||||
|
|
||||||
MainWindow::MainWindow(QWidget *parent) :
|
MainWindow::MainWindow(QWidget *parent) :
|
||||||
QMainWindow(parent),
|
QMainWindow(parent),
|
||||||
@@ -119,8 +118,9 @@ bool MainWindow::event(QEvent* event)
|
|||||||
|
|
||||||
void MainWindow::resizeEvent(QResizeEvent* event)
|
void MainWindow::resizeEvent(QResizeEvent* event)
|
||||||
{
|
{
|
||||||
QMainWindow::resizeEvent(event);
|
QMainWindow::resizeEvent(event);
|
||||||
updateImage(true);
|
this->findChild<QLabel*>("image")->clear();
|
||||||
|
updateImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::checkWindowSize()
|
void MainWindow::checkWindowSize()
|
||||||
@@ -133,7 +133,7 @@ void MainWindow::checkWindowSize()
|
|||||||
{
|
{
|
||||||
Log("Resizing Window", screenSize.width(), "," , screenSize.height() );
|
Log("Resizing Window", screenSize.width(), "," , screenSize.height() );
|
||||||
setFixedSize(screenSize);
|
setFixedSize(screenSize);
|
||||||
updateImage(true);
|
updateImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imageAspectMatchesMonitor)
|
if (imageAspectMatchesMonitor)
|
||||||
@@ -161,67 +161,40 @@ void MainWindow::checkWindowSize()
|
|||||||
void MainWindow::setImage(const ImageDetails &imageDetails)
|
void MainWindow::setImage(const ImageDetails &imageDetails)
|
||||||
{
|
{
|
||||||
currentImage = imageDetails;
|
currentImage = imageDetails;
|
||||||
downloadedData.clear();
|
updateImage();
|
||||||
if (pendingReply)
|
|
||||||
{
|
|
||||||
pendingReply->abort();
|
|
||||||
}
|
|
||||||
updateImage(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::fileDownloaded(QNetworkReply* netReply)
|
void MainWindow::setDebugThumbnail(bool enabled)
|
||||||
{
|
{
|
||||||
if (netReply == pendingReply)
|
debugThumbnail = enabled;
|
||||||
{
|
|
||||||
pendingReply = nullptr;
|
|
||||||
QNetworkReply::NetworkError err = netReply->error();
|
|
||||||
if (err == QNetworkReply::NoError)
|
|
||||||
{
|
|
||||||
downloadedData = netReply->readAll();
|
|
||||||
netReply->deleteLater();
|
|
||||||
updateImage(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::updateImage(bool immediately)
|
void MainWindow::updateImage()
|
||||||
{
|
{
|
||||||
checkWindowSize();
|
checkWindowSize();
|
||||||
if (currentImage.filename == "")
|
if (currentImage.filename == "")
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (currentImage.filename.find("https://") != std::string::npos && downloadedData.isNull())
|
|
||||||
{
|
|
||||||
if (pendingReply == nullptr)
|
|
||||||
{
|
|
||||||
QNetworkRequest request(QUrl(currentImage.filename.c_str()));
|
|
||||||
pendingReply = networkManager->get(request);
|
|
||||||
connect( networkManager, SIGNAL (finished(QNetworkReply*)), this, SLOT (fileDownloaded(QNetworkReply*)));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
QLabel *label = this->findChild<QLabel*>("image");
|
QLabel *label = this->findChild<QLabel*>("image");
|
||||||
const QPixmap* oldImage = label->pixmap();
|
QPixmap oldImage = label->pixmap(Qt::ReturnByValue);
|
||||||
if (oldImage != NULL && !immediately)
|
if (!oldImage.isNull() && transitionSeconds > 0)
|
||||||
{
|
{
|
||||||
QPalette palette;
|
QPalette palette;
|
||||||
palette.setBrush(QPalette::Background, *oldImage);
|
palette.setBrush(QPalette::Window, oldImage);
|
||||||
this->setPalette(palette);
|
this->setPalette(palette);
|
||||||
}
|
}
|
||||||
|
|
||||||
QPixmap p;
|
QPixmap p;
|
||||||
if (!downloadedData.isNull())
|
p.load( currentImage.filename.c_str() );
|
||||||
|
if (p.isNull())
|
||||||
{
|
{
|
||||||
p.loadFromData(downloadedData);
|
Log("Error: failed to load image: ", currentImage.filename);
|
||||||
// BUG BUG have the selector update this?
|
warn("Failed to load image.");
|
||||||
currentImage.width = p.width();
|
if (switcher != nullptr)
|
||||||
currentImage.height = p.height();
|
{
|
||||||
currentImage.rotation = 0;
|
switcher->scheduleImageUpdate();
|
||||||
}
|
}
|
||||||
else
|
return;
|
||||||
{
|
|
||||||
p.load( currentImage.filename.c_str() );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Log("size:", p.width(), "x", p.height(), "(window:", width(), ",", height(), ")");
|
Log("size:", p.width(), "x", p.height(), "(window:", width(), ",", height(), ")");
|
||||||
@@ -237,7 +210,7 @@ void MainWindow::updateImage(bool immediately)
|
|||||||
drawText(background, overlay->getMarginTopRight(), overlay->getFontsizeTopRight(), overlay->getRenderedTopRight(currentImage.filename).c_str(), Qt::AlignTop|Qt::AlignRight);
|
drawText(background, overlay->getMarginTopRight(), overlay->getFontsizeTopRight(), overlay->getRenderedTopRight(currentImage.filename).c_str(), Qt::AlignTop|Qt::AlignRight);
|
||||||
drawText(background, overlay->getMarginBottomLeft(), overlay->getFontsizeBottomLeft(), overlay->getRenderedBottomLeft(currentImage.filename).c_str(), Qt::AlignBottom|Qt::AlignLeft);
|
drawText(background, overlay->getMarginBottomLeft(), overlay->getFontsizeBottomLeft(), overlay->getRenderedBottomLeft(currentImage.filename).c_str(), Qt::AlignBottom|Qt::AlignLeft);
|
||||||
drawText(background, overlay->getMarginBottomRight(), overlay->getFontsizeBottomRight(), overlay->getRenderedBottomRight(currentImage.filename).c_str(), Qt::AlignBottom|Qt::AlignRight);
|
drawText(background, overlay->getMarginBottomRight(), overlay->getFontsizeBottomRight(), overlay->getRenderedBottomRight(currentImage.filename).c_str(), Qt::AlignBottom|Qt::AlignRight);
|
||||||
if (ShouldLog())
|
if (debugThumbnail)
|
||||||
{
|
{
|
||||||
// draw a thumbnail version of the source image in the bottom left, to check for cropping issues
|
// draw a thumbnail version of the source image in the bottom left, to check for cropping issues
|
||||||
QPainter pt(&background);
|
QPainter pt(&background);
|
||||||
@@ -256,13 +229,13 @@ void MainWindow::updateImage(bool immediately)
|
|||||||
|
|
||||||
label->setPixmap(background);
|
label->setPixmap(background);
|
||||||
|
|
||||||
if (oldImage != NULL && !immediately)
|
if (!oldImage.isNull() && transitionSeconds > 0)
|
||||||
{
|
{
|
||||||
auto effect = new QGraphicsOpacityEffect(label);
|
auto effect = new QGraphicsOpacityEffect(label);
|
||||||
effect->setOpacity(0.0);
|
effect->setOpacity(0.0);
|
||||||
label->setGraphicsEffect(effect);
|
label->setGraphicsEffect(effect);
|
||||||
QPropertyAnimation* animation = new QPropertyAnimation(effect, "opacity");
|
QPropertyAnimation* animation = new QPropertyAnimation(effect, "opacity");
|
||||||
animation->setDuration(1000);
|
animation->setDuration(transitionSeconds*1000);
|
||||||
animation->setStartValue(0);
|
animation->setStartValue(0);
|
||||||
animation->setEndValue(1);
|
animation->setEndValue(1);
|
||||||
animation->start(QAbstractAnimation::DeleteWhenStopped);
|
animation->start(QAbstractAnimation::DeleteWhenStopped);
|
||||||
@@ -298,8 +271,10 @@ void MainWindow::setOverlay(std::unique_ptr<Overlay> &o)
|
|||||||
QPixmap MainWindow::getBlurredBackground(const QPixmap& originalSize, const QPixmap& scaled)
|
QPixmap MainWindow::getBlurredBackground(const QPixmap& originalSize, const QPixmap& scaled)
|
||||||
{
|
{
|
||||||
if (currentImage.options.fitAspectAxisToWindow) {
|
if (currentImage.options.fitAspectAxisToWindow) {
|
||||||
// our scaled version will just fill the whole screen, us it directly
|
// our scaled version will just fill the whole screen, use it directly
|
||||||
return scaled.copy();
|
//Log("Using scaled image");
|
||||||
|
QRect rect((scaled.width() - width())/2, 0, width(), height());
|
||||||
|
return scaled.copy(rect);
|
||||||
} else if (scaled.width() < width()) {
|
} else if (scaled.width() < width()) {
|
||||||
QPixmap background = blur(originalSize.scaledToWidth(width(), Qt::SmoothTransformation));
|
QPixmap background = blur(originalSize.scaledToWidth(width(), Qt::SmoothTransformation));
|
||||||
QRect rect(0, (background.height() - height())/2, width(), height());
|
QRect rect(0, (background.height() - height())/2, width(), height());
|
||||||
@@ -314,27 +289,41 @@ QPixmap MainWindow::getBlurredBackground(const QPixmap& originalSize, const QPix
|
|||||||
|
|
||||||
QPixmap MainWindow::getRotatedPixmap(const QPixmap& p)
|
QPixmap MainWindow::getRotatedPixmap(const QPixmap& p)
|
||||||
{
|
{
|
||||||
QMatrix matrix;
|
QTransform transform;
|
||||||
matrix.rotate(currentImage.rotation);
|
transform.rotate(currentImage.rotation);
|
||||||
return p.transformed(matrix);
|
return p.transformed(transform);
|
||||||
}
|
}
|
||||||
|
|
||||||
QPixmap MainWindow::getScaledPixmap(const QPixmap& p)
|
QPixmap MainWindow::getScaledPixmap(const QPixmap& p)
|
||||||
{
|
{
|
||||||
if (currentImage.options.fitAspectAxisToWindow)
|
if (currentImage.options.fitAspectAxisToWindow)
|
||||||
{
|
{
|
||||||
if (currentImage.aspect() == ImageAspect_Portrait)
|
bool stretchWidth = currentImage.aspect() == ImageAspect_Landscape;
|
||||||
|
bool stretchHeight = currentImage.aspect() == ImageAspect_Portrait;
|
||||||
|
// check the stretched image will naturally fill the screen for its aspect ratio
|
||||||
|
if (stretchHeight && (width() > ((double)height()/p.height())*p.width()))
|
||||||
|
{
|
||||||
|
// stretched via height won't fill the width, so stretch the other way
|
||||||
|
stretchHeight = false;
|
||||||
|
stretchWidth = true;
|
||||||
|
}
|
||||||
|
else if (stretchWidth && (height() > ((double)width()/p.width())*p.height()))
|
||||||
|
{
|
||||||
|
// stretched via width won't fill the width, so stretch the other way
|
||||||
|
stretchWidth = false;
|
||||||
|
stretchHeight = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stretchHeight)
|
||||||
{
|
{
|
||||||
// potrait mode, make height of image fit screen and crop top/bottom
|
// potrait mode, make height of image fit screen and crop top/bottom
|
||||||
QPixmap pTemp = p.scaledToHeight(height(), Qt::SmoothTransformation);
|
QPixmap pTemp = p.scaledToHeight(height(), Qt::SmoothTransformation);
|
||||||
return pTemp.copy(0,0,width(),height());
|
return pTemp.copy(0,0,width(),height());
|
||||||
}
|
}
|
||||||
else if (currentImage.aspect() == ImageAspect_Landscape)
|
else if (stretchWidth)
|
||||||
{
|
{
|
||||||
// landscape mode, make width of image fit screen and crop top/bottom
|
// landscape mode, make width of image fit screen and crop top/bottom
|
||||||
QPixmap pTemp = p.scaledToWidth(width(), Qt::SmoothTransformation);
|
QPixmap pTemp = p.scaledToWidth(width(), Qt::SmoothTransformation);
|
||||||
//int imageTempWidth = pTemp.width();
|
|
||||||
//int imageTempHeight = pTemp.height();
|
|
||||||
return pTemp.copy(0,0,width(),height());
|
return pTemp.copy(0,0,width(),height());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,12 +341,12 @@ void MainWindow::drawBackground(const QPixmap& originalSize, const QPixmap& scal
|
|||||||
QPixmap background = blur(originalSize.scaledToHeight(height()));
|
QPixmap background = blur(originalSize.scaledToHeight(height()));
|
||||||
QRect rect((background.width() - width())/2, 0, width(), height());
|
QRect rect((background.width() - width())/2, 0, width(), height());
|
||||||
background = background.copy(rect);
|
background = background.copy(rect);
|
||||||
palette.setBrush(QPalette::Background, background);
|
palette.setBrush(QPalette::Window, background);
|
||||||
} else {
|
} else {
|
||||||
QPixmap background = blur(originalSize.scaledToHeight(height()));
|
QPixmap background = blur(originalSize.scaledToHeight(height()));
|
||||||
QRect rect((background.width() - width())/2, 0, width(), height());
|
QRect rect((background.width() - width())/2, 0, width(), height());
|
||||||
background = background.copy(rect);
|
background = background.copy(rect);
|
||||||
palette.setBrush(QPalette::Background, background);
|
palette.setBrush(QPalette::Window, background);
|
||||||
}
|
}
|
||||||
this->setPalette(palette);
|
this->setPalette(palette);
|
||||||
}
|
}
|
||||||
@@ -393,6 +382,11 @@ void MainWindow::setOverlayHexRGB(QString overlayHexRGB)
|
|||||||
this->overlayHexRGB = overlayHexRGB;
|
this->overlayHexRGB = overlayHexRGB;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::setTransitionTime(unsigned int transitionSeconds)
|
||||||
|
{
|
||||||
|
this->transitionSeconds = transitionSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
void MainWindow::warn(std::string text)
|
void MainWindow::warn(std::string text)
|
||||||
{
|
{
|
||||||
QLabel *label = this->findChild<QLabel*>("image");
|
QLabel *label = this->findChild<QLabel*>("image");
|
||||||
@@ -418,8 +412,3 @@ const ImageDisplayOptions &MainWindow::getBaseOptions()
|
|||||||
{
|
{
|
||||||
return baseImageOptions;
|
return baseImageOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::setNetworkManager(QNetworkAccessManager *networkManagerIn)
|
|
||||||
{
|
|
||||||
networkManager = networkManagerIn;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
#include <QNetworkAccessManager>
|
|
||||||
#include "imagestructs.h"
|
#include "imagestructs.h"
|
||||||
#include "imageselector.h"
|
#include "imageselector.h"
|
||||||
|
|
||||||
@@ -28,17 +27,16 @@ public:
|
|||||||
void setImage(const ImageDetails &imageDetails);
|
void setImage(const ImageDetails &imageDetails);
|
||||||
void setBlurRadius(unsigned int blurRadius);
|
void setBlurRadius(unsigned int blurRadius);
|
||||||
void setBackgroundOpacity(unsigned int opacity);
|
void setBackgroundOpacity(unsigned int opacity);
|
||||||
|
void setTransitionTime(unsigned int transitionSeconds);
|
||||||
void warn(std::string text);
|
void warn(std::string text);
|
||||||
void setOverlay(std::unique_ptr<Overlay> &overlay);
|
void setOverlay(std::unique_ptr<Overlay> &overlay);
|
||||||
void setBaseOptions(const ImageDisplayOptions &baseOptionsIn);
|
void setBaseOptions(const ImageDisplayOptions &baseOptionsIn);
|
||||||
const ImageDisplayOptions &getBaseOptions();
|
const ImageDisplayOptions &getBaseOptions();
|
||||||
void setImageSwitcher(ImageSwitcher *switcherIn);
|
void setImageSwitcher(ImageSwitcher *switcherIn);
|
||||||
void setNetworkManager(QNetworkAccessManager *networkManagerIn);
|
|
||||||
void setOverlayHexRGB(QString overlayHexRGB);
|
void setOverlayHexRGB(QString overlayHexRGB);
|
||||||
|
void setDebugThumbnail(bool enabled);
|
||||||
public slots:
|
public slots:
|
||||||
void checkWindowSize();
|
void checkWindowSize();
|
||||||
private slots:
|
|
||||||
void fileDownloaded(QNetworkReply* pReply);
|
|
||||||
private:
|
private:
|
||||||
Ui::MainWindow *ui;
|
Ui::MainWindow *ui;
|
||||||
|
|
||||||
@@ -47,18 +45,17 @@ private:
|
|||||||
ImageDisplayOptions baseImageOptions;
|
ImageDisplayOptions baseImageOptions;
|
||||||
bool imageAspectMatchesMonitor = false;
|
bool imageAspectMatchesMonitor = false;
|
||||||
ImageDetails currentImage;
|
ImageDetails currentImage;
|
||||||
QByteArray downloadedData;
|
|
||||||
QNetworkAccessManager *networkManager = nullptr;
|
|
||||||
QNetworkReply *pendingReply = nullptr;
|
|
||||||
QSize lastScreenSize = {0,0};
|
QSize lastScreenSize = {0,0};
|
||||||
QString overlayHexRGB = "#FFFF";
|
QString overlayHexRGB = "#FFFF";
|
||||||
|
unsigned int transitionSeconds = 1;
|
||||||
|
bool debugThumbnail = false;
|
||||||
|
|
||||||
std::unique_ptr<Overlay> overlay;
|
std::unique_ptr<Overlay> overlay;
|
||||||
ImageSwitcher *switcher = nullptr;
|
ImageSwitcher *switcher = nullptr;
|
||||||
|
|
||||||
void drawText(QPixmap& image, int margin, int fontsize, QString text, int alignment);
|
void drawText(QPixmap& image, int margin, int fontsize, QString text, int alignment);
|
||||||
|
|
||||||
void updateImage(bool immediately);
|
void updateImage();
|
||||||
int getImageRotation();
|
int getImageRotation();
|
||||||
|
|
||||||
QPixmap getBlurredBackground(const QPixmap& originalSize, const QPixmap& scaled);
|
QPixmap getBlurredBackground(const QPixmap& originalSize, const QPixmap& scaled);
|
||||||
|
|||||||
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
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <QLocale>
|
#include <QLocale>
|
||||||
#include <QTime>
|
#include <QTime>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QFile>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
#include <QRegExp>
|
#include <QRegExp>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
@@ -162,9 +163,47 @@ QString Overlay::getExifDate(std::string filename) {
|
|||||||
dateTime = exif_entry_get_value(exifEntry, buf, sizeof(buf));
|
dateTime = exif_entry_get_value(exifEntry, buf, sizeof(buf));
|
||||||
}
|
}
|
||||||
exif_data_free(exifData);
|
exif_data_free(exifData);
|
||||||
QString exifDateFormat = "yyyy:MM:dd hh:mm:ss";
|
|
||||||
QDateTime exifDateTime = QDateTime::fromString(dateTime, exifDateFormat);
|
|
||||||
return QLocale::system().toString(exifDateTime);
|
|
||||||
}
|
}
|
||||||
return dateTime;
|
QString formatted = formatExifDateString(dateTime);
|
||||||
|
if (!formatted.isEmpty())
|
||||||
|
return formatted;
|
||||||
|
|
||||||
|
QString sidecar = readSidecarExifDate(QString::fromStdString(filename));
|
||||||
|
if (!sidecar.isEmpty())
|
||||||
|
return formatExifDateString(sidecar);
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Overlay::readSidecarExifDate(const QString &filePath)
|
||||||
|
{
|
||||||
|
QString sidecarPath = filePath + ".exif";
|
||||||
|
QFile file(sidecarPath);
|
||||||
|
if (!file.exists())
|
||||||
|
return "";
|
||||||
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||||
|
return "";
|
||||||
|
QString contents = QString::fromUtf8(file.readAll()).trimmed();
|
||||||
|
file.close();
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Overlay::formatExifDateString(const QString &raw)
|
||||||
|
{
|
||||||
|
QString trimmed = raw.trimmed();
|
||||||
|
if (trimmed.isEmpty())
|
||||||
|
return "";
|
||||||
|
|
||||||
|
QDateTime parsed = QDateTime::fromString(trimmed, Qt::ISODate);
|
||||||
|
if (!parsed.isValid())
|
||||||
|
parsed = QDateTime::fromString(trimmed, Qt::ISODateWithMs);
|
||||||
|
if (!parsed.isValid())
|
||||||
|
parsed = QDateTime::fromString(trimmed, "yyyy:MM:dd hh:mm:ss");
|
||||||
|
if (!parsed.isValid())
|
||||||
|
parsed = QDateTime::fromString(trimmed, "yyyy-MM-dd hh:mm:ss");
|
||||||
|
|
||||||
|
if (parsed.isValid())
|
||||||
|
return QLocale::system().toString(parsed);
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,5 +59,7 @@ class Overlay
|
|||||||
QString getBasename(std::string filename);
|
QString getBasename(std::string filename);
|
||||||
void parseInput();
|
void parseInput();
|
||||||
std::string renderString(QString overlayTemplate, std::string filename);
|
std::string renderString(QString overlayTemplate, std::string filename);
|
||||||
|
QString readSidecarExifDate(const QString &filePath);
|
||||||
|
QString formatExifDateString(const QString &raw);
|
||||||
};
|
};
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
#include <QDirIterator>
|
#include <QDirIterator>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QDomDocument>
|
|
||||||
#include <QDomAttr>
|
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <stdlib.h> /* srand, rand */
|
#include <stdlib.h> /* srand, rand */
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
#include <QNetworkAccessManager>
|
|
||||||
#include <QNetworkRequest>
|
|
||||||
#include <QNetworkReply>
|
|
||||||
#include "imageselector.h"
|
#include "imageselector.h"
|
||||||
|
|
||||||
static const QStringList supportedFormats={"jpg","jpeg","png","tif","tiff"};
|
static const QStringList supportedFormats={"jpg","jpeg","png","tif","tiff"};
|
||||||
@@ -20,6 +17,7 @@ class PathTraverser
|
|||||||
virtual QStringList getImages() const = 0;
|
virtual QStringList getImages() const = 0;
|
||||||
virtual const std::string getImagePath(const std::string image) 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 ImageDisplayOptions UpdateOptionsForImage(const std::string& filename, const ImageDisplayOptions& baseOptions) const = 0;
|
||||||
|
virtual bool shouldReloadImages() const { return false; }
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
const std::string path;
|
const std::string path;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
#
|
#
|
||||||
#-------------------------------------------------
|
#-------------------------------------------------
|
||||||
|
|
||||||
QT += core gui network xml
|
QT += core gui network
|
||||||
CONFIG += qt
|
CONFIG += qt
|
||||||
CONFIG += debug
|
CONFIG += debug
|
||||||
CONFIG += c++1z
|
CONFIG += c++1z
|
||||||
@@ -35,6 +35,9 @@ SOURCES += \
|
|||||||
mainwindow.cpp \
|
mainwindow.cpp \
|
||||||
imageswitcher.cpp \
|
imageswitcher.cpp \
|
||||||
pathtraverser.cpp \
|
pathtraverser.cpp \
|
||||||
|
immichpathtraverser.cpp \
|
||||||
|
immichclient.cpp \
|
||||||
|
mqttcontroller.cpp \
|
||||||
overlay.cpp \
|
overlay.cpp \
|
||||||
imageselector.cpp \
|
imageselector.cpp \
|
||||||
appconfig.cpp \
|
appconfig.cpp \
|
||||||
@@ -45,6 +48,9 @@ HEADERS += \
|
|||||||
mainwindow.h \
|
mainwindow.h \
|
||||||
imageselector.h \
|
imageselector.h \
|
||||||
pathtraverser.h \
|
pathtraverser.h \
|
||||||
|
immichpathtraverser.h \
|
||||||
|
immichclient.h \
|
||||||
|
mqttcontroller.h \
|
||||||
overlay.h \
|
overlay.h \
|
||||||
imageswitcher.h \
|
imageswitcher.h \
|
||||||
imagestructs.h \
|
imagestructs.h \
|
||||||
@@ -58,3 +64,4 @@ target.path = /usr/local/bin/
|
|||||||
INSTALLS += target
|
INSTALLS += target
|
||||||
|
|
||||||
unix|win32: LIBS += -lexif
|
unix|win32: LIBS += -lexif
|
||||||
|
unix|win32: LIBS += -lmosquitto
|
||||||
|
|||||||
Reference in New Issue
Block a user