diff --git a/.drone.yml b/.drone.yml index e17f7c3..b850245 100644 --- a/.drone.yml +++ b/.drone.yml @@ -3,18 +3,48 @@ type: docker name: build steps: +- name: compute-release-version + image: alpine:3.19 + environment: + GITEA_TOKEN: + from_secret: GITEA_TOKEN + GITEA_BASE_URL: https://git.coadcorp.com + commands: + - apk add --no-cache curl jq + - | + set -euo pipefail + repo_owner="${DRONE_REPO_OWNER}" + repo_name="${DRONE_REPO_NAME}" + releases_url="${GITEA_BASE_URL}/api/v1/repos/${repo_owner}/${repo_name}/releases?limit=1" + latest_tag="$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" "${releases_url}" | jq -r '.[0].tag_name // empty')" + if [ -z "${latest_tag}" ] || [ "${latest_tag}" = "null" ]; then + latest_tag="v0.0.0" + fi + version="${latest_tag#v}" + IFS='.' read -r major minor patch <<< "${version}" + if ! [[ "${major:-}" =~ ^[0-9]+$ ]]; then major=0; fi + if ! [[ "${minor:-}" =~ ^[0-9]+$ ]]; then minor=0; fi + if ! [[ "${patch:-}" =~ ^[0-9]+$ ]]; then patch=0; fi + patch=$((patch + 1)) + next_tag="v${major}.${minor}.${patch}" + echo "${next_tag}" > .release_version + echo "Next release: ${next_tag}" - name: build-deb-amd64 image: cache.coadcorp.com/library/buildpack-deps:jammy + depends_on: + - compute-release-version environment: DEBIAN_FRONTEND: noninteractive commands: - apt-get update - apt-get install -y --no-install-recommends build-essential qt5-qmake qtbase5-dev qtbase5-dev-tools libexif-dev qt5-image-formats-plugins libmosquitto-dev dpkg-dev fakeroot ca-certificates - - ARCH=amd64 BUILD_DIR=build-amd64 bash sbin/build_deb.sh "${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}" + - ARCH=amd64 BUILD_DIR=build-amd64 bash sbin/build_deb.sh "$(cat .release_version)" - ls -la dist - name: build-deb-armhf image: cache.coadcorp.com/library/buildpack-deps:bullseye + depends_on: + - compute-release-version environment: DEBIAN_FRONTEND: noninteractive ARM_CFLAGS: -march=armv6 -mfpu=vfp -mfloat-abi=hard -marm @@ -31,11 +61,13 @@ steps: EOF - apt-get update - apt-get install -y --no-install-recommends build-essential qt5-qmake qtbase5-dev qtbase5-dev-tools libexif-dev qt5-image-formats-plugins libmosquitto-dev dpkg-dev fakeroot ca-certificates gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf qtbase5-dev:armhf libqt5core5a:armhf libqt5gui5:armhf libqt5widgets5:armhf libqt5network5:armhf libexif-dev:armhf libmosquitto-dev:armhf qt5-image-formats-plugins:armhf - - ARCH=armhf BUILD_DIR=build-armhf QMAKESPEC=$PWD/mkspecs/linux-armhf-g++ QMAKE_QTCONF=$PWD/mkspecs/qt-armhf.conf QMAKE_CFLAGS="$ARM_CFLAGS" QMAKE_CXXFLAGS="$ARM_CFLAGS" bash sbin/build_deb.sh "${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}" + - ARCH=armhf BUILD_DIR=build-armhf QMAKESPEC=$PWD/mkspecs/linux-armhf-g++ QMAKE_QTCONF=$PWD/mkspecs/qt-armhf.conf QMAKE_CFLAGS="$ARM_CFLAGS" QMAKE_CXXFLAGS="$ARM_CFLAGS" bash sbin/build_deb.sh "$(cat .release_version)" - ls -la dist - name: build-deb-arm64 image: cache.coadcorp.com/library/buildpack-deps:bullseye + depends_on: + - compute-release-version environment: DEBIAN_FRONTEND: noninteractive ARM64_CFLAGS: -march=armv8-a @@ -52,7 +84,7 @@ steps: EOF - apt-get update - apt-get install -y --no-install-recommends build-essential qt5-qmake qtbase5-dev qtbase5-dev-tools libexif-dev qt5-image-formats-plugins libmosquitto-dev dpkg-dev fakeroot ca-certificates gcc-aarch64-linux-gnu g++-aarch64-linux-gnu qtbase5-dev:arm64 libqt5core5a:arm64 libqt5gui5:arm64 libqt5widgets5:arm64 libqt5network5:arm64 libexif-dev:arm64 libmosquitto-dev:arm64 qt5-image-formats-plugins:arm64 - - ARCH=arm64 BUILD_DIR=build-arm64 QMAKESPEC=$PWD/mkspecs/linux-arm64-g++ QMAKE_QTCONF=$PWD/mkspecs/qt-arm64.conf QMAKE_CFLAGS="$ARM64_CFLAGS" QMAKE_CXXFLAGS="$ARM64_CFLAGS" bash sbin/build_deb.sh "${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}" + - ARCH=arm64 BUILD_DIR=build-arm64 QMAKESPEC=$PWD/mkspecs/linux-arm64-g++ QMAKE_QTCONF=$PWD/mkspecs/qt-arm64.conf QMAKE_CFLAGS="$ARM64_CFLAGS" QMAKE_CXXFLAGS="$ARM64_CFLAGS" bash sbin/build_deb.sh "$(cat .release_version)" - ls -la dist - name: build-deps-image @@ -75,21 +107,44 @@ steps: - build-deps - name: gitea-release - image: cache.coadcorp.com/plugins/gitea-release + image: alpine:3.19 depends_on: - build-deb-amd64 - build-deb-armhf - build-deb-arm64 - settings: - api_key: + environment: + GITEA_TOKEN: from_secret: GITEA_TOKEN - base_url: https://git.coadcorp.com - files: - - dist/*.deb - draft: false - prerelease: false - title: ${DRONE_TAG} - note: Automated release for ${DRONE_TAG} - when: - event: - - tag + GITEA_BASE_URL: https://git.coadcorp.com + commands: + - apk add --no-cache curl jq + - | + set -euo pipefail + release_tag="$(cat .release_version)" + repo_owner="${DRONE_REPO_OWNER}" + repo_name="${DRONE_REPO_NAME}" + api_base="${GITEA_BASE_URL}/api/v1/repos/${repo_owner}/${repo_name}" + payload="$(jq -n \ + --arg tag "${release_tag}" \ + --arg name "${release_tag}" \ + --arg body "Automated release for ${release_tag}" \ + --arg target "${DRONE_COMMIT_SHA}" \ + '{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false, target_commitish:$target}')" + response="$(curl -sS -w '\n%{http_code}' -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "${payload}" "${api_base}/releases")" + status="$(echo "${response}" | tail -n1)" + body="$(echo "${response}" | sed '$d')" + if [ "${status}" != "201" ] && [ "${status}" != "200" ]; then + body="$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/tags/${release_tag}")" + fi + release_id="$(echo "${body}" | jq -r '.id // empty')" + if [ -z "${release_id}" ] || [ "${release_id}" = "null" ]; then + echo "Failed to create or fetch release for ${release_tag}" + echo "${body}" + exit 1 + fi + for file in dist/*.deb; do + name="$(basename "${file}")" + curl -sS -H "Authorization: token ${GITEA_TOKEN}" \ + -F "attachment=@${file}" \ + "${api_base}/releases/${release_id}/assets?name=${name}" >/dev/null + done diff --git a/src/immichclient.cpp b/src/immichclient.cpp index 3116c67..bcbc57e 100644 --- a/src/immichclient.cpp +++ b/src/immichclient.cpp @@ -148,7 +148,7 @@ QVector ImmichClient::fetchAssets() } if (!config.userId.empty() && config.albumIds.empty() && config.personIds.empty()) - return fetchAssetsByUser(); + return fetchAssetsBySearch(); if (!config.userId.empty() && (!config.albumIds.empty() || !config.personIds.empty())) { @@ -166,20 +166,24 @@ QVector ImmichClient::fetchAssetsBySearch() int maxAssets = config.maxAssets; bool triedZero = false; int page = 1; + QString userFilterKey; + QByteArray firstResponse; + QJsonArray items; + int total = 0; if (ShouldLog()) { Log("Immich search: size=", config.size, ", order=", config.order, ", pageSize=", pageSize, ", maxAssets=", maxAssets, + ", userId=", config.userId, ", albumIds=", config.albumIds.size(), ", personIds=", config.personIds.size(), ", allowedExtensions=", config.allowedExtensions.size()); } - while (true) - { + auto fetchPage = [&](int pageIndex, const QString &userKey) -> QByteArray { QJsonObject body; - body["page"] = page; + body["page"] = pageIndex; body["size"] = pageSize; body["type"] = "IMAGE"; body["order"] = QString::fromStdString(config.order); @@ -199,19 +203,69 @@ QVector ImmichClient::fetchAssetsBySearch() ids.append(QString::fromStdString(id)); body["personIds"] = ids; } + if (!config.userId.empty() && !userKey.isEmpty()) + { + body[userKey] = QString::fromStdString(config.userId); + } + return postJson(apiUrl("/search/metadata"), body, nullptr, kMetadataTimeoutMs); + }; - QByteArray response = postJson(apiUrl("/search/metadata"), body, nullptr, kMetadataTimeoutMs); - if (response.isEmpty()) - break; - + auto parseSearch = [&](const QByteArray &response, QJsonArray &outItems, int &outTotal) -> bool { QJsonDocument doc = QJsonDocument::fromJson(response); if (!doc.isObject()) - break; - + return false; QJsonObject root = doc.object(); + if (root.contains("error") || root.contains("statusCode")) + return false; + if (!root.contains("assets")) + return false; QJsonObject assetsObj = root["assets"].toObject(); - QJsonArray items = assetsObj["items"].toArray(); - int total = assetsObj["total"].toInt(); + outItems = assetsObj["items"].toArray(); + outTotal = assetsObj["total"].toInt(); + return true; + }; + + QStringList userKeyCandidates; + if (!config.userId.empty()) + userKeyCandidates << "ownerId" << "userId"; + userKeyCandidates << ""; + + bool firstResponseReady = false; + for (const auto &candidate : userKeyCandidates) + { + QByteArray response = fetchPage(page, candidate); + if (response.isEmpty()) + continue; + if (!parseSearch(response, items, total)) + continue; + userFilterKey = candidate; + firstResponse = response; + firstResponseReady = true; + if (!config.userId.empty() && userFilterKey.isEmpty()) + { + Log("Immich search user filter not accepted by server; falling back to search without userId."); + } + break; + } + + if (!firstResponseReady) + return assets; + + while (true) + { + if (firstResponseReady) + { + firstResponseReady = false; + } + else + { + QByteArray response = fetchPage(page, userFilterKey); + if (response.isEmpty()) + break; + if (!parseSearch(response, items, total)) + break; + } + Log("Immich page ", page, ": ", items.size(), " assets (total ", total, ")"); if (items.isEmpty()) { @@ -259,6 +313,9 @@ QVector ImmichClient::fetchAssetsByUser() int pageSize = config.pageSize > 0 ? config.pageSize : 200; int maxAssets = config.maxAssets; int skip = 0; + QString endpointPath = "/assets"; + QByteArray initialResponse; + bool endpointResolved = false; if (ShouldLog()) { @@ -268,24 +325,52 @@ QVector ImmichClient::fetchAssetsByUser() ", includeArchived=", config.includeArchived); } - while (true) - { - QUrl url = apiUrl("/assets"); + auto fetchPage = [&](const QString &path, int offset) -> QByteArray { + QUrl url = apiUrl(path); QUrlQuery query; query.addQueryItem("take", QString::number(pageSize)); - query.addQueryItem("skip", QString::number(skip)); + query.addQueryItem("skip", QString::number(offset)); query.addQueryItem("userId", QString::fromStdString(config.userId)); if (!config.includeArchived) query.addQueryItem("isArchived", "false"); url.setQuery(query); + return getBytes(url, nullptr, kMetadataTimeoutMs); + }; - QByteArray response = getBytes(url, nullptr, kMetadataTimeoutMs); + initialResponse = fetchPage(endpointPath, skip); + if (initialResponse.isEmpty()) + { + endpointPath = "/asset"; + initialResponse = fetchPage(endpointPath, skip); + } + if (initialResponse.isEmpty()) + { + Log("Immich user assets endpoint not available; falling back to metadata search (userId filter may be ignored)."); + return fetchAssetsBySearch(); + } + endpointResolved = true; + + while (true) + { + QByteArray response; + if (endpointResolved) + { + response = initialResponse; + endpointResolved = false; + } + else + { + response = fetchPage(endpointPath, skip); + } if (response.isEmpty()) break; QJsonDocument doc = QJsonDocument::fromJson(response); if (!doc.isArray()) - break; + { + Log("Immich user assets response was not an array; falling back to metadata search."); + return fetchAssetsBySearch(); + } QJsonArray items = doc.array(); Log("Immich user assets skip ", skip, ": ", items.size(), " assets");