Files
ytstream/ytstream/runner.py
T
Claude 22d8828080 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>
2026-08-13 11:32:12 +01:00

172 lines
6.1 KiB
Python

"""`run` orchestration: sync, then poll, then materialise, then reap — under a lock.
Order matters. The subscription sync runs *first* so that a channel added on
YouTube at 14:00 has its catalogue built in the same pass rather than an hour
later. Retention runs *last* so a video discovered and materialised in this run is
judged against the window once, not twice.
"""
from __future__ import annotations
import contextlib
import fcntl
import logging
import sqlite3
from pathlib import Path
from . import config, discovery, jellyfin, reap, strm, subsync, util, videos
from .settings import Settings
log = logging.getLogger(__name__)
class AlreadyRunning(Exception):
pass
@contextlib.contextmanager
def exclusive_lock(path: Path | None = None):
"""Non-blocking flock. Raises AlreadyRunning if another run holds it.
The cron schedule is hourly and a first-run import of 119 channels can outlast
that, so overlapping runs are expected and must be a silent no-op rather than
two workers fighting over the same queue.
"""
path = path or config.LOCK_PATH
path.parent.mkdir(parents=True, exist_ok=True)
handle = path.open("w")
try:
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
raise AlreadyRunning(f"another run holds {path}") from exc
yield
finally:
with contextlib.suppress(OSError):
fcntl.flock(handle, fcntl.LOCK_UN)
handle.close()
def materialise_all(
conn: sqlite3.Connection, settings: Settings, limit: int | None = None
) -> dict:
"""Write a `.strm` + `.nfo` + thumbnail for everything in `listed`.
There is no retry ladder and no failure state. Writing a 50-byte text file
either works or the filesystem is broken, and in the latter case the run
should stop rather than mark 400 videos as failed.
"""
stats = {"materialised": 0, "shows": 0, "errors": 0}
seen_channels: set[int] = set()
for video in videos.claim_listed(conn, limit):
channel = conn.execute(
"SELECT * FROM channel WHERE id = ?", (video["channel_pk"],)
).fetchone()
if channel is None: # channel deleted mid-run
continue
if channel["id"] not in seen_channels:
# Lazily, so a channel with nothing inside the window never creates an
# empty series in Jellyfin (plan.md §5).
strm.write_show(channel)
seen_channels.add(channel["id"])
stats["shows"] += 1
try:
strm.materialise(conn, settings, channel, video)
stats["materialised"] += 1
except OSError as exc:
log.error("could not materialise %s: %s", video["video_id"], exc)
stats["errors"] += 1
return stats
def prune_empty_channels(conn: sqlite3.Connection) -> int:
"""Remove directories for subscribed channels that have no episodes to show.
Runs after materialising and reaping, so it only sees the settled state. See
`strm.prune_if_no_episodes` for why these directories exist at all.
"""
pruned = 0
for channel in conn.execute(
"SELECT c.* FROM channel c WHERE NOT EXISTS ("
" SELECT 1 FROM video v WHERE v.channel_pk = c.id AND v.state = ?)",
(videos.MATERIALISED,),
).fetchall():
if strm.prune_if_no_episodes(channel):
pruned += 1
return pruned
def run(
conn: sqlite3.Connection, settings: Settings, channel_pk: int | None = None
) -> dict:
"""One full cycle. Assumes the caller holds the lock."""
result: dict = {}
# Sync first: a channel added on YouTube should get its catalogue this run.
# Skipped for a single-channel run, which is a targeted operation.
if channel_pk is None:
result["sync"] = subsync.sync_all(conn, settings)
result["poll"] = discovery.poll_all(conn, settings, channel_pk)
result["materialise"] = materialise_all(conn, settings)
result["reap"] = reap.run(conn, settings)
result["pruned"] = prune_empty_channels(conn)
if result["materialise"]["materialised"] or result["reap"]["aged_out"]:
jellyfin.from_settings(settings).refresh()
settings.set("last_run_at", util.utcnow_iso())
return result
def summarise(result: dict) -> str:
sync = result.get("sync", {})
poll = result.get("poll", {})
made = result.get("materialise", {})
reaped = result.get("reap", {})
parts = [
f"channels={poll.get('channels', 0)}",
f"discovered={poll.get('queued', 0)}",
f"materialised={made.get('materialised', 0)}",
f"aged_out={reaped.get('aged_out', 0)}",
f"shorts={poll.get('shorts', 0)}",
f"live={poll.get('live', 0)}",
f"poll_failures={poll.get('failed', 0)}",
]
if sync:
parts[1:1] = [
f"subs_added={sync.get('added', 0)}",
f"subs_queued={sync.get('queued', 0)}",
f"subs_removed={sync.get('removed', 0)}",
]
if sync.get("refused"):
parts.append(f"SYNC_REFUSED={sync['refused']}")
if poll.get("kept"):
parts.append(f"kept_for_floor={poll['kept']}")
if result.get("pruned"):
parts.append(f"pruned_empty={result['pruned']}")
if made.get("errors"):
parts.append(f"errors={made['errors']}")
return " ".join(parts)
def exit_code(result: dict) -> int:
"""Non-zero when something needs a human.
The cron entry runs under `runitor`, so this is what turns the healthchecks
check red. A silently-broken subscription mirror is the worst outcome
available — nothing looks wrong until someone asks why a channel never
appeared — so a refused sync must be loud.
"""
sync = result.get("sync", {})
if sync.get("refused"):
return 1
if result.get("materialise", {}).get("errors"):
return 1
if result.get("poll", {}).get("failed"):
# Two of 119 measured channels fail permanently (terminated or private),
# so a poll failure is a warning, not a red check.
log.warning("%d channel(s) failed to poll", result["poll"]["failed"])
return 0