feat: coordinate authoritative playback and audio routing
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -49,6 +50,37 @@ class FreshStoreTests(StoreTestCase):
|
||||
self.assertTrue(all(slot.media_state == "empty" for slot in slots))
|
||||
self.assertEqual(store.player_observation().phase, "unknown")
|
||||
|
||||
def test_schema_one_database_upgrades_external_reconciliation_atomically(self):
|
||||
with self.open_store() as store:
|
||||
database_path = store.path
|
||||
connection = sqlite3.connect(database_path)
|
||||
try:
|
||||
connection.execute(
|
||||
"ALTER TABLE player_observation DROP COLUMN reconciliation_id"
|
||||
)
|
||||
connection.execute("DELETE FROM schema_migrations WHERE version = 2")
|
||||
connection.execute("PRAGMA user_version = 1")
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with self.open_store() as upgraded:
|
||||
self.assertEqual(
|
||||
upgraded.connection.execute("PRAGMA user_version").fetchone()[0],
|
||||
2,
|
||||
)
|
||||
columns = {
|
||||
row["name"]
|
||||
for row in upgraded.connection.execute(
|
||||
"PRAGMA table_info(player_observation)"
|
||||
).fetchall()
|
||||
}
|
||||
self.assertIn("reconciliation_id", columns)
|
||||
migration = upgraded.connection.execute(
|
||||
"SELECT name FROM schema_migrations WHERE version = 2"
|
||||
).fetchone()
|
||||
self.assertEqual(migration["name"], "external_playback_reconciliation")
|
||||
|
||||
def test_slot_reservation_is_committed_before_worker_transition(self):
|
||||
with self.open_store() as store:
|
||||
reservation = store.reserve_download(
|
||||
@@ -127,6 +159,25 @@ class FreshStoreTests(StoreTestCase):
|
||||
self.assertEqual(store.get_slot(2).slot, 2)
|
||||
self.assertEqual(store.get_slot(2).url, "https://example.test/two")
|
||||
|
||||
def test_concurrent_first_open_installs_only_one_valid_database(self):
|
||||
stores = [ControllerStore(self.config, now=self.clock) for _ in range(2)]
|
||||
|
||||
def open_store(store):
|
||||
store.open()
|
||||
return [slot.slot for slot in store.list_slots()]
|
||||
|
||||
try:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(open_store, stores))
|
||||
self.assertEqual(results, [list(range(1, 11)), list(range(1, 11))])
|
||||
markers = stores[0].connection.execute(
|
||||
"SELECT name FROM data_migrations ORDER BY name"
|
||||
).fetchall()
|
||||
self.assertEqual([row["name"] for row in markers], ["fresh_database_v1"])
|
||||
finally:
|
||||
for store in stores:
|
||||
store.close()
|
||||
|
||||
|
||||
class GuardAndExpiryTests(StoreTestCase):
|
||||
def _ready_slot(self, store, *, slot_expected=1):
|
||||
@@ -196,6 +247,7 @@ class GuardAndExpiryTests(StoreTestCase):
|
||||
paused=False,
|
||||
reconciliation_state="confirmed",
|
||||
reconciliation_due_at=None,
|
||||
reconciliation_id=None,
|
||||
)
|
||||
guard = store.mark_restart_uncertain()
|
||||
self.assertEqual(guard.slot, 1)
|
||||
@@ -204,6 +256,150 @@ class GuardAndExpiryTests(StoreTestCase):
|
||||
self.assertEqual(observation.reconciliation_state, "awaiting_evidence")
|
||||
self.assertEqual(observation.active_slot, 1)
|
||||
|
||||
def test_external_playback_restart_gets_stable_review_token_and_exact_clear(self):
|
||||
external_path = "/srv/operator-playlists/live.m3u"
|
||||
with self.open_store() as store:
|
||||
store.set_player_observation(
|
||||
availability="online",
|
||||
phase="playing",
|
||||
active_slot=None,
|
||||
active_path=external_path,
|
||||
paused=False,
|
||||
reconciliation_state="confirmed",
|
||||
reconciliation_due_at=None,
|
||||
reconciliation_id=None,
|
||||
)
|
||||
self.assertIsNone(store.mark_restart_uncertain())
|
||||
first = store.player_observation()
|
||||
self.assertEqual(first.reconciliation_state, "awaiting_evidence")
|
||||
self.assertIsNotNone(first.reconciliation_id)
|
||||
self.assertEqual(first.reconciliation_due_at, self.clock.value + 30)
|
||||
|
||||
self.clock.value += 10
|
||||
self.assertIsNone(store.mark_restart_uncertain())
|
||||
repeated = store.player_observation()
|
||||
self.assertEqual(repeated.reconciliation_id, first.reconciliation_id)
|
||||
self.assertEqual(repeated.reconciliation_due_at, first.reconciliation_due_at)
|
||||
|
||||
self.clock.value = first.reconciliation_due_at + 1
|
||||
due = store.mark_external_reconciliation_due()
|
||||
self.assertEqual(due, (first.reconciliation_id, external_path))
|
||||
self.assertIsNone(store.mark_external_reconciliation_due())
|
||||
review = store.player_observation()
|
||||
self.assertEqual(review.reconciliation_state, "operator_review")
|
||||
self.assertEqual(review.active_path, external_path)
|
||||
|
||||
self.assertFalse(
|
||||
store.clear_external_reconciliation(
|
||||
"stale-review",
|
||||
actor="operator",
|
||||
reason="Player was replaced",
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
store.clear_external_reconciliation(
|
||||
first.reconciliation_id,
|
||||
actor="operator",
|
||||
reason="Player was replaced",
|
||||
)
|
||||
)
|
||||
cleared = store.player_observation()
|
||||
self.assertEqual(cleared.reconciliation_state, "admin_cleared")
|
||||
self.assertIsNone(cleared.active_path)
|
||||
self.assertIsNone(cleared.reconciliation_id)
|
||||
|
||||
def test_restart_converts_existing_active_guard_and_preserves_deadline(self):
|
||||
with self.open_store() as store:
|
||||
self._ready_slot(store)
|
||||
store.protect_slot(1, "player_active", guard_id="active-before-restart")
|
||||
store.set_player_observation(
|
||||
availability="online",
|
||||
phase="playing",
|
||||
active_slot=1,
|
||||
active_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||
paused=False,
|
||||
reconciliation_state="confirmed",
|
||||
reconciliation_due_at=None,
|
||||
reconciliation_id=None,
|
||||
)
|
||||
guard = store.mark_restart_uncertain()
|
||||
self.assertEqual(guard.guard_id, "active-before-restart")
|
||||
self.assertEqual(guard.kind, "restart_uncertain")
|
||||
first_deadline = guard.review_after
|
||||
self.clock.value += 10
|
||||
repeated = store.mark_restart_uncertain()
|
||||
self.assertEqual(repeated.guard_id, guard.guard_id)
|
||||
self.assertEqual(repeated.review_after, first_deadline)
|
||||
|
||||
def test_restart_converts_pre_evidence_pending_guard_for_admin_review(self):
|
||||
with self.open_store() as store:
|
||||
self._ready_slot(store)
|
||||
store.protect_slot(1, "pending_play", guard_id="published-before-crash")
|
||||
guard = store.mark_restart_uncertain()
|
||||
self.assertEqual(guard.guard_id, "published-before-crash")
|
||||
self.assertEqual(guard.kind, "restart_uncertain")
|
||||
self.assertEqual(store.player_observation().active_slot, 1)
|
||||
self.assertTrue(
|
||||
store.clear_guard(
|
||||
1,
|
||||
guard.guard_id,
|
||||
actor="operator",
|
||||
reason="Player replaced",
|
||||
)
|
||||
)
|
||||
|
||||
def test_guard_review_transition_is_reported_only_once(self):
|
||||
with self.open_store() as store:
|
||||
self._ready_slot(store)
|
||||
store.protect_slot(
|
||||
1,
|
||||
"restart_uncertain",
|
||||
guard_id="one-warning",
|
||||
review_after=self.clock.value,
|
||||
)
|
||||
self.assertEqual(len(store.mark_due_guards_for_review()), 1)
|
||||
self.assertEqual(store.mark_due_guards_for_review(), [])
|
||||
|
||||
def test_clearing_one_legacy_guard_does_not_erase_other_observation(self):
|
||||
with self.open_store() as store:
|
||||
self._ready_slot(store)
|
||||
second = store.reserve_download(
|
||||
"https://example.test/second", None, None, job_id="second"
|
||||
)
|
||||
store.mark_download_running("second", 2, second.generation)
|
||||
store.complete_download(
|
||||
"second",
|
||||
2,
|
||||
second.generation,
|
||||
title="Second",
|
||||
items=(("Second", "second.mp4"),),
|
||||
)
|
||||
first_guard = store.protect_slot(
|
||||
1, "legacy_uncertain", guard_id="legacy-one"
|
||||
)
|
||||
store.protect_slot(2, "player_active", guard_id="active-two")
|
||||
store.set_player_observation(
|
||||
availability="online",
|
||||
phase="playing",
|
||||
active_slot=2,
|
||||
active_path="/mnt/Media/YTPlayer/slot2/playlist.m3u",
|
||||
paused=False,
|
||||
reconciliation_state="confirmed",
|
||||
reconciliation_due_at=None,
|
||||
reconciliation_id=None,
|
||||
)
|
||||
self.assertTrue(
|
||||
store.clear_guard(
|
||||
1,
|
||||
first_guard.guard_id,
|
||||
actor="operator",
|
||||
reason="legacy evidence resolved",
|
||||
)
|
||||
)
|
||||
observation = store.player_observation()
|
||||
self.assertEqual(observation.active_slot, 2)
|
||||
self.assertEqual(observation.phase, "playing")
|
||||
|
||||
|
||||
class LegacyMigrationTests(StoreTestCase):
|
||||
def write_legacy(self, data):
|
||||
|
||||
Reference in New Issue
Block a user