"""Bounded provider browsing: page metadata first, download only selected photos.""" import json import time from collections import OrderedDict from datetime import date, datetime, timedelta from datetime import time as day_time from typing import Literal from urllib.parse import quote from zoneinfo import ZoneInfo from pydantic import Field, model_validator 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.photo_orientation import orientation class BrowseFilters(StrictModel): offset: int = Field(default=0, ge=0, le=10000000) limit: int = Field(default=36, ge=1, le=60) filename: str = Field(default="", max_length=200) folder: str = Field(default="", max_length=500) min_bytes: int = Field(default=0, ge=0, le=10**12) max_bytes: int | None = Field(default=None, ge=0, le=10**12) min_width: int = Field(default=0, ge=0, le=100000) max_width: int | None = Field(default=None, ge=0, le=100000) min_height: int = Field(default=0, ge=0, le=100000) max_height: int | None = Field(default=None, ge=0, le=100000) date_from: date | None = None date_to: date | None = None date_field: Literal["time", "indexed_time"] = "indexed_time" person_id: int | None = Field(default=None, ge=1) @model_validator(mode="after") def ranges(self): for low, high in ( (self.min_bytes, self.max_bytes), (self.min_width, self.max_width), (self.min_height, self.max_height), ): if low is not None and high is not None and low > high: raise ValueError("Filter range is reversed") if self.date_from and self.date_to and self.date_from > self.date_to: raise ValueError("Filter date range is reversed") return self class SourceBrowser: def __init__(self, sources): self.sources = sources self.rows: OrderedDict[tuple, tuple] = OrderedDict() def identity(self, source_id): source = self.sources.get(source_id) self.sources.require_api_source(source) if not source.enabled: raise FlowError(E.CONFLICT, "Photo source is disabled", 409) session = ( self.sources.synology.sid() if source.kind == "synology-photos" else None ) return source, (source_id, source.model_dump_json(), session) def item(self, source, row): folder = row.get("additional", {}).get("folder") if isinstance(folder, dict): folder = folder.get("name") if row.get("type") != "photo" or not isinstance(folder, str): return None name = row.get("filename") if not isinstance(name, str): return None folder = folder.strip("/") location = source.location.strip("/") if source.location != "." else "" if location and not (folder == location or folder.startswith(location + "/")): return None if not source.recursive and folder != location: return None path = folder + "/" + name caption = path_caption(path) if source.organized_only and caption is None: return None size = row.get("additional", {}).get("resolution", {}) width, height = size.get("width", 0), size.get("height", 0) if not self.sources.resolution_matches(source, width, height): return None wanted = "portrait" if source.orientation == "auto" else source.orientation if wanted and (not width or not height or orientation(width, height) != wanted): return None people = row.get("additional", {}).get("person", []) ids = {str(p["id"]) for p in people if isinstance(p, dict) and "id" in p} if source.people_filter: selected = {str(i) for i in source.person_ids} if not ( selected <= ids if source.person_match == "all" else selected & ids ): return None return { "id": str(row["id"]), "name": name, "folder": folder, "source_path": (source.nas_root_path or "").rstrip("/") + "/" + path.lstrip("/"), "caption": caption if source.folder_caption else None, "provider_preview": True, "width": width, "height": height, "orientation": orientation(width, height), "filesize": row.get("filesize"), "time": row.get("time"), "indexed_time": row.get("indexed_time"), "people": [ {"id": p["id"], "name": p.get("name") or ""} for p in people if isinstance(p, dict) and "id" in p ], } def matches(self, item, filters): for key in ("filename", "folder"): value = item["name" if key == "filename" else key] if getattr(filters, key).casefold() not in value.casefold(): return False for key, low, high in ( ("filesize", filters.min_bytes, filters.max_bytes), ("width", filters.min_width, filters.max_width), ("height", filters.min_height, filters.max_height), ): value = item.get(key) if (low or high is not None) and ( value is None or value < low or (high is not None and value > high) ): return False if filters.person_id and filters.person_id not in { p["id"] for p in item["people"] }: return False zone = ZoneInfo(self.sources.settings.timezone) value = item.get(filters.date_field) if filters.date_from and ( not value or value < datetime.combine(filters.date_from, day_time(), zone).timestamp() ): return False if ( filters.date_to and filters.date_to < date.max and ( not value or value >= datetime.combine( filters.date_to + timedelta(days=1), day_time(), zone ).timestamp() ) ): return False return True def remember(self, identity, item, row): key = (*identity, item["id"]) self.rows[key] = (time.monotonic(), item, row) self.rows.move_to_end(key) while len(self.rows) > 2048: self.rows.popitem(last=False) def cached(self, source_id, item_id): if not self.rows: return None source, identity = self.identity(source_id) cached = self.rows.get((*identity, item_id)) if cached and time.monotonic() - cached[0] < 1800: return source, cached[1], cached[2] return None def page(self, source_id, offset, limit, filters): source, identity = self.identity(source_id) if source.kind != "synology-photos": # Other providers retain their established catalog implementation. items = self.sources.catalog(source_id) if filters.model_dump(exclude_defaults=True, exclude={"offset", "limit"}): raise FlowError( E.UNSUPPORTED_CAPABILITY, "These filters require Synology Photos" ) return { "items": items[offset : offset + limit], "next_offset": offset + limit if offset + limit < len(items) else None, "scanned": 0, "paged_provider": False, } items = [] scanned = 0 prefix = "SYNO.FotoTeam" if source.people_space == "shared" else "SYNO.Foto" for _ in range(4): data = self.sources.synology.request( prefix + ".Browse.Item", "list", 7, offset=offset, limit=limit, _sid=identity[2], additional=json.dumps( ["folder", "thumbnail", "resolution", "orientation", "person"] ), **({"person_id": filters.person_id} if filters.person_id else {}), ) rows = data.get("list", []) for index, row in enumerate(rows): offset += 1 scanned += 1 item = self.item(source, row) if item and self.matches(item, filters): self.remember(identity, item, row) item = { **item, "preview_url": f"/api/v1/source-libraries/{quote(source_id, safe='')}/browse/{quote(item['id'], safe='')}/preview", } items.append(item) if len(items) == limit: more = ( offset if index + 1 < len(rows) or len(rows) == limit else None ) return { "items": items, "next_offset": more, "scanned": scanned, "paged_provider": True, } if len(rows) < limit: return { "items": items, "next_offset": None, "scanned": scanned, "paged_provider": True, } return { "items": items, "next_offset": offset, "scanned": scanned, "paged_provider": True, } def preview(self, source_id, item_id): entry = self.cached(source_id, item_id) if not entry: raise FlowError( E.NOT_FOUND, "Photo preview expired; refresh the browser page", 404 ) source, _, row = entry return self.sources.synology.download(row, space=source.people_space, size="m")