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