"""Opt-in migration of an unambiguous single photo-source task; no device writes.""" import argparse import json import os import urllib.error import urllib.request from datetime import UTC, datetime, timedelta from types import SimpleNamespace from pigway_frame_flow.renderers.frame_geometry import frame_canvas from pigway_frame_flow.settings import Settings def migrate(call, apply=False): jobs = call("GET", "/api/v1/jobs") candidates = [j for j in jobs if j["source"]["type"] == "photo_source"] sources = call("GET", "/api/v1/source-libraries") if len(candidates) != 1 or len(sources) != 1: raise RuntimeError( "Migration requires exactly one photo task and one source; configure ambiguous bindings in the UI" ) job, source = candidates[0], sources[0] if job.get("library_pipeline"): return {"already_configured": True} if job["source"]["source_id"] != source["id"] or job["source"].get("item_id"): raise RuntimeError( "Migration requires a random photo task from the sole source" ) if job.get("next_run") and datetime.fromisoformat(job["next_run"]) <= datetime.now( UTC ) + timedelta(minutes=2): raise RuntimeError( "Task is due soon; retry after it completes without changing its schedule" ) for status in ["QUEUED", "RENDERING", "UPLOADING", "SYNCING", "UNKNOWN"]: if call("GET", "/api/v1/operation-history?status=" + status + "&page_size=1")[ "total" ]: raise RuntimeError( "Pending operations exist; resolve them before migrating" ) terminals = call("GET", "/api/v1/library-terminals") terminal_ids = {t["id"] for t in terminals} if job["device_id"] not in terminal_ids or "watch" not in terminal_ids: raise RuntimeError("The task frame and watch must both exist") device = call("GET", "/api/v1/devices/" + job["device_id"]) width, height = frame_canvas( SimpleNamespace(**device["display"]), source.get("orientation") ) frame = { "source_id": source["id"], "name": "现有相框方案", "width": width, "height": height, "fit": "cover" if source["folder_caption"] else job["source"].get("fit", "contain"), "caption": source["folder_caption"], "profile": job["source"].get("profile", "color"), "quality": 93, "center_x": 0.5, "center_y": 0.5, } if frame["fit"] == "smart" or job["source"].get("fit") == "smart": raise RuntimeError("Smart crop requires an explicit generation-plan choice") watch = { "source_id": source["id"], "name": "手表默认方案", "width": 410, "height": 502, "fit": "cover", "caption": False, "profile": "color", "quality": 85, "center_x": 0.5, "center_y": 0.5, } if job.get("thumbnail_plan_id"): previous = call("GET", "/api/v1/thumbnail-plans/" + job["thumbnail_plan_id"]) if (previous["width"], previous["height"]) != (410, 502): raise RuntimeError( "The previous thumbnail plan does not match the watch screen" ) watch.update( { k: previous[k] for k in ["width", "height", "fit", "quality", "center_x", "center_y"] } ) plans = call("GET", "/api/v1/generation-plans?source_id=" + source["id"]) expected_names = {frame["name"], watch["name"]} if any(not p["archived"] and p["name"] not in expected_names for p in plans): raise RuntimeError( "Custom generation plans already exist; configure bindings explicitly" ) known = {} for target in [frame, watch]: match = [p for p in plans if not p["archived"] and p["name"] == target["name"]] if match and any(match[0].get(k) != v for k, v in target.items()): raise RuntimeError( "Existing migration plan parameters differ; configure bindings explicitly" ) if match: known[target["name"]] = match[0] for terminal in terminals: if terminal["id"] in [job["device_id"], "watch"] and terminal["binding"]: expected = known.get( frame["name"] if terminal["id"] == job["device_id"] else watch["name"] ) if ( not expected or terminal["binding"]["source_id"] != source["id"] or terminal["binding"]["plan_id"] != expected["id"] ): raise RuntimeError("A terminal already has another binding") if not apply: return { "ready": True, "frame_size": [width, height], "watch_size": [410, 502], "next_run": job["next_run"], } for target in [frame, watch]: if target["name"] not in known: known[target["name"]] = call("POST", "/api/v1/generation-plans", target) for terminal_id, target in [(job["device_id"], frame), ("watch", watch)]: call( "PUT", "/api/v1/library-terminals/" + terminal_id + "/binding", {"source_id": source["id"], "plan_id": known[target["name"]]["id"]}, ) migrated = call( "PUT", "/api/v1/jobs/" + job["id"] + "/library-mode", {"enabled": True} ) if ( migrated["next_run"] != job["next_run"] or migrated["enabled"] != job["enabled"] or migrated["schedule"] != job["schedule"] ): raise RuntimeError("Task scheduling changed concurrently; review the task") seeded = False try: current = call( "GET", "/api/v1/devices/" + job["device_id"] + "/current-content" ) origin = current.get("source") or {} if ( current.get("source_known") and origin.get("type") == "photo_source" and origin.get("source_id") == source["id"] and origin.get("item_id") ): picture = call( "POST", "/api/v1/source-libraries/" + source["id"] + "/import", {"item_id": origin["item_id"]}, ) call("POST", "/api/v1/source-images/" + picture["id"] + "/generate", {}) seeded = True except urllib.error.HTTPError: # Current pixels may no longer be available upstream. The binding and # schedule remain valid; never guess a substitute or write to a device. pass return { "migrated": True, "terminals_bound": 2, "current_source_generated_locally": seeded, "next_run": migrated["next_run"], } def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--apply", action="store_true") args = parser.parse_args() settings = Settings() origin = os.environ.get( "FRAME_FLOW_MIGRATION_URL", "http://127.0.0.1:18787" ).rstrip("/") def call(method, path, payload=None): request = urllib.request.Request( origin + path, method=method, headers={ "Authorization": "Bearer " + settings.api_token, "Content-Type": "application/json", }, data=json.dumps(payload).encode() if payload is not None else None, ) with urllib.request.urlopen(request, timeout=120) as response: return json.load(response) if response.status != 204 else None print(json.dumps(migrate(call, args.apply), ensure_ascii=False)) if __name__ == "__main__": main()