2026-10-05 00:52:39 +08:00
|
|
|
"""Read-only image catalogs; provider credentials never enter SQLite."""
|
|
|
|
|
|
|
|
|
|
import base64
|
|
|
|
|
import json
|
2026-10-17 23:28:04 +08:00
|
|
|
import logging
|
2026-10-05 00:52:39 +08:00
|
|
|
import os
|
|
|
|
|
import posixpath
|
|
|
|
|
import random
|
|
|
|
|
import stat
|
|
|
|
|
import time
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Literal
|
|
|
|
|
from urllib.parse import unquote, urljoin, urlsplit
|
|
|
|
|
from xml.etree import ElementTree
|
|
|
|
|
|
|
|
|
|
from pydantic import Field
|
|
|
|
|
|
2026-10-20 18:50:43 +08:00
|
|
|
from pigway_frame_flow.core.photo_preferences import eligible
|
2026-10-05 00:52:39 +08:00
|
|
|
from pigway_frame_flow.errors import ErrorCode as E
|
|
|
|
|
from pigway_frame_flow.errors import FlowError
|
|
|
|
|
from pigway_frame_flow.models import StrictModel
|
2026-10-05 01:36:05 +08:00
|
|
|
from pigway_frame_flow.providers.photo_names import path_caption
|
2026-10-05 10:38:20 +08:00
|
|
|
from pigway_frame_flow.providers.photo_orientation import dimensions, orientation
|
2026-10-05 08:43:07 +08:00
|
|
|
from pigway_frame_flow.providers.synology_photos import SynologyPhotos
|
2026-10-06 08:47:03 +08:00
|
|
|
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS
|
2026-10-05 00:52:39 +08:00
|
|
|
from pigway_frame_flow.settings import secret
|
|
|
|
|
from pigway_frame_flow.storage.database import encode
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PhotoSourceInput(StrictModel):
|
|
|
|
|
name: str = Field(min_length=1, max_length=120)
|
2026-10-16 22:31:13 +08:00
|
|
|
kind: Literal[
|
|
|
|
|
"local", "synology-photos", "webdav", "google-drive", "alipan", "baidu"
|
|
|
|
|
]
|
2026-10-05 00:52:39 +08:00
|
|
|
location: str = Field(min_length=1, max_length=2048)
|
2026-10-18 15:30:38 +08:00
|
|
|
nas_root_path: str | None = Field(default=None, max_length=2048, pattern=r"^/")
|
2026-10-05 00:52:39 +08:00
|
|
|
recursive: bool = True
|
|
|
|
|
enabled: bool = True
|
2026-10-05 01:36:05 +08:00
|
|
|
catalog_limit: int = Field(default=10000, ge=1, le=100000)
|
2026-10-05 08:18:56 +08:00
|
|
|
synology_previews: bool = False
|
2026-10-05 01:36:05 +08:00
|
|
|
organized_only: bool = False
|
|
|
|
|
folder_caption: bool = False
|
2026-10-05 10:38:20 +08:00
|
|
|
orientation: Literal["auto", "landscape", "portrait"] | None = None
|
2026-10-18 14:59:35 +08:00
|
|
|
minimum_short_edge: int = Field(default=0, ge=0, le=50000)
|
|
|
|
|
minimum_long_edge: int = Field(default=0, ge=0, le=50000)
|
2026-10-08 23:18:11 +08:00
|
|
|
minimum_sharpness: float = Field(default=0, ge=0, le=10000)
|
|
|
|
|
minimum_brightness: float = Field(default=0, ge=0, le=255)
|
|
|
|
|
maximum_brightness: float = Field(default=255, ge=0, le=255)
|
|
|
|
|
avoid_duplicates: bool = False
|
|
|
|
|
people_space: Literal["shared", "personal"] = "shared"
|
2026-10-05 08:43:07 +08:00
|
|
|
people_filter: bool = False
|
|
|
|
|
person_ids: list[int] = Field(default_factory=list, max_length=50)
|
|
|
|
|
person_match: Literal["any", "all"] = "any"
|
2026-10-05 00:52:39 +08:00
|
|
|
credential_env: str | None = Field(
|
|
|
|
|
default=None, pattern=r"^FRAME_FLOW_SOURCE_[A-Z0-9_]+$"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PhotoSourceView(PhotoSourceInput):
|
|
|
|
|
id: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PhotoItem(StrictModel):
|
2026-10-20 18:50:43 +08:00
|
|
|
starred: bool = False
|
|
|
|
|
excluded: bool = False
|
2026-10-05 00:52:39 +08:00
|
|
|
id: str
|
|
|
|
|
name: str
|
2026-10-05 01:36:05 +08:00
|
|
|
caption: dict[str, str] | None = None
|
2026-10-18 15:30:38 +08:00
|
|
|
source_path: str | None = None
|
2026-10-05 08:18:56 +08:00
|
|
|
provider_preview: bool = False
|
2026-10-05 10:38:20 +08:00
|
|
|
orientation: Literal["landscape", "portrait", "square"] | None = None
|
|
|
|
|
width: int | None = None
|
|
|
|
|
height: int | None = None
|
2026-10-05 00:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class PhotoCatalog(StrictModel):
|
|
|
|
|
items: list[PhotoItem]
|
|
|
|
|
total: int
|
|
|
|
|
offset: int
|
|
|
|
|
limit: int
|
|
|
|
|
catalog_limit: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PhotoSources:
|
|
|
|
|
extensions = {".jpg", ".jpeg", ".png", ".webp"}
|
|
|
|
|
|
|
|
|
|
def __init__(self, db, settings, assets):
|
|
|
|
|
self.db, self.settings, self.assets = db, settings, assets
|
|
|
|
|
self.cache = {}
|
2026-10-16 22:31:13 +08:00
|
|
|
self.api_items = {}
|
2026-10-23 19:59:02 +08:00
|
|
|
from pigway_frame_flow.providers.source_browser import SourceBrowser
|
|
|
|
|
|
|
|
|
|
self.browser = SourceBrowser(self)
|
2026-10-05 08:43:07 +08:00
|
|
|
self.synology = SynologyPhotos(settings)
|
2026-10-05 00:52:39 +08:00
|
|
|
# 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"):
|
|
|
|
|
try:
|
|
|
|
|
self.headers(
|
|
|
|
|
PhotoSourceInput.model_validate(
|
|
|
|
|
{k: v for k, v in row.items() if k != "id"}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
except FlowError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def list(self):
|
|
|
|
|
return [
|
|
|
|
|
dict(json.loads(row["payload"]), id=row["key"][13:])
|
|
|
|
|
for row in self.db.all(
|
|
|
|
|
"SELECT key,payload FROM app_config WHERE key LIKE 'photo_source:%' ORDER BY key"
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def get(self, source_id):
|
|
|
|
|
row = self.db.one(
|
|
|
|
|
"SELECT payload FROM app_config WHERE key=?", ("photo_source:" + source_id,)
|
|
|
|
|
)
|
|
|
|
|
if not row:
|
|
|
|
|
raise FlowError(E.NOT_FOUND, "Photo source not found", 404)
|
|
|
|
|
return PhotoSourceInput.model_validate_json(row["payload"])
|
|
|
|
|
|
2026-10-17 01:39:23 +08:00
|
|
|
@staticmethod
|
|
|
|
|
def require_api_source(source):
|
|
|
|
|
# Block persisted legacy sources as well as new requests, before any disk read.
|
|
|
|
|
if source.kind == "local":
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.FORBIDDEN,
|
|
|
|
|
"Filesystem photo sources are disabled; use the Synology Photos API",
|
|
|
|
|
403,
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-05 00:52:39 +08:00
|
|
|
def save(self, source_id, source):
|
2026-10-17 01:39:23 +08:00
|
|
|
self.require_api_source(source)
|
2026-10-08 23:18:11 +08:00
|
|
|
if source.minimum_brightness > source.maximum_brightness:
|
|
|
|
|
raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed")
|
2026-10-16 22:31:13 +08:00
|
|
|
if source.kind not in ("local", "synology-photos") and (
|
2026-10-08 23:18:11 +08:00
|
|
|
source.organized_only or source.folder_caption or source.synology_previews
|
|
|
|
|
):
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.UNSUPPORTED_CAPABILITY,
|
|
|
|
|
"Folder captions and Synology previews require a local photo source",
|
|
|
|
|
)
|
2026-10-16 22:31:13 +08:00
|
|
|
if source.orientation and source.kind not in ("local", "synology-photos"):
|
2026-10-05 10:38:20 +08:00
|
|
|
raise FlowError(
|
|
|
|
|
E.UNSUPPORTED_CAPABILITY,
|
|
|
|
|
"Orientation filtering requires a local photo source",
|
|
|
|
|
)
|
2026-10-16 22:31:13 +08:00
|
|
|
if source.people_filter and source.kind not in ("local", "synology-photos"):
|
2026-10-05 08:43:07 +08:00
|
|
|
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")
|
2026-10-05 00:52:39 +08:00
|
|
|
if source.kind == "local":
|
|
|
|
|
self.local_directory(source.location)
|
|
|
|
|
if source.credential_env:
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.INVALID_REQUEST, "Local sources do not use credentials"
|
|
|
|
|
)
|
2026-10-16 22:31:13 +08:00
|
|
|
elif source.kind == "synology-photos":
|
|
|
|
|
location = source.location.strip("/")
|
|
|
|
|
if location == ".":
|
|
|
|
|
location = ""
|
|
|
|
|
if "\\" in location or any(p in (".", "..") for p in location.split("/")):
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.INVALID_REQUEST,
|
|
|
|
|
"Photos API folder must be a relative folder path or root",
|
|
|
|
|
)
|
|
|
|
|
if source.credential_env or source.synology_previews:
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.INVALID_REQUEST,
|
|
|
|
|
"Photos API sources use the shared Photos session and XL thumbnail download",
|
|
|
|
|
)
|
|
|
|
|
source = source.model_copy(update={"location": location or "."})
|
2026-10-08 23:18:11 +08:00
|
|
|
elif source.kind == "webdav":
|
2026-10-05 00:52:39 +08:00
|
|
|
source = source.model_copy(
|
|
|
|
|
update={
|
|
|
|
|
"recursive": False,
|
|
|
|
|
"location": source.location.rstrip("/") + "/",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
parsed = urlsplit(source.location)
|
|
|
|
|
if (
|
|
|
|
|
parsed.scheme != "https"
|
|
|
|
|
or not parsed.hostname
|
|
|
|
|
or parsed.username
|
|
|
|
|
or parsed.password
|
|
|
|
|
or parsed.query
|
|
|
|
|
or parsed.fragment
|
|
|
|
|
or parsed.port not in (None, 443)
|
|
|
|
|
):
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.INVALID_REQUEST, "WebDAV requires a plain HTTPS directory URL"
|
|
|
|
|
)
|
|
|
|
|
if parsed.hostname.lower() not in self.settings.image_url_hosts:
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.FORBIDDEN, "Configure the allowed image host first", 403
|
|
|
|
|
)
|
|
|
|
|
self.db.execute(
|
|
|
|
|
"INSERT OR REPLACE INTO app_config(key,payload) VALUES(?,?)",
|
|
|
|
|
("photo_source:" + source_id, encode(source.model_dump())),
|
|
|
|
|
)
|
|
|
|
|
self.cache.pop(source_id, None)
|
2026-10-16 22:31:13 +08:00
|
|
|
self.api_items.pop(source_id, None)
|
2026-10-05 00:52:39 +08:00
|
|
|
return dict(source.model_dump(), id=source_id)
|
|
|
|
|
|
|
|
|
|
def delete(self, source_id):
|
|
|
|
|
self.get(source_id)
|
|
|
|
|
for row in self.db.all("SELECT payload FROM jobs"):
|
|
|
|
|
if json.loads(row["payload"])["source"].get("source_id") == source_id:
|
|
|
|
|
raise FlowError(E.CONFLICT, "Remove jobs using this source first", 409)
|
|
|
|
|
for row in self.db.all(
|
|
|
|
|
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')"
|
|
|
|
|
):
|
|
|
|
|
payload = json.loads(row["payload"])
|
|
|
|
|
if payload.get("content", payload).get("source_id") == source_id:
|
|
|
|
|
raise FlowError(E.CONFLICT, "This source has pending operations", 409)
|
|
|
|
|
self.db.execute(
|
|
|
|
|
"DELETE FROM app_config WHERE key=?", ("photo_source:" + source_id,)
|
|
|
|
|
)
|
2026-10-20 18:50:43 +08:00
|
|
|
self.db.execute("DELETE FROM photo_preferences WHERE source_id=?", (source_id,))
|
2026-10-05 00:52:39 +08:00
|
|
|
self.cache.pop(source_id, None)
|
2026-10-16 22:31:13 +08:00
|
|
|
self.api_items.pop(source_id, None)
|
2026-10-05 00:52:39 +08:00
|
|
|
|
|
|
|
|
def local_directory(self, location):
|
|
|
|
|
root = self.settings.photo_root.resolve()
|
|
|
|
|
directory = (root / location).resolve()
|
|
|
|
|
if Path(location).is_absolute() or not directory.is_relative_to(root):
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.FORBIDDEN,
|
|
|
|
|
"Photo directory must be inside the mounted photo root",
|
|
|
|
|
403,
|
|
|
|
|
)
|
|
|
|
|
if not directory.is_dir():
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.NOT_FOUND,
|
|
|
|
|
"Photo directory is unavailable; check its read-only mount",
|
|
|
|
|
404,
|
|
|
|
|
)
|
2026-10-05 07:59:48 +08:00
|
|
|
try:
|
|
|
|
|
with os.scandir(directory):
|
|
|
|
|
pass
|
|
|
|
|
except PermissionError:
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.FORBIDDEN,
|
|
|
|
|
"Photo directory is not readable; configure the container's existing NAS read group",
|
|
|
|
|
403,
|
|
|
|
|
) from None
|
2026-10-05 00:52:39 +08:00
|
|
|
return directory
|
|
|
|
|
|
|
|
|
|
def headers(self, source):
|
|
|
|
|
if not source.credential_env:
|
|
|
|
|
return {}
|
|
|
|
|
try:
|
|
|
|
|
value = secret(source.credential_env)
|
|
|
|
|
except OSError:
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.CONFIG_REQUIRED, "WebDAV credential secret is unavailable"
|
|
|
|
|
) from None
|
|
|
|
|
if value and value not in self.settings.additional_secrets:
|
|
|
|
|
self.settings.additional_secrets.append(value)
|
|
|
|
|
try:
|
|
|
|
|
credentials = json.loads(value)
|
|
|
|
|
username, password = credentials["username"], credentials["password"]
|
|
|
|
|
if (
|
|
|
|
|
not isinstance(username, str)
|
|
|
|
|
or not isinstance(password, str)
|
|
|
|
|
or ":" in username
|
|
|
|
|
):
|
|
|
|
|
raise ValueError()
|
|
|
|
|
except (ValueError, KeyError, TypeError):
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.CONFIG_REQUIRED,
|
|
|
|
|
"WebDAV credential secret must contain username and password",
|
|
|
|
|
) from None
|
|
|
|
|
for sensitive in (value, username, password):
|
|
|
|
|
if sensitive and sensitive not in self.settings.additional_secrets:
|
|
|
|
|
self.settings.additional_secrets.append(sensitive)
|
|
|
|
|
encoded = base64.b64encode((username + ":" + password).encode()).decode()
|
|
|
|
|
if encoded not in self.settings.additional_secrets:
|
|
|
|
|
self.settings.additional_secrets.append(encoded)
|
|
|
|
|
return {"Authorization": "Basic " + encoded}
|
|
|
|
|
|
2026-10-05 08:18:56 +08:00
|
|
|
def thumbnail(self, path):
|
|
|
|
|
candidate = path.parent / "@eaDir" / path.name / "SYNOPHOTO_THUMB_XL.jpg"
|
|
|
|
|
try:
|
|
|
|
|
if candidate.is_symlink() or not candidate.resolve().is_relative_to(
|
|
|
|
|
path.parent.resolve()
|
|
|
|
|
):
|
|
|
|
|
return None
|
|
|
|
|
if (
|
|
|
|
|
candidate.is_file()
|
|
|
|
|
and candidate.stat().st_size <= self.settings.max_bytes
|
|
|
|
|
):
|
|
|
|
|
return candidate
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
return None
|
|
|
|
|
|
2026-10-05 22:44:41 +08:00
|
|
|
def catalog(self, source_id, refresh=False, *, target_size=(1200, 1600)):
|
2026-10-05 00:52:39 +08:00
|
|
|
source = self.get(source_id)
|
2026-10-17 01:39:23 +08:00
|
|
|
self.require_api_source(source)
|
2026-10-05 00:52:39 +08:00
|
|
|
if not source.enabled:
|
|
|
|
|
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
|
2026-10-16 22:31:13 +08:00
|
|
|
session_identity = (
|
|
|
|
|
self.synology.sid() if source.kind == "synology-photos" else None
|
|
|
|
|
)
|
2026-10-05 22:44:41 +08:00
|
|
|
wanted = (
|
|
|
|
|
orientation(*target_size)
|
|
|
|
|
if source.orientation == "auto"
|
|
|
|
|
else source.orientation
|
|
|
|
|
)
|
2026-10-05 00:52:39 +08:00
|
|
|
cached = self.cache.get(source_id)
|
2026-10-05 22:44:41 +08:00
|
|
|
if (
|
|
|
|
|
cached
|
|
|
|
|
and not refresh
|
|
|
|
|
and time.monotonic() - cached[0] < 60
|
|
|
|
|
and cached[2] == wanted
|
2026-10-16 22:31:13 +08:00
|
|
|
and cached[3] == session_identity
|
2026-10-05 22:44:41 +08:00
|
|
|
):
|
2026-10-05 00:52:39 +08:00
|
|
|
return cached[1]
|
2026-10-05 08:43:07 +08:00
|
|
|
allowed = (
|
2026-10-08 23:18:11 +08:00
|
|
|
self.synology.paths(
|
|
|
|
|
source.person_ids,
|
|
|
|
|
source.person_match,
|
|
|
|
|
refresh=refresh,
|
|
|
|
|
space=source.people_space,
|
|
|
|
|
)
|
2026-10-16 22:31:13 +08:00
|
|
|
if source.people_filter and source.kind == "local"
|
2026-10-05 08:43:07 +08:00
|
|
|
else None
|
|
|
|
|
)
|
2026-10-05 00:52:39 +08:00
|
|
|
items = []
|
2026-10-05 10:38:20 +08:00
|
|
|
sizes = (
|
|
|
|
|
{r["path"]: r for r in self.db.all("SELECT * FROM photo_dimensions")}
|
|
|
|
|
if source.orientation
|
|
|
|
|
else {}
|
|
|
|
|
)
|
|
|
|
|
size_updates = []
|
2026-10-05 00:52:39 +08:00
|
|
|
if source.kind == "local":
|
|
|
|
|
directory = self.local_directory(source.location)
|
|
|
|
|
root = self.settings.photo_root.resolve()
|
|
|
|
|
# Do not follow symlink directories or import Synology's thumbnail trees.
|
|
|
|
|
for folder, dirs, files in os.walk(directory, followlinks=False):
|
|
|
|
|
dirs[:] = (
|
|
|
|
|
sorted(
|
|
|
|
|
d
|
|
|
|
|
for d in dirs
|
2026-10-05 01:36:05 +08:00
|
|
|
if d != "#recycle"
|
|
|
|
|
and not d.startswith(".")
|
2026-10-05 00:52:39 +08:00
|
|
|
and not d.startswith("@")
|
|
|
|
|
and not Path(folder, d).is_symlink()
|
|
|
|
|
)
|
|
|
|
|
if source.recursive
|
|
|
|
|
else []
|
|
|
|
|
)
|
2026-10-05 01:36:05 +08:00
|
|
|
caption = path_caption(
|
|
|
|
|
Path(folder).relative_to(root).as_posix() + "/__photo__.jpg"
|
|
|
|
|
)
|
|
|
|
|
if source.organized_only and caption is None:
|
|
|
|
|
continue
|
2026-10-05 00:52:39 +08:00
|
|
|
for name in sorted(files):
|
|
|
|
|
path = Path(folder, name)
|
|
|
|
|
if (
|
|
|
|
|
name.startswith(".")
|
|
|
|
|
or path.suffix.lower() not in self.extensions
|
|
|
|
|
or path.is_symlink()
|
|
|
|
|
):
|
|
|
|
|
continue
|
|
|
|
|
try:
|
2026-10-05 08:18:56 +08:00
|
|
|
large = path.stat().st_size > self.settings.max_bytes
|
|
|
|
|
preview = (
|
|
|
|
|
large
|
|
|
|
|
and source.synology_previews
|
|
|
|
|
and self.thumbnail(path) is not None
|
|
|
|
|
)
|
|
|
|
|
if large and not preview:
|
2026-10-05 00:52:39 +08:00
|
|
|
continue
|
|
|
|
|
relative = path.relative_to(root).as_posix()
|
2026-10-05 08:43:07 +08:00
|
|
|
if allowed is not None and relative not in allowed:
|
|
|
|
|
continue
|
2026-10-05 10:38:20 +08:00
|
|
|
info = {}
|
2026-10-06 08:47:03 +08:00
|
|
|
if wanted or source.kind == "local":
|
2026-10-05 10:38:20 +08:00
|
|
|
key = str(path)
|
|
|
|
|
stamp = path.stat()
|
|
|
|
|
cached_size = sizes.get(key)
|
|
|
|
|
if (
|
|
|
|
|
cached_size
|
|
|
|
|
and cached_size["mtime"] == stamp.st_mtime_ns
|
|
|
|
|
and cached_size["bytes"] == stamp.st_size
|
|
|
|
|
):
|
|
|
|
|
width, height = (
|
|
|
|
|
cached_size["width"],
|
|
|
|
|
cached_size["height"],
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
size = dimensions(root, path)
|
2026-10-06 08:47:03 +08:00
|
|
|
if size is None and preview:
|
|
|
|
|
preview_path = self.thumbnail(path)
|
|
|
|
|
size = (
|
|
|
|
|
dimensions(root, preview_path)
|
|
|
|
|
if preview_path
|
|
|
|
|
else None
|
|
|
|
|
)
|
2026-10-05 10:38:20 +08:00
|
|
|
if size is None:
|
2026-10-06 08:47:03 +08:00
|
|
|
if wanted:
|
|
|
|
|
continue
|
|
|
|
|
size = (0, 0)
|
2026-10-05 10:38:20 +08:00
|
|
|
width, height = size
|
|
|
|
|
size_updates.append(
|
|
|
|
|
(
|
|
|
|
|
key,
|
|
|
|
|
stamp.st_mtime_ns,
|
|
|
|
|
stamp.st_size,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-10-18 14:59:35 +08:00
|
|
|
if not self.resolution_matches(source, width, height):
|
|
|
|
|
continue
|
2026-10-05 10:38:20 +08:00
|
|
|
direction = orientation(width, height)
|
2026-10-06 08:47:03 +08:00
|
|
|
if wanted and direction != wanted:
|
2026-10-05 10:38:20 +08:00
|
|
|
continue
|
|
|
|
|
info = {
|
|
|
|
|
"width": width,
|
|
|
|
|
"height": height,
|
|
|
|
|
"orientation": direction,
|
|
|
|
|
}
|
2026-10-06 08:47:03 +08:00
|
|
|
if width * height > MAX_IMAGE_PIXELS:
|
|
|
|
|
if (
|
|
|
|
|
source.synology_previews
|
|
|
|
|
and self.thumbnail(path) is not None
|
|
|
|
|
):
|
|
|
|
|
preview = True
|
|
|
|
|
else:
|
|
|
|
|
continue
|
2026-10-05 01:36:05 +08:00
|
|
|
items.append(
|
|
|
|
|
{
|
2026-10-05 10:38:20 +08:00
|
|
|
**info,
|
2026-10-05 01:36:05 +08:00
|
|
|
"id": relative,
|
|
|
|
|
"name": name,
|
2026-10-05 08:18:56 +08:00
|
|
|
"provider_preview": preview,
|
2026-10-05 01:36:05 +08:00
|
|
|
"caption": caption if source.folder_caption else None,
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-10-05 00:52:39 +08:00
|
|
|
except OSError:
|
|
|
|
|
continue
|
2026-10-05 01:36:05 +08:00
|
|
|
if len(items) >= source.catalog_limit:
|
2026-10-05 00:52:39 +08:00
|
|
|
break
|
2026-10-05 01:36:05 +08:00
|
|
|
if len(items) >= source.catalog_limit:
|
2026-10-05 00:52:39 +08:00
|
|
|
break
|
2026-10-16 22:31:13 +08:00
|
|
|
elif source.kind == "synology-photos":
|
|
|
|
|
self.synology.sid()
|
|
|
|
|
rows = self.synology.items(
|
|
|
|
|
source.person_ids if source.people_filter else [],
|
|
|
|
|
source.person_match,
|
|
|
|
|
space=source.people_space,
|
|
|
|
|
limit=100000,
|
|
|
|
|
)
|
|
|
|
|
location = "" if source.location == "." else source.location.strip("/")
|
|
|
|
|
self.api_items[source_id] = {}
|
|
|
|
|
for row in rows:
|
|
|
|
|
folder = row.get("additional", {}).get("folder")
|
|
|
|
|
if isinstance(folder, dict):
|
|
|
|
|
folder = folder.get("name")
|
|
|
|
|
if not isinstance(folder, str) or not isinstance(
|
|
|
|
|
row.get("filename"), str
|
|
|
|
|
):
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.CLOUD_UNAVAILABLE,
|
|
|
|
|
"Photos API response lacks folder or filename",
|
|
|
|
|
502,
|
|
|
|
|
)
|
|
|
|
|
relative = folder.strip("/") + "/" + row["filename"]
|
|
|
|
|
if location and not relative.startswith(location + "/"):
|
|
|
|
|
continue
|
|
|
|
|
if not source.recursive and folder.strip("/") != location:
|
|
|
|
|
continue
|
|
|
|
|
caption = path_caption(relative)
|
|
|
|
|
if source.organized_only and caption is None:
|
|
|
|
|
continue
|
|
|
|
|
size = row.get("additional", {}).get("resolution", {})
|
|
|
|
|
width, height = size.get("width", 0), size.get("height", 0)
|
2026-10-18 17:59:02 +08:00
|
|
|
# Photos resolution is already oriented; EXIF must not swap it twice.
|
2026-10-18 14:59:35 +08:00
|
|
|
if not self.resolution_matches(source, width, height):
|
|
|
|
|
continue
|
2026-10-16 22:31:13 +08:00
|
|
|
direction = orientation(width, height)
|
|
|
|
|
if wanted and (not width or not height or direction != wanted):
|
|
|
|
|
continue
|
|
|
|
|
items.append(
|
|
|
|
|
{
|
|
|
|
|
"id": str(row["id"]),
|
|
|
|
|
"name": row["filename"],
|
|
|
|
|
"provider_preview": True,
|
2026-10-18 15:30:38 +08:00
|
|
|
"source_path": (
|
|
|
|
|
source.nas_root_path.rstrip("/")
|
|
|
|
|
+ "/"
|
|
|
|
|
+ relative.lstrip("/")
|
|
|
|
|
if source.nas_root_path
|
|
|
|
|
else "/" + relative.lstrip("/")
|
|
|
|
|
),
|
2026-10-16 22:31:13 +08:00
|
|
|
"caption": caption if source.folder_caption else None,
|
|
|
|
|
"width": width,
|
|
|
|
|
"height": height,
|
|
|
|
|
"orientation": direction,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
self.api_items[source_id][str(row["id"])] = row
|
|
|
|
|
if len(items) >= source.catalog_limit:
|
|
|
|
|
break
|
2026-10-08 23:18:11 +08:00
|
|
|
elif source.kind in ("google-drive", "alipan", "baidu"):
|
|
|
|
|
from pigway_frame_flow.providers.cloud_drives import CloudDrives
|
|
|
|
|
|
|
|
|
|
items = CloudDrives(self).catalog(source_id, source)
|
2026-10-05 00:52:39 +08:00
|
|
|
else:
|
|
|
|
|
data = self.assets.fetch_url(
|
|
|
|
|
source.location,
|
|
|
|
|
method="PROPFIND",
|
|
|
|
|
headers={**self.headers(source), "Depth": "1"},
|
|
|
|
|
limit=2 * 1024 * 1024,
|
|
|
|
|
accepted_status=(207,),
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
b"\x00" in data
|
|
|
|
|
or b"<!DOCTYPE" in data.upper()
|
|
|
|
|
or b"<!ENTITY" in data.upper()
|
|
|
|
|
):
|
|
|
|
|
raise FlowError(E.INVALID_REQUEST, "Unsafe WebDAV XML")
|
|
|
|
|
try:
|
|
|
|
|
document = ElementTree.fromstring(data)
|
|
|
|
|
base = urlsplit(source.location)
|
|
|
|
|
prefix = posixpath.normpath(unquote(base.path)).rstrip("/") + "/"
|
|
|
|
|
seen = set()
|
|
|
|
|
for response in document.findall("{DAV:}response"):
|
|
|
|
|
href = response.findtext("{DAV:}href")
|
|
|
|
|
if not href or response.find(".//{DAV:}collection") is not None:
|
|
|
|
|
continue
|
|
|
|
|
url = urljoin(source.location, href)
|
|
|
|
|
parsed = urlsplit(url)
|
|
|
|
|
if (
|
|
|
|
|
(parsed.scheme, parsed.netloc) != (base.scheme, base.netloc)
|
|
|
|
|
or not posixpath.normpath(unquote(parsed.path)).startswith(
|
|
|
|
|
prefix
|
|
|
|
|
)
|
|
|
|
|
or "\\" in unquote(parsed.path)
|
|
|
|
|
or url in seen
|
|
|
|
|
or parsed.query
|
|
|
|
|
or parsed.fragment
|
|
|
|
|
):
|
|
|
|
|
continue
|
2026-10-05 01:36:05 +08:00
|
|
|
caption = path_caption(unquote(parsed.path))
|
|
|
|
|
if source.organized_only and caption is None:
|
|
|
|
|
continue
|
2026-10-05 00:52:39 +08:00
|
|
|
if Path(parsed.path).suffix.lower() in self.extensions:
|
|
|
|
|
seen.add(url)
|
|
|
|
|
items.append(
|
2026-10-05 01:36:05 +08:00
|
|
|
{
|
|
|
|
|
"id": url,
|
|
|
|
|
"name": unquote(Path(parsed.path).name),
|
|
|
|
|
"caption": caption if source.folder_caption else None,
|
|
|
|
|
}
|
2026-10-05 00:52:39 +08:00
|
|
|
)
|
2026-10-05 01:36:05 +08:00
|
|
|
if len(items) >= source.catalog_limit:
|
2026-10-05 00:52:39 +08:00
|
|
|
break
|
|
|
|
|
except ElementTree.ParseError:
|
|
|
|
|
raise FlowError(E.INVALID_REQUEST, "Invalid WebDAV catalog") from None
|
2026-10-05 10:38:20 +08:00
|
|
|
if size_updates:
|
|
|
|
|
with self.db.transaction() as connection:
|
|
|
|
|
connection.executemany(
|
|
|
|
|
"INSERT OR REPLACE INTO photo_dimensions(path,mtime,bytes,width,height) VALUES(?,?,?,?,?)",
|
|
|
|
|
size_updates,
|
|
|
|
|
)
|
2026-10-16 22:31:13 +08:00
|
|
|
self.cache[source_id] = (time.monotonic(), items, wanted, session_identity)
|
2026-10-05 00:52:39 +08:00
|
|
|
return items
|
|
|
|
|
|
2026-10-18 14:59:35 +08:00
|
|
|
@staticmethod
|
|
|
|
|
def resolution_matches(source, width, height):
|
|
|
|
|
short, long = sorted((width, height))
|
|
|
|
|
return short >= source.minimum_short_edge and long >= source.minimum_long_edge
|
|
|
|
|
|
2026-10-05 22:44:41 +08:00
|
|
|
def read(self, source_id, item_id=None, *, target_size=(1200, 1600)):
|
2026-10-05 00:52:39 +08:00
|
|
|
source = self.get(source_id)
|
2026-10-23 19:59:02 +08:00
|
|
|
browsed = (
|
|
|
|
|
self.browser.cached(source_id, item_id)
|
|
|
|
|
if item_id and source.kind == "synology-photos"
|
|
|
|
|
else None
|
|
|
|
|
)
|
|
|
|
|
items = (
|
|
|
|
|
[browsed[1]]
|
|
|
|
|
if browsed
|
|
|
|
|
else self.catalog(source_id, target_size=target_size)
|
|
|
|
|
)
|
2026-10-20 18:50:43 +08:00
|
|
|
if not item_id:
|
|
|
|
|
items = eligible(self.db, source_id, items)
|
2026-10-05 00:52:39 +08:00
|
|
|
if not items:
|
|
|
|
|
raise FlowError(
|
2026-10-20 18:50:43 +08:00
|
|
|
E.NOT_FOUND, "Photo source contains no eligible images", 404
|
2026-10-05 00:52:39 +08:00
|
|
|
)
|
|
|
|
|
item = (
|
|
|
|
|
next((x for x in items if x["id"] == item_id), None)
|
|
|
|
|
if item_id
|
|
|
|
|
else random.SystemRandom().choice(items)
|
|
|
|
|
)
|
|
|
|
|
if item is None:
|
|
|
|
|
raise FlowError(E.NOT_FOUND, "Photo is not in this source", 404)
|
|
|
|
|
if source.kind == "local":
|
|
|
|
|
root = self.settings.photo_root.resolve()
|
|
|
|
|
path = root / item["id"]
|
2026-10-05 08:18:56 +08:00
|
|
|
if item.get("provider_preview"):
|
|
|
|
|
preview = self.thumbnail(path)
|
|
|
|
|
if preview is None:
|
|
|
|
|
raise FlowError(E.NOT_FOUND, "Synology preview is unavailable", 404)
|
|
|
|
|
path = preview
|
2026-10-05 00:52:39 +08:00
|
|
|
if path.is_symlink() or not path.resolve().is_relative_to(
|
|
|
|
|
self.local_directory(source.location)
|
|
|
|
|
):
|
|
|
|
|
raise FlowError(E.FORBIDDEN, "Photo path is outside its source", 403)
|
|
|
|
|
try:
|
|
|
|
|
# Open every component without following links; a renamed parent
|
|
|
|
|
# must not turn a previously cataloged image into a secret-file read.
|
|
|
|
|
parent_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
|
|
|
|
|
try:
|
|
|
|
|
components = path.relative_to(root).parts
|
|
|
|
|
for component in components[:-1]:
|
|
|
|
|
child_fd = os.open(
|
|
|
|
|
component,
|
|
|
|
|
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
|
|
|
dir_fd=parent_fd,
|
|
|
|
|
)
|
|
|
|
|
os.close(parent_fd)
|
|
|
|
|
parent_fd = child_fd
|
|
|
|
|
file_fd = os.open(
|
|
|
|
|
components[-1],
|
|
|
|
|
os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
|
|
|
|
|
dir_fd=parent_fd,
|
|
|
|
|
)
|
|
|
|
|
with os.fdopen(file_fd, "rb") as handle:
|
|
|
|
|
if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.FORBIDDEN, "Photo must be a regular file", 403
|
|
|
|
|
)
|
|
|
|
|
data = handle.read(self.settings.max_bytes + 1)
|
|
|
|
|
finally:
|
|
|
|
|
os.close(parent_fd)
|
|
|
|
|
except OSError:
|
|
|
|
|
raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None
|
2026-10-16 22:31:13 +08:00
|
|
|
elif source.kind == "synology-photos":
|
|
|
|
|
data = self.synology.download(
|
2026-10-23 19:59:02 +08:00
|
|
|
browsed[2] if browsed else self.api_items[source_id][item["id"]],
|
|
|
|
|
space=source.people_space,
|
2026-10-16 22:31:13 +08:00
|
|
|
)
|
2026-10-17 23:28:04 +08:00
|
|
|
if source.folder_caption:
|
|
|
|
|
caption = dict(item.get("caption") or {})
|
|
|
|
|
try:
|
2026-10-18 00:12:28 +08:00
|
|
|
caption["people"] = " & ".join(
|
2026-10-23 19:59:02 +08:00
|
|
|
[p["name"] for p in item.get("people", []) if p["name"]]
|
|
|
|
|
if browsed
|
|
|
|
|
else self.synology.item_people(
|
|
|
|
|
item["id"], space=source.people_space
|
|
|
|
|
)
|
2026-10-17 23:28:04 +08:00
|
|
|
)
|
|
|
|
|
except FlowError:
|
|
|
|
|
# Optional labels must not prevent displaying a valid photo.
|
|
|
|
|
logging.getLogger(__name__).warning(
|
|
|
|
|
"Photos person labels unavailable; keeping photo caption"
|
|
|
|
|
)
|
2026-10-18 08:19:03 +08:00
|
|
|
item = {
|
|
|
|
|
**item,
|
|
|
|
|
"caption": {k: v for k, v in caption.items() if v} or None,
|
|
|
|
|
}
|
2026-10-08 23:18:11 +08:00
|
|
|
elif source.kind in ("google-drive", "alipan", "baidu"):
|
|
|
|
|
from pigway_frame_flow.providers.cloud_drives import CloudDrives
|
|
|
|
|
|
|
|
|
|
data = CloudDrives(self).read(source_id, source, item)
|
2026-10-05 00:52:39 +08:00
|
|
|
else:
|
|
|
|
|
data = self.assets.fetch_url(item["id"], headers=self.headers(source))
|
|
|
|
|
if len(data) > self.settings.max_bytes:
|
|
|
|
|
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
|
2026-10-18 17:43:25 +08:00
|
|
|
if source.orientation or source.minimum_short_edge or source.minimum_long_edge:
|
2026-10-18 14:59:35 +08:00
|
|
|
from io import BytesIO
|
|
|
|
|
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
with Image.open(BytesIO(data)) as image:
|
2026-10-18 17:43:25 +08:00
|
|
|
width, height = image.size
|
|
|
|
|
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
|
|
|
|
width, height = height, width
|
|
|
|
|
wanted = (
|
|
|
|
|
orientation(*target_size)
|
|
|
|
|
if source.orientation == "auto"
|
|
|
|
|
else source.orientation
|
|
|
|
|
)
|
|
|
|
|
# Validate the fetched rendition before a crop can hide a mismatch.
|
|
|
|
|
if wanted and orientation(width, height) != wanted:
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.INVALID_REQUEST,
|
|
|
|
|
"Photo direction does not match the source orientation",
|
|
|
|
|
)
|
|
|
|
|
if not self.resolution_matches(source, width, height):
|
2026-10-18 14:59:35 +08:00
|
|
|
raise FlowError(
|
|
|
|
|
E.INVALID_REQUEST,
|
|
|
|
|
"Photo is below the configured minimum resolution",
|
|
|
|
|
)
|
2026-10-05 01:36:05 +08:00
|
|
|
return data, item["name"], item.get("caption")
|