200 lines
7.5 KiB
Python
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()
|