Add read-only NAS and WebDAV photo sources with random scheduling

This commit is contained in:
Pigway
2026-10-05 00:52:39 +08:00
parent b530e32c65
commit f5ab1ade77
18 changed files with 5578 additions and 84 deletions
+58 -1
View File
@@ -42,6 +42,7 @@ from pigway_frame_flow.models import (
JobInput,
Operation,
Options,
PhotoSourceContent,
Playlist,
PlaylistInput,
Profile,
@@ -57,6 +58,11 @@ from pigway_frame_flow.models import (
WeatherConfig,
WeatherView,
)
from pigway_frame_flow.providers.photo_sources import (
PhotoCatalog,
PhotoSourceInput,
PhotoSourceView,
)
from pigway_frame_flow.providers.weather import ConfigurableWeatherProvider
from pigway_frame_flow.renderers.images import Assets, convert
from pigway_frame_flow.renderers.templates import TemplateRenderer
@@ -426,7 +432,7 @@ def create_app(settings=None, start_worker=True):
)
def upload(
device_id: str,
payload: ImageContent | URLContent,
payload: ImageContent | URLContent | PhotoSourceContent,
request: Request,
display_now: bool = False,
idempotency_key: str | None = Header(default=None),
@@ -537,6 +543,57 @@ def create_app(settings=None, start_worker=True):
def asset_file(asset_id: str):
return FileResponse(assets.path(asset_id), media_type="image/jpeg")
@app.get(
"/api/v1/photo-sources",
response_model=list[PhotoSourceView],
tags=["Photo sources"],
dependencies=[Depends(require("images:read"))],
)
def photo_sources():
return service.photo_sources.list()
@app.put(
"/api/v1/photo-sources/{source_id}",
response_model=PhotoSourceView,
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def save_photo_source(source_id: str, payload: PhotoSourceInput):
if not re.fullmatch(r"[a-z0-9-]{1,60}", source_id):
raise FlowError(E.INVALID_REQUEST, "Invalid source ID")
return service.photo_sources.save(source_id, payload)
@app.delete(
"/api/v1/photo-sources/{source_id}",
status_code=204,
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def delete_photo_source(source_id: str):
service.photo_sources.delete(source_id)
return Response(status_code=204)
@app.get(
"/api/v1/photo-sources/{source_id}/images",
response_model=PhotoCatalog,
tags=["Photo sources"],
dependencies=[Depends(require("images:read"))],
)
def source_images(
source_id: str,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
refresh: bool = False,
):
items = service.photo_sources.catalog(source_id, refresh=refresh)
return {
"items": items[offset : offset + limit],
"total": len(items),
"offset": offset,
"limit": limit,
"catalog_limit": 10000,
}
@app.delete(
"/api/v1/devices/{device_id}/images/{image_id}",
response_model=Accepted,
+8
View File
@@ -12,9 +12,11 @@ from pigway_frame_flow.models import (
DisplayRequest,
FutureContent,
ImageContent,
PhotoSourceContent,
TemplateContent,
URLContent,
)
from pigway_frame_flow.providers.photo_sources import PhotoSources
from pigway_frame_flow.renderers.images import convert
from pigway_frame_flow.storage.database import encode, now_iso
@@ -23,6 +25,7 @@ class DisplayService:
"""One persistent queue; injected driver registry. No vendor imports."""
def __init__(self, db, settings, registry: DriverRegistry, assets, renderer):
self.photo_sources = PhotoSources(db, settings, assets)
self.db, self.settings, self.registry, self.assets, self.renderer = (
db,
settings,
@@ -49,6 +52,8 @@ class DisplayService:
)
if isinstance(request.content, TemplateContent):
self.renderer.info(request.content.template)
if isinstance(request.content, PhotoSourceContent):
self.photo_sources.get(request.content.source_id)
if writing and not self.settings.mock and not self.settings.write_enabled:
raise FlowError(E.WRITE_DISABLED, "Real device writes are disabled", 403)
@@ -167,6 +172,9 @@ class DisplayService:
(asset["asset_id"], encode(source)),
)
return asset
if isinstance(content, PhotoSourceContent):
data, name = self.photo_sources.read(content.source_id, content.item_id)
return self.assets.put(convert(data, content.fit, content.profile), name)
if isinstance(content, URLContent):
data = self.assets.fetch_url(content.url)
elif isinstance(content, ImageContent) and content.asset_id:
+9 -1
View File
@@ -60,13 +60,21 @@ class TemplateContent(StrictModel):
profile: Profile = Profile.color
class PhotoSourceContent(StrictModel):
type: Literal["photo_source"]
source_id: str = Field(pattern=r"^[a-z0-9-]{1,60}$")
item_id: str | None = Field(default=None, max_length=2048)
fit: Fit = Fit.contain
profile: Profile = Profile.color
class FutureContent(StrictModel):
type: Literal["html", "markdown"]
data: str = ""
Content = Annotated[
ImageContent | URLContent | TemplateContent | FutureContent,
ImageContent | URLContent | TemplateContent | PhotoSourceContent | FutureContent,
Field(discriminator="type"),
]
@@ -0,0 +1,335 @@
"""Read-only image catalogs; provider credentials never enter SQLite."""
import base64
import json
import os
import posixpath
import random
import stat
import time
from pathlib import Path
from typing import Literal
from urllib.parse import unquote, urljoin, urlsplit
from xml.etree import ElementTree
from pydantic import Field
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.settings import secret
from pigway_frame_flow.storage.database import encode
class PhotoSourceInput(StrictModel):
name: str = Field(min_length=1, max_length=120)
kind: Literal["local", "webdav"]
location: str = Field(min_length=1, max_length=2048)
recursive: bool = True
enabled: bool = True
credential_env: str | None = Field(
default=None, pattern=r"^FRAME_FLOW_SOURCE_[A-Z0-9_]+$"
)
class PhotoSourceView(PhotoSourceInput):
id: str
class PhotoItem(StrictModel):
id: str
name: str
class PhotoCatalog(StrictModel):
items: list[PhotoItem]
total: int
offset: int
limit: int
catalog_limit: int
class PhotoSources:
extensions = {".jpg", ".jpeg", ".png", ".webp"}
def __init__(self, db, settings, assets):
self.db, self.settings, self.assets = db, settings, assets
self.cache = {}
# Register provider secrets before request IDs or errors can be reflected.
for row in self.list():
if row["kind"] == "webdav" and row.get("credential_env"):
try:
self.headers(
PhotoSourceInput.model_validate(
{k: v for k, v in row.items() if k != "id"}
)
)
except FlowError:
pass
def list(self):
return [
dict(json.loads(row["payload"]), id=row["key"][13:])
for row in self.db.all(
"SELECT key,payload FROM app_config WHERE key LIKE 'photo_source:%' ORDER BY key"
)
]
def get(self, source_id):
row = self.db.one(
"SELECT payload FROM app_config WHERE key=?", ("photo_source:" + source_id,)
)
if not row:
raise FlowError(E.NOT_FOUND, "Photo source not found", 404)
return PhotoSourceInput.model_validate_json(row["payload"])
def save(self, source_id, source):
if source.kind == "local":
self.local_directory(source.location)
if source.credential_env:
raise FlowError(
E.INVALID_REQUEST, "Local sources do not use credentials"
)
else:
source = source.model_copy(
update={
"recursive": False,
"location": source.location.rstrip("/") + "/",
}
)
parsed = urlsplit(source.location)
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
or parsed.port not in (None, 443)
):
raise FlowError(
E.INVALID_REQUEST, "WebDAV requires a plain HTTPS directory URL"
)
if parsed.hostname.lower() not in self.settings.image_url_hosts:
raise FlowError(
E.FORBIDDEN, "Configure the allowed image host first", 403
)
self.db.execute(
"INSERT OR REPLACE INTO app_config(key,payload) VALUES(?,?)",
("photo_source:" + source_id, encode(source.model_dump())),
)
self.cache.pop(source_id, None)
return dict(source.model_dump(), id=source_id)
def delete(self, source_id):
self.get(source_id)
for row in self.db.all("SELECT payload FROM jobs"):
if json.loads(row["payload"])["source"].get("source_id") == source_id:
raise FlowError(E.CONFLICT, "Remove jobs using this source first", 409)
for row in self.db.all(
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')"
):
payload = json.loads(row["payload"])
if payload.get("content", payload).get("source_id") == source_id:
raise FlowError(E.CONFLICT, "This source has pending operations", 409)
self.db.execute(
"DELETE FROM app_config WHERE key=?", ("photo_source:" + source_id,)
)
self.cache.pop(source_id, None)
def local_directory(self, location):
root = self.settings.photo_root.resolve()
directory = (root / location).resolve()
if Path(location).is_absolute() or not directory.is_relative_to(root):
raise FlowError(
E.FORBIDDEN,
"Photo directory must be inside the mounted photo root",
403,
)
if not directory.is_dir():
raise FlowError(
E.NOT_FOUND,
"Photo directory is unavailable; check its read-only mount",
404,
)
return directory
def headers(self, source):
if not source.credential_env:
return {}
try:
value = secret(source.credential_env)
except OSError:
raise FlowError(
E.CONFIG_REQUIRED, "WebDAV credential secret is unavailable"
) from None
if value and value not in self.settings.additional_secrets:
self.settings.additional_secrets.append(value)
try:
credentials = json.loads(value)
username, password = credentials["username"], credentials["password"]
if (
not isinstance(username, str)
or not isinstance(password, str)
or ":" in username
):
raise ValueError()
except (ValueError, KeyError, TypeError):
raise FlowError(
E.CONFIG_REQUIRED,
"WebDAV credential secret must contain username and password",
) from None
for sensitive in (value, username, password):
if sensitive and sensitive not in self.settings.additional_secrets:
self.settings.additional_secrets.append(sensitive)
encoded = base64.b64encode((username + ":" + password).encode()).decode()
if encoded not in self.settings.additional_secrets:
self.settings.additional_secrets.append(encoded)
return {"Authorization": "Basic " + encoded}
def catalog(self, source_id, refresh=False):
source = self.get(source_id)
if not source.enabled:
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
cached = self.cache.get(source_id)
if cached and not refresh and time.monotonic() - cached[0] < 60:
return cached[1]
items = []
if source.kind == "local":
directory = self.local_directory(source.location)
root = self.settings.photo_root.resolve()
# Do not follow symlink directories or import Synology's thumbnail trees.
for folder, dirs, files in os.walk(directory, followlinks=False):
dirs[:] = (
sorted(
d
for d in dirs
if not d.startswith(".")
and not d.startswith("@")
and not Path(folder, d).is_symlink()
)
if source.recursive
else []
)
for name in sorted(files):
path = Path(folder, name)
if (
name.startswith(".")
or path.suffix.lower() not in self.extensions
or path.is_symlink()
):
continue
try:
if path.stat().st_size > self.settings.max_bytes:
continue
relative = path.relative_to(root).as_posix()
items.append({"id": relative, "name": name})
except OSError:
continue
if len(items) >= 10000:
break
if len(items) >= 10000:
break
else:
data = self.assets.fetch_url(
source.location,
method="PROPFIND",
headers={**self.headers(source), "Depth": "1"},
limit=2 * 1024 * 1024,
accepted_status=(207,),
)
if (
b"\x00" in data
or b"<!DOCTYPE" in data.upper()
or b"<!ENTITY" in data.upper()
):
raise FlowError(E.INVALID_REQUEST, "Unsafe WebDAV XML")
try:
document = ElementTree.fromstring(data)
base = urlsplit(source.location)
prefix = posixpath.normpath(unquote(base.path)).rstrip("/") + "/"
seen = set()
for response in document.findall("{DAV:}response"):
href = response.findtext("{DAV:}href")
if not href or response.find(".//{DAV:}collection") is not None:
continue
url = urljoin(source.location, href)
parsed = urlsplit(url)
if (
(parsed.scheme, parsed.netloc) != (base.scheme, base.netloc)
or not posixpath.normpath(unquote(parsed.path)).startswith(
prefix
)
or "\\" in unquote(parsed.path)
or url in seen
or parsed.query
or parsed.fragment
):
continue
if Path(parsed.path).suffix.lower() in self.extensions:
seen.add(url)
items.append(
{"id": url, "name": unquote(Path(parsed.path).name)}
)
if len(items) >= 10000:
break
except ElementTree.ParseError:
raise FlowError(E.INVALID_REQUEST, "Invalid WebDAV catalog") from None
self.cache[source_id] = (time.monotonic(), items)
return items
def read(self, source_id, item_id=None):
source = self.get(source_id)
items = self.catalog(source_id)
if not items:
raise FlowError(
E.NOT_FOUND, "Photo source contains no supported images", 404
)
item = (
next((x for x in items if x["id"] == item_id), None)
if item_id
else random.SystemRandom().choice(items)
)
if item is None:
raise FlowError(E.NOT_FOUND, "Photo is not in this source", 404)
if source.kind == "local":
root = self.settings.photo_root.resolve()
path = root / item["id"]
if path.is_symlink() or not path.resolve().is_relative_to(
self.local_directory(source.location)
):
raise FlowError(E.FORBIDDEN, "Photo path is outside its source", 403)
try:
# Open every component without following links; a renamed parent
# must not turn a previously cataloged image into a secret-file read.
parent_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
try:
components = path.relative_to(root).parts
for component in components[:-1]:
child_fd = os.open(
component,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=parent_fd,
)
os.close(parent_fd)
parent_fd = child_fd
file_fd = os.open(
components[-1],
os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
dir_fd=parent_fd,
)
with os.fdopen(file_fd, "rb") as handle:
if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
raise FlowError(
E.FORBIDDEN, "Photo must be a regular file", 403
)
data = handle.read(self.settings.max_bytes + 1)
finally:
os.close(parent_fd)
except OSError:
raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None
else:
data = self.assets.fetch_url(item["id"], headers=self.headers(source))
if len(data) > self.settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
return data, item["name"]
+9 -5
View File
@@ -114,7 +114,10 @@ class Assets:
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
return data
def fetch_url(self, url):
def fetch_url(
self, url, *, method="GET", headers=None, limit=None, accepted_status=(200,)
):
limit = limit or self.settings.max_bytes
parsed = urlsplit(url)
if (
parsed.scheme != "https"
@@ -153,16 +156,17 @@ class Assets:
connection = PinnedHTTPS(parsed.hostname, timeout=10)
try:
connection.request(
"GET",
method,
(parsed.path or "/") + ("?" + parsed.query if parsed.query else ""),
headers=headers or {},
)
response = connection.getresponse()
if response.status != 200:
if response.status not in accepted_status:
raise FlowError(
E.INVALID_REQUEST, "Image URL returned an invalid response"
)
data = response.read(self.settings.max_bytes + 1)
if len(data) > self.settings.max_bytes:
data = response.read(limit + 1)
if len(data) > limit:
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
return data
finally:
+3
View File
@@ -48,6 +48,9 @@ class Settings:
"FONT_PATH", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
)
)
photo_root: Path = field(
default_factory=lambda: Path(os.getenv("PHOTO_ROOT", "/photos"))
)
max_bytes: int = 20 * 1024 * 1024
idempotency_hours: int = 168
sync_timeout: int = 600
+34 -3
View File
@@ -29,7 +29,8 @@ function displayDialog(content){
showModal(`<h2>${t('displayOptions')}</h2><form id="display-options"><label class="check-row"><input id="override-restore" type="checkbox">${t('restoreAfter')}</label><div class="form-grid"><div class="field"><label for="override-duration">${t('duration')}</label><input id="override-duration" type="number" min="1" max="604800" value="1800"></div><div class="field"><label for="override-priority">${t('priority')}</label><input id="override-priority" type="number" min="0" max="100" value="50"></div></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button id="confirm-display" type="submit" class="primary">${t('display')}</button></div></form>`);
$('#display-options').onsubmit=busy(async e=>{e.preventDefault();const restore=$('#override-restore').checked;const options={duration:restore?Number($('#override-duration').value):0,restore_previous:restore,priority:Number($('#override-priority').value)};await accepted('/api/v1/display',{device_id:state.device,content,options});$('#modal').close();await navigate('history');});
}
function enhanceGallery(){
async function enhanceGallery(){
await photoSourcesPanel();
const grid=$('.gallery');if(!grid)return;
const controls=document.createElement('div');controls.className='form-grid';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('mode')}</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);
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{const query=$('#gallery-search').value.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;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));};
@@ -47,13 +48,14 @@ async function enhanceAutomation(){
}
async function jobSourceEditor(job){
const raw=$('#job-source');const advanced=document.createElement('details');advanced.innerHTML=`<summary>${t('advanced')}</summary>`;raw.parentElement.before(advanced);advanced.append(raw.parentElement);
const box=document.createElement('div');advanced.before(box);let source=structuredClone(job.source);let images=[],assets=[];try{[images,assets]=await Promise.all([api('/api/v1/devices/'+state.device+'/images'),api('/api/v1/assets')]);}catch(e){toast(e.message);}if(!raw.isConnected)return;
box.innerHTML=`<div class="field"><label for="job-content-type">${t('sourceType')}</label><select id="job-content-type">${['template','image','image_url','restore'].map(k=>`<option value="${k}">${k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):t('imageURL')}</option>`).join('')}</select></div><div id="job-content-fields"></div>`;
const box=document.createElement('div');advanced.before(box);let source=structuredClone(job.source);let images=[],assets=[],photoSources=[];try{[images,assets,photoSources]=await Promise.all([api('/api/v1/devices/'+state.device+'/images'),api('/api/v1/assets'),api('/api/v1/photo-sources')]);}catch(e){toast(e.message);}if(!raw.isConnected)return;
box.innerHTML=`<div class="field"><label for="job-content-type">${t('sourceType')}</label><select id="job-content-type">${['template','image','image_url','photo_source','restore'].map(k=>`<option value="${k}">${k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):k==='photo_source'?t('photoSources'):t('imageURL')}</option>`).join('')}</select></div><div id="job-content-fields"></div>`;
$('#job-content-type').value=source.type;const old=$('#job-template')?.parentElement;if(old)old.remove();
function update(){raw.value=JSON.stringify(source,null,2);}
function fields(){const type=$('#job-content-type').value;const panel=$('#job-content-fields');if(type==='restore'){source={type};panel.innerHTML='';$('#job-restore').checked=false;$('#job-duration').value=0;update();return;}
if(type==='template'){const selected=state.templates.find(tpl=>tpl.id===source.template)||state.templates[0];source={type,template:selected.id,data:source.type==='template'?source.data||{}:{},profile:source.profile||'color'};panel.innerHTML=`<div class="field"><label for="job-template">${t('template')}</label><select id="job-template">${state.templates.map(x=>`<option value="${x.id}" ${selected.id===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="job-source-profile">${t('profile')}</label><select id="job-source-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div id="job-data-fields"></div>`;$('#job-source-profile').value=source.profile;$('#job-source-profile').onchange=()=>{source.profile=$('#job-source-profile').value;update();};const defaults={...selected.defaults,...source.data};const keys=[...new Set([...Object.keys(defaults).filter(k=>typeof defaults[k]!=='object'||Array.isArray(defaults[k])),...(['calendar','daily-dashboard'].includes(selected.id)?['events']:[])])];$('#job-data-fields').innerHTML=keys.map(k=>`<div class="field"><label for="job-field-${k}">${esc({title:state.lang==='zh'?'标题':'Title',message:state.lang==='zh'?'正文':'Message',quote:state.lang==='zh'?'一句话':'Quote',author:state.lang==='zh'?'作者':'Author',events:t('eventsHelp')}[k]||k)}</label><textarea id="job-field-${k}" data-job-field="${k}" rows="2">${esc(Array.isArray(defaults[k])?defaults[k].map(x=>typeof x==='object'?x.title:x).join('\n'):defaults[k]||'')}</textarea></div>`).join('');$('#job-template').onchange=()=>{source={type:'template',template:$('#job-template').value,data:{}};fields();};panel.querySelectorAll('[data-job-field]').forEach(el=>el.oninput=()=>{source.data[el.dataset.jobField]=el.dataset.jobField==='events'?el.value.split('\n').filter(x=>x.trim()):el.value;update();});update();}
else if(type==='image'){const selected=source.image_id?'cloud:'+source.image_id:source.asset_id?'local:'+source.asset_id:'';panel.innerHTML=`<div class="field"><label for="job-image">${t('imageChoice')}</label><select id="job-image" required><option value="">—</option>${images.map(i=>`<option value="cloud:${i.image_id}">${esc(i.name)} · #${i.image_id}</option>`).join('')}${assets.map(a=>`<option value="local:${a.asset_id}">${esc(a.name)} · LOCAL</option>`).join('')}</select></div>`;$('#job-image').value=selected;$('#job-image').onchange=()=>{const [kind,id]=$('#job-image').value.split(':');source=kind==='cloud'?{type:'image',image_id:Number(id)}:{type:'image',asset_id:id};update();};}
else if(type==='photo_source'){panel.innerHTML=`<div class="field"><label for="job-photo-source">${t('photoSources')}</label><select id="job-photo-source" required><option value="">—</option>${photoSources.map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select><p class="hint">${t('sourceJobHint')}</p></div>`;$('#job-photo-source').value=source.source_id||'';$('#job-photo-source').onchange=()=>{source={type:'photo_source',source_id:$('#job-photo-source').value,fit:'contain',profile:'color'};update();};}
else{panel.innerHTML=`<div class="field"><label for="job-url">${t('imageURL')}</label><input id="job-url" type="url" placeholder="https://…" value="${esc(source.type==='image_url'?source.url:'')}" required></div>`;$('#job-url').oninput=()=>{source={type:'image_url',url:$('#job-url').value};update();};}}
$('#job-content-type').onchange=()=>{source={type:$('#job-content-type').value};update();fields();};raw.oninput=()=>{try{source=JSON.parse(raw.value);raw.setCustomValidity('');$('#job-content-type').value=source.type;fields();}catch{raw.setCustomValidity(t('INVALID_REQUEST'));}};fields();
}
@@ -102,3 +104,32 @@ async function officialConnectionPanel(info){
};
try{render(await api(path));}catch{panel.textContent=zh?'官方账号连接由管理员管理':'Official account connection is managed by an administrator';}
}
async function photoSourcesPanel(){
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return;
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
bind('add-photo-source',()=>photoSourceForm());
section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit)));
section.querySelectorAll('[data-source-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/photo-sources/'+b.dataset.sourceDelete,{method:'DELETE'});await navigate('gallery');}));
section.querySelectorAll('[data-source-random]').forEach(b=>b.onclick=busy(()=>previewSource(b.dataset.sourceRandom)));
section.querySelectorAll('[data-source-browse]').forEach(b=>b.onclick=busy(()=>browseSource(b.dataset.sourceBrowse)));
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
}
function photoSourceForm(source=null){
showModal(`<h2>${t(source?'edit':'addSource')}</h2><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="webdav">WebDAV</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value==='webdav';$('#source-credential').disabled=!dav;$('#source-recursive').disabled=dav;};update();$('#source-kind').onchange=update;
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const dav=$('#source-kind').value==='webdav';await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:!dav&&$('#source-recursive').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});$('#modal').close();await navigate('gallery');});
}
async function browseSource(id,offset=0,refresh=false){
const result=await api('/api/v1/photo-sources/'+id+'/images?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)}</button>`).join('')||t('noSources')}</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.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){
const acceptedResult=await api('/api/v1/devices/'+state.device+'/upload',{method:'POST',body:{type:'photo_source',source_id:id,item_id,fit:'contain',profile:'color'},headers:{'Idempotency-Key':clientUUID()}});const op=await waitOperation(acceptedResult.operation_id);
showModal(`<h2>${t('preview')}</h2><img id="source-preview" class="preview" alt="${t('preview')}"><p class="hint">${t('sourcePreviewHint')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('source-display','display',true)}</div>`);await blobImage($('#source-preview'),op.result.url);$('#source-display').disabled=!liveWriteAllowed();bind('source-display',()=>displayDialog({type:'image',asset_id:op.result.asset_id}));
}
function sourceJobForm(id){
showModal(`<h2>${t('sourceJob')}</h2><form id="source-job-form"><div class="field"><label for="source-job-name">${t('name')}</label><input id="source-job-name" required maxlength="120" value="${t('randomPhoto')}"></div><div class="field"><label for="source-job-cron">Cron · Asia/Shanghai</label><input id="source-job-cron" required value="0 8 * * *"><p class="hint">${t('sourceJobHint')}</p></div><div class="field"><label class="check-row"><input id="source-job-enabled" type="checkbox">${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button class="primary" type="submit">${t('save')}</button></div></form>`);$('#source-job-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/jobs',{method:'POST',body:{name:$('#source-job-name').value,device_id:state.device,enabled:$('#source-job-enabled').checked,schedule:{type:'cron',cron:$('#source-job-cron').value},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'}}});$('#modal').close();await navigate('automation');});
}
+3
View File
@@ -16,3 +16,6 @@ Object.assign(window.MESSAGES.zh,{poetryNotice:'Frame Flow 每日诗词:内置
Object.assign(MESSAGES.zh,{officialContentTitle:'KoKonna 官方小组件与订阅',officialContentDescription:'官方产品资料列出的内容类型;下列功能与 Frame Flow 自建模板分开管理。',officialCalendarWeather:'日历与当地天气',officialWordAlmanacTravel:'每日一词、传统黄历、全球旅行推荐',officialNews:'每日新闻',officialCreative:'AI 语音 / 文字 / 涂鸦 / 照片转艺术;照片轮播',officialSubscriptionUnavailable:'公开 OpenAPI 尚未列出订阅目录、当前订阅、开通或取消订阅接口。官方订阅请继续在官方小程序中管理;Frame Flow 不会读取或修改这些设置。你使用的每日诗词属于官方小程序内容,当前无法通过公开 API 确认完整类型列表。',officialProductDocs:'官方内容介绍 ↗',officialAPIDocs:'官方 OpenAPI ↗'});
Object.assign(MESSAGES.en,{officialContentTitle:'KoKonna official widgets and subscriptions',officialContentDescription:'Content types listed in official product materials; managed separately from Frame Flow templates.',officialCalendarWeather:'Calendar and local weather',officialWordAlmanacTravel:'Word of the day, traditional almanac and global travel recommendations',officialNews:'Daily news',officialCreative:'AI voice / text / sketch / photo-to-art; photo rotation',officialSubscriptionUnavailable:'Public OpenAPI does not list subscription discovery, current subscription, subscribe or unsubscribe endpoints. Manage official subscriptions in the official mini-program. Frame Flow cannot read or modify these settings. Your daily-poetry subscription is mini-program content; its complete catalog cannot currently be verified through public API.',officialProductDocs:'Official content overview ↗',officialAPIDocs:'Official OpenAPI ↗'});
Object.assign(MESSAGES.zh,{photoSources:'照片数据源',sourceHint:'从只读 NAS 目录或 WebDAV 选图;预览不会改变画框。',addSource:'添加数据源',browse:'浏览照片',randomPreview:'随机选图预览',sourceJob:'定时随机照片',noSources:'暂无照片,请添加数据源或检查目录。',sourceKind:'数据源类型',nasDirectory:'NAS 本地目录',sourceLocation:'目录 / WebDAV URL',sourceLocationHint:'本地填写挂载照片根目录下的相对路径,如 . 或 family。WebDAV 填写 HTTPS 目录 URL,并先在设置中允许该域名。',sourceCredential:'WebDAV 凭据环境变量名',sourceCredentialHint:'仅填写变量名,禁止填写密码。管理员通过环境变量或 Secret 配置 JSON username/password。',sourceRecursive:'包含子目录(本地目录)',sourceImages:'可用照片',sourceBrowseHint:'按需读取目录,点击文件名才生成预览;不会下载全部原图。',previousPage:'上一页',nextPage:'下一页',sourcePreviewHint:'这是已选中照片的预览,显示时使用同一张照片。',randomPhoto:'随机照片',sourceJobHint:'每次执行重新随机选图。默认暂停;启用后会按时改变画框显示。'});
Object.assign(MESSAGES.en,{photoSources:'Photo sources',sourceHint:'Choose photos from a read-only NAS folder or WebDAV. Preview does not change the frame.',addSource:'Add source',browse:'Browse photos',randomPreview:'Preview random photo',sourceJob:'Schedule random photos',noSources:'No photos yet. Add a source or check the folder.',sourceKind:'Source type',nasDirectory:'NAS local folder',sourceLocation:'Folder / WebDAV URL',sourceLocationHint:'Local: relative folder under the mounted photo root, e.g. . or family. WebDAV: HTTPS directory URL; allow its hostname in Settings first.',sourceCredential:'WebDAV credential environment name',sourceCredentialHint:'Enter a variable name, never a password. Admin configures JSON username/password via environment or Secret.',sourceRecursive:'Include subfolders (local)',sourceImages:'Available photos',sourceBrowseHint:'Catalog loaded on demand. Click a filename to preview; originals are not downloaded in bulk.',previousPage:'Previous page',nextPage:'Next page',sourcePreviewHint:'Display uses this exact previewed photo.',randomPhoto:'Random photo',sourceJobHint:'Choose a fresh random photo each run. Paused by default; enabling changes the frame on schedule.'});
+2
View File
@@ -48,3 +48,5 @@ input[type="file"]::file-selector-button{margin-inline-end:12px}
.official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px}
.history-table{min-width:920px;table-layout:fixed}.history-table th:nth-child(1){width:145px}.history-table th:nth-child(2){width:100px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:100px}.history-table th:last-child{width:120px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{vertical-align:middle;white-space:nowrap}.history-actions button{white-space:nowrap}
.photo-sources-panel{margin-top:24px}.source-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:20px}.source-card{border:1px solid var(--border);border-radius:12px;padding:18px}.source-card h4{margin:0 0 12px}.source-card .actions{margin-top:16px}.source-items{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.source-items button{max-width:100%;overflow-wrap:anywhere}#source-browser>h4{margin-top:24px}