feat: exclude source photos from automatic selection
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m53s

This commit is contained in:
渡口浪人
2026-10-20 18:50:43 +08:00
parent e6a3974d08
commit 383ed45185
17 changed files with 735 additions and 23 deletions
+49 -1
View File
@@ -17,6 +17,7 @@ from starlette.exceptions import HTTPException
from pigway_frame_flow.api.history import install_history
from pigway_frame_flow.core.accounts import install_accounts, seed_accounts
from pigway_frame_flow.core.auth import require, seed_client
from pigway_frame_flow.core.photo_preferences import annotate, set_flags
from pigway_frame_flow.core.registry import DeviceRegistry
from pigway_frame_flow.display.service import DisplayService
from pigway_frame_flow.errors import ErrorCode as E
@@ -43,12 +44,14 @@ from pigway_frame_flow.models import (
Health,
History,
ImageContent,
ImageExclusion,
ImageStar,
ImageURLConfig,
Job,
JobInput,
Operation,
Options,
PhotoPreference,
PhotoSourceContent,
Playlist,
PlaylistInput,
@@ -625,6 +628,10 @@ def create_app(settings=None, start_worker=True):
409,
)
path.unlink(missing_ok=True)
db.execute(
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?",
(asset_id,),
)
db.execute("DELETE FROM asset_metadata WHERE asset_id=?", (asset_id,))
db.execute("DELETE FROM asset_sources WHERE asset_id=?", (asset_id,))
db.execute("DELETE FROM assets WHERE id=?", (asset_id,))
@@ -649,6 +656,18 @@ def create_app(settings=None, start_worker=True):
)
return assets.info(asset_id)
@app.put(
"/api/v1/assets/{asset_id}/exclusion",
response_model=Asset,
tags=["Images"],
dependencies=[Depends(require("images:write"))],
)
def exclude_asset(asset_id: str, payload: ImageExclusion):
with service.lock:
assets.info(asset_id)
set_flags(db, "@assets", asset_id, excluded=payload.excluded)
return assets.info(asset_id)
@app.post(
"/api/v1/assets/batch",
tags=["Images"],
@@ -661,6 +680,10 @@ def create_app(settings=None, start_worker=True):
try:
if payload.action == "delete":
delete_asset(id)
elif payload.action in {"exclude", "include"}:
exclude_asset(
id, ImageExclusion(excluded=payload.action == "exclude")
)
else:
star_asset(id, ImageStar(starred=payload.action == "star"))
results.append({"asset_id": id, "status": "completed"})
@@ -782,13 +805,38 @@ def create_app(settings=None, start_worker=True):
source_id, refresh=refresh, target_size=size
)
return {
"items": items[offset : offset + limit],
"items": annotate(db, source_id, items[offset : offset + limit]),
"total": len(items),
"offset": offset,
"limit": limit,
"catalog_limit": service.photo_sources.get(source_id).catalog_limit,
}
@app.put(
"/api/v1/photo-sources/{source_id}/preferences",
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def source_preference(
source_id: str, payload: PhotoPreference, device_id: str | None = None
):
source = service.photo_sources.get(source_id)
size = (1200, 1600)
if source.orientation == "auto":
size = frame_canvas(
registry.get(device_id or settings.device_id).device().display
)
items = service.photo_sources.catalog(source_id, target_size=size)
if not any(item["id"] == payload.item_id for item in items):
raise FlowError(E.NOT_FOUND, "Photo is not in this source", 404)
return set_flags(
db,
source_id,
payload.item_id,
starred=payload.starred,
excluded=payload.excluded,
)
@app.delete(
"/api/v1/devices/{device_id}/images/{image_id}",
response_model=Accepted,
@@ -0,0 +1,56 @@
"""Persistent selection preferences scoped to a library and its stable item ID."""
def flags(db, source_id, item_id):
row = db.one(
"SELECT starred,excluded FROM photo_preferences WHERE source_id=? AND item_id=?",
(source_id, item_id),
)
return {key: bool(row and row[key]) for key in ("starred", "excluded")}
def set_flags(db, source_id, item_id, *, starred=None, excluded=None):
with db.transaction() as connection:
connection.execute(
"INSERT INTO photo_preferences(source_id,item_id,starred,excluded) VALUES(?,?,?,?) ON CONFLICT(source_id,item_id) DO UPDATE SET starred=coalesce(?,starred),excluded=coalesce(?,excluded)",
(
source_id,
item_id,
int(starred or False),
int(excluded or False),
None if starred is None else int(starred),
None if excluded is None else int(excluded),
),
)
return flags(db, source_id, item_id)
def annotate(db, source_id, items):
preferences = {
row["item_id"]: row
for row in db.all(
"SELECT item_id,starred,excluded FROM photo_preferences WHERE source_id=?",
(source_id,),
)
}
return [
{
**item,
**{
key: bool(preferences.get(item["id"], {}).get(key))
for key in ("starred", "excluded")
},
}
for item in items
]
def eligible(db, source_id, items):
excluded = {
row["item_id"]
for row in db.all(
"SELECT item_id FROM photo_preferences WHERE source_id=? AND excluded=1",
(source_id,),
)
}
return [item for item in items if item["id"] not in excluded]
+3
View File
@@ -5,6 +5,7 @@ import time
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from pigway_frame_flow.core.photo_preferences import eligible
from pigway_frame_flow.display.cloud_library import CloudLibrary
from pigway_frame_flow.display.recovery import RecoveryMonitor
from pigway_frame_flow.display.retention import Retention
@@ -314,6 +315,8 @@ class DisplayService:
import random
items = self.photo_sources.catalog(content.source_id, target_size=size)
if not content.item_id:
items = eligible(self.db, content.source_id, items)
candidates = (
[item for item in items if item["id"] == content.item_id]
if content.item_id
+7 -2
View File
@@ -5,6 +5,7 @@ from io import BytesIO
from PIL import Image, ImageOps
from pigway_frame_flow.core.photo_preferences import eligible
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.extensions.layout import LayoutInput, LayoutStudio
@@ -19,7 +20,11 @@ def prepare(service, content, device_id=None):
from pigway_frame_flow.extensions.quality import passes, photo_quality
source = service.photo_sources.get(content.source_id)
items = service.photo_sources.catalog(content.source_id)
items = eligible(
service.db,
content.source_id,
service.photo_sources.catalog(content.source_id),
)
candidates = rng.sample(items, min(len(items), content.count * 5))
for item in candidates:
try:
@@ -46,7 +51,7 @@ def prepare(service, content, device_id=None):
break
else:
rows = service.db.all(
"SELECT id AS asset_id FROM assets WHERE kind='images' ORDER BY created_at DESC LIMIT 10000"
"SELECT id AS asset_id FROM assets WHERE kind='images' AND NOT EXISTS (SELECT 1 FROM photo_preferences p WHERE p.source_id='@assets' AND p.item_id=assets.id AND p.excluded=1) ORDER BY created_at DESC LIMIT 10000"
)
ids = rng.sample(
[row["asset_id"] for row in rows], min(content.count, len(rows))
+12 -1
View File
@@ -377,6 +377,7 @@ class Readiness(BaseModel):
class Asset(BaseModel):
excluded: bool = False
source_path: str | None = None
starred: bool = False
origin: Literal["manual", "task", "external", "template", "unknown"] = "unknown"
@@ -502,10 +503,20 @@ class WidgetState(StrictModel):
writable: Literal[False] = False
class ImageExclusion(StrictModel):
excluded: bool
class PhotoPreference(StrictModel):
item_id: str = Field(min_length=1, max_length=4096)
starred: bool | None = None
excluded: bool | None = None
class ImageStar(StrictModel):
starred: bool
class AssetBatch(StrictModel):
asset_ids: list[str] = Field(min_length=1, max_length=100)
action: Literal["delete", "star", "unstar"]
action: Literal["delete", "star", "unstar", "exclude", "include"]
+7 -1
View File
@@ -15,6 +15,7 @@ from xml.etree import ElementTree
from pydantic import Field
from pigway_frame_flow.core.photo_preferences import eligible
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import StrictModel
@@ -60,6 +61,8 @@ class PhotoSourceView(PhotoSourceInput):
class PhotoItem(StrictModel):
starred: bool = False
excluded: bool = False
id: str
name: str
caption: dict[str, str] | None = None
@@ -220,6 +223,7 @@ class PhotoSources:
self.db.execute(
"DELETE FROM app_config WHERE key=?", ("photo_source:" + source_id,)
)
self.db.execute("DELETE FROM photo_preferences WHERE source_id=?", (source_id,))
self.cache.pop(source_id, None)
self.api_items.pop(source_id, None)
@@ -581,9 +585,11 @@ 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)
if not item_id:
items = eligible(self.db, source_id, items)
if not items:
raise FlowError(
E.NOT_FOUND, "Photo source contains no supported images", 404
E.NOT_FOUND, "Photo source contains no eligible images", 404
)
item = (
next((x for x in items if x["id"] == item_id), None)
+2
View File
@@ -8,6 +8,7 @@ from uuid import uuid4
from PIL import Image, ImageFilter, ImageOps, ImageStat
from pigway_frame_flow.core.photo_preferences import flags
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.storage.database import now_iso
@@ -142,6 +143,7 @@ class Assets:
if provenance
else None,
"starred": bool(meta and meta["starred"]),
"excluded": flags(self.db, "@assets", asset_id)["excluded"],
"origin": meta["origin"] if meta else "unknown",
"job_id": meta["job_id"] if meta else None,
"job_name": meta["job_name"] if meta else None,
+1
View File
@@ -24,6 +24,7 @@ class Database:
PRAGMA busy_timeout=5000;
PRAGMA synchronous=FULL;
CREATE TABLE IF NOT EXISTS managed_cloud_uploads (device_id TEXT, image_id INTEGER, asset_id TEXT, job_id TEXT, operation_id TEXT, uploaded_at TEXT, confirmed_at TEXT, cleanup_operation TEXT, PRIMARY KEY(device_id,image_id));
CREATE TABLE IF NOT EXISTS photo_preferences (source_id TEXT NOT NULL, item_id TEXT NOT NULL, starred INTEGER NOT NULL DEFAULT 0, excluded INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(source_id,item_id));
CREATE TABLE IF NOT EXISTS asset_metadata (asset_id TEXT PRIMARY KEY, starred INTEGER NOT NULL DEFAULT 0, origin TEXT NOT NULL DEFAULT 'unknown', job_id TEXT, job_name TEXT);
CREATE TABLE IF NOT EXISTS image_retention (device_id TEXT NOT NULL, image_id INTEGER NOT NULL, starred INTEGER NOT NULL DEFAULT 0, expires_at TEXT, upload_operation TEXT, cleanup_operation TEXT, PRIMARY KEY(device_id,image_id));
CREATE TABLE IF NOT EXISTS photo_dimensions (path TEXT PRIMARY KEY, mtime INTEGER NOT NULL, bytes INTEGER NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL);
+4 -3
View File
@@ -34,9 +34,9 @@ function displayDialog(content){
function enhanceGallery(){
const grid=$('.gallery');if(!grid)return;
const controls=document.createElement('div');controls.className='form-grid gallery-filters';controls.innerHTML=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('sortOrder')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
if(state.tab==='local'){controls.classList.add('gallery-filters-local');const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-invert">${t('invertSelection')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);for(const [id,key] of [['asset-origin','originFilter'],['asset-job','taskOrigin']]){const field=document.createElement('div');field.className='field';const label=document.createElement('label');label.htmlFor=id;label.textContent=t(key);field.append(label,bar.querySelector('#'+id));controls.append(field);}const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp');grid.before(help);bar.setAttribute('aria-describedby',help.id);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});const visibleSelection=()=>[...grid.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);$('#asset-all').onclick=()=>{const boxes=visibleSelection();const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};$('#asset-invert').onclick=()=>visibleSelection().forEach(x=>x.checked=!x.checked);for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
if(state.tab==='local'){controls.classList.add('gallery-filters-local');const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-invert">${t('invertSelection')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-exclude">${t('excludePhoto')}</button><button id="asset-batch-include">${t('includePhoto')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);for(const [id,key] of [['asset-origin','originFilter'],['asset-job','taskOrigin']]){const field=document.createElement('div');field.className='field';const label=document.createElement('label');label.htmlFor=id;label.textContent=t(key);field.append(label,bar.querySelector('#'+id));controls.append(field);}const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp')+' '+t('excludePhotoHelp');grid.before(help);bar.setAttribute('aria-describedby',help.id);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=controls.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});const visibleSelection=()=>[...grid.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);$('#asset-all').onclick=()=>{const boxes=visibleSelection();const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};$('#asset-invert').onclick=()=>visibleSelection().forEach(x=>x.checked=!x.checked);for(const action of ['star','unstar','exclude','include','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{state.galleryQuery=$('#gallery-search').value;const query=state.galleryQuery.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;state.galleryOrder=order;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('templateSourceUnavailable'));});}});
cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);const exclude=document.createElement('button');exclude.textContent=t(item.excluded?'includePhoto':'excludePhoto');exclude.setAttribute('aria-pressed',String(Boolean(item.excluded)));card.querySelector('.actions').append(exclude);exclude.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/exclusion',{method:'PUT',body:{excluded:!item.excluded}});await navigate();});if(item.excluded){const badge=document.createElement('small');badge.className='excluded-photo';badge.textContent=t('photoExcluded');card.querySelector('h3').after(badge);}star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('templateSourceUnavailable'));});}});
$('#gallery-search').value=state.galleryQuery||'';$('#gallery-sort').value=state.galleryOrder||'newest';$('#gallery-search').oninput();$('#gallery-sort').onchange();
}
async function weatherSettings(){
@@ -157,7 +157,8 @@ async function photoSourceForm(source=null){
}
async function browseSource(id,offset=0,refresh=false){
const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')}</p><div class="source-items">${result.items.map((x,i)=>`<button data-source-item="${i}">${esc(x.name)}${x.width?`<small>${x.width} × ${x.height}</small>`:''}${x.caption?`<small>${esc([x.caption.date,x.caption.people,x.caption.title,x.caption.place,x.caption.filename].filter(Boolean).join(' · '))}</small>`:''}</button>`).join('')||t('noSourcePhotos')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')} ${t('excludePhotoHelp')}</p><div class="source-items">${result.items.map((x,i)=>`<article class="source-photo"><button data-source-item="${i}">${esc(x.name)}${x.width?`<small>${x.width} × ${x.height}</small>`:''}${x.caption?`<small>${esc([x.caption.date,x.caption.people,x.caption.title,x.caption.place,x.caption.filename].filter(Boolean).join(' · '))}</small>`:''}</button><div class="actions"><button data-source-star="${i}" aria-pressed="${Boolean(x.starred)}">${t(x.starred?'unstar':'star')}</button><button data-source-exclude="${i}" aria-pressed="${Boolean(x.excluded)}">${t(x.excluded?'includePhoto':'excludePhoto')}</button></div>${x.excluded?`<small class="excluded-photo">${t('photoExcluded')}</small>`:''}</article>`).join('')||t('noSourcePhotos')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
for(const key of ['star','exclude'])box.querySelectorAll('[data-source-'+key+']').forEach(button=>button.onclick=busy(async()=>{const item=result.items[Number(button.dataset[key==='star'?'sourceStar':'sourceExclude'])],field=key==='star'?'starred':'excluded';await api('/api/v1/photo-sources/'+id+'/preferences?device_id='+encodeURIComponent(state.device),{method:'PUT',body:{item_id:item.id,[field]:!item[field]}});await browseSource(id,offset);}));
box.querySelectorAll('[data-source-item]').forEach(b=>b.onclick=busy(()=>previewSource(id,result.items[Number(b.dataset.sourceItem)].id)));bind('source-prev',()=>browseSource(id,Math.max(0,offset-50)));bind('source-next',()=>browseSource(id,offset+50));bind('source-refresh',()=>browseSource(id,0,true));
}
async function previewSource(id,item_id=null){
+3
View File
@@ -152,3 +152,6 @@ Object.assign(MESSAGES.en,{RESTORE_IMAGE_PROTECTED:'This image is still needed t
Object.assign(MESSAGES.zh,{searchHistory:'搜索图片名称、请求 ID、任务 ID 或来源',allStatuses:'全部状态',allActions:'全部操作',allDevices:'全部设备',startDate:'开始日期',endDate:'结束日期',filter:'筛选',resetFilters:'重置筛选',dateRangeInvalid:'结束日期不能早于开始日期',historyTotal:'共 {count} 条',pageSize:'每页条数',historySelected:'已选 {count} 条(仅当前页)',selectHistory:'选择记录',deleteHistoryConfirm:'确认删除这 {count} 条历史记录?记录将从历史列表移除,不会删除图片或取消任务。此操作无法撤销。'});
Object.assign(MESSAGES.en,{searchHistory:'Search image name, request ID, operation ID or client',allStatuses:'All statuses',allActions:'All actions',allDevices:'All devices',startDate:'Start date',endDate:'End date',filter:'Filter',resetFilters:'Reset filters',dateRangeInvalid:'End date must not precede start date',historyTotal:'{count} records',pageSize:'Rows per page',historySelected:'{count} selected (current page only)',selectHistory:'Select record',deleteHistoryConfirm:'Delete these {count} history records? This removes them from history without deleting images or cancelling tasks. This cannot be undone.'});
Object.assign(MESSAGES.zh,{excludePhoto:'不再选用',includePhoto:'恢复选用',photoExcluded:'已标记不再选用',excludePhotoHelp:'不再选用的图片将被后续自动选图和自动拼图跳过;图片不删除,星标独立保留,仍可手动使用或恢复选用。标记按图库独立保存。'});
Object.assign(MESSAGES.en,{excludePhoto:'Exclude from selection',includePhoto:'Allow selection',photoExcluded:'Excluded from selection',excludePhotoHelp:'Excluded photos are skipped by future automatic selection and collages. Photos and stars are retained; manual use and restoring eligibility remain available. Preferences are saved separately per library.'});
+5
View File
@@ -247,3 +247,8 @@ form#history-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));
.history-pagination select{width:auto}
@media(max-width:1050px){form#history-filters{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:720px){form#history-filters{grid-template-columns:minmax(0,1fr)}}
.source-photo{min-width:0;display:flex;flex-direction:column;gap:12px;padding:14px;border:1px solid var(--border);border-radius:12px}
.source-photo>[data-source-item]{width:100%;text-align:left;overflow-wrap:anywhere}
.source-photo .actions{margin-top:auto}
.excluded-photo{display:block;color:var(--muted);font-size:12px}