Files
k8scd/immich/share-sync.yaml
Ruben Hensen 88f68cd698 immich: share albums that kaderleden create automatically
Nobody uses the group account anymore, so new albums belong to the
leiding who make them, and share-sync only shared albums the group
account was in. It now first adds the group account as editor to every
album an uploader owns (straight in the database: the API only lets an
album's owner or editors share it), after which the album goes to
everyone like the others. Albums whose name starts with "Privé" or
"Prive" are left alone.
2026-10-24 14:10:30 +02:00

243 lines
9.0 KiB
YAML

# Shares the Scouting MBG photos with every immich user. immich has no user
# groups (on its roadmap as "Soon"), so this does it per user:
# - albums that uploaders (kaderleden) create get the group account as
# editor, unless their name starts with "Privé" or "Prive";
# - every album the group account owns or edits gets every user, as editor
# when they may upload (quota other than 0, which authentik sets from
# Scouts Online) and as viewer otherwise;
# - the group account's library is shared as a partner and shown in
# everyone's timeline, so search covers it (immich only searches your own
# and your timeline partners' photos);
# - the invitation notifications for albums older than a week are hidden,
# or a new member would get one per album;
# - sessions of OAuth users older than SESSION_MAX_AGE_DAYS are ended, so
# someone who left the group loses access at the next login at Scouts
# Online.
# Albums and partners go through the API. Joining other people's albums
# (the API only lets an album's owner or editors share it), the timeline
# flag (which only the receiving user can set), the notifications and the
# sessions go straight to the database.
apiVersion: v1
kind: ConfigMap
metadata:
name: share-sync
namespace: immich
data:
sync.py: |
import json
import os
import subprocess
import urllib.request
API = os.environ["IMMICH_URL"].rstrip("/") + "/api"
KEY = os.environ["IMMICH_API_KEY"]
GROUP_EMAIL = os.environ["GROUP_EMAIL"]
SKIP_EMAILS = set(os.environ.get("SKIP_EMAILS", "").split())
SESSION_MAX_AGE_DAYS = int(os.environ.get("SESSION_MAX_AGE_DAYS", "30"))
DRY_RUN = os.environ.get("DRY_RUN") == "1"
def api(method, path, body=None):
req = urllib.request.Request(
API + path,
method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"x-api-key": KEY, "Accept": "application/json", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
data = resp.read()
return json.loads(data) if data else None
def sql(query, **params):
"""Runs query through psql; :'name' in it is params[name], quoted."""
args = ["psql", "-X", "-q", "-At", "-F", "\t", "-v", "ON_ERROR_STOP=1"]
for name, value in params.items():
args += ["-v", f"{name}={value}"]
out = subprocess.run(args, input=query, capture_output=True, text=True, check=True).stdout
return [line.split("\t") for line in out.splitlines() if line]
def change(what, call):
print(("would " if DRY_RUN else "") + what, flush=True)
if not DRY_RUN:
call()
users = sql('SELECT id, email, name, coalesce("quotaSizeInBytes"::text, \'\') FROM "user" WHERE "deletedAt" IS NULL')
group = next(u for u in users if u[1] == GROUP_EMAIL)
group_id, group_name = group[0], group[2]
members = {u[0]: u[1] for u in users if u[0] != group_id and u[1] not in SKIP_EMAILS}
# A quota of 0 bytes is how immich (and authentik's immich_quota claim)
# says: no uploads.
wanted = {u[0]: "viewer" if u[3] == "0" else "editor" for u in users if u[0] in members}
# Albums of uploaders that the group account isn't in yet
UNJOINED = """
FROM album_user au
JOIN "user" u ON u.id = au."userId"
JOIN album a ON a.id = au."albumId"
WHERE au.role = 'owner' AND u.id != :'group'
AND u."deletedAt" IS NULL AND a."deletedAt" IS NULL
AND (u."quotaSizeInBytes" IS NULL OR u."quotaSizeInBytes" > 0)
AND a."albumName" !~* '^\\s*priv[eé]'
AND NOT EXISTS (SELECT 1 FROM album_user g WHERE g."albumId" = a.id AND g."userId" = :'group')
"""
if DRY_RUN:
for (name,) in sql("SELECT a.\"albumName\"" + UNJOINED, group=group_id):
print(f"would join {name}", flush=True)
else:
for (name,) in sql(
f"""
WITH joined AS (
INSERT INTO album_user ("albumId", "userId", role)
SELECT a.id, :'group', 'editor' {UNJOINED}
RETURNING "albumId"
)
SELECT a."albumName" FROM joined JOIN album a ON a.id = joined."albumId"
""",
group=group_id,
):
print(f"join {name}", flush=True)
for album in api("GET", "/albums"):
roles = {au["user"]["id"]: au["role"] for au in album["albumUsers"]}
if roles.get(group_id) not in ("owner", "editor"):
continue
add = [{"userId": uid, "role": role} for uid, role in wanted.items() if uid not in roles]
if add:
change(
f"add {len(add)} users to {album['albumName']}",
lambda: api("PUT", f"/albums/{album['id']}/users", {"albumUsers": add}),
)
for uid, role in wanted.items():
if roles.get(uid) in ("viewer", "editor") and roles[uid] != role:
change(
f"make {members[uid]} {role} of {album['albumName']}",
lambda: api("PUT", f"/albums/{album['id']}/user/{uid}", {"role": role}),
)
partners = {p["id"] for p in api("GET", "/partners?direction=shared-by")}
for uid, email in members.items():
if uid not in partners:
change(f"share the library with {email}", lambda: api("POST", "/partners", {"sharedWithId": uid}))
if DRY_RUN:
raise SystemExit(0)
sql(
'UPDATE partner SET "inTimeline" = true WHERE "sharedById" = :\'group\' AND NOT "inTimeline"',
group=group_id,
)
sql(
"""
UPDATE notification SET "deletedAt" = now()
WHERE type = 'AlbumInvite' AND "deletedAt" IS NULL AND "readAt" IS NULL
AND description LIKE :'sender' || ' shared an album%'
-- immich stores data as a JSON string inside the jsonb
AND ((data #>> '{}')::jsonb ->> 'albumId')::uuid IN (SELECT id FROM album WHERE "createdAt" < now() - interval '7 days')
""",
sender=group_name,
)
sql(
"""
DELETE FROM session
WHERE "createdAt" < now() - make_interval(days => :days)
AND "userId" IN (SELECT id FROM "user" WHERE "oauthId" IS NOT NULL)
""",
days=SESSION_MAX_AGE_DAYS,
)
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: immich-share
namespace: immich
spec:
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
refreshInterval: 15m
target:
name: immich-share
creationPolicy: Owner
data:
# API key of the group account, with album.read, albumUser.create,
# albumUser.update, partner.read and partner.create
- secretKey: api_key
remoteRef:
key: kv/immich-share
property: api_key
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: share-sync
namespace: immich
spec:
schedule: "*/10 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 1800
template:
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 26
containers:
- name: sync
# The database's own image: it has python3 and psql.
image: ghcr.io/tensorchord/cloudnative-vectorchord:17.11-1.1.1
command: ["python3", "/script/sync.py"]
env:
- name: IMMICH_URL
value: http://helm-immich-server:2283
- name: IMMICH_API_KEY
valueFrom:
secretKeyRef:
name: immich-share
key: api_key
- name: GROUP_EMAIL
value: fotoupload@scoutingmbg.nl
# The shared viewing account from before Scouts Online
- name: SKIP_EMAILS
value: foto@scoutingmbg.nl
- name: PGHOST
value: immich-postgres-17-rw
- name: PGSSLMODE
value: require
# album names are UTF-8 (and so is the Privé check)
- name: PGCLIENTENCODING
value: UTF8
- name: PGDATABASE
value: immich
- name: PGUSER
valueFrom:
secretKeyRef:
name: immich-postgres-17-app
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: immich-postgres-17-app
key: password
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
volumeMounts:
- name: script
mountPath: /script
volumes:
- name: script
configMap:
name: share-sync