1329 lines
48 KiB
Python
1329 lines
48 KiB
Python
"""Transactional SQLite persistence and non-destructive v1 YAML migration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import datetime as dt
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Iterator, Mapping, Sequence
|
|
|
|
import yaml
|
|
|
|
from .config import StorageConfig
|
|
from .contracts import SLOT_IDS
|
|
from .models import (
|
|
DeleteToken,
|
|
DownloadJob,
|
|
DownloadReservation,
|
|
MigrationError,
|
|
MigrationReport,
|
|
PlayerObservation,
|
|
SlotGuard,
|
|
SlotProtectedError,
|
|
SlotRecord,
|
|
StaleOperationError,
|
|
)
|
|
|
|
|
|
SCHEMA_VERSION = 1
|
|
LEGACY_MIGRATION_NAME = "legacy_playlists_yaml_v1"
|
|
MAX_LEGACY_YAML_BYTES = 8 * 1024 * 1024
|
|
|
|
|
|
SCHEMA_SQL = """
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
applied_at REAL NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS data_migrations (
|
|
name TEXT PRIMARY KEY,
|
|
applied_at REAL NOT NULL,
|
|
source_path TEXT,
|
|
source_sha256 TEXT,
|
|
backup_path TEXT,
|
|
details_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS slots (
|
|
slot_number INTEGER PRIMARY KEY CHECK (slot_number BETWEEN 1 AND 10),
|
|
generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0),
|
|
media_state TEXT NOT NULL DEFAULT 'empty'
|
|
CHECK (media_state IN ('empty','queued','downloading','ready','error','deleting')),
|
|
url TEXT,
|
|
youtube_id TEXT,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
item_count INTEGER NOT NULL DEFAULT 0 CHECK (item_count >= 0),
|
|
published_item_count INTEGER NOT NULL DEFAULT 0 CHECK (published_item_count >= 0),
|
|
created_at REAL,
|
|
ready_at REAL,
|
|
expires_at REAL,
|
|
last_error TEXT,
|
|
updated_at REAL NOT NULL,
|
|
CHECK (media_state <> 'empty' OR (url IS NULL AND item_count = 0))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS slot_items (
|
|
slot_number INTEGER NOT NULL REFERENCES slots(slot_number),
|
|
position INTEGER NOT NULL CHECK (position >= 1),
|
|
generation INTEGER NOT NULL CHECK (generation >= 0),
|
|
title TEXT NOT NULL,
|
|
relative_path TEXT NOT NULL,
|
|
PRIMARY KEY (slot_number, position)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS download_jobs (
|
|
job_id TEXT PRIMARY KEY,
|
|
slot_number INTEGER NOT NULL REFERENCES slots(slot_number),
|
|
generation INTEGER NOT NULL CHECK (generation >= 1),
|
|
url TEXT NOT NULL,
|
|
status TEXT NOT NULL
|
|
CHECK (status IN ('reserved','running','succeeded','failed','cancelled','interrupted','stale')),
|
|
submitter TEXT,
|
|
note TEXT,
|
|
requested_at REAL NOT NULL,
|
|
started_at REAL,
|
|
finished_at REAL,
|
|
error TEXT,
|
|
UNIQUE (slot_number, generation)
|
|
);
|
|
|
|
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 slot_guards (
|
|
guard_id TEXT PRIMARY KEY,
|
|
slot_number INTEGER NOT NULL REFERENCES slots(slot_number),
|
|
kind TEXT NOT NULL CHECK (kind IN
|
|
('player_active','pending_play','restart_uncertain','legacy_uncertain')),
|
|
state TEXT NOT NULL CHECK (state IN ('active','review_required','cleared')),
|
|
created_at REAL NOT NULL,
|
|
review_after REAL,
|
|
cleared_at REAL,
|
|
cleared_by TEXT,
|
|
clear_reason TEXT,
|
|
details_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS one_live_guard_per_slot
|
|
ON slot_guards(slot_number)
|
|
WHERE state IN ('active','review_required');
|
|
|
|
CREATE TABLE IF NOT EXISTS player_observation (
|
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
availability TEXT NOT NULL CHECK (availability IN ('unknown','online','offline')),
|
|
phase TEXT NOT NULL CHECK (phase IN ('unknown','idle','playing','paused','error')),
|
|
active_slot INTEGER REFERENCES slots(slot_number),
|
|
active_path TEXT,
|
|
paused INTEGER CHECK (paused IS NULL OR paused IN (0,1)),
|
|
generation INTEGER NOT NULL DEFAULT 0,
|
|
reconciliation_state TEXT NOT NULL CHECK (reconciliation_state IN
|
|
('clear','confirmed','awaiting_evidence','operator_review','admin_cleared','unknown')),
|
|
reconciliation_due_at REAL,
|
|
last_status_at REAL,
|
|
last_telemetry_at REAL,
|
|
last_status_json TEXT,
|
|
last_telemetry_json TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS audio_router_observation (
|
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
mode TEXT,
|
|
freshness TEXT NOT NULL CHECK (freshness IN ('unknown','current','stale')),
|
|
observed_at REAL,
|
|
last_warning TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS audio_leases (
|
|
lease_id TEXT PRIMARY KEY,
|
|
operation_id TEXT,
|
|
playback_generation INTEGER,
|
|
state TEXT NOT NULL CHECK (state IN
|
|
('acquiring','held','unconfirmed','ownership_lost','restore_pending','released','abandoned')),
|
|
previous_mode TEXT,
|
|
target_mode TEXT NOT NULL,
|
|
restoration_mode TEXT,
|
|
restoration_source TEXT NOT NULL CHECK (restoration_source IN
|
|
('observed_previous','configured_fallback','none')),
|
|
controller_changed INTEGER NOT NULL DEFAULT 0 CHECK (controller_changed IN (0,1)),
|
|
confirmation_deadline REAL,
|
|
created_at REAL NOT NULL,
|
|
acquired_at REAL,
|
|
released_at REAL,
|
|
warning TEXT,
|
|
error TEXT
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS one_live_audio_lease
|
|
ON audio_leases((1))
|
|
WHERE state IN ('acquiring','held','unconfirmed','restore_pending');
|
|
|
|
CREATE TABLE IF NOT EXISTS audit_events (
|
|
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
occurred_at REAL NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
slot_number INTEGER REFERENCES slots(slot_number),
|
|
actor TEXT,
|
|
details_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
"""
|
|
|
|
|
|
class _UniqueKeyLoader(yaml.SafeLoader):
|
|
pass
|
|
|
|
|
|
def _construct_unique_mapping(
|
|
loader: _UniqueKeyLoader,
|
|
node: yaml.MappingNode,
|
|
deep: bool = False,
|
|
) -> dict[Any, Any]:
|
|
result: dict[Any, Any] = {}
|
|
for key_node, value_node in node.value:
|
|
key = loader.construct_object(key_node, deep=deep)
|
|
try:
|
|
duplicate = key in result
|
|
except TypeError as exc:
|
|
raise MigrationError("legacy YAML contains an unhashable mapping key") from exc
|
|
if duplicate:
|
|
raise MigrationError(f"legacy YAML contains duplicate key {key!r}")
|
|
result[key] = loader.construct_object(value_node, deep=deep)
|
|
return result
|
|
|
|
|
|
_UniqueKeyLoader.add_constructor(
|
|
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
|
|
_construct_unique_mapping,
|
|
)
|
|
|
|
|
|
def _utc_timestamp(value: Any, field: str, warnings: list[str]) -> float | None:
|
|
if value in (None, ""):
|
|
return None
|
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
return float(value)
|
|
if isinstance(value, dt.datetime):
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=dt.timezone.utc)
|
|
return value.timestamp()
|
|
if isinstance(value, dt.date):
|
|
return dt.datetime.combine(value, dt.time(), tzinfo=dt.timezone.utc).timestamp()
|
|
if isinstance(value, str):
|
|
try:
|
|
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
return parsed.timestamp()
|
|
except ValueError:
|
|
pass
|
|
warnings.append(f"ignored invalid {field}: {value!r}")
|
|
return None
|
|
|
|
|
|
def _coerce_count(value: Any, slot: int, warnings: list[str]) -> int:
|
|
if isinstance(value, bool):
|
|
warnings.append(f"slot {slot}: invalid item_count {value!r}; used 0")
|
|
return 0
|
|
try:
|
|
count = int(value)
|
|
except (TypeError, ValueError, OverflowError):
|
|
count = -1
|
|
if count < 0:
|
|
warnings.append(f"slot {slot}: invalid item_count {value!r}; used 0")
|
|
return 0
|
|
return count
|
|
|
|
|
|
def _row_to_slot(row: sqlite3.Row) -> SlotRecord:
|
|
return SlotRecord(
|
|
slot=row["slot_number"],
|
|
generation=row["generation"],
|
|
media_state=row["media_state"],
|
|
url=row["url"],
|
|
yt_id=row["youtube_id"],
|
|
title=row["title"],
|
|
item_count=row["item_count"],
|
|
published_item_count=row["published_item_count"],
|
|
created_at=row["created_at"],
|
|
ready_at=row["ready_at"],
|
|
expires_at=row["expires_at"],
|
|
last_error=row["last_error"],
|
|
updated_at=row["updated_at"],
|
|
)
|
|
|
|
|
|
class ControllerStore:
|
|
"""Thread-safe transactional store.
|
|
|
|
Controller v2's coordinator is the intended sole writer. The re-entrant
|
|
lock also makes direct calls safe in HTTP/tests and SQLite transactions are
|
|
the final cross-connection reservation boundary.
|
|
"""
|
|
|
|
def __init__(self, config: StorageConfig, *, now=time.time):
|
|
self.config = config
|
|
self.path = config.database_path
|
|
self.legacy_path = config.legacy_yaml_path
|
|
self._now = now
|
|
self._lock = threading.RLock()
|
|
self._connection: sqlite3.Connection | None = None
|
|
self.migration_report = MigrationReport(migrated=False)
|
|
|
|
def open(self) -> "ControllerStore":
|
|
with self._lock:
|
|
if self._connection is not None:
|
|
return self
|
|
self.config.data_base.mkdir(parents=True, exist_ok=True)
|
|
if not self.path.exists():
|
|
if self.legacy_path.exists():
|
|
self.migration_report = self._migrate_legacy_yaml()
|
|
else:
|
|
self._create_new_database()
|
|
self._connection = self._connect(self.path)
|
|
self._validate_database(self._connection)
|
|
self._recover_interrupted_jobs()
|
|
return self
|
|
|
|
def close(self) -> None:
|
|
with self._lock:
|
|
if self._connection is not None:
|
|
self._connection.close()
|
|
self._connection = None
|
|
|
|
def __enter__(self) -> "ControllerStore":
|
|
return self.open()
|
|
|
|
def __exit__(self, exc_type, exc, traceback) -> None:
|
|
self.close()
|
|
|
|
@property
|
|
def connection(self) -> sqlite3.Connection:
|
|
if self._connection is None:
|
|
raise RuntimeError("ControllerStore is not open")
|
|
return self._connection
|
|
|
|
@staticmethod
|
|
def _connect(path: Path, *, runtime: bool = True) -> sqlite3.Connection:
|
|
connection = sqlite3.connect(
|
|
path,
|
|
timeout=5.0,
|
|
isolation_level=None,
|
|
check_same_thread=False,
|
|
)
|
|
connection.row_factory = sqlite3.Row
|
|
connection.execute("PRAGMA foreign_keys = ON")
|
|
connection.execute("PRAGMA busy_timeout = 5000")
|
|
connection.execute("PRAGMA synchronous = FULL")
|
|
if runtime:
|
|
connection.execute("PRAGMA journal_mode = WAL")
|
|
else:
|
|
connection.execute("PRAGMA journal_mode = DELETE")
|
|
return connection
|
|
|
|
@contextlib.contextmanager
|
|
def _transaction(self) -> Iterator[sqlite3.Connection]:
|
|
with self._lock:
|
|
connection = self.connection
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
yield connection
|
|
except BaseException:
|
|
connection.rollback()
|
|
raise
|
|
else:
|
|
connection.commit()
|
|
|
|
def _initialize_schema(self, connection: sqlite3.Connection) -> None:
|
|
connection.executescript(SCHEMA_SQL)
|
|
now = self._now()
|
|
connection.executemany(
|
|
"""
|
|
INSERT OR IGNORE INTO slots
|
|
(slot_number, generation, media_state, updated_at)
|
|
VALUES (?, 0, 'empty', ?)
|
|
""",
|
|
((slot, now) for slot in SLOT_IDS),
|
|
)
|
|
connection.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO player_observation
|
|
(singleton, availability, phase, generation, reconciliation_state)
|
|
VALUES (1, 'unknown', 'unknown', 0, 'clear')
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO audio_router_observation
|
|
(singleton, freshness)
|
|
VALUES (1, 'unknown')
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO schema_migrations(version, name, applied_at)
|
|
VALUES (?, ?, ?)
|
|
""",
|
|
(SCHEMA_VERSION, "controller_v2_initial", now),
|
|
)
|
|
connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
|
|
def _create_new_database(self) -> None:
|
|
staging = self._staging_path()
|
|
try:
|
|
connection = self._connect(staging, runtime=False)
|
|
try:
|
|
self._initialize_schema(connection)
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO data_migrations
|
|
(name, applied_at, details_json)
|
|
VALUES ('fresh_database_v1', ?, '{}')
|
|
""",
|
|
(self._now(),),
|
|
)
|
|
self._validate_database(connection)
|
|
finally:
|
|
connection.close()
|
|
self._fsync_file(staging)
|
|
os.replace(staging, self.path)
|
|
self._fsync_directory(self.path.parent)
|
|
except BaseException:
|
|
staging.unlink(missing_ok=True)
|
|
raise
|
|
|
|
def _staging_path(self) -> Path:
|
|
descriptor, raw_path = tempfile.mkstemp(
|
|
prefix=f".{self.path.name}.",
|
|
suffix=".staging",
|
|
dir=self.path.parent,
|
|
)
|
|
os.close(descriptor)
|
|
return Path(raw_path)
|
|
|
|
@staticmethod
|
|
def _fsync_file(path: Path) -> None:
|
|
descriptor = os.open(path, os.O_RDONLY)
|
|
try:
|
|
os.fsync(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
@staticmethod
|
|
def _fsync_directory(path: Path) -> None:
|
|
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
try:
|
|
os.fsync(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
def _create_backup(self, source: bytes, digest: str) -> Path:
|
|
backup = self.legacy_path.with_name(
|
|
f"{self.legacy_path.name}.pre-v2.{digest}.bak"
|
|
)
|
|
if backup.exists():
|
|
if hashlib.sha256(backup.read_bytes()).hexdigest() != digest:
|
|
raise MigrationError(f"existing migration backup is invalid: {backup}")
|
|
return backup
|
|
|
|
descriptor, raw_temp = tempfile.mkstemp(
|
|
prefix=f".{backup.name}.",
|
|
suffix=".tmp",
|
|
dir=backup.parent,
|
|
)
|
|
temp = Path(raw_temp)
|
|
try:
|
|
with os.fdopen(descriptor, "wb") as stream:
|
|
stream.write(source)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
try:
|
|
os.link(temp, backup)
|
|
except FileExistsError:
|
|
if hashlib.sha256(backup.read_bytes()).hexdigest() != digest:
|
|
raise MigrationError(f"existing migration backup is invalid: {backup}")
|
|
self._fsync_directory(backup.parent)
|
|
finally:
|
|
temp.unlink(missing_ok=True)
|
|
|
|
if hashlib.sha256(backup.read_bytes()).hexdigest() != digest:
|
|
raise MigrationError("migration backup verification failed")
|
|
return backup
|
|
|
|
def _parse_legacy(self, source: bytes) -> tuple[dict[int, Mapping[str, Any]], list[str]]:
|
|
try:
|
|
text = source.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
raise MigrationError("legacy YAML is not valid UTF-8") from exc
|
|
try:
|
|
raw = yaml.load(text, Loader=_UniqueKeyLoader)
|
|
except (yaml.YAMLError, MigrationError) as exc:
|
|
if isinstance(exc, MigrationError):
|
|
raise
|
|
raise MigrationError(f"legacy YAML cannot be parsed: {exc}") from exc
|
|
if raw is None:
|
|
raw = {}
|
|
if not isinstance(raw, Mapping):
|
|
raise MigrationError("legacy YAML root must be a mapping")
|
|
raw_slots = raw.get("slots", {})
|
|
if not isinstance(raw_slots, Mapping):
|
|
raise MigrationError("legacy YAML slots must be a mapping")
|
|
|
|
normalized: dict[int, Mapping[str, Any]] = {}
|
|
original_keys: dict[int, Any] = {}
|
|
for key, value in raw_slots.items():
|
|
if isinstance(key, bool):
|
|
raise MigrationError(f"invalid legacy slot key {key!r}")
|
|
try:
|
|
slot = int(key)
|
|
except (TypeError, ValueError, OverflowError) as exc:
|
|
raise MigrationError(f"invalid legacy slot key {key!r}") from exc
|
|
if slot not in SLOT_IDS:
|
|
raise MigrationError(f"legacy slot {slot} is outside stable range 1..10")
|
|
if slot in normalized:
|
|
raise MigrationError(
|
|
f"legacy slot keys {original_keys[slot]!r} and {key!r} normalize to {slot}"
|
|
)
|
|
if not isinstance(value, Mapping):
|
|
raise MigrationError(f"legacy slot {slot} must be a mapping")
|
|
embedded = value.get("slot", slot)
|
|
if isinstance(embedded, bool):
|
|
raise MigrationError(f"legacy slot {slot} has invalid embedded identity")
|
|
try:
|
|
embedded_slot = int(embedded)
|
|
except (TypeError, ValueError, OverflowError) as exc:
|
|
raise MigrationError(f"legacy slot {slot} has invalid embedded identity") from exc
|
|
if embedded_slot != slot:
|
|
raise MigrationError(
|
|
f"legacy slot key {slot} conflicts with embedded slot {embedded_slot}"
|
|
)
|
|
normalized[slot] = value
|
|
original_keys[slot] = key
|
|
return normalized, []
|
|
|
|
def _migrate_legacy_yaml(self) -> MigrationReport:
|
|
source = self.legacy_path.read_bytes()
|
|
if len(source) > MAX_LEGACY_YAML_BYTES:
|
|
raise MigrationError("legacy YAML exceeds migration size limit")
|
|
digest = hashlib.sha256(source).hexdigest()
|
|
backup = self._create_backup(source, digest)
|
|
slots, warnings = self._parse_legacy(source)
|
|
staging = self._staging_path()
|
|
try:
|
|
connection = self._connect(staging, runtime=False)
|
|
try:
|
|
self._initialize_schema(connection)
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
now = self._now()
|
|
for slot_number, legacy in sorted(slots.items()):
|
|
self._import_legacy_slot(
|
|
connection,
|
|
slot_number,
|
|
legacy,
|
|
warnings,
|
|
now,
|
|
)
|
|
details = json.dumps({"warnings": warnings}, sort_keys=True)
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO data_migrations
|
|
(name, applied_at, source_path, source_sha256,
|
|
backup_path, details_json)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
LEGACY_MIGRATION_NAME,
|
|
now,
|
|
str(self.legacy_path),
|
|
digest,
|
|
str(backup),
|
|
details,
|
|
),
|
|
)
|
|
connection.commit()
|
|
except BaseException:
|
|
connection.rollback()
|
|
raise
|
|
self._validate_database(connection)
|
|
finally:
|
|
connection.close()
|
|
self._fsync_file(staging)
|
|
os.replace(staging, self.path)
|
|
self._fsync_directory(self.path.parent)
|
|
except BaseException:
|
|
staging.unlink(missing_ok=True)
|
|
raise
|
|
return MigrationReport(
|
|
migrated=True,
|
|
source_path=str(self.legacy_path),
|
|
source_sha256=digest,
|
|
backup_path=str(backup),
|
|
warnings=tuple(warnings),
|
|
)
|
|
|
|
def _import_legacy_slot(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
slot: int,
|
|
legacy: Mapping[str, Any],
|
|
warnings: list[str],
|
|
now: float,
|
|
) -> None:
|
|
raw_state = str(legacy.get("state", "ready")).strip().lower()
|
|
error: str | None = None
|
|
guarded = raw_state in {"playing", "paused", "busy"}
|
|
interrupted = raw_state in {"queued", "downloading"}
|
|
if raw_state in {"ready", "idle", "finished", "playing", "paused", "busy"}:
|
|
media_state = "ready"
|
|
elif raw_state == "error":
|
|
media_state = "error"
|
|
error = str(legacy.get("last_error") or "legacy download error")
|
|
elif interrupted:
|
|
media_state = "error"
|
|
error = "download interrupted during Controller v2 migration"
|
|
warnings.append(f"slot {slot}: legacy {raw_state} download marked interrupted")
|
|
else:
|
|
media_state = "error"
|
|
error = f"unknown legacy state: {raw_state}"
|
|
warnings.append(f"slot {slot}: unknown legacy state {raw_state!r}")
|
|
|
|
url_value = legacy.get("url")
|
|
url = str(url_value) if url_value not in (None, "") else None
|
|
if url is None:
|
|
media_state = "error"
|
|
error = error or "legacy slot has no URL"
|
|
warnings.append(f"slot {slot}: missing URL")
|
|
title = str(legacy.get("title") or "")
|
|
count = _coerce_count(legacy.get("item_count", 0), slot, warnings)
|
|
created_at = _utc_timestamp(legacy.get("created_at"), f"slot {slot} created_at", warnings)
|
|
expires_at = _utc_timestamp(legacy.get("expires_at"), f"slot {slot} expires_at", warnings)
|
|
generation = 1
|
|
connection.execute(
|
|
"""
|
|
UPDATE slots
|
|
SET generation = ?, media_state = ?, url = ?, youtube_id = ?,
|
|
title = ?, item_count = ?, published_item_count = ?,
|
|
created_at = ?, ready_at = ?, expires_at = ?, last_error = ?,
|
|
updated_at = ?
|
|
WHERE slot_number = ?
|
|
""",
|
|
(
|
|
generation,
|
|
media_state,
|
|
url,
|
|
legacy.get("yt_id"),
|
|
title,
|
|
count,
|
|
count,
|
|
created_at,
|
|
created_at if media_state == "ready" else None,
|
|
expires_at,
|
|
error,
|
|
now,
|
|
slot,
|
|
),
|
|
)
|
|
if guarded:
|
|
guard_id = f"legacy-{slot}-{generation}"
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO slot_guards
|
|
(guard_id, slot_number, kind, state, created_at,
|
|
review_after, details_json)
|
|
VALUES (?, ?, 'legacy_uncertain', 'review_required', ?, NULL, ?)
|
|
""",
|
|
(
|
|
guard_id,
|
|
slot,
|
|
now,
|
|
json.dumps({"legacy_state": raw_state}, sort_keys=True),
|
|
),
|
|
)
|
|
if interrupted and url is not None:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO download_jobs
|
|
(job_id, slot_number, generation, url, status,
|
|
requested_at, finished_at, error)
|
|
VALUES (?, ?, ?, ?, 'interrupted', ?, ?, ?)
|
|
""",
|
|
(
|
|
f"legacy-interrupted-{slot}-{generation}",
|
|
slot,
|
|
generation,
|
|
url,
|
|
created_at or now,
|
|
now,
|
|
error,
|
|
),
|
|
)
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO audit_events
|
|
(occurred_at, event_type, slot_number, actor, details_json)
|
|
VALUES (?, 'legacy_slot_migrated', ?, 'migration', ?)
|
|
""",
|
|
(now, slot, json.dumps({"legacy_state": raw_state}, sort_keys=True)),
|
|
)
|
|
|
|
@staticmethod
|
|
def _validate_database(connection: sqlite3.Connection) -> None:
|
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
|
if version != SCHEMA_VERSION:
|
|
raise MigrationError(
|
|
f"unsupported Controller database schema {version}; expected {SCHEMA_VERSION}"
|
|
)
|
|
violations = connection.execute("PRAGMA foreign_key_check").fetchall()
|
|
if violations:
|
|
raise MigrationError(f"Controller database foreign-key failure: {violations!r}")
|
|
integrity = connection.execute("PRAGMA integrity_check").fetchone()[0]
|
|
if integrity != "ok":
|
|
raise MigrationError(f"Controller database integrity failure: {integrity}")
|
|
slots = connection.execute(
|
|
"SELECT slot_number FROM slots ORDER BY slot_number"
|
|
).fetchall()
|
|
if [row[0] for row in slots] != list(SLOT_IDS):
|
|
raise MigrationError("Controller database must contain stable slots 1..10")
|
|
|
|
def _recover_interrupted_jobs(self) -> None:
|
|
with self._transaction() as connection:
|
|
now = self._now()
|
|
live = connection.execute(
|
|
"""
|
|
SELECT job_id, slot_number, generation
|
|
FROM download_jobs
|
|
WHERE status IN ('reserved','running')
|
|
"""
|
|
).fetchall()
|
|
for row in live:
|
|
message = "download interrupted by Controller restart"
|
|
connection.execute(
|
|
"""
|
|
UPDATE download_jobs
|
|
SET status = 'interrupted', finished_at = ?, error = ?
|
|
WHERE job_id = ?
|
|
""",
|
|
(now, message, row["job_id"]),
|
|
)
|
|
connection.execute(
|
|
"""
|
|
UPDATE slots
|
|
SET media_state = 'error', last_error = ?, updated_at = ?
|
|
WHERE slot_number = ? AND generation = ?
|
|
""",
|
|
(message, now, row["slot_number"], row["generation"]),
|
|
)
|
|
|
|
def list_slots(self) -> list[SlotRecord]:
|
|
with self._lock:
|
|
rows = self.connection.execute(
|
|
"SELECT * FROM slots ORDER BY slot_number"
|
|
).fetchall()
|
|
return [_row_to_slot(row) for row in rows]
|
|
|
|
def get_slot(self, slot: int) -> SlotRecord:
|
|
if slot not in SLOT_IDS:
|
|
raise ValueError(f"slot must be in 1..10, got {slot!r}")
|
|
with self._lock:
|
|
row = self.connection.execute(
|
|
"SELECT * FROM slots WHERE slot_number = ?", (slot,)
|
|
).fetchone()
|
|
assert row is not None
|
|
return _row_to_slot(row)
|
|
|
|
def get_slot_items(self, slot: int) -> list[tuple[str, str]]:
|
|
with self._lock:
|
|
rows = self.connection.execute(
|
|
"""
|
|
SELECT title, relative_path FROM slot_items
|
|
WHERE slot_number = ? ORDER BY position
|
|
""",
|
|
(slot,),
|
|
).fetchall()
|
|
return [(row["title"], row["relative_path"]) for row in rows]
|
|
|
|
def reserve_download(
|
|
self,
|
|
url: str,
|
|
submitter: str | None,
|
|
note: str | None,
|
|
*,
|
|
job_id: str | None = None,
|
|
) -> DownloadReservation:
|
|
if not isinstance(url, str) or not url.strip():
|
|
raise ValueError("download URL must be non-empty")
|
|
url = url.strip()
|
|
job_id = job_id or uuid.uuid4().hex
|
|
with self._transaction() as connection:
|
|
existing = connection.execute(
|
|
"""
|
|
SELECT s.* FROM slots s
|
|
WHERE s.url = ? AND s.media_state <> 'empty'
|
|
ORDER BY s.slot_number LIMIT 1
|
|
""",
|
|
(url,),
|
|
).fetchone()
|
|
reused = existing is not None
|
|
if existing is not None:
|
|
slot = existing["slot_number"]
|
|
if existing["media_state"] in {"queued", "downloading", "deleting"}:
|
|
return DownloadReservation(status="slot_busy", slot=slot, reused_slot=True)
|
|
if self._slot_has_live_guard(connection, slot):
|
|
return DownloadReservation(status="slot_protected", slot=slot, reused_slot=True)
|
|
else:
|
|
free = connection.execute(
|
|
"""
|
|
SELECT s.slot_number FROM slots s
|
|
WHERE s.media_state = 'empty'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM slot_guards g
|
|
WHERE g.slot_number = s.slot_number
|
|
AND g.state IN ('active','review_required')
|
|
)
|
|
ORDER BY s.slot_number LIMIT 1
|
|
"""
|
|
).fetchone()
|
|
if free is None:
|
|
oldest = connection.execute(
|
|
"""
|
|
SELECT MIN(expires_at) FROM slots
|
|
WHERE expires_at IS NOT NULL
|
|
"""
|
|
).fetchone()[0]
|
|
return DownloadReservation(
|
|
status="queue_full",
|
|
oldest_expires_at=oldest,
|
|
)
|
|
slot = free["slot_number"]
|
|
|
|
current = connection.execute(
|
|
"SELECT generation FROM slots WHERE slot_number = ?", (slot,)
|
|
).fetchone()
|
|
generation = current["generation"] + 1
|
|
now = self._now()
|
|
connection.execute(
|
|
"""
|
|
UPDATE slots
|
|
SET generation = ?, media_state = 'queued', url = ?,
|
|
created_at = COALESCE(created_at, ?), last_error = NULL,
|
|
updated_at = ?
|
|
WHERE slot_number = ?
|
|
""",
|
|
(generation, url, now, now, slot),
|
|
)
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO download_jobs
|
|
(job_id, slot_number, generation, url, status,
|
|
submitter, note, requested_at)
|
|
VALUES (?, ?, ?, ?, 'reserved', ?, ?, ?)
|
|
""",
|
|
(job_id, slot, generation, url, submitter, note, now),
|
|
)
|
|
return DownloadReservation(
|
|
status="reserved",
|
|
slot=slot,
|
|
generation=generation,
|
|
job_id=job_id,
|
|
reused_slot=reused,
|
|
)
|
|
|
|
@staticmethod
|
|
def _slot_has_live_guard(connection: sqlite3.Connection, slot: int) -> bool:
|
|
return connection.execute(
|
|
"""
|
|
SELECT 1 FROM slot_guards
|
|
WHERE slot_number = ? AND state IN ('active','review_required')
|
|
""",
|
|
(slot,),
|
|
).fetchone() is not None
|
|
|
|
def mark_download_running(self, job_id: str, slot: int, generation: int) -> bool:
|
|
with self._transaction() as connection:
|
|
now = self._now()
|
|
updated = connection.execute(
|
|
"""
|
|
UPDATE download_jobs SET status = 'running', started_at = ?
|
|
WHERE job_id = ? AND slot_number = ? AND generation = ?
|
|
AND status = 'reserved'
|
|
""",
|
|
(now, job_id, slot, generation),
|
|
).rowcount
|
|
if not updated:
|
|
return False
|
|
slot_updated = connection.execute(
|
|
"""
|
|
UPDATE slots SET media_state = 'downloading', updated_at = ?
|
|
WHERE slot_number = ? AND generation = ? AND media_state = 'queued'
|
|
""",
|
|
(now, slot, generation),
|
|
).rowcount
|
|
if not slot_updated:
|
|
raise StaleOperationError("download reservation no longer owns the slot")
|
|
return True
|
|
|
|
def complete_download(
|
|
self,
|
|
job_id: str,
|
|
slot: int,
|
|
generation: int,
|
|
*,
|
|
title: str,
|
|
items: Sequence[tuple[str, str]],
|
|
yt_id: str | None = None,
|
|
) -> bool:
|
|
with self._transaction() as connection:
|
|
job = connection.execute(
|
|
"""
|
|
SELECT status FROM download_jobs
|
|
WHERE job_id = ? AND slot_number = ? AND generation = ?
|
|
""",
|
|
(job_id, slot, generation),
|
|
).fetchone()
|
|
if job is None or job["status"] not in {"reserved", "running"}:
|
|
return False
|
|
current = connection.execute(
|
|
"SELECT generation FROM slots WHERE slot_number = ?", (slot,)
|
|
).fetchone()
|
|
if current["generation"] != generation:
|
|
connection.execute(
|
|
"""
|
|
UPDATE download_jobs SET status = 'stale', finished_at = ?,
|
|
error = 'stale worker completion'
|
|
WHERE job_id = ?
|
|
""",
|
|
(self._now(), job_id),
|
|
)
|
|
return False
|
|
now = self._now()
|
|
expires = now + self.config.retention_seconds
|
|
connection.execute("DELETE FROM slot_items WHERE slot_number = ?", (slot,))
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO slot_items
|
|
(slot_number, position, generation, title, relative_path)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
(slot, position, generation, item_title, relative_path)
|
|
for position, (item_title, relative_path) in enumerate(items, start=1)
|
|
),
|
|
)
|
|
connection.execute(
|
|
"""
|
|
UPDATE slots
|
|
SET media_state = 'ready', youtube_id = ?, title = ?,
|
|
item_count = ?, ready_at = ?, expires_at = ?,
|
|
last_error = NULL, updated_at = ?
|
|
WHERE slot_number = ? AND generation = ?
|
|
""",
|
|
(yt_id, title, len(items), now, expires, now, slot, generation),
|
|
)
|
|
connection.execute(
|
|
"""
|
|
UPDATE download_jobs
|
|
SET status = 'succeeded', finished_at = ?, error = NULL
|
|
WHERE job_id = ?
|
|
""",
|
|
(now, job_id),
|
|
)
|
|
return True
|
|
|
|
def fail_download(
|
|
self,
|
|
job_id: str,
|
|
slot: int,
|
|
generation: int,
|
|
error: str,
|
|
) -> bool:
|
|
with self._transaction() as connection:
|
|
now = self._now()
|
|
updated = connection.execute(
|
|
"""
|
|
UPDATE download_jobs
|
|
SET status = 'failed', finished_at = ?, error = ?
|
|
WHERE job_id = ? AND slot_number = ? AND generation = ?
|
|
AND status IN ('reserved','running')
|
|
""",
|
|
(now, error, job_id, slot, generation),
|
|
).rowcount
|
|
if not updated:
|
|
return False
|
|
connection.execute(
|
|
"""
|
|
UPDATE slots
|
|
SET media_state = 'error', last_error = ?, updated_at = ?
|
|
WHERE slot_number = ? AND generation = ?
|
|
""",
|
|
(error, now, slot, generation),
|
|
)
|
|
return True
|
|
|
|
def get_job(self, job_id: str) -> DownloadJob | None:
|
|
with self._lock:
|
|
row = self.connection.execute(
|
|
"SELECT * FROM download_jobs WHERE job_id = ?", (job_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return DownloadJob(
|
|
job_id=row["job_id"],
|
|
slot=row["slot_number"],
|
|
generation=row["generation"],
|
|
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"],
|
|
error=row["error"],
|
|
)
|
|
|
|
def protect_slot(
|
|
self,
|
|
slot: int,
|
|
kind: str,
|
|
*,
|
|
guard_id: str | None = None,
|
|
review_after: float | None = None,
|
|
details: Mapping[str, Any] | None = None,
|
|
) -> SlotGuard:
|
|
if slot not in SLOT_IDS:
|
|
raise ValueError("slot must be in 1..10")
|
|
guard_id = guard_id or uuid.uuid4().hex
|
|
with self._transaction() as connection:
|
|
existing = connection.execute(
|
|
"""
|
|
SELECT * FROM slot_guards
|
|
WHERE slot_number = ? AND state IN ('active','review_required')
|
|
""",
|
|
(slot,),
|
|
).fetchone()
|
|
if existing is not None:
|
|
return self._row_to_guard(existing)
|
|
now = self._now()
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO slot_guards
|
|
(guard_id, slot_number, kind, state, created_at,
|
|
review_after, details_json)
|
|
VALUES (?, ?, ?, 'active', ?, ?, ?)
|
|
""",
|
|
(
|
|
guard_id,
|
|
slot,
|
|
kind,
|
|
now,
|
|
review_after,
|
|
json.dumps(details or {}, sort_keys=True),
|
|
),
|
|
)
|
|
row = connection.execute(
|
|
"SELECT * FROM slot_guards WHERE guard_id = ?", (guard_id,)
|
|
).fetchone()
|
|
assert row is not None
|
|
return self._row_to_guard(row)
|
|
|
|
@staticmethod
|
|
def _row_to_guard(row: sqlite3.Row) -> SlotGuard:
|
|
return SlotGuard(
|
|
guard_id=row["guard_id"],
|
|
slot=row["slot_number"],
|
|
kind=row["kind"],
|
|
state=row["state"],
|
|
created_at=row["created_at"],
|
|
review_after=row["review_after"],
|
|
cleared_at=row["cleared_at"],
|
|
cleared_by=row["cleared_by"],
|
|
clear_reason=row["clear_reason"],
|
|
)
|
|
|
|
def list_live_guards(self) -> list[SlotGuard]:
|
|
with self._lock:
|
|
rows = self.connection.execute(
|
|
"""
|
|
SELECT * FROM slot_guards
|
|
WHERE state IN ('active','review_required')
|
|
ORDER BY slot_number
|
|
"""
|
|
).fetchall()
|
|
return [self._row_to_guard(row) for row in rows]
|
|
|
|
def mark_due_guards_for_review(self, now: float | None = None) -> list[SlotGuard]:
|
|
now = self._now() if now is None else now
|
|
with self._transaction() as connection:
|
|
connection.execute(
|
|
"""
|
|
UPDATE slot_guards SET state = 'review_required'
|
|
WHERE state = 'active' AND review_after IS NOT NULL
|
|
AND review_after <= ?
|
|
""",
|
|
(now,),
|
|
)
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT * FROM slot_guards
|
|
WHERE state = 'review_required' ORDER BY slot_number
|
|
"""
|
|
).fetchall()
|
|
return [self._row_to_guard(row) for row in rows]
|
|
|
|
def clear_guard(
|
|
self,
|
|
slot: int,
|
|
guard_id: str,
|
|
*,
|
|
actor: str,
|
|
reason: str,
|
|
) -> bool:
|
|
if not actor.strip() or not reason.strip():
|
|
raise ValueError("administrative guard clear requires actor and reason")
|
|
with self._transaction() as connection:
|
|
now = self._now()
|
|
updated = connection.execute(
|
|
"""
|
|
UPDATE slot_guards
|
|
SET state = 'cleared', cleared_at = ?, cleared_by = ?,
|
|
clear_reason = ?
|
|
WHERE guard_id = ? AND slot_number = ?
|
|
AND state IN ('active','review_required')
|
|
""",
|
|
(now, actor.strip(), reason.strip(), guard_id, slot),
|
|
).rowcount
|
|
if not updated:
|
|
return False
|
|
connection.execute(
|
|
"""
|
|
UPDATE player_observation
|
|
SET phase = 'unknown', active_slot = NULL, active_path = NULL,
|
|
paused = NULL, reconciliation_state = 'admin_cleared',
|
|
reconciliation_due_at = NULL
|
|
WHERE singleton = 1
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO audit_events
|
|
(occurred_at, event_type, slot_number, actor, details_json)
|
|
VALUES (?, 'playback_guard_admin_cleared', ?, ?, ?)
|
|
""",
|
|
(now, slot, actor.strip(), json.dumps({"reason": reason.strip()})),
|
|
)
|
|
return True
|
|
|
|
def begin_delete(
|
|
self,
|
|
slot: int,
|
|
*,
|
|
reason: str,
|
|
expected_url: str | None = None,
|
|
) -> DeleteToken:
|
|
if slot not in SLOT_IDS:
|
|
raise ValueError("slot must be in 1..10")
|
|
with self._transaction() as connection:
|
|
row = connection.execute(
|
|
"SELECT * FROM slots WHERE slot_number = ?", (slot,)
|
|
).fetchone()
|
|
assert row is not None
|
|
if row["media_state"] == "empty":
|
|
raise StaleOperationError(f"slot {slot} is already empty")
|
|
if expected_url is not None and row["url"] != expected_url:
|
|
raise StaleOperationError(f"slot {slot} URL no longer matches")
|
|
if row["media_state"] in {"queued", "downloading", "deleting"}:
|
|
raise SlotProtectedError(f"slot {slot} is {row['media_state']}")
|
|
if self._slot_has_live_guard(connection, slot):
|
|
raise SlotProtectedError(f"slot {slot} has active playback protection")
|
|
live_job = connection.execute(
|
|
"""
|
|
SELECT 1 FROM download_jobs
|
|
WHERE slot_number = ? AND status IN ('reserved','running')
|
|
""",
|
|
(slot,),
|
|
).fetchone()
|
|
if live_job is not None:
|
|
raise SlotProtectedError(f"slot {slot} has an active download")
|
|
generation = row["generation"] + 1
|
|
now = self._now()
|
|
connection.execute(
|
|
"""
|
|
UPDATE slots
|
|
SET generation = ?, media_state = 'deleting', updated_at = ?
|
|
WHERE slot_number = ?
|
|
""",
|
|
(generation, now, slot),
|
|
)
|
|
return DeleteToken(slot=slot, generation=generation, reason=reason)
|
|
|
|
def finish_delete(self, token: DeleteToken) -> bool:
|
|
with self._transaction() as connection:
|
|
now = self._now()
|
|
updated = connection.execute(
|
|
"""
|
|
UPDATE slots
|
|
SET media_state = 'empty', url = NULL, youtube_id = NULL,
|
|
title = '', item_count = 0, created_at = NULL,
|
|
ready_at = NULL, expires_at = NULL, last_error = NULL,
|
|
updated_at = ?
|
|
WHERE slot_number = ? AND generation = ?
|
|
AND media_state = 'deleting'
|
|
""",
|
|
(now, token.slot, token.generation),
|
|
).rowcount
|
|
if not updated:
|
|
return False
|
|
connection.execute(
|
|
"DELETE FROM slot_items WHERE slot_number = ?", (token.slot,)
|
|
)
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO audit_events
|
|
(occurred_at, event_type, slot_number, actor, details_json)
|
|
VALUES (?, 'slot_deleted', ?, 'controller', ?)
|
|
""",
|
|
(now, token.slot, json.dumps({"reason": token.reason})),
|
|
)
|
|
return True
|
|
|
|
def pending_deletions(self) -> list[DeleteToken]:
|
|
with self._lock:
|
|
rows = self.connection.execute(
|
|
"""
|
|
SELECT slot_number, generation FROM slots
|
|
WHERE media_state = 'deleting' ORDER BY slot_number
|
|
"""
|
|
).fetchall()
|
|
return [
|
|
DeleteToken(
|
|
slot=row["slot_number"],
|
|
generation=row["generation"],
|
|
reason="recovered_interrupted_deletion",
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
def begin_expired_deletions(self, now: float | None = None) -> list[DeleteToken]:
|
|
now = self._now() if now is None else now
|
|
tokens: list[DeleteToken] = []
|
|
for slot in SLOT_IDS:
|
|
record = self.get_slot(slot)
|
|
if (
|
|
record.media_state in {"ready", "error"}
|
|
and record.expires_at is not None
|
|
and record.expires_at <= now
|
|
):
|
|
try:
|
|
tokens.append(self.begin_delete(slot, reason="expired"))
|
|
except SlotProtectedError:
|
|
continue
|
|
return tokens
|
|
|
|
def player_observation(self) -> PlayerObservation:
|
|
with self._lock:
|
|
row = self.connection.execute(
|
|
"SELECT * FROM player_observation WHERE singleton = 1"
|
|
).fetchone()
|
|
assert row is not None
|
|
return PlayerObservation(
|
|
availability=row["availability"],
|
|
phase=row["phase"],
|
|
active_slot=row["active_slot"],
|
|
active_path=row["active_path"],
|
|
paused=None if row["paused"] is None else bool(row["paused"]),
|
|
generation=row["generation"],
|
|
reconciliation_state=row["reconciliation_state"],
|
|
reconciliation_due_at=row["reconciliation_due_at"],
|
|
last_status_at=row["last_status_at"],
|
|
last_telemetry_at=row["last_telemetry_at"],
|
|
)
|
|
|
|
def set_player_observation(
|
|
self,
|
|
*,
|
|
availability: str,
|
|
phase: str,
|
|
active_slot: int | None,
|
|
active_path: str | None,
|
|
paused: bool | None,
|
|
reconciliation_state: str,
|
|
reconciliation_due_at: float | None,
|
|
status_payload: Mapping[str, Any] | None = None,
|
|
telemetry_payload: Mapping[str, Any] | None = None,
|
|
) -> PlayerObservation:
|
|
now = self._now()
|
|
with self._transaction() as connection:
|
|
connection.execute(
|
|
"""
|
|
UPDATE player_observation
|
|
SET availability = ?, phase = ?, active_slot = ?,
|
|
active_path = ?, paused = ?, generation = generation + 1,
|
|
reconciliation_state = ?, reconciliation_due_at = ?,
|
|
last_status_at = CASE WHEN ? IS NULL THEN last_status_at ELSE ? END,
|
|
last_telemetry_at = CASE WHEN ? IS NULL THEN last_telemetry_at ELSE ? END,
|
|
last_status_json = COALESCE(?, last_status_json),
|
|
last_telemetry_json = COALESCE(?, last_telemetry_json)
|
|
WHERE singleton = 1
|
|
""",
|
|
(
|
|
availability,
|
|
phase,
|
|
active_slot,
|
|
active_path,
|
|
None if paused is None else int(paused),
|
|
reconciliation_state,
|
|
reconciliation_due_at,
|
|
None if status_payload is None else 1,
|
|
now,
|
|
None if telemetry_payload is None else 1,
|
|
now,
|
|
None if status_payload is None else json.dumps(status_payload, sort_keys=True),
|
|
None if telemetry_payload is None else json.dumps(telemetry_payload, sort_keys=True),
|
|
),
|
|
)
|
|
return self.player_observation()
|
|
|
|
def mark_restart_uncertain(self) -> SlotGuard | None:
|
|
observation = self.player_observation()
|
|
if observation.active_slot is None:
|
|
return None
|
|
due = None
|
|
if self.config.uncertain_review_after_seconds > 0:
|
|
due = self._now() + self.config.uncertain_review_after_seconds
|
|
guard = self.protect_slot(
|
|
observation.active_slot,
|
|
"restart_uncertain",
|
|
review_after=due,
|
|
details={"previous_phase": observation.phase},
|
|
)
|
|
due = guard.review_after
|
|
self.set_player_observation(
|
|
availability="unknown",
|
|
phase="unknown",
|
|
active_slot=observation.active_slot,
|
|
active_path=observation.active_path,
|
|
paused=None,
|
|
reconciliation_state="awaiting_evidence",
|
|
reconciliation_due_at=due,
|
|
)
|
|
return guard
|
|
|
|
def set_published_item_count(self, slot: int, count: int) -> None:
|
|
with self._transaction() as connection:
|
|
connection.execute(
|
|
"""
|
|
UPDATE slots SET published_item_count = ?, updated_at = ?
|
|
WHERE slot_number = ?
|
|
""",
|
|
(count, self._now(), slot),
|
|
)
|