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>
172 lines
6.1 KiB
Python
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
|