Files
pigway-frame-flow/scripts/migrate-source-libraries.py
T
渡口浪人 6277c9d563
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 6m24s
feat: unify source libraries, generation plans and terminal bindings
2026-10-23 17:45:30 +08:00

200 lines
7.5 KiB
Python

"""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()