#include "immichclient.h" #include "logger.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { const int kMetadataTimeoutMs = 15000; const int kAssetTimeoutMs = 30000; QString DetectImageExtension(const QByteArray &data) { QBuffer buffer; buffer.setData(data); if (!buffer.open(QIODevice::ReadOnly)) return ""; QImageReader reader(&buffer); if (!reader.canRead()) return ""; QByteArray fmt = reader.format().toLower(); if (fmt == "jpeg") fmt = "jpg"; return QString::fromUtf8(fmt); } } 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() { if (!config.enabled) { Log("Immich config is missing url or apiKey."); return QVector(); } if (!config.userId.empty() && config.albumIds.empty() && config.personIds.empty()) return fetchAssetsByUser(); if (!config.userId.empty() && (!config.albumIds.empty() || !config.personIds.empty())) { Log("Immich userId is set but album/person filters are also set; ignoring userId for search filters."); } return fetchAssetsBySearch(); } QVector ImmichClient::fetchAssetsBySearch() { QVector assets; int pageSize = config.pageSize > 0 ? config.pageSize : 200; int maxAssets = config.maxAssets; bool triedZero = false; int page = 1; if (ShouldLog()) { Log("Immich search: size=", config.size, ", order=", config.order, ", pageSize=", pageSize, ", maxAssets=", maxAssets, ", albumIds=", config.albumIds.size(), ", personIds=", config.personIds.size(), ", allowedExtensions=", config.allowedExtensions.size()); } 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; } if (config.personIds.size() > 0) { QJsonArray ids; for (const auto &id : config.personIds) ids.append(QString::fromStdString(id)); body["personIds"] = 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(); Log("Immich page ", page, ": ", items.size(), " assets (total ", total, ")"); 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(); if (!extensionAllowed(asset.originalFileName)) { Log("Immich skip by extension: ", asset.originalFileName.toStdString()); continue; } assets.append(asset); if (maxAssets > 0 && assets.size() >= maxAssets) return assets; } if (items.size() < pageSize) break; page += 1; } return assets; } QVector ImmichClient::fetchAssetsByUser() { QVector assets; int pageSize = config.pageSize > 0 ? config.pageSize : 200; int maxAssets = config.maxAssets; int skip = 0; if (ShouldLog()) { Log("Immich assets: userId=", config.userId, ", pageSize=", pageSize, ", maxAssets=", maxAssets, ", includeArchived=", config.includeArchived); } while (true) { QUrl url = apiUrl("/assets"); QUrlQuery query; query.addQueryItem("take", QString::number(pageSize)); query.addQueryItem("skip", QString::number(skip)); query.addQueryItem("userId", QString::fromStdString(config.userId)); if (!config.includeArchived) query.addQueryItem("isArchived", "false"); url.setQuery(query); QByteArray response = getBytes(url, nullptr, kMetadataTimeoutMs); if (response.isEmpty()) break; QJsonDocument doc = QJsonDocument::fromJson(response); if (!doc.isArray()) break; QJsonArray items = doc.array(); Log("Immich user assets skip ", skip, ": ", items.size(), " assets"); if (items.isEmpty()) 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(); if (!extensionAllowed(asset.originalFileName)) { Log("Immich skip by extension: ", asset.originalFileName.toStdString()); continue; } assets.append(asset); if (maxAssets > 0 && assets.size() >= maxAssets) return assets; } if (items.size() < pageSize) break; skip += pageSize; } return assets; } bool ImmichClient::extensionAllowed(const QString &filename) const { if (config.allowedExtensions.empty()) return true; QString ext = QFileInfo(filename).suffix().toLower(); if (ext.isEmpty()) return false; for (const auto &allowed : config.allowedExtensions) { if (ext == QString::fromStdString(allowed)) return true; } return false; } bool ImmichClient::downloadAsset(const QString &assetId, QByteArray &data, QString &contentType) { return downloadAssetWithSize(assetId, QString::fromStdString(config.size), data, contentType); } bool ImmichClient::downloadAssetWithSize(const QString &assetId, const QString &sizeOverride, QByteArray &data, QString &contentType) { if (!config.enabled) return false; QString size = sizeOverride.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); } Log("Immich download asset ", assetId.toStdString(), " (", size.toStdString(), ")"); 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 ""; for (const auto &match : matches) { if (match.endsWith(".skip")) return dir.filePath(match); } 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()) { if (existing.endsWith(".skip")) { Log("Immich skip marker: ", assetId.toStdString()); return ""; } QFileInfo info(existing); if (info.size() <= 0) { QFile::remove(existing); } else if (existing.endsWith(".img")) { QImageReader reader(existing); if (!reader.canRead()) { Log("Immich cache invalid: ", existing.toStdString()); QFile::remove(existing); } else { Log("Immich cache hit: ", assetId.toStdString()); return existing; } } else { Log("Immich cache hit: ", assetId.toStdString()); return existing; } } QByteArray data; QString contentType; if (!client.downloadAsset(assetId, data, contentType)) return ""; QString detectedExt = DetectImageExtension(data); if (detectedExt.isEmpty()) { Log("Immich download not an image for asset: ", assetId.toStdString()); QByteArray fallbackData; QString fallbackType; if (client.downloadAssetWithSize(assetId, "preview", fallbackData, fallbackType)) { QString fallbackExt = DetectImageExtension(fallbackData); if (!fallbackExt.isEmpty()) { Log("Immich fallback to preview succeeded for asset: ", assetId.toStdString()); data = fallbackData; contentType = fallbackType; detectedExt = fallbackExt; } } if (detectedExt.isEmpty()) { QString skipName = assetId + "_unsupported.skip"; QDir dir(cacheDirPath); QFile skipFile(dir.filePath(skipName)); if (skipFile.open(QIODevice::WriteOnly)) { skipFile.write("unsupported"); skipFile.close(); } return ""; } } QString safeName = sanitizeFileName(assetName); QString extension = detectedExt; if (extension.isEmpty()) extension = extensionForContentType(contentType); if (extension == "img") { QString suffix = QFileInfo(assetName).suffix().toLower(); if (!suffix.isEmpty()) extension = suffix; } 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 ""; Log("Immich cached asset: ", assetId.toStdString(), " -> ", filePath.toStdString()); if (cacheMaxBytes > 0) { if (!cacheSizeKnown) { cacheSizeBytes = calculateCacheSize(); cacheSizeKnown = true; } else { cacheSizeBytes += data.size(); } if (cacheSizeBytes > cacheMaxBytes) enforceCacheLimit(); } return filePath; }