diff --git a/README.md b/README.md index 370e9e1..96b6953 100644 --- a/README.md +++ b/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) * `background_opacity(default=150)`: opacity of the background filling image between 0 (black background) and 255 * `blur_radius(default=20)`: blur radius of the background filling image -* `-v` or `--verbose`: Verbose debug output when running, plus a thumbnail of the original image in the bottom left of the screen +* `-v` or `--verbose`: Verbose debug output when running * `--stretch`: When in aspect mode 'l','p' or 'm' crop the image rather than leaving a blurred background. For example, in landscape mode this will make images as wide as the screen and crop the top and bottom to fit. * `-h` or `--overlay-color` the color of the overlay text, in the form of 3 or 6 digits hex rgb string prefixed by `#`, for example `#00FF00` or `#0F0` for color 🟢 * `-O` is used to create a overlay string. @@ -69,6 +69,7 @@ The file format is: "rotationSeconds" : 300, "opacity" : 200, "debug" : false, + "debugThumbnail" : false, "scheduler" : [ { "exclusive" : true, @@ -117,6 +118,7 @@ Supported keys and values in the JSON configuration are: * `opacity` : the same as the command line `-o` argument * `blur` : the same as the command line `-b` argument * `debug` : set to true to enable verbose output from the program +* `debugThumbnail` : set to true to draw a small thumbnail of the source image in the bottom left * `mqtt` : MQTT playback control (see below) * `immich` : connect to an Immich server instead of a local path (see below) * `scheduler` : this entry is an array of possible path values and associated settings. This key lets you manage display times/settings for a collection of paths. In the example above the top entry shows ONLY files from a Redit feed between 2 and 4pm, ONLY files from the `show_peak_times` folder from 8am to 10am and then 4pm to 7pm. At all other times it alternates displaying files in the `always_show_1` and `always_show_2` folder. @@ -167,7 +169,7 @@ Immich control topic (`immichTopic`): ### 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 `` continues to work even if the cached image doesn't contain EXIF data. #### Getting an Immich API key @@ -192,6 +194,7 @@ Example (single source): "pageSize": 200, "maxAssets": 1000, "refreshSeconds": 300, + "skipRetrySeconds": 3600, "cachePath": "~/.cache/slide/immich", "cacheMaxMB": 512, "includeArchived": false @@ -228,6 +231,7 @@ Immich settings: * `pageSize`: assets fetched per page. * `maxAssets`: cap on total assets fetched (0 means no cap). * `refreshSeconds`: refresh interval for reloading Immich assets (0 disables). +* `skipRetrySeconds`: retry interval for assets marked unsupported (0 disables retry). * `cachePath`: local cache directory for downloaded images. * `cacheMaxMB`: maximum cache size in MB (0 disables cleanup). * `includeArchived`: include archived assets in search results. @@ -266,6 +270,10 @@ See the `Configuration File` section for details of each setting. * libexif * libmosquitto-dev +## Home Assistant + +See `doc/homeassistant.md` for a ready-to-use MQTT dashboard and scripts. + Ubuntu/Raspbian: ``` diff --git a/doc/homeassistant.md b/doc/homeassistant.md new file mode 100644 index 0000000..696021e --- /dev/null +++ b/doc/homeassistant.md @@ -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_template: "album:{{ states('input_text.slide_album_id') }}" + +slide_mqtt_set_person: + alias: Slide Set Person + sequence: + - service: mqtt.publish + data: + topic: slide/immich + payload_template: "person:{{ states('input_text.slide_person_id') }}" + +slide_mqtt_set_user: + alias: Slide Set User + sequence: + - service: mqtt.publish + data: + topic: slide/immich + payload_template: "user:{{ states('input_text.slide_user_id') }}" + +slide_mqtt_set_extensions: + alias: Slide Set Extensions + sequence: + - service: mqtt.publish + data: + topic: slide/immich + payload_template: "extensions:{{ states('input_text.slide_extensions') }}" + +slide_mqtt_set_refresh: + alias: Slide Set Refresh Seconds + sequence: + - service: mqtt.publish + data: + topic: slide/immich + payload_template: "refreshSeconds:{{ states('input_text.slide_refresh_seconds') }}" + +slide_mqtt_set_size: + alias: Slide Set Size + sequence: + - service: mqtt.publish + data: + topic: slide/immich + payload_template: "size:{{ states('input_select.slide_size') }}" + +slide_mqtt_set_order: + alias: Slide Set Order + sequence: + - service: mqtt.publish + data: + topic: slide/immich + payload_template: "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. diff --git a/src/appconfig.cpp b/src/appconfig.cpp index a9b99a9..d7e9e85 100644 --- a/src/appconfig.cpp +++ b/src/appconfig.cpp @@ -139,6 +139,8 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) { SetJSONInt(config.maxAssets, immichJson, "maxAssets"); SetJSONInt(config.refreshSeconds, immichJson, "refreshSeconds"); SetJSONInt(config.refreshSeconds, immichJson, "refreshIntervalSeconds"); + SetJSONInt(config.skipRetrySeconds, immichJson, "skipRetrySeconds"); + SetJSONInt(config.skipRetrySeconds, immichJson, "skipRetryIntervalSeconds"); SetJSONInt(config.cacheMaxMB, immichJson, "cacheMaxMB"); SetJSONBool(config.includeArchived, immichJson, "includeArchived"); @@ -404,6 +406,7 @@ AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) { SetJSONBool(baseShuffle, jsonDoc, "shuffle"); SetJSONBool(baseSorted, jsonDoc, "sorted"); SetJSONBool(loadedConfig.debugMode, jsonDoc, "debug"); + SetJSONBool(loadedConfig.debugThumbnail, jsonDoc, "debugThumbnail"); std::string overlayString = ParseJSONString(jsonDoc, "overlay"); if(!overlayString.empty()) diff --git a/src/appconfig.h b/src/appconfig.h index c25930e..0653a88 100644 --- a/src/appconfig.h +++ b/src/appconfig.h @@ -19,6 +19,7 @@ struct ImmichConfig { int pageSize = 200; int maxAssets = 1000; int refreshSeconds = 300; + int skipRetrySeconds = 3600; std::string cachePath = ""; int cacheMaxMB = 512; bool includeArchived = false; @@ -62,6 +63,8 @@ struct ImmichConfig { } if (refreshSeconds != b.refreshSeconds) return false; + if (skipRetrySeconds != b.skipRetrySeconds) + return false; return true; } @@ -163,6 +166,7 @@ struct AppConfig : public Config { MqttConfig mqtt; bool debugMode = false; + bool debugThumbnail = false; static const std::string valid_aspects; public: diff --git a/src/immichclient.cpp b/src/immichclient.cpp index 8c45d50..50b0339 100644 --- a/src/immichclient.cpp +++ b/src/immichclient.cpp @@ -14,11 +14,42 @@ #include #include #include +#include #include namespace { const int kMetadataTimeoutMs = 15000; const int kAssetTimeoutMs = 30000; +QString ReadJsonString(const QJsonObject &obj, const char *key) +{ + auto value = obj.value(key); + if (!value.isString()) + return ""; + return value.toString(); +} + +QString ExtractAssetDateTime(const QJsonObject &item) +{ + QJsonObject exifInfo = item.value("exifInfo").toObject(); + const char *exifKeys[] = {"dateTimeOriginal", "dateTimeDigitized", "dateTime", "createDate"}; + for (const auto *key : exifKeys) + { + QString value = ReadJsonString(exifInfo, key); + if (!value.isEmpty()) + return value; + } + + const char *fallbackKeys[] = {"localDateTime", "fileCreatedAt", "createdAt", "fileModifiedAt", "updatedAt"}; + for (const auto *key : fallbackKeys) + { + QString value = ReadJsonString(item, key); + if (!value.isEmpty()) + return value; + } + + return ""; +} + QString DetectImageExtension(const QByteArray &data) { QBuffer buffer; @@ -202,6 +233,7 @@ QVector ImmichClient::fetchAssetsBySearch() ImmichAsset asset; asset.id = id; asset.originalFileName = item["originalFileName"].toString(); + asset.exifDateTime = ExtractAssetDateTime(item); if (!extensionAllowed(asset.originalFileName)) { Log("Immich skip by extension: ", asset.originalFileName.toStdString()); @@ -269,6 +301,7 @@ QVector ImmichClient::fetchAssetsByUser() ImmichAsset asset; asset.id = id; asset.originalFileName = item["originalFileName"].toString(); + asset.exifDateTime = ExtractAssetDateTime(item); if (!extensionAllowed(asset.originalFileName)) { Log("Immich skip by extension: ", asset.originalFileName.toStdString()); @@ -349,6 +382,7 @@ ImmichAssetCache::ImmichAssetCache(const ImmichConfig &config) cacheDirPath = resolveCachePath(rawPath); if (config.cacheMaxMB > 0) cacheMaxBytes = static_cast(config.cacheMaxMB) * 1024 * 1024; + skipRetrySeconds = config.skipRetrySeconds; } QString ImmichAssetCache::resolveCachePath(const QString &rawPath) const @@ -446,8 +480,25 @@ QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &a { if (existing.endsWith(".skip")) { - Log("Immich skip marker: ", assetId.toStdString()); - return ""; + 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) diff --git a/src/immichclient.h b/src/immichclient.h index efa9eb7..0ce29b1 100644 --- a/src/immichclient.h +++ b/src/immichclient.h @@ -14,6 +14,7 @@ struct ImmichAsset { QString id; QString originalFileName; + QString exifDateTime; }; class ImmichClient { @@ -55,6 +56,7 @@ class ImmichAssetCache { qint64 cacheMaxBytes = 0; bool cacheSizeKnown = false; qint64 cacheSizeBytes = 0; + int skipRetrySeconds = 0; }; #endif // IMMICHCLIENT_H diff --git a/src/immichpathtraverser.cpp b/src/immichpathtraverser.cpp index d6e1546..83d8965 100644 --- a/src/immichpathtraverser.cpp +++ b/src/immichpathtraverser.cpp @@ -1,6 +1,9 @@ #include "immichpathtraverser.h" #include "logger.h" #include +#include +#include +#include ImmichPathTraverser::ImmichPathTraverser(const ImmichConfig &configIn) : PathTraverser(""), @@ -17,6 +20,7 @@ void ImmichPathTraverser::loadAssets() { assetIds.clear(); assetNames.clear(); + assetDateTimes.clear(); QVector assets = client.fetchAssets(); for (const auto &asset : assets) { @@ -24,6 +28,8 @@ void ImmichPathTraverser::loadAssets() continue; assetIds.append(asset.id); assetNames.insert(asset.id, asset.originalFileName); + if (!asset.exifDateTime.isEmpty()) + assetDateTimes.insert(asset.id, asset.exifDateTime); } Log("Immich assets loaded: ", assetIds.size()); lastRefresh = QDateTime::currentDateTime(); @@ -44,6 +50,12 @@ const std::string ImmichPathTraverser::getImagePath(const std::string image) con QString assetId = QString::fromStdString(image); QString name = assetNames.value(assetId); QString path = cache.getCachedPath(assetId, name, client); + if (!path.isEmpty()) + { + QString dateTime = assetDateTimes.value(assetId); + if (!dateTime.isEmpty()) + writeExifSidecar(path, dateTime); + } return path.toStdString(); } @@ -66,3 +78,29 @@ bool ImmichPathTraverser::shouldReloadImages() const { return refreshDue(); } + +void ImmichPathTraverser::writeExifSidecar(const QString &imagePath, const QString &dateTime) const +{ + if (imagePath.isEmpty() || dateTime.isEmpty()) + 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(); +} diff --git a/src/immichpathtraverser.h b/src/immichpathtraverser.h index 5b7e219..ac75a80 100644 --- a/src/immichpathtraverser.h +++ b/src/immichpathtraverser.h @@ -19,6 +19,7 @@ class ImmichPathTraverser : public PathTraverser private: void loadAssets(); + void writeExifSidecar(const QString &imagePath, const QString &dateTime) const; bool refreshDue() const; ImmichConfig config; @@ -26,6 +27,7 @@ class ImmichPathTraverser : public PathTraverser mutable ImmichAssetCache cache; QStringList assetIds; QHash assetNames; + QHash assetDateTimes; QDateTime lastRefresh; }; diff --git a/src/main.cpp b/src/main.cpp index f453530..d949dc7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -156,6 +156,7 @@ void ConfigureWindowFromSettings(MainWindow &w, const AppConfig &appConfig) std::unique_ptr o = std::unique_ptr(new Overlay(appConfig.overlay)); w.setOverlay(o); } + w.setDebugThumbnail(appConfig.debugThumbnail); w.setBaseOptions(appConfig.baseDisplayOptions); } @@ -370,6 +371,11 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload) config.refreshSeconds = (int)obj["refreshSeconds"].toDouble(); changed = true; } + if (obj.contains("skipRetrySeconds") && obj["skipRetrySeconds"].isDouble()) + { + config.skipRetrySeconds = (int)obj["skipRetrySeconds"].toDouble(); + changed = true; + } if (obj.contains("includeArchived")) { if (obj["includeArchived"].isBool()) @@ -543,6 +549,16 @@ static bool ApplyImmichPayload(ImmichConfig &config, const QString &payload) return true; } } + if (key == "skipretryseconds") + { + bool ok = false; + int parsed = value.toInt(&ok); + if (ok) + { + config.skipRetrySeconds = parsed; + return true; + } + } return false; } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 3e6b645..4378d5c 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -164,6 +164,11 @@ void MainWindow::setImage(const ImageDetails &imageDetails) updateImage(); } +void MainWindow::setDebugThumbnail(bool enabled) +{ + debugThumbnail = enabled; +} + void MainWindow::updateImage() { 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->getMarginBottomLeft(), overlay->getFontsizeBottomLeft(), overlay->getRenderedBottomLeft(currentImage.filename).c_str(), Qt::AlignBottom|Qt::AlignLeft); drawText(background, overlay->getMarginBottomRight(), overlay->getFontsizeBottomRight(), overlay->getRenderedBottomRight(currentImage.filename).c_str(), Qt::AlignBottom|Qt::AlignRight); - if (ShouldLog()) + if (debugThumbnail) { // draw a thumbnail version of the source image in the bottom left, to check for cropping issues QPainter pt(&background); diff --git a/src/mainwindow.h b/src/mainwindow.h index 329a809..37ca25c 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -34,6 +34,7 @@ public: const ImageDisplayOptions &getBaseOptions(); void setImageSwitcher(ImageSwitcher *switcherIn); void setOverlayHexRGB(QString overlayHexRGB); + void setDebugThumbnail(bool enabled); public slots: void checkWindowSize(); private: @@ -47,6 +48,7 @@ private: QSize lastScreenSize = {0,0}; QString overlayHexRGB = "#FFFF"; unsigned int transitionSeconds = 1; + bool debugThumbnail = false; std::unique_ptr overlay; ImageSwitcher *switcher = nullptr; diff --git a/src/overlay.cpp b/src/overlay.cpp index 313d09f..accba55 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -162,9 +163,47 @@ QString Overlay::getExifDate(std::string filename) { dateTime = exif_entry_get_value(exifEntry, buf, sizeof(buf)); } exif_data_free(exifData); - QString exifDateFormat = "yyyy:MM:dd hh:mm:ss"; - QDateTime exifDateTime = QDateTime::fromString(dateTime, exifDateFormat); - return QLocale::system().toString(exifDateTime); } - return dateTime; + QString formatted = formatExifDateString(dateTime); + if (!formatted.isEmpty()) + return formatted; + + QString sidecar = readSidecarExifDate(QString::fromStdString(filename)); + if (!sidecar.isEmpty()) + return formatExifDateString(sidecar); + + return ""; +} + +QString Overlay::readSidecarExifDate(const QString &filePath) +{ + QString sidecarPath = filePath + ".exif"; + QFile file(sidecarPath); + if (!file.exists()) + return ""; + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return ""; + QString contents = QString::fromUtf8(file.readAll()).trimmed(); + file.close(); + return contents; +} + +QString Overlay::formatExifDateString(const QString &raw) +{ + QString trimmed = raw.trimmed(); + if (trimmed.isEmpty()) + return ""; + + QDateTime parsed = QDateTime::fromString(trimmed, Qt::ISODate); + if (!parsed.isValid()) + parsed = QDateTime::fromString(trimmed, Qt::ISODateWithMs); + if (!parsed.isValid()) + parsed = QDateTime::fromString(trimmed, "yyyy:MM:dd hh:mm:ss"); + if (!parsed.isValid()) + parsed = QDateTime::fromString(trimmed, "yyyy-MM-dd hh:mm:ss"); + + if (parsed.isValid()) + return QLocale::system().toString(parsed); + + return trimmed; } diff --git a/src/overlay.h b/src/overlay.h index d11f8f2..8f8e503 100644 --- a/src/overlay.h +++ b/src/overlay.h @@ -59,5 +59,7 @@ class Overlay QString getBasename(std::string filename); void parseInput(); std::string renderString(QString overlayTemplate, std::string filename); + QString readSidecarExifDate(const QString &filePath); + QString formatExifDateString(const QString &raw); }; #endif