first commit

This commit is contained in:
2026-01-31 13:59:50 +11:00
parent 3cb2d9cb3e
commit 7a0bb14df4
10 changed files with 689 additions and 4 deletions

View File

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

35
sbin/build_deb.sh Normal file
View File

@@ -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" <<EOF
Package: ${PACKAGE_NAME}
Version: ${VERSION}
Section: graphics
Priority: optional
Architecture: ${ARCH}
Maintainer: slide build
Depends: libqt5core5a, libqt5gui5, libqt5widgets5, libqt5network5, libexif12, qt5-image-formats-plugins
Description: Lightweight slideshow for photo frames
Simple, lightweight slideshow designed for low power devices.
EOF
dpkg-deb --build "$STAGE_DIR" "$DIST_DIR/${PACKAGE_NAME}_${VERSION}_${ARCH}.deb"

View File

@@ -48,6 +48,70 @@ void SetJSONBool(bool &value, QJsonObject jsonDoc, const char *key) {
}
}
void SetJSONInt(int &value, QJsonObject jsonDoc, const char *key) {
if(jsonDoc.contains(key) && jsonDoc[key].isDouble())
{
value = (int)jsonDoc[key].toDouble();
}
}
std::vector<std::string> ParseJSONStrings(QJsonObject jsonDoc, const char *key) {
std::vector<std::string> 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<std::string> 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 &currentConfig) {
if(configFilePath.empty())
{
@@ -184,6 +248,11 @@ QVector<PathEntry> 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())
{

View File

@@ -4,6 +4,50 @@
#include <QDateTime>
#include "imagestructs.h"
#include <QVector>
#include <vector>
struct ImmichConfig {
bool enabled = false;
std::string url = "";
std::string apiKey = "";
std::vector<std::string> 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)

337
src/immichclient.cpp Normal file
View File

@@ -0,0 +1,337 @@
#include "immichclient.h"
#include "logger.h"
#include <QDir>
#include <QEventLoop>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRegularExpression>
#include <QSaveFile>
#include <QTimer>
#include <QUrlQuery>
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<ImmichAsset> ImmichClient::fetchAssets()
{
QVector<ImmichAsset> 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<qint64>(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;
}

56
src/immichclient.h Normal file
View File

@@ -0,0 +1,56 @@
#ifndef IMMICHCLIENT_H
#define IMMICHCLIENT_H
#include <QByteArray>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QString>
#include <QUrl>
#include <QVector>
#include "appconfig.h"
struct ImmichAsset {
QString id;
QString originalFileName;
};
class ImmichClient {
public:
explicit ImmichClient(const ImmichConfig &config);
QVector<ImmichAsset> 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

View File

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

28
src/immichpathtraverser.h Normal file
View File

@@ -0,0 +1,28 @@
#ifndef IMMICHPATHTRAVERSER_H
#define IMMICHPATHTRAVERSER_H
#include "pathtraverser.h"
#include "immichclient.h"
#include <QHash>
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<QString, QString> assetNames;
};
#endif // IMMICHPATHTRAVERSER_H

View File

@@ -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<ImageSelector> GetSelectorForConfig(const PathEntry& path)
{
std::unique_ptr<PathTraverser> pathTraverser;
if (!path.imageList.empty())
if (path.immich.enabled)
{
pathTraverser = std::unique_ptr<PathTraverser>(new ImmichPathTraverser(path.immich));
}
else if (!path.imageList.empty())
{
pathTraverser = std::unique_ptr<PathTraverser>(new ImageListPathTraverser(path.imageList));
}

View File

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