feat: persist isolated utility export jobs

This commit is contained in:
Media
2026-08-12 18:21:29 -05:00
parent 8db46df69e
commit b934b69e5a
6 changed files with 423 additions and 14 deletions
+208 -12
View File
@@ -25,6 +25,8 @@ from .models import (
DeleteToken,
DownloadJob,
DownloadReservation,
ExportJob,
ExportReservation,
MigrationError,
MigrationReport,
PlayerObservation,
@@ -33,10 +35,11 @@ from .models import (
SlotProtectedError,
SlotRecord,
StaleOperationError,
UTILITY_EXPORT_MODES,
)
SCHEMA_VERSION = 2
SCHEMA_VERSION = 3
LEGACY_MIGRATION_NAME = "legacy_playlists_yaml_v1"
MAX_LEGACY_YAML_BYTES = 8 * 1024 * 1024
@@ -104,6 +107,24 @@ CREATE UNIQUE INDEX IF NOT EXISTS one_live_download_per_slot
ON download_jobs(slot_number)
WHERE status IN ('reserved','running');
CREATE TABLE IF NOT EXISTS export_jobs (
job_id TEXT PRIMARY KEY,
mode TEXT NOT NULL CHECK (mode IN ('mp4','m4a','mp3')),
url TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN
('queued','running','completed','failed','interrupted')),
submitter TEXT,
note TEXT,
requested_at REAL NOT NULL,
started_at REAL,
finished_at REAL,
output_paths_json TEXT NOT NULL DEFAULT '[]',
error TEXT
);
CREATE INDEX IF NOT EXISTS export_jobs_recent
ON export_jobs(requested_at DESC, job_id DESC);
CREATE TABLE IF NOT EXISTS slot_guards (
guard_id TEXT PRIMARY KEY,
slot_number INTEGER NOT NULL REFERENCES slots(slot_number),
@@ -401,6 +422,13 @@ class ControllerStore:
""",
(now,),
)
connection.execute(
"""
INSERT OR IGNORE INTO schema_migrations(version, name, applied_at)
VALUES (3, 'utility_export_jobs', ?)
""",
(now,),
)
connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
def _upgrade_database(self, connection: sqlite3.Connection) -> None:
@@ -409,26 +437,58 @@ class ControllerStore:
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version == SCHEMA_VERSION:
return
if version != 1:
if version not in {1, 2}:
raise MigrationError(
f"unsupported Controller database schema {version}; expected 1 or {SCHEMA_VERSION}"
f"unsupported Controller database schema {version}; expected 1, 2, or {SCHEMA_VERSION}"
)
connection.execute("BEGIN IMMEDIATE")
try:
columns = {
row["name"]
for row in connection.execute(
"PRAGMA table_info(player_observation)"
).fetchall()
}
if "reconciliation_id" not in columns:
if version < 2:
columns = {
row["name"]
for row in connection.execute(
"PRAGMA table_info(player_observation)"
).fetchall()
}
if "reconciliation_id" not in columns:
connection.execute(
"ALTER TABLE player_observation ADD COLUMN reconciliation_id TEXT"
)
connection.execute(
"ALTER TABLE player_observation ADD COLUMN reconciliation_id TEXT"
"""
INSERT OR IGNORE INTO schema_migrations(version, name, applied_at)
VALUES (2, 'external_playback_reconciliation', ?)
""",
(self._now(),),
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS export_jobs (
job_id TEXT PRIMARY KEY,
mode TEXT NOT NULL CHECK (mode IN ('mp4','m4a','mp3')),
url TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN
('queued','running','completed','failed','interrupted')),
submitter TEXT,
note TEXT,
requested_at REAL NOT NULL,
started_at REAL,
finished_at REAL,
output_paths_json TEXT NOT NULL DEFAULT '[]',
error TEXT
)
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS export_jobs_recent
ON export_jobs(requested_at DESC, job_id DESC)
"""
)
connection.execute(
"""
INSERT OR IGNORE INTO schema_migrations(version, name, applied_at)
VALUES (2, 'external_playback_reconciliation', ?)
VALUES (3, 'utility_export_jobs', ?)
""",
(self._now(),),
)
@@ -837,6 +897,15 @@ class ControllerStore:
row["generation"],
),
)
connection.execute(
"""
UPDATE export_jobs
SET status = 'interrupted', finished_at = ?,
error = 'export interrupted by Controller restart'
WHERE status IN ('queued','running')
""",
(now,),
)
def list_slots(self) -> list[SlotRecord]:
with self._lock:
@@ -1136,6 +1205,133 @@ class ControllerStore:
error=row["error"],
)
@staticmethod
def _row_to_export_job(row: sqlite3.Row) -> ExportJob:
try:
raw_paths = json.loads(row["output_paths_json"])
except (TypeError, ValueError, json.JSONDecodeError):
raw_paths = []
output_paths = tuple(
value for value in raw_paths if isinstance(value, str)
) if isinstance(raw_paths, list) else ()
return ExportJob(
job_id=row["job_id"],
mode=row["mode"],
url=row["url"],
status=row["status"],
submitter=row["submitter"],
note=row["note"],
requested_at=row["requested_at"],
started_at=row["started_at"],
finished_at=row["finished_at"],
output_paths=output_paths,
error=row["error"],
)
def reserve_export_job(
self,
url: str,
mode: str,
submitter: str | None,
note: str | None,
*,
max_active: int,
job_id: str | None = None,
) -> ExportReservation:
if not isinstance(url, str) or not url.strip():
raise ValueError("export URL must be non-empty")
if mode not in UTILITY_EXPORT_MODES:
raise ValueError("unsupported utility export mode")
if not isinstance(max_active, int) or isinstance(max_active, bool) or max_active < 1:
raise ValueError("max_active must be a positive integer")
job_id = job_id or uuid.uuid4().hex
with self._transaction() as connection:
active = connection.execute(
"""
SELECT COUNT(*) FROM export_jobs
WHERE status IN ('queued','running')
"""
).fetchone()[0]
if active >= max_active:
return ExportReservation(status="queue_full")
connection.execute(
"""
INSERT INTO export_jobs
(job_id, mode, url, status, submitter, note, requested_at)
VALUES (?, ?, ?, 'queued', ?, ?, ?)
""",
(job_id, mode, url.strip(), submitter, note, self._now()),
)
return ExportReservation(status="queued", job_id=job_id)
def mark_export_running(self, job_id: str) -> bool:
with self._transaction() as connection:
return bool(
connection.execute(
"""
UPDATE export_jobs SET status = 'running', started_at = ?
WHERE job_id = ? AND status = 'queued'
""",
(self._now(), job_id),
).rowcount
)
def complete_export_job(
self,
job_id: str,
output_paths: Sequence[str],
) -> bool:
normalized = tuple(output_paths)
if not normalized or any(
not isinstance(path, str) or not path for path in normalized
):
raise ValueError("completed export must contain output paths")
with self._transaction() as connection:
return bool(
connection.execute(
"""
UPDATE export_jobs
SET status = 'completed', finished_at = ?,
output_paths_json = ?, error = NULL
WHERE job_id = ? AND status = 'running'
""",
(self._now(), json.dumps(normalized), job_id),
).rowcount
)
def fail_export_job(self, job_id: str, error: str) -> bool:
with self._transaction() as connection:
return bool(
connection.execute(
"""
UPDATE export_jobs
SET status = 'failed', finished_at = ?, error = ?
WHERE job_id = ? AND status IN ('queued','running')
""",
(self._now(), error, job_id),
).rowcount
)
def get_export_job(self, job_id: str) -> ExportJob | None:
with self._lock:
row = self.connection.execute(
"SELECT * FROM export_jobs WHERE job_id = ?", (job_id,)
).fetchone()
return None if row is None else self._row_to_export_job(row)
def list_export_jobs(self, *, limit: int = 20) -> list[ExportJob]:
if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 100:
raise ValueError("export job limit must be between 1 and 100")
with self._lock:
rows = self.connection.execute(
"""
SELECT * FROM export_jobs
ORDER BY requested_at DESC, job_id DESC LIMIT ?
""",
(limit,),
).fetchall()
return [self._row_to_export_job(row) for row in rows]
def protect_slot(
self,
slot: int,