diff --git a/SHA256SUMS b/SHA256SUMS index b2811cc..87d2af5 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -31,7 +31,7 @@ b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATI 10842c477d34e6fde7af0a98c739ef513ef48480d52e2fe6d6874eed33461d15 docs/README.md 5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md -2311d232a8394f365165d3fab8e5a5d27a88410f214919a8f3b94f2b24e02cb8 docs/SOURCE_LIBRARIES.md +aafc41e44e8c5e0d1554a3be5b6b961cf98ed71185f6084b0480f6e9dbea7ae8 docs/SOURCE_LIBRARIES.md fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md 1ae7e53355dc27e2995c85f2a9a55ad87e53085cccb3235970236666b6485799 docs/WATCH-THUMBNAIL.md f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md @@ -46,9 +46,9 @@ ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/ 2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py -48321bc1ebd77b09ab91a8f4b3f46ba7b852713f827a31c37a620efc05a63bcd pigway_frame_flow/api/app.py +37f907c13f5747a37252d9e3e8a882b106fbd9158e8727d7dc05cb827f63173c pigway_frame_flow/api/app.py 1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py -586a886999a8327aa61df622f7e9c844cc865a8220027cf8b0d57fcfbb76214c pigway_frame_flow/api/libraries.py +5debca31ee59487c4168ab910e516fc42a3f3f008c3ade617feeb0f6d56cc6c8 pigway_frame_flow/api/libraries.py 3dda509a34daca61836913976dba35e59c2135c48bf04bc7617e923cc5cffdc1 pigway_frame_flow/api/thumbnails.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py 0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py @@ -104,10 +104,11 @@ e90b6becc3e19baa83fab763cb156eb8882a96594c04040305dcb315f68e85d0 pigway_frame_f 69e7419e67e8416bf73f37e257a2586d2a1aa4d2706da8f7b42c5cb8ca7f1eb3 pigway_frame_flow/providers/oauth.py abff8ee4b4eb1575451bcd5addf54451056af665d7aaa90173b5ed69a75a4d8e pigway_frame_flow/providers/photo_names.py 7d6fd8ff3ae99798b26d7a82644353e2c9ce5439e18c64393dc261c395ac6f4c pigway_frame_flow/providers/photo_orientation.py -885d1945dacd611143eb6992da4621ce32ae304d80514f150d55318bdcb10e0f pigway_frame_flow/providers/photo_sources.py +21353750457ecd5802c944b24a9976f05cbaa1920052785d26fda7e468ea3aae pigway_frame_flow/providers/photo_sources.py 114adfb3fcd1ab050c1dd2f1fff9d91fdde192b8eced907dee3bc8086037e13d pigway_frame_flow/providers/photos_cache.py 119427bc435ec8d4c91114713f37d07f22067eaeec391261d1441a1436baf5e0 pigway_frame_flow/providers/poetry.py -c44bf2b4a3776bebe3689b251ae3e8c352a43088829ae8c3f6fd379b64dea05e pigway_frame_flow/providers/synology_photos.py +b1266d22ea914aa4d52e0b80cc1e2948c72219ea766140eb5aa22f6d42531053 pigway_frame_flow/providers/source_browser.py +fd24397a0a0a8f3268586bc505c13816aca53ae08791f794fdb81cdbc1e6eaab pigway_frame_flow/providers/synology_photos.py 7dfc5eab241122c4758d8b0c027369049231451dc8e0e1b49f7da56532a8e87d pigway_frame_flow/providers/weather.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/renderers/__init__.py ce1e3d9dd7d8dc8d76d6206ab9cd05974bd2e81e1bbf28711f4b461332bf9236 pigway_frame_flow/renderers/frame_geometry.py @@ -130,17 +131,18 @@ f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_f 8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json 82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json -67542e813a803fccbaab17db8ad525c32219d6ba88d9c83e65aac95cf2325df6 pigway_frame_flow/web/app.js +3b17eb1781ee8c6147e06ccc16486aa27cf47c01f2a75c89529677c00b88dc90 pigway_frame_flow/web/app.js f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_flow/web/apple-touch-icon.png d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js 94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg 1f77efa9acfb40872593e3b7c475d972b960cb9f21e9afb936add03c3f2dd542 pigway_frame_flow/web/features.js a07ea444ab4ac75ea11e1188a431b48195353b1e08f7e80f3b931e8097ea6221 pigway_frame_flow/web/i18n.js -25f975a560aab00b57c5527a674d52bc45bd6c4b42e6169f1db406f2040f2177 pigway_frame_flow/web/index.html -7787ff064be5beddd18cd5c7271aa900b33f991ae95dd9ff8ffe95154f7b2a3a pigway_frame_flow/web/libraries.js +6385ec8b0383fa9bdbb23c4c685c8ef1afd7926fd74c8032811e647808548255 pigway_frame_flow/web/index.html +a68b22ea3baf2a1b9d44f58d59e4bcc27017af49b852b5f76815634539c9c698 pigway_frame_flow/web/libraries.js 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg -86ecbf86eb41a7a46b4878cbac679f83dc3de0ee6687bcc7779140689f0b5ec3 pigway_frame_flow/web/style.css +9dcf0419d0292efce8ead7309dc70b4afd27dd24fe82cf9e673682342650f4da pigway_frame_flow/web/source-browser.js +e857baff294a00e9799d2b6b5473644f836f507f4aab8044a6a99fb9f48234b1 pigway_frame_flow/web/style.css cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE 050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js 1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css @@ -190,7 +192,7 @@ d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-pro e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py -77cdbcc0ec48125964d710ea437210d325d9aef30e02053742c506ef113ac519 tests/contract.json +771c825ff00147638627451ce90d4b7f8aab0c6844f8187afa7ab618f8dc235e tests/contract.json 9b3632a93c68c057b2fb276aa4a1b2b514f74934f0183480930d951d91704cae tests/schema-contract.json 23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py @@ -236,6 +238,7 @@ e0a426eed6f1b6f59207b4de7d8286b6852550bfa28ad30e20f7e911ad9865ee tests/test_ret ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py 0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py +a821fbe5096b2fa5b781423e66673b8d5659b60fc37c5c10470afa2a818659c8 tests/test_source_browser.py 686b925646d52dcf6d9574b9b451af499a402ee2793a99f1154d4984f8458183 tests/test_source_libraries.py 2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py diff --git a/docs/SOURCE_LIBRARIES.md b/docs/SOURCE_LIBRARIES.md index db6687e..812e11c 100644 --- a/docs/SOURCE_LIBRARIES.md +++ b/docs/SOURCE_LIBRARIES.md @@ -5,12 +5,20 @@ ## 使用顺序 1. 在“照片数据源”配置来源、人物、分辨率、质量、去重等选图条件。 -2. 在“图库 → 来源图库”选择来源,选入照片,保存无文字叠加的源画面。 +2. 在侧栏“来源图库”选择来源,选入照片,保存无文字叠加的源画面。 3. 新增该来源内的生成方案,可使用相框横版、竖版、手表预设,或自定义尺寸、适配方式、裁切位置、质量、色彩与说明叠加。也可将旧版小图方案参数复制到所选来源下。 4. 来源图库标签为“源画面”和该来源的生成方案;未生成的版本明确显示未生成,生成后可预览、下载、查看参数快照、删除。归档方案仍可查看已有版本。 5. 在“终端图库”对每个终端单选来源和方案。切换来源后必须重新选择方案,服务端检查归属和屏幕尺寸。绑定同一方案的终端共用生成文件。 6. 需要自动化时,为照片任务勾选“按来源图库统一生成并推送”。新建照片任务默认使用该模式;旧任务不在启动时自动改写,确认绑定后可在编辑任务时切换。唯一明确的单来源、单照片任务也可使用 `scripts/migrate-source-libraries.py` 预检,再加 `--apply` 迁移;脚本保留启用状态、调度配置和下次执行时间,不推送设备。若当前设备有可靠的照片来源回执,会选入该画面并仅在本地生成两个版本。生成模式不再额外选择旧版小图方案。 +## 从来源选入照片 + +“从来源选入照片”打开独立选图页面,使用照片卡片、按需预览和前后翻页。Synology Photos 每批显示最多 36 张照片,直接按偏移量读取 NAS 元数据,不再先遍历整个目录。一次请求最多检查四批;筛选较少见的照片时,当前批可能不足 36 张或为空,仍可继续下一页检索。页码表示当前扫描位置,不提供未经全库扫描的虚假总数;NAS 内容变动时可重置筛选重新浏览。 + +可按文件名、文件夹关键字、文件大小、像素宽高、日期范围及识别人物组合筛选。保留来源本身的目录、递归、人物、方向、最小分辨率等限制。日期使用系统时区:入库日期取 NAS `indexed_time`,照片日期取 `time`,不将入库时间冒充原文件创建时间。人物数据使用 NAS 现有识别结果,不执行新的人脸识别。 + +预览懒加载 NAS 中图(`m`),选入时才下载用于本地生成的 XL 画面。已浏览的选择记录缓存最多 2048 条、有效期 30 分钟,并绑定来源配置和 NAS 会话;来源范围或登录账号改变后不能复用旧预览。选入会使用已校验的页面记录,避免再次全库读取和遍历所有人物相册。其他云盘继续使用现有目录接口,高级筛选与快速分页仅支持 Synology Photos。 + ## 一次选图,多种版本 任务先选定一次源画面并保存到本地,再按来源的终端绑定生成版本。默认等比裁切铺满,不拉伸、不补黑边。主动选择完整画面时必要处补白;说明叠加使用来源提供的日期、标题、地点等元数据。源画面本身始终不加文字。Synology Photos 当前取得的是可用源预览,不称为原图;本地存储按画面原尺寸归一化为 JPEG 并处理 EXIF 朝向。 @@ -32,6 +40,7 @@ ## API 与存储 - `/api/v1/source-libraries`:来源图库;`/{id}/images`、`/import`、`/run` 用于列表、选入与统一生成。 +- `/api/v1/source-libraries/{id}/browse`:按批浏览 NAS 元数据;`/browse/{item_id}/preview` 读取已浏览照片的小预览。 - `/api/v1/generation-plans`:按 `source_id` 查询与创建方案;`/{id}` 修改或归档。 - `/api/v1/source-images/{id}`:源画面删除;`/generate`、`/preview`、`/star`、`/exclusion` 用于版本生成、预览和来源偏好。 - `/api/v1/library-terminals`:终端;`/{id}/binding` 管理单一绑定,`/{id}/images` 查询成品,`/{id}/images/{asset_id}/send` 显式推送。 diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 05a5544..1e43661 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -1404,6 +1404,7 @@ def create_app(settings=None, start_worker=True): "app.js", "thumbnails.js", "libraries.js", + "source-browser.js", "features.js", "extensions.js", "style.css", diff --git a/pigway_frame_flow/api/libraries.py b/pigway_frame_flow/api/libraries.py index 845afd4..932673b 100644 --- a/pigway_frame_flow/api/libraries.py +++ b/pigway_frame_flow/api/libraries.py @@ -8,6 +8,7 @@ from pigway_frame_flow.display.libraries import GenerationPlan from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError from pigway_frame_flow.models import ImageExclusion, ImageStar, StrictModel +from pigway_frame_flow.providers.source_browser import BrowseFilters class BindingInput(StrictModel): @@ -79,6 +80,30 @@ def install_libraries(app, service): )["n"], } + @app.get( + "/api/v1/source-libraries/{source_id}/browse", + dependencies=[Depends(require("images:read"))], + ) + def browse( + source_id: str, + filters: BrowseFilters = Query(), + ): + return service.photo_sources.browser.page( + source_id, filters.offset, filters.limit, filters + ) + + @app.get( + "/api/v1/source-libraries/{source_id}/browse/{item_id}/preview", + dependencies=[Depends(require("images:read"))], + ) + def browse_preview(source_id: str, item_id: str): + data = service.photo_sources.browser.preview(source_id, item_id) + return Response( + data, + media_type="image/jpeg", + headers={"Cache-Control": "private, no-store"}, + ) + @app.post( "/api/v1/source-libraries/{source_id}/import", dependencies=[Depends(require("admin"))], diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py index ca9ab95..874d2d8 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -88,6 +88,9 @@ class PhotoSources: self.db, self.settings, self.assets = db, settings, assets self.cache = {} self.api_items = {} + from pigway_frame_flow.providers.source_browser import SourceBrowser + + self.browser = SourceBrowser(self) self.synology = SynologyPhotos(settings) # Register provider secrets before request IDs or errors can be reflected. for row in self.list(): @@ -584,7 +587,16 @@ class PhotoSources: def read(self, source_id, item_id=None, *, target_size=(1200, 1600)): source = self.get(source_id) - items = self.catalog(source_id, target_size=target_size) + 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) + ) if not item_id: items = eligible(self.db, source_id, items) if not items: @@ -641,13 +653,18 @@ class PhotoSources: raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None elif source.kind == "synology-photos": data = self.synology.download( - self.api_items[source_id][item["id"]], space=source.people_space + browsed[2] if browsed else self.api_items[source_id][item["id"]], + space=source.people_space, ) if source.folder_caption: caption = dict(item.get("caption") or {}) try: caption["people"] = " & ".join( - self.synology.item_people(item["id"], space=source.people_space) + [p["name"] for p in item.get("people", []) if p["name"]] + if browsed + else self.synology.item_people( + item["id"], space=source.people_space + ) ) except FlowError: # Optional labels must not prevent displaying a valid photo. diff --git a/pigway_frame_flow/providers/source_browser.py b/pigway_frame_flow/providers/source_browser.py new file mode 100644 index 0000000..86dfd81 --- /dev/null +++ b/pigway_frame_flow/providers/source_browser.py @@ -0,0 +1,252 @@ +"""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") diff --git a/pigway_frame_flow/providers/synology_photos.py b/pigway_frame_flow/providers/synology_photos.py index 1c84c52..3fb0ee7 100644 --- a/pigway_frame_flow/providers/synology_photos.py +++ b/pigway_frame_flow/providers/synology_photos.py @@ -337,12 +337,14 @@ class SynologyPhotos: self.cache[key] = (time.monotonic(), names) return list(self.cache[key][1].get(str(item_id), [])) - def download(self, row, *, space="shared", rendition="thumbnail"): + def download(self, row, *, space="shared", rendition="thumbnail", size="xl"): from pigway_frame_flow.providers.photos_cache import PhotosCache sid = self.sid() url, pin = self.endpoint() prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto" + if size not in ("m", "xl"): + raise FlowError(E.INVALID_REQUEST, "Invalid Photos preview size") if rendition == "thumbnail": thumb = row.get("additional", {}).get("thumbnail", {}) if not thumb.get("cache_key") or not thumb.get("unit_id"): @@ -353,7 +355,7 @@ class SynologyPhotos: params = dict( id=thumb["unit_id"], cache_key=thumb["cache_key"], - size="xl", + size=size, type="unit", ) else: @@ -372,6 +374,7 @@ class SynologyPhotos: space, row["id"], rendition, + size, row.get("indexed_time"), row.get("filesize"), row.get("additional", {}).get("thumbnail", {}).get("cache_key"), diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index ebbe0b5..510a201 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -49,7 +49,7 @@ function operationResultHTML(o){ } function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;} function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;} -function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).filter(([id])=>!['legacy-plans','legacy-gallery'].includes(id)).map(([id,icon])=>``).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);const watchSelected=['terminal-gallery','legacy-gallery'].includes(state.page)&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>``).join('')+``;revealCurrentNavigation();} +function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).filter(([id])=>!['legacy-plans','legacy-gallery','source-browser'].includes(id)).map(([id,icon])=>``).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);const watchSelected=['terminal-gallery','legacy-gallery'].includes(state.page)&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>``).join('')+``;revealCurrentNavigation();} // A destination appears only when its connection and read/write adapter are available. // Future cloud-drive destinations must implement their adapter before registration here. const galleryDestinationAdapters=new Set(['kokonna','local-assets','watch-thumbnails']); diff --git a/pigway_frame_flow/web/index.html b/pigway_frame_flow/web/index.html index fa5b079..186f733 100644 --- a/pigway_frame_flow/web/index.html +++ b/pigway_frame_flow/web/index.html @@ -1,3 +1,3 @@