9 Commits
v0.0.5 ... main

Author SHA1 Message Date
fc0ec58636 [ci skip] add ability to skip immich photos via tag 2026-02-02 14:11:38 +11:00
85ef89fa4b undo drone changes [ci skip]
All checks were successful
continuous-integration/drone/tag Build is passing
2026-02-02 11:06:38 +11:00
e5f5934eb6 try auto tagging again
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-02 10:59:11 +11:00
80286da166 drone fix
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-02 10:52:21 +11:00
6f2b8fe90c immich fixes
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-02 10:47:08 +11:00
bc672256fb fix sidecar handling
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone/tag Build is passing
2026-02-02 09:55:28 +11:00
86b19d5513 immich improvements
Some checks failed
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build was killed
2026-02-02 09:23:38 +11:00
3958da1983 improve handling of images from immich albums
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone/tag Build is passing
2026-02-02 09:02:06 +11:00
76c98f63dd fix immich handling [CI SKIP]
All checks were successful
continuous-integration/drone/tag Build is passing
2026-02-02 08:51:58 +11:00
16 changed files with 861 additions and 35 deletions

8
CHANGELOG.md Normal file
View File

@@ -0,0 +1,8 @@
# Changelog
# [Unreleased]
- Nothing yet.
# [0.0.9] - 2026-02-01
- Fix sidecar handling

View File

