feat: unify source libraries, generation plans and terminal bindings
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user