Never serve a partial file: it makes Jellyfin transcode
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>
This commit is contained in:
@@ -413,3 +413,132 @@ def test_reclassifying_a_materialised_video_removes_its_files(
|
||||
assert stats["shorts"] == 1
|
||||
assert videos.get(conn, "vid00000001")["state"] == videos.SKIPPED_SHORT
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
# ------------------------------------------------- the min_keep_videos floor
|
||||
#
|
||||
# §5 defines retention as max(retention_days, min_keep_videos newest) and gives
|
||||
# the reason: a channel that uploads every six weeks has nothing inside a 30-day
|
||||
# window and would appear in Jellyfin as an empty series. reap.py implemented the
|
||||
# second half; discovery did not, so it only protected videos that had already
|
||||
# been materialised. Measured on the real 119 subscriptions: 441 episodes but 57
|
||||
# channels with none, 55 of them holding skipped_old rows.
|
||||
|
||||
|
||||
def _old(conn, channel, count, *, start=1):
|
||||
"""`count` skipped_old videos, newest first at 2026-01-{start}..."""
|
||||
made = []
|
||||
for index in range(count):
|
||||
made.append(add_video(
|
||||
conn, channel["id"], f"old{index + start:08d}",
|
||||
upload_date=f"2026-01-{index + start:02d}",
|
||||
state=videos.SKIPPED_OLD,
|
||||
))
|
||||
return made
|
||||
|
||||
|
||||
def test_a_channel_with_nothing_in_the_window_keeps_its_newest(conn, settings, channel):
|
||||
_old(conn, channel, 8)
|
||||
|
||||
promoted = discovery.top_up_to_min_keep(conn, settings, channel)
|
||||
|
||||
assert promoted == 5
|
||||
listed = conn.execute(
|
||||
"SELECT video_id FROM video WHERE state = ? ORDER BY upload_date DESC",
|
||||
(videos.LISTED,),
|
||||
).fetchall()
|
||||
# The five NEWEST, not the first five found.
|
||||
assert [row["video_id"] for row in listed] == [
|
||||
"old00000008", "old00000007", "old00000006", "old00000005", "old00000004",
|
||||
]
|
||||
|
||||
|
||||
def test_the_floor_counts_what_is_already_there(conn, settings, channel):
|
||||
"""A channel with 3 in-window videos needs only 2 older ones."""
|
||||
for index in range(3):
|
||||
add_video(conn, channel["id"], f"new{index:08d}", upload_date="2026-08-10")
|
||||
_old(conn, channel, 6)
|
||||
|
||||
assert discovery.top_up_to_min_keep(conn, settings, channel) == 2
|
||||
|
||||
|
||||
def test_a_busy_channel_is_untouched(conn, settings, channel):
|
||||
for index in range(9):
|
||||
add_video(conn, channel["id"], f"new{index:08d}", upload_date="2026-08-10")
|
||||
_old(conn, channel, 4)
|
||||
|
||||
assert discovery.top_up_to_min_keep(conn, settings, channel) == 0
|
||||
assert conn.execute(
|
||||
"SELECT count(*) FROM video WHERE state = ?", (videos.SKIPPED_OLD,)
|
||||
).fetchone()[0] == 4
|
||||
|
||||
|
||||
def test_topping_up_is_idempotent(conn, settings, channel):
|
||||
"""It runs every poll, every hour. Twice must not mean ten episodes."""
|
||||
_old(conn, channel, 8)
|
||||
|
||||
first = discovery.top_up_to_min_keep(conn, settings, channel)
|
||||
second = discovery.top_up_to_min_keep(conn, settings, channel)
|
||||
|
||||
assert (first, second) == (5, 0)
|
||||
assert conn.execute(
|
||||
"SELECT count(*) FROM video WHERE state = ?", (videos.LISTED,)
|
||||
).fetchone()[0] == 5
|
||||
|
||||
|
||||
def test_aged_out_videos_are_never_revived(conn, settings, channel):
|
||||
"""They were on disk and were deleted. Reviving them presents months of old
|
||||
episodes to Jellyfin as new, which is what the tombstone exists to stop."""
|
||||
for index in range(6):
|
||||
add_video(conn, channel["id"], f"gone{index:08d}",
|
||||
upload_date=f"2026-02-{index + 1:02d}", state=videos.AGED_OUT)
|
||||
|
||||
assert discovery.top_up_to_min_keep(conn, settings, channel) == 0
|
||||
assert conn.execute(
|
||||
"SELECT count(*) FROM video WHERE state = ?", (videos.AGED_OUT,)
|
||||
).fetchone()[0] == 6
|
||||
|
||||
|
||||
def test_shorts_and_livestreams_do_not_count_towards_the_floor(conn, settings, channel):
|
||||
"""They are excluded by policy and can never be episodes, so a channel whose
|
||||
newest uploads are all Shorts must reach further back for long-form ones."""
|
||||
for index in range(4):
|
||||
add_video(conn, channel["id"], f"shrt{index:08d}",
|
||||
upload_date="2026-08-11", state=videos.SKIPPED_SHORT)
|
||||
add_video(conn, channel["id"], "live0000001",
|
||||
upload_date="2026-08-11", state=videos.SKIPPED_LIVE)
|
||||
_old(conn, channel, 7)
|
||||
|
||||
assert discovery.top_up_to_min_keep(conn, settings, channel) == 5
|
||||
|
||||
|
||||
def test_a_floor_of_zero_disables_it(conn, settings, channel):
|
||||
settings.set("min_keep_videos", "0")
|
||||
_old(conn, channel, 6)
|
||||
|
||||
assert discovery.top_up_to_min_keep(conn, settings, channel) == 0
|
||||
|
||||
|
||||
def test_a_channel_with_no_videos_at_all_stays_empty(conn, settings, channel):
|
||||
"""Two of the real 119 have no long-form uploads whatsoever (their UULF
|
||||
playlist 404s). There is nothing to promote and no directory should appear."""
|
||||
assert discovery.top_up_to_min_keep(conn, settings, channel) == 0
|
||||
|
||||
|
||||
def test_promoted_videos_survive_the_next_reap(conn, settings, channel):
|
||||
"""The whole point: the two halves of max(window, N newest) must agree. If
|
||||
reap deleted what discovery just promoted, channels would flicker hourly."""
|
||||
from ytstream import reap
|
||||
|
||||
_old(conn, channel, 5)
|
||||
discovery.top_up_to_min_keep(conn, settings, channel)
|
||||
for row in conn.execute(
|
||||
"SELECT video_id FROM video WHERE state = ?", (videos.LISTED,)
|
||||
).fetchall():
|
||||
videos.mark_materialised(
|
||||
conn, row["video_id"], rel_path=f"c/{row['video_id']}.strm",
|
||||
season=2026, episode=1, upload_date="2026-01-01", duration=900,
|
||||
title="t",
|
||||
)
|
||||
|
||||
assert reap.candidates(conn, settings) == []
|
||||
|
||||
+27
-4
@@ -341,14 +341,37 @@ def test_growing_flag_means_no_wait_at_all(tmp_path):
|
||||
server.close()
|
||||
|
||||
|
||||
def test_status_reports_the_serving_mode_and_grace(manager_factory):
|
||||
"""/healthz has to show this: it is the difference between 'plays' and
|
||||
'playback error', and it is otherwise invisible without the unit file."""
|
||||
def test_the_default_is_to_wait_for_a_complete_file(manager_factory):
|
||||
"""The default must not serve a partial file. Jellyfin responds to one by
|
||||
dragging a gigabyte through the proxy to probe a fragmented MP4 that has no
|
||||
duration in its header, then transcoding a stream it cannot seek: measured
|
||||
2026-08-13, 3 minutes to start, 6 seconds of video, permanent stall."""
|
||||
assert proxy.FIRST_BYTE_GRACE == float("inf")
|
||||
|
||||
status = manager_factory().status()
|
||||
assert status["mode"] == "wait-for-complete"
|
||||
assert status["first_byte_grace_s"] is None
|
||||
|
||||
|
||||
def test_status_is_valid_json_with_no_grace_cap(manager_factory):
|
||||
"""json.dumps emits `Infinity` for an infinite float, which is not JSON and
|
||||
breaks any client that parses /healthz strictly."""
|
||||
payload = json.dumps(manager_factory().status())
|
||||
assert "Infinity" not in payload
|
||||
assert json.loads(payload)["first_byte_grace_s"] is None
|
||||
|
||||
|
||||
def test_status_reports_a_finite_grace_when_one_is_set(manager_factory):
|
||||
manager = manager_factory()
|
||||
manager.strict = False
|
||||
manager.first_byte_grace = 12.0
|
||||
|
||||
status = manager.status()
|
||||
assert status["mode"] == "wait-then-stream"
|
||||
assert status["first_byte_grace_s"] == proxy.FIRST_BYTE_GRACE
|
||||
assert status["first_byte_grace_s"] == 12.0
|
||||
|
||||
|
||||
def test_status_reports_growing_mode(manager_factory):
|
||||
growing = manager_factory(growing=True)
|
||||
assert growing.status()["mode"] == "growing"
|
||||
assert growing.status()["first_byte_grace_s"] == 0.0
|
||||
|
||||
@@ -230,3 +230,37 @@ def test_summarise_is_a_single_line():
|
||||
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()
|
||||
|
||||
@@ -310,3 +310,55 @@ def test_a_renamed_episode_removes_its_old_files(conn, settings, media_root,
|
||||
assert not old.exists()
|
||||
assert (media_root / second["rel_path"]).exists()
|
||||
assert len(list(media_root.rglob("*.strm"))) == 1
|
||||
|
||||
|
||||
# ------------------------------------- pruning channels with nothing to show
|
||||
#
|
||||
# §5: a channel with nothing inside the window must not leave an empty series in
|
||||
# Jellyfin. runner creates directories lazily to honour that, but
|
||||
# channels.subscribe() mkdirs to write tvshow.nfo and a poster, so every
|
||||
# subscription got one regardless. Measured after approving all 119 real
|
||||
# subscriptions: 57 empty series.
|
||||
|
||||
|
||||
def test_prune_removes_a_channel_with_no_episodes(conn, media_root, channel):
|
||||
tree = media_root / "clabretro"
|
||||
tree.mkdir()
|
||||
(tree / "tvshow.nfo").write_text("<tvshow/>")
|
||||
(tree / "poster.jpg").write_bytes(b"x" * 100)
|
||||
|
||||
assert strm.prune_if_no_episodes(channel) is True
|
||||
assert not tree.exists()
|
||||
|
||||
|
||||
def test_prune_leaves_a_channel_that_has_episodes(conn, settings, media_root,
|
||||
channel, video, no_thumbs):
|
||||
strm.materialise(conn, settings, channel, video)
|
||||
|
||||
assert strm.prune_if_no_episodes(channel) is False
|
||||
assert (media_root / "clabretro").exists()
|
||||
|
||||
|
||||
def test_prune_finds_episodes_in_any_season(conn, media_root, channel):
|
||||
"""The .strm lives a directory down, so a shallow check would delete it."""
|
||||
deep = media_root / "clabretro" / "Season 2019"
|
||||
deep.mkdir(parents=True)
|
||||
(deep / "clabretro - S2019E1010 - Old [dQw4w9WgXcQ].strm").write_text("url")
|
||||
|
||||
assert strm.prune_if_no_episodes(channel) is False
|
||||
assert deep.exists()
|
||||
|
||||
|
||||
def test_prune_is_a_no_op_when_there_is_no_directory(conn, channel):
|
||||
assert strm.prune_if_no_episodes(channel) is False
|
||||
|
||||
|
||||
def test_prune_refuses_a_blank_channel_directory(conn, media_root):
|
||||
from conftest import add_channel
|
||||
|
||||
row = add_channel(conn, "UC" + "r" * 22, "Blank", "")
|
||||
keep = media_root / "keep"
|
||||
keep.mkdir()
|
||||
|
||||
assert strm.prune_if_no_episodes(row) is False
|
||||
assert keep.exists()
|
||||
|
||||
Reference in New Issue
Block a user