feat: add paged NAS photo browser with metadata filters and previews
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 7m29s

This commit is contained in:
渡口浪人
2026-10-23 19:59:02 +08:00
parent 8ce4aafb93
commit 6ebc5e7afb
14 changed files with 579 additions and 25 deletions
+1
View File
@@ -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",
+25
View File
@@ -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"))],
+20 -3
View File
@@ -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.
@@ -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)
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"),
+1 -1
View File
@@ -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])=>`<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.
// Future cloud-drive destinations must implement their adapter before registration here.
const galleryDestinationAdapters=new Set(['kokonna','local-assets','watch-thumbnails']);
+1 -1
View File
@@ -1,3 +1,3 @@
<!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>
<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>
+1 -7
View File
@@ -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();});
}
async function libraryImportDialog(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();
}
function libraryImportDialog(sourceId){return openSourceBrowser(sourceId);}
async function terminalLibraryPage(epoch,sources,terminals){
if(!terminals.some(t=>t.id===libraryUI.terminal))libraryUI.terminal=state.tab==='watch'?'watch':state.device;
+33
View File
@@ -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');}
}
+9
View File
@@ -274,3 +274,12 @@ form#history-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));
.library-plan-toolbar>.tabs{margin-bottom:0}
.source-library-gallery{grid-template-columns:repeat(auto-fill,min(100%,500px));margin-top: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)}}