338 lines
8.4 KiB
C++
338 lines
8.4 KiB
C++
#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;
|
|
}
|