Deploy it, and fix the six things installation found
Both units are installed and running, 10 of 119 channels approved, 251 episodes live in Jellyfin with verified DirectPlay. 345 tests. Six problems surfaced that no test could have, and two of them were mine in the deploy scripts. deploy.sh had a circular dependency with bootstrap.sh: deploy started the units and told the operator to run bootstrap, but bootstrap refused to run until the state directory existed, which only deploy creates. The units started against a non-existent venv, failed 203/EXEC and restart-looped 17 and 21 times. deploy.sh now creates the directory, calls bootstrap itself through runuser so the venv is not left root-owned, and refuses to start units when the venv is still missing. Deno was absent, and `doctor` is the only reason we know. It is mandatory rather than nice-to-have — without a JS runtime yt-dlp cannot solve the n challenge, which youtube-automate measured on this machine as 22 formats instead of 29 plus throttling. Nothing else would have complained; playback would just have quietly degraded. bootstrap.sh now installs it and asserts yt-dlp reports it. Episodes had no synopsis at all, because materialise passed plot=None while both sources hand us descriptions for free. Now plumbed through from RSS (media:group/media:description) and from videos.list, which carries snippet.description in the call already being made for durations — so the ~40% of episodes older than RSS reaches get one too. That needed a schema v2 migration; v1 was left exactly as shipped so a fresh install and a migrated one are identical, and a test asserts it. `materialise --all` — the documented recovery from a Jellyfin metadata wipe — was itself creating duplicates. Episode numbers were re-derived each run, and next_episode() excludes the row being numbered, so re-materialising a day's videos in a different order renumbered them and orphaned the old files. One run left 102 orphaned NFOs against 251 episodes. An episode number is now permanent once assigned, and a video whose rel_path changes has its old files removed first. Running it twice is now a no-op. Two Jellyfin behaviours worth having in writing. It ignores <runtime> and <durationinseconds> for episodes while reading the rest of the NFO happily, so a .strm shows no duration until first played — not fixable without probing, which is the one thing this design exists to avoid. And a plain /Library/Refresh does not reliably re-read a rewritten NFO: after rewriting all 251, fifty kept their old empty metadata. The fix is metadataRefreshMode=Default with replaceAllMetadata=false, which took plots from 201 to 251 while the proxy served zero requests. §5's prohibition on replaceAllMetadata=true still stands — that one probes. Exposed as `ytstream refresh-metadata` and run automatically after `materialise --all`. The measurement §5 has been waiting for: a full Jellyfin scan of 251 .strm files took ~119 s, about 8 minutes per 1,000 episodes, and made zero media probes. That last number is the fact the whole design rests on, now confirmed at scale on the real library rather than on seven PoC files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
61cc1672ec
commit
d3bf8d6f19
+5
-1
@@ -181,12 +181,16 @@ class FakeApi:
|
||||
if limit is not None and produced >= limit:
|
||||
return
|
||||
|
||||
def durations(self, video_ids):
|
||||
def details(self, video_ids):
|
||||
self.duration_calls += 1
|
||||
self.calls += 1
|
||||
return {vid: self._durations[vid] for vid in video_ids
|
||||
if vid in self._durations}
|
||||
|
||||
# `durations` was the old name; kept so a stale caller fails loudly in tests
|
||||
# rather than silently skipping enrichment.
|
||||
durations = details
|
||||
|
||||
def channel(self, channel_id):
|
||||
self.calls += 1
|
||||
return self._channel
|
||||
|
||||
@@ -389,3 +389,27 @@ def test_a_late_title_renames_an_already_materialised_episode(
|
||||
second = strm.materialise(conn, settings, channel,
|
||||
videos.get(conn, "vid00000001"))
|
||||
assert "Proper Name" in second["rel_path"]
|
||||
|
||||
|
||||
def test_reclassifying_a_materialised_video_removes_its_files(
|
||||
conn, settings, media_root, channel, monkeypatch
|
||||
):
|
||||
"""A premiere that becomes a livestream, or a duration that only resolves on a
|
||||
later run, would otherwise leave files on disk with no row owning them."""
|
||||
from ytstream import strm
|
||||
|
||||
monkeypatch.setattr(strm, "fetch_thumbnail", lambda *a, **k: False)
|
||||
add_video(conn, channel["id"], "vid00000001", duration=None)
|
||||
result = strm.materialise(conn, settings, channel,
|
||||
videos.get(conn, "vid00000001"))
|
||||
path = media_root / result["rel_path"]
|
||||
assert path.exists()
|
||||
|
||||
patch_api(monkeypatch, discovery,
|
||||
FakeApi(durations={"vid00000001": {"duration": 30, "is_live": False,
|
||||
"title": "", "description": ""}}))
|
||||
stats = discovery.enrich_durations(conn, settings, ["vid00000001"])
|
||||
|
||||
assert stats["shorts"] == 1
|
||||
assert videos.get(conn, "vid00000001")["state"] == videos.SKIPPED_SHORT
|
||||
assert not path.exists()
|
||||
|
||||
@@ -258,3 +258,55 @@ def test_untitled_video_does_not_get_its_id_written_back_as_a_title(
|
||||
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
|
||||
|
||||
@@ -207,3 +207,75 @@ def test_deleting_a_channel_cascades_to_its_videos(conn, channel):
|
||||
with conn:
|
||||
conn.execute("DELETE FROM channel WHERE id = ?", (channel["id"],))
|
||||
assert conn.execute("SELECT COUNT(*) FROM video").fetchone()[0] == 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------- migrations
|
||||
|
||||
|
||||
def test_a_v1_database_migrates_to_v2(tmp_path):
|
||||
"""The live database on susan was created at v1. A fresh install must end up
|
||||
identical to a migrated one, which is why the description column is a v2
|
||||
migration rather than an edit to the v1 script."""
|
||||
import sqlite3 as sq
|
||||
|
||||
from ytstream import db
|
||||
|
||||
path = tmp_path / "v1.db"
|
||||
raw = sq.connect(path)
|
||||
raw.executescript(db._SCHEMA_V1)
|
||||
raw.execute("PRAGMA user_version = 1")
|
||||
raw.commit()
|
||||
raw.close()
|
||||
|
||||
conn = db.connect(path)
|
||||
try:
|
||||
assert conn.execute("PRAGMA user_version").fetchone()[0] == db.SCHEMA_VERSION
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(video)")}
|
||||
assert "description" in columns
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migration_is_idempotent(tmp_path):
|
||||
from ytstream import db
|
||||
|
||||
path = tmp_path / "twice.db"
|
||||
for _ in range(3):
|
||||
conn = db.connect(path)
|
||||
conn.close()
|
||||
conn = db.connect(path)
|
||||
try:
|
||||
columns = [row[1] for row in conn.execute("PRAGMA table_info(video)")]
|
||||
assert columns.count("description") == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_fresh_and_migrated_schemas_match(tmp_path):
|
||||
"""A fresh v2 install and a v1 database brought forward must agree, or the two
|
||||
populations diverge silently."""
|
||||
import sqlite3 as sq
|
||||
|
||||
from ytstream import db
|
||||
|
||||
fresh = db.connect(tmp_path / "fresh.db")
|
||||
fresh_cols = [tuple(row)[1:3] for row in fresh.execute("PRAGMA table_info(video)")]
|
||||
fresh.close()
|
||||
|
||||
old = tmp_path / "old.db"
|
||||
raw = sq.connect(old)
|
||||
raw.executescript(db._SCHEMA_V1)
|
||||
raw.execute("PRAGMA user_version = 1")
|
||||
raw.commit()
|
||||
raw.close()
|
||||
migrated = db.connect(old)
|
||||
migrated_cols = [tuple(row)[1:3]
|
||||
for row in migrated.execute("PRAGMA table_info(video)")]
|
||||
migrated.close()
|
||||
|
||||
assert fresh_cols == migrated_cols
|
||||
|
||||
|
||||
def test_description_survives_a_round_trip(conn, channel):
|
||||
add_video(conn, channel["id"], "vid00000001", description="A synopsis")
|
||||
assert videos.get(conn, "vid00000001")["description"] == "A synopsis"
|
||||
|
||||
Reference in New Issue
Block a user