253 lines
9.8 KiB
Python
253 lines
9.8 KiB
Python
"""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")
|