The 12s first-byte grace was the wrong trade and a real play found it within the hour. A 2-hour upload took 3 minutes to start, played 6 seconds, and stalled. Jellyfin had run ffmpeg with -probesize 1G against the growing stream and then transcoded to HLS with libx264. The cause is the container. A fragmented MP4 with empty_moov has no duration in its header, so the only way to get one is to sum every fragment -- probing a growing file reads all of it. Jellyfin cannot establish duration, codec or bitrate, so it abandons direct play and transcodes a stream it also cannot seek. It was targeting 4.83 Mbps against a source measured at 3.29: re-encoding a stream that already fit, because it could not measure it. The same video once complete reports SupportsDirectPlay with the exact runtime and bitrate. So FIRST_BYTE_GRACE defaults to infinite again, with --wait-timeout raised to 600s for a 2-hour upload. A cold long video is slow to start, which is accepted: the fetch outlives the request so a retry is instant, and a retryable stall beats a transcode that wastes a gigabyte and cannot work. Both failure modes are recorded at the constant in the order measured so the 12s cap is not reintroduced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
9.7 KiB
Python
267 lines
9.7 KiB
Python
"""Orchestration: ordering, the lock, and what turns the cron check red."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from ytstream import config, discovery, jellyfin, reap, runner, subsync, videos
|
|
|
|
from conftest import add_video
|
|
|
|
|
|
@pytest.fixture()
|
|
def quiet_jellyfin(monkeypatch):
|
|
"""Jellyfin refresh is best effort; record calls instead of making them."""
|
|
calls = []
|
|
monkeypatch.setattr(jellyfin.Jellyfin, "refresh", lambda self: calls.append(1))
|
|
return calls
|
|
|
|
|
|
@pytest.fixture()
|
|
def no_thumbs(monkeypatch):
|
|
from ytstream import strm
|
|
|
|
monkeypatch.setattr(strm, "fetch_thumbnail", lambda *a, **k: False)
|
|
|
|
|
|
# ----------------------------------------------------------------------- lock
|
|
|
|
|
|
def test_lock_is_exclusive(tmp_path):
|
|
path = tmp_path / "run.lock"
|
|
with runner.exclusive_lock(path):
|
|
with pytest.raises(runner.AlreadyRunning):
|
|
with runner.exclusive_lock(path):
|
|
pass # pragma: no cover
|
|
|
|
|
|
def test_lock_is_released_after_use(tmp_path):
|
|
path = tmp_path / "run.lock"
|
|
with runner.exclusive_lock(path):
|
|
pass
|
|
with runner.exclusive_lock(path):
|
|
pass
|
|
|
|
|
|
def test_lock_is_released_even_when_the_body_raises(tmp_path):
|
|
path = tmp_path / "run.lock"
|
|
with pytest.raises(ValueError):
|
|
with runner.exclusive_lock(path):
|
|
raise ValueError("boom")
|
|
with runner.exclusive_lock(path):
|
|
pass
|
|
|
|
|
|
# --------------------------------------------------------------- materialising
|
|
|
|
|
|
def test_materialise_all_writes_everything_listed(conn, settings, media_root,
|
|
channel, no_thumbs):
|
|
for index in range(3):
|
|
add_video(conn, channel["id"], f"vid{index:08d}")
|
|
|
|
stats = runner.materialise_all(conn, settings)
|
|
|
|
assert stats["materialised"] == 3
|
|
assert stats["errors"] == 0
|
|
assert videos.queue_depth(conn) == 0
|
|
|
|
|
|
def test_tvshow_nfo_is_written_once_per_channel(conn, settings, media_root,
|
|
channel, no_thumbs):
|
|
for index in range(3):
|
|
add_video(conn, channel["id"], f"vid{index:08d}")
|
|
|
|
stats = runner.materialise_all(conn, settings)
|
|
|
|
assert stats["shows"] == 1
|
|
assert (media_root / "clabretro" / "tvshow.nfo").exists()
|
|
|
|
|
|
def test_channel_with_nothing_in_the_window_creates_no_directory(
|
|
conn, settings, media_root, channel, no_thumbs
|
|
):
|
|
"""52 of 117 measured channels are in this state, and an empty series in
|
|
Jellyfin looks like a bug rather than a quiet channel."""
|
|
add_video(conn, channel["id"], "vid00000001", state=videos.SKIPPED_OLD)
|
|
|
|
runner.materialise_all(conn, settings)
|
|
|
|
assert not (media_root / "clabretro").exists()
|
|
|
|
|
|
def test_materialise_all_honours_the_limit(conn, settings, media_root, channel,
|
|
no_thumbs):
|
|
for index in range(5):
|
|
add_video(conn, channel["id"], f"vid{index:08d}")
|
|
stats = runner.materialise_all(conn, settings, limit=2)
|
|
assert stats["materialised"] == 2
|
|
|
|
|
|
def test_materialise_skips_a_video_whose_channel_vanished(conn, settings,
|
|
media_root, channel,
|
|
no_thumbs):
|
|
add_video(conn, channel["id"], "vid00000001")
|
|
# Simulate the channel being unsubscribed between claiming and writing, with
|
|
# foreign keys off so the row survives to be found.
|
|
conn.execute("PRAGMA foreign_keys = OFF")
|
|
with conn:
|
|
conn.execute("DELETE FROM channel WHERE id = ?", (channel["id"],))
|
|
|
|
stats = runner.materialise_all(conn, settings)
|
|
|
|
assert stats["materialised"] == 0
|
|
assert stats["errors"] == 0
|
|
|
|
|
|
# ------------------------------------------------------------------ full cycle
|
|
|
|
|
|
def test_run_order_is_sync_poll_materialise_reap(conn, settings, media_root,
|
|
channel, monkeypatch,
|
|
quiet_jellyfin):
|
|
order = []
|
|
monkeypatch.setattr(subsync, "sync_all",
|
|
lambda *a: order.append("sync") or {"added": 0, "queued": 0,
|
|
"removed": 0, "refused": 0,
|
|
"sources": 0,
|
|
"pending_removal": 0})
|
|
monkeypatch.setattr(discovery, "poll_all",
|
|
lambda *a: order.append("poll") or {"queued": 0})
|
|
monkeypatch.setattr(runner, "materialise_all",
|
|
lambda *a, **k: order.append("materialise") or
|
|
{"materialised": 0, "shows": 0, "errors": 0})
|
|
monkeypatch.setattr(reap, "run",
|
|
lambda *a: order.append("reap") or {"aged_out": 0})
|
|
|
|
runner.run(conn, settings)
|
|
|
|
assert order == ["sync", "poll", "materialise", "reap"]
|
|
|
|
|
|
def test_single_channel_run_skips_the_sync(conn, settings, channel, monkeypatch,
|
|
quiet_jellyfin):
|
|
"""A targeted run is an operator action, not a mirror pass."""
|
|
monkeypatch.setattr(subsync, "sync_all",
|
|
lambda *a: pytest.fail("sync should not run"))
|
|
monkeypatch.setattr(discovery, "poll_all", lambda *a: {"queued": 0})
|
|
monkeypatch.setattr(reap, "run", lambda *a: {"aged_out": 0})
|
|
|
|
result = runner.run(conn, settings, channel_pk=channel["id"])
|
|
|
|
assert "sync" not in result
|
|
|
|
|
|
def test_run_records_last_run_at(conn, settings, channel, monkeypatch,
|
|
quiet_jellyfin):
|
|
monkeypatch.setattr(subsync, "sync_all", lambda *a: {"added": 0, "queued": 0,
|
|
"removed": 0, "refused": 0,
|
|
"sources": 0,
|
|
"pending_removal": 0})
|
|
monkeypatch.setattr(discovery, "poll_all", lambda *a: {"queued": 0})
|
|
monkeypatch.setattr(reap, "run", lambda *a: {"aged_out": 0})
|
|
|
|
runner.run(conn, settings)
|
|
|
|
assert settings.raw("last_run_at")
|
|
|
|
|
|
def test_jellyfin_is_refreshed_only_when_the_tree_changed(
|
|
conn, settings, media_root, channel, monkeypatch, quiet_jellyfin, no_thumbs
|
|
):
|
|
monkeypatch.setattr(subsync, "sync_all", lambda *a: {"added": 0, "queued": 0,
|
|
"removed": 0, "refused": 0,
|
|
"sources": 0,
|
|
"pending_removal": 0})
|
|
monkeypatch.setattr(discovery, "poll_all", lambda *a: {"queued": 0})
|
|
|
|
runner.run(conn, settings)
|
|
assert quiet_jellyfin == []
|
|
|
|
add_video(conn, channel["id"], "vid00000001")
|
|
runner.run(conn, settings)
|
|
assert len(quiet_jellyfin) == 1
|
|
|
|
|
|
# ------------------------------------------------------------------ exit codes
|
|
|
|
|
|
def test_refused_sync_turns_the_check_red():
|
|
"""A silently-broken mirror is the worst outcome available: nothing looks
|
|
wrong until someone asks why a channel never appeared."""
|
|
assert runner.exit_code({"sync": {"refused": 1}}) == 1
|
|
|
|
|
|
def test_materialise_errors_turn_the_check_red():
|
|
assert runner.exit_code({"materialise": {"errors": 2}}) == 1
|
|
|
|
|
|
def test_poll_failures_alone_do_not_turn_the_check_red():
|
|
"""Two of 119 measured channels fail permanently — terminated or private."""
|
|
assert runner.exit_code({"poll": {"failed": 2}}) == 0
|
|
|
|
|
|
def test_clean_run_is_zero():
|
|
assert runner.exit_code({"sync": {"refused": 0}, "poll": {"failed": 0},
|
|
"materialise": {"errors": 0}}) == 0
|
|
|
|
|
|
# ------------------------------------------------------------------- summarise
|
|
|
|
|
|
def test_summarise_mentions_a_refusal():
|
|
text = runner.summarise({
|
|
"sync": {"added": 0, "queued": 0, "removed": 0, "refused": 1},
|
|
"poll": {"channels": 5, "queued": 0},
|
|
"materialise": {"materialised": 0},
|
|
"reap": {"aged_out": 0},
|
|
})
|
|
assert "SYNC_REFUSED" in text
|
|
|
|
|
|
def test_summarise_is_a_single_line():
|
|
text = runner.summarise({
|
|
"sync": {"added": 1, "queued": 2, "removed": 3, "refused": 0},
|
|
"poll": {"channels": 119, "queued": 4, "shorts": 5, "live": 6, "failed": 2},
|
|
"materialise": {"materialised": 7, "errors": 0},
|
|
"reap": {"aged_out": 8},
|
|
})
|
|
assert "\n" not in text
|
|
assert "channels=119" in text
|
|
assert "materialised=7" in text
|
|
assert "aged_out=8" in text
|
|
|
|
|
|
def test_run_prunes_channels_that_have_nothing_to_show(conn, settings, media_root,
|
|
monkeypatch):
|
|
"""End to end: a subscribed channel whose directory exists only because
|
|
subscribe() wrote tvshow.nfo must not survive a run as an empty series."""
|
|
from conftest import add_channel
|
|
from ytstream import runner
|
|
|
|
empty = add_channel(conn, "UC" + "s" * 22, "Dead Channel", "dead-channel")
|
|
tree = media_root / "dead-channel"
|
|
tree.mkdir()
|
|
(tree / "tvshow.nfo").write_text("<tvshow/>")
|
|
|
|
assert runner.prune_empty_channels(conn) == 1
|
|
assert not tree.exists()
|
|
|
|
|
|
def test_run_keeps_a_channel_with_a_materialised_video(conn, settings, media_root):
|
|
from conftest import add_channel, add_video
|
|
from ytstream import runner, videos
|
|
|
|
row = add_channel(conn, "UC" + "t" * 22, "Alive", "alive")
|
|
tree = media_root / "alive"
|
|
(tree / "Season 2026").mkdir(parents=True)
|
|
(tree / "Season 2026" / "ep.strm").write_text("url")
|
|
video = add_video(conn, row["id"], "vid00000009")
|
|
videos.mark_materialised(conn, "vid00000009",
|
|
rel_path="alive/Season 2026/ep.strm", season=2026,
|
|
episode=1, upload_date="2026-08-01", duration=900,
|
|
title="t")
|
|
|
|
assert runner.prune_empty_channels(conn) == 0
|
|
assert tree.exists()
|