Files
pigway-frame-flow/pigway_frame_flow/providers/source_browser.py
T

253 lines
9.8 KiB
Python
Raw Normal View History

"""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")