Add optional photo providers, layout studio and scoped integrations

This commit is contained in:
Pigway
2026-10-08 23:18:11 +08:00
parent 3643fae86a
commit 69a0841868
38 changed files with 2843 additions and 41 deletions
+12 -3
View File
@@ -98,6 +98,8 @@ def create_app(settings=None, start_worker=True):
weather = ConfigurableWeatherProvider(db)
renderer = TemplateRenderer(db, settings, weather, assets)
service = DisplayService(db, settings, registry, assets, renderer)
from pigway_frame_flow.extensions.api import install_extensions
scheduler = Scheduler(service)
seed_client(db, settings.api_token)
seed_accounts(db, settings.config_dir)
@@ -123,6 +125,7 @@ def create_app(settings=None, start_worker=True):
lifespan=lifespan,
responses=error_responses,
)
install_extensions(app, service)
install_accounts(app, db)
app.state.service = service
app.state.scheduler = scheduler
@@ -483,7 +486,9 @@ def create_app(settings=None, start_worker=True):
request: Request,
idempotency_key: str | None = Header(default=None),
):
renderer.info(payload.template)
service.validate(
DisplayRequest(device_id=settings.device_id, content=payload), writing=False
)
return submit(
request,
"render",
@@ -701,8 +706,12 @@ def create_app(settings=None, start_worker=True):
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def photos_people():
return service.photo_sources.synology.people()
def photos_people(space: Literal["shared", "personal"] = "shared"):
return (
service.photo_sources.synology.people()
if space == "shared"
else service.photo_sources.synology.people(space)
)
@app.get(
"/api/v1/photo-sources",
+11 -2
View File
@@ -32,10 +32,15 @@ class DeviceRegistry:
for id, entry in devices.items():
if not entry.get("enabled", True):
continue
if entry.get("driver") != "kokonna":
raise ValueError("Unsupported configured device driver")
if not __import__("re").fullmatch(r"[a-z0-9-]{1,60}", id):
raise ValueError("Invalid configured device ID")
if entry.get("driver") == "generic_http_frame":
from pigway_frame_flow.drivers.generic_http import GenericHTTPDriver
self.drivers[id] = GenericHTTPDriver(id, entry, settings)
continue
if entry.get("driver") != "kokonna":
raise ValueError("Unsupported configured device driver")
client: MockClient | KokonnaClient
if settings.mock:
root = settings.data_dir / "cache" / id
@@ -71,4 +76,8 @@ class DeviceRegistry:
def official_widgets(self, id: str):
self.get(id)
if id not in self.official:
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "This driver has no official widgets", 422
)
return self.official[id].widgets()
+86
View File
@@ -15,6 +15,7 @@ from pigway_frame_flow.models import (
FutureContent,
ImageContent,
PhotoSourceContent,
ProviderContent,
TemplateContent,
URLContent,
)
@@ -37,6 +38,13 @@ class DisplayService:
assets,
renderer,
)
from typing import Callable
self.operation_handlers: dict[str, Callable] = {}
self.reconciliation_handlers: dict[str, Callable] = {}
from pigway_frame_flow.providers.external import ExternalProviders
self.providers = ExternalProviders(self)
self.lock = threading.RLock()
self.retention = Retention(self)
self.cloud_library = CloudLibrary(self)
@@ -56,8 +64,23 @@ class DisplayService:
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Content type is unsupported", 422
)
if isinstance(request.content, ProviderContent):
self.providers.config(request.content.provider_id)
self.renderer.info(request.content.template)
if isinstance(request.content, TemplateContent):
self.renderer.info(request.content.template)
if request.content.template in ("collage", "poster"):
from pigway_frame_flow.extensions.layout import LayoutPlan
try:
plan = LayoutPlan.model_validate(request.content.data.get("layout"))
except ValueError:
raise FlowError(
E.INVALID_REQUEST,
"Create a valid layout in Layout Studio first",
) from None
for region in plan.regions:
self.assets.path(region.asset_id)
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:
@@ -168,6 +191,18 @@ class DisplayService:
return self.db.one("SELECT * FROM device_state WHERE device_id=?", (id,))
def content_asset(self, content, device_id=None, operation_id=None):
if isinstance(content, ProviderContent):
values = {**content.data, **self.providers.get(content.provider_id)}
return self.content_asset(
TemplateContent(
type="template",
template=content.template,
data=values,
profile=content.profile,
),
device_id,
operation_id,
)
if isinstance(content, TemplateContent):
values = self.renderer.resolve(content.template, content.data)
data = self.renderer.render(content.template, values, content.profile)
@@ -219,6 +254,53 @@ class DisplayService:
data, name, caption = self.photo_sources.read(
content.source_id, item["id"], target_size=size
)
if (
source.minimum_sharpness
or source.minimum_brightness
or source.maximum_brightness < 255
or source.avoid_duplicates
):
from pigway_frame_flow.extensions.quality import (
passes,
photo_quality,
)
quality = photo_quality(data)
if not passes(quality, source):
raise FlowError(
E.INVALID_REQUEST,
"Photo fails configured quality thresholds",
)
previous_quality = self.db.one(
"SELECT payload FROM app_config WHERE key=?",
("recent-photo:" + content.source_id,),
)
fingerprints = (
json.loads(previous_quality["payload"])
if previous_quality
else []
)
if source.avoid_duplicates and any(
int(quality["fingerprint"], 16)
.__xor__(int(old, 16))
.bit_count()
<= 4
for old in fingerprints
):
raise FlowError(
E.CONFLICT,
"Photo resembles a recently selected photo",
409,
)
self.db.execute(
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
(
"recent-photo:" + content.source_id,
encode(
(fingerprints + [quality["fingerprint"]])[-200:]
),
),
)
rendered = (
render_captioned_photo(
data,
@@ -376,6 +458,8 @@ class DisplayService:
def _execute(self, op, payload):
id, device_id, action = op["id"], op["device_id"], op["action"]
driver = self.registry.get(device_id)
if action in self.operation_handlers:
return self.operation_handlers[action](op, payload)
if action in ("render", "local-upload"):
self.update(id, "RENDERING")
from pydantic import TypeAdapter
@@ -699,6 +783,8 @@ class DisplayService:
def reconcile(self, id, acknowledge=False):
with self.lock:
op = self.operation(id)
if op["action"] in self.reconciliation_handlers:
return self.reconciliation_handlers[op["action"]](op, acknowledge)
driver = self.registry.get(op["device_id"])
device = driver.device(refresh=True)
images = driver.images()
+110
View File
@@ -0,0 +1,110 @@
"""Opt-in standard HTTP frame protocol for privately managed devices."""
import requests
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import Device, GalleryImage
from pigway_frame_flow.settings import secret
class GenericHTTPDriver:
def __init__(self, id, entry, settings):
from urllib.parse import urlsplit
self.id, self.entry, self.settings = id, entry, settings
self.endpoint = entry.get("endpoint", "").rstrip("/")
parsed = urlsplit(self.endpoint)
if (
parsed.scheme not in ("https", "http")
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
):
raise ValueError("Invalid generic frame endpoint")
if parsed.scheme == "http" and not entry.get("allow_insecure_lan", False):
raise ValueError("HTTP frames require explicit allow_insecure_lan")
self.token = secret(entry.get("token_env", "FRAME_FLOW_HTTP_FRAME_TOKEN"))
settings.additional_secrets.append(self.token)
def call(self, method, path, **kwargs):
writing = method != "GET"
if writing and not self.settings.write_enabled:
raise FlowError(E.WRITE_DISABLED, "Device writes are disabled", 403)
try:
response = requests.request(
method,
self.endpoint + path,
headers={"Authorization": "Bearer " + self.token} if self.token else {},
timeout=10,
allow_redirects=False,
**kwargs,
)
response.raise_for_status()
return response
except requests.RequestException:
raise FlowError(
E.WRITE_RESULT_UNKNOWN if writing else E.CLOUD_UNAVAILABLE,
"HTTP frame request failed",
409 if writing else 502,
) from None
def invalidate(self):
pass
def device(self, refresh=False):
raw = self.call("GET", "/device").json()
raw["id"] = self.id
raw["driver"] = "generic_http_frame"
return Device.model_validate(raw)
def get_current_image(self):
return self.device().current_content.id
def health(self):
try:
self.device()
return True
except FlowError:
return False
def images(self):
return [
GalleryImage.model_validate(x) for x in self.call("GET", "/images").json()
]
def upload(self, path, name):
with path.open("rb") as f:
return int(
self.call(
"POST", "/images", files={"file": (name, f, "image/jpeg")}
).json()["image_id"]
)
def display(self, image_id):
self.call("POST", "/display", json={"image_id": image_id})
def delete(self, image_id):
self.call("DELETE", f"/images/{int(image_id)}")
def download(self, image_id):
response = self.call("GET", f"/images/{int(image_id)}/content", stream=True)
try:
parts = []
total = 0
for chunk in response.iter_content(65536):
total += len(chunk)
if total > self.settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
parts.append(chunk)
return b"".join(parts)
finally:
response.close()
def display_by_name(self, name):
matches = [x for x in self.images() if x.name == name]
if len(matches) != 1:
raise FlowError(E.CONFLICT, "Filename must match exactly one image", 409)
self.display(matches[0].image_id)
+292
View File
@@ -0,0 +1,292 @@
"""Optional capabilities share application auth, assets and operation pipeline."""
import hashlib
import json
import secrets
from urllib.parse import urlsplit
from fastapi import Depends, File, UploadFile
from pydantic import Field, SecretStr
from pigway_frame_flow.core.auth import ALL_SCOPES, require
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.extensions.layout import LayoutInput, LayoutPlan, LayoutStudio
from pigway_frame_flow.extensions.storage import LocalRetention, StoragePolicy
from pigway_frame_flow.models import StrictModel
from pigway_frame_flow.storage.database import encode, now_iso
class AIConfig(StrictModel):
endpoint: str = Field(default="", max_length=2048)
model: str = Field(default="", max_length=120)
class AISecret(StrictModel):
key: SecretStr = Field(min_length=1, max_length=8192)
class ClientInput(StrictModel):
client_id: str = Field(pattern=r"^[a-z0-9-]{1,60}$")
name: str = Field(min_length=1, max_length=120)
permissions: list[str] = Field(min_length=1, max_length=20)
def install_extensions(app, service):
from pigway_frame_flow.extensions.official import install_official
install_official(app, service)
from pigway_frame_flow.extensions.webhooks import install_webhooks
install_webhooks(app, service)
from pigway_frame_flow.extensions.providers import install_providers
install_providers(app, service)
db, settings = service.db, service.settings
studio = LayoutStudio(service.renderer)
service.local_retention = LocalRetention(service)
@app.post(
"/api/v1/layout/images",
status_code=201,
tags=["Layout"],
dependencies=[Depends(require("render:write"))],
)
async def import_images(files: list[UploadFile] = File(...)):
from io import BytesIO
from PIL import Image, ImageOps
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS
if not 1 <= len(files) <= 12:
raise FlowError(E.INVALID_REQUEST, "Choose between one and twelve images")
prepared = []
for file in files:
data = await file.read(settings.max_bytes + 1)
if len(data) > settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
try:
with Image.open(BytesIO(data)) as image:
if (
image.format not in {"JPEG", "PNG", "WEBP"}
or image.width * image.height > MAX_IMAGE_PIXELS
):
raise ValueError()
rgba = ImageOps.exif_transpose(image).convert("RGBA")
rgb = Image.new("RGB", rgba.size, "white")
rgb.paste(rgba, mask=rgba.getchannel("A"))
out = BytesIO()
rgb.save(out, "JPEG", quality=93, optimize=True)
prepared.append((out.getvalue(), file.filename or "photo.jpg"))
except (OSError, ValueError, Image.DecompressionBombError):
raise FlowError(E.INVALID_REQUEST, "Invalid image") from None
result = []
for data, name in prepared:
asset = service.assets.put(data, name)
db.execute(
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,'manual')",
(asset["asset_id"],),
)
result.append(asset)
return result
@app.post(
"/api/v1/layout/plan",
response_model=LayoutPlan,
tags=["Layout"],
dependencies=[Depends(require("render:write"))],
)
def layout_plan(payload: LayoutInput):
return studio.plan(payload)
@app.get(
"/api/v1/settings/ai-layout",
tags=["Layout"],
dependencies=[Depends(require("admin"))],
)
def ai_config():
row = db.one("SELECT payload FROM app_config WHERE key='ai_layout'")
from pigway_frame_flow.settings import secret
return {
**(json.loads(row["payload"]) if row else AIConfig().model_dump()),
"key_configured": bool(
secret("FRAME_FLOW_AI_KEY")
or (settings.config_dir / "ai-layout.secret").exists()
),
"sends_photos": False,
}
@app.put(
"/api/v1/settings/ai-layout",
response_model=AIConfig,
tags=["Layout"],
dependencies=[Depends(require("admin"))],
)
def save_ai(payload: AIConfig):
if payload.endpoint:
parsed = urlsplit(payload.endpoint)
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, "AI endpoint must be a plain HTTPS URL"
)
if parsed.hostname not in settings.image_url_hosts:
settings.image_url_hosts.append(parsed.hostname)
db.execute(
"INSERT OR REPLACE INTO app_config VALUES('image_url_hosts',?)",
(encode(settings.image_url_hosts),),
)
db.execute(
"INSERT OR REPLACE INTO app_config VALUES('ai_layout',?)",
(encode(payload.model_dump()),),
)
return payload
@app.put(
"/api/v1/settings/ai-layout/key",
status_code=204,
tags=["Layout"],
dependencies=[Depends(require("admin"))],
)
def save_ai_key(payload: AISecret):
from pigway_frame_flow.extensions.secrets import save_secret
save_secret(
settings.config_dir / "ai-layout.secret", payload.key.get_secret_value()
)
settings.additional_secrets.append(payload.key.get_secret_value())
@app.get(
"/api/v1/storage", tags=["Storage"], dependencies=[Depends(require("admin"))]
)
def storage():
return service.local_retention.stats()
@app.put(
"/api/v1/storage/policy",
response_model=StoragePolicy,
tags=["Storage"],
dependencies=[Depends(require("admin"))],
)
def storage_policy(payload: StoragePolicy):
db.execute(
"INSERT OR REPLACE INTO app_config VALUES('local_retention',?)",
(encode(payload.model_dump()),),
)
return payload
@app.post(
"/api/v1/storage/cleanup",
tags=["Storage"],
dependencies=[Depends(require("admin"))],
)
def cleanup(dry_run: bool = True):
return service.local_retention.sweep(force=True, dry_run=dry_run)
@app.get(
"/api/v1/clients", tags=["Clients"], dependencies=[Depends(require("admin"))]
)
def clients():
return [
{
k: json.loads(row[k]) if k == "permissions" else row[k]
for k in [
"client_id",
"name",
"permissions",
"created_at",
"last_used_at",
"enabled",
]
}
for row in db.all("SELECT * FROM clients")
]
@app.post(
"/api/v1/clients",
status_code=201,
tags=["Clients"],
dependencies=[Depends(require("admin"))],
)
def create_client(payload: ClientInput):
if payload.client_id == "admin" or db.one(
"SELECT client_id FROM clients WHERE client_id=?", (payload.client_id,)
):
raise FlowError(E.CONFLICT, "Client already exists", 409)
if set(payload.permissions) - set(ALL_SCOPES):
raise FlowError(E.INVALID_REQUEST, "Unknown permission scope")
token = secrets.token_urlsafe(32)
settings.additional_secrets.append(token)
db.execute(
"INSERT INTO clients VALUES(?,?,?,?,?,?,?)",
(
payload.client_id,
hashlib.sha256(token.encode()).hexdigest(),
payload.name,
encode(payload.permissions),
now_iso(),
None,
1,
),
)
# Deliberate one-time delivery to the administrator; never list the token again.
return {"client_id": payload.client_id, "token": token}
@app.delete(
"/api/v1/clients/{client_id}",
status_code=204,
tags=["Clients"],
dependencies=[Depends(require("admin"))],
)
def revoke_client(client_id: str):
if client_id == "admin":
raise FlowError(
E.FORBIDDEN, "The deployment administrator cannot be revoked here", 403
)
db.execute("UPDATE clients SET enabled=0 WHERE client_id=?", (client_id,))
@app.put(
"/api/v1/photo-sources/{source_id}/credentials",
status_code=204,
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def drive_credentials(source_id: str, payload: AISecret):
source = service.photo_sources.get(source_id)
if source.kind not in ("google-drive", "alipan", "baidu"):
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Only native cloud drive sources use OAuth credentials here",
422,
)
try:
raw = json.loads(payload.key.get_secret_value())
if (
not isinstance(raw, dict)
or not isinstance(raw.get("access_token"), str)
or not raw["access_token"]
):
raise ValueError()
except (ValueError, TypeError):
raise FlowError(
E.INVALID_REQUEST, "Credentials must be JSON with access_token"
) from None
from pigway_frame_flow.extensions.secrets import save_secret
save_secret(
settings.config_dir / ("source-" + source_id + ".secret"),
payload.key.get_secret_value(),
)
for k in ("access_token", "refresh_token", "client_secret"):
if raw.get(k):
settings.additional_secrets.append(str(raw[k]))
service.photo_sources.cache.pop(source_id, None)
+227
View File
@@ -0,0 +1,227 @@
"""Bounded layout plans shared by deterministic and optional AI renderers."""
import json
from io import BytesIO
from typing import Literal
from PIL import Image, ImageDraw, ImageOps
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.settings import secret
class Region(StrictModel):
asset_id: str = Field(pattern=r"^[a-f0-9]{32}$")
x: int = Field(ge=0)
y: int = Field(ge=0)
width: int = Field(ge=100)
height: int = Field(ge=100)
class LayoutInput(StrictModel):
asset_ids: list[str] = Field(min_length=1, max_length=12)
title: str = Field(default="", max_length=100)
message: str = Field(default="", max_length=500)
orientation: Literal["portrait", "landscape"] = "portrait"
style: Literal["grid", "hero", "poster"] = "grid"
use_ai: bool = False
prompt: str = Field(default="", max_length=1500)
@model_validator(mode="after")
def ids(self):
import re
if len(set(self.asset_ids)) != len(self.asset_ids) or any(
not re.fullmatch(r"[a-f0-9]{32}", x) for x in self.asset_ids
):
raise ValueError("Choose distinct local images")
return self
class LayoutPlan(StrictModel):
width: int = Field(ge=1200, le=1600)
height: int = Field(ge=1200, le=1600)
title: str = Field(default="", max_length=100)
message: str = Field(default="", max_length=500)
regions: list[Region] = Field(min_length=1, max_length=12)
engine: Literal["local", "ai"] = "local"
@model_validator(mode="after")
def bounds(self):
if (self.width, self.height) not in [(1200, 1600), (1600, 1200)]:
raise ValueError("Unsupported canvas")
for i, region in enumerate(self.regions):
if (
region.x + region.width > self.width
or region.y + region.height > self.height - 120
or region.y < 150
):
raise ValueError("Photo region outside safe photo area")
for other in self.regions[:i]:
if (
region.x < other.x + other.width
and other.x < region.x + region.width
and region.y < other.y + other.height
and other.y < region.y + region.height
):
raise ValueError("Photo regions overlap")
return self
def local_plan(request: LayoutInput):
import math
width, height = (1200, 1600) if request.orientation == "portrait" else (1600, 1200)
gap, top, bottom = 24, 170, height - 140
n = len(request.asset_ids)
regions = []
if request.style in ("hero", "poster") and n > 1:
hero_height = int((bottom - top) * (0.45 if n > 7 else 0.62))
regions.append(
Region(
asset_id=request.asset_ids[0],
x=gap,
y=top,
width=width - 2 * gap,
height=hero_height - gap,
)
)
top += hero_height
ids = request.asset_ids[1:]
else:
ids = request.asset_ids
columns = min(3, max(1, math.ceil(math.sqrt(len(ids) * width / (bottom - top)))))
rows = math.ceil(len(ids) / columns)
cell_w, cell_h = (
(width - gap * (columns + 1)) // columns,
(bottom - top - gap * (rows - 1)) // rows,
)
for i, asset_id in enumerate(ids):
regions.append(
Region(
asset_id=asset_id,
x=gap + (i % columns) * (cell_w + gap),
y=top + (i // columns) * (cell_h + gap),
width=cell_w,
height=cell_h,
)
)
return LayoutPlan(
width=width,
height=height,
title=request.title,
message=request.message,
regions=regions,
)
class LayoutStudio:
def __init__(self, renderer):
self.renderer = renderer
def plan(self, request):
plan = local_plan(request)
metadata = []
for asset_id in request.asset_ids:
with Image.open(self.renderer.assets.path(asset_id)) as image:
metadata.append(
{"asset_id": asset_id, "width": image.width, "height": image.height}
)
if not request.use_ai:
return plan
db = self.renderer.db
row = db.one("SELECT payload FROM app_config WHERE key='ai_layout'")
config = json.loads(row["payload"]) if row else {}
key = secret("FRAME_FLOW_AI_KEY")
key_path = self.renderer.settings.config_dir / "ai-layout.secret"
if not key and key_path.exists():
key = key_path.read_text().strip()
if not key or not config.get("endpoint") or not config.get("model"):
raise FlowError(
E.CONFIG_REQUIRED,
"Configure the AI layout endpoint, model and key first",
409,
)
self.renderer.settings.additional_secrets.append(key)
body = {
"model": config["model"],
"response_format": {"type": "json_object"},
"messages": [
{
"role": "system",
"content": "Return ONLY a JSON layout following this example. Keep canvas size, all asset ids exactly once, non-overlapping photo rectangles y>=150 and bottom<=height-120. Keep title/message unchanged. Never include code, URLs or HTML. Example: "
+ plan.model_dump_json(),
},
{
"role": "user",
"content": json.dumps(
{
"images": metadata,
"style": request.style,
"instruction": request.prompt,
},
ensure_ascii=False,
),
},
],
"max_tokens": 2000,
}
try:
data = self.renderer.assets.fetch_url(
config["endpoint"],
method="POST",
headers={
"Authorization": "Bearer " + key,
"Content-Type": "application/json",
},
body=json.dumps(body).encode(),
limit=256 * 1024,
)
result = json.loads(data)["choices"][0]["message"]["content"]
candidate = LayoutPlan.model_validate_json(result)
if (
sorted(x.asset_id for x in candidate.regions)
!= sorted(request.asset_ids)
or (candidate.width, candidate.height) != (plan.width, plan.height)
or candidate.title != request.title
or candidate.message != request.message
):
raise ValueError()
candidate.engine = "ai"
return candidate
except Exception:
raise FlowError(
E.RENDER_FAILED,
"AI layout failed or returned an invalid plan; choose local layout to continue",
502,
) from None
def render(self, data, profile):
plan = LayoutPlan.model_validate(data["layout"])
canvas = Image.new("RGB", (plan.width, plan.height), "white")
for region in plan.regions:
with Image.open(self.renderer.assets.path(region.asset_id)) as source:
image = ImageOps.exif_transpose(source).convert("RGB")
image = ImageOps.fit(
image,
(region.width, region.height),
method=Image.Resampling.LANCZOS,
)
canvas.paste(image, (region.x, region.y))
draw = ImageDraw.Draw(canvas)
for text, y, size in [
(plan.title, 35, 54),
(plan.message, plan.height - 100, 30),
]:
font = self.renderer.font(size)
while text and draw.textlength(text, font=font) > plan.width - 48:
text = text[:-1]
draw.text((24, y), text, font=font, fill="black")
if profile == "grayscale":
canvas = ImageOps.grayscale(canvas).convert("RGB")
out = BytesIO()
canvas.save(out, "JPEG", quality=93, optimize=True)
return out.getvalue()
+171
View File
@@ -0,0 +1,171 @@
"""Explicit widget mutations, queued once with conservative uncertain-write handling."""
from fastapi import Depends, Header, Request
from pydantic import Field
from pigway_frame_flow.core.auth import require
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import Accepted, StrictModel
from pigway_frame_flow.storage.database import encode
class WidgetSelection(StrictModel):
cover_id: int | None = Field(default=None, gt=0)
args: dict[str, str] = Field(default_factory=dict, max_length=30)
def install_official(app, service):
import json
for device_id, connection in service.registry.official.items():
row = service.db.one(
"SELECT payload FROM app_config WHERE key=?", ("mock-widget:" + device_id,)
)
if connection.reader.mock and row:
connection.reader.mock_selection = json.loads(row["payload"])["cover_id"]
def check(device_id, payload):
service.registry.get(device_id)
connection = service.registry.official.get(device_id)
if not connection:
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "This driver has no official widgets", 422
)
if not service.settings.mock and not service.settings.write_enabled:
raise FlowError(E.WRITE_DISABLED, "Device writes are disabled", 403)
view = connection.widgets()
if not view.configured:
raise FlowError(
E.OFFICIAL_AUTH_REQUIRED,
"Connect and associate an official account first",
401,
)
if payload.cover_id is not None:
widget = next((x for x in view.catalog if x.id == payload.cover_id), None)
if not widget:
raise FlowError(
E.INVALID_REQUEST, "Widget is not in the authenticated catalog"
)
if set(payload.args) - set(widget.arg_keys):
raise FlowError(E.INVALID_REQUEST, "Unknown widget parameter")
if any(len(k) > 120 or len(v) > 2000 for k, v in payload.args.items()):
raise FlowError(E.INVALID_REQUEST, "Widget parameter is too long")
elif payload.args:
raise FlowError(
E.INVALID_REQUEST, "Cancellation does not accept parameters"
)
return connection
@app.post(
"/api/v1/devices/{device_id}/widgets/selection",
response_model=Accepted,
status_code=202,
tags=["Official"],
dependencies=[Depends(require("admin"))],
)
def select(
device_id: str,
payload: WidgetSelection,
request: Request,
idempotency_key: str | None = Header(default=None),
):
check(device_id, payload)
id = service.submit(
"official-widget",
payload.model_dump(),
device_id,
request.state.client_id,
request.state.request_id,
idempotency_key,
)
return Accepted(operation_id=id, job_id=id, status="accepted")
def execute(op, payload):
selection = WidgetSelection.model_validate(payload)
connection = check(op["device_id"], selection)
service.update(op["id"], "UPLOADING")
service.db.execute(
"UPDATE operations SET write_started=1 WHERE id=?", (op["id"],)
)
with connection.lock:
if connection.reader.mock:
connection.reader.mock_selection = selection.cover_id
service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
("mock-widget:" + op["device_id"], encode(selection.model_dump())),
)
else:
connection.reader.rpc.set_widget(
connection.reader._robot_id, selection.cover_id, selection.args
)
connection.reader._cached = None
try:
view = connection.widgets()
except Exception:
raise FlowError(
E.WRITE_RESULT_UNKNOWN,
"Widget write sent but read-only confirmation failed",
409,
) from None
if (
not view.current_verified
or (view.current.id if view.current else None) != selection.cover_id
):
raise FlowError(
E.WRITE_RESULT_UNKNOWN,
"Widget write acknowledged but selection is not confirmed",
409,
)
service.update(op["id"], "COMPLETED", {"cover_id": selection.cover_id})
service.db.history(op, "COMPLETED")
service.db.event(
"display.changed",
{
"device_id": op["device_id"],
"operation_id": op["id"],
"cover_id": selection.cover_id,
},
)
service.operation_handlers["official-widget"] = execute
def reconcile(op, acknowledge):
id = op["operation_id"]
row = service.db.one("SELECT payload FROM operations WHERE id=?", (id,))
expected_cover = json.loads(row["payload"])["cover_id"]
connection = service.registry.official[op["device_id"]]
with connection.lock:
connection.reader._cached = None
view = connection.widgets()
confirmed = (
view.current_verified
and (view.current.id if view.current else None) == expected_cover
)
resolved = op["status"] != "UNKNOWN"
if op["status"] == "UNKNOWN" and (confirmed or acknowledge):
service.update(
id,
"COMPLETED" if confirmed else "FAILED",
{"cover_id": expected_cover} if confirmed else None,
)
service.db.history(
dict(
id=id,
client_id="admin",
request_id=op["request_id"],
device_id=op["device_id"],
action="reconcile",
),
"CONFIRMED" if confirmed else "ACKNOWLEDGED",
)
resolved = True
return dict(
operation_id=id,
resolved=resolved,
current_image_id=None,
expected_image_id=None,
cloud_images=[],
)
service.reconciliation_handlers["official-widget"] = reconcile
+51
View File
@@ -0,0 +1,51 @@
from fastapi import Depends
from pigway_frame_flow.core.auth import require
from pigway_frame_flow.extensions.api import AISecret
from pigway_frame_flow.extensions.secrets import save_secret
from pigway_frame_flow.providers.external import (
ExternalProviderConfig,
)
def install_providers(app, service):
providers = service.providers
@app.get(
"/api/v1/providers/external",
tags=["Providers"],
dependencies=[Depends(require("devices:read"))],
)
def list_providers():
return providers.list()
@app.put(
"/api/v1/providers/external/{id}",
tags=["Providers"],
dependencies=[Depends(require("admin"))],
)
def configure(id: str, payload: ExternalProviderConfig):
return providers.save(id, payload)
@app.get(
"/api/v1/providers/external/{id}/data",
tags=["Providers"],
dependencies=[Depends(require("devices:read"))],
)
def provider_data(id: str):
return providers.get(id)
@app.put(
"/api/v1/providers/external/{id}/key",
status_code=204,
tags=["Providers"],
dependencies=[Depends(require("admin"))],
)
def provider_key(id: str, payload: AISecret):
providers.config(id)
save_secret(
service.settings.config_dir / ("provider-" + id + ".secret"),
payload.key.get_secret_value(),
)
service.settings.additional_secrets.append(payload.key.get_secret_value())
providers.cache.pop(id, None)
+39
View File
@@ -0,0 +1,39 @@
"""Small-thumbnail quality heuristics. Never delete or modify source photos."""
import hashlib
from io import BytesIO
from PIL import Image, ImageFilter, ImageOps, ImageStat
def photo_quality(data):
with Image.open(BytesIO(data)) as source:
image = ImageOps.exif_transpose(source).convert("L")
image.thumbnail((256, 256))
brightness = ImageStat.Stat(image).mean[0]
edges = image.filter(ImageFilter.FIND_EDGES)
sharpness = ImageStat.Stat(
edges.crop((2, 2, max(3, image.width - 2), max(3, image.height - 2)))
).var[0]
small = image.resize((9, 8))
pixels = list(small.tobytes())
bits = "".join(
"1" if pixels[y * 9 + x] > pixels[y * 9 + x + 1] else "0"
for y in range(8)
for x in range(8)
)
return {
"brightness": round(brightness, 2),
"sharpness": round(sharpness, 2),
"fingerprint": f"{int(bits, 2):016x}",
"sha256": hashlib.sha256(data).hexdigest(),
}
def passes(result, source):
return (
result["sharpness"] >= source.minimum_sharpness
and source.minimum_brightness
<= result["brightness"]
<= source.maximum_brightness
)
+18
View File
@@ -0,0 +1,18 @@
"""Atomic mode-0600 secrets; filenames supplied by application code, never callers."""
import os
import tempfile
from pathlib import Path
def save_secret(path, value):
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as file:
temporary = Path(file.name)
try:
file.write(value)
file.flush()
os.fsync(file.fileno())
temporary.chmod(0o600)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
+135
View File
@@ -0,0 +1,135 @@
"""Opt-in local retention, protecting stars, current content and durable references."""
import re
import time
from datetime import UTC, datetime, timedelta
from pydantic import Field
from pigway_frame_flow.models import StrictModel
from pigway_frame_flow.storage.database import encode, now_iso
class StoragePolicy(StrictModel):
enabled: bool = False
generated_days: int = Field(default=7, ge=1, le=3650)
cache_days: int = Field(default=2, ge=1, le=3650)
max_bytes: int = Field(default=1024 * 1024 * 1024, ge=1024 * 1024, le=1024**4)
class LocalRetention:
def __init__(self, service):
self.service = service
self.last = 0.0
def config(self):
row = self.service.db.one(
"SELECT payload FROM app_config WHERE key='local_retention'"
)
return (
StoragePolicy.model_validate_json(row["payload"])
if row
else StoragePolicy()
)
def protected(self):
db = self.service.db
ids = {
row["asset_id"]
for row in db.all("SELECT asset_id FROM asset_metadata WHERE starred=1")
}
payloads = []
for query in [
"SELECT payload FROM jobs",
"SELECT payload FROM template_config",
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
]:
payloads += [row["payload"] for row in db.all(query)]
for payload in payloads:
ids.update(re.findall(r"[a-f0-9]{32}", payload))
images: set[tuple[str, int]] = set()
for row in db.all("SELECT * FROM device_state"):
images.update(
(row["device_id"], row[key])
for key in ["previous_image_id", "base_image_id", "managed_image_id"]
if row[key]
)
images.update(
(row["device_id"], row["image_id"])
for row in db.all("SELECT device_id,image_id FROM overrides")
)
for device_id, image_id in images:
row = db.one(
"SELECT asset_id FROM image_sources WHERE device_id=? AND image_id=?",
(device_id, image_id),
)
if row:
ids.add(row["asset_id"])
return ids
def inspect(self):
rows = []
for row in self.service.db.all("SELECT * FROM assets ORDER BY created_at"):
path = self.service.assets.path(row["id"])
if path.exists():
rows.append((row, path, path.stat().st_size))
return rows
def stats(self):
rows = self.inspect()
protected = self.protected()
return {
"policy": self.config().model_dump(),
"total_bytes": sum(x[2] for x in rows),
"asset_count": len(rows),
"protected_count": sum(x[0]["id"] in protected for x in rows),
}
def sweep(self, force=False, dry_run=False):
if not force and time.monotonic() - self.last < 3600:
return {}
if not dry_run:
self.last = time.monotonic()
config = self.config()
if not config.enabled:
return {"deleted": 0, "bytes_freed": 0, "disabled": True}
with self.service.lock:
protected = self.protected()
rows = self.inspect()
total = sum(x[2] for x in rows)
removed = []
freed = 0
for row, path, size in rows:
# Manual uploaded photos are never removed by automatic retention.
if row["id"] in protected or row["kind"] not in ("generated", "cache"):
continue
days = (
config.cache_days
if row["kind"] == "cache"
else config.generated_days
)
expired = datetime.fromisoformat(row["created_at"]) < datetime.now(
UTC
) - timedelta(days=days)
if not expired and total <= config.max_bytes:
continue
removed.append(row["id"])
total -= size
freed += size
if not dry_run:
path.unlink(missing_ok=True)
for table, key in [
("asset_metadata", "asset_id"),
("asset_sources", "asset_id"),
("assets", "id"),
]:
self.service.db.execute(
f"DELETE FROM {table} WHERE {key}=?", (row["id"],)
)
result = {"deleted": len(removed), "bytes_freed": freed, "dry_run": dry_run}
if not dry_run:
self.service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES('local_retention_last',?)",
(encode({**result, "timestamp": now_iso()}),),
)
return result
+196
View File
@@ -0,0 +1,196 @@
"""Signed, bounded, durable event delivery; independent of frame writes."""
import hashlib
import hmac
import json
import secrets
import time
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from fastapi import Depends
from pydantic import Field
from pigway_frame_flow.core.auth import require
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.storage.database import encode, now_iso
class WebhookInput(StrictModel):
callback_url: str = Field(max_length=2048)
event_types: list[str] = Field(min_length=1, max_length=20)
enabled: bool = True
class Webhooks:
def __init__(self, service):
self.service = service
self.last = 0.0
service.db.conn.executescript("""CREATE TABLE IF NOT EXISTS webhook_deliveries (
subscription_id TEXT,event_id TEXT,status TEXT,attempts INTEGER DEFAULT 0,next_at TEXT,error_code TEXT,
PRIMARY KEY(subscription_id,event_id));""")
def tick(self):
if time.monotonic() - self.last < 60:
return
self.last = time.monotonic()
db = self.service.db
for sub in db.all("SELECT * FROM webhook_subscriptions WHERE enabled=1"):
types = json.loads(sub["event_types"])
for event in db.all(
"SELECT * FROM events e WHERE created_at>=? AND NOT EXISTS (SELECT 1 FROM webhook_deliveries d WHERE d.event_id=e.id AND d.subscription_id=?) ORDER BY created_at LIMIT 500",
(sub["created_at"], sub["id"]),
):
if event["event_type"] not in types:
continue
db.execute(
"INSERT OR IGNORE INTO webhook_deliveries VALUES(?,?,'QUEUED',0,?,NULL)",
(sub["id"], event["id"], now_iso()),
)
for row in db.all(
"SELECT d.*,s.callback_url FROM webhook_deliveries d JOIN webhook_subscriptions s ON s.id=d.subscription_id WHERE s.enabled=1 AND d.status IN ('QUEUED','RETRY') AND d.next_at<=? ORDER BY d.next_at LIMIT 2",
(now_iso(),),
):
event = db.one("SELECT * FROM events WHERE id=?", (row["event_id"],))
if not event:
continue
keyfile = self.service.settings.config_dir / (
"webhook-" + row["subscription_id"] + ".secret"
)
try:
key = keyfile.read_text().strip()
payload = encode(
{
"id": event["id"],
"type": event["event_type"],
"timestamp": event["created_at"],
"data": json.loads(event["payload"]),
}
).encode()
signature = hmac.new(key.encode(), payload, hashlib.sha256).hexdigest()
self.service.assets.fetch_url(
row["callback_url"],
method="POST",
body=payload,
headers={
"Content-Type": "application/json",
"X-Frame-Flow-Event-ID": event["id"],
"X-Frame-Flow-Signature": "sha256=" + signature,
},
limit=64 * 1024,
accepted_status=(200, 201, 202, 204),
)
status, error = "DELIVERED", None
except Exception:
status, error = (
("FAILED" if row["attempts"] >= 2 else "RETRY"),
"DELIVERY_FAILED",
)
db.execute(
"UPDATE webhook_deliveries SET status=?,attempts=attempts+1,next_at=?,error_code=? WHERE subscription_id=? AND event_id=?",
(
status,
(
datetime.now(UTC) + timedelta(seconds=60 * 2 ** row["attempts"])
).isoformat(),
error,
row["subscription_id"],
row["event_id"],
),
)
def install_webhooks(app, service):
worker = Webhooks(service)
service.webhooks = worker
db = service.db
@app.get(
"/api/v1/webhooks", tags=["Webhooks"], dependencies=[Depends(require("admin"))]
)
def list_hooks():
return [dict(row) for row in db.all("SELECT * FROM webhook_subscriptions")]
@app.post(
"/api/v1/webhooks",
status_code=201,
tags=["Webhooks"],
dependencies=[Depends(require("admin"))],
)
def add_hook(payload: WebhookInput):
from urllib.parse import urlsplit
parsed = urlsplit(payload.callback_url)
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.port not in (None, 443)
or parsed.hostname not in service.settings.image_url_hosts
):
raise FlowError(
E.INVALID_REQUEST, "Webhook requires an explicitly allowed HTTPS host"
)
allowed = {
"operation.completed",
"operation.failed",
"display.changed",
"device.online",
"device.offline",
"device.low_battery",
}
if set(payload.event_types) - allowed:
raise FlowError(E.INVALID_REQUEST, "Unsupported event type")
id = uuid4().hex
key = secrets.token_urlsafe(32)
import os
path = service.settings.config_dir / ("webhook-" + id + ".secret")
with os.fdopen(
os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w"
) as f:
f.write(key)
db.execute(
"INSERT INTO webhook_subscriptions VALUES(?,?,?,?,?,?)",
(
id,
"admin",
payload.callback_url,
encode(payload.event_types),
int(payload.enabled),
now_iso(),
),
)
service.settings.additional_secrets.append(key)
return {"id": id, "signing_secret": key}
@app.delete(
"/api/v1/webhooks/{id}",
status_code=204,
tags=["Webhooks"],
dependencies=[Depends(require("admin"))],
)
def delete_hook(id: str):
if not db.one("SELECT id FROM webhook_subscriptions WHERE id=?", (id,)):
raise FlowError(E.NOT_FOUND, "Webhook not found", 404)
db.execute("DELETE FROM webhook_subscriptions WHERE id=?", (id,))
(service.settings.config_dir / ("webhook-" + id + ".secret")).unlink(
missing_ok=True
)
@app.get(
"/api/v1/webhooks/{id}/deliveries",
tags=["Webhooks"],
dependencies=[Depends(require("admin"))],
)
def deliveries(id: str):
return [
dict(row)
for row in db.all(
"SELECT * FROM webhook_deliveries WHERE subscription_id=? ORDER BY next_at DESC LIMIT 100",
(id,),
)
]
@@ -95,14 +95,19 @@ def capabilities() -> tuple[OfficialCapability, ...]:
return tuple(
OfficialCapability(
method=method,
implemented=method in READ_OPERATIONS or method == "app.login",
implemented=method in READ_OPERATIONS
or method in {"app.login", "appFrame.setFrameCover"},
effect="read"
if method in READ_OPERATIONS
else "authentication"
if method == "app.login"
else "write"
if method == "appFrame.setFrameCover"
else "unverified",
evidence="live_verified"
if method in READ_OPERATIONS or method == "app.login"
else "mock_verified"
if method == "appFrame.setFrameCover"
else "apk_discovered",
)
for method in DISCOVERED_METHODS
@@ -17,8 +17,15 @@ class OfficialRPC:
def authorized(self) -> bool:
return bool(self._token)
def _call(self, method: str, params: dict) -> dict:
if method not in READ_OPERATIONS and method != "app.login":
def _call(self, method: str, params: dict, *, allow_write=False) -> dict:
if method == "appFrame.setFrameCover" and not allow_write:
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Use the explicit widget operation", 422
)
if method not in READ_OPERATIONS and method not in (
"app.login",
"appFrame.setFrameCover",
):
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Official operation is not enabled", 422
)
@@ -62,6 +69,10 @@ class OfficialRPC:
except FlowError:
raise
except (requests.RequestException, ValueError, AttributeError):
if method == "appFrame.setFrameCover":
raise FlowError(
E.WRITE_RESULT_UNKNOWN, "Official widget write result unknown", 409
) from None
raise FlowError(
E.CLOUD_UNAVAILABLE, "Official service query failed", 502
) from None
@@ -86,3 +97,10 @@ class OfficialRPC:
def widgets(self, robot_id: int) -> dict:
return self._call("appFrame.getFrameCovers", {"robotId": robot_id})
def set_widget(self, robot_id, cover_id, args):
return self._call(
"appFrame.setFrameCover",
{"robotId": robot_id, "coverId": cover_id, "args": args},
allow_write=True,
)
@@ -15,6 +15,7 @@ class WidgetReader:
self.rpc = OfficialRPC(token)
self._robot_id = robot_id
self.mock = mock
self.mock_selection = 188
self._cached = None
self._cached_at = 0.0
@@ -37,7 +38,8 @@ class WidgetReader:
source="mock",
configured=True,
catalog=catalog,
current=catalog[0],
current=next((x for x in catalog if x.id == self.mock_selection), None),
status="OK" if self.mock_selection else "NO_WIDGET",
current_verified=True,
)
if not self.rpc.authorized or self._robot_id is None:
+16 -2
View File
@@ -68,13 +68,26 @@ class PhotoSourceContent(StrictModel):
profile: Profile = Profile.color
class ProviderContent(StrictModel):
type: Literal["provider"]
provider_id: str = Field(pattern=r"^[a-z0-9-]{1,60}$")
template: str = Field(default="daily-dashboard", pattern=r"^[a-z0-9-]{1,60}$")
data: dict[str, Any] = Field(default_factory=dict)
profile: Profile = Profile.color
class FutureContent(StrictModel):
type: Literal["html", "markdown"]
data: str = ""
Content = Annotated[
ImageContent | URLContent | TemplateContent | PhotoSourceContent | FutureContent,
ImageContent
| URLContent
| TemplateContent
| PhotoSourceContent
| ProviderContent
| FutureContent,
Field(discriminator="type"),
]
@@ -141,6 +154,7 @@ class ErrorResponse(BaseModel):
class OperationResult(BaseModel):
cover_id: int | None = None
photo_attempts: list[dict[str, str]] = Field(default_factory=list)
image_id: int | None = None
asset_id: str | None = None
@@ -195,7 +209,7 @@ class CurrentContent(BaseModel):
class Device(BaseModel):
id: str
name: str
driver: Literal["kokonna"] = "kokonna"
driver: str = "kokonna"
status: Literal["online", "offline"]
model: str
model_code: str
+227
View File
@@ -0,0 +1,227 @@
"""Read-only OAuth-token drive readers. Credentials never enter catalog metadata."""
import json
import re
from pathlib import Path
from urllib.parse import urlencode, urlsplit
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.settings import secret
class CloudDrives:
def __init__(self, photos):
self.photos = photos
self.settings = photos.settings
def credentials(self, source_id, source):
raw = secret(source.credential_env) if source.credential_env else ""
path = self.settings.config_dir / ("source-" + source_id + ".secret")
if not raw and path.exists():
raw = path.read_text()
try:
value = json.loads(raw)
if (
not isinstance(value.get("access_token"), str)
or not value["access_token"]
):
raise ValueError()
for key in ["access_token", "refresh_token", "client_secret"]:
if value.get(key):
self.settings.additional_secrets.append(value[key])
return value
except (ValueError, TypeError, AttributeError):
raise FlowError(
E.CONFIG_REQUIRED,
"Authorize this cloud drive with a valid OAuth access token",
409,
) from None
def fetch(self, url, **kwargs):
# Only fixed provider API hosts and explicit download domains reach the network.
host = urlsplit(url).hostname
trusted = {"www.googleapis.com", "openapi.aliyundrive.com", "pan.baidu.com"}
if host in trusted and host not in self.settings.image_url_hosts:
self.settings.image_url_hosts.append(host)
return self.photos.assets.fetch_url(url, **kwargs)
def api(self, url, token, *, body=None, params=None):
headers = {"Authorization": "Bearer " + token}
if body is not None:
headers["Content-Type"] = "application/json"
try:
raw = self.fetch(
url + ("?" + urlencode(params) if params else ""),
method="POST" if body is not None else "GET",
headers=headers,
body=json.dumps(body).encode() if body is not None else None,
limit=2 * 1024 * 1024,
)
data = json.loads(raw)
if data.get("error") or data.get("code") or data.get("errno", 0):
raise ValueError()
return data
except Exception:
raise FlowError(
E.CLOUD_UNAVAILABLE,
"Cloud drive query failed; check authorization and permissions",
502,
) from None
def catalog(self, source_id, source):
credentials = self.credentials(source_id, source)
token = credentials["access_token"]
items: list[dict] = []
queue = [source.location]
visited = set()
page_count = 0
while queue and len(items) < source.catalog_limit:
folder = queue.pop(0)
if folder in visited:
continue
visited.add(folder)
cursor = None
offset = 0
while len(items) < source.catalog_limit:
page_count += 1
if page_count > 200:
raise FlowError(
E.CONFLICT,
"Drive query exceeds 200 pages; select a smaller folder",
409,
)
if source.kind == "google-drive":
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,200}", folder):
raise FlowError(
E.INVALID_REQUEST, "Invalid Google Drive folder ID"
)
page = self.api(
"https://www.googleapis.com/drive/v3/files",
token,
params={
"q": f"'{folder}' in parents and trashed = false",
"pageSize": 1000,
"fields": "nextPageToken,files(id,name,mimeType)",
**({"pageToken": cursor} if cursor else {}),
},
)
rows = page.get("files", [])
next_page = page.get("nextPageToken")
elif source.kind == "alipan":
drive = credentials.get("drive_id")
if not drive:
raise FlowError(
E.CONFIG_REQUIRED,
"AliPan credentials require drive_id",
409,
)
page = self.api(
"https://openapi.aliyundrive.com/adrive/v1.0/openFile/list",
token,
body={
"drive_id": drive,
"parent_file_id": folder,
"limit": 100,
**({"marker": cursor} if cursor else {}),
},
)
rows = page.get("items", [])
next_page = page.get("next_marker")
else:
page = self.api(
"https://pan.baidu.com/rest/2.0/xpan/file",
token,
params={
"method": "list",
"dir": folder,
"start": offset,
"limit": 1000,
"access_token": token,
},
)
rows = page.get("list", [])
next_page = str(offset + 1000) if len(rows) == 1000 else None
for row in rows:
name = row.get("name", row.get("server_filename", ""))
id = str(row.get("id", row.get("file_id", row.get("fs_id", ""))))
directory = (
row.get("mimeType") == "application/vnd.google-apps.folder"
or row.get("type") == "folder"
or row.get("isdir") == 1
)
if directory:
if source.recursive:
queue.append(row.get("path", id))
continue
if (
not id
or Path(name).suffix.lower() not in self.photos.extensions
):
continue
items.append(
{
"id": id,
"name": name,
"caption": None,
"provider_preview": False,
}
)
if len(items) >= source.catalog_limit:
break
if not next_page:
break
if next_page == cursor:
raise FlowError(
E.CLOUD_UNAVAILABLE, "Drive pagination did not advance", 502
)
cursor = next_page
offset += 1000
return items
def read(self, source_id, source, item):
credentials = self.credentials(source_id, source)
token = credentials["access_token"]
id = item["id"]
if source.kind == "google-drive":
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,200}", id):
raise FlowError(E.INVALID_REQUEST, "Invalid file ID")
return self.fetch(
"https://www.googleapis.com/drive/v3/files/" + id + "?alt=media",
headers={"Authorization": "Bearer " + token},
)
suffixes: tuple[str, ...]
if source.kind == "alipan":
result = self.api(
"https://openapi.aliyundrive.com/adrive/v1.0/openFile/getDownloadUrl",
token,
body={"drive_id": credentials["drive_id"], "file_id": id},
)
url = result.get("url", "")
suffixes = ("aliyuncs.com", "alipan.com", "aliyundrive.com")
else:
result = self.api(
"https://pan.baidu.com/rest/2.0/xpan/multimedia",
token,
params={
"method": "filemetas",
"fsids": json.dumps([int(id)]),
"dlink": 1,
"access_token": token,
},
)
url = result.get("list", [{}])[0].get("dlink", "")
suffixes = ("baidupcs.com", "baidu.com")
host = urlsplit(url).hostname or ""
if not any(
host == suffix or host.endswith("." + suffix) for suffix in suffixes
):
raise FlowError(E.FORBIDDEN, "Unexpected drive download host", 403)
if host not in self.settings.image_url_hosts:
self.settings.image_url_hosts.append(host)
if source.kind == "baidu":
url += ("&" if "?" in url else "?") + urlencode({"access_token": token})
return self.fetch(
url,
headers={"User-Agent": "pan.baidu.com"} if source.kind == "baidu" else {},
)
+196
View File
@@ -0,0 +1,196 @@
"""Optional read-only calendar and state providers, no vendor dependency in templates."""
import json
import re
import time
from datetime import UTC, datetime, timedelta
from urllib.parse import quote, urlsplit
import requests
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.storage.database import encode
class ExternalProviderConfig(StrictModel):
kind: str = Field(pattern=r"^(google-calendar|ics|home-assistant|http-json)$")
name: str = Field(min_length=1, max_length=120)
endpoint: str = Field(max_length=2048)
enabled: bool = True
allow_insecure_lan: bool = False
entities: list[str] = Field(default_factory=list, max_length=100)
class ExternalProviders:
def __init__(self, service):
self.service = service
self.cache = {}
def list(self):
return [
dict(json.loads(row["payload"]), id=row["key"][9:])
for row in self.service.db.all(
"SELECT key,payload FROM app_config WHERE key LIKE 'provider:%'"
)
]
def config(self, id):
row = self.service.db.one(
"SELECT payload FROM app_config WHERE key=?", ("provider:" + id,)
)
if not row:
raise FlowError(E.NOT_FOUND, "Provider not found", 404)
return ExternalProviderConfig.model_validate_json(row["payload"])
def save(self, id, config):
if not re.fullmatch(r"[a-z0-9-]{1,60}", id):
raise FlowError(E.INVALID_REQUEST, "Invalid provider ID")
if config.kind != "google-calendar":
parsed = urlsplit(config.endpoint)
if (
parsed.scheme not in ("http", "https")
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.fragment
):
raise FlowError(
E.INVALID_REQUEST,
"Provider needs an HTTP(S) endpoint without credentials",
)
if not config.allow_insecure_lan and (
parsed.scheme != "https"
or parsed.hostname not in self.service.settings.image_url_hosts
):
raise FlowError(
E.FORBIDDEN,
"Provider host must be allowed; LAN access requires explicit opt-in",
403,
)
self.service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
("provider:" + id, encode(config.model_dump())),
)
self.cache.pop(id, None)
return dict(config.model_dump(), id=id)
def get(self, id):
config = self.config(id)
if not config.enabled:
raise FlowError(E.CONFLICT, "Provider disabled", 409)
if id in self.cache and time.monotonic() < self.cache[id][0]:
return self.cache[id][1]
secretfile = self.service.settings.config_dir / ("provider-" + id + ".secret")
token = secretfile.read_text().strip() if secretfile.exists() else ""
if token:
self.service.settings.additional_secrets.append(token)
headers = {"Authorization": "Bearer " + token} if token else {}
if config.kind == "google-calendar":
if not token:
raise FlowError(
E.CONFIG_REQUIRED,
"Google Calendar requires an authorized OAuth token",
409,
)
start = datetime.now(UTC)
from urllib.parse import urlencode
url = (
"https://www.googleapis.com/calendar/v3/calendars/"
+ quote(config.endpoint, safe="")
+ "/events?"
+ urlencode(
{
"timeMin": start.isoformat(),
"timeMax": (start + timedelta(days=7)).isoformat(),
"singleEvents": "true",
"orderBy": "startTime",
"maxResults": 100,
}
)
)
if "www.googleapis.com" not in self.service.settings.image_url_hosts:
self.service.settings.image_url_hosts.append("www.googleapis.com")
else:
url = config.endpoint.rstrip("/") + (
"/api/states" if config.kind == "home-assistant" else ""
)
try:
if config.allow_insecure_lan:
# Endpoint is an explicitly administrator-approved, fixed LAN destination.
with requests.get(
url, headers=headers, timeout=10, allow_redirects=False, stream=True
) as response:
response.raise_for_status()
parts = []
total = 0
for part in response.iter_content(65536):
total += len(part)
if total > 2 * 1024 * 1024:
raise ValueError()
parts.append(part)
raw = b"".join(parts)
else:
raw = self.service.assets.fetch_url(
url, headers=headers, limit=2 * 1024 * 1024
)
result: dict
if config.kind == "ics":
events = []
text = (
raw.decode("utf-8-sig").replace("\r\n ", "").replace("\r\n", "\n")
)
for block in text.split("BEGIN:VEVENT")[1:]:
block = block.split("END:VEVENT")[0]
values = {
line.split(":", 1)[0].split(";")[0]: line.split(":", 1)[1]
for line in block.splitlines()
if ":" in line
}
if "RRULE" in values:
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Recurring ICS events require a calendar provider with expanded instances",
422,
)
if values.get("SUMMARY"):
events.append(values["SUMMARY"].replace("\\n", " "))
result = {"events": events[:100]}
elif config.kind == "google-calendar":
result = {
"events": [
{
"title": x.get("summary", "Untitled"),
"start": x.get("start", {}).get(
"dateTime", x.get("start", {}).get("date")
),
}
for x in json.loads(raw).get("items", [])
]
}
elif config.kind == "home-assistant":
result = {
"events": [
x["entity_id"] + ": " + str(x["state"])
for x in json.loads(raw)
if not config.entities or x["entity_id"] in config.entities
]
}
else:
result = json.loads(raw)
if not isinstance(result, dict):
raise ValueError()
result = self.service.settings.sanitize(result)
self.cache[id] = (time.monotonic() + 300, result)
return result
except FlowError:
raise
except Exception:
raise FlowError(
E.CLOUD_UNAVAILABLE,
"External provider unavailable; check connection and credentials",
502,
) from None
+30 -3
View File
@@ -27,7 +27,7 @@ from pigway_frame_flow.storage.database import encode
class PhotoSourceInput(StrictModel):
name: str = Field(min_length=1, max_length=120)
kind: Literal["local", "webdav"]
kind: Literal["local", "webdav", "google-drive", "alipan", "baidu"]
location: str = Field(min_length=1, max_length=2048)
recursive: bool = True
enabled: bool = True
@@ -36,6 +36,11 @@ class PhotoSourceInput(StrictModel):
organized_only: bool = False
folder_caption: bool = False
orientation: Literal["auto", "landscape", "portrait"] | None = None
minimum_sharpness: float = Field(default=0, ge=0, le=10000)
minimum_brightness: float = Field(default=0, ge=0, le=255)
maximum_brightness: float = Field(default=255, ge=0, le=255)
avoid_duplicates: bool = False
people_space: Literal["shared", "personal"] = "shared"
people_filter: bool = False
person_ids: list[int] = Field(default_factory=list, max_length=50)
person_match: Literal["any", "all"] = "any"
@@ -102,6 +107,15 @@ class PhotoSources:
return PhotoSourceInput.model_validate_json(row["payload"])
def save(self, source_id, source):
if source.minimum_brightness > source.maximum_brightness:
raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed")
if source.kind != "local" and (
source.organized_only or source.folder_caption or source.synology_previews
):
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Folder captions and Synology previews require a local photo source",
)
if source.orientation and source.kind != "local":
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
@@ -126,7 +140,7 @@ class PhotoSources:
raise FlowError(
E.INVALID_REQUEST, "Local sources do not use credentials"
)
else:
elif source.kind == "webdav":
source = source.model_copy(
update={
"recursive": False,
@@ -266,7 +280,12 @@ class PhotoSources:
):
return cached[1]
allowed = (
self.synology.paths(source.person_ids, source.person_match, refresh=refresh)
self.synology.paths(
source.person_ids,
source.person_match,
refresh=refresh,
space=source.people_space,
)
if source.people_filter
else None
)
@@ -387,6 +406,10 @@ class PhotoSources:
break
if len(items) >= source.catalog_limit:
break
elif source.kind in ("google-drive", "alipan", "baidu"):
from pigway_frame_flow.providers.cloud_drives import CloudDrives
items = CloudDrives(self).catalog(source_id, source)
else:
data = self.assets.fetch_url(
source.location,
@@ -503,6 +526,10 @@ class PhotoSources:
os.close(parent_fd)
except OSError:
raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None
elif source.kind in ("google-drive", "alipan", "baidu"):
from pigway_frame_flow.providers.cloud_drives import CloudDrives
data = CloudDrives(self).read(source_id, source, item)
else:
data = self.assets.fetch_url(item["id"], headers=self.headers(source))
if len(data) > self.settings.max_bytes:
+15 -7
View File
@@ -83,6 +83,9 @@ class SynologyPhotos:
("SYNO.API.Auth", "login"),
("SYNO.API.Auth", "logout"),
("SYNO.FotoTeam.Browse.Person", "list"),
("SYNO.Foto.Browse.Person", "list"),
("SYNO.Foto.Browse.Item", "list"),
("SYNO.Foto.Browse.Folder", "get"),
("SYNO.FotoTeam.Browse.Item", "list"),
("SYNO.FotoTeam.Browse.Folder", "get"),
}:
@@ -193,9 +196,10 @@ class SynologyPhotos:
return rows
raise FlowError(E.CONFLICT, "Photos catalog exceeds query limit", 409)
def people(self):
def people(self, space="shared"):
prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto"
with self.lock:
rows = self.pages("SYNO.FotoTeam.Browse.Person", 3)
rows = self.pages(prefix + ".Browse.Person", 3)
return [
{
"id": r["id"],
@@ -206,13 +210,17 @@ class SynologyPhotos:
for r in rows
]
def paths(self, person_ids, mode, refresh=False):
key = (tuple(sorted(person_ids)), mode)
def paths(self, person_ids, mode, refresh=False, *, space="shared"):
prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto"
key = (tuple(sorted(person_ids)), mode, space)
with self.lock:
cached = self.cache.get(key)
if cached and not refresh and time.monotonic() - cached[0] < 900:
return cached[1]
known = {p["id"] for p in self.people()}
known = {
p["id"]
for p in (self.people() if space == "shared" else self.people(space))
}
ids = person_ids or sorted(known)
if not set(ids) <= known:
raise FlowError(E.INVALID_REQUEST, "Selected person no longer exists")
@@ -221,7 +229,7 @@ class SynologyPhotos:
for person in ids:
paths = set()
for row in self.pages(
"SYNO.FotoTeam.Browse.Item",
prefix + ".Browse.Item",
7,
person_id=person,
additional=json.dumps(["folder"]),
@@ -241,7 +249,7 @@ class SynologyPhotos:
)
if folder_id not in folders:
folders[folder_id] = self.request(
"SYNO.FotoTeam.Browse.Folder",
prefix + ".Browse.Folder",
"get",
2,
id=folder_id,
+9 -1
View File
@@ -153,7 +153,14 @@ class Assets:
return data
def fetch_url(
self, url, *, method="GET", headers=None, limit=None, accepted_status=(200,)
self,
url,
*,
method="GET",
headers=None,
body=None,
limit=None,
accepted_status=(200,),
):
limit = limit or self.settings.max_bytes
parsed = urlsplit(url)
@@ -197,6 +204,7 @@ class Assets:
method,
(parsed.path or "/") + ("?" + parsed.query if parsed.query else ""),
headers=headers or {},
body=body,
)
response = connection.getresponse()
if response.status not in accepted_status:
+4
View File
@@ -106,6 +106,10 @@ class TemplateRenderer:
return values
def render(self, template_id, data, profile="color"):
if template_id in ("collage", "poster"):
from pigway_frame_flow.extensions.layout import LayoutStudio
return LayoutStudio(self).render(data, profile)
spec = self.info(template_id)
values = self.resolve(template_id, data)
current = datetime.now(ZoneInfo(self.settings.timezone))
+20
View File
@@ -54,6 +54,7 @@ class Scheduler:
self.service, self.db = service, service.db
self.stop_event = threading.Event()
self.thread = None
self.webhook_thread = None
self.tick_lock = threading.Lock()
def jobs(self):
@@ -248,6 +249,21 @@ class Scheduler:
target=self.run, name="frame-flow-worker", daemon=True
)
self.thread.start()
if hasattr(self.service, "webhooks"):
self.webhook_thread = threading.Thread(
target=self.run_webhooks, name="frame-flow-webhooks", daemon=True
)
self.webhook_thread.start()
def run_webhooks(self):
while not self.stop_event.is_set():
try:
self.service.webhooks.tick()
except Exception:
logging.getLogger("frame-flow").error(
"Webhook cycle failed; details suppressed"
)
self.stop_event.wait(60)
def run(self):
while not self.stop_event.is_set():
@@ -255,6 +271,8 @@ class Scheduler:
self.tick()
self.service.expire()
self.service.cloud_library.sweep()
if hasattr(self.service, "local_retention"):
self.service.local_retention.sweep()
processed = self.service.process_one()
self.service.sync()
except Exception:
@@ -268,6 +286,8 @@ class Scheduler:
self.stop_event.set()
if self.thread:
self.thread.join(timeout=45)
if self.webhook_thread:
self.webhook_thread.join(timeout=25)
def healthy(self):
return bool(
+11
View File
@@ -0,0 +1,11 @@
{
"id": "collage",
"name": "Multi-photo collage / 多图拼版",
"width": 1200,
"height": 1600,
"fields": [
"layout"
],
"defaults": {},
"blocks": []
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "poster",
"name": "Poster / 海报",
"width": 1200,
"height": 1600,
"fields": [
"layout"
],
"defaults": {},
"blocks": []
}
+4 -2
View File
@@ -3,7 +3,7 @@ const $=s=>document.querySelector(s);
const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0};
function forgetLogin(){localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
async function loadConnection(){state.settings=await api('/api/v1/settings');try{state.devices=await api('/api/v1/devices');}catch(error){state.devices=[];toast(error.message);}state.templates=await api('/api/v1/templates');state.device=state.devices[0]?.id||state.device;}
const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',automation:'◷',playlists:'▤',history:'↺',settings:'⚙'};
const icons={studio:'▦',connections:'↔',dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',automation:'◷',playlists:'▤',history:'↺',settings:'⚙'};
const t=k=>window.MESSAGES[state.lang][k]||k;
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt=v=>v?new Date(v).toLocaleString(state.lang==='zh'?'zh-CN':'en-GB',{timeZone:'Asia/Shanghai'}):'—';
@@ -77,11 +77,13 @@ async function templateEditor(selected){
}
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
const views={
studio:()=>layoutStudioPage(),
connections:()=>connectionsPage(),
'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); },
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"><div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
async gallery(epoch){let items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs"><button id="cloud-tab" class="${state.tab==='cloud'?'active':''}">${t('cloud')}</button><button id="local-tab" class="${state.tab==='local'?'active':''}">${t('local')}</button></div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}</div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${items.length?`<div class="gallery">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`:`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});},
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate();};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
async automation(epoch){const jobs=await api('/api/v1/jobs');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">Asia/Shanghai · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${jobs.map((j,i)=>`<tr><td><strong>${esc(j.name)}</strong><br><small>${esc(j.device_id)}</small></td><td>${t(j.schedule.type)}<br>${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}</td><td><span class="badge">${t(j.enabled?'enabled':'disabled')}</span></td><td>${fmt(j.next_run)}</td><td>${fmt(j.last_run)}</td><td><code>${esc(j.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${i}">${t('edit')}</button><button data-job-toggle="${i}">${t(j.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${i}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-job',()=>jobEditor());document.querySelectorAll('[data-job-edit]').forEach(b=>b.onclick=()=>jobEditor(jobs[b.dataset.jobEdit]));document.querySelectorAll('[data-job-toggle]').forEach(b=>b.onclick=busy(async()=>{const j=jobs[b.dataset.jobToggle];await api('/api/v1/jobs/'+j.id,{method:'PUT',body:jobPayload(j,{enabled:!j.enabled})});await navigate();}));document.querySelectorAll('[data-job-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/jobs/'+jobs[b.dataset.jobDelete].id,{method:'DELETE'});await navigate();}));},
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(o.action)}<br><small>${esc(o.device_id)}</small></td><td><span class="badge ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')}</td><td>${esc(o.client_id)}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
+52
View File
@@ -0,0 +1,52 @@
'use strict';
const tr=(zh,en)=>state.lang==='zh'?zh:en;
async function layoutStudioPage(){
const assets=await api('/api/v1/assets?source=uploaded&limit=200');let selected=new Set(),plan=null;
$('#main').innerHTML=`<div class="notice">${tr('一次选择 1–12 张图片,生成多图拼版或海报。AI 只接收文字与尺寸,不接收照片。预览不会上传到画框。','Choose 1–12 photos for a collage or poster. AI receives text and dimensions, never photos. Preview does not upload to the frame.')}</div><div class="columns"><section class="card"><h3>${tr('选择照片','Choose photos')}</h3><input id="studio-files" type="file" multiple accept="image/jpeg,image/png,image/webp"><p class="hint">${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}</p><div id="studio-assets" class="studio-assets">${assets.map(a=>`<label class="check-row"><input type="checkbox" value="${a.asset_id}">${esc(a.name)}</label>`).join('')}</div><div class="field"><label>${tr('标题','Title')}</label><input id="studio-title" maxlength="100"></div><div class="field"><label>${tr('底部文字','Footer text')}</label><textarea id="studio-message" maxlength="500"></textarea></div><div class="form-grid"><div class="field"><label>${tr('布局','Layout')}</label><select id="studio-style"><option value="grid">${tr('均匀拼版','Grid')}</option><option value="hero">${tr('一张主图+多张附图','Hero with supporting photos')}</option><option value="poster">${tr('海报','Poster')}</option></select></div><div class="field"><label>${tr('方向','Orientation')}</label><select id="studio-orientation"><option value="portrait">${tr('竖向','Portrait')}</option><option value="landscape">${tr('横向','Landscape')}</option></select></div></div><div class="field"><label>${t('profile')}</label><select id="studio-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><label class="check-row"><input id="studio-ai" type="checkbox">${tr('使用 AI 排版(先在连接中心配置)','Use AI layout (configure in Connections first)')}</label><div class="field"><label>${tr('AI 排版要求','AI layout instructions')}</label><textarea id="studio-prompt" maxlength="1500" placeholder="${tr('例如:第一张作为主图,其余大小错落','Example: feature the first photo with smaller supporting photos')}"></textarea></div><div class="actions"><button id="studio-plan" class="primary">${tr('规划布局','Plan layout')}</button><button id="studio-render" disabled>${t('render')}</button></div><p class="hint">${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}</p><div id="studio-region-editor"></div></section><section class="card"><h3>${t('preview')}</h3><div id="studio-layout"></div><div id="studio-preview" class="empty">${tr('先规划布局,再生成最终图片','Plan the layout, then render the final image')}</div><div class="actions"><button id="studio-display" disabled>${t('display')}</button><button id="studio-download" disabled>${t('download')}</button></div></section></div>`;
let rendered=null;
const invalidate=()=>{rendered=null;$('#studio-display').disabled=true;$('#studio-download').disabled=true;};
$('#studio-assets').onchange=()=>{selected=new Set([...$('#studio-assets input:checked')].map(x=>x.value));plan=null;$('#studio-render').disabled=true;invalidate();};
$('#studio-files').onchange=busy(async()=>{const files=[...$('#studio-files').files];if(!files.length)return;const body=new FormData();files.forEach(f=>body.append('files',f));const uploaded=await api('/api/v1/layout/images',{method:'POST',body});for(const a of uploaded){const label=document.createElement('label');label.className='check-row';label.innerHTML=`<input type="checkbox" checked value="${a.asset_id}">${esc(a.name)}`;$('#studio-assets').append(label);}$('#studio-assets').onchange();toast(t('done'));});
async function showPlan(){
const box=$('#studio-layout');box.innerHTML='';box.className='studio-canvas';box.style.aspectRatio=plan.width+'/'+plan.height;
$('#studio-region-editor').innerHTML=plan.regions.map((r,i)=>`<div class="field"><label>${tr('图片区域','Photo region')} ${i+1}</label><div class="region-fields">${['x','y','width','height'].map(k=>`<label>${k}<input data-region="${i}" data-key="${k}" type="number" value="${r[k]}" min="0"></label>`).join('')}</div></div>`).join('');
const update=(el,r)=>{el.style.left=(r.x/plan.width*100)+'%';el.style.top=(r.y/plan.height*100)+'%';el.style.width=(r.width/plan.width*100)+'%';el.style.height=(r.height/plan.height*100)+'%';};
for(const [i,r] of plan.regions.entries()){
const img=document.createElement('img');img.alt=tr('拖动图片区域','Drag photo region')+' '+(i+1);img.draggable=false;update(img,r);box.append(img);await blobImage(img,'/api/v1/assets/'+r.asset_id);
img.onpointerdown=e=>{e.preventDefault();img.setPointerCapture(e.pointerId);const start={x:e.clientX,y:e.clientY,rx:r.x,ry:r.y};img.onpointermove=ev=>{r.x=Math.max(0,Math.min(plan.width-r.width,Math.round(start.rx+(ev.clientX-start.x)*plan.width/box.clientWidth)));r.y=Math.max(150,Math.min(plan.height-120-r.height,Math.round(start.ry+(ev.clientY-start.y)*plan.height/box.clientHeight)));update(img,r);for(const el of document.querySelectorAll(`[data-region="${i}"]`))el.value=r[el.dataset.key];invalidate();};img.onpointerup=()=>{img.onpointermove=null;};};
}
document.querySelectorAll('[data-region]').forEach(el=>el.oninput=()=>{const r=plan.regions[Number(el.dataset.region)];r[el.dataset.key]=Number(el.value);update(box.children[Number(el.dataset.region)],r);invalidate();});
}
for(const id of ['studio-title','studio-message','studio-style','studio-orientation','studio-ai','studio-prompt','studio-profile'])$('#'+id).oninput=()=>{plan=null;$('#studio-render').disabled=true;invalidate();};
bind('studio-plan',async()=>{plan=await api('/api/v1/layout/plan',{method:'POST',body:{asset_ids:[...selected],title:$('#studio-title').value,message:$('#studio-message').value,orientation:$('#studio-orientation').value,style:$('#studio-style').value,use_ai:$('#studio-ai').checked,prompt:$('#studio-prompt').value}});invalidate();await showPlan();$('#studio-render').disabled=false;});
bind('studio-render',async()=>{if(!plan)return;const accepted=await api('/api/v1/render',{method:'POST',body:{type:'template',template:$('#studio-style').value==='poster'?'poster':'collage',data:{layout:plan},profile:$('#studio-profile').value}});const op=await waitOperation(accepted.operation_id);rendered=op.result;$('#studio-preview').className='';$('#studio-preview').innerHTML='<img class="preview" id="studio-result" alt="Preview">';await blobImage($('#studio-result'),rendered.url);$('#studio-display').disabled=!liveWriteAllowed();$('#studio-download').disabled=false;});
bind('studio-display',()=>displayDialog({type:'image',asset_id:rendered.asset_id}));bind('studio-download',()=>downloadImage(rendered.url,'frame-flow-layout.jpg'));
}
async function connectionsPage(){
$('#main').innerHTML=`<div class="notice">${tr('可选连接不会影响 NAS 照片和现有设备。账号密钥保存在受限配置文件中,外部客户端密钥仅显示一次。','Optional connections do not affect NAS photos or existing devices. Provider keys use restricted config files; client tokens are shown once.')}</div>`;
await aiSettingsPanel();await storageSettingsPanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();
}
async function aiSettingsPanel(){
const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}</h3><p class="hint">${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}</p><form id="ai-settings-form"><div class="field"><label>Endpoint</label><input id="ai-endpoint" type="url" value="${esc(config.endpoint)}" placeholder="https://…/v1/chat/completions"></div><div class="field"><label>Model</label><input id="ai-model" value="${esc(config.model)}"></div><div class="field"><label>${tr('API Key(留空保留已有密钥)','API key (blank keeps existing)')}</label><input id="ai-key" type="password" autocomplete="off"><p class="hint">${t('keyConfigured')}: ${t(config.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));});
}
async function storageSettingsPanel(){
const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('本地图库保留策略','Local storage retention')}</h3><p>${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}</p><p class="hint">${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}</p><form id="storage-policy"><label class="check-row"><input id="storage-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="form-grid"><div class="field"><label>${tr('生成图保留天数','Generated image retention days')}</label><input id="storage-days" type="number" min="1" value="${p.generated_days}"></div><div class="field"><label>${tr('缓存保留天数','Cache retention days')}</label><input id="storage-cache-days" type="number" min="1" value="${p.cache_days}"></div><div class="field"><label>${tr('容量目标 MiB','Target size MiB')}</label><input id="storage-size" type="number" min="1" value="${p.max_bytes/1024/1024}"></div></div><div class="actions"><button type="submit">${t('save')}</button><button id="storage-preview" type="button">${tr('预估清理','Preview cleanup')}</button></div><p id="storage-estimate"></p></form>`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';});
}
async function clientsPanel(){
const clients=await api('/api/v1/clients');const scopes=['devices:read','images:read','display:write','templates:read','render:write','jobs:read','jobs:write','history:read'];const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('外部客户端权限','External client permissions')}</h3><p class="hint">${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}</p>${clients.map(c=>`<p>${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`<button data-revoke="${esc(c.client_id)}">${tr('撤销','Revoke')}</button>`:''}</p>`).join('')}<form id="client-form"><div class="field"><label>Client ID</label><input id="client-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label>${t('name')}</label><input id="client-name" required></div>${scopes.map(s=>`<label class="check-row"><input type="checkbox" name="client-scope" value="${s}" ${['devices:read','display:write','templates:read','render:write'].includes(s)?'checked':''}>${s}</label>`).join('')}<button type="submit">${tr('创建客户端','Create client')}</button><div id="client-secret" class="notice" hidden></div></form>`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;});
}
async function webhooksPanel(){
const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>Webhook</h3><p class="hint">${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}</p>${hooks.map(h=>`<p>${esc(h.callback_url)} <button data-hook-delete="${h.id}">${t('delete')}</button></p>`).join('')}<form id="hook-form"><div class="field"><label>Callback URL</label><input id="hook-url" type="url" required></div><div class="field"><label>${tr('事件(逗号分隔)','Events (comma separated)')}</label><input id="hook-events" value="operation.completed,operation.failed"></div><button type="submit">${t('save')}</button><p id="hook-secret"></p></form>`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;});
}
async function externalProvidersPanel(){
const providers=await api('/api/v1/providers/external');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('日历、家庭状态与监控','Calendars, home state and monitoring')}</h3><p class="hint">${tr('Google Calendar 使用 OAuth Token;家庭日历可用 ICS。Home Assistant 和 NAS/树莓派/服务器监控可以通过只读 JSON 接口提供数据,不访问外部数据库。LAN 访问必须明确开启。','Google Calendar uses an OAuth token; family calendars can use ICS. Home Assistant and NAS/Pi/server monitoring supply read-only JSON data without database access. LAN access requires explicit opt-in.')}</p>${providers.map(p=>`<p>${esc(p.name)} · ${esc(p.kind)} <button data-provider-read="${p.id}">${tr('读取','Read')}</button><button data-provider-job="${p.id}">${tr('创建展示任务','Create display job')}</button></p>`).join('')}<form id="provider-form"><div class="field"><label>ID</label><input id="provider-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label>${t('name')}</label><input id="provider-name" required></div><div class="field"><label>${tr('类型','Type')}</label><select id="provider-kind"><option value="google-calendar">Google Calendar</option><option value="ics">ICS · ${tr('家庭日历','Family calendar')}</option><option value="home-assistant">Home Assistant</option><option value="http-json">HTTP JSON · NAS / Pi / Server</option></select></div><div class="field"><label>${tr('地址(Google 日历填写 Calendar ID)','URL (Calendar ID for Google)')}</label><input id="provider-url" required></div><div class="field"><label>${tr('Token(可选)','Token (optional)')}</label><input id="provider-key" type="password" autocomplete="off"></div><label class="check-row"><input id="provider-lan" type="checkbox">${tr('允许访问指定 LAN 地址','Allow access to this LAN address')}</label><button type="submit">${t('save')}</button></form><pre id="provider-result"></pre>`;$('#main').append(card);$('#provider-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#provider-id').value;await api('/api/v1/providers/external/'+id,{method:'PUT',body:{kind:$('#provider-kind').value,name:$('#provider-name').value,endpoint:$('#provider-url').value,allow_insecure_lan:$('#provider-lan').checked}});const key=$('#provider-key').value;$('#provider-key').value='';if(key)await api('/api/v1/providers/external/'+id+'/key',{method:'PUT',body:{key}});await navigate();});document.querySelectorAll('[data-provider-read]').forEach(b=>b.onclick=busy(async()=>{$('#provider-result').textContent=JSON.stringify(await api('/api/v1/providers/external/'+b.dataset.providerRead+'/data'),null,2);}));document.querySelectorAll('[data-provider-job]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:tr('外部信息板','External dashboard'),enabled:false,schedule:{type:'daily',time:'07:00'},source:{type:'provider',provider_id:b.dataset.providerJob,template:'daily-dashboard',data:{title:tr('家庭信息','Home information')}},display_duration:0,restore_previous:false,priority:50}});await navigate('automation');}));
}
async function officialWidgetControls(info,data){
const panel=document.createElement('section');panel.className='card';panel.innerHTML=`<h3>${tr('订阅管理','Subscription management')}</h3><p class="hint">${tr('这与本地模板独立。请选择官方目录中的小组件,填写其参数后再确认。取消订阅不会自动恢复之前的照片。','This is separate from local templates. Choose a widget from the official catalog and fill its parameters before confirming. Cancelling does not automatically restore a previous photo.')}</p><div class="field"><label>${tr('官方小组件','Official widget')}</label><select id="official-widget-choice">${data.catalog.map(w=>`<option value="${w.id}">${esc(w.name)}</option>`).join('')}</select></div><div id="official-widget-args"></div><div class="actions"><button id="official-widget-use" class="primary">${tr('确认使用','Apply widget')}</button><button id="official-widget-cancel">${tr('取消订阅','Cancel subscription')}</button></div>`;info.append(panel);const disabled=!data.configured||!liveWriteAllowed();$('#official-widget-use').disabled=disabled;$('#official-widget-cancel').disabled=disabled;
const fields=()=>{const w=data.catalog.find(w=>w.id===Number($('#official-widget-choice').value));$('#official-widget-args').innerHTML=(w?.arg_keys||[]).map(k=>`<div class="field"><label>${esc(k)}</label><input data-widget-arg="${esc(k)}" maxlength="2000"></div>`).join('');};if(data.current)$('#official-widget-choice').value=data.current.id;fields();$('#official-widget-choice').onchange=fields;
const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));}
}
function addPhotoQualityFields(source){
const box=document.createElement('section');box.innerHTML=`<h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}</p><div class="field"><label>${tr('人物空间','People space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label>${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label>${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label>${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label><div class="field"><label>${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></textarea></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
}
+6 -6
View File
@@ -84,9 +84,9 @@ async function officialContentInfo(){
const data=await api('/api/v1/devices/'+state.device+'/widgets');
const label=data.source==='live'?(zh?'实时目录':'Live catalog'):data.source==='mock'?'Mock':(zh?'历史目录 · '+data.observed_at:'Catalog snapshot · '+data.observed_at);
const currentLabel=data.status==='DEVICE_REQUIRED'?(zh?'官方账号已连接,请先关联画框':'Official account connected; link a frame first'):!data.configured?(zh?'尚未授权官方账号,当前状态不可读取。':'Official account not authorized; current state is unavailable.'):data.status==='NO_WIDGET'?(zh?'未使用官方小组件':'No official widget selected'):data.current_verified&&data.current?((zh?'当前小组件:':'Current widget: ')+data.current.name):(zh?'当前小组件尚未核实':'Current widget is unverified');
info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}${!data.configured?(zh?' · 需官方账号授权':' · Official account required'):''}</summary><p>${esc(label)}</p><p class="notice">${esc(currentLabel)}</p><p class="notice widget-auth-notice">${zh?'如需使用官方小组件,需要另外授权 KoKonna 官方账号登录信息。现有 API Key 接入继续用于设备状态、图片和图库,不受影响。':'Official widgets require separate KoKonna account authorization. Your existing API Key continues to support device status, images and gallery.'}</p><p>${zh?'此入口只读,不会切换或取消官方订阅。':'Read only. This does not change official subscriptions.'}</p><input type="search" aria-label="${zh?'搜索小组件':'Search widgets'}" placeholder="${zh?'搜索名称或分类':'Search name or category'}"><div class="widget-list"></div>`;
info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}${!data.configured?(zh?' · 需官方账号授权':' · Official account required'):''}</summary><p>${esc(label)}</p><p class="notice">${esc(currentLabel)}</p><p class="notice widget-auth-notice">${zh?'如需使用官方小组件,需要另外授权 KoKonna 官方账号登录信息。现有 API Key 接入继续用于设备状态、图片和图库,不受影响。':'Official widgets require separate KoKonna account authorization. Your existing API Key continues to support device status, images and gallery.'}</p><p>${zh?'使用或取消订阅会改变画框内容;发送后不自动重试,结果在历史记录中核对。':'Selecting or cancelling changes frame content. Writes are never automatically retried; check History.'}</p><input type="search" aria-label="${zh?'搜索小组件':'Search widgets'}" placeholder="${zh?'搜索名称或分类':'Search name or category'}"><div class="widget-list"></div>`;
const render=()=>{const query=info.querySelector('input').value.toLowerCase();info.querySelector('.widget-list').innerHTML=data.catalog.filter(x=>(x.name+' '+x.tags.join(' ')).toLowerCase().includes(query)).map(x=>`<article class="card"><strong>${esc(x.name)}</strong><p>${esc(x.tags.join(' · '))}</p><small>${esc(x.arg_keys.join(' · '))}</small></article>`).join('');};
info.querySelector('input').oninput=render;render();officialConnectionPanel(info);
info.querySelector('input').oninput=render;render();officialConnectionPanel(info);await officialWidgetControls(info,data);
}catch(e){info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'}</summary><p class="notice widget-auth-notice">${esc(e.message)}</p>`;officialConnectionPanel(info);}
}
@@ -113,7 +113,7 @@ async function photosPeoplePicker(selected){
target.innerHTML=`<div class="field"><label for="photos-username">${t('username')}</label><input id="photos-username" autocomplete="username"></div><div class="field"><label for="photos-password">${t('password')}</label><input id="photos-password" type="password" autocomplete="current-password"></div><p class="hint">${t('photosSessionHint')}</p><button type="button" id="photos-connect">${t('photosConnect')}</button>`;
$('#photos-connect').onclick=busy(async()=>{const password=$('#photos-password').value;$('#photos-password').value='';await api(path,{method:'POST',body:{username:$('#photos-username').value,password}});await photosPeoplePicker(selected);});return;
}
const people=await api(path+'/people');
const people=await api(path+'/people?space='+encodeURIComponent($('#source-people-space')?.value||'shared'));
target.innerHTML=`<div class="field"><label for="people-search">${t('peopleSearch')}</label><input id="people-search" type="search"></div><label class="check-row"><input id="people-unnamed" type="checkbox">${t('peopleUnnamed')}</label><div id="people-list" style="max-height:260px;overflow:auto"></div><p id="people-selected" class="hint"></p><button type="button" id="photos-disconnect">${t('photosDisconnect')}</button>`;
const render=()=>{const query=$('#people-search').value.toLowerCase();const rows=people.filter(p=>(p.named||selected.has(p.id)||$('#people-unnamed').checked)&&(p.name.toLowerCase().includes(query)||String(p.id).includes(query)));$('#people-list').innerHTML=rows.map(p=>`<label class="check-row"><input type="checkbox" data-person="${p.id}" ${selected.has(p.id)?'checked':''}>${esc(p.name)} · ${p.count}</label>`).join('');$('#people-selected').textContent=t('peopleSelected')+selected.size;$('#people-list').querySelectorAll('[data-person]').forEach(box=>box.onchange=()=>{const id=Number(box.dataset.person);box.checked?selected.add(id):selected.delete(id);$('#people-selected').textContent=t('peopleSelected')+selected.size;});};
$('#people-search').oninput=render;$('#people-unnamed').onchange=render;$('#photos-disconnect').onclick=busy(async()=>{await api(path,{method:'DELETE'});await photosPeoplePicker(selected);});render();
@@ -131,9 +131,9 @@ async function photoSourcesPanel(){
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
}
async 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 for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></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-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value==='webdav';$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-recursive').disabled=dav;$('#source-synology-previews').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').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,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:$('#source-organized').checked,folder_caption:$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});$('#modal').close();await navigate('photo-sources');});
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><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</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 for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></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-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value!=='local';$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};update();$('#source-kind').onchange=update;addPhotoQualityFields(source);$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=$('#source-kind').value!=='local';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:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');});
}
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;
+2
View File
@@ -114,3 +114,5 @@ Object.assign(window.MESSAGES.en,{
templatePreviewHint:'Enter content on the left and select Generate preview. Inspect the final image here before selecting Display on frame.',
helpProfile:'Color preserves colors; grayscale creates a grayscale image. This does not change device hardware settings.'
});
Object.assign(window.MESSAGES.zh,{studio:'拼版与海报',connections:'连接中心'});
Object.assign(window.MESSAGES.en,{studio:'Layout Studio',connections:'Connections'});
+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"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.0</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></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"></script><script src="/ui/features.js"></script><script src="/ui/app.js"></script></body></html>
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.0</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></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"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
+1
View File
@@ -74,3 +74,4 @@ dialog.image-viewer h2.image-viewer-name{font-size:16px;min-width:0;flex:1}
@media(max-width:600px){dialog.image-viewer{width:calc(100vw - 16px);height:calc(100dvh - 16px);max-height:calc(100dvh - 16px);padding:12px}}
@media(max-width:600px){.image-viewer-footer{flex-wrap:wrap;gap:8px}.image-viewer-name{flex-basis:100%!important}.image-viewer-actions{width:100%;justify-content:flex-end;gap:6px}.image-viewer-actions button{padding:8px 10px}}
.studio-assets{max-height:240px;overflow:auto;margin:18px 0}.studio-canvas{position:relative;width:100%;background:white;margin-bottom:20px}.studio-canvas img{position:absolute;object-fit:cover;cursor:move;touch-action:none;border:2px solid var(--accent)}.region-fields{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.region-fields input{width:100%}#client-secret,#hook-secret{overflow-wrap:anywhere}