feat: persist isolated utility export jobs
This commit is contained in:
@@ -82,6 +82,9 @@ class ConfigurationTests(unittest.TestCase):
|
||||
self.assertEqual(config.storage.uncertain_review_after_seconds, 900.0)
|
||||
self.assertEqual(config.mqtt.publish_ack_timeout_seconds, 2.0)
|
||||
self.assertEqual(config.mqtt.router_echo_grace_seconds, 2.0)
|
||||
self.assertEqual(config.downloads.export_directory, Path("exports"))
|
||||
self.assertEqual(config.downloads.export_max_height, 1080)
|
||||
self.assertEqual(config.downloads.max_queued_exports, 20)
|
||||
|
||||
def test_documented_environment_overrides_apply(self):
|
||||
env = {
|
||||
@@ -175,6 +178,34 @@ class ConfigurationTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ConfigError, f"{field} must be an absolute path"):
|
||||
load_config(path, environ={})
|
||||
|
||||
def test_export_directory_must_stay_relative_to_media_root(self):
|
||||
invalid = ("/tmp/exports", "../exports", "nested/../exports", r"nested\\exports")
|
||||
for value in invalid:
|
||||
with self.subTest(value=value), tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "config.yaml"
|
||||
path.write_text(
|
||||
f"downloads:\n export_directory: {value!r}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ConfigError, "safe relative directory"):
|
||||
load_config(path, environ={})
|
||||
|
||||
def test_export_limits_are_bounded_positive_integers(self):
|
||||
cases = (
|
||||
("export_max_height", 0),
|
||||
("max_queued_exports", 0),
|
||||
("max_queued_exports", 1001),
|
||||
)
|
||||
for field, value in cases:
|
||||
with self.subTest(field=field), tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "config.yaml"
|
||||
path.write_text(
|
||||
f"downloads:\n {field}: {value}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ConfigError, field):
|
||||
load_config(path, environ={})
|
||||
|
||||
|
||||
class ImportLifecycleTests(unittest.TestCase):
|
||||
def test_controller_core_import_does_not_connect_or_create_runtime_paths(self):
|
||||
|
||||
+127
-2
@@ -50,7 +50,7 @@ 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):
|
||||
def test_schema_one_database_upgrades_reconciliation_and_exports_atomically(self):
|
||||
with self.open_store() as store:
|
||||
database_path = store.path
|
||||
connection = sqlite3.connect(database_path)
|
||||
@@ -67,7 +67,7 @@ class FreshStoreTests(StoreTestCase):
|
||||
with self.open_store() as upgraded:
|
||||
self.assertEqual(
|
||||
upgraded.connection.execute("PRAGMA user_version").fetchone()[0],
|
||||
2,
|
||||
3,
|
||||
)
|
||||
columns = {
|
||||
row["name"]
|
||||
@@ -80,6 +80,131 @@ class FreshStoreTests(StoreTestCase):
|
||||
"SELECT name FROM schema_migrations WHERE version = 2"
|
||||
).fetchone()
|
||||
self.assertEqual(migration["name"], "external_playback_reconciliation")
|
||||
export_migration = upgraded.connection.execute(
|
||||
"SELECT name FROM schema_migrations WHERE version = 3"
|
||||
).fetchone()
|
||||
self.assertEqual(export_migration["name"], "utility_export_jobs")
|
||||
tables = {
|
||||
row["name"]
|
||||
for row in upgraded.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
).fetchall()
|
||||
}
|
||||
self.assertIn("export_jobs", tables)
|
||||
|
||||
def test_schema_two_database_adds_only_utility_export_jobs(self):
|
||||
with self.open_store() as store:
|
||||
database_path = store.path
|
||||
connection = sqlite3.connect(database_path)
|
||||
try:
|
||||
connection.execute("DROP TABLE export_jobs")
|
||||
connection.execute("DELETE FROM schema_migrations WHERE version = 3")
|
||||
connection.execute("PRAGMA user_version = 2")
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with self.open_store() as upgraded:
|
||||
self.assertEqual(
|
||||
upgraded.connection.execute("PRAGMA user_version").fetchone()[0],
|
||||
3,
|
||||
)
|
||||
self.assertEqual(upgraded.list_export_jobs(), [])
|
||||
|
||||
def test_export_jobs_are_slot_free_bounded_and_durable(self):
|
||||
with self.open_store() as store:
|
||||
first = store.reserve_export_job(
|
||||
"https://www.youtube.com/watch?v=one",
|
||||
"mp4",
|
||||
"Alice",
|
||||
"Sunday",
|
||||
max_active=1,
|
||||
job_id="export-one",
|
||||
)
|
||||
self.assertEqual(first.status, "queued")
|
||||
self.assertEqual(first.job_id, "export-one")
|
||||
self.assertEqual(
|
||||
store.reserve_export_job(
|
||||
"https://www.youtube.com/watch?v=two",
|
||||
"mp3",
|
||||
None,
|
||||
None,
|
||||
max_active=1,
|
||||
).status,
|
||||
"queue_full",
|
||||
)
|
||||
self.assertTrue(store.mark_export_running("export-one"))
|
||||
self.assertTrue(
|
||||
store.complete_export_job(
|
||||
"export-one",
|
||||
("exports/video/export-one/Service.mp4",),
|
||||
)
|
||||
)
|
||||
job = store.get_export_job("export-one")
|
||||
self.assertEqual(job.mode, "mp4")
|
||||
self.assertEqual(job.status, "completed")
|
||||
self.assertEqual(job.submitter, "Alice")
|
||||
self.assertEqual(
|
||||
job.output_paths,
|
||||
("exports/video/export-one/Service.mp4",),
|
||||
)
|
||||
self.assertTrue(all(slot.media_state == "empty" for slot in store.list_slots()))
|
||||
|
||||
def test_restart_marks_live_exports_interrupted_without_touching_slots(self):
|
||||
store = self.open_store()
|
||||
store.reserve_export_job(
|
||||
"https://www.youtube.com/playlist?list=one",
|
||||
"m4a",
|
||||
None,
|
||||
None,
|
||||
max_active=2,
|
||||
job_id="queued-export",
|
||||
)
|
||||
store.reserve_export_job(
|
||||
"https://www.youtube.com/watch?v=two",
|
||||
"mp3",
|
||||
None,
|
||||
None,
|
||||
max_active=2,
|
||||
job_id="running-export",
|
||||
)
|
||||
store.mark_export_running("running-export")
|
||||
store.close()
|
||||
|
||||
with self.open_store() as reopened:
|
||||
jobs = {job.job_id: job for job in reopened.list_export_jobs()}
|
||||
self.assertEqual(jobs["queued-export"].status, "interrupted")
|
||||
self.assertEqual(jobs["running-export"].status, "interrupted")
|
||||
self.assertTrue(
|
||||
all("Controller restart" in job.error for job in jobs.values())
|
||||
)
|
||||
self.assertTrue(
|
||||
all(slot.media_state == "empty" for slot in reopened.list_slots())
|
||||
)
|
||||
|
||||
def test_export_failures_do_not_corrupt_player_inventory(self):
|
||||
with self.open_store() as store:
|
||||
slot = store.reserve_download(
|
||||
"https://www.youtube.com/watch?v=managed",
|
||||
None,
|
||||
None,
|
||||
job_id="managed",
|
||||
)
|
||||
store.fail_download(
|
||||
"managed", slot.slot, slot.generation, "managed failure"
|
||||
)
|
||||
before = store.get_slot(slot.slot)
|
||||
export = store.reserve_export_job(
|
||||
"https://www.youtube.com/watch?v=export",
|
||||
"mp3",
|
||||
None,
|
||||
None,
|
||||
max_active=5,
|
||||
job_id="failed-export",
|
||||
)
|
||||
self.assertEqual(export.status, "queued")
|
||||
self.assertTrue(store.fail_export_job("failed-export", "ffmpeg failed"))
|
||||
self.assertEqual(store.get_slot(slot.slot), before)
|
||||
|
||||
def test_slot_reservation_is_committed_before_worker_transition(self):
|
||||
with self.open_store() as store:
|
||||
|
||||
Reference in New Issue
Block a user