diff --git a/README.md b/README.md index 0eea03a..1b68120 100644 --- a/README.md +++ b/README.md @@ -117,12 +117,65 @@ 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 +* `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. * `exclusive` : When set to `true` only this entry will be used when it is in its valid time window. * `times` : times is a JSON array of start and end times in which it is valid to display this image. The time is in the format HH:MM:SS and is based on the systems local time. If `start` isn't defined then it defaults to the start of the day, if `end` isn't defined it defaults to the end of the day. * `path` : the path to image files * `stretch` : as above +### Immich configuration (lightweight + low power) + +Immich uses an API key and a `/api` base path. This integration requests the asset search endpoint and downloads thumbnail images into a local cache before displaying them. That keeps bandwidth and power usage low while still letting `slide` do its normal scaling and transitions. + +Example (single source): +``` +{ + "immich": { + "url": "http://immich.local:2283", + "apiKey": "IMMICH_API_KEY", + "albumId": "b7f3c8b2-2e3f-4b32-9dc9-8c3f8b0a3ef7", + "size": "fullsize", + "order": "desc", + "pageSize": 200, + "maxAssets": 1000, + "cachePath": "~/.cache/slide/immich", + "cacheMaxMB": 512, + "includeArchived": false + } +} +``` + +Example (scheduler entry): +``` +{ + "scheduler": [ + { + "exclusive": true, + "immich": { + "url": "http://immich.local:2283", + "apiKey": "IMMICH_API_KEY", + "albumIds": ["b7f3c8b2-2e3f-4b32-9dc9-8c3f8b0a3ef7"], + "size": "fullsize" + } + } + ] +} +``` + +Immich settings: +* `url`: base Immich server URL (the integration appends `/api` automatically if missing). +* `apiKey`: Immich API key (needs `asset.view`, and `asset.download` if `size` is `original`). +* `albumId` or `albumIds`: optional album filters. +* `size`: `"fullsize"`, `"preview"`, `"thumbnail"`, or `"original"` (original uses the download endpoint). +* `order`: `"asc"` or `"desc"` ordering for asset search. +* `pageSize`: assets fetched per page. +* `maxAssets`: cap on total assets fetched (0 means no cap). +* `cachePath`: local cache directory for downloaded thumbnails. +* `cacheMaxMB`: maximum cache size in MB (0 disables cleanup). +* `includeArchived`: include archived assets in search results. +When `immich` is set on an entry, `path` and `imageList` are ignored. + ## Folder Options file When using the default or recursive folder mode we support having per folder display options. The options are stored in a file called "options.json" in the images folder and support a subset of the applications configuration settings: ``` diff --git a/sbin/build_deb.sh b/sbin/build_deb.sh new file mode 100644 index 0000000..fd01cde --- /dev/null +++ b/sbin/build_deb.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION="${1:-${VERSION:-0.0.0}}" +VERSION="${VERSION#v}" +ARCH="${ARCH:-$(dpkg --print-architecture)}" + +PACKAGE_NAME="slide" +BUILD_DIR="$ROOT_DIR/build" +DIST_DIR="$ROOT_DIR/dist" +STAGE_DIR="$BUILD_DIR/deb" + +cd "$ROOT_DIR" +make build + +rm -rf "$STAGE_DIR" +mkdir -p "$STAGE_DIR/DEBIAN" "$STAGE_DIR/usr/local/bin" "$DIST_DIR" + +install -m 0755 "$BUILD_DIR/slide" "$STAGE_DIR/usr/local/bin/slide" + +cat > "$STAGE_DIR/DEBIAN/control" < ParseJSONStrings(QJsonObject jsonDoc, const char *key) { + std::vector values; + if(jsonDoc.contains(key) && jsonDoc[key].isArray()) + { + QJsonArray jsonArray = jsonDoc[key].toArray(); + foreach (const QJsonValue & value, jsonArray) + { + if (value.isString()) + { + values.push_back(value.toString().toStdString()); + } + } + } + return values; +} + +ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) { + ImmichConfig config; + + std::string url = ParseJSONString(immichJson, "url"); + std::string apiKey = ParseJSONString(immichJson, "apiKey"); + if(!url.empty()) + config.url = url; + if(!apiKey.empty()) + config.apiKey = apiKey; + + std::string size = ParseJSONString(immichJson, "size"); + if(!size.empty()) + config.size = size; + + std::string order = ParseJSONString(immichJson, "order"); + if(!order.empty()) + config.order = order; + + std::string cachePath = ParseJSONString(immichJson, "cachePath"); + if(!cachePath.empty()) + config.cachePath = cachePath; + + std::string albumId = ParseJSONString(immichJson, "albumId"); + if(!albumId.empty()) + config.albumIds.push_back(albumId); + + std::vector albumIds = ParseJSONStrings(immichJson, "albumIds"); + if(albumIds.size() > 0) + config.albumIds = albumIds; + + SetJSONInt(config.pageSize, immichJson, "pageSize"); + SetJSONInt(config.maxAssets, immichJson, "maxAssets"); + SetJSONInt(config.cacheMaxMB, immichJson, "cacheMaxMB"); + SetJSONBool(config.includeArchived, immichJson, "includeArchived"); + + if(!config.url.empty() && !config.apiKey.empty()) + config.enabled = true; + + return config; +} + Config loadConfiguration(const std::string &configFilePath, const Config ¤tConfig) { if(configFilePath.empty()) { @@ -184,6 +248,11 @@ QVector parsePathEntry(QJsonObject &jsonMainDoc, bool baseRecursive, SetJSONBool(entry.baseDisplayOptions.fitAspectAxisToWindow, schedulerJson, "stretch"); + if(schedulerJson.contains("immich") && schedulerJson["immich"].isObject()) + { + entry.immich = ParseImmichConfigObject(schedulerJson["immich"].toObject()); + } + std::string pathString = ParseJSONString(schedulerJson, "path"); if(!pathString.empty()) { entry.path = pathString; @@ -265,6 +334,10 @@ AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) { entry.recursive = baseRecursive; entry.sorted = baseSorted; entry.shuffle = baseShuffle; + if(jsonDoc.contains("immich") && jsonDoc["immich"].isObject()) + { + entry.immich = ParseImmichConfigObject(jsonDoc["immich"].toObject()); + } std::string pathString = ParseJSONString(jsonDoc, "path"); if(!pathString.empty()) { @@ -293,4 +366,4 @@ Config getConfigurationForFolder(const std::string &folderPath, const Config &cu return loadConfiguration(jsonFile.toStdString(), currentConfig ); } return currentConfig; -} \ No newline at end of file +} diff --git a/src/appconfig.h b/src/appconfig.h index 64b22e2..503b565 100644 --- a/src/appconfig.h +++ b/src/appconfig.h @@ -4,6 +4,50 @@ #include #include "imagestructs.h" #include +#include + +struct ImmichConfig { + bool enabled = false; + std::string url = ""; + std::string apiKey = ""; + std::vector albumIds; + std::string size = "fullsize"; + std::string order = "desc"; + int pageSize = 200; + int maxAssets = 1000; + std::string cachePath = ""; + int cacheMaxMB = 512; + bool includeArchived = false; + + bool operator==(const ImmichConfig &b) const + { + if (enabled != b.enabled) + return false; + if (url != b.url || apiKey != b.apiKey) + return false; + if (size != b.size || order != b.order) + return false; + if (pageSize != b.pageSize || maxAssets != b.maxAssets) + return false; + if (cachePath != b.cachePath || cacheMaxMB != b.cacheMaxMB) + return false; + if (includeArchived != b.includeArchived) + return false; + if (albumIds.size() != b.albumIds.size()) + return false; + for (size_t i = 0; i < albumIds.size(); ++i) + { + if (albumIds[i] != b.albumIds[i]) + return false; + } + return true; + } + + bool operator!=(const ImmichConfig &b) const + { + return !operator==(b); + } +}; // configuration options that apply to an image/folder of images struct Config { @@ -21,6 +65,7 @@ struct PathEntry { std::string imageList = ""; bool exclusive = false; // only use this entry when it is valid, skip others + ImmichConfig immich; bool recursive = false; bool shuffle = false; bool sorted = false; @@ -40,6 +85,8 @@ struct PathEntry { return true; if (b.path != path || b.imageList != imageList) return true; + if (b.immich != immich) + return true; if (b.baseDisplayOptions.timeWindows.count() != baseDisplayOptions.timeWindows.count()) return true; for(int i = 0; i < baseDisplayOptions.timeWindows.count(); ++i) @@ -83,4 +130,4 @@ Config getConfigurationForFolder(const std::string &folderPath, const Config &cu ImageAspectScreenFilter parseAspectFromString(char aspect); QString getAppConfigFilePath(const std::string &configPath); -#endif \ No newline at end of file +#endif diff --git a/src/immichclient.cpp b/src/immichclient.cpp new file mode 100644 index 0000000..972ff1d --- /dev/null +++ b/src/immichclient.cpp @@ -0,0 +1,337 @@ +#include "immichclient.h" +#include "logger.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +const int kMetadataTimeoutMs = 15000; +const int kAssetTimeoutMs = 30000; +} + +ImmichClient::ImmichClient(const ImmichConfig &configIn) + : config(configIn) +{} + +QUrl ImmichClient::apiUrl(const QString &path) const +{ + QString base = QString::fromStdString(config.url).trimmed(); + if (base.endsWith("/")) + base.chop(1); + if (!base.endsWith("/api")) + base += "/api"; + return QUrl(base + path); +} + +QNetworkRequest ImmichClient::makeRequest(const QUrl &url) const +{ + QNetworkRequest request(url); + request.setRawHeader("x-api-key", QByteArray::fromStdString(config.apiKey)); + return request; +} + +bool ImmichClient::waitForReply(QNetworkReply *reply, QByteArray &data, QString *contentType, int timeoutMs) +{ + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + timer.start(timeoutMs); + loop.exec(); + + if (!timer.isActive()) + { + reply->abort(); + reply->deleteLater(); + return false; + } + + if (contentType) + *contentType = reply->header(QNetworkRequest::ContentTypeHeader).toString(); + data = reply->readAll(); + bool ok = reply->error() == QNetworkReply::NoError; + if (!ok) + { + Log("Immich request failed: ", reply->errorString().toStdString()); + } + reply->deleteLater(); + return ok; +} + +QByteArray ImmichClient::postJson(const QUrl &url, const QJsonObject &body, QString *contentType, int timeoutMs) +{ + QNetworkRequest request = makeRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + QNetworkReply *reply = manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact)); + QByteArray data; + if (!waitForReply(reply, data, contentType, timeoutMs)) + return QByteArray(); + return data; +} + +QByteArray ImmichClient::getBytes(const QUrl &url, QString *contentType, int timeoutMs) +{ + QNetworkRequest request = makeRequest(url); + request.setRawHeader("Accept", "*/*"); + QNetworkReply *reply = manager.get(request); + QByteArray data; + if (!waitForReply(reply, data, contentType, timeoutMs)) + return QByteArray(); + return data; +} + +QVector ImmichClient::fetchAssets() +{ + QVector assets; + if (!config.enabled) + { + Log("Immich config is missing url or apiKey."); + return assets; + } + + int pageSize = config.pageSize > 0 ? config.pageSize : 200; + int maxAssets = config.maxAssets; + bool triedZero = false; + int page = 1; + + while (true) + { + QJsonObject body; + body["page"] = page; + body["size"] = pageSize; + body["type"] = "IMAGE"; + body["order"] = QString::fromStdString(config.order); + if (config.includeArchived) + body["withArchived"] = true; + if (config.albumIds.size() > 0) + { + QJsonArray ids; + for (const auto &id : config.albumIds) + ids.append(QString::fromStdString(id)); + body["albumIds"] = ids; + } + + QByteArray response = postJson(apiUrl("/search/metadata"), body, nullptr, kMetadataTimeoutMs); + if (response.isEmpty()) + break; + + QJsonDocument doc = QJsonDocument::fromJson(response); + if (!doc.isObject()) + break; + + QJsonObject root = doc.object(); + QJsonObject assetsObj = root["assets"].toObject(); + QJsonArray items = assetsObj["items"].toArray(); + int total = assetsObj["total"].toInt(); + if (items.isEmpty()) + { + if (total > 0 && page == 1 && !triedZero) + { + triedZero = true; + page = 0; + continue; + } + break; + } + + for (const auto &value : items) + { + QJsonObject item = value.toObject(); + QString id = item["id"].toString(); + if (id.isEmpty()) + continue; + ImmichAsset asset; + asset.id = id; + asset.originalFileName = item["originalFileName"].toString(); + assets.append(asset); + if (maxAssets > 0 && assets.size() >= maxAssets) + return assets; + } + + if (items.size() < pageSize) + break; + page += 1; + } + + return assets; +} + +bool ImmichClient::downloadAsset(const QString &assetId, QByteArray &data, QString &contentType) +{ + if (!config.enabled) + return false; + + QString size = QString::fromStdString(config.size).trimmed().toLower(); + if (size.isEmpty()) + size = "fullsize"; + + QUrl url; + if (size == "original" || size == "download") + { + url = apiUrl("/assets/" + assetId + "/download"); + } + else + { + if (size != "fullsize" && size != "preview" && size != "thumbnail") + { + Log("Immich size '", size.toStdString(), "' not recognized. Defaulting to fullsize."); + size = "fullsize"; + } + url = apiUrl("/assets/" + assetId + "/thumbnail"); + QUrlQuery query; + query.addQueryItem("size", size); + url.setQuery(query); + } + + QByteArray payload = getBytes(url, &contentType, kAssetTimeoutMs); + if (payload.isEmpty()) + return false; + + data = payload; + return true; +} + +ImmichAssetCache::ImmichAssetCache(const ImmichConfig &config) +{ + QString rawPath = QString::fromStdString(config.cachePath); + cacheDirPath = resolveCachePath(rawPath); + if (config.cacheMaxMB > 0) + cacheMaxBytes = static_cast(config.cacheMaxMB) * 1024 * 1024; +} + +QString ImmichAssetCache::resolveCachePath(const QString &rawPath) const +{ + if (rawPath.isEmpty()) + { + return QDir::homePath() + "/.cache/slide/immich"; + } + if (rawPath.startsWith("~")) + { + return QDir::homePath() + rawPath.mid(1); + } + return rawPath; +} + +void ImmichAssetCache::ensureCacheDir() const +{ + QDir dir(cacheDirPath); + if (!dir.exists()) + dir.mkpath("."); +} + +QString ImmichAssetCache::findExisting(const QString &assetId) const +{ + QDir dir(cacheDirPath); + QStringList matches = dir.entryList(QStringList() << (assetId + "_*"), QDir::Files, QDir::Time); + if (matches.isEmpty()) + return ""; + return dir.filePath(matches.first()); +} + +QString ImmichAssetCache::sanitizeFileName(const QString &name) const +{ + QString safe = name; + safe.replace(QRegularExpression("[^A-Za-z0-9_.-]"), "_"); + if (safe.isEmpty()) + safe = "asset"; + return safe; +} + +QString ImmichAssetCache::extensionForContentType(const QString &contentType) const +{ + if (contentType.contains("jpeg", Qt::CaseInsensitive)) + return "jpg"; + if (contentType.contains("png", Qt::CaseInsensitive)) + return "png"; + if (contentType.contains("webp", Qt::CaseInsensitive)) + return "webp"; + if (contentType.contains("gif", Qt::CaseInsensitive)) + return "gif"; + return "img"; +} + +qint64 ImmichAssetCache::calculateCacheSize() const +{ + QDir dir(cacheDirPath); + QFileInfoList files = dir.entryInfoList(QDir::Files, QDir::Time); + qint64 total = 0; + for (const auto &info : files) + total += info.size(); + return total; +} + +void ImmichAssetCache::enforceCacheLimit() +{ + if (cacheMaxBytes <= 0) + return; + + QDir dir(cacheDirPath); + QFileInfoList files = dir.entryInfoList(QDir::Files, QDir::Time); + qint64 total = 0; + for (const auto &info : files) + total += info.size(); + + for (int i = files.size() - 1; i >= 0 && total > cacheMaxBytes; --i) + { + total -= files[i].size(); + QFile::remove(files[i].filePath()); + } + cacheSizeBytes = total; + cacheSizeKnown = true; +} + +QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &assetName, ImmichClient &client) +{ + ensureCacheDir(); + + QString existing = findExisting(assetId); + if (!existing.isEmpty()) + return existing; + + QByteArray data; + QString contentType; + if (!client.downloadAsset(assetId, data, contentType)) + return ""; + + QString safeName = sanitizeFileName(assetName); + QString extension = extensionForContentType(contentType); + QString filename = assetId + "_" + safeName + "." + extension; + + QDir dir(cacheDirPath); + QString filePath = dir.filePath(filename); + + QSaveFile file(filePath); + if (!file.open(QIODevice::WriteOnly)) + return ""; + file.write(data); + if (!file.commit()) + return ""; + + if (cacheMaxBytes > 0) + { + if (!cacheSizeKnown) + { + cacheSizeBytes = calculateCacheSize(); + cacheSizeKnown = true; + } + else + { + cacheSizeBytes += data.size(); + } + if (cacheSizeBytes > cacheMaxBytes) + enforceCacheLimit(); + } + + return filePath; +} diff --git a/src/immichclient.h b/src/immichclient.h new file mode 100644 index 0000000..8adaae1 --- /dev/null +++ b/src/immichclient.h @@ -0,0 +1,56 @@ +#ifndef IMMICHCLIENT_H +#define IMMICHCLIENT_H + +#include +#include +#include +#include +#include +#include +#include + +#include "appconfig.h" + +struct ImmichAsset { + QString id; + QString originalFileName; +}; + +class ImmichClient { + public: + explicit ImmichClient(const ImmichConfig &config); + QVector fetchAssets(); + bool downloadAsset(const QString &assetId, QByteArray &data, QString &contentType); + + private: + QUrl apiUrl(const QString &path) const; + QNetworkRequest makeRequest(const QUrl &url) const; + QByteArray postJson(const QUrl &url, const QJsonObject &body, QString *contentType, int timeoutMs); + QByteArray getBytes(const QUrl &url, QString *contentType, int timeoutMs); + bool waitForReply(QNetworkReply *reply, QByteArray &data, QString *contentType, int timeoutMs); + + ImmichConfig config; + QNetworkAccessManager manager; +}; + +class ImmichAssetCache { + public: + explicit ImmichAssetCache(const ImmichConfig &config); + QString getCachedPath(const QString &assetId, const QString &assetName, ImmichClient &client); + + private: + QString resolveCachePath(const QString &rawPath) const; + QString findExisting(const QString &assetId) const; + QString sanitizeFileName(const QString &name) const; + QString extensionForContentType(const QString &contentType) const; + void ensureCacheDir() const; + void enforceCacheLimit(); + qint64 calculateCacheSize() const; + + QString cacheDirPath; + qint64 cacheMaxBytes = 0; + bool cacheSizeKnown = false; + qint64 cacheSizeBytes = 0; +}; + +#endif // IMMICHCLIENT_H diff --git a/src/immichpathtraverser.cpp b/src/immichpathtraverser.cpp new file mode 100644 index 0000000..a3232ec --- /dev/null +++ b/src/immichpathtraverser.cpp @@ -0,0 +1,47 @@ +#include "immichpathtraverser.h" +#include "logger.h" + +ImmichPathTraverser::ImmichPathTraverser(const ImmichConfig &configIn) + : PathTraverser(""), + config(configIn), + client(configIn), + cache(configIn) +{ + loadAssets(); +} + +ImmichPathTraverser::~ImmichPathTraverser() {} + +void ImmichPathTraverser::loadAssets() +{ + assetIds.clear(); + assetNames.clear(); + QVector assets = client.fetchAssets(); + for (const auto &asset : assets) + { + if (asset.id.isEmpty()) + continue; + assetIds.append(asset.id); + assetNames.insert(asset.id, asset.originalFileName); + } + Log("Immich assets loaded: ", assetIds.size()); +} + +QStringList ImmichPathTraverser::getImages() const +{ + return assetIds; +} + +const std::string ImmichPathTraverser::getImagePath(const std::string image) const +{ + QString assetId = QString::fromStdString(image); + QString name = assetNames.value(assetId); + QString path = cache.getCachedPath(assetId, name, client); + return path.toStdString(); +} + +ImageDisplayOptions ImmichPathTraverser::UpdateOptionsForImage(const std::string& filename, const ImageDisplayOptions& options) const +{ + Q_UNUSED(filename); + return options; +} diff --git a/src/immichpathtraverser.h b/src/immichpathtraverser.h new file mode 100644 index 0000000..8469190 --- /dev/null +++ b/src/immichpathtraverser.h @@ -0,0 +1,28 @@ +#ifndef IMMICHPATHTRAVERSER_H +#define IMMICHPATHTRAVERSER_H + +#include "pathtraverser.h" +#include "immichclient.h" + +#include + +class ImmichPathTraverser : public PathTraverser +{ + public: + ImmichPathTraverser(const ImmichConfig &config); + virtual ~ImmichPathTraverser(); + QStringList getImages() const override; + virtual const std::string getImagePath(const std::string image) const override; + virtual ImageDisplayOptions UpdateOptionsForImage(const std::string& filename, const ImageDisplayOptions& options) const override; + + private: + void loadAssets(); + + ImmichConfig config; + mutable ImmichClient client; + mutable ImmichAssetCache cache; + QStringList assetIds; + QHash assetNames; +}; + +#endif // IMMICHPATHTRAVERSER_H diff --git a/src/main.cpp b/src/main.cpp index e80c628..81b8659 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2,6 +2,7 @@ #include "imageselector.h" #include "imageswitcher.h" #include "pathtraverser.h" +#include "immichpathtraverser.h" #include "overlay.h" #include "appconfig.h" #include "logger.h" @@ -157,7 +158,11 @@ void ConfigureWindowFromSettings(MainWindow &w, const AppConfig &appConfig) std::unique_ptr GetSelectorForConfig(const PathEntry& path) { std::unique_ptr pathTraverser; - if (!path.imageList.empty()) + if (path.immich.enabled) + { + pathTraverser = std::unique_ptr(new ImmichPathTraverser(path.immich)); + } + else if (!path.imageList.empty()) { pathTraverser = std::unique_ptr(new ImageListPathTraverser(path.imageList)); } diff --git a/src/slide.pro b/src/slide.pro index 0cffcbd..0006704 100644 --- a/src/slide.pro +++ b/src/slide.pro @@ -4,7 +4,7 @@ # #------------------------------------------------- -QT += core gui +QT += core gui network CONFIG += qt CONFIG += debug CONFIG += c++1z @@ -35,6 +35,8 @@ SOURCES += \ mainwindow.cpp \ imageswitcher.cpp \ pathtraverser.cpp \ + immichpathtraverser.cpp \ + immichclient.cpp \ overlay.cpp \ imageselector.cpp \ appconfig.cpp \ @@ -45,6 +47,8 @@ HEADERS += \ mainwindow.h \ imageselector.h \ pathtraverser.h \ + immichpathtraverser.h \ + immichclient.h \ overlay.h \ imageswitcher.h \ imagestructs.h \