from __future__ import annotations import json import sqlite3 from contextlib import contextmanager from pathlib import Path from typing import Any from musicdl.catalogsync.db import connect_database from .jobdefs import DOWNLOAD_LANE, display_name, job_lane_type, primary_stage_type from .models import ItemStatus, JobItem, JobRun, JobStatus, JobStage, StageStatus def _json_dumps(data: dict[str, Any] | None) -> str | None: if data is None: return None return json.dumps(data, ensure_ascii=False) def _json_loads(data: str | None) -> dict[str, Any]: if not data: return {} decoded = json.loads(data) if isinstance(decoded, dict): return decoded return {} def _encode_sources(values: list[str] | None) -> str | None: if not values: return None normalized = [value.strip() for value in values if value and value.strip()] if not normalized: return None return ",".join(normalized) def _decode_sources(value: str | None) -> list[str]: if not value: return [] return [item.strip() for item in str(value).split(",") if item.strip()] def _progress_percent(completed: int, total: int) -> int: normalized_total = max(int(total or 0), 0) normalized_completed = max(int(completed or 0), 0) if normalized_total <= 0: return 0 if normalized_completed >= normalized_total: return 100 return int((normalized_completed * 100) / normalized_total) def _format_speed_text(speed_bytes_per_sec: int) -> str: speed_value = int(speed_bytes_per_sec or 0) if speed_value <= 0: return "" if speed_value >= 1024 * 1024: return f"{speed_value / (1024 * 1024):.1f} MB/s" if speed_value >= 1024: return f"{speed_value / 1024:.1f} KB/s" return f"{speed_value} B/s" def _scope_summary(playlist_scope: dict[str, Any] | None) -> str: playlist_ids = (playlist_scope or {}).get("playlist_ids") if not isinstance(playlist_ids, list): return "all playlists" count = len([value for value in playlist_ids if value is not None]) if count <= 0: return "all playlists" suffix = "playlist" if count == 1 else "playlists" return f"{count} {suffix}" def _completed_stage_items(stage: JobStage | None) -> int: if stage is None: return 0 total_items = max(int(stage.total_items or 0), 0) pending_items = max(int(stage.pending_items or 0), 0) running_items = max(int(stage.running_items or 0), 0) return max(total_items - pending_items - running_items, 0) def _primary_progress_percent(stage: JobStage | None, worker_progress_values: list[float]) -> int: if stage is not None and int(stage.total_items or 0) > 0: return _progress_percent(_completed_stage_items(stage), int(stage.total_items or 0)) if not worker_progress_values: return 0 return int(max(worker_progress_values)) def _primary_progress_text( stage: JobStage | None, worker_progress_texts: list[str], ) -> str: for text in worker_progress_texts: normalized = str(text or "").strip() if normalized: return normalized if stage is None: return "" completed_items = _completed_stage_items(stage) total_items = max(int(stage.total_items or 0), 0) noun = "songs" if str(stage.stage_type) == "download" else "items" return f"{completed_items} / {total_items} {noun}" _STAGE_COUNTER_BY_STATUS: dict[ItemStatus, str] = { ItemStatus.PENDING: "pending_items", ItemStatus.RUNNING: "running_items", ItemStatus.SUCCEEDED: "success_items", ItemStatus.FAILED: "failed_items", ItemStatus.SKIPPED: "skipped_items", } class OpsRepository: def __init__(self, db_path: str | Path): self.db_path = Path(db_path) def _connect(self) -> sqlite3.Connection: return connect_database(self.db_path) @contextmanager def _connection(self): conn = self._connect() try: yield conn conn.commit() finally: conn.close() def _fetchone(self, query: str, params: tuple[Any, ...] = ()) -> sqlite3.Row | None: with self._connection() as conn: return conn.execute(query, params).fetchone() def _fetchall(self, query: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]: with self._connection() as conn: return conn.execute(query, params).fetchall() def _row_to_job(self, row: sqlite3.Row) -> JobRun: return JobRun( id=int(row["id"]), job_type=str(row["job_type"]), status=JobStatus(str(row["status"])), priority=int(row["priority"]), requested_by=row["requested_by"], config_snapshot=_json_loads(row["config_snapshot_json"]), sources=_decode_sources(row["sources"]), download_sources=_decode_sources(row["download_sources"]), playlist_scope=_json_loads(row["playlist_scope_json"]), created_at=row["created_at"], started_at=row["started_at"], ended_at=row["ended_at"], last_error=row["last_error"], resume_token=row["resume_token"], ) def _row_to_stage(self, row: sqlite3.Row) -> JobStage: return JobStage( id=int(row["id"]), job_run_id=int(row["job_run_id"]), stage_type=str(row["stage_type"]), seq_no=int(row["seq_no"]), status=StageStatus(str(row["status"])), total_items=int(row["total_items"]), pending_items=int(row["pending_items"]), running_items=int(row["running_items"]), success_items=int(row["success_items"]), failed_items=int(row["failed_items"]), skipped_items=int(row["skipped_items"]), started_at=row["started_at"], ended_at=row["ended_at"], last_error=row["last_error"], ) def _row_to_item(self, row: sqlite3.Row) -> JobItem: return JobItem( id=int(row["id"]), job_stage_id=int(row["job_stage_id"]), item_type=str(row["item_type"]), item_key=str(row["item_key"]), playlist_pool_id=row["playlist_pool_id"], playlist_id=row["playlist_id"], song_id=row["song_id"], file_location_id=row["file_location_id"], status=ItemStatus(str(row["status"])), attempt_count=int(row["attempt_count"]), max_attempts=int(row["max_attempts"]), worker_id=row["worker_id"], started_at=row["started_at"], ended_at=row["ended_at"], last_error=row["last_error"], last_error_code=row["last_error_code"], payload=_json_loads(row["payload_json"]), ) def create_job( self, job_type: str, config_snapshot: dict[str, Any], requested_by: str | None = None, sources: list[str] | None = None, download_sources: list[str] | None = None, playlist_scope: dict[str, Any] | None = None, status: JobStatus = JobStatus.QUEUED, ) -> int: if config_snapshot is None: raise ValueError("config_snapshot is required") with self._connection() as conn: return int( conn.execute( """ INSERT INTO job_runs ( job_type, status, requested_by, config_snapshot_json, sources, download_sources, playlist_scope_json ) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( job_type, status.value, requested_by, _json_dumps(config_snapshot), _encode_sources(sources), _encode_sources(download_sources), _json_dumps(playlist_scope), ), ).lastrowid ) def get_job(self, job_id: int) -> JobRun | None: row = self._fetchone("SELECT * FROM job_runs WHERE id = ?", (job_id,)) if row is None: return None return self._row_to_job(row) def _normalize_playlist_scope_ids(self, playlist_scope: dict[str, Any] | None) -> list[int]: raw_values = (playlist_scope or {}).get("playlist_ids") if not isinstance(raw_values, list): return [] normalized_ids: list[int] = [] seen: set[int] = set() for value in raw_values: playlist_id = self._coerce_int(value) if playlist_id is None or playlist_id <= 0 or playlist_id in seen: continue normalized_ids.append(playlist_id) seen.add(playlist_id) return normalized_ids def create_stage( self, job_run_id: int, stage_type: str, seq_no: int, status: StageStatus = StageStatus.PENDING, ) -> int: with self._connection() as conn: return int( conn.execute( """ INSERT INTO job_stages (job_run_id, stage_type, seq_no, status) VALUES (?, ?, ?, ?) """, (job_run_id, stage_type, seq_no, status.value), ).lastrowid ) def get_stage(self, stage_id: int) -> JobStage | None: row = self._fetchone("SELECT * FROM job_stages WHERE id = ?", (stage_id,)) if row is None: return None return self._row_to_stage(row) def create_item( self, job_stage_id: int, item_type: str, item_key: str, playlist_pool_id: int | None = None, playlist_id: int | None = None, song_id: int | None = None, file_location_id: int | None = None, payload: dict[str, Any] | None = None, max_attempts: int = 3, status: ItemStatus = ItemStatus.PENDING, ) -> int: with self._connection() as conn: item_id = int( conn.execute( """ INSERT INTO job_items ( job_stage_id, item_type, item_key, playlist_pool_id, playlist_id, song_id, file_location_id, status, max_attempts, payload_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( job_stage_id, item_type, item_key, playlist_pool_id, playlist_id, song_id, file_location_id, status.value, max_attempts, _json_dumps(payload), ), ).lastrowid ) conn.execute( """ UPDATE job_stages SET total_items = total_items + 1, pending_items = pending_items + CASE WHEN ? = 'pending' THEN 1 ELSE 0 END, running_items = running_items + CASE WHEN ? = 'running' THEN 1 ELSE 0 END, success_items = success_items + CASE WHEN ? = 'succeeded' THEN 1 ELSE 0 END, failed_items = failed_items + CASE WHEN ? = 'failed' THEN 1 ELSE 0 END, skipped_items = skipped_items + CASE WHEN ? = 'skipped' THEN 1 ELSE 0 END WHERE id = ? """, ( status.value, status.value, status.value, status.value, status.value, job_stage_id, ), ) return item_id def get_item(self, item_id: int) -> JobItem | None: row = self._fetchone("SELECT * FROM job_items WHERE id = ?", (item_id,)) if row is None: return None return self._row_to_item(row) def _adjust_stage_item_counters( self, conn: sqlite3.Connection, *, stage_id: int, from_status: ItemStatus | None, to_status: ItemStatus | None, ) -> None: if from_status == to_status: return from_counter = _STAGE_COUNTER_BY_STATUS.get(from_status) if from_status else None to_counter = _STAGE_COUNTER_BY_STATUS.get(to_status) if to_status else None if from_counter: conn.execute( f""" UPDATE job_stages SET {from_counter} = CASE WHEN {from_counter} > 0 THEN {from_counter} - 1 ELSE 0 END WHERE id = ? """, (stage_id,), ) if to_counter: conn.execute( f""" UPDATE job_stages SET {to_counter} = {to_counter} + 1 WHERE id = ? """, (stage_id,), ) def _recalculate_stage_counters(self, conn: sqlite3.Connection, stage_id: int) -> None: row = conn.execute( """ SELECT COUNT(*) AS total_items, SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending_items, SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running_items, SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END) AS success_items, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_items, SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END) AS skipped_items FROM job_items WHERE job_stage_id = ? """, (stage_id,), ).fetchone() if row is None: return conn.execute( """ UPDATE job_stages SET total_items = ?, pending_items = ?, running_items = ?, success_items = ?, failed_items = ?, skipped_items = ? WHERE id = ? """, ( int(row["total_items"] or 0), int(row["pending_items"] or 0), int(row["running_items"] or 0), int(row["success_items"] or 0), int(row["failed_items"] or 0), int(row["skipped_items"] or 0), int(stage_id), ), ) def list_recoverable_jobs(self) -> list[JobRun]: rows = self._fetchall( """ SELECT * FROM job_runs WHERE status IN (?, ?) ORDER BY priority ASC, created_at ASC, id ASC """, (JobStatus.RUNNING.value, JobStatus.PAUSE_REQUESTED.value), ) return [self._row_to_job(row) for row in rows] def pause_job_for_recovery(self, job_id: int) -> None: with self._connection() as conn: conn.execute( """ UPDATE job_runs SET status = ?, ended_at = CURRENT_TIMESTAMP WHERE id = ? """, (JobStatus.PAUSED.value, job_id), ) conn.execute( """ UPDATE job_stages SET status = ?, ended_at = CURRENT_TIMESTAMP WHERE job_run_id = ? AND status IN (?, ?) """, ( StageStatus.PAUSED.value, job_id, StageStatus.RUNNING.value, StageStatus.PAUSE_REQUESTED.value, ), ) def list_running_items(self, job_id: int) -> list[JobItem]: rows = self._fetchall( """ SELECT i.* FROM job_items AS i JOIN job_stages AS s ON s.id = i.job_stage_id WHERE s.job_run_id = ? AND i.status = ? ORDER BY i.id ASC """, (job_id, ItemStatus.RUNNING.value), ) return [self._row_to_item(row) for row in rows] def mark_item_interrupted(self, item_id: int, last_error: str | None = None) -> bool: with self._connection() as conn: item_row = conn.execute( "SELECT job_stage_id, status, worker_id FROM job_items WHERE id = ?", (item_id,), ).fetchone() if item_row is None: return False from_status = ItemStatus(str(item_row["status"])) if from_status is not ItemStatus.RUNNING: return False cursor = conn.execute( """ UPDATE job_items SET status = ?, worker_id = NULL, ended_at = CURRENT_TIMESTAMP, last_error = ?, last_error_code = NULL WHERE id = ? AND status = ? """, (ItemStatus.INTERRUPTED.value, last_error, item_id, from_status.value), ) if cursor.rowcount != 1: return False self._adjust_stage_item_counters( conn, stage_id=int(item_row["job_stage_id"]), from_status=from_status, to_status=ItemStatus.INTERRUPTED, ) worker_id = item_row["worker_id"] if worker_id is not None: conn.execute( """ UPDATE job_workers SET status = 'idle', current_job_item_id = NULL, current_song_id = NULL, current_playlist_id = NULL, current_display_text = NULL, heartbeat_at = CURRENT_TIMESTAMP WHERE id = ? """, (int(worker_id),), ) return True def add_job_event( self, job_id: int, event_type: str, message: str | None = None, *, stage_id: int | None = None, item_id: int | None = None, worker_id: int | None = None, details: dict[str, Any] | None = None, ) -> int: with self._connection() as conn: return int( conn.execute( """ INSERT INTO job_events ( job_run_id, job_stage_id, job_item_id, worker_id, event_type, message, details_json ) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( job_id, stage_id, item_id, worker_id, event_type, message, _json_dumps(details), ), ).lastrowid ) def create_command( self, job_run_id: int, command_type: str, target_item_id: int | None = None, payload: dict[str, Any] | None = None, ) -> int: with self._connection() as conn: return int( conn.execute( """ INSERT INTO job_commands ( job_run_id, command_type, target_item_id, payload_json ) VALUES (?, ?, ?, ?) """, (job_run_id, command_type, target_item_id, _json_dumps(payload)), ).lastrowid ) def list_pending_commands(self) -> list[dict[str, Any]]: rows = self._fetchall( """ SELECT * FROM job_commands WHERE status = 'pending' ORDER BY id ASC """ ) return [dict(row) for row in rows] def request_job_pause(self, job_id: int) -> None: with self._connection() as conn: conn.execute( """ UPDATE job_runs SET status = ? WHERE id = ? AND status IN (?, ?, ?) """, ( JobStatus.PAUSE_REQUESTED.value, job_id, JobStatus.RUNNING.value, JobStatus.QUEUED.value, JobStatus.PAUSE_REQUESTED.value, ), ) conn.execute( """ UPDATE job_stages SET status = ? WHERE job_run_id = ? AND status = ? """, ( StageStatus.PAUSE_REQUESTED.value, job_id, StageStatus.RUNNING.value, ), ) def resume_job(self, job_id: int) -> None: with self._connection() as conn: interrupted_counts = conn.execute( """ SELECT job_stage_id, COUNT(*) AS count_value FROM job_items AS i JOIN job_stages AS s ON s.id = i.job_stage_id WHERE s.job_run_id = ? AND i.status = ? GROUP BY job_stage_id """, (job_id, ItemStatus.INTERRUPTED.value), ).fetchall() conn.execute( """ UPDATE job_runs SET status = ?, ended_at = NULL WHERE id = ? AND status IN (?, ?) """, ( JobStatus.QUEUED.value, job_id, JobStatus.PAUSED.value, JobStatus.PAUSE_REQUESTED.value, ), ) conn.execute( """ UPDATE job_stages SET status = ?, ended_at = NULL WHERE job_run_id = ? AND status IN (?, ?) """, ( StageStatus.PENDING.value, job_id, StageStatus.PAUSED.value, StageStatus.PAUSE_REQUESTED.value, ), ) conn.execute( """ UPDATE job_items SET status = ?, worker_id = NULL, started_at = NULL, ended_at = NULL, last_error = NULL, last_error_code = NULL WHERE job_stage_id IN ( SELECT id FROM job_stages WHERE job_run_id = ? ) AND status = ? """, ( ItemStatus.PENDING.value, job_id, ItemStatus.INTERRUPTED.value, ), ) for row in interrupted_counts: conn.execute( """ UPDATE job_stages SET pending_items = pending_items + ? WHERE id = ? """, (int(row["count_value"] or 0), int(row["job_stage_id"])), ) def cancel_job(self, job_id: int) -> bool: with self._connection() as conn: job_row = conn.execute( "SELECT id, status FROM job_runs WHERE id = ?", (job_id,), ).fetchone() if job_row is None: return False if str(job_row["status"]) not in { JobStatus.QUEUED.value, JobStatus.RUNNING.value, JobStatus.PAUSE_REQUESTED.value, JobStatus.PAUSED.value, }: return False stage_rows = conn.execute( "SELECT id, status FROM job_stages WHERE job_run_id = ?", (job_id,), ).fetchall() stage_ids = [int(row["id"]) for row in stage_rows] if stage_ids: placeholders = ", ".join("?" for _ in stage_ids) conn.execute( f""" UPDATE job_items SET status = ?, worker_id = NULL, ended_at = CURRENT_TIMESTAMP, last_error = ?, last_error_code = NULL WHERE job_stage_id IN ({placeholders}) AND status = ? """, ( ItemStatus.CANCELED.value, "Job canceled by operator.", *stage_ids, ItemStatus.PENDING.value, ), ) for stage_id in stage_ids: self._recalculate_stage_counters(conn, stage_id) conn.execute( """ UPDATE job_stages SET status = ?, ended_at = CURRENT_TIMESTAMP, last_error = COALESCE(last_error, ?) WHERE job_run_id = ? AND status IN (?, ?, ?) """, ( StageStatus.SKIPPED.value, "Job canceled by operator.", job_id, StageStatus.PENDING.value, StageStatus.PAUSE_REQUESTED.value, StageStatus.PAUSED.value, ), ) conn.execute( """ UPDATE job_stages SET status = ?, ended_at = CURRENT_TIMESTAMP, last_error = COALESCE(last_error, ?) WHERE job_run_id = ? AND status = ? AND running_items = 0 """, ( StageStatus.SKIPPED.value, "Job canceled by operator.", job_id, StageStatus.RUNNING.value, ), ) conn.execute( """ UPDATE job_workers SET status = 'idle', current_job_item_id = NULL, current_song_id = NULL, current_playlist_id = NULL, current_display_text = NULL, heartbeat_at = CURRENT_TIMESTAMP WHERE job_run_id = ? """, (job_id,), ) conn.execute( """ UPDATE job_runs SET status = ?, ended_at = CURRENT_TIMESTAMP, last_error = COALESCE(last_error, ?) WHERE id = ? """, ( JobStatus.CANCELED.value, "Job canceled by operator.", job_id, ), ) return True def finalize_canceled_job(self, job_id: int) -> None: with self._connection() as conn: conn.execute( """ UPDATE job_stages SET status = ?, ended_at = CURRENT_TIMESTAMP, last_error = COALESCE(last_error, ?) WHERE job_run_id = ? AND status = ? AND running_items = 0 """, ( StageStatus.SKIPPED.value, "Job canceled by operator.", job_id, StageStatus.RUNNING.value, ), ) conn.execute( """ UPDATE job_runs SET status = ?, ended_at = COALESCE(ended_at, CURRENT_TIMESTAMP), last_error = COALESCE(last_error, ?) WHERE id = ? """, ( JobStatus.CANCELED.value, "Job canceled by operator.", job_id, ), ) def requeue_item(self, item_id: int, force: bool, job_id: int | None = None) -> bool: with self._connection() as conn: query = """ SELECT i.job_stage_id, i.status, i.attempt_count, i.max_attempts, s.job_run_id, s.status AS stage_status, j.status AS job_status FROM job_items AS i JOIN job_stages AS s ON s.id = i.job_stage_id JOIN job_runs AS j ON j.id = s.job_run_id WHERE i.id = ? """ params: list[Any] = [item_id] if job_id is not None: query += " AND s.job_run_id = ?" params.append(job_id) row = conn.execute(query, tuple(params)).fetchone() if row is None: return False status = ItemStatus(str(row["status"])) if status not in {ItemStatus.FAILED, ItemStatus.INTERRUPTED, ItemStatus.CANCELED}: return False is_exhausted = int(row["attempt_count"]) >= int(row["max_attempts"]) if is_exhausted and not force: return False cursor = conn.execute( """ UPDATE job_items SET status = ?, worker_id = NULL, started_at = NULL, ended_at = NULL, last_error = NULL, last_error_code = NULL WHERE id = ? AND status = ? """, (ItemStatus.PENDING.value, item_id, status.value), ) if cursor.rowcount != 1: return False self._adjust_stage_item_counters( conn, stage_id=int(row["job_stage_id"]), from_status=status, to_status=ItemStatus.PENDING, ) if str(row["stage_status"]) in { StageStatus.COMPLETED.value, StageStatus.FAILED.value, StageStatus.PAUSED.value, StageStatus.SKIPPED.value, }: conn.execute( """ UPDATE job_stages SET status = ?, ended_at = NULL, last_error = NULL WHERE id = ? """, (StageStatus.PENDING.value, int(row["job_stage_id"])), ) if str(row["job_status"]) in { JobStatus.COMPLETED.value, JobStatus.COMPLETED_WITH_ERRORS.value, JobStatus.FAILED.value, JobStatus.PAUSED.value, JobStatus.CANCELED.value, }: conn.execute( """ UPDATE job_runs SET status = ?, ended_at = NULL, last_error = NULL WHERE id = ? """, (JobStatus.QUEUED.value, int(row["job_run_id"])), ) return True def mark_command_applied(self, command_id: int) -> None: with self._connection() as conn: conn.execute( """ UPDATE job_commands SET status = 'applied', applied_at = COALESCE(applied_at, CURRENT_TIMESTAMP) WHERE id = ? """, (command_id,), ) def job_has_running_items(self, job_id: int) -> bool: row = self._fetchone( """ SELECT COUNT(1) AS cnt FROM job_items AS i JOIN job_stages AS s ON s.id = i.job_stage_id WHERE s.job_run_id = ? AND i.status = ? """, (job_id, ItemStatus.RUNNING.value), ) return bool(row and int(row["cnt"]) > 0) def finalize_pause(self, job_id: int) -> None: with self._connection() as conn: conn.execute( """ UPDATE job_runs SET status = ?, ended_at = CURRENT_TIMESTAMP WHERE id = ? AND status = ? """, ( JobStatus.PAUSED.value, job_id, JobStatus.PAUSE_REQUESTED.value, ), ) conn.execute( """ UPDATE job_stages SET status = ?, ended_at = CURRENT_TIMESTAMP WHERE job_run_id = ? AND status = ? """, ( StageStatus.PAUSED.value, job_id, StageStatus.PAUSE_REQUESTED.value, ), ) def claim_next_runnable_job(self) -> JobRun | None: row = self._fetchone( """ SELECT * FROM job_runs WHERE status = ? ORDER BY priority ASC, created_at ASC, id ASC LIMIT 1 """, (JobStatus.QUEUED.value,), ) if row is None: return None return self._row_to_job(row) def claim_and_mark_next_runnable_job(self) -> JobRun | None: with self._connection() as conn: while True: row = conn.execute( """ SELECT * FROM job_runs WHERE status = ? ORDER BY priority ASC, created_at ASC, id ASC LIMIT 1 """, (JobStatus.QUEUED.value,), ).fetchone() if row is None: return None cursor = conn.execute( """ UPDATE job_runs SET status = ?, started_at = COALESCE(started_at, CURRENT_TIMESTAMP), ended_at = NULL WHERE id = ? AND status = ? """, (JobStatus.RUNNING.value, int(row["id"]), JobStatus.QUEUED.value), ) if cursor.rowcount != 1: continue updated = conn.execute( "SELECT * FROM job_runs WHERE id = ?", (int(row["id"]),), ).fetchone() if updated is None: return None return self._row_to_job(updated) def mark_job_running(self, job_id: int) -> bool: with self._connection() as conn: cursor = conn.execute( """ UPDATE job_runs SET status = ?, started_at = COALESCE(started_at, CURRENT_TIMESTAMP), ended_at = NULL WHERE id = ? AND status IN (?, ?) """, ( JobStatus.RUNNING.value, job_id, JobStatus.QUEUED.value, JobStatus.RUNNING.value, ), ) return cursor.rowcount == 1 def mark_stage_running(self, stage_id: int) -> None: with self._connection() as conn: conn.execute( """ UPDATE job_stages SET status = ?, started_at = COALESCE(started_at, CURRENT_TIMESTAMP), ended_at = NULL WHERE id = ? """, (StageStatus.RUNNING.value, stage_id), ) def get_active_job(self) -> JobRun | None: row = self._fetchone( """ SELECT * FROM job_runs WHERE status IN (?, ?) ORDER BY CASE status WHEN ? THEN 0 WHEN ? THEN 1 ELSE 2 END, COALESCE(started_at, created_at) ASC, id ASC LIMIT 1 """, ( JobStatus.RUNNING.value, JobStatus.PAUSE_REQUESTED.value, JobStatus.RUNNING.value, JobStatus.PAUSE_REQUESTED.value, ), ) if row is None: return None return self._row_to_job(row) def list_active_jobs(self) -> list[JobRun]: rows = self._fetchall( """ SELECT * FROM job_runs WHERE status IN (?, ?) ORDER BY CASE status WHEN ? THEN 0 WHEN ? THEN 1 ELSE 2 END, priority ASC, COALESCE(started_at, created_at) ASC, id ASC """, ( JobStatus.RUNNING.value, JobStatus.PAUSE_REQUESTED.value, JobStatus.RUNNING.value, JobStatus.PAUSE_REQUESTED.value, ), ) return [self._row_to_job(row) for row in rows] def list_queued_jobs(self, limit: int | None = None) -> list[JobRun]: query = [ """ SELECT * FROM job_runs WHERE status = ? ORDER BY priority ASC, created_at ASC, id ASC """ ] params: list[Any] = [JobStatus.QUEUED.value] if limit is not None: query.append("LIMIT ?") params.append(int(limit)) rows = self._fetchall("\n".join(query), tuple(params)) return [self._row_to_job(row) for row in rows] def claim_job_if_queued(self, job_id: int) -> JobRun | None: with self._connection() as conn: cursor = conn.execute( """ UPDATE job_runs SET status = ?, started_at = COALESCE(started_at, CURRENT_TIMESTAMP), ended_at = NULL WHERE id = ? AND status = ? """, (JobStatus.RUNNING.value, int(job_id), JobStatus.QUEUED.value), ) if cursor.rowcount != 1: return None row = conn.execute("SELECT * FROM job_runs WHERE id = ?", (int(job_id),)).fetchone() if row is None: return None return self._row_to_job(row) def list_job_stages(self, job_id: int) -> list[JobStage]: rows = self._fetchall( """ SELECT * FROM job_stages WHERE job_run_id = ? ORDER BY seq_no ASC, id ASC """, (job_id,), ) return [self._row_to_stage(row) for row in rows] def mark_stage_finished( self, stage_id: int, *, status: StageStatus, last_error: str | None = None, ) -> None: with self._connection() as conn: conn.execute( """ UPDATE job_stages SET status = ?, ended_at = CURRENT_TIMESTAMP, last_error = ? WHERE id = ? """, (status.value, last_error, stage_id), ) def mark_job_finished( self, job_id: int, *, status: JobStatus, last_error: str | None = None, ) -> None: with self._connection() as conn: conn.execute( """ UPDATE job_runs SET status = ?, ended_at = CURRENT_TIMESTAMP, last_error = ? WHERE id = ? """, (status.value, last_error, job_id), ) def claim_next_stage_item(self, stage_id: int, worker_name: str) -> JobItem | None: normalized_worker = str(worker_name).strip() or "worker" conn = self._connect() try: conn.execute("BEGIN IMMEDIATE") row = conn.execute( """ SELECT i.*, s.job_run_id FROM job_items AS i JOIN job_stages AS s ON s.id = i.job_stage_id WHERE i.job_stage_id = ? AND i.status = ? ORDER BY i.id ASC LIMIT 1 """, (stage_id, ItemStatus.PENDING.value), ).fetchone() if row is None: conn.commit() return None worker_row = conn.execute( """ SELECT id FROM job_workers WHERE worker_name = ? AND job_stage_id = ? ORDER BY id DESC LIMIT 1 """, (normalized_worker, stage_id), ).fetchone() if worker_row is None: worker_id = int( conn.execute( """ INSERT INTO job_workers ( job_run_id, job_stage_id, worker_name, status, current_job_item_id, heartbeat_at ) VALUES (?, ?, ?, 'running', ?, CURRENT_TIMESTAMP) """, ( int(row["job_run_id"]), stage_id, normalized_worker, int(row["id"]), ), ).lastrowid ) else: worker_id = int(worker_row["id"]) conn.execute( """ UPDATE job_workers SET status = 'running', current_job_item_id = ?, current_song_id = NULL, current_playlist_id = NULL, current_display_text = NULL, last_progress_text = NULL, downloaded_bytes = 0, total_bytes = 0, speed_bytes_per_sec = 0, progress_percent = 0, heartbeat_at = CURRENT_TIMESTAMP WHERE id = ? """, (int(row["id"]), worker_id), ) updated = conn.execute( """ UPDATE job_items SET status = ?, worker_id = ?, attempt_count = attempt_count + 1, started_at = COALESCE(started_at, CURRENT_TIMESTAMP), ended_at = NULL WHERE id = ? AND status = ? """, ( ItemStatus.RUNNING.value, worker_id, int(row["id"]), ItemStatus.PENDING.value, ), ) if updated.rowcount != 1: conn.rollback() return None self._adjust_stage_item_counters( conn, stage_id=stage_id, from_status=ItemStatus.PENDING, to_status=ItemStatus.RUNNING, ) claimed = conn.execute( "SELECT * FROM job_items WHERE id = ?", (int(row["id"]),), ).fetchone() conn.commit() if claimed is None: return None return self._row_to_item(claimed) finally: conn.close() def mark_item_running(self, item_id: int, worker_id: int | None) -> bool: with self._connection() as conn: row = conn.execute( "SELECT job_stage_id, status FROM job_items WHERE id = ?", (item_id,), ).fetchone() if row is None: return False from_status = ItemStatus(str(row["status"])) if from_status is not ItemStatus.PENDING: return False cursor = conn.execute( """ UPDATE job_items SET status = ?, worker_id = ?, attempt_count = attempt_count + 1, started_at = COALESCE(started_at, CURRENT_TIMESTAMP), ended_at = NULL WHERE id = ? AND status = ? """, (ItemStatus.RUNNING.value, worker_id, item_id, from_status.value), ) if cursor.rowcount != 1: return False self._adjust_stage_item_counters( conn, stage_id=int(row["job_stage_id"]), from_status=from_status, to_status=ItemStatus.RUNNING, ) return True def claim_item(self, item_id: int, worker_name: str) -> JobItem: normalized_worker = str(worker_name).strip() or "worker" with self._connection() as conn: row = conn.execute( """ SELECT i.*, s.job_run_id FROM job_items AS i JOIN job_stages AS s ON s.id = i.job_stage_id WHERE i.id = ? """, (item_id,), ).fetchone() if row is None: raise RuntimeError(f"Unknown item: {item_id}") from_status = ItemStatus(str(row["status"])) if from_status is not ItemStatus.PENDING: raise RuntimeError( f"Item {item_id} is not claimable: expected {ItemStatus.PENDING.value}, got {from_status.value}" ) worker_row = conn.execute( """ SELECT id FROM job_workers WHERE worker_name = ? AND job_stage_id = ? ORDER BY id DESC LIMIT 1 """, (normalized_worker, int(row["job_stage_id"])), ).fetchone() if worker_row is None: worker_id = int( conn.execute( """ INSERT INTO job_workers ( job_run_id, job_stage_id, worker_name, status, current_job_item_id, heartbeat_at ) VALUES (?, ?, ?, 'running', ?, CURRENT_TIMESTAMP) """, (int(row["job_run_id"]), int(row["job_stage_id"]), normalized_worker, item_id), ).lastrowid ) else: worker_id = int(worker_row["id"]) conn.execute( """ UPDATE job_workers SET status = 'running', current_job_item_id = ?, current_song_id = NULL, current_playlist_id = NULL, current_display_text = NULL, last_progress_text = NULL, downloaded_bytes = 0, total_bytes = 0, speed_bytes_per_sec = 0, progress_percent = 0, heartbeat_at = CURRENT_TIMESTAMP WHERE id = ? """, (item_id, worker_id), ) cursor = conn.execute( """ UPDATE job_items SET status = ?, worker_id = ?, attempt_count = attempt_count + 1, started_at = COALESCE(started_at, CURRENT_TIMESTAMP), ended_at = NULL WHERE id = ? AND status = ? """, (ItemStatus.RUNNING.value, worker_id, item_id, from_status.value), ) if cursor.rowcount != 1: raise RuntimeError(f"Failed to claim item {item_id}") self._adjust_stage_item_counters( conn, stage_id=int(row["job_stage_id"]), from_status=from_status, to_status=ItemStatus.RUNNING, ) claimed = conn.execute("SELECT * FROM job_items WHERE id = ?", (item_id,)).fetchone() if claimed is None: raise RuntimeError(f"Failed to load claimed item: {item_id}") return self._row_to_item(claimed) @staticmethod def _coerce_int(value: Any) -> int | None: if value is None: return None if isinstance(value, bool): return None if isinstance(value, int): return value text = str(value).strip() if not text: return None if text.isdigit() or (text.startswith("-") and text[1:].isdigit()): try: return int(text) except ValueError: return None return None def build_download_row(self, item_id: int) -> dict[str, Any]: with self._connection() as conn: item_row = conn.execute( "SELECT id, song_id, playlist_id, payload_json FROM job_items WHERE id = ?", (item_id,), ).fetchone() if item_row is None: raise RuntimeError(f"Unknown item: {item_id}") payload = _json_loads(item_row["payload_json"]) payload_row = payload.get("row") row = dict(payload_row) if isinstance(payload_row, dict) else {} song_id = next( ( candidate for candidate in ( self._coerce_int(item_row["song_id"]), self._coerce_int(row.get("id")), self._coerce_int(row.get("song_id")), self._coerce_int(payload.get("song_id")), ) if candidate is not None ), None, ) if song_id is None: raise RuntimeError(f"Item {item_id} does not have song context") song_row = conn.execute("SELECT * FROM songs WHERE id = ?", (song_id,)).fetchone() merged: dict[str, Any] = dict(song_row) if song_row is not None else {} merged.update(row) merged.setdefault("id", song_id) merged.setdefault("song_id", song_id) if item_row["playlist_id"] is not None: merged.setdefault("playlist_id", int(item_row["playlist_id"])) missing = [key for key in ("id", "platform") if merged.get(key) in (None, "")] if missing: raise RuntimeError( f"Download row for item {item_id} is incomplete; missing: {', '.join(missing)}" ) return merged def get_playlist_row_for_item(self, item_id: int) -> dict[str, Any]: with self._connection() as conn: item_row = conn.execute( "SELECT id, playlist_id, payload_json FROM job_items WHERE id = ?", (item_id,), ).fetchone() if item_row is None: raise RuntimeError(f"Unknown item: {item_id}") payload = _json_loads(item_row["payload_json"]) payload_row = payload.get("playlist_row") row = dict(payload_row) if isinstance(payload_row, dict) else {} playlist_id = next( ( candidate for candidate in ( self._coerce_int(item_row["playlist_id"]), self._coerce_int(row.get("id")), self._coerce_int(row.get("playlist_id")), self._coerce_int(payload.get("playlist_id")), ) if candidate is not None ), None, ) if playlist_id is None: raise RuntimeError(f"Item {item_id} does not have playlist context") playlist_row = conn.execute("SELECT * FROM playlists WHERE id = ?", (playlist_id,)).fetchone() merged: dict[str, Any] = dict(playlist_row) if playlist_row is not None else {} merged.update(row) merged.setdefault("id", playlist_id) merged.setdefault("playlist_id", playlist_id) missing = [key for key in ("id",) if merged.get(key) in (None, "")] if missing: raise RuntimeError( f"Playlist row for item {item_id} is incomplete; missing: {', '.join(missing)}" ) return merged def get_upload_row_for_item(self, item_id: int) -> dict[str, Any]: with self._connection() as conn: item_row = conn.execute( "SELECT id, item_key, file_location_id, payload_json FROM job_items WHERE id = ?", (item_id,), ).fetchone() if item_row is None: raise RuntimeError(f"Unknown item: {item_id}") payload = _json_loads(item_row["payload_json"]) payload_row = payload.get("upload_row") row = dict(payload_row) if isinstance(payload_row, dict) else {} upload_task_candidates: list[int] = [] for value in ( payload.get("upload_task_id"), payload.get("task_id"), payload.get("id"), row.get("upload_task_id"), row.get("task_id"), row.get("id"), ): normalized = self._coerce_int(value) if normalized is not None and normalized not in upload_task_candidates: upload_task_candidates.append(normalized) suffix = str(item_row["item_key"] or "").rsplit(":", 1)[-1].strip() suffix_id = self._coerce_int(suffix) if suffix_id is not None and suffix_id not in upload_task_candidates: upload_task_candidates.append(suffix_id) source_location_candidates: list[int] = [] for value in ( item_row["file_location_id"], payload.get("file_location_id"), payload.get("source_location_id"), row.get("file_location_id"), row.get("source_location_id"), ): normalized = self._coerce_int(value) if normalized is not None and normalized not in source_location_candidates: source_location_candidates.append(normalized) query = """ SELECT ut.*, fl.absolute_path, fl.locator AS source_locator, fa.song_id FROM upload_tasks AS ut JOIN file_locations AS fl ON fl.id = ut.source_location_id JOIN file_assets AS fa ON fa.id = ut.file_asset_id """ task_row: sqlite3.Row | None = None for upload_task_id in upload_task_candidates: task_row = conn.execute(query + " WHERE ut.id = ?", (upload_task_id,)).fetchone() if task_row is not None: break if task_row is None: for source_location_id in source_location_candidates: task_row = conn.execute( query + " WHERE ut.source_location_id = ? ORDER BY ut.id DESC LIMIT 1", (source_location_id,), ).fetchone() if task_row is not None: break merged: dict[str, Any] = dict(task_row) if task_row is not None else {} merged.update(row) if merged.get("id") is None: first_id = upload_task_candidates[0] if upload_task_candidates else None if first_id is not None: merged["id"] = first_id if merged.get("source_location_id") is None and source_location_candidates: merged["source_location_id"] = source_location_candidates[0] if task_row is None and not merged: raise RuntimeError(f"Upload task row is unavailable for item {item_id}") for optional_key in ("absolute_path", "target_container_name", "source_locator", "song_id"): merged.setdefault(optional_key, None) for optional_key in ("file_asset_id", "source_location_id", "target_backend_id"): merged.setdefault(optional_key, None) missing = [key for key in ("id", "target_locator") if merged.get(key) in (None, "")] if missing: raise RuntimeError( f"Upload row for item {item_id} is incomplete; missing: {', '.join(missing)}" ) return merged def mark_item_succeeded( self, item_id: int, result_payload: dict[str, Any] | None = None, ) -> bool: with self._connection() as conn: row = conn.execute( "SELECT job_stage_id, status, worker_id, payload_json FROM job_items WHERE id = ?", (item_id,), ).fetchone() if row is None: return False from_status = ItemStatus(str(row["status"])) if from_status is not ItemStatus.RUNNING: return False payload = _json_loads(row["payload_json"]) if result_payload: payload.update(result_payload) cursor = conn.execute( """ UPDATE job_items SET status = ?, payload_json = ?, worker_id = NULL, ended_at = CURRENT_TIMESTAMP, last_error = NULL, last_error_code = NULL WHERE id = ? AND status = ? """, ( ItemStatus.SUCCEEDED.value, _json_dumps(payload), item_id, from_status.value, ), ) if cursor.rowcount != 1: return False self._adjust_stage_item_counters( conn, stage_id=int(row["job_stage_id"]), from_status=from_status, to_status=ItemStatus.SUCCEEDED, ) worker_id = row["worker_id"] if worker_id is not None: conn.execute( """ UPDATE job_workers SET status = 'idle', current_job_item_id = NULL, current_song_id = NULL, current_playlist_id = NULL, current_display_text = NULL, processed_count = processed_count + 1, heartbeat_at = CURRENT_TIMESTAMP WHERE id = ? """, (int(worker_id),), ) return True def mark_item_failed(self, item_id: int, error_message: str) -> bool: with self._connection() as conn: row = conn.execute( "SELECT job_stage_id, status, worker_id FROM job_items WHERE id = ?", (item_id,), ).fetchone() if row is None: return False from_status = ItemStatus(str(row["status"])) if from_status is not ItemStatus.RUNNING: return False cursor = conn.execute( """ UPDATE job_items SET status = ?, worker_id = NULL, ended_at = CURRENT_TIMESTAMP, last_error = ?, last_error_code = NULL WHERE id = ? AND status = ? """, ( ItemStatus.FAILED.value, str(error_message), item_id, from_status.value, ), ) if cursor.rowcount != 1: return False self._adjust_stage_item_counters( conn, stage_id=int(row["job_stage_id"]), from_status=from_status, to_status=ItemStatus.FAILED, ) worker_id = row["worker_id"] if worker_id is not None: conn.execute( """ UPDATE job_workers SET status = 'idle', current_job_item_id = NULL, current_song_id = NULL, current_playlist_id = NULL, current_display_text = NULL, error_count = error_count + 1, heartbeat_at = CURRENT_TIMESTAMP WHERE id = ? """, (int(worker_id),), ) return True def mark_item_skipped( self, item_id: int, *, reason_message: str | None = None, reason_code: str | None = None, ) -> bool: with self._connection() as conn: row = conn.execute( "SELECT job_stage_id, status, worker_id FROM job_items WHERE id = ?", (item_id,), ).fetchone() if row is None: return False from_status = ItemStatus(str(row["status"])) if from_status is not ItemStatus.RUNNING: return False cursor = conn.execute( """ UPDATE job_items SET status = ?, worker_id = NULL, ended_at = CURRENT_TIMESTAMP, last_error = ?, last_error_code = ? WHERE id = ? AND status = ? """, ( ItemStatus.SKIPPED.value, str(reason_message or ""), str(reason_code or ""), item_id, from_status.value, ), ) if cursor.rowcount != 1: return False self._adjust_stage_item_counters( conn, stage_id=int(row["job_stage_id"]), from_status=from_status, to_status=ItemStatus.SKIPPED, ) worker_id = row["worker_id"] if worker_id is not None: conn.execute( """ UPDATE job_workers SET status = 'idle', current_job_item_id = NULL, current_song_id = NULL, current_playlist_id = NULL, current_display_text = NULL, processed_count = processed_count + 1, heartbeat_at = CURRENT_TIMESTAMP WHERE id = ? """, (int(worker_id),), ) return True def update_worker_state(self, worker_name: str, **state: Any) -> None: normalized_worker = str(worker_name).strip() or "worker" with self._connection() as conn: row = None current_item_id = self._coerce_int(state.get("current_job_item_id")) if current_item_id is not None: row = conn.execute( """ SELECT id FROM job_workers WHERE worker_name = ? AND current_job_item_id = ? ORDER BY id DESC LIMIT 1 """, (normalized_worker, current_item_id), ).fetchone() if row is None: row = conn.execute( """ SELECT jw.id FROM job_workers AS jw JOIN job_items AS ji ON ji.job_stage_id = jw.job_stage_id WHERE jw.worker_name = ? AND ji.id = ? ORDER BY CASE WHEN jw.current_job_item_id = ? THEN 0 ELSE 1 END, jw.id DESC LIMIT 1 """, (normalized_worker, current_item_id, current_item_id), ).fetchone() if row is None: row = conn.execute( """ SELECT id FROM job_workers WHERE worker_name = ? ORDER BY id DESC LIMIT 1 """, (normalized_worker,), ).fetchone() if row is None: worker_id = int( conn.execute( """ INSERT INTO job_workers (worker_name, status, heartbeat_at) VALUES (?, ?, CURRENT_TIMESTAMP) """, (normalized_worker, str(state.get("status") or "running")), ).lastrowid ) else: worker_id = int(row["id"]) updates = ["heartbeat_at = CURRENT_TIMESTAMP"] params: list[Any] = [] for field_name in ( "status", "current_job_item_id", "current_song_id", "current_playlist_id", "current_display_text", "last_progress_text", "downloaded_bytes", "total_bytes", "speed_bytes_per_sec", "progress_percent", ): if field_name in state: updates.append(f"{field_name} = ?") params.append(state[field_name]) processed_increment = int(state.get("processed_increment") or 0) error_increment = int(state.get("error_increment") or 0) if processed_increment: updates.append("processed_count = processed_count + ?") params.append(processed_increment) if error_increment: updates.append("error_count = error_count + ?") params.append(error_increment) params.append(worker_id) conn.execute( f"UPDATE job_workers SET {', '.join(updates)} WHERE id = ?", tuple(params), ) def list_task_center_rows(self, limit: int = 50) -> list[dict[str, Any]]: queued_positions: dict[int, int] = {} lane_queue_counts: dict[str, int] = {} for job in self.list_queued_jobs(): lane_type = job_lane_type(job.job_type) lane_queue_counts[lane_type] = lane_queue_counts.get(lane_type, 0) + 1 queued_positions[int(job.id)] = lane_queue_counts[lane_type] rows = self._fetchall( """ SELECT * FROM job_runs WHERE status IN (?, ?, ?, ?, ?, ?, ?, ?) ORDER BY CASE status WHEN ? THEN 0 WHEN ? THEN 1 WHEN ? THEN 2 WHEN ? THEN 3 WHEN ? THEN 4 WHEN ? THEN 5 WHEN ? THEN 6 WHEN ? THEN 7 ELSE 8 END, priority ASC, COALESCE(started_at, created_at) DESC, id DESC LIMIT ? """, ( JobStatus.RUNNING.value, JobStatus.PAUSE_REQUESTED.value, JobStatus.QUEUED.value, JobStatus.PAUSED.value, JobStatus.COMPLETED.value, JobStatus.COMPLETED_WITH_ERRORS.value, JobStatus.FAILED.value, JobStatus.CANCELED.value, JobStatus.RUNNING.value, JobStatus.PAUSE_REQUESTED.value, JobStatus.QUEUED.value, JobStatus.PAUSED.value, JobStatus.COMPLETED.value, JobStatus.COMPLETED_WITH_ERRORS.value, JobStatus.FAILED.value, JobStatus.CANCELED.value, int(limit), ), ) items: list[dict[str, Any]] = [] for row in rows: job = self._row_to_job(row) lane_type = job_lane_type(job.job_type) stages = self.list_job_stages(job.id) primary_type = primary_stage_type(job.job_type) primary_stage = next( (stage for stage in stages if str(stage.stage_type) == str(primary_type)), None, ) workers = self.list_job_workers(job.id, active_only=True, limit=100) primary_workers = workers if primary_stage is not None: matched_workers = [ worker for worker in workers if int(worker.get("job_stage_id") or 0) == int(primary_stage.id) ] if matched_workers: primary_workers = matched_workers worker_progress_values = [ float(worker["progress_percent"]) for worker in primary_workers if worker.get("progress_percent") is not None ] worker_progress_texts = [ str(worker.get("last_progress_text") or "") for worker in primary_workers if str(worker.get("last_progress_text") or "").strip() ] speed_bytes_per_sec = sum(int(worker.get("speed_bytes_per_sec") or 0) for worker in primary_workers) downloaded_bytes = sum(int(worker.get("downloaded_bytes") or 0) for worker in primary_workers) total_bytes = sum(int(worker.get("total_bytes") or 0) for worker in primary_workers) queue_position = queued_positions.get(int(job.id)) items.append( { "id": int(job.id), "job_type": str(job.job_type), "status": job.status.value, "priority": int(job.priority), "display_name": display_name(job.job_type, job.playlist_scope), "lane_type": lane_type, "queue_label": f"queued #{queue_position}" if queue_position else "", "scope_summary": _scope_summary(job.playlist_scope), "primary_stage_type": str(primary_stage.stage_type) if primary_stage is not None else primary_type, "active_worker_count": len(primary_workers), "primary_progress_percent": _primary_progress_percent( primary_stage, worker_progress_values, ), "primary_progress_text": _primary_progress_text( primary_stage, worker_progress_texts, ), "speed_bytes_per_sec": speed_bytes_per_sec, "speed_text": _format_speed_text(speed_bytes_per_sec), "downloaded_bytes": downloaded_bytes, "total_bytes": total_bytes, "created_at": job.created_at, "started_at": job.started_at, "ended_at": job.ended_at, "last_error": job.last_error, "playlist_scope": dict(job.playlist_scope or {}), } ) return items def list_job_workers( self, job_id: int, *, active_only: bool = False, limit: int = 50, ) -> list[dict[str, Any]]: where_parts = ["jw.job_run_id = ?"] params: list[Any] = [int(job_id)] if active_only: where_parts.append("(jw.status != 'idle' OR jw.current_job_item_id IS NOT NULL)") params.append(int(limit)) rows = self._fetchall( f""" SELECT jw.*, js.stage_type, ji.item_type, ji.item_key, s.name AS song_name, p.name AS playlist_name FROM job_workers AS jw LEFT JOIN job_stages AS js ON js.id = jw.job_stage_id LEFT JOIN job_items AS ji ON ji.id = jw.current_job_item_id LEFT JOIN songs AS s ON s.id = COALESCE(jw.current_song_id, ji.song_id) LEFT JOIN playlists AS p ON p.id = COALESCE(jw.current_playlist_id, ji.playlist_id) WHERE {' AND '.join(where_parts)} ORDER BY CASE WHEN jw.status = 'running' THEN 0 WHEN jw.status = 'paused' THEN 1 ELSE 2 END, COALESCE(jw.heartbeat_at, '') DESC, jw.id DESC LIMIT ? """, tuple(params), ) return [dict(row) for row in rows] def stage_has_open_items(self, stage_id: int) -> bool: row = self._fetchone( """ SELECT COUNT(1) AS cnt FROM job_items WHERE job_stage_id = ? AND status IN (?, ?, ?) """, ( int(stage_id), ItemStatus.PENDING.value, ItemStatus.RUNNING.value, ItemStatus.INTERRUPTED.value, ), ) return bool(row and int(row["cnt"]) > 0) def playlist_has_open_items(self, stage_id: int, playlist_id: int) -> bool: row = self._fetchone( """ SELECT COUNT(1) AS cnt FROM job_items WHERE job_stage_id = ? AND playlist_id = ? AND status IN (?, ?, ?) """, ( int(stage_id), int(playlist_id), ItemStatus.PENDING.value, ItemStatus.RUNNING.value, ItemStatus.INTERRUPTED.value, ), ) return bool(row and int(row["cnt"]) > 0) def list_job_items_with_context( self, job_id: int, *, statuses: list[str] | None = None, limit: int = 100, ) -> list[dict[str, Any]]: query = [ """ SELECT i.*, s.stage_type, p.name AS playlist_name, sg.name AS song_name, sg.singers AS song_singers FROM job_items AS i JOIN job_stages AS s ON s.id = i.job_stage_id LEFT JOIN playlists AS p ON p.id = i.playlist_id LEFT JOIN songs AS sg ON sg.id = i.song_id WHERE s.job_run_id = ? """ ] params: list[Any] = [int(job_id)] if statuses: placeholders = ", ".join("?" for _ in statuses) query.append(f"AND i.status IN ({placeholders})") params.extend(str(status) for status in statuses) query.append("ORDER BY i.id DESC LIMIT ?") params.append(int(limit)) rows = self._fetchall("\n".join(query), tuple(params)) items: list[dict[str, Any]] = [] for row in rows: item = dict(row) payload = _json_loads(row["payload_json"]) item["payload"] = payload item["display_name"] = ( item.get("song_name") or item.get("playlist_name") or str(payload.get("display_name") or payload.get("name") or row["item_key"]) ) items.append(item) return items def list_job_playlist_progress(self, job_id: int) -> list[dict[str, Any]]: job = self.get_job(job_id) if job is None: return [] playlist_ids = self._normalize_playlist_scope_ids(job.playlist_scope) if not playlist_ids: return [] placeholders = ", ".join("?" for _ in playlist_ids) rows = self._fetchall( f""" WITH scoped_playlists AS ( SELECT p.id AS playlist_id, p.platform, p.remote_playlist_id, p.name AS playlist_name FROM playlists AS p WHERE p.id IN ({placeholders}) ), local_downloaded_songs AS ( SELECT DISTINCT fa.song_id FROM file_locations AS fl JOIN file_assets AS fa ON fa.id = fl.file_asset_id JOIN storage_backends AS sb ON sb.id = fl.backend_id WHERE fl.status = 'active' AND sb.backend_type = 'local_fs' ), job_song_status AS ( SELECT ji.song_id, MAX(CASE WHEN ji.status = 'succeeded' THEN 1 ELSE 0 END) AS succeeded_flag, MAX(CASE WHEN ji.status = 'running' THEN 1 ELSE 0 END) AS running_flag, MAX(CASE WHEN ji.status IN ('pending', 'interrupted') THEN 1 ELSE 0 END) AS pending_flag, MAX(CASE WHEN ji.status = 'failed' THEN 1 ELSE 0 END) AS failed_flag, MAX(CASE WHEN ji.status = 'skipped' THEN 1 ELSE 0 END) AS skipped_flag FROM job_items AS ji JOIN job_stages AS js ON js.id = ji.job_stage_id WHERE js.job_run_id = ? AND js.stage_type = 'download' AND ji.song_id IS NOT NULL GROUP BY ji.song_id ), song_states AS ( SELECT sp.playlist_id, sp.platform, sp.remote_playlist_id, sp.playlist_name, ps.song_id, CASE WHEN lds.song_id IS NOT NULL OR COALESCE(jss.succeeded_flag, 0) = 1 THEN 'downloaded' WHEN COALESCE(jss.running_flag, 0) = 1 THEN 'running' WHEN COALESCE(jss.pending_flag, 0) = 1 THEN 'pending' WHEN COALESCE(jss.failed_flag, 0) = 1 THEN 'failed' WHEN COALESCE(jss.skipped_flag, 0) = 1 THEN 'skipped' ELSE 'pending' END AS song_state FROM scoped_playlists AS sp LEFT JOIN playlist_songs AS ps ON ps.playlist_id = sp.playlist_id LEFT JOIN local_downloaded_songs AS lds ON lds.song_id = ps.song_id LEFT JOIN job_song_status AS jss ON jss.song_id = ps.song_id ) SELECT playlist_id, platform, remote_playlist_id, playlist_name, COUNT(DISTINCT song_id) AS total_songs, COUNT(DISTINCT CASE WHEN song_state = 'downloaded' THEN song_id END) AS downloaded_songs, COUNT(DISTINCT CASE WHEN song_state = 'running' THEN song_id END) AS running_songs, COUNT(DISTINCT CASE WHEN song_state = 'pending' THEN song_id END) AS pending_songs, COUNT(DISTINCT CASE WHEN song_state = 'failed' THEN song_id END) AS failed_songs, COUNT(DISTINCT CASE WHEN song_state = 'skipped' THEN song_id END) AS skipped_songs FROM song_states GROUP BY playlist_id, platform, remote_playlist_id, playlist_name ORDER BY playlist_name ASC, playlist_id ASC """, tuple(playlist_ids + [int(job_id)]), ) items: list[dict[str, Any]] = [] for row in rows: payload = dict(row) payload["progress_percent"] = _progress_percent( int(payload.get("downloaded_songs") or 0), int(payload.get("total_songs") or 0), ) items.append(payload) return items def list_job_playlist_song_progress( self, job_id: int, playlist_id: int, *, limit: int = 500, ) -> list[dict[str, Any]]: job = self.get_job(job_id) if job is None: return [] scoped_playlist_ids = set(self._normalize_playlist_scope_ids(job.playlist_scope)) if not scoped_playlist_ids or int(playlist_id) not in scoped_playlist_ids: return [] normalized_limit = max(min(int(limit), 2000), 1) rows = self._fetchall( """ WITH local_downloaded_songs AS ( SELECT DISTINCT fa.song_id FROM file_locations AS fl JOIN file_assets AS fa ON fa.id = fl.file_asset_id JOIN storage_backends AS sb ON sb.id = fl.backend_id WHERE fl.status = 'active' AND sb.backend_type = 'local_fs' ), job_song_status AS ( SELECT ji.song_id, MAX(CASE WHEN ji.status = 'succeeded' THEN 1 ELSE 0 END) AS succeeded_flag, MAX(CASE WHEN ji.status = 'running' THEN 1 ELSE 0 END) AS running_flag, MAX(CASE WHEN ji.status IN ('pending', 'interrupted') THEN 1 ELSE 0 END) AS pending_flag, MAX(CASE WHEN ji.status = 'failed' THEN 1 ELSE 0 END) AS failed_flag, MAX(CASE WHEN ji.status = 'skipped' THEN 1 ELSE 0 END) AS skipped_flag, MAX(CASE WHEN ji.status = 'failed' THEN ji.last_error ELSE '' END) AS failed_reason, MAX(CASE WHEN ji.status = 'skipped' THEN ji.last_error ELSE '' END) AS skipped_reason FROM job_items AS ji JOIN job_stages AS js ON js.id = ji.job_stage_id WHERE js.job_run_id = ? AND js.stage_type = 'download' AND ji.song_id IS NOT NULL GROUP BY ji.song_id ) SELECT ps.song_id, ps.position, s.platform, s.remote_song_id, s.name AS song_name, s.singers, s.metadata_json, CASE WHEN lds.song_id IS NOT NULL OR COALESCE(jss.succeeded_flag, 0) = 1 THEN 'downloaded' WHEN COALESCE(jss.running_flag, 0) = 1 THEN 'running' WHEN COALESCE(jss.pending_flag, 0) = 1 THEN 'pending' WHEN COALESCE(jss.failed_flag, 0) = 1 THEN 'failed' WHEN COALESCE(jss.skipped_flag, 0) = 1 THEN 'skipped' ELSE 'pending' END AS song_state, CASE WHEN COALESCE(jss.failed_reason, '') != '' THEN jss.failed_reason WHEN COALESCE(jss.skipped_reason, '') != '' THEN jss.skipped_reason ELSE '' END AS status_note FROM playlist_songs AS ps JOIN songs AS s ON s.id = ps.song_id LEFT JOIN local_downloaded_songs AS lds ON lds.song_id = ps.song_id LEFT JOIN job_song_status AS jss ON jss.song_id = ps.song_id WHERE ps.playlist_id = ? ORDER BY COALESCE(ps.position, 2147483647) ASC, ps.song_id ASC LIMIT ? """, (int(job_id), int(playlist_id), int(normalized_limit)), ) items: list[dict[str, Any]] = [] for row in rows: payload = dict(row) metadata = _json_loads(payload.get("metadata_json")) snapshot = metadata.get("snapshot") if isinstance(metadata, dict) else {} raw_data = snapshot.get("raw_data") if isinstance(snapshot, dict) else {} search = raw_data.get("search") if isinstance(raw_data, dict) else {} is_non_music_resource = bool( isinstance(search, dict) and bool(search.get("qq_toplist_fallback")) ) or str(payload.get("remote_song_id") or "").strip().lower().startswith("qqtop_") status_note = str(payload.get("status_note") or "").strip() if is_non_music_resource and "非音乐资源" not in status_note: status_note = "非音乐资源(有声榜条目)" items.append( { "song_id": int(payload["song_id"]), "position": payload.get("position"), "platform": payload.get("platform"), "remote_song_id": payload.get("remote_song_id"), "song_name": payload.get("song_name"), "singers": payload.get("singers"), "status": payload.get("song_state"), "status_note": status_note, "is_non_music_resource": bool(is_non_music_resource), } ) return items def get_download_stats(self) -> dict[str, int]: total_songs_row = self._fetchone("SELECT COUNT(*) AS count_value FROM songs") local_summary = self._fetchone( """ SELECT COUNT(DISTINCT fa.song_id) AS downloaded_songs, COUNT(DISTINCT fa.id) AS local_file_assets, COUNT(DISTINCT fl.id) AS local_file_locations FROM file_locations AS fl JOIN file_assets AS fa ON fa.id = fl.file_asset_id JOIN storage_backends AS sb ON sb.id = fl.backend_id WHERE fl.status = 'active' AND sb.backend_type = 'local_fs' """ ) return { "total_songs": int(total_songs_row["count_value"]) if total_songs_row else 0, "downloaded_songs": int(local_summary["downloaded_songs"]) if local_summary else 0, "local_file_assets": int(local_summary["local_file_assets"]) if local_summary else 0, "local_file_locations": int(local_summary["local_file_locations"]) if local_summary else 0, } def _row_to_config_revision(self, row: sqlite3.Row) -> dict[str, Any]: return { "id": int(row["id"]), "source_type": str(row["source_type"]), "file_path": str(row["file_path"]), "content_text": str(row["content_text"]), "content_hash": str(row["content_hash"]), "created_at": row["created_at"], "applied_at": row["applied_at"], "note": row["note"], } def create_config_revision( self, *, source_type: str = "env_file", file_path: str, content_text: str, content_hash: str, note: str | None = None, ) -> int: with self._connection() as conn: try: return int( conn.execute( """ INSERT INTO config_revisions ( source_type, file_path, content_text, content_hash, note ) VALUES (?, ?, ?, ?, ?) """, (source_type, file_path, content_text, content_hash, note), ).lastrowid ) except sqlite3.IntegrityError: row = conn.execute( """ SELECT id FROM config_revisions WHERE source_type = ? AND file_path = ? AND content_hash = ? """, (source_type, file_path, content_hash), ).fetchone() if row is None: raise return int(row["id"]) def get_config_revision(self, revision_id: int) -> dict[str, Any] | None: row = self._fetchone("SELECT * FROM config_revisions WHERE id = ?", (revision_id,)) if row is None: return None return self._row_to_config_revision(row) def list_config_revisions(self, limit: int = 50) -> list[dict[str, Any]]: with self._connection() as conn: rows = conn.execute( """ SELECT * FROM config_revisions ORDER BY id DESC LIMIT ? """, (int(limit),), ).fetchall() return [self._row_to_config_revision(row) for row in rows] def mark_config_revision_applied(self, revision_id: int) -> None: with self._connection() as conn: conn.execute( """ UPDATE config_revisions SET applied_at = COALESCE(applied_at, CURRENT_TIMESTAMP) WHERE id = ? """, (revision_id,), )