Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc0ec58636 | |||
| 85ef89fa4b | |||
| e5f5934eb6 | |||
| 80286da166 | |||
| 6f2b8fe90c | |||
| bc672256fb | |||
| 86b19d5513 | |||
| 3958da1983 | |||
| 76c98f63dd | |||
| 7a75083cf3 | |||
| 806d701535 | |||
| 3644001dbc |
32
.drone.yml
32
.drone.yml
@@ -14,7 +14,7 @@ steps:
|
||||
- ls -la dist
|
||||
|
||||
- name: build-deb-armhf
|
||||
image: cache.coadcorp.com/library/buildpack-deps:jammy
|
||||
image: cache.coadcorp.com/library/buildpack-deps:bullseye
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
ARM_CFLAGS: -march=armv6 -mfpu=vfp -mfloat-abi=hard -marm
|
||||
@@ -22,14 +22,12 @@ steps:
|
||||
- dpkg --add-architecture armhf
|
||||
- |
|
||||
cat > /etc/apt/sources.list <<'EOF'
|
||||
deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy main restricted universe multiverse
|
||||
deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-updates main restricted universe multiverse
|
||||
deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-backports main restricted universe multiverse
|
||||
deb [arch=amd64] http://security.ubuntu.com/ubuntu jammy-security main restricted universe multiverse
|
||||
deb [arch=armhf] http://ports.ubuntu.com/ubuntu-ports jammy main restricted universe multiverse
|
||||
deb [arch=armhf] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted universe multiverse
|
||||
deb [arch=armhf] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse
|
||||
deb [arch=armhf] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted universe multiverse
|
||||
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
|
||||
@@ -37,7 +35,7 @@ steps:
|
||||
- ls -la dist
|
||||
|
||||
- name: build-deb-arm64
|
||||
image: cache.coadcorp.com/library/buildpack-deps:jammy
|
||||
image: cache.coadcorp.com/library/buildpack-deps:bullseye
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
ARM64_CFLAGS: -march=armv8-a
|
||||
@@ -45,14 +43,12 @@ steps:
|
||||
- dpkg --add-architecture arm64
|
||||
- |
|
||||
cat > /etc/apt/sources.list <<'EOF'
|
||||
deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy main restricted universe multiverse
|
||||
deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-updates main restricted universe multiverse
|
||||
deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-backports main restricted universe multiverse
|
||||
deb [arch=amd64] http://security.ubuntu.com/ubuntu jammy-security main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted universe multiverse
|
||||
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
|
||||
|
||||
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
|
||||
21
INSTALL.md
21
INSTALL.md
@@ -6,6 +6,8 @@ This project depends on the following dynamically linked libraries:
|
||||
|
||||
* qt5
|
||||
* libexif
|
||||
* qt5-image-formats-plugins
|
||||
* libmosquitto1
|
||||
|
||||
### OSX
|
||||
|
||||
@@ -13,28 +15,29 @@ This project depends on the following dynamically linked libraries:
|
||||
brew install qt libexif
|
||||
```
|
||||
|
||||
### Raspbian Stretch
|
||||
### Debian / Ubuntu / Raspberry Pi OS (runtime dependencies)
|
||||
|
||||
```
|
||||
brew install qt5 libexif12
|
||||
sudo apt-get install -y qt5-image-formats-plugins libmosquitto1
|
||||
```
|
||||
|
||||
## Extract binaries
|
||||
### Raspberry Pi Zero / Raspbian (additional image format support)
|
||||
|
||||
```
|
||||
tar xf slide_<arch>_<version>.tar.gz
|
||||
sudo apt-get install -y qt5-image-formats-plugins libmosquitto1 libmng1
|
||||
```
|
||||
|
||||
## Move binary to executable folder
|
||||
## Install .deb package
|
||||
|
||||
### OSX
|
||||
Use apt so dependencies are resolved automatically:
|
||||
|
||||
```
|
||||
mv slide_<version>/slide.app/Contents/MacOS/slide /usr/local/bin/
|
||||
sudo apt-get install ./slide_<version>_<arch>.deb
|
||||
```
|
||||
|
||||
### Linux
|
||||
If you must use dpkg, install runtime dependencies first:
|
||||
|
||||
```
|
||||
mv slide_<version>/slide /usr/bin/
|
||||
sudo apt-get install -y qt5-image-formats-plugins libmosquitto1
|
||||
sudo dpkg -i slide_<version>_<arch>.deb
|
||||
```
|
||||
|
||||
63
README.md
63
README.md
@@ -23,7 +23,7 @@ slide [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color overlay_
|
||||
|
||||
* `image_folder`: where to search for images (.jpg files)
|
||||
* `-i imageFile,...`: comma delimited list of full paths to image files to display
|
||||
* `-c path_to_config_json`: the path to an optional slide.options.json file containing configuration parameters
|
||||
* `-c path_to_config_json`: path to a JSON config file, or a directory containing `slide.options.json`
|
||||
* `-t` how many seconds to display each picture for
|
||||
* `-r` for recursive traversal of `image_folder`
|
||||
* `-s` for shuffle instead of random image rotation
|
||||
@@ -34,7 +34,7 @@ slide [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color overlay_
|
||||
* `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
|
||||
* `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.
|
||||
* `-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.
|
||||
@@ -55,7 +55,7 @@ slide [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color overlay_
|
||||
To exit the application, press escape. If you're using a touch display, touch all 4 corners at the same time.
|
||||
|
||||
## Configuration file
|
||||
Slide supports loading configuration from a JSON formatted file called `slide.options.json`. This file can be specified by the `-c` command line option, we will also attempt to read `~/.config/slide/slide.options.json` and `/etc/slide/slide.options.json` in that order. The first file to load is used and its options will override command line parameters.
|
||||
Slide supports loading configuration from a JSON formatted file called `slide.options.json`. This file can be specified by the `-c` command line option (file path or directory), and we will also attempt to read `~/.config/slide/slide.options.json` and `/etc/slide/slide.options.json` in that order. The first file to load is used and its options will override command line parameters.
|
||||
The file format is:
|
||||
```
|
||||
{
|
||||
@@ -69,6 +69,7 @@ The file format is:
|
||||
"rotationSeconds" : 300,
|
||||
"opacity" : 200,
|
||||
"debug" : false,
|
||||
"debugThumbnail" : false,
|
||||
"scheduler" : [
|
||||
{
|
||||
"exclusive" : true,
|
||||
@@ -117,6 +118,7 @@ Supported keys and values in the JSON configuration are:
|
||||
* `opacity` : the same as the command line `-o` argument
|
||||
* `blur` : the same as the command line `-b` argument
|
||||
* `debug` : set to true to enable verbose output from the program
|
||||
* `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.
|
||||
@@ -136,6 +138,7 @@ Example:
|
||||
"host": "mqtt.local",
|
||||
"port": 1883,
|
||||
"topic": "slide/control",
|
||||
"immichTopic": "slide/immich",
|
||||
"clientId": "slide-frame",
|
||||
"username": "slide",
|
||||
"password": "secret",
|
||||
@@ -151,10 +154,44 @@ Commands:
|
||||
* `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.
|
||||
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):
|
||||
```
|
||||
@@ -167,6 +204,9 @@ Example (single source):
|
||||
"order": "desc",
|
||||
"pageSize": 200,
|
||||
"maxAssets": 1000,
|
||||
"refreshSeconds": 300,
|
||||
"skipRetrySeconds": 3600,
|
||||
"skipTags": ["frame-ignore"],
|
||||
"cachePath": "~/.cache/slide/immich",
|
||||
"cacheMaxMB": 512,
|
||||
"includeArchived": false
|
||||
@@ -194,14 +234,25 @@ Example (scheduler entry):
|
||||
Immich settings:
|
||||
* `url`: base Immich server URL (the integration appends `/api` automatically if missing).
|
||||
* `apiKey`: Immich API key (needs `asset.view`, and `asset.download` if `size` is `original`).
|
||||
* `userId`: optional user id to retrieve all assets owned by that user via the assets endpoint (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"]`).
|
||||
* `skipTags`: optional list of tag names to exclude (case-insensitive).
|
||||
* `skipTagIds`: optional list of tag IDs to exclude (case-insensitive).
|
||||
* `size`: `"fullsize"`, `"preview"`, `"thumbnail"`, or `"original"` (original uses the download endpoint).
|
||||
* `order`: `"asc"` or `"desc"` ordering for asset search.
|
||||
|
||||
Note: tag filtering requires Immich to include tag data in search results. If your server doesn’t return tags on `/search/metadata`, `skipTags`/`skipTagIds` won’t apply.
|
||||
* `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
|
||||
@@ -235,6 +286,10 @@ See the `Configuration File` section for details of each setting.
|
||||
* libexif
|
||||
* libmosquitto-dev
|
||||
|
||||
## Home Assistant
|
||||
|
||||
See `doc/homeassistant.md` for a ready-to-use MQTT dashboard and scripts.
|
||||
|
||||
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.
|
||||
@@ -66,4 +66,4 @@ Description: Lightweight slideshow for photo frames
|
||||
Simple, lightweight slideshow designed for low power devices.
|
||||
EOF
|
||||
|
||||
dpkg-deb --build "$STAGE_DIR" "$DIST_DIR/${PACKAGE_NAME}_${VERSION}_${ARCH}.deb"
|
||||
dpkg-deb --build -Zgzip "$STAGE_DIR" "$DIST_DIR/${PACKAGE_NAME}_${VERSION}_${ARCH}.deb"
|
||||
|
||||
@@ -71,6 +71,32 @@ std::vector<std::string> ParseJSONStrings(QJsonObject jsonDoc, const char *key)
|
||||
return values;
|
||||
}
|
||||
|
||||
std::vector<std::string> NormalizeExtensions(const std::vector<std::string> &values) {
|
||||
std::vector<std::string> normalized;
|
||||
for (const auto &value : values)
|
||||
{
|
||||
std::string s = value;
|
||||
for (auto &c : s)
|
||||
c = static_cast<char>(::tolower(c));
|
||||
if (!s.empty())
|
||||
normalized.push_back(s);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
std::vector<std::string> NormalizeStrings(const std::vector<std::string> &values) {
|
||||
std::vector<std::string> normalized;
|
||||
for (const auto &value : values)
|
||||
{
|
||||
std::string s = value;
|
||||
for (auto &c : s)
|
||||
c = static_cast<char>(::tolower(c));
|
||||
if (!s.empty())
|
||||
normalized.push_back(s);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) {
|
||||
ImmichConfig config;
|
||||
|
||||
@@ -81,6 +107,13 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) {
|
||||
if(!apiKey.empty())
|
||||
config.apiKey = apiKey;
|
||||
|
||||
std::string userId = ParseJSONString(immichJson, "userId");
|
||||
if(!userId.empty())
|
||||
config.userId = userId;
|
||||
std::string ownerId = ParseJSONString(immichJson, "ownerId");
|
||||
if(!ownerId.empty() && config.userId.empty())
|
||||
config.userId = ownerId;
|
||||
|
||||
std::string size = ParseJSONString(immichJson, "size");
|
||||
if(!size.empty())
|
||||
config.size = size;
|
||||
@@ -101,8 +134,42 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) {
|
||||
if(albumIds.size() > 0)
|
||||
config.albumIds = albumIds;
|
||||
|
||||
std::string personId = ParseJSONString(immichJson, "personId");
|
||||
if(!personId.empty())
|
||||
config.personIds.push_back(personId);
|
||||
|
||||
std::vector<std::string> personIds = ParseJSONStrings(immichJson, "personIds");
|
||||
if(personIds.size() > 0)
|
||||
config.personIds = personIds;
|
||||
|
||||
std::vector<std::string> allowedExtensions = ParseJSONStrings(immichJson, "extensions");
|
||||
if(allowedExtensions.size() == 0)
|
||||
allowedExtensions = ParseJSONStrings(immichJson, "allowedExtensions");
|
||||
if(allowedExtensions.size() > 0)
|
||||
config.allowedExtensions = NormalizeExtensions(allowedExtensions);
|
||||
|
||||
std::vector<std::string> skipTags = ParseJSONStrings(immichJson, "skipTags");
|
||||
if(skipTags.size() == 0)
|
||||
skipTags = ParseJSONStrings(immichJson, "excludeTags");
|
||||
std::string skipTag = ParseJSONString(immichJson, "skipTag");
|
||||
if(!skipTag.empty())
|
||||
skipTags.push_back(skipTag);
|
||||
if(skipTags.size() > 0)
|
||||
config.skipTags = NormalizeStrings(skipTags);
|
||||
|
||||
std::vector<std::string> skipTagIds = ParseJSONStrings(immichJson, "skipTagIds");
|
||||
std::string skipTagId = ParseJSONString(immichJson, "skipTagId");
|
||||
if(!skipTagId.empty())
|
||||
skipTagIds.push_back(skipTagId);
|
||||
if(skipTagIds.size() > 0)
|
||||
config.skipTagIds = NormalizeStrings(skipTagIds);
|
||||
|
||||
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");
|
||||
|
||||
@@ -123,6 +190,10 @@ MqttConfig ParseMqttConfigObject(QJsonObject mqttJson) {
|
||||
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;
|
||||
@@ -139,6 +210,9 @@ MqttConfig ParseMqttConfigObject(QJsonObject mqttJson) {
|
||||
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;
|
||||
|
||||
@@ -233,28 +307,36 @@ QString getAppConfigFilePath(const std::string &configPath) {
|
||||
std::string systemConfigFolder = "/etc/slide";
|
||||
QString baseConfigFilename("slide.options.json");
|
||||
|
||||
QDir directory(userConfigFolder.c_str());
|
||||
QString jsonFile = "";
|
||||
if (!configPath.empty())
|
||||
{
|
||||
directory.setPath(configPath.c_str());
|
||||
jsonFile = directory.filePath(baseConfigFilename);
|
||||
}
|
||||
if(!directory.exists(jsonFile))
|
||||
QFileInfo configInfo(QString::fromStdString(configPath));
|
||||
if (configInfo.exists() && configInfo.isFile())
|
||||
{
|
||||
directory.setPath(userConfigFolder.c_str());
|
||||
jsonFile = directory.filePath(baseConfigFilename);
|
||||
}
|
||||
if(!directory.exists(jsonFile))
|
||||
{
|
||||
directory.setPath(systemConfigFolder.c_str());
|
||||
jsonFile = directory.filePath(baseConfigFilename);
|
||||
return configInfo.absoluteFilePath();
|
||||
}
|
||||
|
||||
QDir directory(configInfo.isDir() ? configInfo.absoluteFilePath()
|
||||
: QString::fromStdString(configPath));
|
||||
QString jsonFile = directory.filePath(baseConfigFilename);
|
||||
if (directory.exists(jsonFile))
|
||||
{
|
||||
return jsonFile;
|
||||
}
|
||||
}
|
||||
|
||||
QDir userDir(userConfigFolder.c_str());
|
||||
QString userFile = userDir.filePath(baseConfigFilename);
|
||||
if (userDir.exists(userFile))
|
||||
{
|
||||
return userFile;
|
||||
}
|
||||
|
||||
QDir systemDir(systemConfigFolder.c_str());
|
||||
QString systemFile = systemDir.filePath(baseConfigFilename);
|
||||
if (systemDir.exists(systemFile))
|
||||
{
|
||||
return systemFile;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -353,6 +435,7 @@ AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) {
|
||||
SetJSONBool(baseShuffle, jsonDoc, "shuffle");
|
||||
SetJSONBool(baseSorted, jsonDoc, "sorted");
|
||||
SetJSONBool(loadedConfig.debugMode, jsonDoc, "debug");
|
||||
SetJSONBool(loadedConfig.debugThumbnail, jsonDoc, "debugThumbnail");
|
||||
|
||||
std::string overlayString = ParseJSONString(jsonDoc, "overlay");
|
||||
if(!overlayString.empty())
|
||||
|
||||
@@ -10,11 +10,18 @@ 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::vector<std::string> skipTags;
|
||||
std::vector<std::string> skipTagIds;
|
||||
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;
|
||||
@@ -25,6 +32,8 @@ struct ImmichConfig {
|
||||
return false;
|
||||
if (url != b.url || apiKey != b.apiKey)
|
||||
return false;
|
||||
if (userId != b.userId)
|
||||
return false;
|
||||
if (size != b.size || order != b.order)
|
||||
return false;
|
||||
if (pageSize != b.pageSize || maxAssets != b.maxAssets)
|
||||
@@ -40,6 +49,38 @@ struct ImmichConfig {
|
||||
if (albumIds[i] != b.albumIds[i])
|
||||
return false;
|
||||
}
|
||||
if (personIds.size() != b.personIds.size())
|
||||
return false;
|
||||
for (size_t i = 0; i < personIds.size(); ++i)
|
||||
{
|
||||
if (personIds[i] != b.personIds[i])
|
||||
return false;
|
||||
}
|
||||
if (allowedExtensions.size() != b.allowedExtensions.size())
|
||||
return false;
|
||||
for (size_t i = 0; i < allowedExtensions.size(); ++i)
|
||||
{
|
||||
if (allowedExtensions[i] != b.allowedExtensions[i])
|
||||
return false;
|
||||
}
|
||||
if (skipTags.size() != b.skipTags.size())
|
||||
return false;
|
||||
for (size_t i = 0; i < skipTags.size(); ++i)
|
||||
{
|
||||
if (skipTags[i] != b.skipTags[i])
|
||||
return false;
|
||||
}
|
||||
if (skipTagIds.size() != b.skipTagIds.size())
|
||||
return false;
|
||||
for (size_t i = 0; i < skipTagIds.size(); ++i)
|
||||
{
|
||||
if (skipTagIds[i] != b.skipTagIds[i])
|
||||
return false;
|
||||
}
|
||||
if (refreshSeconds != b.refreshSeconds)
|
||||
return false;
|
||||
if (skipRetrySeconds != b.skipRetrySeconds)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -54,6 +95,7 @@ struct MqttConfig {
|
||||
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 = "";
|
||||
@@ -66,6 +108,7 @@ struct MqttConfig {
|
||||
host == b.host &&
|
||||
port == b.port &&
|
||||
topic == b.topic &&
|
||||
immichTopic == b.immichTopic &&
|
||||
clientId == b.clientId &&
|
||||
username == b.username &&
|
||||
password == b.password &&
|
||||
@@ -139,6 +182,7 @@ struct AppConfig : public Config {
|
||||
MqttConfig mqtt;
|
||||
|
||||
bool debugMode = false;
|
||||
bool debugThumbnail = false;
|
||||
|
||||
static const std::string valid_aspects;
|
||||
public:
|
||||
|
||||
@@ -242,7 +242,7 @@ const ImageDetails ShuffleImageSelector::getNextImage(const ImageDisplayOptions
|
||||
|
||||
void ShuffleImageSelector::reloadImagesIfNoneLeft()
|
||||
{
|
||||
if (images.size() == 0 || current_image_shuffle >= images.size())
|
||||
if (images.size() == 0 || current_image_shuffle >= images.size() || pathTraverser->shouldReloadImages())
|
||||
{
|
||||
current_image_shuffle = 0;
|
||||
images = pathTraverser->getImages();
|
||||
@@ -305,7 +305,7 @@ const ImageDetails SortedImageSelector::getNextImage(const ImageDisplayOptions &
|
||||
|
||||
void SortedImageSelector::reloadImagesIfEmpty()
|
||||
{
|
||||
if (images.size() == 0)
|
||||
if (images.size() == 0 || pathTraverser->shouldReloadImages())
|
||||
{
|
||||
images = pathTraverser->getImages();
|
||||
std::sort(images.begin(), images.end());
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <QEventLoop>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QBuffer>
|
||||
#include <QImageReader>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
@@ -12,10 +14,56 @@
|
||||
#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)
|
||||
@@ -93,22 +141,51 @@ QByteArray ImmichClient::getBytes(const QUrl &url, QString *contentType, int tim
|
||||
|
||||
QVector<ImmichAsset> ImmichClient::fetchAssets()
|
||||
{
|
||||
QVector<ImmichAsset> assets;
|
||||
if (!config.enabled)
|
||||
{
|
||||
Log("Immich config is missing url or apiKey.");
|
||||
return assets;
|
||||
return QVector<ImmichAsset>();
|
||||
}
|
||||
|
||||
if (!config.userId.empty() && config.albumIds.empty() && config.personIds.empty())
|
||||
return 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;
|
||||
|
||||
while (true)
|
||||
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(),
|
||||
", skipTags=", config.skipTags.size(),
|
||||
", skipTagIds=", config.skipTagIds.size());
|
||||
}
|
||||
|
||||
auto fetchPage = [&](int pageIndex, const QString &userKey) -> QByteArray {
|
||||
QJsonObject body;
|
||||
body["page"] = page;
|
||||
body["page"] = pageIndex;
|
||||
body["size"] = pageSize;
|
||||
body["type"] = "IMAGE";
|
||||
body["order"] = QString::fromStdString(config.order);
|
||||
@@ -121,19 +198,77 @@ QVector<ImmichAsset> ImmichClient::fetchAssets()
|
||||
ids.append(QString::fromStdString(id));
|
||||
body["albumIds"] = ids;
|
||||
}
|
||||
if (config.personIds.size() > 0)
|
||||
{
|
||||
QJsonArray ids;
|
||||
for (const auto &id : config.personIds)
|
||||
ids.append(QString::fromStdString(id));
|
||||
body["personIds"] = ids;
|
||||
}
|
||||
if (!config.userId.empty() && !userKey.isEmpty())
|
||||
{
|
||||
body[userKey] = QString::fromStdString(config.userId);
|
||||
}
|
||||
return postJson(apiUrl("/search/metadata"), body, nullptr, kMetadataTimeoutMs);
|
||||
};
|
||||
|
||||
QByteArray response = postJson(apiUrl("/search/metadata"), body, nullptr, kMetadataTimeoutMs);
|
||||
if (response.isEmpty())
|
||||
break;
|
||||
|
||||
auto parseSearch = [&](const QByteArray &response, QJsonArray &outItems, int &outTotal) -> bool {
|
||||
QJsonDocument doc = QJsonDocument::fromJson(response);
|
||||
if (!doc.isObject())
|
||||
break;
|
||||
|
||||
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();
|
||||
QJsonArray items = assetsObj["items"].toArray();
|
||||
int total = assetsObj["total"].toInt();
|
||||
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)
|
||||
@@ -154,6 +289,17 @@ QVector<ImmichAsset> ImmichClient::fetchAssets()
|
||||
ImmichAsset asset;
|
||||
asset.id = id;
|
||||
asset.originalFileName = item["originalFileName"].toString();
|
||||
asset.exifDateTime = ExtractAssetDateTime(item);
|
||||
if (shouldSkipByTag(item))
|
||||
{
|
||||
Log("Immich skip by tag: ", asset.originalFileName.toStdString());
|
||||
continue;
|
||||
}
|
||||
if (!extensionAllowed(asset.originalFileName))
|
||||
{
|
||||
Log("Immich skip by extension: ", asset.originalFileName.toStdString());
|
||||
continue;
|
||||
}
|
||||
assets.append(asset);
|
||||
if (maxAssets > 0 && assets.size() >= maxAssets)
|
||||
return assets;
|
||||
@@ -167,12 +313,196 @@ QVector<ImmichAsset> ImmichClient::fetchAssets()
|
||||
return assets;
|
||||
}
|
||||
|
||||
QVector<ImmichAsset> ImmichClient::fetchAssetsByUser()
|
||||
{
|
||||
QVector<ImmichAsset> assets;
|
||||
|
||||
int pageSize = config.pageSize > 0 ? config.pageSize : 200;
|
||||
int maxAssets = config.maxAssets;
|
||||
int skip = 0;
|
||||
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::shouldSkipByTag(const QJsonObject &item) const
|
||||
{
|
||||
if (config.skipTags.empty() && config.skipTagIds.empty())
|
||||
return false;
|
||||
|
||||
auto matchesName = [&](const QString &name) -> bool {
|
||||
if (name.isEmpty())
|
||||
return false;
|
||||
QString lowered = name.toLower();
|
||||
for (const auto &tag : config.skipTags)
|
||||
{
|
||||
if (lowered == QString::fromStdString(tag))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
auto matchesId = [&](const QString &id) -> bool {
|
||||
if (id.isEmpty())
|
||||
return false;
|
||||
QString lowered = id.toLower();
|
||||
for (const auto &tagId : config.skipTagIds)
|
||||
{
|
||||
if (lowered == QString::fromStdString(tagId))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (item.contains("tagIds") && item["tagIds"].isArray())
|
||||
{
|
||||
QJsonArray tagIds = item["tagIds"].toArray();
|
||||
for (const auto &value : tagIds)
|
||||
{
|
||||
if (value.isString() && matchesId(value.toString()))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.contains("tags") && item["tags"].isArray())
|
||||
{
|
||||
QJsonArray tags = item["tags"].toArray();
|
||||
for (const auto &value : tags)
|
||||
{
|
||||
if (value.isString())
|
||||
{
|
||||
if (matchesName(value.toString()))
|
||||
return true;
|
||||
continue;
|
||||
}
|
||||
if (!value.isObject())
|
||||
continue;
|
||||
QJsonObject tagObj = value.toObject();
|
||||
if (matchesId(tagObj["id"].toString()))
|
||||
return true;
|
||||
if (matchesName(tagObj["name"].toString()))
|
||||
return true;
|
||||
if (matchesName(tagObj["value"].toString()))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ImmichClient::downloadAsset(const QString &assetId, QByteArray &data, QString &contentType)
|
||||
{
|
||||
return downloadAssetWithSize(assetId, QString::fromStdString(config.size), data, contentType);
|
||||
}
|
||||
|
||||
bool ImmichClient::downloadAssetWithSize(const QString &assetId, const QString &sizeOverride, QByteArray &data, QString &contentType)
|
||||
{
|
||||
if (!config.enabled)
|
||||
return false;
|
||||
|
||||
QString size = QString::fromStdString(config.size).trimmed().toLower();
|
||||
QString size = sizeOverride.trimmed().toLower();
|
||||
if (size.isEmpty())
|
||||
size = "fullsize";
|
||||
|
||||
@@ -194,6 +524,7 @@ bool ImmichClient::downloadAsset(const QString &assetId, QByteArray &data, QStri
|
||||
url.setQuery(query);
|
||||
}
|
||||
|
||||
Log("Immich download asset ", assetId.toStdString(), " (", size.toStdString(), ")");
|
||||
QByteArray payload = getBytes(url, &contentType, kAssetTimeoutMs);
|
||||
if (payload.isEmpty())
|
||||
return false;
|
||||
@@ -208,6 +539,7 @@ ImmichAssetCache::ImmichAssetCache(const ImmichConfig &config)
|
||||
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
|
||||
@@ -236,7 +568,20 @@ QString ImmichAssetCache::findExisting(const QString &assetId) const
|
||||
QStringList matches = dir.entryList(QStringList() << (assetId + "_*"), QDir::Files, QDir::Time);
|
||||
if (matches.isEmpty())
|
||||
return "";
|
||||
return dir.filePath(matches.first());
|
||||
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
|
||||
@@ -297,15 +642,104 @@ QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &a
|
||||
|
||||
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 safeName = sanitizeFileName(assetName);
|
||||
QString extension = extensionForContentType(contentType);
|
||||
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 baseName = QFileInfo(assetName).completeBaseName();
|
||||
if (baseName.isEmpty())
|
||||
baseName = assetName;
|
||||
QString safeName = sanitizeFileName(baseName);
|
||||
QString extension = detectedExt;
|
||||
if (extension.isEmpty())
|
||||
extension = extensionForContentType(contentType);
|
||||
if (extension == "img")
|
||||
{
|
||||
QString suffix = QFileInfo(assetName).suffix().toLower();
|
||||
if (!suffix.isEmpty())
|
||||
extension = suffix;
|
||||
}
|
||||
QString filename = assetId + "_" + safeName + "." + extension;
|
||||
|
||||
QDir dir(cacheDirPath);
|
||||
@@ -318,6 +752,8 @@ QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &a
|
||||
if (!file.commit())
|
||||
return "";
|
||||
|
||||
Log("Immich cached asset: ", assetId.toStdString(), " -> ", filePath.toStdString());
|
||||
|
||||
if (cacheMaxBytes > 0)
|
||||
{
|
||||
if (!cacheSizeKnown)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
struct ImmichAsset {
|
||||
QString id;
|
||||
QString originalFileName;
|
||||
QString exifDateTime;
|
||||
};
|
||||
|
||||
class ImmichClient {
|
||||
@@ -21,8 +22,13 @@ class ImmichClient {
|
||||
explicit ImmichClient(const ImmichConfig &config);
|
||||
QVector<ImmichAsset> fetchAssets();
|
||||
bool downloadAsset(const QString &assetId, QByteArray &data, QString &contentType);
|
||||
bool downloadAssetWithSize(const QString &assetId, const QString &sizeOverride, QByteArray &data, QString &contentType);
|
||||
|
||||
private:
|
||||
QVector<ImmichAsset> fetchAssetsBySearch();
|
||||
QVector<ImmichAsset> fetchAssetsByUser();
|
||||
bool shouldSkipByTag(const QJsonObject &item) const;
|
||||
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);
|
||||
@@ -51,6 +57,7 @@ class ImmichAssetCache {
|
||||
qint64 cacheMaxBytes = 0;
|
||||
bool cacheSizeKnown = false;
|
||||
qint64 cacheSizeBytes = 0;
|
||||
int skipRetrySeconds = 0;
|
||||
};
|
||||
|
||||
#endif // IMMICHCLIENT_H
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#include "immichpathtraverser.h"
|
||||
#include "logger.h"
|
||||
#include <QDateTime>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QSaveFile>
|
||||
|
||||
ImmichPathTraverser::ImmichPathTraverser(const ImmichConfig &configIn)
|
||||
: PathTraverser(""),
|
||||
@@ -16,6 +20,7 @@ void ImmichPathTraverser::loadAssets()
|
||||
{
|
||||
assetIds.clear();
|
||||
assetNames.clear();
|
||||
assetDateTimes.clear();
|
||||
QVector<ImmichAsset> assets = client.fetchAssets();
|
||||
for (const auto &asset : assets)
|
||||
{
|
||||
@@ -23,12 +28,20 @@ void ImmichPathTraverser::loadAssets()
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -37,6 +50,12 @@ const std::string ImmichPathTraverser::getImagePath(const std::string image) con
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -45,3 +64,45 @@ ImageDisplayOptions ImmichPathTraverser::UpdateOptionsForImage(const std::string
|
||||
Q_UNUSED(filename);
|
||||
return options;
|
||||
}
|
||||
|
||||
bool ImmichPathTraverser::refreshDue() const
|
||||
{
|
||||
if (config.refreshSeconds <= 0)
|
||||
return false;
|
||||
if (!lastRefresh.isValid())
|
||||
return true;
|
||||
return lastRefresh.secsTo(QDateTime::currentDateTime()) >= config.refreshSeconds;
|
||||
}
|
||||
|
||||
bool ImmichPathTraverser::shouldReloadImages() const
|
||||
{
|
||||
return refreshDue();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "immichclient.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QDateTime>
|
||||
|
||||
class ImmichPathTraverser : public PathTraverser
|
||||
{
|
||||
@@ -14,15 +15,20 @@ class ImmichPathTraverser : public PathTraverser
|
||||
QStringList getImages() const override;
|
||||
virtual const std::string getImagePath(const std::string image) const override;
|
||||
virtual ImageDisplayOptions UpdateOptionsForImage(const std::string& filename, const ImageDisplayOptions& options) const override;
|
||||
bool shouldReloadImages() const override;
|
||||
|
||||
private:
|
||||
void loadAssets();
|
||||
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
|
||||
|
||||
346
src/main.cpp
346
src/main.cpp
@@ -19,6 +19,9 @@
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <memory>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
|
||||
void usage(std::string programName) {
|
||||
std::cerr << "Usage: " << programName << " [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color #rrggbb] [-a aspect('l','p','a', 'm')] [-o background_opacity(0..255)] [-b blur_radius] -p image_folder [-r] [-s] [-S] [-v] [--verbose] [--stretch] [-c config_file_path]" << std::endl;
|
||||
@@ -153,6 +156,7 @@ void ConfigureWindowFromSettings(MainWindow &w, const AppConfig &appConfig)
|
||||
std::unique_ptr<Overlay> o = std::unique_ptr<Overlay>(new Overlay(appConfig.overlay));
|
||||
w.setOverlay(o);
|
||||
}
|
||||
w.setDebugThumbnail(appConfig.debugThumbnail);
|
||||
w.setBaseOptions(appConfig.baseDisplayOptions);
|
||||
}
|
||||
|
||||
@@ -243,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[])
|
||||
{
|
||||
QApplication a(argc, argv);
|
||||
@@ -292,6 +612,32 @@ int main(int argc, char *argv[])
|
||||
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();
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include <QGraphicsPixmapItem>
|
||||
#include <QApplication>
|
||||
#include <QScreen>
|
||||
#include <QTransform>
|
||||
|
||||
MainWindow::MainWindow(QWidget *parent) :
|
||||
QMainWindow(parent),
|
||||
@@ -163,6 +164,11 @@ void MainWindow::setImage(const ImageDetails &imageDetails)
|
||||
updateImage();
|
||||
}
|
||||
|
||||
void MainWindow::setDebugThumbnail(bool enabled)
|
||||
{
|
||||
debugThumbnail = enabled;
|
||||
}
|
||||
|
||||
void MainWindow::updateImage()
|
||||
{
|
||||
checkWindowSize();
|
||||
@@ -170,16 +176,26 @@ void MainWindow::updateImage()
|
||||
return;
|
||||
|
||||
QLabel *label = this->findChild<QLabel*>("image");
|
||||
const QPixmap* oldImage = label->pixmap();
|
||||
if (oldImage != NULL && transitionSeconds > 0)
|
||||
QPixmap oldImage = label->pixmap(Qt::ReturnByValue);
|
||||
if (!oldImage.isNull() && transitionSeconds > 0)
|
||||
{
|
||||
QPalette palette;
|
||||
palette.setBrush(QPalette::Background, *oldImage);
|
||||
palette.setBrush(QPalette::Window, oldImage);
|
||||
this->setPalette(palette);
|
||||
}
|
||||
|
||||
QPixmap p;
|
||||
p.load( currentImage.filename.c_str() );
|
||||
if (p.isNull())
|
||||
{
|
||||
Log("Error: failed to load image: ", currentImage.filename);
|
||||
warn("Failed to load image.");
|
||||
if (switcher != nullptr)
|
||||
{
|
||||
switcher->scheduleImageUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Log("size:", p.width(), "x", p.height(), "(window:", width(), ",", height(), ")");
|
||||
|
||||
@@ -194,7 +210,7 @@ void MainWindow::updateImage()
|
||||
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->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
|
||||
QPainter pt(&background);
|
||||
@@ -213,7 +229,7 @@ void MainWindow::updateImage()
|
||||
|
||||
label->setPixmap(background);
|
||||
|
||||
if (oldImage != NULL && transitionSeconds > 0)
|
||||
if (!oldImage.isNull() && transitionSeconds > 0)
|
||||
{
|
||||
auto effect = new QGraphicsOpacityEffect(label);
|
||||
effect->setOpacity(0.0);
|
||||
@@ -273,9 +289,9 @@ QPixmap MainWindow::getBlurredBackground(const QPixmap& originalSize, const QPix
|
||||
|
||||
QPixmap MainWindow::getRotatedPixmap(const QPixmap& p)
|
||||
{
|
||||
QMatrix matrix;
|
||||
matrix.rotate(currentImage.rotation);
|
||||
return p.transformed(matrix);
|
||||
QTransform transform;
|
||||
transform.rotate(currentImage.rotation);
|
||||
return p.transformed(transform);
|
||||
}
|
||||
|
||||
QPixmap MainWindow::getScaledPixmap(const QPixmap& p)
|
||||
@@ -325,12 +341,12 @@ void MainWindow::drawBackground(const QPixmap& originalSize, const QPixmap& scal
|
||||
QPixmap background = blur(originalSize.scaledToHeight(height()));
|
||||
QRect rect((background.width() - width())/2, 0, width(), height());
|
||||
background = background.copy(rect);
|
||||
palette.setBrush(QPalette::Background, background);
|
||||
palette.setBrush(QPalette::Window, background);
|
||||
} else {
|
||||
QPixmap background = blur(originalSize.scaledToHeight(height()));
|
||||
QRect rect((background.width() - width())/2, 0, width(), height());
|
||||
background = background.copy(rect);
|
||||
palette.setBrush(QPalette::Background, background);
|
||||
palette.setBrush(QPalette::Window, background);
|
||||
}
|
||||
this->setPalette(palette);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ public:
|
||||
const ImageDisplayOptions &getBaseOptions();
|
||||
void setImageSwitcher(ImageSwitcher *switcherIn);
|
||||
void setOverlayHexRGB(QString overlayHexRGB);
|
||||
void setDebugThumbnail(bool enabled);
|
||||
public slots:
|
||||
void checkWindowSize();
|
||||
private:
|
||||
@@ -47,6 +48,7 @@ private:
|
||||
QSize lastScreenSize = {0,0};
|
||||
QString overlayHexRGB = "#FFFF";
|
||||
unsigned int transitionSeconds = 1;
|
||||
bool debugThumbnail = false;
|
||||
|
||||
std::unique_ptr<Overlay> overlay;
|
||||
ImageSwitcher *switcher = nullptr;
|
||||
|
||||
@@ -18,6 +18,8 @@ MqttController::MqttController(const MqttConfig &configIn, QObject *parent)
|
||||
mosquitto_lib_init();
|
||||
g_mqttInitialized = true;
|
||||
}
|
||||
controlTopic = QString::fromStdString(config.topic);
|
||||
immichTopic = QString::fromStdString(config.immichTopic);
|
||||
}
|
||||
|
||||
MqttController::~MqttController()
|
||||
@@ -103,6 +105,18 @@ void MqttController::subscribe()
|
||||
{
|
||||
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)
|
||||
@@ -113,15 +127,26 @@ void MqttController::HandleMessage(struct mosquitto *mosq, void *userdata, const
|
||||
return;
|
||||
|
||||
QString payload = QString::fromUtf8(static_cast<const char *>(message->payload), message->payloadlen);
|
||||
QMetaObject::invokeMethod(self, "handleCommand", Qt::QueuedConnection, Q_ARG(QString, payload));
|
||||
QString topic = QString::fromUtf8(message->topic);
|
||||
QMetaObject::invokeMethod(self, "handleMessage", Qt::QueuedConnection,
|
||||
Q_ARG(QString, topic),
|
||||
Q_ARG(QString, payload));
|
||||
}
|
||||
|
||||
void MqttController::handleCommand(const 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();
|
||||
|
||||
@@ -24,9 +24,10 @@ signals:
|
||||
void nextImage();
|
||||
void nextFolder();
|
||||
void restart();
|
||||
void immichControl(const QString &payload);
|
||||
|
||||
private slots:
|
||||
void handleCommand(const QString &payload);
|
||||
void handleMessage(const QString &topic, const QString &payload);
|
||||
|
||||
private:
|
||||
static void HandleConnect(struct mosquitto *mosq, void *userdata, int rc);
|
||||
@@ -35,6 +36,8 @@ private:
|
||||
void subscribe();
|
||||
|
||||
MqttConfig config;
|
||||
QString controlTopic;
|
||||
QString immichTopic;
|
||||
struct mosquitto *client = nullptr;
|
||||
bool connected = false;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <QLocale>
|
||||
#include <QTime>
|
||||
#include <QFileInfo>
|
||||
#include <QFile>
|
||||
#include <QStringList>
|
||||
#include <QRegExp>
|
||||
#include <iostream>
|
||||
@@ -162,9 +163,47 @@ QString Overlay::getExifDate(std::string filename) {
|
||||
dateTime = exif_entry_get_value(exifEntry, buf, sizeof(buf));
|
||||
}
|
||||
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);
|
||||
void parseInput();
|
||||
std::string renderString(QString overlayTemplate, std::string filename);
|
||||
QString readSidecarExifDate(const QString &filePath);
|
||||
QString formatExifDateString(const QString &raw);
|
||||
};
|
||||
#endif
|
||||
|
||||
@@ -17,6 +17,7 @@ class PathTraverser
|
||||
virtual QStringList getImages() const = 0;
|
||||
virtual const std::string getImagePath(const std::string image) const = 0;
|
||||
virtual ImageDisplayOptions UpdateOptionsForImage(const std::string& filename, const ImageDisplayOptions& baseOptions) const = 0;
|
||||
virtual bool shouldReloadImages() const { return false; }
|
||||
|
||||
protected:
|
||||
const std::string path;
|
||||
|
||||
Reference in New Issue
Block a user