feat: automated Immich portfolio sync (GitHub Action) (#28)
GitHub Action that mirrors Immich 'portfolio'-tagged images to the portfolio gallery. Runs daily + on-demand (workflow_dispatch). Two-way mirror: tagged added, untagged removed. EXIF preserved. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c128093f46
commit
dde6155304
@@ -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
|
||||||
@@ -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-<assetId>-*-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-<assetId>-*.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-<assetId>-<ÅÅÅÅMMDD>-<safeOriginalName>.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. "<assetid>__"-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); });
|
||||||
Reference in New Issue
Block a user