Add optional photo providers, layout studio and scoped integrations
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "collage",
|
||||
"name": "Multi-photo collage / 多图拼版",
|
||||
"width": 1200,
|
||||
"height": 1600,
|
||||
"fields": [
|
||||
"layout"
|
||||
],
|
||||
"defaults": {},
|
||||
"blocks": []
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "poster",
|
||||
"name": "Poster / 海报",
|
||||
"width": 1200,
|
||||
"height": 1600,
|
||||
"fields": [
|
||||
"layout"
|
||||
],
|
||||
"defaults": {},
|
||||
"blocks": []
|
||||
}
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();});}));},
|
||||
|
||||
@@ -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';
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user