feat: persist isolated utility export jobs
This commit is contained in:
@@ -63,6 +63,9 @@ class DownloadConfig:
|
||||
max_workers: int = 2
|
||||
process_timeout_seconds: float = 2 * 3600.0
|
||||
max_height: int = 1080
|
||||
export_directory: Path = Path("exports")
|
||||
export_max_height: int = 1080
|
||||
max_queued_exports: int = 20
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -172,6 +175,20 @@ def _optional_path(value: Any, name: str) -> Path | None:
|
||||
return Path(text)
|
||||
|
||||
|
||||
def _relative_directory(value: Any, name: str) -> Path:
|
||||
text = _text(value, name)
|
||||
assert text is not None
|
||||
path = Path(text)
|
||||
if (
|
||||
path.is_absolute()
|
||||
or not path.parts
|
||||
or any(part in {".", ".."} for part in path.parts)
|
||||
or "\\" in text
|
||||
):
|
||||
raise ConfigError(f"{name} must be a safe relative directory")
|
||||
return path
|
||||
|
||||
|
||||
def _env_value(env: Mapping[str, str], key: str, fallback: Any) -> Any:
|
||||
return env[key] if key in env else fallback
|
||||
|
||||
@@ -277,6 +294,19 @@ def load_config(
|
||||
max_workers=_integer(downloads_raw.get("max_workers", 2), "downloads.max_workers", maximum=10),
|
||||
process_timeout_seconds=_number(downloads_raw.get("process_timeout_seconds", 2 * 3600), "downloads.process_timeout_seconds", minimum=1.0),
|
||||
max_height=_integer(downloads_raw.get("max_height", 1080), "downloads.max_height"),
|
||||
export_directory=_relative_directory(
|
||||
downloads_raw.get("export_directory", "exports"),
|
||||
"downloads.export_directory",
|
||||
),
|
||||
export_max_height=_integer(
|
||||
downloads_raw.get("export_max_height", 1080),
|
||||
"downloads.export_max_height",
|
||||
),
|
||||
max_queued_exports=_integer(
|
||||
downloads_raw.get("max_queued_exports", 20),
|
||||
"downloads.max_queued_exports",
|
||||
maximum=1000,
|
||||
),
|
||||
)
|
||||
if downloads.cookie_required and downloads.cookie_path is None:
|
||||
raise ConfigError("downloads.cookie_path is required when cookie_required is true")
|
||||
|
||||
@@ -10,6 +10,7 @@ MEDIA_STATES = frozenset(
|
||||
)
|
||||
LIVE_JOB_STATES = frozenset({"reserved", "running"})
|
||||
LIVE_GUARD_STATES = frozenset({"active", "review_required"})
|
||||
UTILITY_EXPORT_MODES = frozenset({"mp4", "m4a", "mp3"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -54,6 +55,27 @@ class DownloadJob:
|
||||
error: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExportReservation:
|
||||
status: str
|
||||
job_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExportJob:
|
||||
job_id: str
|
||||
mode: str
|
||||
url: str
|
||||
status: str
|
||||
submitter: str | None
|
||||
note: str | None
|
||||
requested_at: float
|
||||
started_at: float | None
|
||||
finished_at: float | None
|
||||
output_paths: tuple[str, ...]
|
||||
error: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeleteToken:
|
||||
slot: int
|
||||
|
||||
+208
-12
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user