feat: add paged NAS photo browser with metadata filters and previews
This commit is contained in:
+13
-10
@@ -31,7 +31,7 @@ b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATI
|
|||||||
10842c477d34e6fde7af0a98c739ef513ef48480d52e2fe6d6874eed33461d15 docs/README.md
|
10842c477d34e6fde7af0a98c739ef513ef48480d52e2fe6d6874eed33461d15 docs/README.md
|
||||||
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
|
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
|
||||||
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
||||||
2311d232a8394f365165d3fab8e5a5d27a88410f214919a8f3b94f2b24e02cb8 docs/SOURCE_LIBRARIES.md
|
aafc41e44e8c5e0d1554a3be5b6b961cf98ed71185f6084b0480f6e9dbea7ae8 docs/SOURCE_LIBRARIES.md
|
||||||
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
|
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
|
||||||
1ae7e53355dc27e2995c85f2a9a55ad87e53085cccb3235970236666b6485799 docs/WATCH-THUMBNAIL.md
|
1ae7e53355dc27e2995c85f2a9a55ad87e53085cccb3235970236666b6485799 docs/WATCH-THUMBNAIL.md
|
||||||
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
|
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
|
||||||
@@ -46,9 +46,9 @@ ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/
|
|||||||
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
|
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__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
|
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
|
3dda509a34daca61836913976dba35e59c2135c48bf04bc7617e923cc5cffdc1 pigway_frame_flow/api/thumbnails.py
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
|
||||||
0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py
|
0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py
|
||||||
@@ -104,10 +104,11 @@ e90b6becc3e19baa83fab763cb156eb8882a96594c04040305dcb315f68e85d0 pigway_frame_f
|
|||||||
69e7419e67e8416bf73f37e257a2586d2a1aa4d2706da8f7b42c5cb8ca7f1eb3 pigway_frame_flow/providers/oauth.py
|
69e7419e67e8416bf73f37e257a2586d2a1aa4d2706da8f7b42c5cb8ca7f1eb3 pigway_frame_flow/providers/oauth.py
|
||||||
abff8ee4b4eb1575451bcd5addf54451056af665d7aaa90173b5ed69a75a4d8e pigway_frame_flow/providers/photo_names.py
|
abff8ee4b4eb1575451bcd5addf54451056af665d7aaa90173b5ed69a75a4d8e pigway_frame_flow/providers/photo_names.py
|
||||||
7d6fd8ff3ae99798b26d7a82644353e2c9ce5439e18c64393dc261c395ac6f4c pigway_frame_flow/providers/photo_orientation.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
|
114adfb3fcd1ab050c1dd2f1fff9d91fdde192b8eced907dee3bc8086037e13d pigway_frame_flow/providers/photos_cache.py
|
||||||
119427bc435ec8d4c91114713f37d07f22067eaeec391261d1441a1436baf5e0 pigway_frame_flow/providers/poetry.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
|
7dfc5eab241122c4758d8b0c027369049231451dc8e0e1b49f7da56532a8e87d pigway_frame_flow/providers/weather.py
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/renderers/__init__.py
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/renderers/__init__.py
|
||||||
ce1e3d9dd7d8dc8d76d6206ab9cd05974bd2e81e1bbf28711f4b461332bf9236 pigway_frame_flow/renderers/frame_geometry.py
|
ce1e3d9dd7d8dc8d76d6206ab9cd05974bd2e81e1bbf28711f4b461332bf9236 pigway_frame_flow/renderers/frame_geometry.py
|
||||||
@@ -130,17 +131,18 @@ f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_f
|
|||||||
8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json
|
8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json
|
||||||
82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json
|
82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json
|
||||||
d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.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
|
f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_flow/web/apple-touch-icon.png
|
||||||
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
|
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
|
||||||
94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
|
94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
|
||||||
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg
|
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg
|
||||||
1f77efa9acfb40872593e3b7c475d972b960cb9f21e9afb936add03c3f2dd542 pigway_frame_flow/web/features.js
|
1f77efa9acfb40872593e3b7c475d972b960cb9f21e9afb936add03c3f2dd542 pigway_frame_flow/web/features.js
|
||||||
a07ea444ab4ac75ea11e1188a431b48195353b1e08f7e80f3b931e8097ea6221 pigway_frame_flow/web/i18n.js
|
a07ea444ab4ac75ea11e1188a431b48195353b1e08f7e80f3b931e8097ea6221 pigway_frame_flow/web/i18n.js
|
||||||
25f975a560aab00b57c5527a674d52bc45bd6c4b42e6169f1db406f2040f2177 pigway_frame_flow/web/index.html
|
6385ec8b0383fa9bdbb23c4c685c8ef1afd7926fd74c8032811e647808548255 pigway_frame_flow/web/index.html
|
||||||
7787ff064be5beddd18cd5c7271aa900b33f991ae95dd9ff8ffe95154f7b2a3a pigway_frame_flow/web/libraries.js
|
a68b22ea3baf2a1b9d44f58d59e4bcc27017af49b852b5f76815634539c9c698 pigway_frame_flow/web/libraries.js
|
||||||
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg
|
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
|
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
|
||||||
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
|
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
|
||||||
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
|
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
|
||||||
@@ -190,7 +192,7 @@ d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-pro
|
|||||||
e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py
|
e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py
|
||||||
b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py
|
b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py
|
||||||
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
|
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
|
||||||
77cdbcc0ec48125964d710ea437210d325d9aef30e02053742c506ef113ac519 tests/contract.json
|
771c825ff00147638627451ce90d4b7f8aab0c6844f8187afa7ab618f8dc235e tests/contract.json
|
||||||
9b3632a93c68c057b2fb276aa4a1b2b514f74934f0183480930d951d91704cae tests/schema-contract.json
|
9b3632a93c68c057b2fb276aa4a1b2b514f74934f0183480930d951d91704cae tests/schema-contract.json
|
||||||
23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py
|
23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py
|
||||||
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
|
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
|
||||||
@@ -236,6 +238,7 @@ e0a426eed6f1b6f59207b4de7d8286b6852550bfa28ad30e20f7e911ad9865ee tests/test_ret
|
|||||||
ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py
|
ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py
|
||||||
0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py
|
0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py
|
||||||
a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py
|
a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py
|
||||||
|
a821fbe5096b2fa5b781423e66673b8d5659b60fc37c5c10470afa2a818659c8 tests/test_source_browser.py
|
||||||
686b925646d52dcf6d9574b9b451af499a402ee2793a99f1154d4984f8458183 tests/test_source_libraries.py
|
686b925646d52dcf6d9574b9b451af499a402ee2793a99f1154d4984f8458183 tests/test_source_libraries.py
|
||||||
2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py
|
2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py
|
||||||
ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py
|
ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py
|
||||||
|
|||||||
@@ -5,12 +5,20 @@
|
|||||||
## 使用顺序
|
## 使用顺序
|
||||||
|
|
||||||
1. 在“照片数据源”配置来源、人物、分辨率、质量、去重等选图条件。
|
1. 在“照片数据源”配置来源、人物、分辨率、质量、去重等选图条件。
|
||||||
2. 在“图库 → 来源图库”选择来源,选入照片,保存无文字叠加的源画面。
|
2. 在侧栏“来源图库”选择来源,选入照片,保存无文字叠加的源画面。
|
||||||
3. 新增该来源内的生成方案,可使用相框横版、竖版、手表预设,或自定义尺寸、适配方式、裁切位置、质量、色彩与说明叠加。也可将旧版小图方案参数复制到所选来源下。
|
3. 新增该来源内的生成方案,可使用相框横版、竖版、手表预设,或自定义尺寸、适配方式、裁切位置、质量、色彩与说明叠加。也可将旧版小图方案参数复制到所选来源下。
|
||||||
4. 来源图库标签为“源画面”和该来源的生成方案;未生成的版本明确显示未生成,生成后可预览、下载、查看参数快照、删除。归档方案仍可查看已有版本。
|
4. 来源图库标签为“源画面”和该来源的生成方案;未生成的版本明确显示未生成,生成后可预览、下载、查看参数快照、删除。归档方案仍可查看已有版本。
|
||||||
5. 在“终端图库”对每个终端单选来源和方案。切换来源后必须重新选择方案,服务端检查归属和屏幕尺寸。绑定同一方案的终端共用生成文件。
|
5. 在“终端图库”对每个终端单选来源和方案。切换来源后必须重新选择方案,服务端检查归属和屏幕尺寸。绑定同一方案的终端共用生成文件。
|
||||||
6. 需要自动化时,为照片任务勾选“按来源图库统一生成并推送”。新建照片任务默认使用该模式;旧任务不在启动时自动改写,确认绑定后可在编辑任务时切换。唯一明确的单来源、单照片任务也可使用 `scripts/migrate-source-libraries.py` 预检,再加 `--apply` 迁移;脚本保留启用状态、调度配置和下次执行时间,不推送设备。若当前设备有可靠的照片来源回执,会选入该画面并仅在本地生成两个版本。生成模式不再额外选择旧版小图方案。
|
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 朝向。
|
任务先选定一次源画面并保存到本地,再按来源的终端绑定生成版本。默认等比裁切铺满,不拉伸、不补黑边。主动选择完整画面时必要处补白;说明叠加使用来源提供的日期、标题、地点等元数据。源画面本身始终不加文字。Synology Photos 当前取得的是可用源预览,不称为原图;本地存储按画面原尺寸归一化为 JPEG 并处理 EXIF 朝向。
|
||||||
@@ -32,6 +40,7 @@
|
|||||||
## API 与存储
|
## API 与存储
|
||||||
|
|
||||||
- `/api/v1/source-libraries`:来源图库;`/{id}/images`、`/import`、`/run` 用于列表、选入与统一生成。
|
- `/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/generation-plans`:按 `source_id` 查询与创建方案;`/{id}` 修改或归档。
|
||||||
- `/api/v1/source-images/{id}`:源画面删除;`/generate`、`/preview`、`/star`、`/exclusion` 用于版本生成、预览和来源偏好。
|
- `/api/v1/source-images/{id}`:源画面删除;`/generate`、`/preview`、`/star`、`/exclusion` 用于版本生成、预览和来源偏好。
|
||||||
- `/api/v1/library-terminals`:终端;`/{id}/binding` 管理单一绑定,`/{id}/images` 查询成品,`/{id}/images/{asset_id}/send` 显式推送。
|
- `/api/v1/library-terminals`:终端;`/{id}/binding` 管理单一绑定,`/{id}/images` 查询成品,`/{id}/images/{asset_id}/send` 显式推送。
|
||||||
|
|||||||
@@ -1404,6 +1404,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
"app.js",
|
"app.js",
|
||||||
"thumbnails.js",
|
"thumbnails.js",
|
||||||
"libraries.js",
|
"libraries.js",
|
||||||
|
"source-browser.js",
|
||||||
"features.js",
|
"features.js",
|
||||||
"extensions.js",
|
"extensions.js",
|
||||||
"style.css",
|
"style.css",
|
||||||
|
|||||||
@@ -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 ErrorCode as E
|
||||||
from pigway_frame_flow.errors import FlowError
|
from pigway_frame_flow.errors import FlowError
|
||||||
from pigway_frame_flow.models import ImageExclusion, ImageStar, StrictModel
|
from pigway_frame_flow.models import ImageExclusion, ImageStar, StrictModel
|
||||||
|
from pigway_frame_flow.providers.source_browser import BrowseFilters
|
||||||
|
|
||||||
|
|
||||||
class BindingInput(StrictModel):
|
class BindingInput(StrictModel):
|
||||||
@@ -79,6 +80,30 @@ def install_libraries(app, service):
|
|||||||
)["n"],
|
)["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(
|
@app.post(
|
||||||
"/api/v1/source-libraries/{source_id}/import",
|
"/api/v1/source-libraries/{source_id}/import",
|
||||||
dependencies=[Depends(require("admin"))],
|
dependencies=[Depends(require("admin"))],
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ class PhotoSources:
|
|||||||
self.db, self.settings, self.assets = db, settings, assets
|
self.db, self.settings, self.assets = db, settings, assets
|
||||||
self.cache = {}
|
self.cache = {}
|
||||||
self.api_items = {}
|
self.api_items = {}
|
||||||
|
from pigway_frame_flow.providers.source_browser import SourceBrowser
|
||||||
|
|
||||||
|
self.browser = SourceBrowser(self)
|
||||||
self.synology = SynologyPhotos(settings)
|
self.synology = SynologyPhotos(settings)
|
||||||
# Register provider secrets before request IDs or errors can be reflected.
|
# Register provider secrets before request IDs or errors can be reflected.
|
||||||
for row in self.list():
|
for row in self.list():
|
||||||
@@ -584,7 +587,16 @@ class PhotoSources:
|
|||||||
|
|
||||||
def read(self, source_id, item_id=None, *, target_size=(1200, 1600)):
|
def read(self, source_id, item_id=None, *, target_size=(1200, 1600)):
|
||||||
source = self.get(source_id)
|
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:
|
if not item_id:
|
||||||
items = eligible(self.db, source_id, items)
|
items = eligible(self.db, source_id, items)
|
||||||
if not items:
|
if not items:
|
||||||
@@ -641,13 +653,18 @@ class PhotoSources:
|
|||||||
raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None
|
raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None
|
||||||
elif source.kind == "synology-photos":
|
elif source.kind == "synology-photos":
|
||||||
data = self.synology.download(
|
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:
|
if source.folder_caption:
|
||||||
caption = dict(item.get("caption") or {})
|
caption = dict(item.get("caption") or {})
|
||||||
try:
|
try:
|
||||||
caption["people"] = " & ".join(
|
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:
|
except FlowError:
|
||||||
# Optional labels must not prevent displaying a valid photo.
|
# Optional labels must not prevent displaying a valid photo.
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -337,12 +337,14 @@ class SynologyPhotos:
|
|||||||
self.cache[key] = (time.monotonic(), names)
|
self.cache[key] = (time.monotonic(), names)
|
||||||
return list(self.cache[key][1].get(str(item_id), []))
|
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
|
from pigway_frame_flow.providers.photos_cache import PhotosCache
|
||||||
|
|
||||||
sid = self.sid()
|
sid = self.sid()
|
||||||
url, pin = self.endpoint()
|
url, pin = self.endpoint()
|
||||||
prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto"
|
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":
|
if rendition == "thumbnail":
|
||||||
thumb = row.get("additional", {}).get("thumbnail", {})
|
thumb = row.get("additional", {}).get("thumbnail", {})
|
||||||
if not thumb.get("cache_key") or not thumb.get("unit_id"):
|
if not thumb.get("cache_key") or not thumb.get("unit_id"):
|
||||||
@@ -353,7 +355,7 @@ class SynologyPhotos:
|
|||||||
params = dict(
|
params = dict(
|
||||||
id=thumb["unit_id"],
|
id=thumb["unit_id"],
|
||||||
cache_key=thumb["cache_key"],
|
cache_key=thumb["cache_key"],
|
||||||
size="xl",
|
size=size,
|
||||||
type="unit",
|
type="unit",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -372,6 +374,7 @@ class SynologyPhotos:
|
|||||||
space,
|
space,
|
||||||
row["id"],
|
row["id"],
|
||||||
rendition,
|
rendition,
|
||||||
|
size,
|
||||||
row.get("indexed_time"),
|
row.get("indexed_time"),
|
||||||
row.get("filesize"),
|
row.get("filesize"),
|
||||||
row.get("additional", {}).get("thumbnail", {}).get("cache_key"),
|
row.get("additional", {}).get("thumbnail", {}).get("cache_key"),
|
||||||
|
|||||||
@@ -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 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 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])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).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=>`<option value="${esc(d.id)}" ${!watchSelected&&d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('')+`<option value="@watch" ${watchSelected?'selected':''}>${tr('手表 · 410×502','Watch · 410×502')}</option>`;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])=>`<button data-page="${id}" class="${(id===state.page||id==='gallery'&&state.page==='source-browser')?'active':''}" ${(id===state.page||id==='gallery'&&state.page==='source-browser')?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).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=>`<option value="${esc(d.id)}" ${!watchSelected&&d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('')+`<option value="@watch" ${watchSelected?'selected':''}>${tr('手表 · 410×502','Watch · 410×502')}</option>`;revealCurrentNavigation();}
|
||||||
// A destination appears only when its connection and read/write adapter are available.
|
// A destination appears only when its connection and read/write adapter are available.
|
||||||
// Future cloud-drive destinations must implement their adapter before registration here.
|
// Future cloud-drive destinations must implement their adapter before registration here.
|
||||||
const galleryDestinationAdapters=new Set(['kokonna','local-assets','watch-thumbnails']);
|
const galleryDestinationAdapters=new Set(['kokonna','local-assets','watch-thumbnails']);
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="icon" type="image/svg+xml" href="/ui/favicon.svg"><link rel="icon" href="/ui/favicon.ico" sizes="16x16 32x32 48x48"><link rel="apple-touch-icon" sizes="180x180" href="/ui/apple-touch-icon.png"><link rel="stylesheet" href="/ui/style.css"></head>
|
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="icon" type="image/svg+xml" href="/ui/favicon.svg"><link rel="icon" href="/ui/favicon.ico" sizes="16x16 32x32 48x48"><link rel="apple-touch-icon" sizes="180x180" href="/ui/apple-touch-icon.png"><link rel="stylesheet" href="/ui/style.css"></head>
|
||||||
<body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.29</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.29"></script><script src="/ui/features.js?v=0.1.29"></script><script src="/ui/extensions.js?v=0.1.29"></script><script src="/ui/app.js?v=0.1.29"></script><script src="/ui/thumbnails.js?v=0.1.29"></script><script src="/ui/libraries.js?v=0.1.29"></script></body></html>
|
<body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.29</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.29"></script><script src="/ui/features.js?v=0.1.29"></script><script src="/ui/extensions.js?v=0.1.29"></script><script src="/ui/app.js?v=0.1.29"></script><script src="/ui/thumbnails.js?v=0.1.29"></script><script src="/ui/libraries.js?v=0.1.29"></script><script src="/ui/source-browser.js?v=0.1.29"></script></body></html>
|
||||||
|
|||||||
@@ -76,13 +76,7 @@ async function generationPlanEditor(sourceId,plan,pictures){
|
|||||||
$('#generation-form').onsubmit=busy(async e=>{e.preventDefault();const saved=await api('/api/v1/generation-plans'+(plan?'/'+plan.id:''),{method:plan?'PUT':'POST',body:read()});libraryUI.plan=saved.id;$('#modal').close();await navigate();});
|
$('#generation-form').onsubmit=busy(async e=>{e.preventDefault();const saved=await api('/api/v1/generation-plans'+(plan?'/'+plan.id:''),{method:plan?'PUT':'POST',body:read()});libraryUI.plan=saved.id;$('#modal').close();await navigate();});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function libraryImportDialog(sourceId){
|
function libraryImportDialog(sourceId){return openSourceBrowser(sourceId);}
|
||||||
showModal(`<h2>${tr('从照片来源选入','Import from photo source')}</h2><p class="hint">${tr('保存无叠加的源画面,不会修改来源照片。','Stores undecorated source pixels without modifying provider photos.')}</p><div class="field"><label for="library-import-item">${tr('选择照片','Choose picture')}</label><select id="library-import-item"></select></div><div class="actions"><button id="library-import-prev">${tr('上一页','Previous')}</button><button id="library-import-next">${tr('下一页','Next')}</button></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button id="library-import-confirm" class="primary">${tr('选入本地','Import locally')}</button></div>`);
|
|
||||||
let offset=0;
|
|
||||||
async function load(){const page=await api('/api/v1/photo-sources/'+encodeURIComponent(sourceId)+'/images?limit=50&offset='+offset);if(!$('#library-import-item'))return;$('#library-import-item').innerHTML=librarySelectOptions(page.items,page.items[0]?.id,tr('请选择','Choose'));$('#library-import-prev').disabled=offset===0;$('#library-import-next').disabled=offset+50>=page.total;}
|
|
||||||
bind('library-import-prev',async()=>{offset=Math.max(0,offset-50);await load();});bind('library-import-next',async()=>{offset+=50;await load();});
|
|
||||||
bind('library-import-confirm',async()=>{const item=$('#library-import-item').value;if(!item)return;await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/import',{method:'POST',body:{item_id:item}});$('#modal').close();libraryUI.offset=0;await navigate();});await load();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function terminalLibraryPage(epoch,sources,terminals){
|
async function terminalLibraryPage(epoch,sources,terminals){
|
||||||
if(!terminals.some(t=>t.id===libraryUI.terminal))libraryUI.terminal=state.tab==='watch'?'watch':state.device;
|
if(!terminals.some(t=>t.id===libraryUI.terminal))libraryUI.terminal=state.tab==='watch'?'watch':state.device;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use strict';
|
||||||
|
Object.assign(MESSAGES.zh,{'source-browser':'从来源选入照片'});
|
||||||
|
Object.assign(MESSAGES.en,{'source-browser':'Import from source'});
|
||||||
|
icons['source-browser']='▧';
|
||||||
|
const sourceBrowser={source:null,filters:{},offset:0,previous:[]};
|
||||||
|
function openSourceBrowser(sourceId){sourceBrowser.source=sourceId;sourceBrowser.offset=0;sourceBrowser.previous=[];return navigate('source-browser');}
|
||||||
|
views['source-browser']=async function(epoch){
|
||||||
|
const sources=await api('/api/v1/source-libraries');if(epoch!==state.epoch)return;
|
||||||
|
if(!sources.length)return navigate('gallery');
|
||||||
|
if(!sources.some(s=>s.id===sourceBrowser.source)){sourceBrowser.source=sources[0].id;sourceBrowser.filters={};sourceBrowser.offset=0;sourceBrowser.previous=[];}
|
||||||
|
const source=sources.find(s=>s.id===sourceBrowser.source),nas=source.kind==='synology-photos',f=sourceBrowser.filters;
|
||||||
|
const input=(name,label,type='text',extra='')=>`<div class="field"><label for="browse-${name}">${label}</label><input id="browse-${name}" name="${name}" type="${type}" value="${esc(f[name]||'')}" ${extra}></div>`;
|
||||||
|
$('#main').innerHTML=`<section class="card"><div class="library-source-toolbar"><div class="field"><label for="browse-source">${tr('照片来源','Photo source')}</label><select id="browse-source">${librarySelectOptions(sources,source.id,'')}</select></div><button id="browse-back">${tr('返回来源图库','Back to source library')}</button></div><p class="hint">${tr('按批读取照片信息,预览仅在进入可视区域时加载;选入后保存在本地,不会推送设备。','Reads metadata in bounded batches and loads previews on demand. Import stores locally without sending to devices.')}</p><form id="source-browser-filters"><fieldset ${nas?'':'disabled'}><div class="source-browser-filters">${input('filename',tr('文件名关键字','Filename keyword'),'search','maxlength="200"')}${input('folder',tr('文件夹关键字','Folder keyword'),'search','maxlength="500"')}${input('min_mb',tr('最小文件大小(MB)','Min file size (MB)'),'number','min="0" step="0.1" max="1000000"')}${input('max_mb',tr('最大文件大小(MB)','Max file size (MB)'),'number','min="0" step="0.1" max="1000000"')}${input('min_width',tr('最小宽度(px)','Min width (px)'),'number','min="0" max="100000"')}${input('max_width',tr('最大宽度(px)','Max width (px)'),'number','min="0" max="100000"')}${input('min_height',tr('最小高度(px)','Min height (px)'),'number','min="0" max="100000"')}${input('max_height',tr('最大高度(px)','Max height (px)'),'number','min="0" max="100000"')}<div class="field"><label for="browse-date_field">${tr('日期类型','Date type')}</label><select name="date_field" id="browse-date_field"><option value="indexed_time" ${f.date_field!=='time'?'selected':''}>${tr('NAS 创建/入库日期','NAS indexed date')}</option><option value="time" ${f.date_field==='time'?'selected':''}>${tr('照片拍摄日期','Photo date')}</option></select></div>${input('date_from',tr('开始日期','From date'),'date')}${input('date_to',tr('结束日期(含当天)','Through date (inclusive)'),'date')}<div class="field"><label for="browse-person_id">${tr('人物识别','Recognized person')}</label><select id="browse-person_id" name="person_id"><option value="">${tr('不限人物','Any person')}</option>${f.person_id?`<option value="${esc(f.person_id)}" selected>${esc(f.person_name||f.person_id)}</option>`:''}</select><small id="browse-people-status" class="hint"></small></div></div><div class="actions"><button type="submit" class="primary">${tr('搜索 / 筛选','Search / Filter')}</button><button type="reset">${tr('重置','Reset')}</button></div></fieldset></form><p class="hint">${nas?tr('筛选在当前来源设置范围内进行;日期使用系统时区,入库日期不等同于原文件的创建时间。筛选较少见照片时,可继续下一页检索,页面不会等待全库扫描。','Filters stay within this source. Dates use the system timezone; indexed dates are not original file creation dates. Continue through pages for rare matches without waiting for a full scan.'):tr('此来源使用现有目录接口;高级筛选和快速分页目前支持 Synology Photos。','This provider uses its existing catalog. Advanced filters and fast paging currently support Synology Photos.')}</p></section><p id="browse-status" role="status">${tr('正在读取本批照片…','Reading this batch…')}</p><div id="browse-gallery" class="gallery source-browser-gallery" aria-busy="true"></div><div class="actions library-pagination"><button id="browse-prev" ${sourceBrowser.previous.length?'':'disabled'}>${tr('上一页','Previous')}</button><button id="browse-next" disabled>${tr('下一页','Next')}</button></div>`;
|
||||||
|
bind('browse-back',()=>{libraryUI.source=source.id;libraryUI.offset=0;return navigate('gallery');});
|
||||||
|
$('#browse-source').onchange=()=>{sourceBrowser.filters={};return openSourceBrowser($('#browse-source').value);};
|
||||||
|
$('#source-browser-filters').onsubmit=busy(async e=>{e.preventDefault();const values=Object.fromEntries(new FormData(e.currentTarget));const people=$('#browse-person_id');sourceBrowser.filters={...values,person_name:people.selectedOptions[0]?.textContent||''};sourceBrowser.offset=0;sourceBrowser.previous=[];await navigate();});
|
||||||
|
$('#source-browser-filters').onreset=e=>{e.preventDefault();sourceBrowser.filters={};sourceBrowser.offset=0;sourceBrowser.previous=[];navigate();};
|
||||||
|
const params=new URLSearchParams({offset:sourceBrowser.offset,limit:36});for(const [key,value] of Object.entries(f)){if(value!==''&&key!=='person_name'&&!key.endsWith('_mb'))params.set(key,value);}for(const [key,target] of [['min_mb','min_bytes'],['max_mb','max_bytes']])if(f[key]!==undefined&&f[key]!=='')params.set(target,Math.round(Number(f[key])*1024*1024));
|
||||||
|
if(nas)loadBrowserPeople(source,epoch);
|
||||||
|
let page;try{page=await api('/api/v1/source-libraries/'+encodeURIComponent(source.id)+'/browse?'+params);}catch(error){if(epoch!==state.epoch)return;$('#browse-status').textContent=error.message;$('#browse-gallery').setAttribute('aria-busy','false');return;}
|
||||||
|
if(epoch!==state.epoch)return;
|
||||||
|
$('#browse-status').textContent=tr(`本批找到 ${page.items.length} 张照片${page.paged_provider?`,检查 ${page.scanned} 条记录`:''}。${!page.items.length&&page.next_offset!==null?'可继续下一页检索。':''}`,`${page.items.length} matches in this batch${page.paged_provider?`, ${page.scanned} records checked`:''}.${!page.items.length&&page.next_offset!==null?' Continue to the next batch.':''}`);
|
||||||
|
$('#browse-gallery').innerHTML=page.items.map((item,i)=>`<article class="card image-card"><button class="image-preview" data-browse-preview="${i}" ${item.preview_url?'':'disabled'}>${item.preview_url?`<img class="thumb" alt="${esc(item.name)}" data-src="${esc(item.preview_url)}">`:`<span class="empty">${tr('选入后预览','Preview after import')}</span>`}</button><h3 title="${esc(item.name)}">${esc(item.name)}</h3><small class="browse-folder">${esc(item.folder||item.source_path||'')}</small><small>${item.width&&item.height?`${item.width} × ${item.height} px · `:''}${item.filesize?`${(item.filesize/1024/1024).toFixed(2)} MB`:''}</small><small>${item.indexed_time?tr('入库:','Indexed: ')+esc(fmt(new Date(item.indexed_time*1000).toISOString())):''}</small><small>${item.time?tr('拍摄:','Photo: ')+esc(fmt(new Date(item.time*1000).toISOString())):''}</small><small>${esc((item.people||[]).map(p=>p.name).filter(Boolean).join('、'))}</small><div class="actions"><button data-browse-preview="${i}" ${item.preview_url?'':'disabled'}>${tr('预览','Preview')}</button><button class="primary" data-browse-import="${i}">${tr('选入本地','Import locally')}</button></div></article>`).join('');$('#browse-gallery').setAttribute('aria-busy','false');
|
||||||
|
$('#browse-next').disabled=page.next_offset===null;
|
||||||
|
bind('browse-prev',()=>{sourceBrowser.offset=sourceBrowser.previous.pop()||0;return navigate();});
|
||||||
|
bind('browse-next',()=>{if(page.next_offset===null)return;sourceBrowser.previous.push(sourceBrowser.offset);sourceBrowser.offset=page.next_offset;return navigate();});
|
||||||
|
document.querySelectorAll('[data-browse-preview]').forEach(b=>b.onclick=busy(()=>{const item=page.items[b.dataset.browsePreview];return openImageViewer(item.preview_url,item.name);}));
|
||||||
|
document.querySelectorAll('[data-browse-import]').forEach(b=>b.onclick=busy(async()=>{const item=page.items[b.dataset.browseImport];await api('/api/v1/source-libraries/'+encodeURIComponent(source.id)+'/import',{method:'POST',body:{item_id:item.id}});if(epoch!==state.epoch)return;b.textContent=tr('已选入','Imported');b.disabled=true;libraryUI.offset=0;toast(tr('已保存到来源图库','Saved in source library'));}));
|
||||||
|
hydrateImages();
|
||||||
|
};
|
||||||
|
async function loadBrowserPeople(source,epoch){
|
||||||
|
try{const people=await api('/api/v1/integrations/synology-photos/people?space='+encodeURIComponent(source.people_space||'shared'));if(epoch!==state.epoch)return;$('#browse-person_id').innerHTML='<option value="">'+tr('不限人物','Any person')+'</option>'+people.map(p=>`<option value="${p.id}" ${String(p.id)===String(sourceBrowser.filters.person_id)?'selected':''}>${esc(p.named?p.name:tr('未命名人物 ','Unnamed person ')+p.id)} (${p.count})</option>`).join('');}catch(error){if(epoch===state.epoch)$('#browse-people-status').textContent=tr('人物列表暂不可用,可继续浏览照片','People unavailable; photo browsing remains available');}
|
||||||
|
}
|
||||||
@@ -274,3 +274,12 @@ form#history-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));
|
|||||||
.library-plan-toolbar>.tabs{margin-bottom:0}
|
.library-plan-toolbar>.tabs{margin-bottom:0}
|
||||||
.source-library-gallery{grid-template-columns:repeat(auto-fill,min(100%,500px));margin-top:20px}
|
.source-library-gallery{grid-template-columns:repeat(auto-fill,min(100%,500px));margin-top:20px}
|
||||||
.library-pagination{margin-top:20px;margin-bottom:20px}
|
.library-pagination{margin-top:20px;margin-bottom:20px}
|
||||||
|
|
||||||
|
#source-browser-filters{margin-top:20px}
|
||||||
|
#source-browser-filters fieldset{border:0;padding:0;margin:0;min-width:0}
|
||||||
|
.source-browser-filters{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:18px}
|
||||||
|
.source-browser-filters>.field{margin:0;min-width:0}
|
||||||
|
.source-browser-gallery{grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));margin-top:20px}
|
||||||
|
.browse-folder{overflow-wrap:anywhere}
|
||||||
|
@media(max-width:1050px){.source-browser-filters{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||||
|
@media(max-width:480px){.source-browser-filters{grid-template-columns:minmax(0,1fr)}}
|
||||||
|
|||||||
@@ -1962,5 +1962,37 @@
|
|||||||
"502",
|
"502",
|
||||||
"503"
|
"503"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"/api/v1/source-libraries/{source_id}/browse": {
|
||||||
|
"get": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"/api/v1/source-libraries/{source_id}/browse/{item_id}/preview": {
|
||||||
|
"get": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import copy
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def browser(client, service, monkeypatch):
|
||||||
|
photos = service.photo_sources
|
||||||
|
photos.save(
|
||||||
|
"family",
|
||||||
|
PhotoSourceInput(
|
||||||
|
name="Family",
|
||||||
|
kind="synology-photos",
|
||||||
|
location="albums",
|
||||||
|
people_filter=True,
|
||||||
|
person_ids=[7],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(photos.synology, "sid", lambda: "test-session")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
photos, "catalog", lambda *a, **kw: pytest.fail("Must not scan full catalog")
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"id": i,
|
||||||
|
"type": "photo",
|
||||||
|
"filename": f"Photo-{i}.jpg",
|
||||||
|
"filesize": 2000000,
|
||||||
|
"time": int(datetime(2026, 10, 20, 2, tzinfo=UTC).timestamp()),
|
||||||
|
"indexed_time": int(datetime(2026, 10, 21, 2, tzinfo=UTC).timestamp()),
|
||||||
|
"additional": {
|
||||||
|
"folder": "albums/travel",
|
||||||
|
"resolution": {"width": 1600, "height": 1200},
|
||||||
|
"person": [{"id": 7, "name": "Person"}],
|
||||||
|
"thumbnail": {"cache_key": "x", "unit_id": i},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i in range(1, 201)
|
||||||
|
]
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def request(api, method, version, **params):
|
||||||
|
calls.append(params)
|
||||||
|
assert params["limit"] <= 60
|
||||||
|
selected = rows
|
||||||
|
if params.get("person_id"):
|
||||||
|
selected = [
|
||||||
|
r
|
||||||
|
for r in rows
|
||||||
|
if any(
|
||||||
|
p["id"] == params["person_id"] for p in r["additional"]["person"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"list": copy.deepcopy(
|
||||||
|
selected[params["offset"] : params["offset"] + params["limit"]]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(photos.synology, "request", request)
|
||||||
|
return photos, rows, calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounded_paging_preview_and_import(client, service, browser, monkeypatch):
|
||||||
|
photos, _, calls = browser
|
||||||
|
first = client.get("/api/v1/source-libraries/family/browse?limit=12").json()
|
||||||
|
assert len(first["items"]) == 12 and first["next_offset"] == 12 and len(calls) == 1
|
||||||
|
second = client.get(
|
||||||
|
"/api/v1/source-libraries/family/browse?limit=12&offset=12"
|
||||||
|
).json()
|
||||||
|
assert not {x["id"] for x in first["items"]} & {x["id"] for x in second["items"]}
|
||||||
|
image = BytesIO()
|
||||||
|
Image.new("RGB", (1600, 1200)).save(image, format="JPEG")
|
||||||
|
downloads = []
|
||||||
|
|
||||||
|
def download(row, **kwargs):
|
||||||
|
downloads.append(kwargs)
|
||||||
|
return image.getvalue()
|
||||||
|
|
||||||
|
monkeypatch.setattr(photos.synology, "download", download)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
photos.synology,
|
||||||
|
"item_people",
|
||||||
|
lambda *a, **kw: pytest.fail("No full person album lookup"),
|
||||||
|
)
|
||||||
|
assert client.get(first["items"][0]["preview_url"]).status_code == 200
|
||||||
|
assert downloads[-1]["size"] == "m"
|
||||||
|
# Optional captions must use metadata already returned on this page.
|
||||||
|
source = photos.get("family").model_copy(update={"folder_caption": True})
|
||||||
|
photos.save("family", source)
|
||||||
|
client.get("/api/v1/source-libraries/family/browse?limit=12")
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/source-libraries/family/import",
|
||||||
|
json={"item_id": first["items"][0]["id"]},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert "size" not in downloads[-1] # Default XL for saved source pixels.
|
||||||
|
assert r.json()["caption"]["people"] == "Person"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sparse_search_is_bounded_and_can_continue(client, browser):
|
||||||
|
_, _, calls = browser
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/source-libraries/family/browse?filename=not-found&limit=10"
|
||||||
|
).json()
|
||||||
|
assert r["items"] == [] and r["scanned"] == 40 and r["next_offset"] == 40
|
||||||
|
assert len(calls) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_filters_and_source_scope(client, browser):
|
||||||
|
photos, rows, _ = browser
|
||||||
|
rows[0]["additional"]["folder"] = "albums-other/secret"
|
||||||
|
rows[1]["additional"]["person"] = [{"id": 8, "name": "Other"}]
|
||||||
|
rows[2]["filesize"] = 100
|
||||||
|
rows[3]["additional"]["resolution"]["width"] = 200
|
||||||
|
rows[4]["indexed_time"] = 1
|
||||||
|
params = {
|
||||||
|
"limit": 8,
|
||||||
|
"filename": "PHOTO-",
|
||||||
|
"folder": "TRAVEL",
|
||||||
|
"min_bytes": 1000000,
|
||||||
|
"max_bytes": 3000000,
|
||||||
|
"min_width": 1000,
|
||||||
|
"max_width": 2000,
|
||||||
|
"min_height": 1000,
|
||||||
|
"max_height": 2000,
|
||||||
|
"date_from": "2026-10-21",
|
||||||
|
"date_to": "2026-10-21",
|
||||||
|
"person_id": 7,
|
||||||
|
}
|
||||||
|
r = client.get("/api/v1/source-libraries/family/browse", params=params)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert {i["id"] for i in r.json()["items"]}.isdisjoint({"1", "2", "3", "4", "5"})
|
||||||
|
assert r.json()["items"]
|
||||||
|
assert photos.browser.cached("family", "1") is None
|
||||||
|
assert (
|
||||||
|
client.get("/api/v1/source-libraries/family/browse/1/preview").status_code
|
||||||
|
== 404
|
||||||
|
)
|
||||||
|
# Metadata already cached must not survive a source scope change.
|
||||||
|
item_id = r.json()["items"][0]["id"]
|
||||||
|
photos.save("family", photos.get("family").model_copy(update={"location": "other"}))
|
||||||
|
assert photos.browser.cached("family", item_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
"limit=1000",
|
||||||
|
"offset=-1",
|
||||||
|
"min_width=-1",
|
||||||
|
"min_bytes=20&max_bytes=10",
|
||||||
|
"date_from=2026-10-22&date_to=2026-10-20",
|
||||||
|
"person_id=-1",
|
||||||
|
"date_field=unknown",
|
||||||
|
"unexpected=1",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_filters_rejected(client, browser, query):
|
||||||
|
assert (
|
||||||
|
client.get("/api/v1/source-libraries/family/browse?" + query).status_code == 422
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_changes_invalidate_preview(client, browser, monkeypatch):
|
||||||
|
photos, _, _ = browser
|
||||||
|
item = client.get("/api/v1/source-libraries/family/browse?limit=1").json()["items"][
|
||||||
|
0
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(photos.synology, "sid", lambda: "new-session")
|
||||||
|
assert client.get(item["preview_url"]).status_code == 404
|
||||||
Reference in New Issue
Block a user