Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc0ec58636 | |||
| 85ef89fa4b | |||
| e5f5934eb6 | |||
| 80286da166 | |||
| 6f2b8fe90c | |||
| bc672256fb | |||
| 86b19d5513 |
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
|
||||||
30
README.md
30
README.md
@@ -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)
|
* `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 🟢
|
* `-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.
|
||||||
@@ -69,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,
|
||||||
@@ -117,6 +118,7 @@ Supported keys and values in the JSON configuration are:
|
|||||||
* `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)
|
* `mqtt` : MQTT playback control (see below)
|
||||||
* `immich` : connect to an Immich server instead of a local path (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.
|
||||||
@@ -167,7 +169,7 @@ Immich control topic (`immichTopic`):
|
|||||||
|
|
||||||
### Immich configuration (lightweight + low power)
|
### 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 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
|
#### Getting an Immich API key
|
||||||
|
|
||||||
@@ -180,6 +182,17 @@ In the Immich web UI, go to Settings and find **API Keys** (menu labels can vary
|
|||||||
* `asset.view` — required for the viewAsset endpoint (thumbnail/preview/fullsize).
|
* `asset.view` — required for the viewAsset endpoint (thumbnail/preview/fullsize).
|
||||||
* `asset.download` — required if you set `size` to `original` (download endpoint).
|
* `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):
|
Example (single source):
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
@@ -192,6 +205,8 @@ Example (single source):
|
|||||||
"pageSize": 200,
|
"pageSize": 200,
|
||||||
"maxAssets": 1000,
|
"maxAssets": 1000,
|
||||||
"refreshSeconds": 300,
|
"refreshSeconds": 300,
|
||||||
|
"skipRetrySeconds": 3600,
|
||||||
|
"skipTags": ["frame-ignore"],
|
||||||
"cachePath": "~/.cache/slide/immich",
|
"cachePath": "~/.cache/slide/immich",
|
||||||
"cacheMaxMB": 512,
|
"cacheMaxMB": 512,
|
||||||
"includeArchived": false
|
"includeArchived": false
|
||||||
@@ -219,15 +234,20 @@ Example (scheduler entry):
|
|||||||
Immich settings:
|
Immich settings:
|
||||||
* `url`: base Immich server URL (the integration appends `/api` automatically if missing).
|
* `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`).
|
* `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.
|
* `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.
|
* `albumId` or `albumIds`: optional album filters.
|
||||||
* `personId` or `personIds`: optional person filters.
|
* `personId` or `personIds`: optional person filters.
|
||||||
* `extensions` / `allowedExtensions`: optional list of file extensions to include (for example `["jpg","jpeg","png"]`).
|
* `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).
|
* `size`: `"fullsize"`, `"preview"`, `"thumbnail"`, or `"original"` (original uses the download endpoint).
|
||||||
* `order`: `"asc"` or `"desc"` ordering for asset search.
|
* `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.
|
* `pageSize`: assets fetched per page.
|
||||||
* `maxAssets`: cap on total assets fetched (0 means no cap).
|
* `maxAssets`: cap on total assets fetched (0 means no cap).
|
||||||
* `refreshSeconds`: refresh interval for reloading Immich assets (0 disables).
|
* `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.
|
* `cachePath`: local cache directory for downloaded images.
|
||||||
* `cacheMaxMB`: maximum cache size in MB (0 disables cleanup).
|
* `cacheMaxMB`: maximum cache size in MB (0 disables cleanup).
|
||||||
* `includeArchived`: include archived assets in search results.
|
* `includeArchived`: include archived assets in search results.
|
||||||
@@ -266,6 +286,10 @@ See the `Configuration File` section for details of each setting.
|
|||||||
* libexif
|
* libexif
|
||||||
* libmosquitto-dev
|
* 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.
|
||||||
@@ -84,6 +84,19 @@ std::vector<std::string> NormalizeExtensions(const std::vector<std::string> &val
|
|||||||
return normalized;
|
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 ParseImmichConfigObject(QJsonObject immichJson) {
|
||||||
ImmichConfig config;
|
ImmichConfig config;
|
||||||
|
|
||||||
@@ -135,10 +148,28 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) {
|
|||||||
if(allowedExtensions.size() > 0)
|
if(allowedExtensions.size() > 0)
|
||||||
config.allowedExtensions = NormalizeExtensions(allowedExtensions);
|
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.pageSize, immichJson, "pageSize");
|
||||||
SetJSONInt(config.maxAssets, immichJson, "maxAssets");
|
SetJSONInt(config.maxAssets, immichJson, "maxAssets");
|
||||||
SetJSONInt(config.refreshSeconds, immichJson, "refreshSeconds");
|
SetJSONInt(config.refreshSeconds, immichJson, "refreshSeconds");
|
||||||
SetJSONInt(config.refreshSeconds, immichJson, "refreshIntervalSeconds");
|
SetJSONInt(config.refreshSeconds, immichJson, "refreshIntervalSeconds");
|
||||||
|
SetJSONInt(config.skipRetrySeconds, immichJson, "skipRetrySeconds");
|
||||||
|
SetJSONInt(config.skipRetrySeconds, immichJson, "skipRetryIntervalSeconds");
|
||||||
SetJSONInt(config.cacheMaxMB, immichJson, "cacheMaxMB");
|
SetJSONInt(config.cacheMaxMB, immichJson, "cacheMaxMB");
|
||||||
SetJSONBool(config.includeArchived, immichJson, "includeArchived");
|
SetJSONBool(config.includeArchived, immichJson, "includeArchived");
|
||||||
|
|
||||||
@@ -404,6 +435,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())
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ struct ImmichConfig {
|
|||||||
std::vector<std::string> albumIds;
|
std::vector<std::string> albumIds;
|
||||||
std::vector<std::string> personIds;
|
std::vector<std::string> personIds;
|
||||||
std::vector<std::string> allowedExtensions;
|
std::vector<std::string> allowedExtensions;
|
||||||
|
std::vector<std::string> skipTags;
|
||||||
|
std::vector<std::string> skipTagIds;
|
||||||
std::string size = "fullsize";
|
std::string size = "fullsize";
|
||||||
std::string order = "desc";
|
std::string order = "desc";
|
||||||
int pageSize = 200;
|
int pageSize = 200;
|
||||||
int maxAssets = 1000;
|
int maxAssets = 1000;
|
||||||
int refreshSeconds = 300;
|
int refreshSeconds = 300;
|
||||||
|
int skipRetrySeconds = 3600;
|
||||||
std::string cachePath = "";
|
std::string cachePath = "";
|
||||||
int cacheMaxMB = 512;
|
int cacheMaxMB = 512;
|
||||||
bool includeArchived = false;
|
bool includeArchived = false;
|
||||||
@@ -60,8 +63,24 @@ struct ImmichConfig {
|
|||||||
if (allowedExtensions[i] != b.allowedExtensions[i])
|
if (allowedExtensions[i] != b.allowedExtensions[i])
|
||||||
return false;
|
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)
|
if (refreshSeconds != b.refreshSeconds)
|
||||||
return false;
|
return false;
|
||||||
|
if (skipRetrySeconds != b.skipRetrySeconds)
|
||||||
|
return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +182,7 @@ struct AppConfig : public Config {
|
|||||||
MqttConfig mqtt;
|
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:
|
||||||
|
|||||||
@@ -14,11 +14,42 @@
|
|||||||
#include <QSaveFile>
|
#include <QSaveFile>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QUrlQuery>
|
#include <QUrlQuery>
|
||||||
|
#include <QDateTime>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
const int kMetadataTimeoutMs = 15000;
|
const int kMetadataTimeoutMs = 15000;
|
||||||
const int kAssetTimeoutMs = 30000;
|
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)
|
QString DetectImageExtension(const QByteArray &data)
|
||||||
{
|
{
|
||||||
QBuffer buffer;
|
QBuffer buffer;
|
||||||
@@ -117,7 +148,7 @@ QVector<ImmichAsset> ImmichClient::fetchAssets()
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!config.userId.empty() && config.albumIds.empty() && config.personIds.empty())
|
if (!config.userId.empty() && config.albumIds.empty() && config.personIds.empty())
|
||||||
return fetchAssetsByUser();
|
return fetchAssetsBySearch();
|
||||||
|
|
||||||
if (!config.userId.empty() && (!config.albumIds.empty() || !config.personIds.empty()))
|
if (!config.userId.empty() && (!config.albumIds.empty() || !config.personIds.empty()))
|
||||||
{
|
{
|
||||||
@@ -135,20 +166,26 @@ QVector<ImmichAsset> ImmichClient::fetchAssetsBySearch()
|
|||||||
int maxAssets = config.maxAssets;
|
int maxAssets = config.maxAssets;
|
||||||
bool triedZero = false;
|
bool triedZero = false;
|
||||||
int page = 1;
|
int page = 1;
|
||||||
|
QString userFilterKey;
|
||||||
|
QByteArray firstResponse;
|
||||||
|
QJsonArray items;
|
||||||
|
int total = 0;
|
||||||
|
|
||||||
if (ShouldLog())
|
if (ShouldLog())
|
||||||
{
|
{
|
||||||
Log("Immich search: size=", config.size, ", order=", config.order,
|
Log("Immich search: size=", config.size, ", order=", config.order,
|
||||||
", pageSize=", pageSize, ", maxAssets=", maxAssets,
|
", pageSize=", pageSize, ", maxAssets=", maxAssets,
|
||||||
|
", userId=", config.userId,
|
||||||
", albumIds=", config.albumIds.size(),
|
", albumIds=", config.albumIds.size(),
|
||||||
", personIds=", config.personIds.size(),
|
", personIds=", config.personIds.size(),
|
||||||
", allowedExtensions=", config.allowedExtensions.size());
|
", allowedExtensions=", config.allowedExtensions.size(),
|
||||||
|
", skipTags=", config.skipTags.size(),
|
||||||
|
", skipTagIds=", config.skipTagIds.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
while (true)
|
auto fetchPage = [&](int pageIndex, const QString &userKey) -> QByteArray {
|
||||||
{
|
|
||||||
QJsonObject body;
|
QJsonObject body;
|
||||||
body["page"] = page;
|
body["page"] = pageIndex;
|
||||||
body["size"] = pageSize;
|
body["size"] = pageSize;
|
||||||
body["type"] = "IMAGE";
|
body["type"] = "IMAGE";
|
||||||
body["order"] = QString::fromStdString(config.order);
|
body["order"] = QString::fromStdString(config.order);
|
||||||
@@ -168,19 +205,69 @@ QVector<ImmichAsset> ImmichClient::fetchAssetsBySearch()
|
|||||||
ids.append(QString::fromStdString(id));
|
ids.append(QString::fromStdString(id));
|
||||||
body["personIds"] = ids;
|
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);
|
auto parseSearch = [&](const QByteArray &response, QJsonArray &outItems, int &outTotal) -> bool {
|
||||||
if (response.isEmpty())
|
|
||||||
break;
|
|
||||||
|
|
||||||
QJsonDocument doc = QJsonDocument::fromJson(response);
|
QJsonDocument doc = QJsonDocument::fromJson(response);
|
||||||
if (!doc.isObject())
|
if (!doc.isObject())
|
||||||
break;
|
return false;
|
||||||
|
|
||||||
QJsonObject root = doc.object();
|
QJsonObject root = doc.object();
|
||||||
|
if (root.contains("error") || root.contains("statusCode"))
|
||||||
|
return false;
|
||||||
|
if (!root.contains("assets"))
|
||||||
|
return false;
|
||||||
QJsonObject assetsObj = root["assets"].toObject();
|
QJsonObject assetsObj = root["assets"].toObject();
|
||||||
QJsonArray items = assetsObj["items"].toArray();
|
outItems = assetsObj["items"].toArray();
|
||||||
int total = assetsObj["total"].toInt();
|
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, ")");
|
Log("Immich page ", page, ": ", items.size(), " assets (total ", total, ")");
|
||||||
if (items.isEmpty())
|
if (items.isEmpty())
|
||||||
{
|
{
|
||||||
@@ -202,6 +289,12 @@ QVector<ImmichAsset> ImmichClient::fetchAssetsBySearch()
|
|||||||
ImmichAsset asset;
|
ImmichAsset asset;
|
||||||
asset.id = id;
|
asset.id = id;
|
||||||
asset.originalFileName = item["originalFileName"].toString();
|
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))
|
if (!extensionAllowed(asset.originalFileName))
|
||||||
{
|
{
|
||||||
Log("Immich skip by extension: ", asset.originalFileName.toStdString());
|
Log("Immich skip by extension: ", asset.originalFileName.toStdString());
|
||||||
@@ -227,6 +320,9 @@ QVector<ImmichAsset> ImmichClient::fetchAssetsByUser()
|
|||||||
int pageSize = config.pageSize > 0 ? config.pageSize : 200;
|
int pageSize = config.pageSize > 0 ? config.pageSize : 200;
|
||||||
int maxAssets = config.maxAssets;
|
int maxAssets = config.maxAssets;
|
||||||
int skip = 0;
|
int skip = 0;
|
||||||
|
QString endpointPath = "/assets";
|
||||||
|
QByteArray initialResponse;
|
||||||
|
bool endpointResolved = false;
|
||||||
|
|
||||||
if (ShouldLog())
|
if (ShouldLog())
|
||||||
{
|
{
|
||||||
@@ -236,24 +332,52 @@ QVector<ImmichAsset> ImmichClient::fetchAssetsByUser()
|
|||||||
", includeArchived=", config.includeArchived);
|
", includeArchived=", config.includeArchived);
|
||||||
}
|
}
|
||||||
|
|
||||||
while (true)
|
auto fetchPage = [&](const QString &path, int offset) -> QByteArray {
|
||||||
{
|
QUrl url = apiUrl(path);
|
||||||
QUrl url = apiUrl("/assets");
|
|
||||||
QUrlQuery query;
|
QUrlQuery query;
|
||||||
query.addQueryItem("take", QString::number(pageSize));
|
query.addQueryItem("take", QString::number(pageSize));
|
||||||
query.addQueryItem("skip", QString::number(skip));
|
query.addQueryItem("skip", QString::number(offset));
|
||||||
query.addQueryItem("userId", QString::fromStdString(config.userId));
|
query.addQueryItem("userId", QString::fromStdString(config.userId));
|
||||||
if (!config.includeArchived)
|
if (!config.includeArchived)
|
||||||
query.addQueryItem("isArchived", "false");
|
query.addQueryItem("isArchived", "false");
|
||||||
url.setQuery(query);
|
url.setQuery(query);
|
||||||
|
return getBytes(url, nullptr, kMetadataTimeoutMs);
|
||||||
|
};
|
||||||
|
|
||||||
QByteArray response = 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())
|
if (response.isEmpty())
|
||||||
break;
|
break;
|
||||||
|
|
||||||
QJsonDocument doc = QJsonDocument::fromJson(response);
|
QJsonDocument doc = QJsonDocument::fromJson(response);
|
||||||
if (!doc.isArray())
|
if (!doc.isArray())
|
||||||
break;
|
{
|
||||||
|
Log("Immich user assets response was not an array; falling back to metadata search.");
|
||||||
|
return fetchAssetsBySearch();
|
||||||
|
}
|
||||||
|
|
||||||
QJsonArray items = doc.array();
|
QJsonArray items = doc.array();
|
||||||
Log("Immich user assets skip ", skip, ": ", items.size(), " assets");
|
Log("Immich user assets skip ", skip, ": ", items.size(), " assets");
|
||||||
@@ -269,6 +393,7 @@ QVector<ImmichAsset> ImmichClient::fetchAssetsByUser()
|
|||||||
ImmichAsset asset;
|
ImmichAsset asset;
|
||||||
asset.id = id;
|
asset.id = id;
|
||||||
asset.originalFileName = item["originalFileName"].toString();
|
asset.originalFileName = item["originalFileName"].toString();
|
||||||
|
asset.exifDateTime = ExtractAssetDateTime(item);
|
||||||
if (!extensionAllowed(asset.originalFileName))
|
if (!extensionAllowed(asset.originalFileName))
|
||||||
{
|
{
|
||||||
Log("Immich skip by extension: ", asset.originalFileName.toStdString());
|
Log("Immich skip by extension: ", asset.originalFileName.toStdString());
|
||||||
@@ -302,6 +427,71 @@ bool ImmichClient::extensionAllowed(const QString &filename) const
|
|||||||
return false;
|
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)
|
bool ImmichClient::downloadAsset(const QString &assetId, QByteArray &data, QString &contentType)
|
||||||
{
|
{
|
||||||
return downloadAssetWithSize(assetId, QString::fromStdString(config.size), data, contentType);
|
return downloadAssetWithSize(assetId, QString::fromStdString(config.size), data, contentType);
|
||||||
@@ -349,6 +539,7 @@ ImmichAssetCache::ImmichAssetCache(const ImmichConfig &config)
|
|||||||
cacheDirPath = resolveCachePath(rawPath);
|
cacheDirPath = resolveCachePath(rawPath);
|
||||||
if (config.cacheMaxMB > 0)
|
if (config.cacheMaxMB > 0)
|
||||||
cacheMaxBytes = static_cast<qint64>(config.cacheMaxMB) * 1024 * 1024;
|
cacheMaxBytes = static_cast<qint64>(config.cacheMaxMB) * 1024 * 1024;
|
||||||
|
skipRetrySeconds = config.skipRetrySeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString ImmichAssetCache::resolveCachePath(const QString &rawPath) const
|
QString ImmichAssetCache::resolveCachePath(const QString &rawPath) const
|
||||||
@@ -377,12 +568,20 @@ QString ImmichAssetCache::findExisting(const QString &assetId) const
|
|||||||
QStringList matches = dir.entryList(QStringList() << (assetId + "_*"), QDir::Files, QDir::Time);
|
QStringList matches = dir.entryList(QStringList() << (assetId + "_*"), QDir::Files, QDir::Time);
|
||||||
if (matches.isEmpty())
|
if (matches.isEmpty())
|
||||||
return "";
|
return "";
|
||||||
|
QString skipFile;
|
||||||
for (const auto &match : matches)
|
for (const auto &match : matches)
|
||||||
{
|
{
|
||||||
if (match.endsWith(".skip"))
|
if (match.endsWith(".skip"))
|
||||||
return dir.filePath(match);
|
{
|
||||||
|
if (skipFile.isEmpty())
|
||||||
|
skipFile = dir.filePath(match);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (match.endsWith(".exif"))
|
||||||
|
continue;
|
||||||
|
return dir.filePath(match);
|
||||||
}
|
}
|
||||||
return dir.filePath(matches.first());
|
return skipFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString ImmichAssetCache::sanitizeFileName(const QString &name) const
|
QString ImmichAssetCache::sanitizeFileName(const QString &name) const
|
||||||
@@ -446,8 +645,25 @@ QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &a
|
|||||||
{
|
{
|
||||||
if (existing.endsWith(".skip"))
|
if (existing.endsWith(".skip"))
|
||||||
{
|
{
|
||||||
Log("Immich skip marker: ", assetId.toStdString());
|
if (skipRetrySeconds > 0)
|
||||||
return "";
|
{
|
||||||
|
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);
|
QFileInfo info(existing);
|
||||||
if (info.size() <= 0)
|
if (info.size() <= 0)
|
||||||
@@ -511,7 +727,10 @@ QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QString safeName = sanitizeFileName(assetName);
|
QString baseName = QFileInfo(assetName).completeBaseName();
|
||||||
|
if (baseName.isEmpty())
|
||||||
|
baseName = assetName;
|
||||||
|
QString safeName = sanitizeFileName(baseName);
|
||||||
QString extension = detectedExt;
|
QString extension = detectedExt;
|
||||||
if (extension.isEmpty())
|
if (extension.isEmpty())
|
||||||
extension = extensionForContentType(contentType);
|
extension = extensionForContentType(contentType);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
struct ImmichAsset {
|
struct ImmichAsset {
|
||||||
QString id;
|
QString id;
|
||||||
QString originalFileName;
|
QString originalFileName;
|
||||||
|
QString exifDateTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
class ImmichClient {
|
class ImmichClient {
|
||||||
@@ -26,6 +27,7 @@ class ImmichClient {
|
|||||||
private:
|
private:
|
||||||
QVector<ImmichAsset> fetchAssetsBySearch();
|
QVector<ImmichAsset> fetchAssetsBySearch();
|
||||||
QVector<ImmichAsset> fetchAssetsByUser();
|
QVector<ImmichAsset> fetchAssetsByUser();
|
||||||
|
bool shouldSkipByTag(const QJsonObject &item) const;
|
||||||
bool extensionAllowed(const QString &filename) const;
|
bool extensionAllowed(const QString &filename) const;
|
||||||
QUrl apiUrl(const QString &path) const;
|
QUrl apiUrl(const QString &path) const;
|
||||||
QNetworkRequest makeRequest(const QUrl &url) const;
|
QNetworkRequest makeRequest(const QUrl &url) const;
|
||||||
@@ -55,6 +57,7 @@ class ImmichAssetCache {
|
|||||||
qint64 cacheMaxBytes = 0;
|
qint64 cacheMaxBytes = 0;
|
||||||
bool cacheSizeKnown = false;
|
bool cacheSizeKnown = false;
|
||||||
qint64 cacheSizeBytes = 0;
|
qint64 cacheSizeBytes = 0;
|
||||||
|
int skipRetrySeconds = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // IMMICHCLIENT_H
|
#endif // IMMICHCLIENT_H
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
#include "immichpathtraverser.h"
|
#include "immichpathtraverser.h"
|
||||||
#include "logger.h"
|
#include "logger.h"
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QSaveFile>
|
||||||
|
|
||||||
ImmichPathTraverser::ImmichPathTraverser(const ImmichConfig &configIn)
|
ImmichPathTraverser::ImmichPathTraverser(const ImmichConfig &configIn)
|
||||||
: PathTraverser(""),
|
: PathTraverser(""),
|
||||||
@@ -17,6 +20,7 @@ void ImmichPathTraverser::loadAssets()
|
|||||||
{
|
{
|
||||||
assetIds.clear();
|
assetIds.clear();
|
||||||
assetNames.clear();
|
assetNames.clear();
|
||||||
|
assetDateTimes.clear();
|
||||||
QVector<ImmichAsset> assets = client.fetchAssets();
|
QVector<ImmichAsset> assets = client.fetchAssets();
|
||||||
for (const auto &asset : assets)
|
for (const auto &asset : assets)
|
||||||
{
|
{
|
||||||
@@ -24,6 +28,8 @@ void ImmichPathTraverser::loadAssets()
|
|||||||
continue;
|
continue;
|
||||||
assetIds.append(asset.id);
|
assetIds.append(asset.id);
|
||||||
assetNames.insert(asset.id, asset.originalFileName);
|
assetNames.insert(asset.id, asset.originalFileName);
|
||||||
|
if (!asset.exifDateTime.isEmpty())
|
||||||
|
assetDateTimes.insert(asset.id, asset.exifDateTime);
|
||||||
}
|
}
|
||||||
Log("Immich assets loaded: ", assetIds.size());
|
Log("Immich assets loaded: ", assetIds.size());
|
||||||
lastRefresh = QDateTime::currentDateTime();
|
lastRefresh = QDateTime::currentDateTime();
|
||||||
@@ -44,6 +50,12 @@ const std::string ImmichPathTraverser::getImagePath(const std::string image) con
|
|||||||
QString assetId = QString::fromStdString(image);
|
QString assetId = QString::fromStdString(image);
|
||||||
QString name = assetNames.value(assetId);
|
QString name = assetNames.value(assetId);
|
||||||
QString path = cache.getCachedPath(assetId, name, client);
|
QString path = cache.getCachedPath(assetId, name, client);
|
||||||
|
if (!path.isEmpty())
|
||||||
|
{
|
||||||
|
QString dateTime = assetDateTimes.value(assetId);
|
||||||
|
if (!dateTime.isEmpty())
|
||||||
|
writeExifSidecar(path, dateTime);
|
||||||
|
}
|
||||||
return path.toStdString();
|
return path.toStdString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,3 +78,31 @@ bool ImmichPathTraverser::shouldReloadImages() const
|
|||||||
{
|
{
|
||||||
return refreshDue();
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class ImmichPathTraverser : public PathTraverser
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
void loadAssets();
|
void loadAssets();
|
||||||
|
void writeExifSidecar(const QString &imagePath, const QString &dateTime) const;
|
||||||
bool refreshDue() const;
|
bool refreshDue() const;
|
||||||
|
|
||||||
ImmichConfig config;
|
ImmichConfig config;
|
||||||
@@ -26,6 +27,7 @@ class ImmichPathTraverser : public PathTraverser
|
|||||||
mutable ImmichAssetCache cache;
|
mutable ImmichAssetCache cache;
|
||||||
QStringList assetIds;
|
QStringList assetIds;
|
||||||
QHash<QString, QString> assetNames;
|
QHash<QString, QString> assetNames;
|
||||||
|
QHash<QString, QString> assetDateTimes;
|
||||||
QDateTime lastRefresh;
|
QDateTime lastRefresh;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
16
src/main.cpp
16
src/main.cpp
@@ -156,6 +156,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +371,11 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload)
|
|||||||
config.refreshSeconds = (int)obj["refreshSeconds"].toDouble();
|
config.refreshSeconds = (int)obj["refreshSeconds"].toDouble();
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (obj.contains("skipRetrySeconds") && obj["skipRetrySeconds"].isDouble())
|
||||||
|
{
|
||||||
|
config.skipRetrySeconds = (int)obj["skipRetrySeconds"].toDouble();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
if (obj.contains("includeArchived"))
|
if (obj.contains("includeArchived"))
|
||||||
{
|
{
|
||||||
if (obj["includeArchived"].isBool())
|
if (obj["includeArchived"].isBool())
|
||||||
@@ -543,6 +549,16 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (key == "skipretryseconds")
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
int parsed = value.toInt(&ok);
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
config.skipRetrySeconds = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ void MainWindow::setImage(const ImageDetails &imageDetails)
|
|||||||
updateImage();
|
updateImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::setDebugThumbnail(bool enabled)
|
||||||
|
{
|
||||||
|
debugThumbnail = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
void MainWindow::updateImage()
|
void MainWindow::updateImage()
|
||||||
{
|
{
|
||||||
checkWindowSize();
|
checkWindowSize();
|
||||||
@@ -205,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->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);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public:
|
|||||||
const ImageDisplayOptions &getBaseOptions();
|
const ImageDisplayOptions &getBaseOptions();
|
||||||
void setImageSwitcher(ImageSwitcher *switcherIn);
|
void setImageSwitcher(ImageSwitcher *switcherIn);
|
||||||
void setOverlayHexRGB(QString overlayHexRGB);
|
void setOverlayHexRGB(QString overlayHexRGB);
|
||||||
|
void setDebugThumbnail(bool enabled);
|
||||||
public slots:
|
public slots:
|
||||||
void checkWindowSize();
|
void checkWindowSize();
|
||||||
private:
|
private:
|
||||||
@@ -47,6 +48,7 @@ private:
|
|||||||
QSize lastScreenSize = {0,0};
|
QSize lastScreenSize = {0,0};
|
||||||
QString overlayHexRGB = "#FFFF";
|
QString overlayHexRGB = "#FFFF";
|
||||||
unsigned int transitionSeconds = 1;
|
unsigned int transitionSeconds = 1;
|
||||||
|
bool debugThumbnail = false;
|
||||||
|
|
||||||
std::unique_ptr<Overlay> overlay;
|
std::unique_ptr<Overlay> overlay;
|
||||||
ImageSwitcher *switcher = nullptr;
|
ImageSwitcher *switcher = nullptr;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user