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:
Tom Flux
2026-08-12 17:21:35 +01:00
co-authored by Claude Opus 5
parent 61cc1672ec
commit d3bf8d6f19
14 changed files with 490 additions and 38 deletions
+46 -4
View File
@@ -1,5 +1,9 @@
#!/bin/bash #!/bin/bash
# Everything that does NOT need root. Run as susan, before deploy.sh. # Everything that does NOT need root: the venv, yt-dlp, the POT container, the
# database.
#
# Normally invoked for you by deploy.sh, which creates the state directory first.
# Safe to run directly as susan afterwards — for instance to rebuild the venv:
# #
# /opt/ytstream/deploy/bootstrap.sh # /opt/ytstream/deploy/bootstrap.sh
set -euo pipefail set -euo pipefail
@@ -11,8 +15,12 @@ REPO=/opt/ytstream
say() { printf '\n\033[1m==> %s\033[0m\n' "$1"; } say() { printf '\n\033[1m==> %s\033[0m\n' "$1"; }
if [[ ! -d $STATE ]]; then if [[ ! -d $STATE ]]; then
echo "$STATE does not exist yet — run 'sudo $REPO/deploy/deploy.sh' first," >&2 echo "$STATE does not exist. Run 'sudo $REPO/deploy/deploy.sh' — it creates the" >&2
echo "or create it with: sudo install -d -o susan -g automation -m 0770 $STATE" >&2 echo "directory and then calls this script." >&2
exit 1
fi
if [[ ! -w $STATE ]]; then
echo "$STATE is not writable by $(id -un). Expected mode 0770 susan:automation." >&2
exit 1 exit 1
fi fi
@@ -32,6 +40,40 @@ print("POT plugin:", "MISSING" if missing else "present")
raise SystemExit(1 if missing else 0) raise SystemExit(1 if missing else 0)
PY PY
say "Installing the Deno JS runtime"
# MANDATORY, not optional. Without a JS runtime yt-dlp cannot solve the `n`
# challenge, and youtube-automate measured the consequence on this machine: 22
# formats instead of 29, and throttled downloads. yt-dlp finds it by PATH, and the
# unit pins PATH to this venv's bin, so it has to live here rather than anywhere
# more natural.
if [[ -x $VENV/bin/deno ]]; then
echo " already present: $("$VENV/bin/deno" --version | head -1)"
elif [[ -x /var/lib/youtube-automate/venv/bin/deno ]]; then
# Same machine, same architecture, already verified working. Prefer it over a
# download while youtube-automate is still installed.
install -m 0755 /var/lib/youtube-automate/venv/bin/deno "$VENV/bin/deno"
echo " copied from the youtube-automate venv: $("$VENV/bin/deno" --version | head -1)"
else
tmp=$(mktemp -d)
curl -fsSL -o "$tmp/deno.zip" \
https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip
unzip -q "$tmp/deno.zip" -d "$tmp"
install -m 0755 "$tmp/deno" "$VENV/bin/deno"
rm -rf "$tmp"
echo " installed $("$VENV/bin/deno" --version | head -1)"
fi
say "Confirming yt-dlp can solve JS challenges"
# All three lines must appear, per youtube-automate specs.md §3.
if "$VENV/bin/yt-dlp" -v --simulate --no-warnings \
"https://www.youtube.com/watch?v=dQw4w9WgXcQ" 2>&1 \
| grep -q "JS runtimes: deno"; then
echo " yt-dlp reports the deno runtime"
else
echo " WARNING: yt-dlp did not report a deno JS runtime — playback may be" >&2
echo " throttled or missing formats. Check: ytstream doctor" >&2
fi
say "Starting the POT provider container if it is not already up" say "Starting the POT provider container if it is not already up"
if ! curl -sf --max-time 5 http://127.0.0.1:4416/ping >/dev/null 2>&1; then if ! curl -sf --max-time 5 http://127.0.0.1:4416/ping >/dev/null 2>&1; then
docker run -d --name bgutil-provider --restart unless-stopped \ docker run -d --name bgutil-provider --restart unless-stopped \
@@ -43,4 +85,4 @@ fi
say "Initialising the database" say "Initialising the database"
"$VENV/bin/ytstream" status || true "$VENV/bin/ytstream" status || true
say "Done — now run 'sudo $REPO/deploy/deploy.sh'" say "Virtualenv ready at $VENV"
+27 -4
View File
@@ -6,14 +6,21 @@
# #
# sudo /opt/ytstream/deploy/deploy.sh # sudo /opt/ytstream/deploy/deploy.sh
# #
# Everything that does NOT need root — the venv, the database, the POT provider # This is the only command needed. It creates the state directory, builds the venv
# container, subscriptions, the API key — is handled by `bootstrap.sh` and the # by calling bootstrap.sh as the service user, and only then installs and starts
# application itself. Run bootstrap.sh (as susan) first. # the units.
#
# That ordering is not cosmetic. An earlier version installed the units first and
# told the operator to run bootstrap.sh separately — but bootstrap.sh needs the
# state directory, which only this script can create, so neither could go first.
# The units started, failed 203/EXEC against a venv that did not exist yet, and
# restart-looped until the venv appeared.
set -euo pipefail set -euo pipefail
REPO=/opt/ytstream REPO=/opt/ytstream
STATE=/var/lib/ytstream STATE=/var/lib/ytstream
VENV=$STATE/venv VENV=$STATE/venv
SERVICE_USER=susan
HOSTNAME_=tube.jihakuz.xyz HOSTNAME_=tube.jihakuz.xyz
if [[ $EUID -ne 0 ]]; then if [[ $EUID -ne 0 ]]; then
@@ -26,7 +33,23 @@ say() { printf '\n\033[1m==> %s\033[0m\n' "$1"; }
say "Creating $STATE" say "Creating $STATE"
# root-owned directory, group-writable by `automation` so susan's cron job and the # root-owned directory, group-writable by `automation` so susan's cron job and the
# admin server can both write the database. # admin server can both write the database.
install -d -o susan -g automation -m 0770 "$STATE" install -d -o "$SERVICE_USER" -g automation -m 0770 "$STATE"
say "Building the virtualenv"
if [[ -x $VENV/bin/ytstream ]]; then
echo " already present at $VENV"
else
# As the service user, so the venv is not left root-owned. Absolute path:
# runuser lives in /sbin, which is not always on the invoking PATH.
/sbin/runuser -u "$SERVICE_USER" -- bash "$REPO/deploy/bootstrap.sh"
fi
# Refuse to start units that cannot possibly work. Restart=always would otherwise
# turn a missing venv into a restart loop in the journal.
if [[ ! -x $VENV/bin/ytstream ]]; then
echo "The virtualenv was not built. Fix that, then re-run $0." >&2
exit 1
fi
say "Installing the /usr/local/bin shim" say "Installing the /usr/local/bin shim"
cat > /usr/local/bin/ytstream <<EOF cat > /usr/local/bin/ytstream <<EOF
+102 -8
View File
@@ -1,15 +1,17 @@
# `ytstream` — implementation plan # `ytstream` — implementation plan
**Target machine:** `susan` **Target machine:** `susan`
**Status:** **built.** Phases 04 of §13 are code-complete with 337 passing tests, verified against **Status:** **deployed and running.** Both systemd units are installed and active, 10 of the 119
the live YouTube Data API and the running proxy. What remains is installation, which needs root mirrored channels are approved, and 251 episodes are live in Jellyfin with correct metadata and
(`sudo deploy/deploy.sh`), and the cut-over in §12. The streaming PoC measurements this plan was verified DirectPlay. 345 tests pass. What remains is the cron entries, curating the rest of the
designed around are in **`FINDINGS.md`** alongside this file; what the build itself changed is in subscription list, and the cut-over in §12.
**§17**.
**Repo:** `/opt/ytstream`, pushed to `/disks/git-repos/ytstream.git`, branch `main`. The PoC code The streaming PoC measurements this plan was designed around are in **`FINDINGS.md`** alongside this
still lives in `/home/susan/ytstream` and is *not* under version control; Phase 2 moves it in and file. What the build changed is in **§17**; what deployment changed is in **§18**.
retires that directory.
**Repo:** `/opt/ytstream`, pushed to `/disks/git-repos/ytstream.git`, branch `main`. The PoC
scaffolding under `/home/susan/ytstream` is superseded; the proxy now lives in `proxy/` and the
PoC-era media tree was moved to `/disks/Plex/_cache/ytstream-poc-tree-backup`.
**Relationship to `youtube-automate`:** ytstream **replaces** it. The two are entirely separate **Relationship to `youtube-automate`:** ytstream **replaces** it. The two are entirely separate
trees, databases, services and Jellyfin libraries, and they will run side by side only for as long trees, databases, services and Jellyfin libraries, and they will run side by side only for as long
@@ -959,3 +961,95 @@ real name.
| Asianometry backfill | 6 episodes | | Asianometry backfill | 6 episodes |
| Generated `.strm` played through the proxy | h264 720p + aac, ranges honoured | | Generated `.strm` played through the proxy | h264 720p + aac, ranges honoured |
| NFO `durationinseconds` vs API truth | 889 vs 889 | | NFO `durationinseconds` vs API truth | 889 vs 889 |
---
## 18. What deployment changed — 2026-08-12
Installing it found six things the tests could not. Two were my bugs in the deploy
scripts, one was a missing dependency, and three were Jellyfin behaviours that only
appear against a real library.
### The deploy scripts had a circular dependency
`deploy.sh` installed and started the units, then told the operator to run
`bootstrap.sh` — but `bootstrap.sh` refused to run until `/var/lib/ytstream`
existed, and only `deploy.sh` creates it. Neither could go first. The units started
against a venv that did not exist, failed `203/EXEC`, and restart-looped 17 and 21
times until the venv appeared.
`deploy.sh` now creates the state directory, calls `bootstrap.sh` itself via
`runuser` so the venv is not left root-owned, and refuses to start the units at all
if the venv is still missing. One command, correct order.
### Deno was missing, and `doctor` caught it
**Mandatory, not optional.** Without a JS runtime yt-dlp cannot solve the `n`
challenge; youtube-automate measured the consequence on this machine as 22 formats
instead of 29 and throttled downloads. The new venv had `yt-dlp-ejs` but no `deno`,
and nothing else would have noticed until playback quietly degraded.
`bootstrap.sh` now installs it — preferring a copy from the youtube-automate venv
while that still exists, falling back to the GitHub release — and verifies yt-dlp
reports `JS runtimes: deno`.
### Jellyfin ignores `<runtime>` and `<durationinseconds>` for episodes
It reads the rest of the NFO — `aired` and the `youtube` provider id both arrive —
but runtime comes only from a media probe, so a `.strm` episode shows **no duration
until it has been played once**. Not fixable from our side: the only way to supply
one is to probe, which means fetching every episode, which is the one thing this
design exists to avoid. Accepted limitation, stated here so nobody re-litigates it.
### Episodes had no plot at all
`strm.materialise` passed `plot=None`, so every synopsis was empty — while both
sources hand us descriptions for free. Now plumbed through: RSS carries
`media:group/media:description`, and `videos.list` carries `snippet.description` in
the call already being made for durations, so the ~40% of episodes older than RSS
reaches still get one. That needed a **schema v2 migration**; v1 was left exactly as
it shipped so a fresh install and a migrated one end up identical, which is asserted
by a test.
### `materialise --all` created duplicates instead of repairing
The documented recovery path from a metadata wipe was itself broken. Episode numbers
were re-derived on every run, and `next_episode()` excludes the row it is numbering,
so re-materialising a day's videos in a different order renumbered them — new
filenames, old files left behind. One run left **102 orphaned NFOs against 251
episodes**.
Two fixes: an episode number, once assigned, is now permanent and reused from the
row; and materialising a video that already has a different `rel_path` removes the
old files first. Running `materialise --all` twice in a row is now a no-op, verified
on the live tree and pinned by tests.
### There is a safe metadata refresh, and this is it
§5 says never to use `replaceAllMetadata=true`, and that stands. But a plain
`/Library/Refresh` does **not** reliably re-read a rewritten NFO — after rewriting
all 251, fifty kept their old empty metadata. The middle ground works:
```
POST /Items/{id}/Refresh?metadataRefreshMode=Default&imageRefreshMode=Default
&replaceAllMetadata=false&recursive=true
```
Measured across the whole 251-episode library: **plots and aired dates went from 201
to 251, and the proxy served 0 requests.** The safety is entirely in
`replaceAllMetadata=false` — with it `true`, Jellyfin discards what it has and
re-derives from the media. Exposed as `ytstream refresh-metadata`, and run
automatically after `materialise --all`.
### Measured on the deployed service
| | |
|---|---|
| Channels approved (of 119 queued) | 10 |
| Episodes materialised | **251** in 70 s |
| Filtered as Shorts / livestreams | 2 / 4 |
| Tree size | 49 MB for 753 files |
| **Jellyfin full scan** | **251 episodes in ~119 s** (~8 min per 1,000) |
| **Media probes during that scan** | **0** |
| Playback via Jellyfin `PlaybackInfo` | DirectPlay h264 720p + aac, 2049 s runtime |
| Episodes with plot / aired after refresh | 251 / 251 |
| `doctor` | all fatal checks pass |
+5 -1
View File
@@ -181,12 +181,16 @@ class FakeApi:
if limit is not None and produced >= limit: if limit is not None and produced >= limit:
return return
def durations(self, video_ids): def details(self, video_ids):
self.duration_calls += 1 self.duration_calls += 1
self.calls += 1 self.calls += 1
return {vid: self._durations[vid] for vid in video_ids return {vid: self._durations[vid] for vid in video_ids
if vid in self._durations} 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): def channel(self, channel_id):
self.calls += 1 self.calls += 1
return self._channel return self._channel
+24
View File
@@ -389,3 +389,27 @@ def test_a_late_title_renames_an_already_materialised_episode(
second = strm.materialise(conn, settings, channel, second = strm.materialise(conn, settings, channel,
videos.get(conn, "vid00000001")) videos.get(conn, "vid00000001"))
assert "Proper Name" in second["rel_path"] 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()
+52
View File
@@ -258,3 +258,55 @@ def test_untitled_video_does_not_get_its_id_written_back_as_a_title(
assert "vid00000001]" in result["rel_path"] assert "vid00000001]" in result["rel_path"]
# ...but the row stays untitled, so a later feed poll can still repair it. # ...but the row stays untitled, so a later feed poll can still repair it.
assert videos.get(conn, "vid00000001")["title"] == "" 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
+72
View File
@@ -207,3 +207,75 @@ def test_deleting_a_channel_cascades_to_its_videos(conn, channel):
with conn: with conn:
conn.execute("DELETE FROM channel WHERE id = ?", (channel["id"],)) conn.execute("DELETE FROM channel WHERE id = ?", (channel["id"],))
assert conn.execute("SELECT COUNT(*) FROM video").fetchone()[0] == 0 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"
+19 -7
View File
@@ -295,7 +295,8 @@ class Api:
yield {"video_id": video_id, yield {"video_id": video_id,
"published": published, "published": published,
"published_at": exact, "published_at": exact,
"title": title}, next_token "title": title,
"description": snippet.get("description") or ""}, next_token
produced += 1 produced += 1
if limit is not None and produced >= limit: if limit is not None and produced >= limit:
return return
@@ -306,22 +307,33 @@ class Api:
# ---------------------------------------------------------------- durations # ---------------------------------------------------------------- durations
def durations(self, video_ids: list[str]) -> dict[str, dict]: def details(self, video_ids: list[str]) -> dict[str, dict]:
"""{video_id: {duration, is_live}} for up to any number of ids. """{video_id: {duration, is_live, title, description}} for any number of ids.
Batched 50 per call, so 441 videos costs 9 units. `is_live` comes from the Batched 50 per call, so 441 videos costs 9 units. `snippet` rides along at
presence of liveStreamingDetails rather than from the duration, because no extra cost and is the only way to fill in a title or description for a
live and upcoming items both report PT0S. video older than the ~15 entries RSS reaches back — which for a 30-day
window is a real fraction of every channel.
`is_live` comes from the presence of liveStreamingDetails rather than from
the duration, because live and upcoming items both report PT0S.
""" """
out: dict[str, dict] = {} out: dict[str, dict] = {}
for start in range(0, len(video_ids), PAGE_SIZE): for start in range(0, len(video_ids), PAGE_SIZE):
batch = video_ids[start:start + PAGE_SIZE] batch = video_ids[start:start + PAGE_SIZE]
page = self._get("videos", part="contentDetails,liveStreamingDetails", page = self._get(
"videos", part="snippet,contentDetails,liveStreamingDetails",
id=",".join(batch), maxResults=PAGE_SIZE) id=",".join(batch), maxResults=PAGE_SIZE)
for item in page.get("items") or []: for item in page.get("items") or []:
details = item.get("contentDetails") or {} details = item.get("contentDetails") or {}
snippet = item.get("snippet") or {}
out[item["id"]] = { out[item["id"]] = {
"duration": parse_duration(details.get("duration", "")), "duration": parse_duration(details.get("duration", "")),
"is_live": bool(item.get("liveStreamingDetails")), "is_live": bool(item.get("liveStreamingDetails")),
"title": (snippet.get("title") or "").strip(),
"description": snippet.get("description") or "",
} }
return out return out
# Kept as the old name so nothing silently changes meaning mid-refactor.
durations = details
+30
View File
@@ -360,6 +360,15 @@ def cmd_materialise(args) -> int:
stats = runner.materialise_all(conn, settings, args.limit) stats = runner.materialise_all(conn, settings, args.limit)
print(f"materialised={stats['materialised']} shows={stats['shows']} " print(f"materialised={stats['materialised']} shows={stats['shows']} "
f"errors={stats['errors']}") f"errors={stats['errors']}")
if stats["materialised"]:
client = jellyfin.from_settings(settings)
client.refresh()
if args.all:
# A plain scan does not reliably notice a rewritten NFO — after one
# such rewrite, 50 of 251 episodes kept their old metadata. This
# forces the re-read, and does not probe.
print("asking Jellyfin to re-read local metadata...")
client.refresh_library_metadata(config.MEDIA_ROOT)
return 1 if stats["errors"] else 0 return 1 if stats["errors"] else 0
finally: finally:
conn.close() conn.close()
@@ -375,6 +384,23 @@ def cmd_reap(args) -> int:
conn.close() conn.close()
def cmd_refresh_metadata(args) -> int:
"""Force Jellyfin to re-read every NFO, without probing any media."""
conn, settings = _open()
try:
client = jellyfin.from_settings(settings)
if not client.configured:
print("Jellyfin is not configured.", file=sys.stderr)
return 1
if not client.refresh_library_metadata(config.MEDIA_ROOT):
print(f"No Jellyfin library covers {config.MEDIA_ROOT}.", file=sys.stderr)
return 1
print("Jellyfin is re-reading local metadata (this does not probe media).")
return 0
finally:
conn.close()
def cmd_run(args) -> int: def cmd_run(args) -> int:
try: try:
with runner.exclusive_lock(): with runner.exclusive_lock():
@@ -528,6 +554,10 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_parser("reap", help="delete videos past the retention window").set_defaults( sub.add_parser("reap", help="delete videos past the retention window").set_defaults(
func=cmd_reap) func=cmd_reap)
sub.add_parser("refresh-metadata",
help="make Jellyfin re-read the NFOs (never probes media)"
).set_defaults(func=cmd_refresh_metadata)
run_cmd = sub.add_parser("run", help="sync, poll, materialise, reap (what cron calls)") run_cmd = sub.add_parser("run", help="sync, poll, materialise, reap (what cron calls)")
run_cmd.add_argument("--channel", type=int, help="restrict to one channel") run_cmd.add_argument("--channel", type=int, help="restrict to one channel")
run_cmd.set_defaults(func=cmd_run) run_cmd.set_defaults(func=cmd_run)
+11 -1
View File
@@ -15,7 +15,7 @@ from pathlib import Path
from . import config from . import config
SCHEMA_VERSION = 1 SCHEMA_VERSION = 2
_SCHEMA_V1 = """ _SCHEMA_V1 = """
CREATE TABLE IF NOT EXISTS channel ( CREATE TABLE IF NOT EXISTS channel (
@@ -121,6 +121,14 @@ CREATE TABLE IF NOT EXISTS setting (
""" """
# v2: the video description, used as the NFO <plot>. Both RSS
# (media:group/media:description) and playlistItems.list (snippet.description) carry
# it for free, and without it every episode has an empty synopsis in Jellyfin.
_SCHEMA_V2 = """
ALTER TABLE video ADD COLUMN description TEXT;
"""
def connect(path: Path | None = None) -> sqlite3.Connection: def connect(path: Path | None = None) -> sqlite3.Connection:
"""Open the database, applying migrations if needed.""" """Open the database, applying migrations if needed."""
path = Path(path) if path is not None else config.DB_PATH path = Path(path) if path is not None else config.DB_PATH
@@ -144,6 +152,8 @@ def migrate(conn: sqlite3.Connection) -> int:
with conn: with conn:
if current < 1: if current < 1:
conn.executescript(_SCHEMA_V1) conn.executescript(_SCHEMA_V1)
if current < 2:
conn.executescript(_SCHEMA_V2)
# Future migrations append here, each guarded by `if current < N`. # Future migrations append here, each guarded by `if current < N`.
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
return SCHEMA_VERSION return SCHEMA_VERSION
+39 -5
View File
@@ -100,6 +100,8 @@ def parse_entries(payload: bytes) -> list[dict]:
{ {
"video_id": video_id, "video_id": video_id,
"title": (entry.findtext("atom:title", "", NS) or "").strip(), "title": (entry.findtext("atom:title", "", NS) or "").strip(),
"description": entry.findtext(
"media:group/media:description", "", NS) or "",
"published": published_date, "published": published_date,
"published_at": published, "published_at": published,
} }
@@ -152,6 +154,13 @@ def _record(
# backfill saw it, for instance. If a later feed supplies the title, take # backfill saw it, for instance. If a later feed supplies the title, take
# it, and if the episode is already on disk under its video id, remove the # it, and if the episode is already on disk under its video id, remove the
# files and re-queue so it is rewritten under the real name. # files and re-queue so it is rewritten under the real name.
if not (existing["description"] or "").strip() and entry.get("description"):
with conn:
conn.execute(
"UPDATE video SET description = ? WHERE video_id = ?",
(entry["description"], entry["video_id"]),
)
if not (existing["title"] or "").strip() and entry["title"]: if not (existing["title"] or "").strip() and entry["title"]:
with conn: with conn:
conn.execute( conn.execute(
@@ -172,6 +181,7 @@ def _record(
channel_pk=channel["id"], channel_pk=channel["id"],
video_id=entry["video_id"], video_id=entry["video_id"],
title=entry["title"], title=entry["title"],
description=entry.get("description") or "",
upload_date=entry["published"].isoformat(), upload_date=entry["published"].isoformat(),
published_at=entry.get("published_at"), published_at=entry.get("published_at"),
state=state, state=state,
@@ -266,7 +276,7 @@ def enrich_durations(
minimum = settings.get_int("min_duration_seconds") minimum = settings.get_int("min_duration_seconds")
client = api.Api(settings.get_str("youtube_api_key")) client = api.Api(settings.get_str("youtube_api_key"))
try: try:
found = client.durations(video_ids) found = client.details(video_ids)
except api.ApiError as exc: except api.ApiError as exc:
# Durations are an enrichment, not a gate: a NULL duration costs a runtime # Durations are an enrichment, not a gate: a NULL duration costs a runtime
# display, not a working library. # display, not a working library.
@@ -275,13 +285,36 @@ def enrich_durations(
for video_id, info in found.items(): for video_id, info in found.items():
videos.set_duration(conn, video_id, info["duration"]) videos.set_duration(conn, video_id, info["duration"])
# Fill in title and description only when we do not already have them: the
# feed is just as good a source and this must not overwrite it.
existing = videos.get(conn, video_id)
if existing is not None:
for field in ("title", "description"):
if not (existing[field] or "").strip() and info.get(field):
with conn:
conn.execute(
f"UPDATE video SET {field} = ? WHERE video_id = ?",
(info[field], video_id),
)
stats["resolved"] += 1 stats["resolved"] += 1
skip = None
if info["is_live"]: if info["is_live"]:
videos.set_state(conn, video_id, videos.SKIPPED_LIVE) skip, key = videos.SKIPPED_LIVE, "live"
stats["live"] += 1
elif info["duration"] is not None and info["duration"] < minimum: elif info["duration"] is not None and info["duration"] < minimum:
videos.set_state(conn, video_id, videos.SKIPPED_SHORT) skip, key = videos.SKIPPED_SHORT, "shorts"
stats["shorts"] += 1
if skip:
# A video can reach this pass having already been materialised — a
# premiere that turned into a livestream, or a duration lookup that
# only succeeded on a later run. Setting the state without removing
# the files would leave them orphaned: on disk for Jellyfin to show,
# but with no row that admits to owning them.
if existing is not None and existing["state"] == videos.MATERIALISED:
strm.remove(existing)
log.info("%s reclassified as %s after materialising; files removed",
video_id, skip)
videos.set_state(conn, video_id, skip)
stats[key] += 1
return stats return stats
@@ -335,6 +368,7 @@ def backfill_channel(
# backfill named after their video id, because RSS reaches # backfill named after their video id, because RSS reaches
# back only ~23 days against a 30-day window. # back only ~23 days against a 30-day window.
title=entry.get("title") or "", title=entry.get("title") or "",
description=entry.get("description") or "",
upload_date=entry["published"].isoformat(), upload_date=entry["published"].isoformat(),
published_at=entry["published_at"], published_at=entry["published_at"],
state=videos.LISTED, state=videos.LISTED,
+36
View File
@@ -138,6 +138,42 @@ class Jellyfin:
except JellyfinError as exc: except JellyfinError as exc:
log.warning("jellyfin refresh failed: %s", exc) log.warning("jellyfin refresh failed: %s", exc)
def reread_local_metadata(self, item_id: str, *, recursive: bool = True) -> None:
"""Re-read NFOs for an item without probing the media.
A plain `/Library/Refresh` only re-reads an NFO when Jellyfin notices the
file changed, and it does not always notice: after rewriting 251 NFOs, 50
of them kept their old (empty) metadata. This is the fix, and it is safe —
measured 2026-08-12, a recursive refresh of a 56-episode series in this
mode made **zero** requests to the proxy.
The safety is entirely in `replaceAllMetadata=false`. With it `true`,
Jellyfin discards what it has and re-derives from the media, which for a
.strm library means fetching every episode. Never set it.
"""
try:
self._request(
"POST", f"/Items/{item_id}/Refresh",
params={
"metadataRefreshMode": "Default",
"imageRefreshMode": "Default",
"replaceAllMetadata": "false",
"replaceAllImages": "false",
"recursive": "true" if recursive else "false",
},
)
except JellyfinError as exc:
log.warning("jellyfin metadata re-read failed: %s", exc)
def refresh_library_metadata(self, path) -> bool:
"""Re-read every NFO under the library covering `path`. Best effort."""
library = self.find_library(path)
if not library or not library.get("ItemId"):
log.warning("no Jellyfin library found for %s", path)
return False
self.reread_local_metadata(library["ItemId"])
return True
def from_settings(settings) -> Jellyfin: def from_settings(settings) -> Jellyfin:
return Jellyfin( return Jellyfin(
+18 -1
View File
@@ -106,6 +106,15 @@ def materialise(
bytes to the same paths. bytes to the same paths.
""" """
upload_date = naming.parse_upload_date(video["upload_date"]) upload_date = naming.parse_upload_date(video["upload_date"])
# An episode number, once assigned, is permanent. Re-deriving it would let it
# move: next_episode() looks at the MAX for that day excluding this row, so
# re-materialising a day's videos in a different order renumbers them — and
# Jellyfin treats a renumbered episode as a different episode. Reusing the
# stored pair is what makes `materialise --all` safe to run.
if video["season"] and video["episode"]:
season, episode = video["season"], video["episode"]
else:
season, episode = videos.next_episode( season, episode = videos.next_episode(
conn, channel["id"], upload_date, video["video_id"] conn, channel["id"], upload_date, video["video_id"]
) )
@@ -120,6 +129,14 @@ def materialise(
channel, season, episode, title, video["video_id"] channel, season, episode, title, video["video_id"]
) )
# If this video already lives somewhere else on disk, remove that copy before
# writing the new one. A title arriving late changes the filename, and without
# this the old `.strm`, `.nfo` and thumbnail stay behind — so Jellyfin shows the
# episode twice and one of the two never gets updated again. Measured: running
# `materialise --all` once left 102 orphaned NFOs against 251 episodes.
if video["rel_path"] and video["rel_path"] != relative:
remove(video)
strm_path.parent.mkdir(parents=True, exist_ok=True) strm_path.parent.mkdir(parents=True, exist_ok=True)
# No trailing newline: some Jellyfin versions have historically been fussy # No trailing newline: some Jellyfin versions have historically been fussy
# about trailing whitespace in .strm files, and there is nothing to gain. # about trailing whitespace in .strm files, and there is nothing to gain.
@@ -134,7 +151,7 @@ def materialise(
show_title=channel["title"], show_title=channel["title"],
season=season, season=season,
episode=episode, episode=episode,
plot=None, plot=video["description"],
aired=upload_date.isoformat(), aired=upload_date.isoformat(),
duration_seconds=video["duration"], duration_seconds=video["duration"],
video_id=video["video_id"], video_id=video["video_id"],
+5 -3
View File
@@ -66,17 +66,19 @@ def insert(
discovery_source: str, discovery_source: str,
duration: int | None = None, duration: int | None = None,
published_at: str | None = None, published_at: str | None = None,
description: str | None = None,
) -> None: ) -> None:
with conn: with conn:
conn.execute( conn.execute(
"INSERT OR IGNORE INTO video " "INSERT OR IGNORE INTO video "
"(video_id, channel_pk, title, upload_date, published_at, duration, " "(video_id, channel_pk, title, description, upload_date, published_at, "
" state, discovery_source, discovered_at) " " duration, state, discovery_source, discovered_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
( (
video_id, video_id,
channel_pk, channel_pk,
title, title,
description,
upload_date, upload_date,
published_at, published_at,
duration, duration,