@@ -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 images, set `size` to `preview`/`thumbnail` or use `extensions` to limit results to JPEG/PNG. 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):
``` ```
{ {
@@ -191,6 +204,9 @@ Example (single source):
"order": "desc", "order": "desc",
"pageSize": 200, "pageSize": 200,
"maxAssets": 1000, "maxAssets": 1000,
"refreshSeconds": 300,
"skipRetrySeconds": 3600,
"skipTags": ["frame-ignore"],
"cachePath": "~/.cache/slide/immich", "cachePath": "~/.cache/slide/immich",
"cacheMaxMB": 512, "cacheMaxMB": 512,
"includeArchived": false "includeArchived": false
@@ -218,14 +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 doesnt return tags on `/search/metadata`, `skipTags`/`skipTagIds` wont 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).
* `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.
@@ -264,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
View 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 cant decode them.
- `refreshSeconds` controls how often `slide` refreshes the Immich asset list.

View File

@@ -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,8 +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, "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");
@@ -402,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())

View File

@@ -14,10 +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 skipRetrySeconds = 3600;
std::string cachePath = ""; std::string cachePath = "";
int cacheMaxMB = 512; int cacheMaxMB = 512;
bool includeArchived = false; bool includeArchived = false;
@@ -59,6 +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)
return false;
if (skipRetrySeconds != b.skipRetrySeconds)
return false;
return true; return true;
} }
@@ -160,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:

View File

@@ -242,7 +242,7 @@ const ImageDetails ShuffleImageSelector::getNextImage(const ImageDisplayOptions
void ShuffleImageSelector::reloadImagesIfNoneLeft() void ShuffleImageSelector::reloadImagesIfNoneLeft()
{ {
if (images.size() == 0 || current_image_shuffle >= images.size()) if (images.size() == 0 || current_image_shuffle >= images.size() || pathTraverser->shouldReloadImages())
{ {
current_image_shuffle = 0; current_image_shuffle = 0;
images = pathTraverser->getImages(); images = pathTraverser->getImages();
@@ -305,7 +305,7 @@ const ImageDetails SortedImageSelector::getNextImage(const ImageDisplayOptions &
void SortedImageSelector::reloadImagesIfEmpty() void SortedImageSelector::reloadImagesIfEmpty()
{ {
if (images.size() == 0) if (images.size() == 0 || pathTraverser->shouldReloadImages())
{ {
images = pathTraverser->getImages(); images = pathTraverser->getImages();
std::sort(images.begin(), images.end()); std::sort(images.begin(), images.end());

View File

@@ -5,6 +5,8 @@
#include <QEventLoop> #include <QEventLoop>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QBuffer>
#include <QImageReader>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
@@ -12,11 +14,56 @@
#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)
{
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) ImmichClient::ImmichClient(const ImmichConfig &configIn)
@@ -101,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()))
{ {
@@ -119,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);
@@ -152,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())
{ {
@@ -186,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());
@@ -211,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())
{ {
@@ -220,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");
@@ -253,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());
@@ -286,12 +427,82 @@ 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);
}
bool ImmichClient::downloadAssetWithSize(const QString &assetId, const QString &sizeOverride, QByteArray &data, QString &contentType)
{ {
if (!config.enabled) if (!config.enabled)
return false; return false;
QString size = QString::fromStdString(config.size).trimmed().toLower(); QString size = sizeOverride.trimmed().toLower();
if (size.isEmpty()) if (size.isEmpty())
size = "fullsize"; size = "fullsize";
@@ -328,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
@@ -356,7 +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 "";
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 QString ImmichAssetCache::sanitizeFileName(const QString &name) const
@@ -417,18 +642,104 @@ QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &a
QString existing = findExisting(assetId); QString existing = findExisting(assetId);
if (!existing.isEmpty()) 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()); Log("Immich cache hit: ", assetId.toStdString());
return existing; return existing;
} }
}
else
{
Log("Immich cache hit: ", assetId.toStdString());
return existing;
}
}
QByteArray data; QByteArray data;
QString contentType; QString contentType;
if (!client.downloadAsset(assetId, data, contentType)) if (!client.downloadAsset(assetId, data, contentType))
return ""; return "";
QString safeName = sanitizeFileName(assetName); QString detectedExt = DetectImageExtension(data);
QString extension = extensionForContentType(contentType); 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; QString filename = assetId + "_" + safeName + "." + extension;
QDir dir(cacheDirPath); QDir dir(cacheDirPath);

View File

@@ -14,6 +14,7 @@
struct ImmichAsset { struct ImmichAsset {
QString id; QString id;
QString originalFileName; QString originalFileName;
QString exifDateTime;
}; };
class ImmichClient { class ImmichClient {
@@ -21,10 +22,12 @@ class ImmichClient {
explicit ImmichClient(const ImmichConfig &config); explicit ImmichClient(const ImmichConfig &config);
QVector<ImmichAsset> fetchAssets(); QVector<ImmichAsset> fetchAssets();
bool downloadAsset(const QString &assetId, QByteArray &data, QString &contentType); bool downloadAsset(const QString &assetId, QByteArray &data, QString &contentType);
bool downloadAssetWithSize(const QString &assetId, const QString &sizeOverride, QByteArray &data, QString &contentType);
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;
@@ -54,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

View File

@@ -1,5 +1,9 @@
#include "immichpathtraverser.h" #include "immichpathtraverser.h"
#include "logger.h" #include "logger.h"
#include <QDateTime>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
ImmichPathTraverser::ImmichPathTraverser(const ImmichConfig &configIn) ImmichPathTraverser::ImmichPathTraverser(const ImmichConfig &configIn)
: PathTraverser(""), : PathTraverser(""),
@@ -16,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)
{ {
@@ -23,12 +28,20 @@ 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();
} }
QStringList ImmichPathTraverser::getImages() const QStringList ImmichPathTraverser::getImages() const
{ {
if (refreshDue())
{
Log("Immich refresh due, reloading assets.");
const_cast<ImmichPathTraverser*>(this)->loadAssets();
}
return assetIds; return assetIds;
} }
@@ -37,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();
} }
@@ -45,3 +64,45 @@ ImageDisplayOptions ImmichPathTraverser::UpdateOptionsForImage(const std::string
Q_UNUSED(filename); Q_UNUSED(filename);
return options; 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();
}

View File

@@ -5,6 +5,7 @@
#include "immichclient.h" #include "immichclient.h"
#include <QHash> #include <QHash>
#include <QDateTime>
class ImmichPathTraverser : public PathTraverser class ImmichPathTraverser : public PathTraverser
{ {
@@ -14,15 +15,20 @@ class ImmichPathTraverser : public PathTraverser
QStringList getImages() const override; QStringList getImages() const override;
virtual const std::string getImagePath(const std::string image) 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; virtual ImageDisplayOptions UpdateOptionsForImage(const std::string& filename, const ImageDisplayOptions& options) const override;
bool shouldReloadImages() const override;
private: private:
void loadAssets(); void loadAssets();
void writeExifSidecar(const QString &imagePath, const QString &dateTime) const;
bool refreshDue() const;
ImmichConfig config; ImmichConfig config;
mutable ImmichClient client; mutable ImmichClient client;
mutable ImmichAssetCache cache; mutable ImmichAssetCache cache;
QStringList assetIds; QStringList assetIds;
QHash<QString, QString> assetNames; QHash<QString, QString> assetNames;
QHash<QString, QString> assetDateTimes;
QDateTime lastRefresh;
}; };
#endif // IMMICHPATHTRAVERSER_H #endif // IMMICHPATHTRAVERSER_H

View File

@@ -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);
} }
@@ -365,6 +366,16 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload)
config.maxAssets = (int)obj["maxAssets"].toDouble(); config.maxAssets = (int)obj["maxAssets"].toDouble();
changed = true; 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.contains("includeArchived"))
{ {
if (obj["includeArchived"].isBool()) if (obj["includeArchived"].isBool())
@@ -528,6 +539,26 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload)
return true; 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; return false;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ class PathTraverser
virtual QStringList getImages() const = 0; virtual QStringList getImages() const = 0;
virtual const std::string getImagePath(const std::string image) const = 0; virtual const std::string getImagePath(const std::string image) const = 0;
virtual ImageDisplayOptions UpdateOptionsForImage(const std::string& filename, const ImageDisplayOptions& baseOptions) const = 0; virtual ImageDisplayOptions UpdateOptionsForImage(const std::string& filename, const ImageDisplayOptions& baseOptions) const = 0;
virtual bool shouldReloadImages() const { return false; }
protected: protected:
const std::string path; const std::string path;