660 lines
26 KiB
Python
660 lines
26 KiB
Python
"""Source pictures, source-scoped render plans, and single terminal bindings."""
|
|
|
|
import hashlib
|
|
import json
|
|
from io import BytesIO
|
|
from uuid import uuid4
|
|
|
|
from PIL import Image
|
|
from pydantic import Field, model_validator
|
|
|
|
from pigway_frame_flow.core.photo_preferences import flags
|
|
from pigway_frame_flow.display.thumbnail_plans import ThumbnailPlan
|
|
from pigway_frame_flow.errors import ErrorCode as E
|
|
from pigway_frame_flow.errors import FlowError
|
|
from pigway_frame_flow.models import PhotoSourceContent
|
|
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS, convert
|
|
from pigway_frame_flow.renderers.photo_caption import render_captioned_photo
|
|
from pigway_frame_flow.storage.database import encode, now_iso
|
|
|
|
|
|
class GenerationPlan(ThumbnailPlan):
|
|
source_id: str = Field(min_length=1, max_length=120)
|
|
caption: bool = False
|
|
profile: str = Field(default="color", pattern="^(color|grayscale)$")
|
|
|
|
@model_validator(mode="after")
|
|
def caption_size(self):
|
|
if self.caption and min(self.width, self.height) < 128:
|
|
raise ValueError(
|
|
"Caption output requires both dimensions to be at least 128 pixels"
|
|
)
|
|
return self
|
|
|
|
|
|
class Libraries:
|
|
def __init__(self, service):
|
|
self.service, self.db = service, service.db
|
|
for kind in ("source-images", "renditions"):
|
|
(service.settings.data_dir / kind).mkdir(exist_ok=True)
|
|
for sql in [
|
|
"CREATE TABLE IF NOT EXISTS library_plans(id TEXT PRIMARY KEY,source_id TEXT NOT NULL,payload TEXT NOT NULL,archived INTEGER NOT NULL DEFAULT 0)",
|
|
"CREATE TABLE IF NOT EXISTS source_images(id TEXT PRIMARY KEY,source_id TEXT NOT NULL,item_id TEXT NOT NULL,digest TEXT NOT NULL,caption TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(source_id,item_id,digest))",
|
|
"CREATE TABLE IF NOT EXISTS library_versions(asset_id TEXT PRIMARY KEY,source_image_id TEXT NOT NULL,plan_id TEXT NOT NULL,revision TEXT NOT NULL,plan TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(source_image_id,plan_id,revision))",
|
|
"CREATE TABLE IF NOT EXISTS terminal_bindings(terminal_id TEXT PRIMARY KEY,source_id TEXT NOT NULL,plan_id TEXT NOT NULL)",
|
|
"CREATE TABLE IF NOT EXISTS terminal_deliveries(terminal_id TEXT NOT NULL,asset_id TEXT NOT NULL,operation_id TEXT PRIMARY KEY,parent_operation_id TEXT,created_at TEXT NOT NULL)",
|
|
]:
|
|
self.db.execute(sql)
|
|
service.operation_handlers["library-generate"] = self.execute
|
|
|
|
def terminals(self):
|
|
result = []
|
|
for terminal_id, driver in self.service.registry.drivers.items():
|
|
cached = getattr(driver, "cached", None)
|
|
result.append(
|
|
{
|
|
"id": terminal_id,
|
|
"name": getattr(cached, "name", terminal_id),
|
|
"kind": "frame",
|
|
"transport_supported": True,
|
|
"binding": self.binding(terminal_id),
|
|
}
|
|
)
|
|
result.append(
|
|
{
|
|
"id": "watch",
|
|
"name": "Watch",
|
|
"kind": "watch",
|
|
"width": 410,
|
|
"height": 502,
|
|
"transport_supported": False,
|
|
"binding": self.binding("watch"),
|
|
}
|
|
)
|
|
return result
|
|
|
|
def binding(self, terminal_id):
|
|
return self.db.one(
|
|
"SELECT * FROM terminal_bindings WHERE terminal_id=?", (terminal_id,)
|
|
)
|
|
|
|
def plan(self, plan_id, active=True):
|
|
row = self.db.one("SELECT * FROM library_plans WHERE id=?", (plan_id,))
|
|
if not row or (active and row["archived"]):
|
|
raise FlowError(E.NOT_FOUND, "Generation plan not found", 404)
|
|
return GenerationPlan.model_validate_json(row["payload"])
|
|
|
|
def plans(self, source_id):
|
|
self.service.photo_sources.get(source_id)
|
|
return [
|
|
{**json.loads(r["payload"]), "id": r["id"], "archived": bool(r["archived"])}
|
|
for r in self.db.all(
|
|
"SELECT * FROM library_plans WHERE source_id=? ORDER BY archived,id",
|
|
(source_id,),
|
|
)
|
|
]
|
|
|
|
def save_plan(self, plan, plan_id=None):
|
|
with self.service.lock:
|
|
self.service.photo_sources.get(plan.source_id)
|
|
if plan_id:
|
|
previous = self.plan(plan_id)
|
|
if previous.source_id != plan.source_id:
|
|
raise FlowError(
|
|
E.CONFLICT,
|
|
"A generation plan cannot move between source libraries",
|
|
409,
|
|
)
|
|
for row in self.db.all(
|
|
"SELECT terminal_id FROM terminal_bindings WHERE plan_id=?",
|
|
(plan_id,),
|
|
):
|
|
self.validate_size(row["terminal_id"], plan)
|
|
plan_id = plan_id or uuid4().hex
|
|
self.db.execute(
|
|
"INSERT INTO library_plans VALUES(?,?,?,0) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload",
|
|
(plan_id, plan.source_id, encode(plan.model_dump())),
|
|
)
|
|
return {"id": plan_id, **plan.model_dump(), "archived": False}
|
|
|
|
def archive(self, plan_id):
|
|
with self.service.lock:
|
|
self.plan(plan_id)
|
|
if self.db.one(
|
|
"SELECT terminal_id FROM terminal_bindings WHERE plan_id=?", (plan_id,)
|
|
):
|
|
raise FlowError(
|
|
E.CONFLICT,
|
|
"Unbind terminals before deleting this generation plan",
|
|
409,
|
|
{"reason": "libraryPlanBound"},
|
|
)
|
|
self.db.execute(
|
|
"UPDATE library_plans SET archived=1 WHERE id=?", (plan_id,)
|
|
)
|
|
|
|
def validate_size(self, terminal_id, plan):
|
|
if terminal_id == "watch":
|
|
allowed = {(410, 502)}
|
|
else:
|
|
display = self.service.registry.get(terminal_id).device().display
|
|
allowed = {(display.width, display.height), (display.height, display.width)}
|
|
if (plan.width, plan.height) not in allowed:
|
|
raise FlowError(
|
|
E.INVALID_REQUEST,
|
|
"Generation dimensions must match the terminal screen",
|
|
422,
|
|
{"reason": "librarySizeMismatch"},
|
|
)
|
|
|
|
def bind(self, terminal_id, source_id, plan_id):
|
|
with self.service.lock:
|
|
self.service.photo_sources.get(source_id)
|
|
plan = self.plan(plan_id)
|
|
if plan.source_id != source_id:
|
|
raise FlowError(
|
|
E.CONFLICT, "Generation plan belongs to another source library", 409
|
|
)
|
|
self.validate_size(terminal_id, plan)
|
|
self.db.execute(
|
|
"INSERT INTO terminal_bindings VALUES(?,?,?) ON CONFLICT(terminal_id) DO UPDATE SET source_id=excluded.source_id,plan_id=excluded.plan_id",
|
|
(terminal_id, source_id, plan_id),
|
|
)
|
|
return self.binding(terminal_id)
|
|
|
|
def ingest(self, source_id, item_id, data, name, caption):
|
|
digest = hashlib.sha256(
|
|
data + b"\0" + encode(caption or {}).encode()
|
|
).hexdigest()
|
|
row = self.db.one(
|
|
"SELECT id FROM source_images WHERE source_id=? AND item_id=? AND digest=?",
|
|
(source_id, item_id, digest),
|
|
)
|
|
if row:
|
|
return self.picture(row["id"])
|
|
# Normalize EXIF and JPEG storage without resizing or adding decorations.
|
|
try:
|
|
with Image.open(BytesIO(data)) as image:
|
|
if (
|
|
image.format not in {"JPEG", "PNG", "WEBP"}
|
|
or image.width * image.height > MAX_IMAGE_PIXELS
|
|
):
|
|
raise ValueError("Unsupported source image")
|
|
size = image.size
|
|
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
|
size = (image.height, image.width)
|
|
normalized = convert(data, "cover", size=size, quality=95)
|
|
except (OSError, ValueError, Image.DecompressionBombError):
|
|
raise FlowError(E.INVALID_REQUEST, "Invalid source image") from None
|
|
asset = self.service.assets.put(normalized, name, "source-images")
|
|
self.db.execute(
|
|
"INSERT INTO source_images VALUES(?,?,?,?,?,?)",
|
|
(
|
|
asset["asset_id"],
|
|
source_id,
|
|
item_id,
|
|
digest,
|
|
encode(caption or {}),
|
|
now_iso(),
|
|
),
|
|
)
|
|
self.db.execute(
|
|
"INSERT INTO asset_sources VALUES(?,?)",
|
|
(
|
|
asset["asset_id"],
|
|
encode(
|
|
{
|
|
"type": "library_source",
|
|
"source_id": source_id,
|
|
"item_id": item_id,
|
|
"source_rendition": "preview"
|
|
if self.service.photo_sources.get(source_id).kind
|
|
== "synology-photos"
|
|
else "source",
|
|
}
|
|
),
|
|
),
|
|
)
|
|
return self.picture(asset["asset_id"])
|
|
|
|
def import_picture(self, source_id, item_id):
|
|
with self.service.lock:
|
|
data, name, caption = self.service.photo_sources.read(source_id, item_id)
|
|
return self.ingest(source_id, item_id, data, name, caption)
|
|
|
|
def picture(self, picture_id):
|
|
row = self.db.one("SELECT * FROM source_images WHERE id=?", (picture_id,))
|
|
if not row:
|
|
raise FlowError(E.NOT_FOUND, "Source picture not found", 404)
|
|
return {
|
|
**self.service.assets.info(picture_id),
|
|
**row,
|
|
"caption": json.loads(row["caption"]),
|
|
**flags(self.db, row["source_id"], row["item_id"]),
|
|
"versions": self.versions(picture_id),
|
|
}
|
|
|
|
def versions(self, picture_id):
|
|
return [
|
|
{
|
|
**self.service.assets.info(r["asset_id"]),
|
|
"source_image_id": r["source_image_id"],
|
|
"plan_id": r["plan_id"],
|
|
"revision": r["revision"],
|
|
"plan": json.loads(r["plan"]),
|
|
"terminal_uses": self.terminal_uses(r["plan_id"], r["asset_id"]),
|
|
}
|
|
for r in self.db.all(
|
|
"SELECT * FROM library_versions WHERE source_image_id=? ORDER BY created_at DESC,asset_id",
|
|
(picture_id,),
|
|
)
|
|
]
|
|
|
|
def terminal_uses(self, plan_id, asset_id):
|
|
return self.db.all(
|
|
"SELECT b.terminal_id,d.operation_id,COALESCE(o.status,'GENERATED') AS status "
|
|
"FROM terminal_bindings b LEFT JOIN terminal_deliveries d ON d.operation_id=(SELECT operation_id FROM terminal_deliveries latest "
|
|
"WHERE latest.terminal_id=b.terminal_id AND latest.asset_id=? ORDER BY latest.created_at DESC,latest.operation_id DESC LIMIT 1) "
|
|
"LEFT JOIN operations o ON o.id=d.operation_id WHERE b.plan_id=? ORDER BY b.terminal_id",
|
|
(asset_id, plan_id),
|
|
)
|
|
|
|
def render(self, data, caption, plan):
|
|
if plan.caption and caption:
|
|
return render_captioned_photo(
|
|
data,
|
|
caption,
|
|
self.service.renderer.font,
|
|
plan.fit,
|
|
plan.profile,
|
|
size=(plan.width, plan.height),
|
|
centering=(plan.center_x, plan.center_y),
|
|
quality=plan.quality,
|
|
respect_fit=True,
|
|
)
|
|
return convert(
|
|
data,
|
|
plan.fit,
|
|
plan.profile,
|
|
size=(plan.width, plan.height),
|
|
centering=(plan.center_x, plan.center_y),
|
|
quality=plan.quality,
|
|
)
|
|
|
|
def generate(self, picture_id, plan_id, snapshot=None):
|
|
with self.service.lock:
|
|
picture = self.picture(picture_id)
|
|
plan = (
|
|
GenerationPlan.model_validate(snapshot)
|
|
if snapshot
|
|
else self.plan(plan_id)
|
|
)
|
|
if picture["source_id"] != plan.source_id:
|
|
raise FlowError(
|
|
E.CONFLICT,
|
|
"Picture and generation plan must share a source library",
|
|
409,
|
|
)
|
|
revision = hashlib.sha256(encode(plan.model_dump()).encode()).hexdigest()
|
|
row = self.db.one(
|
|
"SELECT asset_id FROM library_versions WHERE source_image_id=? AND plan_id=? AND revision=?",
|
|
(picture_id, plan_id, revision),
|
|
)
|
|
if row:
|
|
return next(
|
|
v
|
|
for v in self.versions(picture_id)
|
|
if v["asset_id"] == row["asset_id"]
|
|
)
|
|
data = self.service.assets.path(picture_id).read_bytes()
|
|
asset = self.service.assets.put(
|
|
self.render(data, picture["caption"], plan),
|
|
picture["name"].removesuffix(".jpg") + "-" + plan.name,
|
|
"renditions",
|
|
)
|
|
self.db.execute(
|
|
"INSERT INTO library_versions VALUES(?,?,?,?,?,?)",
|
|
(
|
|
asset["asset_id"],
|
|
picture_id,
|
|
plan_id,
|
|
revision,
|
|
encode(plan.model_dump()),
|
|
now_iso(),
|
|
),
|
|
)
|
|
self.db.execute(
|
|
"INSERT INTO asset_sources VALUES(?,?)",
|
|
(
|
|
asset["asset_id"],
|
|
encode(
|
|
{
|
|
"type": "library_version",
|
|
"source_image_id": picture_id,
|
|
"source_id": plan.source_id,
|
|
"plan_id": plan_id,
|
|
"revision": revision,
|
|
}
|
|
),
|
|
),
|
|
)
|
|
return next(
|
|
v
|
|
for v in self.versions(picture_id)
|
|
if v["asset_id"] == asset["asset_id"]
|
|
)
|
|
|
|
def validate_asset_binding(self, terminal_id, asset_id):
|
|
if self.db.one("SELECT id FROM source_images WHERE id=?", (asset_id,)):
|
|
raise FlowError(
|
|
E.CONFLICT,
|
|
"Generate a terminal version before sending source pixels",
|
|
409,
|
|
{"reason": "libraryGenerateFirst"},
|
|
)
|
|
version = self.db.one(
|
|
"SELECT v.plan_id,s.source_id FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.asset_id=?",
|
|
(asset_id,),
|
|
)
|
|
if version:
|
|
binding = self.binding(terminal_id)
|
|
if (
|
|
not binding
|
|
or binding["source_id"] != version["source_id"]
|
|
or binding["plan_id"] != version["plan_id"]
|
|
):
|
|
raise FlowError(
|
|
E.CONFLICT, "Image no longer matches the terminal binding", 409
|
|
)
|
|
|
|
def send(
|
|
self,
|
|
terminal_id,
|
|
asset_id,
|
|
options=None,
|
|
client_id="admin",
|
|
parent_operation_id=None,
|
|
):
|
|
with self.service.lock:
|
|
binding = self.binding(terminal_id)
|
|
version = self.db.one(
|
|
"SELECT v.*,s.source_id FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.asset_id=?",
|
|
(asset_id,),
|
|
)
|
|
if (
|
|
not binding
|
|
or not version
|
|
or binding["source_id"] != version["source_id"]
|
|
or binding["plan_id"] != version["plan_id"]
|
|
):
|
|
raise FlowError(
|
|
E.CONFLICT,
|
|
"Image does not match the terminal source and plan binding",
|
|
409,
|
|
)
|
|
if terminal_id == "watch":
|
|
raise FlowError(
|
|
E.UNSUPPORTED_CAPABILITY, "Watch transfer is not connected", 422
|
|
)
|
|
request = {
|
|
"device_id": terminal_id,
|
|
"content": {"type": "image", "asset_id": asset_id},
|
|
"options": {
|
|
**(options or {}),
|
|
"cleanup_unstarred": False,
|
|
"generate_thumbnail": False,
|
|
"thumbnail_plan_id": None,
|
|
},
|
|
}
|
|
from pigway_frame_flow.models import DisplayRequest
|
|
|
|
request_model = DisplayRequest.model_validate(request)
|
|
request = request_model.model_dump(mode="json")
|
|
previous = self.db.one(
|
|
"SELECT operation_id FROM terminal_deliveries WHERE terminal_id=? AND asset_id=? "
|
|
+ ("AND parent_operation_id=? " if parent_operation_id else "")
|
|
+ "ORDER BY created_at DESC,operation_id DESC LIMIT 1",
|
|
(terminal_id, asset_id, parent_operation_id)
|
|
if parent_operation_id
|
|
else (terminal_id, asset_id),
|
|
)
|
|
if previous:
|
|
op = self.db.one(
|
|
"SELECT id,status,payload FROM operations WHERE id=?",
|
|
(previous["operation_id"],),
|
|
)
|
|
if op and (
|
|
parent_operation_id
|
|
or (
|
|
op["status"]
|
|
in ("QUEUED", "RENDERING", "UPLOADING", "SYNCING", "UNKNOWN")
|
|
and json.loads(op["payload"]) == request
|
|
)
|
|
):
|
|
return op["id"]
|
|
self.service.validate(request_model)
|
|
# Reuse the queue's durable idempotency receipt if a restart occurs
|
|
# between submitting a child operation and linking its delivery row.
|
|
key = (
|
|
"library-output-"
|
|
+ hashlib.sha256(
|
|
encode([parent_operation_id, terminal_id, asset_id]).encode()
|
|
).hexdigest()
|
|
if parent_operation_id
|
|
else None
|
|
)
|
|
operation = self.service.submit(
|
|
"display",
|
|
request,
|
|
terminal_id,
|
|
client_id,
|
|
"library-" + uuid4().hex,
|
|
key=key,
|
|
)
|
|
self.db.execute(
|
|
"INSERT INTO terminal_deliveries VALUES(?,?,?,?,?)",
|
|
(terminal_id, asset_id, operation, parent_operation_id, now_iso()),
|
|
)
|
|
return operation
|
|
|
|
def pipeline(
|
|
self,
|
|
source_id,
|
|
item_id=None,
|
|
options=None,
|
|
deliver=False,
|
|
operation_id=None,
|
|
caller="admin",
|
|
):
|
|
root_operation = (
|
|
(self.db.one("SELECT * FROM operations WHERE id=?", (operation_id,)) or {})
|
|
if operation_id
|
|
else {}
|
|
)
|
|
previous = (
|
|
json.loads(root_operation["result"] or "{}") if root_operation else {}
|
|
)
|
|
bindings = previous.get("binding_snapshot")
|
|
if bindings is None:
|
|
bindings = self.db.all(
|
|
"SELECT * FROM terminal_bindings WHERE source_id=? ORDER BY terminal_id",
|
|
(source_id,),
|
|
)
|
|
bindings = [
|
|
{**b, "plan_snapshot": self.plan(b["plan_id"]).model_dump()}
|
|
for b in bindings
|
|
]
|
|
if not bindings:
|
|
raise FlowError(
|
|
E.CONFLICT,
|
|
"Bind at least one terminal to this source library first",
|
|
409,
|
|
{"reason": "libraryBindFirst"},
|
|
)
|
|
if operation_id:
|
|
self.service.update(operation_id, "RENDERING")
|
|
if previous.get("source_image_id"):
|
|
selected = self.picture(previous["source_image_id"])
|
|
if selected["source_id"] != source_id:
|
|
raise FlowError(
|
|
E.CONFLICT, "Saved selection belongs to another source library", 409
|
|
)
|
|
else:
|
|
selected = self.service.content_asset(
|
|
PhotoSourceContent(
|
|
type="photo_source", source_id=source_id, item_id=item_id
|
|
),
|
|
operation_id=operation_id,
|
|
source_only=True,
|
|
source_size=max(
|
|
(
|
|
(b["plan_snapshot"]["width"], b["plan_snapshot"]["height"])
|
|
for b in bindings
|
|
),
|
|
key=lambda size: size[0] * size[1],
|
|
),
|
|
)
|
|
result = {
|
|
"source_image_id": selected["asset_id"],
|
|
"binding_snapshot": bindings,
|
|
"outputs": [],
|
|
"errors": [],
|
|
}
|
|
if operation_id:
|
|
self.service.assets.record_origin(selected["asset_id"], root_operation)
|
|
self.service.update(operation_id, "RENDERING", result)
|
|
versions = {}
|
|
for binding in bindings:
|
|
terminal_id, plan_id = binding["terminal_id"], binding["plan_id"]
|
|
try:
|
|
if plan_id not in versions:
|
|
versions[plan_id] = self.generate(
|
|
selected["asset_id"], plan_id, binding["plan_snapshot"]
|
|
)
|
|
version = versions[plan_id]
|
|
if operation_id:
|
|
self.service.assets.record_origin(
|
|
version["asset_id"], root_operation
|
|
)
|
|
output = {
|
|
"terminal_id": terminal_id,
|
|
"asset_id": version["asset_id"],
|
|
"plan_id": plan_id,
|
|
"status": "GENERATED",
|
|
}
|
|
if deliver and terminal_id != "watch":
|
|
output["operation_id"] = self.send(
|
|
terminal_id,
|
|
version["asset_id"],
|
|
options,
|
|
"library-terminal" if operation_id else caller,
|
|
parent_operation_id=operation_id,
|
|
)
|
|
output["status"] = "QUEUED"
|
|
result["outputs"].append(output)
|
|
if operation_id:
|
|
self.service.update(operation_id, "RENDERING", result)
|
|
except (FlowError, OSError) as exc:
|
|
result["errors"].append(
|
|
{
|
|
"terminal_id": terminal_id,
|
|
"plan_id": plan_id,
|
|
"message": str(exc),
|
|
}
|
|
)
|
|
if operation_id:
|
|
self.service.update(
|
|
operation_id,
|
|
"COMPLETED" if not result["errors"] else "FAILED",
|
|
result,
|
|
error={
|
|
"code": E.INVALID_REQUEST,
|
|
"message": "Some terminal outputs failed",
|
|
"request_id": root_operation["request_id"],
|
|
"details": {"reason": "libraryOutputPartial"},
|
|
}
|
|
if result["errors"]
|
|
else None,
|
|
)
|
|
return result
|
|
|
|
def execute(self, operation, payload):
|
|
result = self.pipeline(
|
|
payload["content"]["source_id"],
|
|
payload["content"].get("item_id"),
|
|
payload.get("options"),
|
|
deliver=True,
|
|
operation_id=operation["id"],
|
|
)
|
|
self.db.history(operation, "COMPLETED" if not result["errors"] else "FAILED")
|
|
|
|
def check_asset_references(self, asset_id):
|
|
reference = "%" + asset_id + "%"
|
|
if (
|
|
self.db.one(
|
|
"SELECT id FROM operations WHERE (payload LIKE ? OR result LIKE ?) AND status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
|
|
(reference, reference),
|
|
)
|
|
or self.db.one("SELECT id FROM jobs WHERE payload LIKE ?", (reference,))
|
|
or self.db.one(
|
|
"SELECT template_id FROM template_config WHERE payload LIKE ?",
|
|
(reference,),
|
|
)
|
|
):
|
|
raise FlowError(
|
|
E.CONFLICT,
|
|
"Image is used by a task, template or pending operation",
|
|
409,
|
|
)
|
|
|
|
def remove_asset(self, asset_id):
|
|
self.service.assets.path(asset_id).unlink(missing_ok=True)
|
|
self.db.execute("DELETE FROM library_versions WHERE asset_id=?", (asset_id,))
|
|
self.db.execute("DELETE FROM terminal_deliveries WHERE asset_id=?", (asset_id,))
|
|
self.db.execute("DELETE FROM source_images WHERE id=?", (asset_id,))
|
|
for table, key in [
|
|
("asset_sources", "asset_id"),
|
|
("asset_metadata", "asset_id"),
|
|
("assets", "id"),
|
|
]:
|
|
self.db.execute(f"DELETE FROM {table} WHERE {key}=?", (asset_id,))
|
|
self.db.execute(
|
|
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?",
|
|
(asset_id,),
|
|
)
|
|
|
|
def delete_picture(self, picture_id):
|
|
with self.service.lock:
|
|
self.picture(picture_id)
|
|
ids = [
|
|
r["asset_id"]
|
|
for r in self.db.all(
|
|
"SELECT asset_id FROM library_versions WHERE source_image_id=?",
|
|
(picture_id,),
|
|
)
|
|
] + [picture_id]
|
|
for asset_id in ids:
|
|
self.check_asset_references(asset_id)
|
|
for asset_id in ids:
|
|
self.remove_asset(asset_id)
|
|
|
|
def check_source_delete(self, source_id):
|
|
if (
|
|
self.db.one(
|
|
"SELECT terminal_id FROM terminal_bindings WHERE source_id=?",
|
|
(source_id,),
|
|
)
|
|
or self.db.one(
|
|
"SELECT id FROM library_plans WHERE source_id=? AND archived=0",
|
|
(source_id,),
|
|
)
|
|
or self.db.one(
|
|
"SELECT id FROM source_images WHERE source_id=?", (source_id,)
|
|
)
|
|
):
|
|
raise FlowError(
|
|
E.CONFLICT,
|
|
"Remove terminal bindings, generation plans and local source pictures first",
|
|
409,
|
|
)
|