diff --git a/.github/workflows/portfolio-sync.yml b/.github/workflows/portfolio-sync.yml new file mode 100644 index 0000000..fe80926 --- /dev/null +++ b/.github/workflows/portfolio-sync.yml @@ -0,0 +1,73 @@ +name: Portfolio-synk (Immich → portfolio) + +on: + schedule: + - cron: '0 3 * * *' # dygnsvis 03:00 UTC + workflow_dispatch: # "på begäran" — manuell knapp / API-trigger + +permissions: + contents: write # för att committa nya bilder + +concurrency: + group: portfolio-sync # ingen parallell körning (race-skydd) + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install sharp + run: npm install sharp@^0.33.5 + + - name: Run portfolio sync + id: sync + env: + IMMICH_API_KEY: ${{ secrets.IMMICH_API_KEY }} + run: node scripts/portfolio-sync.mjs + + - name: Commit & push if changed + if: steps.sync.outputs.changed == 'true' + run: | + git config user.name "portfolio-sync[bot]" + git config user.email "actions@github.com" + git add src/assets/images/portfolio/ + git commit -m "chore(portfolio): sync ${{ steps.sync.outputs.added }} added, ${{ steps.sync.outputs.removed }} removed from Immich" + git push origin HEAD:main + + - name: Trigger Coolify deploy + if: steps.sync.outputs.changed == 'true' + run: | + DEP=$(curl -s -X POST -H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" \ + "http://100.66.207.21:8000/api/v1/deploy?uuid=kbhpiq6anucu8owch29c207w" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['deployments'][0]['deployment_uuid'])") + echo "deployment: $DEP" + for i in $(seq 1 20); do + S=$(curl -s -H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" "http://100.66.207.21:8000/api/v1/deployments/$DEP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))") + echo "[$i] deploy-status: $S" + [ "$S" = "finished" ] && break + [ "$S" = "failed" ] && { echo "DEPLOY FAILED"; exit 1; } + sleep 12 + done + + - name: Purge Cloudflare cache + if: steps.sync.outputs.changed == 'true' + run: | + curl -s -X POST "https://api.cloudflare.com/client/v4/zones/22c7ed24735e7417d814ef66d90e4397/purge_cache" \ + -H "Authorization: Bearer ${{ secrets.CF_PURGE_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d '{"files":["https://imagesbyolofsson.se/portfolio"]}' + + - name: Summary + run: | + echo "### Portfolio-synk klar" >> $GITHUB_STEP_SUMMARY + echo "- Tillagda: ${{ steps.sync.outputs.added }}" >> $GITHUB_STEP_SUMMARY + echo "- Borttagna: ${{ steps.sync.outputs.removed }}" >> $GITHUB_STEP_SUMMARY + echo "- Ändring: ${{ steps.sync.outputs.changed }}" >> $GITHUB_STEP_SUMMARY diff --git a/scripts/portfolio-sync.mjs b/scripts/portfolio-sync.mjs new file mode 100644 index 0000000..8dfa2b7 --- /dev/null +++ b/scripts/portfolio-sync.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// Portfolio-synk: speglar Immich "portfolio"-taggade bilder till src/assets/images/portfolio/ +// Självläkande: härleder synkat tillstånd från immich--*-filnamn i repot. +// Bevarar EXIF (portfolion läser DateTimeOriginal/Model/ISO för år/månad-filter + lightbox). +import fs from "node:fs"; +import path from "node:path"; +import sharp from "sharp"; + +const IMMICH = process.env.IMMICH_URL || "https://foto.imagesbyolofsson.se/api"; +const KEY = process.env.IMMICH_API_KEY; +const TAGID = process.env.PORTFOLIO_TAG_ID || "67d16b6c-0dea-42ef-a289-2c9e57fa5c5a"; +const PORTFOLIO_DIR = "src/assets/images/portfolio"; +const MAX_DIM = 3000; +const JPEG_QUALITY = 88; + +if (!KEY) { console.error("IMMICH_API_KEY saknas"); process.exit(1); } + +async function api(method, pathname, body) { + const res = await fetch(IMMICH + pathname, { + method, + headers: { "x-api-key": KEY, "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`${method} ${pathname} → ${res.status}: ${(await res.text()).slice(0, 200)}`); + return res; +} + +// Hämta alla portfolio-taggade assets (paginerat, unikt på id — search/metadata ger dubbletter över sidgräns) +async function fetchTagged() { + const map = new Map(); + let page = 1; + while (true) { + const res = await api("POST", "/search/metadata", { tagIds: [TAGID], size: 250, page }); + const d = await res.json(); + const items = d.assets.items; + if (!items.length) break; + for (const a of items) { + const date = a.exifInfo?.dateTimeOriginal || a.fileCreatedAt; + map.set(a.id, { id: a.id, name: a.originalFileName, date }); + } + const next = d.assets.nextPage; + if (!next) break; + page = Number(next); + } + return [...map.values()]; +} + +// Vilka immich--*.jpg finns redan i repot? → { assetId: filename } +function existingSynced() { + if (!fs.existsSync(PORTFOLIO_DIR)) return {}; + const out = {}; + for (const f of fs.readdirSync(PORTFOLIO_DIR)) { + const m = f.match(/^immich-([0-9a-f-]{36})-/i); + if (m) out[m[1]] = f; + } + return out; +} + +// Bygg destinationsfilnamn: immich--<ÅÅÅÅMMDD>-.jpg +function destName(asset) { + const d = asset.date ? new Date(asset.date) : new Date(); + const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`; + // strippa ev. "__"-prefix från originalnamnet (från migreringen), behåll basnamn utan ändelse + let base = asset.name.replace(/^[0-9a-f-]{36}__/i, "").replace(/\.[^.]+$/, ""); + base = base.replace(/[^\w.\-]/g, "_").slice(0, 60); + return `immich-${asset.id}-${ymd}-${base}.jpg`; +} + +async function downloadAndResize(asset, destPath) { + const res = await api("GET", `/assets/${asset.id}/original`); + const buf = Buffer.from(await res.arrayBuffer()); + await sharp(buf) + .rotate() // respektera EXIF-orientering + .resize(MAX_DIM, MAX_DIM, { fit: "inside", withoutEnlargement: true }) + .withMetadata() // BEVARA EXIF (kritiskt för portfolions datum/kamera) + .jpeg({ quality: JPEG_QUALITY }) + .toFile(destPath); +} + +async function main() { + const tagged = await fetchTagged(); + const synced = existingSynced(); + const taggedIds = new Set(tagged.map((a) => a.id)); + + const toAdd = tagged.filter((a) => !synced[a.id]); + const toRemove = Object.entries(synced).filter(([id]) => !taggedIds.has(id)).map(([id, file]) => ({ id, file })); + + console.log(`portfolio-synk: ${tagged.length} taggade, ${Object.keys(synced).length} redan synkade`); + console.log(`→ lägg till: ${toAdd.length}, ta bort: ${toRemove.length}`); + + fs.mkdirSync(PORTFOLIO_DIR, { recursive: true }); + + for (const a of toAdd) { + const fname = destName(a); + const dest = path.join(PORTFOLIO_DIR, fname); + try { + await downloadAndResize(a, dest); + console.log(` + ${fname}`); + } catch (e) { + console.error(` ! misslyckades ${a.name}: ${e.message}`); + process.exitCode = 1; // signalera fel men fortsätt med resten + } + } + for (const r of toRemove) { + fs.rmSync(path.join(PORTFOLIO_DIR, r.file), { force: true }); + console.log(` - ${r.file}`); + } + + // skriv ut om något ändrats (workflow läser detta för att avgöra commit) + const changed = toAdd.length > 0 || toRemove.length > 0; + fs.writeFileSync(process.env.GITHUB_OUTPUT || "/dev/stdout", + `changed=${changed}\nadded=${toAdd.length}\nremoved=${toRemove.length}\n`, { flag: "a" }); +} + +main().catch((e) => { console.error("FEL:", e.message); process.exit(1); });