Add optional AI generation, MQTT, OAuth refresh and calendar recurrence
This commit is contained in:
@@ -110,7 +110,9 @@ def create_app(settings=None, start_worker=True):
|
||||
async def lifespan(app):
|
||||
if start_worker:
|
||||
scheduler.start()
|
||||
app.state.mqtt.start()
|
||||
yield
|
||||
app.state.mqtt.stop()
|
||||
scheduler.stop()
|
||||
db.close()
|
||||
|
||||
@@ -128,6 +130,7 @@ def create_app(settings=None, start_worker=True):
|
||||
install_extensions(app, service)
|
||||
install_accounts(app, db)
|
||||
app.state.service = service
|
||||
app.state.worker_enabled = start_worker
|
||||
app.state.scheduler = scheduler
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -1262,6 +1265,7 @@ def create_app(settings=None, start_worker=True):
|
||||
if filename not in [
|
||||
"app.js",
|
||||
"features.js",
|
||||
"extensions.js",
|
||||
"style.css",
|
||||
"i18n.js",
|
||||
"swagger-ui-bundle.js",
|
||||
|
||||
@@ -104,9 +104,9 @@ class DisplayService:
|
||||
)
|
||||
return existing["operation_id"]
|
||||
if (
|
||||
action not in ("render", "local-upload")
|
||||
action not in ("render", "render-ai", "local-upload")
|
||||
and tx.execute(
|
||||
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 LIMIT 1",
|
||||
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 AND action!='render-ai' LIMIT 1",
|
||||
(device_id,),
|
||||
).fetchone()
|
||||
):
|
||||
@@ -178,7 +178,7 @@ class DisplayService:
|
||||
def blocked(self, device_id):
|
||||
return (
|
||||
self.db.one(
|
||||
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 LIMIT 1",
|
||||
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 AND action!='render-ai' LIMIT 1",
|
||||
(device_id,),
|
||||
)
|
||||
is not None
|
||||
@@ -406,9 +406,11 @@ class DisplayService:
|
||||
def process_one(self):
|
||||
with self.lock:
|
||||
for op in self.queued_operations():
|
||||
if op["action"] not in ("render", "local-upload") and self.blocked(
|
||||
op["device_id"]
|
||||
):
|
||||
if op["action"] not in (
|
||||
"render",
|
||||
"render-ai",
|
||||
"local-upload",
|
||||
) and self.blocked(op["device_id"]):
|
||||
continue
|
||||
payload = json.loads(op["payload"])
|
||||
if op["action"] == "display":
|
||||
@@ -460,7 +462,7 @@ class DisplayService:
|
||||
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"):
|
||||
if action in ("render", "render-ai", "local-upload"):
|
||||
self.update(id, "RENDERING")
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Optional one-image generation, using the durable operation queue."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
from typing import Literal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import Depends, Header, Request
|
||||
from PIL import Image, ImageOps
|
||||
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.extensions.api import AISecret
|
||||
from pigway_frame_flow.extensions.secrets import save_secret
|
||||
from pigway_frame_flow.models import Accepted, StrictModel
|
||||
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS
|
||||
from pigway_frame_flow.storage.database import encode
|
||||
|
||||
|
||||
class ImageGenerationConfig(StrictModel):
|
||||
endpoint: str = Field(max_length=2048)
|
||||
model: str = Field(min_length=1, max_length=120)
|
||||
size: Literal["1024x1024", "1024x1536", "1536x1024"] = "1024x1536"
|
||||
|
||||
|
||||
class ImageGenerationInput(StrictModel):
|
||||
prompt: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
def install_ai_images(app, service):
|
||||
def config():
|
||||
row = service.db.one("SELECT payload FROM app_config WHERE key='ai_images'")
|
||||
if not row:
|
||||
raise FlowError(E.CONFIG_REQUIRED, "Configure image generation first", 409)
|
||||
return ImageGenerationConfig.model_validate_json(row["payload"])
|
||||
|
||||
@app.get(
|
||||
"/api/v1/settings/ai-images",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
tags=["AI"],
|
||||
)
|
||||
def get_config():
|
||||
row = service.db.one("SELECT payload FROM app_config WHERE key='ai_images'")
|
||||
return {
|
||||
"config": json.loads(row["payload"]) if row else None,
|
||||
"key_configured": (
|
||||
service.settings.config_dir / "ai-images.secret"
|
||||
).exists(),
|
||||
}
|
||||
|
||||
@app.put(
|
||||
"/api/v1/settings/ai-images",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
tags=["AI"],
|
||||
)
|
||||
def set_config(payload: ImageGenerationConfig):
|
||||
url = urlsplit(payload.endpoint)
|
||||
if (
|
||||
url.scheme != "https"
|
||||
or not url.hostname
|
||||
or url.username
|
||||
or url.password
|
||||
or url.query
|
||||
or url.fragment
|
||||
or url.port not in (None, 443)
|
||||
):
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Image generation requires a plain public HTTPS endpoint",
|
||||
)
|
||||
if url.hostname not in service.settings.image_url_hosts:
|
||||
service.settings.image_url_hosts.append(url.hostname)
|
||||
service.db.execute(
|
||||
"INSERT OR REPLACE INTO app_config VALUES('image_url_hosts',?)",
|
||||
(encode(service.settings.image_url_hosts),),
|
||||
)
|
||||
service.db.execute(
|
||||
"INSERT OR REPLACE INTO app_config VALUES('ai_images',?)",
|
||||
(payload.model_dump_json(),),
|
||||
)
|
||||
return payload
|
||||
|
||||
@app.put(
|
||||
"/api/v1/settings/ai-images/key",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require("admin"))],
|
||||
tags=["AI"],
|
||||
)
|
||||
def set_key(payload: AISecret):
|
||||
key = payload.key.get_secret_value()
|
||||
save_secret(service.settings.config_dir / "ai-images.secret", key)
|
||||
service.settings.additional_secrets.append(key)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/ai/images",
|
||||
status_code=202,
|
||||
response_model=Accepted,
|
||||
dependencies=[Depends(require("render:write"))],
|
||||
tags=["AI"],
|
||||
)
|
||||
def generate(
|
||||
payload: ImageGenerationInput,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None),
|
||||
):
|
||||
config()
|
||||
if not (service.settings.config_dir / "ai-images.secret").exists():
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED, "Configure image generation key first", 409
|
||||
)
|
||||
id = service.submit(
|
||||
"render-ai",
|
||||
payload.model_dump(),
|
||||
service.settings.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):
|
||||
service.update(op["id"], "RENDERING")
|
||||
conf = config()
|
||||
key = (service.settings.config_dir / "ai-images.secret").read_text().strip()
|
||||
service.settings.additional_secrets.append(key)
|
||||
service.db.execute(
|
||||
"UPDATE operations SET write_started=1 WHERE id=?", (op["id"],)
|
||||
)
|
||||
try:
|
||||
raw = service.assets.fetch_url(
|
||||
conf.endpoint,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": "Bearer " + key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body=json.dumps(
|
||||
{
|
||||
"model": conf.model,
|
||||
"prompt": payload["prompt"],
|
||||
"size": conf.size,
|
||||
"n": 1,
|
||||
}
|
||||
).encode(),
|
||||
limit=service.settings.max_bytes,
|
||||
)
|
||||
result = json.loads(raw)["data"]
|
||||
if len(result) != 1:
|
||||
raise ValueError()
|
||||
if result[0].get("b64_json"):
|
||||
image_data = base64.b64decode(result[0]["b64_json"], validate=True)
|
||||
else:
|
||||
image_data = service.assets.fetch_url(result[0]["url"])
|
||||
with Image.open(BytesIO(image_data)) as image:
|
||||
if 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"))
|
||||
output = BytesIO()
|
||||
rgb.save(output, "JPEG", quality=93)
|
||||
asset = service.assets.put(output.getvalue(), "ai-art.jpg", "generated")
|
||||
except Exception:
|
||||
raise FlowError(
|
||||
E.RENDER_FAILED,
|
||||
"Image generation failed; request may have been billed. No automatic retry was sent.",
|
||||
502,
|
||||
) from None
|
||||
service.update(op["id"], "COMPLETED", asset)
|
||||
service.db.history(op, "COMPLETED")
|
||||
service.db.event("operation.completed", {"operation_id": op["id"]})
|
||||
|
||||
service.operation_handlers["render-ai"] = execute
|
||||
@@ -42,6 +42,15 @@ def install_extensions(app, service):
|
||||
from pigway_frame_flow.extensions.providers import install_providers
|
||||
|
||||
install_providers(app, service)
|
||||
from pigway_frame_flow.extensions.ai_images import install_ai_images
|
||||
|
||||
install_ai_images(app, service)
|
||||
from pigway_frame_flow.extensions.mqtt import install_mqtt
|
||||
|
||||
install_mqtt(app, service)
|
||||
from pigway_frame_flow.extensions.device_events import DeviceObserver
|
||||
|
||||
service.device_observer = DeviceObserver(service)
|
||||
db, settings = service.db, service.settings
|
||||
studio = LayoutStudio(service.renderer)
|
||||
service.local_retention = LocalRetention(service)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Low-frequency read-only observation emits transitions, never frame writes."""
|
||||
|
||||
import json
|
||||
|
||||
from pigway_frame_flow.storage.database import encode
|
||||
|
||||
|
||||
class DeviceObserver:
|
||||
def __init__(self, service):
|
||||
self.service = service
|
||||
|
||||
def tick(self):
|
||||
for id, driver in self.service.registry.drivers.items():
|
||||
try:
|
||||
device = driver.device()
|
||||
except Exception:
|
||||
# Cloud query failure is not evidence that the physical device is offline.
|
||||
continue
|
||||
low = (
|
||||
device.power.battery_percent is not None
|
||||
and device.power.battery_percent <= 20
|
||||
)
|
||||
state = {"status": device.status, "low_battery": low}
|
||||
key = "device-observation:" + id
|
||||
row = self.service.db.one(
|
||||
"SELECT payload FROM app_config WHERE key=?", (key,)
|
||||
)
|
||||
previous = json.loads(row["payload"]) if row else None
|
||||
if previous == state:
|
||||
continue
|
||||
if previous:
|
||||
if previous["status"] != state["status"] and state["status"] in (
|
||||
"online",
|
||||
"offline",
|
||||
):
|
||||
self.service.db.event(
|
||||
"device." + state["status"], {"device_id": id}
|
||||
)
|
||||
if low and not previous["low_battery"]:
|
||||
self.service.db.event(
|
||||
"device.low_battery",
|
||||
{
|
||||
"device_id": id,
|
||||
"battery_percent": device.power.battery_percent,
|
||||
},
|
||||
)
|
||||
self.service.db.execute(
|
||||
"INSERT OR REPLACE INTO app_config VALUES(?,?)", (key, encode(state))
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Opt-in MQTT ingress shares the display queue, scopes and idempotency."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
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 DisplayRequest, StrictModel
|
||||
from pigway_frame_flow.settings import secret
|
||||
from pigway_frame_flow.storage.database import encode
|
||||
|
||||
|
||||
class MQTTConfig(StrictModel):
|
||||
enabled: bool = False
|
||||
host: str = Field(default="", pattern=r"^[a-zA-Z0-9.:-]*$", max_length=253)
|
||||
port: int = Field(default=8883, ge=1, le=65535)
|
||||
tls: bool = True
|
||||
allow_insecure_lan: bool = False
|
||||
topic: str = Field(default="frame-flow/display", pattern=r"^[a-zA-Z0-9_/-]{1,120}$")
|
||||
username: str = Field(default="", max_length=120)
|
||||
|
||||
|
||||
class MQTTBridge:
|
||||
def __init__(self, service):
|
||||
self.service = service
|
||||
self.client = None
|
||||
self.connected = False
|
||||
|
||||
def config(self):
|
||||
row = self.service.db.one("SELECT payload FROM app_config WHERE key='mqtt'")
|
||||
return MQTTConfig.model_validate_json(row["payload"]) if row else MQTTConfig()
|
||||
|
||||
def accept(self, payload, retained=False):
|
||||
if retained:
|
||||
raise FlowError(E.INVALID_REQUEST, "Retained display commands are rejected")
|
||||
if len(payload) > 65536:
|
||||
raise FlowError(E.INVALID_REQUEST, "MQTT command exceeds size limit")
|
||||
token = secret("FRAME_FLOW_MQTT_TOKEN")
|
||||
row = (
|
||||
self.service.db.one(
|
||||
"SELECT * FROM clients WHERE token_hash=? AND enabled=1",
|
||||
(hashlib.sha256(token.encode()).hexdigest(),),
|
||||
)
|
||||
if token
|
||||
else None
|
||||
)
|
||||
if not row:
|
||||
raise FlowError(
|
||||
E.UNAUTHORIZED, "Configure an enabled Frame Flow MQTT client token", 401
|
||||
)
|
||||
scopes = json.loads(row["permissions"])
|
||||
if "admin" not in scopes and "display:write" not in scopes:
|
||||
raise FlowError(E.FORBIDDEN, "MQTT client lacks display:write", 403)
|
||||
self.service.settings.additional_secrets.append(token)
|
||||
data = json.loads(payload)
|
||||
request_id = data.pop("request_id", "")
|
||||
if not isinstance(request_id, str) or not request_id or len(request_id) > 120:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "MQTT commands require a stable request_id"
|
||||
)
|
||||
request = DisplayRequest.model_validate(data)
|
||||
self.service.validate(request)
|
||||
id = self.service.submit(
|
||||
"display",
|
||||
request.model_dump(mode="json"),
|
||||
request.device_id,
|
||||
row["client_id"],
|
||||
self.service.settings.sanitize(request_id),
|
||||
"mqtt-" + hashlib.sha256(request_id.encode()).hexdigest(),
|
||||
)
|
||||
return {"operation_id": id, "status": "accepted"}
|
||||
|
||||
def start(self):
|
||||
config = self.config()
|
||||
if not config.enabled:
|
||||
return
|
||||
if not config.host or (not config.tls and not config.allow_insecure_lan):
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED,
|
||||
"MQTT requires host and TLS or explicit LAN opt-in",
|
||||
409,
|
||||
)
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
self.client = mqtt.Client(
|
||||
mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id="frame-flow-"
|
||||
+ hashlib.sha256(str(self.service.settings.data_dir).encode()).hexdigest()[
|
||||
:12
|
||||
],
|
||||
)
|
||||
password = secret("FRAME_FLOW_MQTT_PASSWORD")
|
||||
self.service.settings.additional_secrets.append(password)
|
||||
if config.username:
|
||||
self.client.username_pw_set(config.username, password)
|
||||
if config.tls:
|
||||
self.client.tls_set()
|
||||
self.client.reconnect_delay_set(min_delay=5, max_delay=120)
|
||||
|
||||
def connected(client, userdata, flags, reason_code, properties):
|
||||
self.connected = not reason_code.is_failure
|
||||
if self.connected:
|
||||
client.subscribe(config.topic, qos=1)
|
||||
|
||||
def disconnected(client, userdata, flags, reason_code, properties):
|
||||
self.connected = False
|
||||
|
||||
def message(client, userdata, message):
|
||||
try:
|
||||
response = self.accept(message.payload, message.retain)
|
||||
except FlowError as exc:
|
||||
response = {"error": {"code": exc.code, "message": exc.message}}
|
||||
except Exception:
|
||||
response = {
|
||||
"error": {
|
||||
"code": E.INVALID_REQUEST,
|
||||
"message": "Invalid MQTT display request",
|
||||
}
|
||||
}
|
||||
client.publish(
|
||||
config.topic + "/result", encode(response), qos=1, retain=False
|
||||
)
|
||||
|
||||
self.client.on_connect = connected
|
||||
self.client.on_disconnect = disconnected
|
||||
self.client.on_message = message
|
||||
self.client.connect_async(config.host, config.port, keepalive=60)
|
||||
self.client.loop_start()
|
||||
|
||||
def stop(self):
|
||||
if self.client:
|
||||
self.client.disconnect()
|
||||
self.client.loop_stop()
|
||||
self.client = None
|
||||
self.connected = False
|
||||
|
||||
|
||||
def install_mqtt(app, service):
|
||||
service.mqtt = MQTTBridge(service)
|
||||
app.state.mqtt = service.mqtt
|
||||
|
||||
@app.get(
|
||||
"/api/v1/integrations/mqtt",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
tags=["MQTT"],
|
||||
)
|
||||
def state():
|
||||
return {
|
||||
"config": service.mqtt.config().model_dump(),
|
||||
"connected": service.mqtt.connected,
|
||||
}
|
||||
|
||||
@app.put(
|
||||
"/api/v1/integrations/mqtt",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
tags=["MQTT"],
|
||||
)
|
||||
def configure(payload: MQTTConfig):
|
||||
if payload.enabled and (
|
||||
not payload.host or not payload.tls and not payload.allow_insecure_lan
|
||||
):
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "MQTT needs a host and TLS or explicit LAN opt-in"
|
||||
)
|
||||
service.mqtt.stop()
|
||||
service.db.execute(
|
||||
"INSERT OR REPLACE INTO app_config VALUES('mqtt',?)",
|
||||
(payload.model_dump_json(),),
|
||||
)
|
||||
if getattr(app.state, "worker_enabled", False):
|
||||
service.mqtt.start()
|
||||
return state()
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
@@ -30,6 +31,29 @@ class CloudDrives:
|
||||
for key in ["access_token", "refresh_token", "client_secret"]:
|
||||
if value.get(key):
|
||||
self.settings.additional_secrets.append(value[key])
|
||||
if (
|
||||
value.get("expires_at")
|
||||
and float(value["expires_at"]) <= time.time() + 60
|
||||
):
|
||||
if source.credential_env:
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED,
|
||||
"Environment OAuth token expired; update its secret",
|
||||
409,
|
||||
)
|
||||
from pigway_frame_flow.providers.oauth import OAuthTokens
|
||||
|
||||
if not hasattr(self.photos, "oauth_tokens"):
|
||||
self.photos.oauth_tokens = OAuthTokens(
|
||||
self.settings, self.photos.assets
|
||||
)
|
||||
with self.photos.oauth_tokens.lock:
|
||||
latest = json.loads(path.read_text())
|
||||
value = (
|
||||
latest
|
||||
if float(latest.get("expires_at", 0)) > time.time() + 60
|
||||
else self.photos.oauth_tokens.refresh(source.kind, latest, path)
|
||||
)
|
||||
return value
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
raise FlowError(
|
||||
|
||||
@@ -139,26 +139,9 @@ class ExternalProviders:
|
||||
)
|
||||
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]}
|
||||
from pigway_frame_flow.providers.ical import calendar_events
|
||||
|
||||
result = calendar_events(raw)
|
||||
elif config.kind == "google-calendar":
|
||||
result = {
|
||||
"events": [
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Bounded read-only iCalendar expansion for the next seven days."""
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
|
||||
|
||||
def calendar_events(raw, now=None):
|
||||
now = now or datetime.now(UTC)
|
||||
end = now + timedelta(days=7)
|
||||
text = re.sub(r"\r?\n[ \t]", "", raw.decode("utf-8-sig"))
|
||||
blocks = text.split("BEGIN:VEVENT")[1:]
|
||||
if len(blocks) > 2000:
|
||||
raise FlowError(E.INVALID_REQUEST, "Calendar exceeds 2000 events")
|
||||
|
||||
def stamp(key, value):
|
||||
zone = re.search(r"TZID=([^;:]+)", key)
|
||||
tz = ZoneInfo(zone[1]) if zone else ZoneInfo("Asia/Shanghai")
|
||||
if len(value) == 8:
|
||||
return datetime.strptime(value, "%Y%m%d").replace(tzinfo=tz)
|
||||
fmt = "%Y%m%dT%H%M%SZ" if value.endswith("Z") else "%Y%m%dT%H%M%S"
|
||||
return datetime.strptime(value, fmt).replace(
|
||||
tzinfo=UTC if value.endswith("Z") else tz
|
||||
)
|
||||
|
||||
parsed: list[tuple] = []
|
||||
for block in blocks:
|
||||
props: dict[str, list[tuple[str, str]]] = {}
|
||||
for line in block.split("END:VEVENT")[0].splitlines():
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
props.setdefault(key.split(";")[0], []).append((key, value))
|
||||
if "DTSTART" not in props or "SUMMARY" not in props:
|
||||
continue
|
||||
start = stamp(*props["DTSTART"][0])
|
||||
uid = props.get("UID", [(None, str(len(parsed)))])[0][1]
|
||||
override = (
|
||||
stamp(*props["RECURRENCE-ID"][0]) if "RECURRENCE-ID" in props else None
|
||||
)
|
||||
parsed.append((props, start, uid, override))
|
||||
overrides = {
|
||||
(uid, original.astimezone(UTC)) for _, _, uid, original in parsed if original
|
||||
}
|
||||
result = []
|
||||
for props, start, uid, override in parsed:
|
||||
if props.get("STATUS", [(None, "")])[0][1] == "CANCELLED":
|
||||
continue
|
||||
occurrences = [start]
|
||||
if "RRULE" in props and not override:
|
||||
rule = props["RRULE"][0][1]
|
||||
if not re.search(r"(?:^|;)FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)(?:;|$)", rule):
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Calendar frequency must be daily or slower",
|
||||
422,
|
||||
)
|
||||
if now.year - start.year > 150:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Calendar recurrence starts too far in the past"
|
||||
)
|
||||
occurrences = rrulestr(rule, dtstart=start).between(
|
||||
now - timedelta(days=1), end, inc=True
|
||||
)
|
||||
for key, value in props.get("RDATE", []):
|
||||
occurrences.extend(stamp(key, item) for item in value.split(","))
|
||||
excluded = {
|
||||
stamp(key, item).astimezone(UTC)
|
||||
for key, value in props.get("EXDATE", [])
|
||||
for item in value.split(",")
|
||||
}
|
||||
title = (
|
||||
props["SUMMARY"][0][1]
|
||||
.replace("\\n", " ")
|
||||
.replace("\\,", ",")
|
||||
.replace("\\;", ";")
|
||||
.replace("\\\\", "\\")
|
||||
)
|
||||
all_day = len(props["DTSTART"][0][1]) == 8
|
||||
for occurrence in sorted(set(occurrences)):
|
||||
instant = occurrence.astimezone(UTC)
|
||||
lower = (
|
||||
now.astimezone(occurrence.tzinfo).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
if all_day
|
||||
else now
|
||||
)
|
||||
if instant in excluded or (not override and (uid, instant) in overrides):
|
||||
continue
|
||||
if lower <= instant < end:
|
||||
result.append({"title": title, "start": occurrence.isoformat()})
|
||||
if len(result) > 1000:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Calendar expansion exceeds 1000 instances"
|
||||
)
|
||||
return {"events": sorted(result, key=lambda x: x["start"])}
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Fixed-provider OAuth refresh. No secret payloads in durable operation history."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.extensions.secrets import save_secret
|
||||
|
||||
|
||||
class OAuthTokens:
|
||||
ENDPOINTS = {
|
||||
"google-drive": "https://oauth2.googleapis.com/token",
|
||||
"alipan": "https://openapi.aliyundrive.com/oauth/access_token",
|
||||
"baidu": "https://openapi.baidu.com/oauth/2.0/token",
|
||||
}
|
||||
|
||||
def __init__(self, settings, assets):
|
||||
self.settings, self.assets = settings, assets
|
||||
self.lock = threading.RLock()
|
||||
|
||||
def refresh(self, kind, credentials, path=None):
|
||||
with self.lock:
|
||||
if not all(
|
||||
credentials.get(key)
|
||||
for key in ("refresh_token", "client_id", "client_secret")
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED,
|
||||
"OAuth refresh requires client ID, client secret and refresh token",
|
||||
409,
|
||||
)
|
||||
values = {
|
||||
key: credentials[key]
|
||||
for key in ("refresh_token", "client_id", "client_secret")
|
||||
}
|
||||
values["grant_type"] = "refresh_token"
|
||||
for key in ("access_token", "refresh_token", "client_secret"):
|
||||
if credentials.get(key):
|
||||
self.settings.additional_secrets.append(credentials[key])
|
||||
endpoint = self.ENDPOINTS[kind]
|
||||
host = endpoint.split("/")[2]
|
||||
if host not in self.settings.image_url_hosts:
|
||||
self.settings.image_url_hosts.append(host)
|
||||
as_json = kind == "alipan"
|
||||
body = (
|
||||
json.dumps(values).encode() if as_json else urlencode(values).encode()
|
||||
)
|
||||
try:
|
||||
raw = self.assets.fetch_url(
|
||||
endpoint,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json"
|
||||
if as_json
|
||||
else "application/x-www-form-urlencoded"
|
||||
},
|
||||
body=body,
|
||||
limit=65536,
|
||||
)
|
||||
response = json.loads(raw)
|
||||
if (
|
||||
not isinstance(response.get("access_token"), str)
|
||||
or not response["access_token"]
|
||||
):
|
||||
raise ValueError()
|
||||
expiry = int(response["expires_in"])
|
||||
if not 60 <= expiry <= 366 * 86400:
|
||||
raise ValueError()
|
||||
updated = {
|
||||
**credentials,
|
||||
"access_token": response["access_token"],
|
||||
"expires_at": time.time() + expiry,
|
||||
}
|
||||
if response.get("refresh_token"):
|
||||
updated["refresh_token"] = response["refresh_token"]
|
||||
for key in ("access_token", "refresh_token"):
|
||||
if updated.get(key):
|
||||
self.settings.additional_secrets.append(updated[key])
|
||||
if path:
|
||||
save_secret(path, json.dumps(updated))
|
||||
return updated
|
||||
except Exception:
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE,
|
||||
"OAuth refresh failed; reconnect or refresh credentials manually",
|
||||
502,
|
||||
) from None
|
||||
@@ -258,6 +258,7 @@ class Scheduler:
|
||||
def run_webhooks(self):
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
self.service.device_observer.tick()
|
||||
self.service.webhooks.tick()
|
||||
except Exception:
|
||||
logging.getLogger("frame-flow").error(
|
||||
|
||||
@@ -5,7 +5,7 @@ async function layoutStudioPage(){
|
||||
$('#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-assets').onchange=()=>{selected=new Set([...document.querySelectorAll('#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;
|
||||
@@ -24,7 +24,7 @@ async function layoutStudioPage(){
|
||||
}
|
||||
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();
|
||||
await aiSettingsPanel();await aiImagePanel();await storageSettingsPanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();await mqttPanel();
|
||||
}
|
||||
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'));});
|
||||
@@ -50,3 +50,9 @@ 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';
|
||||
|
||||
}
|
||||
async function aiImagePanel(){
|
||||
const info=await api('/api/v1/settings/ai-images');const conf=info.config||{};const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 生图','AI image generation')}</h3><p class="hint">${tr('独立配置 images/generations 接口。每次仅生成一张并保存本地,不会自动显示到画框。请求可能收费,失败不自动重试。','Configure a separate images/generations endpoint. One image is saved locally per request, never automatically displayed. Requests may be billed; failures are not retried.')}</p><form id="image-ai-config"><div class="field"><label>Endpoint</label><input id="image-ai-url" type="url" required value="${esc(conf.endpoint||'')}"></div><div class="field"><label>Model</label><input id="image-ai-model" required value="${esc(conf.model||'')}"></div><div class="field"><label>Size</label><select id="image-ai-size"><option>1024x1536</option><option>1536x1024</option><option>1024x1024</option></select></div><div class="field"><label>API Key</label><input id="image-ai-key" type="password" autocomplete="off"><p class="hint">${t('keyConfigured')}: ${t(info.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form><form id="image-ai-generate"><div class="field"><label>${tr('画面描述','Image prompt')}</label><textarea id="image-ai-prompt" required maxlength="4000"></textarea></div><button type="submit">${tr('生成一张(可能收费)','Generate one image (may incur charges)')}</button></form><div id="image-ai-preview"></div>`;$('#main').append(card);$('#image-ai-size').value=conf.size||'1024x1536';$('#image-ai-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-images',{method:'PUT',body:{endpoint:$('#image-ai-url').value,model:$('#image-ai-model').value,size:$('#image-ai-size').value}});const key=$('#image-ai-key').value;$('#image-ai-key').value='';if(key)await api('/api/v1/settings/ai-images/key',{method:'PUT',body:{key}});toast(t('done'));});$('#image-ai-generate').onsubmit=busy(async e=>{e.preventDefault();const result=await api('/api/v1/ai/images',{method:'POST',body:{prompt:$('#image-ai-prompt').value},headers:{'Idempotency-Key':'ai-image-'+clientUUID()}});const op=await waitOperation(result.operation_id);$('#image-ai-preview').innerHTML='<img class="preview" id="image-ai-result" alt="Generated image">';await blobImage($('#image-ai-result'),op.result.url);toast(tr('已存入本地图库','Saved to local gallery'));});
|
||||
}
|
||||
async function mqttPanel(){
|
||||
const status=await api('/api/v1/integrations/mqtt');const c=status.config;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>MQTT · ${t(status.connected?'connected':'disconnected')}</h3><p class="hint">${tr('默认关闭。须配置环境 Secret FRAME_FLOW_MQTT_TOKEN(有 display:write 权限的客户端 Token),以及可选 FRAME_FLOW_MQTT_PASSWORD。消息必须携带 request_id;retained 消息被拒绝。结果发送到命令主题的 /result。','Disabled by default. Configure FRAME_FLOW_MQTT_TOKEN with display:write scope and optional FRAME_FLOW_MQTT_PASSWORD as environment secrets. Commands require request_id; retained commands are rejected. Results are published to the command topic plus /result.')}</p><form id="mqtt-config"><label class="check-row"><input id="mqtt-enabled" type="checkbox" ${c.enabled?'checked':''}>${t('enabled')}</label><div class="field"><label>Host</label><input id="mqtt-host" value="${esc(c.host)}"></div><div class="field"><label>Port</label><input id="mqtt-port" type="number" min="1" max="65535" value="${c.port}"></div><div class="field"><label>Topic</label><input id="mqtt-topic" value="${esc(c.topic)}"></div><div class="field"><label>${t('username')}</label><input id="mqtt-username" value="${esc(c.username)}"></div><label class="check-row"><input id="mqtt-tls" type="checkbox" ${c.tls?'checked':''}>TLS</label><label class="check-row"><input id="mqtt-lan" type="checkbox" ${c.allow_insecure_lan?'checked':''}>${tr('明确允许 LAN 明文连接','Explicitly allow plaintext LAN connection')}</label><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#mqtt-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/integrations/mqtt',{method:'PUT',body:{enabled:$('#mqtt-enabled').checked,host:$('#mqtt-host').value,port:Number($('#mqtt-port').value),topic:$('#mqtt-topic').value,username:$('#mqtt-username').value,tls:$('#mqtt-tls').checked,allow_insecure_lan:$('#mqtt-lan').checked}});toast(t('done'));});
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ async function photoSourcesPanel(){
|
||||
}
|
||||
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><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='';};
|
||||
$('#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-organized').disabled=dav;$('#source-caption').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){
|
||||
|
||||
Reference in New Issue
Block a user