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>
365 lines
13 KiB
Python
365 lines
13 KiB
Python
"""Materialising: .strm contents, NFO sidecars, naming, and idempotency."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
import pytest
|
|
|
|
from ytstream import config, strm, videos
|
|
|
|
from conftest import add_video
|
|
|
|
|
|
@pytest.fixture()
|
|
def video(conn, channel):
|
|
return add_video(conn, channel["id"], "dQw4w9WgXcQ",
|
|
title="A Video: With/Punctuation",
|
|
upload_date="2026-08-12", duration=1337)
|
|
|
|
|
|
@pytest.fixture()
|
|
def no_thumbs(monkeypatch):
|
|
"""Thumbnails are a network fetch; every test here runs without one."""
|
|
monkeypatch.setattr(strm, "fetch_thumbnail", lambda *a, **k: False)
|
|
|
|
|
|
def test_strm_contains_only_the_proxy_url(conn, settings, media_root, channel,
|
|
video, no_thumbs):
|
|
result = strm.materialise(conn, settings, channel, video)
|
|
|
|
path = media_root / result["rel_path"]
|
|
assert path.read_text() == "http://127.0.0.1:8099/watch/dQw4w9WgXcQ"
|
|
# No trailing newline, and nothing else in the file.
|
|
assert path.read_bytes() == b"http://127.0.0.1:8099/watch/dQw4w9WgXcQ"
|
|
|
|
|
|
def test_strm_url_follows_the_configured_base(conn, settings, media_root, channel,
|
|
video, no_thumbs):
|
|
settings.set("proxy_base_url", "http://127.0.0.1:9999/")
|
|
result = strm.materialise(conn, settings, channel, video)
|
|
assert (media_root / result["rel_path"]).read_text().startswith(
|
|
"http://127.0.0.1:9999/watch/"
|
|
)
|
|
|
|
|
|
def test_layout_matches_the_naming_scheme(conn, settings, media_root, channel,
|
|
video, no_thumbs):
|
|
result = strm.materialise(conn, settings, channel, video)
|
|
|
|
# Season = upload year, episode = MMDD*10 + ordinal.
|
|
assert result["season"] == 2026
|
|
assert result["episode"] == 8120
|
|
assert result["rel_path"] == (
|
|
"clabretro/Season 2026/"
|
|
"clabretro - S2026E8120 - A Video With Punctuation [dQw4w9WgXcQ].strm"
|
|
)
|
|
|
|
|
|
def test_nfo_is_written_alongside(conn, settings, media_root, channel, video,
|
|
no_thumbs):
|
|
result = strm.materialise(conn, settings, channel, video)
|
|
nfo_path = (media_root / result["rel_path"]).with_suffix(".nfo")
|
|
|
|
text = nfo_path.read_text()
|
|
|
|
assert "<season>2026</season>" in text
|
|
assert "<episode>8120</episode>" in text
|
|
assert "<aired>2026-08-12</aired>" in text
|
|
# durationinseconds is what stops a .strm episode showing a zero runtime
|
|
# before it has ever been played.
|
|
assert "<durationinseconds>1337</durationinseconds>" in text
|
|
assert 'type="youtube"' in text
|
|
|
|
|
|
def test_nfo_has_no_streamdetails(conn, settings, media_root, channel, video,
|
|
no_thumbs):
|
|
"""Pre-seeding them was measured to change nothing about Jellyfin probing."""
|
|
result = strm.materialise(conn, settings, channel, video)
|
|
text = (media_root / result["rel_path"]).with_suffix(".nfo").read_text()
|
|
assert "streamdetails" not in text
|
|
assert "fileinfo" not in text
|
|
|
|
|
|
def test_materialise_is_idempotent(conn, settings, media_root, channel, video,
|
|
no_thumbs):
|
|
first = strm.materialise(conn, settings, channel, video)
|
|
before = (media_root / first["rel_path"]).read_bytes()
|
|
|
|
second = strm.materialise(
|
|
conn, settings, channel, videos.get(conn, "dQw4w9WgXcQ")
|
|
)
|
|
|
|
assert second["rel_path"] == first["rel_path"]
|
|
assert (media_root / second["rel_path"]).read_bytes() == before
|
|
|
|
|
|
def test_row_is_marked_materialised(conn, settings, media_root, channel, video,
|
|
no_thumbs):
|
|
strm.materialise(conn, settings, channel, video)
|
|
row = videos.get(conn, "dQw4w9WgXcQ")
|
|
assert row["state"] == videos.MATERIALISED
|
|
assert row["rel_path"]
|
|
assert row["materialised_at"]
|
|
|
|
|
|
def test_episode_ordinals_increment_within_a_day(conn, settings, media_root,
|
|
channel, no_thumbs):
|
|
for index in range(3):
|
|
row = add_video(conn, channel["id"], f"vid{index:08d}",
|
|
upload_date="2026-08-12")
|
|
result = strm.materialise(conn, settings, channel, row)
|
|
assert result["episode"] == 8120 + index
|
|
|
|
|
|
def test_ordinals_are_stable_when_an_earlier_video_ages_out(
|
|
conn, settings, media_root, channel, no_thumbs
|
|
):
|
|
"""Aged-out rows keep their episode number, so later ordinals never shift."""
|
|
first = add_video(conn, channel["id"], "vid00000001", upload_date="2026-08-12")
|
|
strm.materialise(conn, settings, channel, first)
|
|
videos.mark_aged_out(conn, "vid00000001")
|
|
|
|
second = add_video(conn, channel["id"], "vid00000002", upload_date="2026-08-12")
|
|
result = strm.materialise(conn, settings, channel, second)
|
|
|
|
assert result["episode"] == 8121
|
|
|
|
|
|
def test_title_falls_back_to_the_video_id(conn, settings, media_root, channel,
|
|
no_thumbs):
|
|
"""Backfilled rows carry no title until the feed supplies one."""
|
|
row = add_video(conn, channel["id"], "vid00000001", title="",
|
|
upload_date="2026-08-12")
|
|
result = strm.materialise(conn, settings, channel, row)
|
|
assert "vid00000001" in result["rel_path"]
|
|
|
|
|
|
def test_write_show_creates_tvshow_nfo(media_root, channel):
|
|
strm.write_show(channel)
|
|
text = (media_root / "clabretro" / "tvshow.nfo").read_text()
|
|
assert "<title>clabretro</title>" in text
|
|
assert channel["channel_id"] in text
|
|
|
|
|
|
def test_show_nfo_uses_the_channel_title_not_a_video_title(conn, media_root, channel):
|
|
"""The channel/video join has `title` on both sides, and reading the wrong one
|
|
renamed every series after whichever episode happened to be first."""
|
|
add_video(conn, channel["id"], "vid00000001", title="Some Episode Title")
|
|
strm.write_show(channel)
|
|
text = (media_root / "clabretro" / "tvshow.nfo").read_text()
|
|
assert "Some Episode Title" not in text
|
|
|
|
|
|
def test_remove_deletes_strm_and_sidecars(conn, settings, media_root, channel,
|
|
video, no_thumbs):
|
|
result = strm.materialise(conn, settings, channel, video)
|
|
strm_path = media_root / result["rel_path"]
|
|
stem = strm_path.name[: -len(".strm")]
|
|
thumb = strm_path.with_name(stem + "-thumb.jpg")
|
|
thumb.write_bytes(b"x" * 2000)
|
|
|
|
removed = strm.remove(videos.get(conn, "dQw4w9WgXcQ"))
|
|
|
|
assert removed == 3 # .strm, .nfo, -thumb.jpg
|
|
assert not strm_path.exists()
|
|
assert not thumb.exists()
|
|
|
|
|
|
def test_remove_leaves_files_it_does_not_own(conn, settings, media_root, channel,
|
|
video, no_thumbs):
|
|
result = strm.materialise(conn, settings, channel, video)
|
|
season_dir = (media_root / result["rel_path"]).parent
|
|
stranger = season_dir / "someone-elses-file.txt"
|
|
stranger.write_text("not ours")
|
|
|
|
strm.remove(videos.get(conn, "dQw4w9WgXcQ"))
|
|
|
|
assert stranger.exists()
|
|
|
|
|
|
def test_remove_channel_tree(conn, media_root, channel):
|
|
tree = media_root / "clabretro"
|
|
(tree / "Season 2026").mkdir(parents=True)
|
|
(tree / "tvshow.nfo").write_text("<tvshow/>")
|
|
|
|
assert strm.remove_channel_tree(channel) is True
|
|
assert not tree.exists()
|
|
|
|
|
|
def test_remove_channel_tree_refuses_the_media_root(conn, media_root):
|
|
from conftest import add_channel
|
|
|
|
row = add_channel(conn, "UC" + "q" * 22, "Blank", "")
|
|
keep = media_root / "keep"
|
|
keep.mkdir()
|
|
|
|
assert strm.remove_channel_tree(row) is False
|
|
assert keep.exists()
|
|
|
|
|
|
def test_fetch_thumbnail_rejects_the_grey_placeholder(monkeypatch, tmp_path):
|
|
"""YouTube serves a tiny placeholder rather than a 404 for missing maxres."""
|
|
import urllib.request
|
|
|
|
class Response:
|
|
status = 200
|
|
|
|
def __init__(self, payload):
|
|
self.payload = payload
|
|
|
|
def read(self):
|
|
return self.payload
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
calls = []
|
|
|
|
def fake_open(request, timeout=None):
|
|
calls.append(request.full_url)
|
|
# maxres returns the placeholder; hq returns something real.
|
|
return Response(b"x" * 120 if "maxres" in request.full_url else b"y" * 5000)
|
|
|
|
monkeypatch.setattr(urllib.request, "urlopen", fake_open)
|
|
destination = tmp_path / "out-thumb.jpg"
|
|
|
|
assert strm.fetch_thumbnail("dQw4w9WgXcQ", destination) is True
|
|
assert destination.read_bytes() == b"y" * 5000
|
|
assert len(calls) == 2
|
|
|
|
|
|
def test_fetch_thumbnail_is_skipped_when_one_already_exists(monkeypatch, tmp_path):
|
|
import urllib.request
|
|
|
|
monkeypatch.setattr(urllib.request, "urlopen",
|
|
lambda *a, **k: pytest.fail("should not fetch"))
|
|
existing = tmp_path / "out-thumb.jpg"
|
|
existing.write_bytes(b"cached")
|
|
|
|
assert strm.fetch_thumbnail("dQw4w9WgXcQ", existing) is True
|
|
|
|
|
|
def test_untitled_video_does_not_get_its_id_written_back_as_a_title(
|
|
conn, settings, media_root, channel, no_thumbs
|
|
):
|
|
"""Writing the fallback back to the database makes the row look titled, which
|
|
permanently disables the title repair in discovery._record. That shipped once
|
|
and left five of twenty episodes named after their video ids."""
|
|
row = add_video(conn, channel["id"], "vid00000001", title="",
|
|
upload_date="2026-08-12")
|
|
|
|
result = strm.materialise(conn, settings, channel, row)
|
|
|
|
# The filename falls back to the id...
|
|
assert "vid00000001]" in result["rel_path"]
|
|
# ...but the row stays untitled, so a later feed poll can still repair it.
|
|
assert videos.get(conn, "vid00000001")["title"] == ""
|
|
|
|
|
|
def test_episode_number_is_stable_across_rematerialising(
|
|
conn, settings, media_root, channel, no_thumbs
|
|
):
|
|
"""`materialise --all` is the documented recovery from a Jellyfin metadata
|
|
wipe. If it renumbered episodes, the recovery would create a second copy of
|
|
every episode instead of repairing the first."""
|
|
ids = []
|
|
for index in range(3):
|
|
row = add_video(conn, channel["id"], f"vid{index:08d}",
|
|
upload_date="2026-08-12")
|
|
ids.append(strm.materialise(conn, settings, channel, row)["episode"])
|
|
|
|
again = [strm.materialise(conn, settings, channel, videos.get(conn, f"vid{i:08d}"))
|
|
["episode"] for i in range(3)]
|
|
|
|
assert again == ids == [8120, 8121, 8122]
|
|
|
|
|
|
def test_rematerialising_leaves_no_orphans(conn, settings, media_root, channel,
|
|
no_thumbs):
|
|
for index in range(3):
|
|
add_video(conn, channel["id"], f"vid{index:08d}", upload_date="2026-08-12")
|
|
for index in range(3):
|
|
strm.materialise(conn, settings, channel, videos.get(conn, f"vid{index:08d}"))
|
|
for index in range(3):
|
|
strm.materialise(conn, settings, channel, videos.get(conn, f"vid{index:08d}"))
|
|
|
|
assert len(list(media_root.rglob("*.strm"))) == 3
|
|
assert len([p for p in media_root.rglob("*.nfo") if p.name != "tvshow.nfo"]) == 3
|
|
|
|
|
|
def test_a_renamed_episode_removes_its_old_files(conn, settings, media_root,
|
|
channel, no_thumbs):
|
|
"""The late-title path renames the file; the old one must not survive."""
|
|
row = add_video(conn, channel["id"], "vid00000001", title="",
|
|
upload_date="2026-08-12")
|
|
first = strm.materialise(conn, settings, channel, row)
|
|
old = media_root / first["rel_path"]
|
|
assert old.exists()
|
|
|
|
with conn:
|
|
conn.execute("UPDATE video SET title = ? WHERE video_id = ?",
|
|
("Proper Title", "vid00000001"))
|
|
second = strm.materialise(conn, settings, channel,
|
|
videos.get(conn, "vid00000001"))
|
|
|
|
assert second["rel_path"] != first["rel_path"]
|
|
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()
|