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

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