Add read-only Synology Photos people filtering

This commit is contained in:
Pigway
2026-10-05 08:43:07 +08:00
parent 9e14adcb1f
commit f3d9a1ae97
12 changed files with 1405 additions and 5 deletions
@@ -18,6 +18,7 @@ from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import StrictModel
from pigway_frame_flow.providers.photo_names import path_caption
from pigway_frame_flow.providers.synology_photos import SynologyPhotos
from pigway_frame_flow.settings import secret
from pigway_frame_flow.storage.database import encode
@@ -32,6 +33,9 @@ class PhotoSourceInput(StrictModel):
synology_previews: bool = False
organized_only: bool = False
folder_caption: bool = False
people_filter: bool = False
person_ids: list[int] = Field(default_factory=list, max_length=50)
person_match: Literal["any", "all"] = "any"
credential_env: str | None = Field(
default=None, pattern=r"^FRAME_FLOW_SOURCE_[A-Z0-9_]+$"
)
@@ -62,6 +66,7 @@ class PhotoSources:
def __init__(self, db, settings, assets):
self.db, self.settings, self.assets = db, settings, assets
self.cache = {}
self.synology = SynologyPhotos(settings)
# Register provider secrets before request IDs or errors can be reflected.
for row in self.list():
if row["kind"] == "webdav" and row.get("credential_env"):
@@ -91,6 +96,19 @@ class PhotoSources:
return PhotoSourceInput.model_validate_json(row["payload"])
def save(self, source_id, source):
if source.people_filter and source.kind != "local":
raise FlowError(
E.INVALID_REQUEST,
"Person filtering requires a local Synology photo source",
)
if any(i <= 0 for i in source.person_ids) or len(set(source.person_ids)) != len(
source.person_ids
):
raise FlowError(
E.INVALID_REQUEST, "Person IDs must be unique positive integers"
)
if source.people_filter and not source.person_ids:
raise FlowError(E.INVALID_REQUEST, "Select at least one person")
if source.kind == "local":
self.local_directory(source.location)
if source.credential_env:
@@ -226,6 +244,11 @@ class PhotoSources:
cached = self.cache.get(source_id)
if cached and not refresh and time.monotonic() - cached[0] < 60:
return cached[1]
allowed = (
self.synology.paths(source.person_ids, source.person_match, refresh=refresh)
if source.people_filter
else None
)
items = []
if source.kind == "local":
directory = self.local_directory(source.location)
@@ -267,6 +290,8 @@ class PhotoSources:
if large and not preview:
continue
relative = path.relative_to(root).as_posix()
if allowed is not None and relative not in allowed:
continue
items.append(
{
"id": relative,
@@ -0,0 +1,263 @@
"""Optional, read-only Synology Photos person catalog with pinned TLS."""
import hashlib
import http.client
import json
import os
import ssl
import threading
import time
from urllib.parse import urlencode, urlsplit
from pydantic import Field, SecretStr
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import StrictModel
class PhotosLogin(StrictModel):
username: str = Field(min_length=1, max_length=120)
password: SecretStr = Field(min_length=1, max_length=256)
class PhotosConnection(StrictModel):
configured: bool
connected: bool
class PhotosPerson(StrictModel):
id: int
name: str
count: int = 0
named: bool = False
class SynologyPhotos:
def __init__(self, settings):
self.settings = settings
self.path = settings.config_dir / "synology-photos-session.json"
self.lock = threading.RLock()
self.cache = {}
if self.path.is_file():
try:
self.sid()
except FlowError:
pass
def endpoint(self):
url = urlsplit(os.getenv("SYNOLOGY_PHOTOS_URL", ""))
pin = os.getenv("SYNOLOGY_PHOTOS_CERT_SHA256", "").lower().replace(":", "")
if (
url.scheme != "https"
or not url.hostname
or url.username
or url.password
or url.query
or url.fragment
or url.path not in ("", "/")
or len(pin) != 64
or any(c not in "0123456789abcdef" for c in pin)
):
raise FlowError(
E.CONFIG_REQUIRED,
"Configure Synology Photos URL and certificate fingerprint",
503,
)
return url, pin
def status(self):
try:
self.endpoint()
configured = True
except FlowError:
configured = False
return {
"configured": configured,
"connected": configured and self.path.is_file(),
}
def request(self, api, method, version, **params):
# No arbitrary RPC forwarding or mutation methods, even for administrators.
if (api, method) not in {
("SYNO.API.Auth", "login"),
("SYNO.API.Auth", "logout"),
("SYNO.FotoTeam.Browse.Person", "list"),
("SYNO.FotoTeam.Browse.Item", "list"),
("SYNO.FotoTeam.Browse.Folder", "get"),
}:
raise FlowError(E.FORBIDDEN, "Unsupported Photos query", 403)
url, pin = self.endpoint()
connection = http.client.HTTPSConnection(
url.hostname,
url.port or 443,
timeout=30,
context=ssl._create_unverified_context(),
)
try:
connection.connect()
certificate = connection.sock.getpeercert(binary_form=True)
if not certificate or hashlib.sha256(certificate).hexdigest() != pin:
raise FlowError(E.FORBIDDEN, "Synology Photos certificate changed", 403)
# Check the certificate on this exact socket BEFORE transmitting a secret.
data = urlencode(
{"api": api, "method": method, "version": version, **params}
)
connection.request(
"POST",
"/webapi/entry.cgi",
data,
{"Content-Type": "application/x-www-form-urlencoded"},
)
response = connection.getresponse()
raw = response.read(4 * 1024 * 1024 + 1)
if response.status != 200 or len(raw) > 4 * 1024 * 1024:
raise ValueError()
result = json.loads(raw)
if not result.get("success"):
code = result.get("error", {}).get("code")
if api == "SYNO.API.Auth" or code in (105, 106, 107, 119):
if api != "SYNO.API.Auth":
self.disconnect()
raise FlowError(
E.CONFIG_REQUIRED, "Reconnect Synology Photos account", 409
)
raise FlowError(
E.CLOUD_UNAVAILABLE, "Synology Photos query was rejected", 502
)
return result.get("data", {})
except (OSError, ValueError, http.client.HTTPException):
raise FlowError(
E.CLOUD_UNAVAILABLE, "Synology Photos is unavailable", 502
) from None
finally:
connection.close()
def sid(self):
try:
sid = json.loads(self.path.read_text())["sid"]
if not isinstance(sid, str) or not sid:
raise ValueError()
except (OSError, ValueError, KeyError):
raise FlowError(
E.CONFIG_REQUIRED, "Connect Synology Photos account first", 409
) from None
if sid not in self.settings.additional_secrets:
self.settings.additional_secrets.append(sid)
return sid
def connect(self, payload):
password = payload.password.get_secret_value()
self.settings.additional_secrets.extend([payload.username, password])
with self.lock:
sid = self.request(
"SYNO.API.Auth",
"login",
7,
account=payload.username,
passwd=password,
session="SynologyPhotos",
format="sid",
)["sid"]
self.settings.additional_secrets.append(sid)
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(".tmp")
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as stream:
json.dump({"sid": sid}, stream)
os.chmod(temporary, 0o600)
temporary.replace(self.path)
self.cache.clear()
return self.status()
def disconnect(self):
with self.lock:
self.path.unlink(missing_ok=True)
self.cache.clear()
return self.status()
def pages(self, api, version, **params):
rows = []
for offset in range(0, 100000, 500):
page = self.request(
api,
"list",
version,
offset=offset,
limit=500,
_sid=self.sid(),
**params,
).get("list", [])
rows.extend(page)
if len(page) < 500:
return rows
raise FlowError(E.CONFLICT, "Photos catalog exceeds query limit", 409)
def people(self):
with self.lock:
rows = self.pages("SYNO.FotoTeam.Browse.Person", 3)
return [
{
"id": r["id"],
"name": r.get("name") or str(r["id"]),
"count": r.get("item_count", 0),
"named": bool(r.get("name")),
}
for r in rows
]
def paths(self, person_ids, mode, refresh=False):
key = (tuple(sorted(person_ids)), mode)
with self.lock:
cached = self.cache.get(key)
if cached and not refresh and time.monotonic() - cached[0] < 900:
return cached[1]
known = {p["id"] for p in self.people()}
ids = person_ids or sorted(known)
if not set(ids) <= known:
raise FlowError(E.INVALID_REQUEST, "Selected person no longer exists")
sets = []
folders = {}
for person in ids:
paths = set()
for row in self.pages(
"SYNO.FotoTeam.Browse.Item",
7,
person_id=person,
additional=json.dumps(["folder"]),
):
folder = row.get("additional", {}).get("folder", {})
if isinstance(folder, str):
name = folder
elif isinstance(folder, dict) and folder.get("name"):
name = folder["name"]
else:
folder_id = row.get("folder_id")
if folder_id is None:
raise FlowError(
E.CLOUD_UNAVAILABLE,
"Photos response has no folder identity",
502,
)
if folder_id not in folders:
folders[folder_id] = self.request(
"SYNO.FotoTeam.Browse.Folder",
"get",
2,
id=folder_id,
_sid=self.sid(),
)["folder"]["name"]
name = folders[folder_id]
if "filename" not in row:
raise FlowError(
E.CLOUD_UNAVAILABLE, "Photos response has no filename", 502
)
paths.add(name.strip("/") + "/" + row["filename"])
sets.append(paths)
selected = (
(set.intersection(*sets) if mode == "all" else set.union(*sets))
if sets
else set()
)
self.cache[key] = (time.monotonic(), selected)
return selected