Initial implementation of youtube-automate

A DVR for YouTube subscriptions, laid out so Jellyfin presents each channel
as a show and each video as an episode. Cron-driven, idempotent, with a
public admin UI for a non-operator.

Verified end to end on susan against three real channels: PO tokens, h264
downloads, Jellyfin resolution from local NFOs with all providers disabled,
retention and tombstones.

Corrections to the original design handover (specs.md documents each with
the evidence, and specs.handover-original.md preserves the original):

- The format sort selected 360p. Ranking acodec above res makes `bv*` prefer
  the combined 360p stream, which carries AAC, over the 720p video-only
  stream whose acodec is none. vcodec now leads, so a video without h264 at
  720p yields h264 lower down rather than VP9 this hardware cannot transcode.
- yt-dlp now requires a JS runtime and the yt-dlp-ejs solver scripts, which
  only ship with the [default] extra. Without them the n challenge fails and
  the mweb formats disappear entirely.
- --flat-playlist carries no upload dates, so the specced client-side date
  filter for backfill was impossible. Backfill is RSS-first.
- skipped_old was terminal, so raising a channel's retention appeared to do
  nothing. Added an explicit rescan.
- is_upcoming premieres now defer and retry instead of being skipped forever.
- TubeArchivist is gone, so the media root and the tube.jihakuz.xyz vhost
  were both reclaimed; the latter still pointed at its dead port.

240 offline tests, no network and no real yt-dlp invocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Flux
2026-08-11 21:42:48 +01:00
co-authored by Claude Opus 5
commit 18bb2e420b
44 changed files with 7188 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""youtube-automate — a DVR for YouTube subscriptions, laid out for Jellyfin."""
__version__ = "1.0.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+241
View File
@@ -0,0 +1,241 @@
"""Channel resolution, subscribe and unsubscribe."""
from __future__ import annotations
import logging
import re
import shutil
import sqlite3
import subprocess
import tempfile
import urllib.request
from pathlib import Path
from . import config, naming, nfo, util, ytdlp
from .settings import Settings
log = logging.getLogger(__name__)
_CHANNEL_ID = re.compile(r"^UC[A-Za-z0-9_-]{22}$")
_HANDLE = re.compile(r"^@[A-Za-z0-9._-]+$")
# Artwork we try to pull at subscribe time. Best effort — a channel without them
# still works, it just looks plainer in Jellyfin.
_ARTWORK = (("avatar_uncropped", "poster.jpg"), ("banner_uncropped", "fanart.jpg"))
class ResolutionError(RuntimeError):
pass
def normalise_url(text: str) -> str:
"""Turn any accepted channel reference into a URL yt-dlp understands."""
text = (text or "").strip()
if not text:
raise ResolutionError("no channel given")
if text.startswith(("http://", "https://")):
return text
if _CHANNEL_ID.match(text):
return f"https://www.youtube.com/channel/{text}"
if _HANDLE.match(text):
return f"https://www.youtube.com/{text}"
if text.startswith("www.youtube.com") or text.startswith("youtube.com"):
return "https://" + text
# Bare word: assume it's a handle without the @.
if re.match(r"^[A-Za-z0-9._-]+$", text):
return f"https://www.youtube.com/@{text}"
raise ResolutionError(f"could not interpret {text!r} as a channel")
def resolve(settings: Settings, text: str) -> dict:
"""Fetch channel metadata without enumerating the uploads."""
url = normalise_url(text)
args = [
"--flat-playlist",
"--playlist-items",
"0",
"-J",
"--no-warnings",
"--ignore-config",
*ytdlp.extractor_args(settings.get_str("pot_provider_url")),
url,
]
try:
data = ytdlp.run_json(args, timeout=180)
except ytdlp.YtdlpError as exc:
raise ResolutionError(str(exc)) from exc
channel_id = data.get("channel_id") or data.get("id") or ""
if not _CHANNEL_ID.match(channel_id):
raise ResolutionError(f"no channel id found for {url}")
handle = data.get("uploader_id") or ""
if handle and not handle.startswith("@"):
handle = ""
return {
"channel_id": channel_id,
"title": (data.get("channel") or data.get("title") or channel_id).strip(),
"description": data.get("description") or "",
"handle": handle,
"thumbnails": data.get("thumbnails") or [],
}
def uulf_playlist_id(channel_id: str) -> str:
"""Long-form-only uploads playlist for a channel (specs.md §4)."""
return "UULF" + channel_id[2:]
# --------------------------------------------------------------------------
# artwork
def _pick_thumbnail(thumbnails: list[dict], wanted_id: str) -> str | None:
for thumb in thumbnails:
if str(thumb.get("id", "")) == wanted_id and thumb.get("url"):
return thumb["url"]
return None
def _download_image(url: str, destination: Path) -> bool:
"""Fetch an image and normalise it to JPEG via ffmpeg.
YouTube serves avatars as webp as often as jpeg, and naming a webp file
.jpg would be a lie some clients notice.
"""
try:
request = urllib.request.Request(
url, headers={"User-Agent": config.USER_AGENT}
)
with urllib.request.urlopen(request, timeout=60) as response:
payload = response.read()
except OSError as exc:
log.warning("artwork download failed (%s): %s", url, exc)
return False
with tempfile.NamedTemporaryFile(suffix=".img", delete=True) as raw:
raw.write(payload)
raw.flush()
result = subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-i", raw.name, str(destination)],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
log.warning("artwork conversion failed: %s", result.stderr.strip()[:200])
return False
return True
def write_channel_metadata(channel_dir: Path, info: dict) -> None:
"""Write tvshow.nfo and best-effort artwork into the channel directory."""
channel_dir.mkdir(parents=True, exist_ok=True)
nfo.write(
channel_dir / "tvshow.nfo",
nfo.tvshow_nfo(info["title"], info.get("description"), info["channel_id"]),
)
for thumb_id, filename in _ARTWORK:
url = _pick_thumbnail(info.get("thumbnails") or [], thumb_id)
if url:
_download_image(url, channel_dir / filename)
# --------------------------------------------------------------------------
# subscribe / unsubscribe
def get(conn: sqlite3.Connection, pk: int) -> sqlite3.Row | None:
return conn.execute("SELECT * FROM channel WHERE id = ?", (pk,)).fetchone()
def get_by_channel_id(conn: sqlite3.Connection, channel_id: str) -> sqlite3.Row | None:
return conn.execute(
"SELECT * FROM channel WHERE channel_id = ?", (channel_id,)
).fetchone()
def all_channels(conn: sqlite3.Connection) -> list[sqlite3.Row]:
return conn.execute("SELECT * FROM channel ORDER BY title COLLATE NOCASE").fetchall()
def _unique_dir_name(conn: sqlite3.Connection, base: str) -> str:
"""dir_name is UNIQUE; two channels can legitimately share a title."""
candidate = base
suffix = 2
while conn.execute(
"SELECT 1 FROM channel WHERE dir_name = ?", (candidate,)
).fetchone():
candidate = f"{base} ({suffix})"
suffix += 1
return candidate
def subscribe(conn: sqlite3.Connection, settings: Settings, text: str) -> sqlite3.Row:
"""Resolve, insert and lay down on-disk metadata. Raises ResolutionError."""
info = resolve(settings, text)
existing = get_by_channel_id(conn, info["channel_id"])
if existing:
raise ResolutionError(f"already subscribed to {existing['title']}")
dir_name = _unique_dir_name(
conn, naming.channel_dir_name(info["title"], info["channel_id"])
)
with conn:
cursor = conn.execute(
"INSERT INTO channel (channel_id, handle, title, description, dir_name, added_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
info["channel_id"],
info["handle"],
info["title"],
info["description"],
dir_name,
util.utcnow_iso(),
),
)
pk = cursor.lastrowid
write_channel_metadata(config.MEDIA_ROOT / dir_name, info)
log.info("subscribed to %s (%s)", info["title"], info["channel_id"])
return get(conn, pk)
def refresh_metadata(conn: sqlite3.Connection, settings: Settings, pk: int) -> None:
"""Re-resolve a channel and rewrite tvshow.nfo if the title changed.
dir_name is deliberately never recomputed — channels rename themselves and
we do not want orphaned directories.
"""
row = get(conn, pk)
if row is None:
return
info = resolve(settings, row["channel_id"])
if info["title"] != row["title"] or info["description"] != (row["description"] or ""):
with conn:
conn.execute(
"UPDATE channel SET title = ?, description = ? WHERE id = ?",
(info["title"], info["description"], pk),
)
write_channel_metadata(config.MEDIA_ROOT / row["dir_name"], info)
def unsubscribe(conn: sqlite3.Connection, pk: int) -> str:
"""Hard delete: remove the directory tree, then the rows. Irreversible."""
row = get(conn, pk)
if row is None:
raise LookupError(f"no channel with id {pk}")
title = row["title"]
channel_dir = config.MEDIA_ROOT / row["dir_name"]
if channel_dir.is_dir():
shutil.rmtree(channel_dir, ignore_errors=True)
with conn:
conn.execute("DELETE FROM channel WHERE id = ?", (pk,))
log.info("unsubscribed from %s and removed %s", title, channel_dir)
return title
+372
View File
@@ -0,0 +1,372 @@
"""Command line entry point."""
from __future__ import annotations
import argparse
import getpass
import sys
from . import (
channels,
config,
db,
discovery,
doctor,
download,
jellyfin,
reap,
runner,
util,
)
from .settings import Settings
from .web import auth
def _open():
conn = db.connect()
return conn, Settings(conn)
# --------------------------------------------------------------------------
# commands
def cmd_doctor(args) -> int:
conn, settings = _open()
try:
text, code = doctor.report(doctor.run_checks(settings))
print(text)
return code
finally:
conn.close()
def cmd_set_password(args) -> int:
conn, settings = _open()
try:
password = getpass.getpass("New admin password: ")
if len(password) < 8:
print("Password must be at least 8 characters.", file=sys.stderr)
return 1
if password != getpass.getpass("Repeat: "):
print("Passwords did not match.", file=sys.stderr)
return 1
settings.set("admin_password_hash", auth.hash_password(password))
if not settings.raw("session_secret"):
settings.set("session_secret", auth.new_secret())
print("Admin password set.")
return 0
finally:
conn.close()
def cmd_set_jellyfin_key(args) -> int:
conn, settings = _open()
try:
key = args.key or getpass.getpass("Jellyfin API key: ")
key = key.strip()
if not key:
print("No key given.", file=sys.stderr)
return 1
client = jellyfin.Jellyfin(settings.get_str("jellyfin_url"), key)
try:
client.virtual_folders()
except jellyfin.JellyfinError as exc:
print(f"Key rejected by Jellyfin: {exc}", file=sys.stderr)
return 1
settings.set("jellyfin_api_key", key)
print("Jellyfin API key stored and verified.")
return 0
finally:
conn.close()
def cmd_setup_jellyfin_library(args) -> int:
conn, settings = _open()
try:
client = jellyfin.from_settings(settings)
if not client.configured:
print("Set jellyfin_url and the API key first.", file=sys.stderr)
return 1
existing = client.find_library(config.MEDIA_ROOT)
if existing:
print(
f"Library '{existing.get('Name')}' already covers {config.MEDIA_ROOT}."
)
return 0
client.create_library(config.MEDIA_ROOT)
created = client.find_library(config.MEDIA_ROOT)
if not created:
print("Library creation reported success but it is not present.",
file=sys.stderr)
return 1
print(f"Created library '{created.get('Name')}' for {config.MEDIA_ROOT}.")
return 0
finally:
conn.close()
def cmd_subscribe(args) -> int:
conn, settings = _open()
try:
try:
row = channels.subscribe(conn, settings, args.url)
except channels.ResolutionError as exc:
print(f"Could not subscribe: {exc}", file=sys.stderr)
return 1
print(f"Subscribed to {row['title']} ({row['channel_id']}) -> {row['dir_name']}/")
return 0
finally:
conn.close()
def cmd_unsubscribe(args) -> int:
conn, settings = _open()
try:
row = channels.get(conn, args.id)
if row is None:
print(f"No channel with id {args.id}.", file=sys.stderr)
return 1
if not args.yes:
print(f"This permanently deletes {config.MEDIA_ROOT / row['dir_name']} "
f"and all rows for {row['title']}.")
if input("Type the channel title to confirm: ").strip() != row["title"]:
print("Aborted.")
return 1
title = channels.unsubscribe(conn, args.id)
jellyfin.from_settings(settings).refresh()
print(f"Unsubscribed from {title}.")
return 0
finally:
conn.close()
def cmd_poll(args) -> int:
conn, settings = _open()
try:
if args.rescan:
rows = (
[channels.get(conn, args.channel)]
if args.channel
else channels.all_channels(conn)
)
total = sum(
discovery.rescan_channel(conn, settings, row) for row in rows if row
)
print(f"re-queued {total} previously-skipped video(s)")
totals = discovery.poll_all(conn, settings, args.channel)
print(
f"queued={totals['queued']} extended={totals['extended']} "
f"already-known={totals['known']} outside-window={totals['old']} "
f"repaired={totals['repaired']} poll-failures={totals['failed']}"
)
return 1 if totals["failed"] else 0
finally:
conn.close()
def cmd_download(args) -> int:
conn, settings = _open()
try:
videos_recovered = download.recover_orphans()
if videos_recovered:
print(f"cleared {videos_recovered} orphan file(s) from the work dir")
try:
counts = download.drain(conn, settings, args.limit)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 1
if not counts:
print("nothing to download")
return 0
print(" ".join(f"{state}={count}" for state, count in sorted(counts.items())))
return 0
finally:
conn.close()
def cmd_reap(args) -> int:
conn, settings = _open()
try:
result = reap.run(conn, settings)
print(f"deleted={result['deleted']} evicted={result['evicted']}")
return 0
finally:
conn.close()
def cmd_run(args) -> int:
try:
with runner.exclusive_lock():
conn, settings = _open()
try:
result = runner.run(conn, settings, args.channel)
print(runner.summarise(result))
return 1 if "error" in result.get("download", {}) else 0
finally:
conn.close()
except runner.AlreadyRunning:
# Expected when a long backfill outlasts the hourly cron tick.
return 0
def cmd_serve(args) -> int:
from .web import server
server.serve(args.host, args.port, secure_cookies=not args.insecure_cookies)
return 0
def cmd_set_retention(args) -> int:
conn, _ = _open()
try:
row = channels.get(conn, args.id)
if row is None:
print(f"No channel with id {args.id}.", file=sys.stderr)
return 1
if args.days.lower() in ("default", "none", "clear"):
value = None
else:
try:
value = int(args.days)
except ValueError:
print("days must be a whole number or 'default'.", file=sys.stderr)
return 1
if value < 1:
print("days must be at least 1.", file=sys.stderr)
return 1
with conn:
conn.execute(
"UPDATE channel SET retention_days = ? WHERE id = ?", (value, args.id)
)
shown = "the global default" if value is None else f"{value} days"
print(f"{row['title']} retention set to {shown}.")
return 0
finally:
conn.close()
def cmd_channels(args) -> int:
conn, _ = _open()
try:
rows = channels.all_channels(conn)
if not rows:
print("No channels subscribed.")
return 0
print(f"{'id':>3} {'title':<32} {'handle':<20} {'videos':>6} last poll")
for row in rows:
counts = conn.execute(
"SELECT COUNT(*) FROM video WHERE channel_pk = ? AND state = 'downloaded'",
(row["id"],),
).fetchone()[0]
print(
f"{row['id']:>3} {row['title'][:32]:<32} {(row['handle'] or ''):<20} "
f"{counts:>6} {row['last_polled_at'] or 'never'}"
)
return 0
finally:
conn.close()
# --------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="youtube-automate",
description="A DVR for YouTube subscriptions, laid out for Jellyfin.",
)
parser.add_argument("-v", "--verbose", action="store_true", help="debug logging")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("doctor", help="check the installation").set_defaults(
func=cmd_doctor
)
sub.add_parser("set-password", help="set the admin UI password").set_defaults(
func=cmd_set_password
)
key = sub.add_parser("set-jellyfin-key", help="store and verify the Jellyfin API key")
key.add_argument("key", nargs="?", help="omit to be prompted (preferred)")
key.set_defaults(func=cmd_set_jellyfin_key)
sub.add_parser(
"setup-jellyfin-library", help="create the Shows library for the media root"
).set_defaults(func=cmd_setup_jellyfin_library)
subscribe = sub.add_parser("subscribe", help="subscribe to a channel")
subscribe.add_argument("url", help="channel URL, @handle, or UC... id")
subscribe.set_defaults(func=cmd_subscribe)
unsubscribe = sub.add_parser(
"unsubscribe", help="remove a channel and everything it downloaded"
)
unsubscribe.add_argument("id", type=int, help="channel id from `channels`")
unsubscribe.add_argument(
"--yes", action="store_true", help="skip the confirmation prompt"
)
unsubscribe.set_defaults(func=cmd_unsubscribe)
sub.add_parser("channels", help="list subscribed channels").set_defaults(
func=cmd_channels
)
poll = sub.add_parser("poll", help="discover new videos")
poll.add_argument("--channel", type=int, help="restrict to one channel id")
poll.add_argument(
"--rescan",
action="store_true",
help="also re-queue videos previously skipped as too old that the "
"current retention window now covers",
)
poll.set_defaults(func=cmd_poll)
serve = sub.add_parser("serve", help="run the admin web server")
serve.add_argument("--host", default="127.0.0.1")
serve.add_argument("--port", type=int, default=8085)
serve.add_argument(
"--insecure-cookies",
action="store_true",
help="omit the Secure cookie flag (local testing over plain http only)",
)
serve.set_defaults(func=cmd_serve)
run_cmd = sub.add_parser("run", help="poll, download and reap (what cron calls)")
run_cmd.add_argument("--channel", type=int, help="restrict discovery to one channel")
run_cmd.set_defaults(func=cmd_run)
sub.add_parser("reap", help="delete videos past the retention window").set_defaults(
func=cmd_reap
)
down = sub.add_parser("download", help="drain the pending queue")
down.add_argument("--limit", type=int, help="stop after this many videos")
down.set_defaults(func=cmd_download)
retention = sub.add_parser(
"set-retention", help="set or clear a channel's retention override"
)
retention.add_argument("id", type=int, help="channel id")
retention.add_argument(
"days", help="number of days, or 'default' to clear the override"
)
retention.set_defaults(func=cmd_set_retention)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
util.setup_logging(args.verbose)
util.apply_umask()
return args.func(args)
+33
View File
@@ -0,0 +1,33 @@
"""Filesystem paths and process-level constants.
Every path is overridable through the environment so the test suite can point the
whole application at a tmpdir without touching the real media tree.
"""
from __future__ import annotations
import os
from pathlib import Path
def _path(env: str, default: str) -> Path:
return Path(os.environ.get(env, default))
STATE_DIR = _path("YTA_STATE_DIR", "/var/lib/youtube-automate")
MEDIA_ROOT = _path("YTA_MEDIA_ROOT", "/disks/Plex/YouTube")
DB_PATH = _path("YTA_DB_PATH", str(STATE_DIR / "subs.db"))
LOCK_PATH = _path("YTA_LOCK_PATH", str(STATE_DIR / "run.lock"))
VENV_BIN = _path("YTA_VENV_BIN", str(STATE_DIR / "venv" / "bin"))
WORK_DIR = MEDIA_ROOT / ".work"
# Files created by the service must stay group-readable by `mediaserver`, which is
# how Jellyfin reaches the tree. See specs.md §2.
UMASK = 0o002
# Media-adjacent sidecars we own and therefore may delete on reap.
SIDECAR_SUFFIXES = (".nfo", "-thumb.jpg", ".en.srt", ".info.json")
USER_AGENT = "youtube-automate/1.0"
+87
View File
@@ -0,0 +1,87 @@
"""SQLite access and schema migrations.
WAL is mandatory: the hourly cron job and the long-running admin server both write.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from . import config
SCHEMA_VERSION = 1
_SCHEMA_V1 = """
CREATE TABLE IF NOT EXISTS channel (
id INTEGER PRIMARY KEY,
channel_id TEXT NOT NULL UNIQUE,
handle TEXT,
title TEXT NOT NULL,
description TEXT,
dir_name TEXT NOT NULL UNIQUE,
added_at TEXT NOT NULL,
backfilled INTEGER NOT NULL DEFAULT 0,
retention_days INTEGER,
last_polled_at TEXT,
last_poll_ok INTEGER,
consecutive_poll_failures INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS video (
id INTEGER PRIMARY KEY,
video_id TEXT NOT NULL UNIQUE,
channel_pk INTEGER NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
title TEXT,
upload_date TEXT,
duration INTEGER,
season INTEGER,
episode INTEGER,
state TEXT NOT NULL,
discovery_source TEXT NOT NULL,
rel_path TEXT,
size_bytes INTEGER,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
discovered_at TEXT NOT NULL,
downloaded_at TEXT,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_video_state ON video(state);
CREATE INDEX IF NOT EXISTS idx_video_upload_date ON video(upload_date);
CREATE INDEX IF NOT EXISTS idx_video_channel ON video(channel_pk);
CREATE TABLE IF NOT EXISTS setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
def connect(path: Path | None = None) -> sqlite3.Connection:
"""Open the database, applying migrations if needed."""
path = Path(path) if path is not None else config.DB_PATH
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, timeout=30.0, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA busy_timeout = 30000")
migrate(conn)
return conn
def migrate(conn: sqlite3.Connection) -> int:
"""Bring the schema up to SCHEMA_VERSION. Idempotent."""
current = conn.execute("PRAGMA user_version").fetchone()[0]
if current >= SCHEMA_VERSION:
return current
with conn:
if current < 1:
conn.executescript(_SCHEMA_V1)
# Future migrations append here, each guarded by `if current < N`.
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
return SCHEMA_VERSION
+363
View File
@@ -0,0 +1,363 @@
"""Discovery: RSS polling and the subscribe-time backfill.
Primary path is the undocumented UULF uploads playlist feed, which excludes
Shorts and livestreams at the cheapest possible point (verified — see specs.md
§4). The channel_id feed is the fallback, and rows discovered that way carry
`discovery_source='uc_feed'` so the download step knows to apply the duration and
live-status match filter.
"""
from __future__ import annotations
import logging
import sqlite3
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from datetime import date, timedelta
from . import channels, config, util, videos, ytdlp
from .settings import Settings
log = logging.getLogger(__name__)
NS = {
"atom": "http://www.w3.org/2005/Atom",
"yt": "http://www.youtube.com/xml/schemas/2015",
"media": "http://search.yahoo.com/mrss/",
}
FEED_BASE = "https://www.youtube.com/feeds/videos.xml"
class FeedUnavailable(Exception):
"""The feed could not be fetched at all (network/5xx). Not the same as 404."""
def uulf_feed_url(channel_id: str) -> str:
return f"{FEED_BASE}?playlist_id={channels.uulf_playlist_id(channel_id)}"
def uc_feed_url(channel_id: str) -> str:
return f"{FEED_BASE}?channel_id={channel_id}"
def fetch_feed(url: str, timeout: float = 30.0) -> bytes | None:
"""Return the feed body, or None if YouTube says it doesn't exist.
A 404 on UULF/UUSH/UULV means "no such playlist", i.e. the channel has none
of that kind of video — it is not an error.
"""
request = urllib.request.Request(url, headers={"User-Agent": config.USER_AGENT})
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
raise FeedUnavailable(f"HTTP {exc.code}") from exc
except OSError as exc:
raise FeedUnavailable(str(exc)) from exc
def parse_entries(payload: bytes) -> list[dict]:
"""Parse an Atom feed into video dicts. The feed carries no duration."""
try:
root = ET.fromstring(payload)
except ET.ParseError as exc:
raise FeedUnavailable(f"unparseable feed: {exc}") from exc
entries = []
for entry in root.findall("atom:entry", NS):
video_id = entry.findtext("yt:videoId", "", NS)
if not video_id:
continue
published = entry.findtext("atom:published", "", NS)
try:
published_date = date.fromisoformat(published[:10])
except ValueError:
continue
description = entry.findtext("media:group/media:description", "", NS)
entries.append(
{
"video_id": video_id,
"title": (entry.findtext("atom:title", "", NS) or "").strip(),
"published": published_date,
"description": description or "",
}
)
return entries
def effective_retention_days(settings: Settings, channel: sqlite3.Row) -> int:
override = channel["retention_days"] if "retention_days" in channel.keys() else None
if override:
return int(override)
return settings.get_int("retention_days")
# --------------------------------------------------------------------------
def _record(
conn: sqlite3.Connection,
channel: sqlite3.Row,
entry: dict,
source: str,
cutoff: date,
) -> str:
"""Insert or repair one discovered video. Returns what happened."""
existing = videos.get(conn, entry["video_id"])
if existing is not None:
# The only repair we ever perform: a video the fallback path rejected as
# too short, later confirmed long-form by the authoritative UULF feed.
# Deleted rows are tombstones and are never touched here.
if (
source == videos.SOURCE_UULF
and existing["state"] == videos.SKIPPED_SHORT
and existing["discovery_source"] == videos.SOURCE_UC
):
with conn:
conn.execute(
"UPDATE video SET state = ?, discovery_source = ?, "
"last_error = NULL WHERE video_id = ?",
(videos.PENDING, videos.SOURCE_UULF, entry["video_id"]),
)
log.info(
"re-queued %s: UULF confirms it is long-form", entry["video_id"]
)
return "repaired"
return "known"
state = videos.PENDING if entry["published"] >= cutoff else videos.SKIPPED_OLD
videos.insert(
conn,
channel_pk=channel["id"],
video_id=entry["video_id"],
title=entry["title"],
upload_date=entry["published"].isoformat(),
state=state,
discovery_source=source,
)
return "queued" if state == videos.PENDING else "old"
def poll_channel(conn: sqlite3.Connection, settings: Settings, channel: sqlite3.Row) -> dict:
"""Poll one channel. Never raises for feed problems — records them instead."""
stats = {"queued": 0, "old": 0, "known": 0, "repaired": 0, "source": None}
source = videos.SOURCE_UULF
try:
payload = fetch_feed(uulf_feed_url(channel["channel_id"]))
entries = parse_entries(payload) if payload else []
if not entries:
# UULF 404'd or came back empty — fall back to the channel feed.
log.warning(
"UULF feed empty for %s, falling back to channel_id feed",
channel["title"],
)
source = videos.SOURCE_UC
payload = fetch_feed(uc_feed_url(channel["channel_id"]))
entries = parse_entries(payload) if payload else []
except FeedUnavailable as exc:
_record_poll_failure(conn, channel, str(exc))
stats["error"] = str(exc)
return stats
cutoff = util.today() - timedelta(days=effective_retention_days(settings, channel))
for entry in entries:
stats[_record(conn, channel, entry, source, cutoff)] += 1
stats["source"] = source
_record_poll_success(conn, channel)
return stats
def _record_poll_success(conn: sqlite3.Connection, channel: sqlite3.Row) -> None:
with conn:
conn.execute(
"UPDATE channel SET last_polled_at = ?, last_poll_ok = 1, "
"consecutive_poll_failures = 0 WHERE id = ?",
(util.utcnow_iso(), channel["id"]),
)
def _record_poll_failure(conn: sqlite3.Connection, channel: sqlite3.Row, error: str) -> None:
log.error("poll failed for %s: %s", channel["title"], error)
with conn:
conn.execute(
"UPDATE channel SET last_polled_at = ?, last_poll_ok = 0, "
"consecutive_poll_failures = consecutive_poll_failures + 1 WHERE id = ?",
(util.utcnow_iso(), channel["id"]),
)
# --------------------------------------------------------------------------
# backfill
def _flat_playlist(settings: Settings, channel_id: str, limit: int) -> list[dict]:
"""Reverse-chronological upload list. Entries carry no upload date."""
args = [
"--flat-playlist",
"--playlist-end",
str(limit),
"-J",
"--no-warnings",
"--ignore-config",
*ytdlp.extractor_args(settings.get_str("pot_provider_url")),
f"https://www.youtube.com/playlist?list={channels.uulf_playlist_id(channel_id)}",
]
try:
data = ytdlp.run_json(args, timeout=300)
except ytdlp.YtdlpError as exc:
log.warning("flat playlist failed for %s: %s", channel_id, exc)
return []
return [entry for entry in (data.get("entries") or []) if entry.get("id")]
def _upload_date(settings: Settings, video_id: str) -> date | None:
"""One extraction to learn a single video's upload date."""
args = [
"--skip-download",
"--no-warnings",
"--ignore-config",
"--no-playlist",
"--print",
"%(upload_date)s",
*ytdlp.extractor_args(settings.get_str("pot_provider_url")),
f"https://www.youtube.com/watch?v={video_id}",
]
result = ytdlp.run(args, timeout=180)
text = (result.stdout or "").strip().splitlines()
if result.returncode != 0 or not text:
return None
try:
from . import naming
return naming.parse_upload_date(text[-1])
except ValueError:
return None
def backfill_channel(
conn: sqlite3.Connection, settings: Settings, channel: sqlite3.Row
) -> dict:
"""Queue the last `backfill_days` for a freshly subscribed channel.
The RSS feed is the primary source because it is the only one that carries
upload dates — `--flat-playlist` reports `timestamp: None` for every entry.
RSS returns ~15 items, which covers the default 7-day window for any channel
uploading less than twice a day. Only when the feed's oldest entry is still
inside the window do we extend via the playlist, resolving those extra dates
one video at a time.
"""
days = settings.get_int("backfill_days")
cutoff = util.today() - timedelta(days=days)
stats = {"queued": 0, "old": 0, "known": 0, "repaired": 0, "extended": 0}
try:
payload = fetch_feed(uulf_feed_url(channel["channel_id"]))
entries = parse_entries(payload) if payload else []
source = videos.SOURCE_UULF
if not entries:
source = videos.SOURCE_UC
payload = fetch_feed(uc_feed_url(channel["channel_id"]))
entries = parse_entries(payload) if payload else []
except FeedUnavailable as exc:
_record_poll_failure(conn, channel, str(exc))
stats["error"] = str(exc)
return stats
seen = set()
for entry in entries:
seen.add(entry["video_id"])
stats[_record(conn, channel, entry, source, cutoff)] += 1
oldest = min((entry["published"] for entry in entries), default=None)
if oldest is not None and oldest >= cutoff:
# The feed did not reach past the window, so there may be more.
log.info(
"%s: RSS reaches only to %s, extending backfill via playlist",
channel["title"],
oldest.isoformat(),
)
for item in _flat_playlist(settings, channel["channel_id"], 50):
video_id = item["id"]
if video_id in seen or videos.exists(conn, video_id):
continue
upload_date = _upload_date(settings, video_id)
if upload_date is None:
continue
if upload_date < cutoff:
break # playlist is reverse-chronological; everything after is older
videos.insert(
conn,
channel_pk=channel["id"],
video_id=video_id,
title=(item.get("title") or "").strip(),
upload_date=upload_date.isoformat(),
duration=item.get("duration"),
state=videos.PENDING,
discovery_source=videos.SOURCE_BACKFILL,
)
stats["extended"] += 1
with conn:
conn.execute("UPDATE channel SET backfilled = 1 WHERE id = ?", (channel["id"],))
_record_poll_success(conn, channel)
return stats
def rescan_channel(
conn: sqlite3.Connection, settings: Settings, channel: sqlite3.Row
) -> int:
"""Re-queue `skipped_old` rows that the current retention window now covers.
`skipped_old` is judged against whatever window was in force at discovery
time, and it is otherwise terminal. Without this, raising a channel's
retention_days would appear to do nothing for an infrequent uploader —
every one of their videos is already marked old. This is deliberately an
explicit action rather than something poll does silently, because doing it
on every poll would make `backfill_days` meaningless: it would immediately
re-queue everything the initial backfill had deliberately left behind.
Tombstones (`deleted`) are never touched.
"""
cutoff = util.today() - timedelta(days=effective_retention_days(settings, channel))
with conn:
cursor = conn.execute(
"UPDATE video SET state = ?, last_error = NULL "
"WHERE channel_pk = ? AND state = ? AND upload_date >= ?",
(videos.PENDING, channel["id"], videos.SKIPPED_OLD, cutoff.isoformat()),
)
if cursor.rowcount:
log.info(
"%s: re-queued %d video(s) now inside the %s window",
channel["title"],
cursor.rowcount,
cutoff.isoformat(),
)
return cursor.rowcount
def poll_all(conn: sqlite3.Connection, settings: Settings, channel_pk: int | None = None) -> dict:
"""Backfill anything new, then poll everything. Returns aggregate counts."""
if channel_pk is not None:
rows = [row for row in [channels.get(conn, channel_pk)] if row is not None]
else:
rows = channels.all_channels(conn)
totals = {"queued": 0, "old": 0, "known": 0, "repaired": 0, "extended": 0, "failed": 0}
for channel in rows:
if not channel["backfilled"]:
stats = backfill_channel(conn, settings, channel)
else:
stats = poll_channel(conn, settings, channel)
if "error" in stats:
totals["failed"] += 1
for key in ("queued", "old", "known", "repaired", "extended"):
totals[key] += stats.get(key, 0)
log.info("%s: %s", channel["title"], stats)
return totals
+203
View File
@@ -0,0 +1,203 @@
"""Preflight checks.
`doctor` is the first acceptance criterion and the thing to run when something
breaks. Every check returns a row rather than raising, so one failure doesn't
hide the others.
"""
from __future__ import annotations
import grp
import os
import stat
import subprocess
from dataclasses import dataclass
from pathlib import Path
from . import config, jellyfin, ytdlp
from .settings import Settings
MEDIA_GROUP = "mediaserver"
@dataclass
class Check:
name: str
ok: bool
detail: str
fatal: bool = True
def _deno() -> Check:
binary = config.VENV_BIN / "deno"
if not binary.exists():
return Check(
"js runtime",
False,
f"deno not found at {binary} — yt-dlp cannot solve n challenges (specs.md §3)",
)
try:
result = subprocess.run(
[str(binary), "--version"], capture_output=True, text=True, timeout=30
)
except OSError as exc:
return Check("js runtime", False, f"deno unusable: {exc}")
if result.returncode != 0:
return Check("js runtime", False, "deno --version failed")
return Check("js runtime", True, result.stdout.splitlines()[0])
def _ytdlp() -> Check:
try:
return Check("yt-dlp", True, ytdlp.version())
except (OSError, ytdlp.YtdlpError) as exc:
return Check("yt-dlp", False, str(exc))
def _ejs() -> Check:
try:
from importlib.metadata import version as pkg_version
return Check("yt-dlp-ejs", True, pkg_version("yt-dlp-ejs"))
except Exception:
return Check(
"yt-dlp-ejs",
False,
"not installed — reinstall with the yt-dlp[default] extra (specs.md §3)",
)
def _pot(settings: Settings) -> Check:
url = settings.get_str("pot_provider_url")
try:
info = ytdlp.pot_provider_ping(url)
except Exception as exc:
return Check("pot provider", False, f"{url}/ping unreachable: {exc}")
server_version = str(info.get("version", "?"))
installed = ytdlp.plugin_version()
if installed and installed != server_version:
return Check(
"pot provider",
False,
f"version skew: server {server_version} vs plugin {installed}",
)
return Check("pot provider", True, f"up, version {server_version}")
def _database() -> Check:
try:
from . import db
conn = db.connect()
version = conn.execute("PRAGMA user_version").fetchone()[0]
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
conn.close()
except Exception as exc:
return Check("database", False, str(exc))
if str(mode).lower() != "wal":
return Check("database", False, f"journal_mode is {mode}, expected wal")
return Check("database", True, f"{config.DB_PATH} (schema v{version}, {mode})")
def _media_root() -> Check:
root = config.MEDIA_ROOT
if not root.is_dir():
return Check("media root", False, f"{root} does not exist")
if not os.access(root, os.W_OK | os.X_OK):
return Check("media root", False, f"{root} is not writable")
info = root.stat()
problems = []
try:
group = grp.getgrgid(info.st_gid).gr_name
except KeyError:
group = str(info.st_gid)
if group != MEDIA_GROUP:
problems.append(f"group is {group}, expected {MEDIA_GROUP}")
if not info.st_mode & stat.S_ISGID:
problems.append("setgid bit not set (new dirs won't inherit the group)")
if problems:
return Check("media root", False, f"{root}: " + "; ".join(problems))
return Check("media root", True, f"{root} ({group}, setgid)")
def _work_dir() -> Check:
work = config.WORK_DIR
if not work.is_dir():
return Check("work dir", False, f"{work} does not exist")
if work.stat().st_dev != config.MEDIA_ROOT.stat().st_dev:
return Check(
"work dir",
False,
"not on the same filesystem as the media root — moves would be copies",
)
if not (work / ".ignore").exists():
return Check("work dir", False, f"{work}/.ignore missing", fatal=False)
return Check("work dir", True, f"{work} (same fs, .ignore present)")
def _jellyfin(settings: Settings) -> Check:
client = jellyfin.from_settings(settings)
if not client.base_url:
return Check("jellyfin", False, "jellyfin_url not set", fatal=False)
try:
info = client.public_info()
except jellyfin.JellyfinError as exc:
return Check("jellyfin", False, str(exc))
label = f"{info.get('ServerName', '?')} {info.get('Version', '?')}"
if not client.api_key:
return Check(
"jellyfin",
False,
f"{label} reachable but no API key set (run set-jellyfin-key)",
fatal=False,
)
try:
library = client.find_library(config.MEDIA_ROOT)
except jellyfin.JellyfinError as exc:
return Check("jellyfin", False, f"API key rejected: {exc}")
if library is None:
return Check(
"jellyfin",
False,
f"{label}, no library for {config.MEDIA_ROOT} (run setup-jellyfin-library)",
fatal=False,
)
return Check("jellyfin", True, f"{label}, library '{library.get('Name')}'")
def run_checks(settings: Settings) -> list[Check]:
return [
_ytdlp(),
_ejs(),
_deno(),
_pot(settings),
_database(),
_media_root(),
_work_dir(),
_jellyfin(settings),
]
def report(checks: list[Check]) -> tuple[str, int]:
"""Render the checks and return (text, exit_code)."""
lines = []
failed_fatal = 0
for check in checks:
if check.ok:
mark = "ok "
elif check.fatal:
mark = "FAIL"
failed_fatal += 1
else:
mark = "warn"
lines.append(f" [{mark}] {check.name:<14} {check.detail}")
if failed_fatal:
lines.append(f"\n{failed_fatal} fatal problem(s).")
else:
lines.append("\nAll fatal checks passed.")
return "\n".join(lines), (1 if failed_fatal else 0)
+330
View File
@@ -0,0 +1,330 @@
"""The download worker.
One video at a time, into `.work/`, then everything moves into the season
directory with `os.rename()` — which is atomic because the work dir shares a
filesystem with the media root.
"""
from __future__ import annotations
import json
import logging
import shutil
import sqlite3
from pathlib import Path
from . import config, jellyfin, naming, nfo, videos, ytdlp
from .settings import Settings
log = logging.getLogger(__name__)
# yt-dlp exit code / message fragments that mean "the match filter rejected it",
# which is a decision rather than a failure.
_REJECT_MARKERS = (
"does not pass filter",
"skipping ..",
)
class DownloadOutcome:
QUEUED = "queued"
DONE = "downloaded"
SKIPPED_SHORT = videos.SKIPPED_SHORT
SKIPPED_LIVE = videos.SKIPPED_LIVE
DEFERRED = videos.DEFERRED
FAILED = videos.FAILED
def build_args(settings: Settings, video: sqlite3.Row) -> list[str]:
"""The yt-dlp invocation from specs.md §6."""
max_height = settings.get_int("max_height")
args = [
*ytdlp.extractor_args(settings.get_str("pot_provider_url")),
"-f",
f"bv*[height<={max_height}]+ba/b[height<={max_height}]",
# vcodec must outrank res: on hardware that cannot transcode, h264 at a
# lower resolution beats VP9 at 720p. And acodec must NOT outrank res,
# or `bv*` picks the combined 360p stream because it carries AAC.
"-S",
f"vcodec:h264,res:{max_height},acodec:aac",
"--merge-output-format",
"mp4",
"--no-playlist",
"--ignore-config",
"--write-info-json",
"--write-thumbnail",
"--convert-thumbnails",
"jpg",
"--retries",
"3",
"--fragment-retries",
"10",
"--sleep-requests",
"2",
"--sleep-interval",
"5",
"--max-sleep-interval",
"15",
"-P",
str(config.WORK_DIR),
"-o",
"%(id)s.%(ext)s",
]
if settings.get_bool("write_subs"):
args += [
"--write-subs",
"--write-auto-subs",
"--sub-langs",
settings.get_str("sub_langs"),
"--convert-subs",
"srt",
]
if settings.get_bool("sponsorblock_mark"):
args += ["--sponsorblock-mark", "all", "--embed-chapters"]
# The match filter only applies to rows the fallback feed produced. UULF has
# already excluded Shorts and livestreams for everything else.
if video["discovery_source"] == videos.SOURCE_UC:
minimum = settings.get_int("min_duration_seconds")
args += [
"--match-filter",
f"duration>?{minimum} & live_status!=?is_live "
f"& live_status!=?is_upcoming & !was_live",
]
args.append(f"https://www.youtube.com/watch?v={video['video_id']}")
return args
def _work_files(video_id: str) -> list[Path]:
return sorted(config.WORK_DIR.glob(f"{video_id}.*"))
def cleanup_work(video_id: str) -> None:
for path in _work_files(video_id):
try:
path.unlink()
except OSError as exc: # pragma: no cover - unusual fs state
log.warning("could not remove %s: %s", path, exc)
def _classify_rejection(info: dict | None, stdout: str, stderr: str) -> str | None:
"""Decide why the match filter rejected a video, if it did."""
blob = f"{stdout}\n{stderr}".lower()
if not any(marker in blob for marker in _REJECT_MARKERS):
return None
live_status = (info or {}).get("live_status")
if live_status == "is_upcoming":
return DownloadOutcome.DEFERRED
if live_status in ("is_live", "was_live") or (info or {}).get("was_live"):
return DownloadOutcome.SKIPPED_LIVE
# Duration is the only other condition in our filter.
return DownloadOutcome.SKIPPED_SHORT
def _read_info_json(video_id: str) -> dict | None:
path = config.WORK_DIR / f"{video_id}.info.json"
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
log.warning("unreadable info.json for %s: %s", video_id, exc)
return None
def _choose_subtitle(video_id: str) -> Path | None:
"""`--sub-langs en.*` matches both `en` and `en-orig`, yielding two identical
English tracks. Prefer `.en.srt`; otherwise promote `en-orig`."""
preferred = config.WORK_DIR / f"{video_id}.en.srt"
if preferred.exists():
return preferred
candidates = sorted(config.WORK_DIR.glob(f"{video_id}.en*.srt"))
return candidates[0] if candidates else None
def _move_into_place(
conn: sqlite3.Connection, video: sqlite3.Row, info: dict
) -> tuple[str, int]:
"""Rename every artefact into the season directory. Returns (rel_path, size)."""
upload_date = naming.parse_upload_date(
info.get("upload_date") or video["upload_date"]
)
season, episode = videos.next_episode(
conn, video["channel_pk"], upload_date, video["video_id"]
)
title = (info.get("title") or video["title"] or video["video_id"]).strip()
stem = naming.basename(
video["dir_name"], season, episode, title, video["video_id"]
)
season_dir = config.MEDIA_ROOT / video["dir_name"] / naming.season_dir_name(season)
season_dir.mkdir(parents=True, exist_ok=True)
media_source = config.WORK_DIR / f"{video['video_id']}.mp4"
if not media_source.exists():
raise FileNotFoundError(f"no mp4 produced for {video['video_id']}")
# The episode NFO is generated into the work dir first so that a failure
# here never leaves a half-populated season directory.
nfo.write(
config.WORK_DIR / f"{video['video_id']}.nfo",
nfo.episode_nfo(
title=title,
show_title=video["channel_title"],
season=season,
episode=episode,
plot=info.get("description"),
aired=upload_date.isoformat(),
duration_seconds=info.get("duration"),
video_id=video["video_id"],
),
)
moves: list[tuple[Path, Path]] = [(media_source, season_dir / f"{stem}.mp4")]
for suffix, target in (
(".nfo", f"{stem}.nfo"),
(".info.json", f"{stem}.info.json"),
(".jpg", f"{stem}-thumb.jpg"),
):
source = config.WORK_DIR / f"{video['video_id']}{suffix}"
if source.exists():
moves.append((source, season_dir / target))
subtitle = _choose_subtitle(video["video_id"])
if subtitle:
moves.append((subtitle, season_dir / f"{stem}.en.srt"))
for source, destination in moves:
source.replace(destination) # same filesystem: atomic rename
size = (season_dir / f"{stem}.mp4").stat().st_size
rel_path = str(
(season_dir / f"{stem}.mp4").relative_to(config.MEDIA_ROOT)
)
videos.mark_downloaded(
conn,
video["video_id"],
rel_path=rel_path,
size_bytes=size,
season=season,
episode=episode,
upload_date=upload_date.isoformat(),
duration=info.get("duration"),
title=title,
)
return rel_path, size
def download_one(conn: sqlite3.Connection, settings: Settings, video: sqlite3.Row) -> str:
"""Download a single video. Returns the resulting state."""
video_id = video["video_id"]
config.WORK_DIR.mkdir(parents=True, exist_ok=True)
cleanup_work(video_id)
videos.set_state(conn, video_id, videos.DOWNLOADING)
result = ytdlp.run(build_args(settings, video))
info = _read_info_json(video_id)
rejection = _classify_rejection(info, result.stdout, result.stderr)
if rejection is not None:
cleanup_work(video_id)
videos.set_state(conn, video_id, rejection)
log.info("%s rejected by match filter -> %s", video_id, rejection)
return rejection
if result.returncode != 0:
error = ytdlp.first_error(result.stderr) or f"exit {result.returncode}"
cleanup_work(video_id)
outcome = videos.record_failure(
conn, video_id, error, settings.get_int("max_attempts")
)
log.error("%s failed: %s (%s)", video_id, error, outcome)
return videos.FAILED
if info is None:
cleanup_work(video_id)
videos.record_failure(
conn, video_id, "no info.json produced", settings.get_int("max_attempts")
)
return videos.FAILED
try:
rel_path, size = _move_into_place(conn, video, info)
except Exception as exc: # noqa: BLE001 - any failure must clean up
cleanup_work(video_id)
videos.record_failure(
conn, video_id, str(exc), settings.get_int("max_attempts")
)
log.exception("moving %s into place failed", video_id)
return videos.FAILED
cleanup_work(video_id)
_warn_if_not_h264(video_id, info)
log.info("downloaded %s -> %s (%.1f MB)", video_id, rel_path, size / 1e6)
return videos.DOWNLOADED
def _warn_if_not_h264(video_id: str, info: dict) -> None:
"""§6: log the cases where no h264 rendition existed, since Jellyfin will
have to transcode them and this box cannot."""
vcodec = str(info.get("vcodec") or "")
acodec = str(info.get("acodec") or "")
if vcodec and not vcodec.startswith(("avc1", "h264")):
log.warning("%s has no h264 rendition (got %s) — will transcode", video_id, vcodec)
if acodec and not acodec.startswith(("mp4a", "aac")):
log.warning("%s has no aac audio (got %s) — will transcode", video_id, acodec)
def drain(
conn: sqlite3.Connection, settings: Settings, limit: int | None = None
) -> dict:
"""Work the queue, one video at a time. Returns per-outcome counts."""
if not _provider_healthy(settings):
raise RuntimeError(
"PO token provider is not reachable — refusing to download and "
"accumulate 403s. Check the bgutil-provider container."
)
counts: dict[str, int] = {}
queue = videos.claim_pending(conn, settings.get_int("max_attempts"), limit)
log.info("%d video(s) in the queue", len(queue))
for video in queue:
state = download_one(conn, settings, video)
counts[state] = counts.get(state, 0) + 1
if counts.get(videos.DOWNLOADED):
jellyfin.from_settings(settings).refresh()
return counts
def _provider_healthy(settings: Settings) -> bool:
try:
ytdlp.pot_provider_ping(settings.get_str("pot_provider_url"))
return True
except Exception as exc: # noqa: BLE001
log.error("POT provider health check failed: %s", exc)
return False
def recover_orphans() -> int:
"""Remove anything left in the work dir by a killed run."""
if not config.WORK_DIR.is_dir():
return 0
removed = 0
for path in config.WORK_DIR.iterdir():
if path.name == ".ignore":
continue
try:
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
else:
path.unlink()
removed += 1
except OSError: # pragma: no cover
pass
return removed
+134
View File
@@ -0,0 +1,134 @@
"""Minimal Jellyfin API client.
Only three things are needed: check the server is alive, create the Shows library
with internet metadata providers switched off, and trigger a refresh after we
change the tree.
"""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from . import config
log = logging.getLogger(__name__)
LIBRARY_NAME = "YouTube"
COLLECTION_TYPE = "tvshows"
# Metadata is supplied entirely by our own NFO sidecars, so every fetcher is
# disabled for all three item types a Shows library resolves.
_ITEM_TYPES = ("Series", "Season", "Episode")
class JellyfinError(RuntimeError):
pass
class Jellyfin:
def __init__(self, base_url: str, api_key: str = "", timeout: float = 30.0):
self.base_url = (base_url or "").rstrip("/")
self.api_key = api_key or ""
self.timeout = timeout
@property
def configured(self) -> bool:
return bool(self.base_url and self.api_key)
def _request(
self,
method: str,
path: str,
params: dict | None = None,
body: dict | None = None,
):
url = self.base_url + path
if params:
url += "?" + urllib.parse.urlencode(params)
data = None
headers = {"User-Agent": config.USER_AGENT, "Accept": "application/json"}
if self.api_key:
headers["X-Emby-Token"] = self.api_key
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
payload = response.read()
except urllib.error.HTTPError as exc:
raise JellyfinError(f"{method} {path} -> HTTP {exc.code}") from exc
except OSError as exc:
raise JellyfinError(f"{method} {path} -> {exc}") from exc
if not payload:
return None
try:
return json.loads(payload)
except json.JSONDecodeError:
return None
def public_info(self) -> dict:
"""Unauthenticated liveness check."""
return self._request("GET", "/System/Info/Public") or {}
def virtual_folders(self) -> list[dict]:
return self._request("GET", "/Library/VirtualFolders") or []
def find_library(self, path: Path | str) -> dict | None:
target = str(path).rstrip("/")
for folder in self.virtual_folders():
for location in folder.get("Locations") or []:
if str(location).rstrip("/") == target:
return folder
return None
def create_library(self, path: Path | str, name: str = LIBRARY_NAME) -> None:
"""Create the Shows library with all internet providers disabled."""
options = {
"EnableInternetProviders": False,
"SaveLocalMetadata": True,
"EnableRealtimeMonitor": False,
"EnableChapterImageExtraction": False,
"PathInfos": [{"Path": str(path)}],
"TypeOptions": [
{
"Type": item_type,
"MetadataFetchers": [],
"MetadataFetcherOrder": [],
"ImageFetchers": [],
"ImageFetcherOrder": [],
}
for item_type in _ITEM_TYPES
],
}
self._request(
"POST",
"/Library/VirtualFolders",
params={
"name": name,
"collectionType": COLLECTION_TYPE,
"paths": str(path),
"refreshLibrary": "false",
},
body={"LibraryOptions": options},
)
def refresh(self) -> None:
"""Trigger a library scan. Best effort — never fatal to the caller."""
try:
self._request("POST", "/Library/Refresh")
except JellyfinError as exc:
log.warning("jellyfin refresh failed: %s", exc)
def from_settings(settings) -> Jellyfin:
return Jellyfin(
settings.get_str("jellyfin_url"), settings.get_str("jellyfin_api_key")
)
+104
View File
@@ -0,0 +1,104 @@
"""Filename sanitisation and season/episode numbering.
Season is the upload year; episode is ``MMDD * 10 + ordinal_within_day``. That
scheme sorts correctly across a whole year (1 Jan is 1010, 31 Dec is 12310) and
leaves room for ten uploads per channel per day.
"""
from __future__ import annotations
import logging
import re
from datetime import date
log = logging.getLogger(__name__)
# Characters that are illegal or awkward in filenames on the platforms Jellyfin
# clients run on. Replaced with a space rather than deleted so that "A/B" reads
# as "A B" instead of collapsing into "AB".
FORBIDDEN = '/\\:*?"<>|'
MAX_TITLE_LEN = 120
MAX_ORDINAL = 9
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
_WHITESPACE = re.compile(r"\s+")
def sanitize_component(text: str, max_len: int = MAX_TITLE_LEN) -> str:
"""Make one path component safe, collapsing whitespace and truncating."""
text = _CONTROL.sub(" ", text or "")
text = "".join(" " if char in FORBIDDEN else char for char in text)
text = _WHITESPACE.sub(" ", text).strip()
text = truncate_on_word_boundary(text, max_len)
# A component may not begin or end with a dot or space: leading dots hide the
# file from Jellyfin, trailing ones confuse some clients.
text = text.strip(" .")
return text
def truncate_on_word_boundary(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
cut = text[:max_len]
space = cut.rfind(" ")
# Only honour the word boundary if it doesn't throw away most of the name.
if space > max_len * 0.6:
cut = cut[:space]
return cut.rstrip()
def channel_dir_name(title: str, channel_id: str) -> str:
"""Directory name for a channel. Stored once and never recomputed."""
name = sanitize_component(title)
return name or channel_id
def parse_upload_date(value: str | date) -> date:
"""Accept yt-dlp's YYYYMMDD, ISO YYYY-MM-DD, or a date."""
if isinstance(value, date):
return value
text = str(value).strip()
if len(text) == 8 and text.isdigit():
return date(int(text[:4]), int(text[4:6]), int(text[6:8]))
return date.fromisoformat(text[:10])
def season_for(upload_date: date) -> int:
return upload_date.year
def episode_base(upload_date: date) -> int:
"""First episode number available on this date."""
return (upload_date.month * 100 + upload_date.day) * 10
def episode_number(upload_date: date, ordinal: int) -> int:
"""Episode number for the nth upload on a given date (n starting at 0)."""
if ordinal > MAX_ORDINAL:
log.warning(
"more than %d uploads on %s; clamping ordinal %d",
MAX_ORDINAL + 1,
upload_date.isoformat(),
ordinal,
)
return episode_base(upload_date) + min(max(ordinal, 0), MAX_ORDINAL)
def episode_range(upload_date: date) -> tuple[int, int]:
"""Inclusive (low, high) episode numbers belonging to this date."""
base = episode_base(upload_date)
return base, base + MAX_ORDINAL
def season_dir_name(season: int) -> str:
return f"Season {season}"
def basename(channel_dir: str, season: int, episode: int, title: str, video_id: str) -> str:
"""Filename stem shared by the media file and every sidecar.
The [video_id] suffix guarantees uniqueness regardless of title collisions.
"""
safe_title = sanitize_component(title) or video_id
return f"{channel_dir} - S{season}E{episode} - {safe_title} [{video_id}]"
+81
View File
@@ -0,0 +1,81 @@
"""Kodi-style NFO sidecars.
Video descriptions are hostile input — they contain ampersands, angle brackets,
emoji, ASCII art and control characters — so these are always built with
ElementTree's serialiser and never by string formatting.
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from pathlib import Path
# XML 1.0 forbids most control characters outright; ElementTree will happily
# serialise them and produce a document no parser will read back. Written as a
# raw string so `re` interprets the escapes, not Python.
_ILLEGAL_XML = re.compile(
r"[^\x09\x0a\x0d\x20-퟿-\U00010000-\U0010ffff]"
)
def clean_text(value: str | None) -> str:
return _ILLEGAL_XML.sub("", value or "")
def _child(parent: ET.Element, tag: str, text: str | None) -> ET.Element:
element = ET.SubElement(parent, tag)
element.text = clean_text(text)
return element
def _serialise(root: ET.Element) -> bytes:
ET.indent(root, space=" ")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def tvshow_nfo(title: str, plot: str | None, channel_id: str) -> bytes:
root = ET.Element("tvshow")
_child(root, "title", title)
_child(root, "plot", plot)
_child(root, "studio", "YouTube")
unique = _child(root, "uniqueid", channel_id)
unique.set("type", "youtube")
unique.set("default", "true")
return _serialise(root)
def episode_nfo(
*,
title: str,
show_title: str,
season: int,
episode: int,
plot: str | None,
aired: str,
duration_seconds: int | None,
video_id: str,
) -> bytes:
root = ET.Element("episodedetails")
_child(root, "title", title)
_child(root, "showtitle", show_title)
_child(root, "season", str(season))
_child(root, "episode", str(episode))
_child(root, "plot", plot)
_child(root, "aired", aired)
if duration_seconds:
# Kodi/Jellyfin expect <runtime> in whole minutes.
_child(root, "runtime", str(max(1, round(duration_seconds / 60))))
_child(root, "studio", "YouTube")
unique = _child(root, "uniqueid", video_id)
unique.set("type", "youtube")
unique.set("default", "true")
return _serialise(root)
def write(path: Path, payload: bytes) -> None:
"""Write atomically so a crash never leaves Jellyfin a half-written NFO."""
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + ".tmp")
temporary.write_bytes(payload)
temporary.replace(path)
+150
View File
@@ -0,0 +1,150 @@
"""Retention: delete videos that have aged out.
Reap is a purely local operation. Jellyfin watch-state protection was considered
and declined (specs.md §15), so nothing here needs the Jellyfin API except the
refresh at the end — and that is best effort.
"""
from __future__ import annotations
import logging
import sqlite3
from datetime import timedelta
from pathlib import Path
from . import config, jellyfin, util, videos
from .settings import Settings
log = logging.getLogger(__name__)
def effective_retention(settings: Settings, channel_override: int | None) -> int:
return int(channel_override) if channel_override else settings.get_int("retention_days")
def candidates(conn: sqlite3.Connection, settings: Settings) -> list[sqlite3.Row]:
"""Downloaded videos past their channel's effective retention window."""
rows = conn.execute(
"SELECT v.*, c.dir_name, c.retention_days AS channel_retention, "
"c.title AS channel_title "
"FROM video v JOIN channel c ON c.id = v.channel_pk "
"WHERE v.state = ? ORDER BY v.upload_date ASC",
(videos.DOWNLOADED,),
).fetchall()
today = util.today()
due = []
for row in rows:
days = effective_retention(settings, row["channel_retention"])
if row["upload_date"] and row["upload_date"] < (today - timedelta(days=days)).isoformat():
due.append(row)
return due
def _delete_artefacts(rel_path: str) -> int:
"""Remove the media file and every sidecar sharing its stem."""
media = config.MEDIA_ROOT / rel_path
season_dir = media.parent
stem = media.stem # includes the [videoid], so it cannot collide
removed = 0
if season_dir.is_dir():
for path in season_dir.iterdir():
if path.name.startswith(stem):
try:
path.unlink()
removed += 1
except OSError as exc:
log.warning("could not delete %s: %s", path, exc)
return removed
def _prune_empty_season(season_dir: Path) -> None:
"""Remove the season directory once nothing is left in it.
The channel directory is deliberately kept even when it holds no seasons:
it still carries tvshow.nfo and the artwork, and deleting it would make an
active subscription vanish from Jellyfin and come back later.
"""
if not season_dir.is_dir() or season_dir == config.MEDIA_ROOT:
return
try:
next(season_dir.iterdir())
except StopIteration:
try:
season_dir.rmdir()
log.info("pruned empty season directory %s", season_dir)
except OSError as exc: # pragma: no cover
log.warning("could not prune %s: %s", season_dir, exc)
except OSError: # pragma: no cover
pass
def delete_video(conn: sqlite3.Connection, video: sqlite3.Row) -> bool:
"""Delete one video's files and leave a tombstone row."""
rel_path = video["rel_path"]
if not rel_path:
videos.mark_deleted(conn, video["video_id"])
return False
season_dir = (config.MEDIA_ROOT / rel_path).parent
removed = _delete_artefacts(rel_path)
_prune_empty_season(season_dir)
videos.mark_deleted(conn, video["video_id"])
log.info(
"reaped %s (%s, uploaded %s): %d file(s)",
video["video_id"],
video["channel_title"],
video["upload_date"],
removed,
)
return True
def disk_cap_evictions(
conn: sqlite3.Connection, settings: Settings
) -> list[sqlite3.Row]:
"""Oldest-first list of videos to evict to get back under the cap."""
cap_gb = settings.get_int("disk_cap_gb")
if cap_gb <= 0:
return []
cap_bytes = cap_gb * 1024**3
rows = conn.execute(
"SELECT v.*, c.dir_name, c.title AS channel_title "
"FROM video v JOIN channel c ON c.id = v.channel_pk "
"WHERE v.state = ? ORDER BY v.upload_date ASC",
(videos.DOWNLOADED,),
).fetchall()
total = sum(row["size_bytes"] or 0 for row in rows)
if total <= cap_bytes:
return []
evict = []
for row in rows:
if total <= cap_bytes:
break
evict.append(row)
total -= row["size_bytes"] or 0
log.info("disk cap %d GB exceeded; evicting %d video(s)", cap_gb, len(evict))
return evict
def run(conn: sqlite3.Connection, settings: Settings) -> dict:
"""Age-out pass plus optional disk-cap eviction."""
deleted = 0
for video in candidates(conn, settings):
if delete_video(conn, video):
deleted += 1
evicted = 0
for video in disk_cap_evictions(conn, settings):
if delete_video(conn, video):
evicted += 1
if deleted or evicted:
# Without this, Jellyfin shows ghost episodes until its own scheduled scan.
jellyfin.from_settings(settings).refresh()
return {"deleted": deleted, "evicted": evicted}
+96
View File
@@ -0,0 +1,96 @@
"""`run` orchestration: poll, then download, then reap — under a lock."""
from __future__ import annotations
import contextlib
import fcntl
import logging
import sqlite3
from pathlib import Path
from . import config, discovery, download, reap, 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 large backfill 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 recover(conn: sqlite3.Connection) -> dict:
"""Undo the effects of a killed run before doing anything else."""
requeued = videos.recover_downloading(conn)
orphans = download.recover_orphans()
if requeued or orphans:
log.info(
"crash recovery: %d row(s) back to pending, %d orphan file(s) cleared",
requeued,
orphans,
)
return {"requeued": requeued, "orphans": orphans}
def run(
conn: sqlite3.Connection, settings: Settings, channel_pk: int | None = None
) -> dict:
"""One full cycle. Assumes the caller holds the lock."""
result = {"recovered": recover(conn)}
result["poll"] = discovery.poll_all(conn, settings, channel_pk)
try:
result["download"] = download.drain(conn, settings)
except RuntimeError as exc:
# The POT provider being down is loud and fatal for this run, but the
# poll results are still worth keeping.
log.error("%s", exc)
result["download"] = {"error": str(exc)}
return result
result["reap"] = reap.run(conn, settings)
settings.set("last_run_at", util.utcnow_iso())
return result
def summarise(result: dict) -> str:
poll = result.get("poll", {})
down = result.get("download", {})
reaped = result.get("reap", {})
parts = [
f"discovered={poll.get('queued', 0)}",
f"repaired={poll.get('repaired', 0)}",
f"poll_failures={poll.get('failed', 0)}",
f"downloaded={down.get(videos.DOWNLOADED, 0)}",
f"failed={down.get(videos.FAILED, 0)}",
f"skipped={down.get(videos.SKIPPED_SHORT, 0) + down.get(videos.SKIPPED_LIVE, 0)}",
f"deferred={down.get(videos.DEFERRED, 0)}",
f"reaped={reaped.get('deleted', 0)}",
f"evicted={reaped.get('evicted', 0)}",
]
if "error" in down:
parts.append(f"ERROR={down['error']}")
return " ".join(parts)
+144
View File
@@ -0,0 +1,144 @@
"""Typed settings accessors backed by the `setting` key/value table.
A missing key must never crash anything, so every read falls back to the default
and every malformed stored value falls back to the default too.
"""
from __future__ import annotations
import sqlite3
from urllib.parse import urlparse
DEFAULTS: dict[str, str] = {
"retention_days": "14",
"backfill_days": "7",
"max_height": "720",
"min_duration_seconds": "120",
"sponsorblock_mark": "true",
"write_subs": "true",
"sub_langs": "en.*",
"jellyfin_url": "http://127.0.0.1:8096",
"jellyfin_api_key": "",
"pot_provider_url": "http://127.0.0.1:4416",
"max_attempts": "5",
"disk_cap_gb": "0",
}
# Editable through the settings form. Everything else in the table is internal.
EDITABLE = tuple(DEFAULTS)
# Never rendered, never settable through the web form.
SECRET_KEYS = ("admin_password_hash", "session_secret")
# Shown as a masked value rather than plaintext.
MASKED_KEYS = ("jellyfin_api_key",)
_INT_KEYS = (
"retention_days",
"backfill_days",
"max_height",
"min_duration_seconds",
"max_attempts",
"disk_cap_gb",
)
_BOOL_KEYS = ("sponsorblock_mark", "write_subs")
_URL_KEYS = ("jellyfin_url", "pot_provider_url")
_TRUE = {"1", "true", "yes", "on"}
_FALSE = {"0", "false", "no", "off", ""}
class Settings:
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
def raw(self, key: str) -> str:
row = self.conn.execute(
"SELECT value FROM setting WHERE key = ?", (key,)
).fetchone()
if row is None:
return DEFAULTS.get(key, "")
return row["value"]
def get_str(self, key: str) -> str:
return self.raw(key)
def get_int(self, key: str) -> int:
try:
return int(self.raw(key))
except (TypeError, ValueError):
return int(DEFAULTS.get(key, "0") or 0)
def get_bool(self, key: str) -> bool:
value = self.raw(key).strip().lower()
if value in _TRUE:
return True
if value in _FALSE:
return False
return DEFAULTS.get(key, "false").lower() in _TRUE
def set(self, key: str, value: str) -> None:
with self.conn:
self.conn.execute(
"INSERT INTO setting (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, str(value)),
)
def all_editable(self) -> dict[str, str]:
return {key: self.raw(key) for key in EDITABLE}
def validate(key: str, value: str) -> tuple[bool, str]:
"""Validate one submitted setting.
Returns (ok, message). On failure the caller re-renders the form with the
message inline rather than raising.
"""
value = value.strip()
if key in _INT_KEYS:
try:
number = int(value)
except ValueError:
return False, "must be a whole number"
if number < 0:
return False, "must be zero or greater"
if key == "retention_days" and number < 1:
return False, "must be at least 1 day"
if key == "max_height" and number < 144:
return False, "must be at least 144"
return True, ""
if key in _BOOL_KEYS:
if value.lower() not in _TRUE | _FALSE:
return False, "must be true or false"
return True, ""
if key in _URL_KEYS:
parsed = urlparse(value)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return False, "must be a http:// or https:// URL"
return True, ""
if key == "sub_langs":
if not value:
return False, "must not be empty"
return True, ""
if key == "jellyfin_api_key":
return True, ""
return key in DEFAULTS, "unknown setting"
def validate_all(submitted: dict[str, str]) -> dict[str, str]:
"""Return {key: error} for everything that failed validation."""
errors: dict[str, str] = {}
for key, value in submitted.items():
if key not in EDITABLE:
continue
ok, message = validate(key, value)
if not ok:
errors[key] = message
return errors
+45
View File
@@ -0,0 +1,45 @@
"""Small shared helpers."""
from __future__ import annotations
import logging
import os
from datetime import date, datetime, timezone
from . import config
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def utcnow_iso() -> str:
return utcnow().replace(microsecond=0).isoformat()
def today() -> date:
return utcnow().date()
def apply_umask() -> None:
"""Ensure files land group-writable so Jellyfin's group can read them."""
os.umask(config.UMASK)
def setup_logging(verbose: bool = False) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# yt-dlp and urllib are noisy at debug level and we drive them deliberately.
logging.getLogger("urllib3").setLevel(logging.WARNING)
def human_bytes(value: int | None) -> str:
size = float(value or 0)
for unit in ("B", "KB", "MB", "GB", "TB"):
if size < 1024 or unit == "TB":
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"
+209
View File
@@ -0,0 +1,209 @@
"""Video row helpers and the state machine.
States (specs.md §8):
pending discovered, queued
downloading claimed by a worker; recovered to pending on startup
downloaded on disk, rel_path set
deleted aged out — tombstone, never re-downloaded
deferred premiere/upcoming, retried by later polls
skipped_short below min_duration_seconds; repaired if later seen in UULF
skipped_live livestream, never retried
skipped_old already outside the window when discovered
failed download error, retried up to max_attempts
"""
from __future__ import annotations
import sqlite3
from datetime import date
from . import naming, util
PENDING = "pending"
DOWNLOADING = "downloading"
DOWNLOADED = "downloaded"
DELETED = "deleted"
DEFERRED = "deferred"
SKIPPED_SHORT = "skipped_short"
SKIPPED_LIVE = "skipped_live"
SKIPPED_OLD = "skipped_old"
FAILED = "failed"
# States that mean "we have made a final negative decision about this video".
# A deleted row is a tombstone and must never be resurrected by any code path.
TERMINAL = (DELETED, SKIPPED_LIVE, SKIPPED_OLD)
SOURCE_UULF = "uulf_feed"
SOURCE_UC = "uc_feed"
SOURCE_BACKFILL = "backfill"
def get(conn: sqlite3.Connection, video_id: str) -> sqlite3.Row | None:
return conn.execute(
"SELECT * FROM video WHERE video_id = ?", (video_id,)
).fetchone()
def exists(conn: sqlite3.Connection, video_id: str) -> bool:
return get(conn, video_id) is not None
def insert(
conn: sqlite3.Connection,
*,
channel_pk: int,
video_id: str,
title: str,
upload_date: str | None,
state: str,
discovery_source: str,
duration: int | None = None,
) -> None:
with conn:
conn.execute(
"INSERT OR IGNORE INTO video "
"(video_id, channel_pk, title, upload_date, duration, state, "
" discovery_source, discovered_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
video_id,
channel_pk,
title,
upload_date,
duration,
state,
discovery_source,
util.utcnow_iso(),
),
)
def set_state(
conn: sqlite3.Connection, video_id: str, state: str, *, error: str | None = None
) -> None:
with conn:
conn.execute(
"UPDATE video SET state = ?, last_error = ? WHERE video_id = ?",
(state, error, video_id),
)
def record_failure(conn: sqlite3.Connection, video_id: str, error: str, max_attempts: int) -> str:
"""Bump attempts and decide whether to keep retrying."""
with conn:
conn.execute(
"UPDATE video SET attempts = attempts + 1, last_error = ? WHERE video_id = ?",
(error[:500], video_id),
)
row = conn.execute(
"SELECT attempts FROM video WHERE video_id = ?", (video_id,)
).fetchone()
attempts = row["attempts"] if row else max_attempts
state = FAILED
conn.execute(
"UPDATE video SET state = ? WHERE video_id = ?", (state, video_id)
)
return "exhausted" if attempts >= max_attempts else state
def mark_downloaded(
conn: sqlite3.Connection,
video_id: str,
*,
rel_path: str,
size_bytes: int,
season: int,
episode: int,
upload_date: str,
duration: int | None,
title: str,
) -> None:
with conn:
conn.execute(
"UPDATE video SET state = ?, rel_path = ?, size_bytes = ?, season = ?, "
"episode = ?, upload_date = ?, duration = ?, title = ?, "
"downloaded_at = ?, last_error = NULL WHERE video_id = ?",
(
DOWNLOADED,
rel_path,
size_bytes,
season,
episode,
upload_date,
duration,
title,
util.utcnow_iso(),
video_id,
),
)
def mark_deleted(conn: sqlite3.Connection, video_id: str) -> None:
"""Keep the row — it is the tombstone that prevents re-download."""
with conn:
conn.execute(
"UPDATE video SET state = ?, rel_path = NULL, size_bytes = NULL, "
"deleted_at = ? WHERE video_id = ?",
(DELETED, util.utcnow_iso(), video_id),
)
def next_episode(
conn: sqlite3.Connection, channel_pk: int, upload_date: date, video_id: str
) -> tuple[int, int]:
"""Assign (season, episode) for a video.
The ordinal is computed against what is already in the database for this
channel and date — never against the current batch — so it stays stable
across runs and across crashes mid-batch.
"""
season = naming.season_for(upload_date)
low, high = naming.episode_range(upload_date)
row = conn.execute(
"SELECT MAX(episode) AS top FROM video "
"WHERE channel_pk = ? AND season = ? AND episode BETWEEN ? AND ? "
"AND video_id != ?",
(channel_pk, season, low, high, video_id),
).fetchone()
top = row["top"] if row and row["top"] is not None else None
if top is None:
return season, low
if top >= high:
# More than ten uploads in a day; naming.episode_number logs the clamp.
return season, high
return season, top + 1
def claim_pending(
conn: sqlite3.Connection, max_attempts: int, limit: int | None = None
) -> list[sqlite3.Row]:
"""Queue: pending rows, plus failed rows that still have attempts left."""
sql = (
"SELECT v.*, c.dir_name, c.title AS channel_title, c.channel_id "
"FROM video v JOIN channel c ON c.id = v.channel_pk "
"WHERE (v.state = ? OR (v.state = ? AND v.attempts < ?)) "
"ORDER BY v.upload_date ASC, v.discovered_at ASC"
)
params: list = [PENDING, FAILED, max_attempts]
if limit:
sql += " LIMIT ?"
params.append(limit)
return conn.execute(sql, params).fetchall()
def recover_downloading(conn: sqlite3.Connection) -> int:
"""Crash recovery: anything left claimed goes back on the queue."""
with conn:
cursor = conn.execute(
"UPDATE video SET state = ? WHERE state = ?", (PENDING, DOWNLOADING)
)
return cursor.rowcount
def queue_depth(conn: sqlite3.Connection) -> int:
return conn.execute(
"SELECT COUNT(*) FROM video WHERE state IN (?, ?, ?)",
(PENDING, FAILED, DOWNLOADING),
).fetchone()[0]
+1
View File
@@ -0,0 +1 @@
"""Admin web UI."""
+173
View File
@@ -0,0 +1,173 @@
"""Password hashing, session cookies and CSRF tokens.
The admin UI is publicly reachable over HTTPS, so this has to be real. The design
goal from specs.md §11 is "log in once per device, effectively never again",
which means a long-lived signed cookie rather than HTTP basic auth.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import time
SCRYPT_N = 2**14
SCRYPT_R = 8
SCRYPT_P = 1
DKLEN = 32
SESSION_MAX_AGE = 365 * 24 * 3600 # one year
COOKIE_NAME = "yta_session"
# Login throttling: after this many consecutive failures from one address, refuse
# for LOCKOUT_SECONDS regardless of whether the password is right.
MAX_FAILURES = 5
LOCKOUT_SECONDS = 60
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _unb64(text: str) -> bytes:
padding = "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(text + padding)
# --------------------------------------------------------------------------
# passwords
def hash_password(password: str, *, salt: bytes | None = None) -> str:
salt = salt if salt is not None else secrets.token_bytes(16)
derived = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=SCRYPT_N,
r=SCRYPT_R,
p=SCRYPT_P,
dklen=DKLEN,
)
return f"scrypt${SCRYPT_N}${SCRYPT_R}${SCRYPT_P}${_b64(salt)}${_b64(derived)}"
def verify_password(stored: str, password: str) -> bool:
if not stored:
return False
try:
scheme, n, r, p, salt_b64, hash_b64 = stored.split("$")
if scheme != "scrypt":
return False
derived = hashlib.scrypt(
password.encode("utf-8"),
salt=_unb64(salt_b64),
n=int(n),
r=int(r),
p=int(p),
dklen=len(_unb64(hash_b64)),
)
except (ValueError, TypeError):
return False
return hmac.compare_digest(derived, _unb64(hash_b64))
# --------------------------------------------------------------------------
# sessions
def new_secret() -> str:
return _b64(secrets.token_bytes(32))
def _sign(secret: str, payload: bytes) -> str:
return _b64(hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).digest())
def issue_session(secret: str, *, issued_at: float | None = None) -> str:
payload = json.dumps(
{"iat": int(issued_at if issued_at is not None else time.time())},
separators=(",", ":"),
).encode("utf-8")
return f"{_b64(payload)}.{_sign(secret, payload)}"
def verify_session(secret: str, token: str, *, now: float | None = None) -> bool:
if not token or not secret:
return False
try:
payload_b64, signature = token.split(".", 1)
payload = _unb64(payload_b64)
except (ValueError, TypeError):
return False
if not hmac.compare_digest(_sign(secret, payload), signature):
return False
try:
issued_at = int(json.loads(payload)["iat"])
except (ValueError, KeyError, TypeError):
return False
age = (now if now is not None else time.time()) - issued_at
return 0 <= age <= SESSION_MAX_AGE
def cookie_header(token: str, *, secure: bool = True) -> str:
parts = [
f"{COOKIE_NAME}={token}",
"Path=/",
"HttpOnly",
"SameSite=Lax",
f"Max-Age={SESSION_MAX_AGE}",
]
if secure:
parts.insert(2, "Secure")
return "; ".join(parts)
def clear_cookie_header() -> str:
return f"{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
# --------------------------------------------------------------------------
# CSRF
def csrf_token(secret: str, session_token: str) -> str:
return _sign(secret, b"csrf:" + session_token.encode("utf-8"))
def verify_csrf(secret: str, session_token: str, submitted: str) -> bool:
if not submitted:
return False
return hmac.compare_digest(csrf_token(secret, session_token), submitted)
# --------------------------------------------------------------------------
# throttling
class LoginThrottle:
"""In-memory consecutive-failure tracker keyed by remote address."""
def __init__(self, max_failures: int = MAX_FAILURES, lockout: int = LOCKOUT_SECONDS):
self.max_failures = max_failures
self.lockout = lockout
self._state: dict[str, tuple[int, float]] = {}
def locked(self, key: str, *, now: float | None = None) -> bool:
failures, last = self._state.get(key, (0, 0.0))
if failures < self.max_failures:
return False
elapsed = (now if now is not None else time.time()) - last
if elapsed >= self.lockout:
self._state.pop(key, None)
return False
return True
def record_failure(self, key: str, *, now: float | None = None) -> None:
failures, _ = self._state.get(key, (0, 0.0))
self._state[key] = (failures + 1, now if now is not None else time.time())
def record_success(self, key: str) -> None:
self._state.pop(key, None)
+400
View File
@@ -0,0 +1,400 @@
"""The admin HTTP server.
Stdlib only. Binds to localhost; nginx terminates TLS in front of it at
tube.jihakuz.xyz. Because that hostname is public, this carries real
authentication, CSRF tokens on every state-changing request, and login
throttling.
"""
from __future__ import annotations
import http.cookies
import json
import logging
import subprocess
import sys
import threading
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from .. import channels, config, db, discovery, jellyfin, util, videos, ytdlp
from ..settings import EDITABLE, MASKED_KEYS, Settings, validate_all
from . import auth, templates
log = logging.getLogger(__name__)
MAX_BODY = 64 * 1024
class AdminServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, address, handler, *, secure_cookies: bool = True):
super().__init__(address, handler)
self.throttle = auth.LoginThrottle()
self.secure_cookies = secure_cookies
self.db_lock = threading.Lock()
class Handler(BaseHTTPRequestHandler):
server_version = "youtube-automate"
protocol_version = "HTTP/1.1"
# ---------------------------------------------------------------- utils
def log_message(self, fmt, *args): # noqa: A003 - stdlib signature
log.debug("%s - %s", self.client_address[0], fmt % args)
def _client_key(self) -> str:
"""Real client address, since we always sit behind nginx."""
forwarded = self.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[0].strip()
return self.client_address[0]
def _send(self, status: int, body: bytes, headers: dict | None = None) -> None:
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "same-origin")
self.send_header("X-Frame-Options", "DENY")
for key, value in (headers or {}).items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(body)
def _redirect(self, location: str, headers: dict | None = None) -> None:
combined = {"Location": location}
combined.update(headers or {})
self._send(303, b"", combined)
def _json(self, status: int, payload: dict) -> None:
body = json.dumps(payload, indent=2).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _form(self) -> dict[str, str]:
length = int(self.headers.get("Content-Length") or 0)
if length <= 0 or length > MAX_BODY:
return {}
raw = self.rfile.read(length).decode("utf-8", "replace")
return {
key: values[-1]
for key, values in urllib.parse.parse_qs(raw, keep_blank_values=True).items()
}
def _cookie_token(self) -> str:
header = self.headers.get("Cookie")
if not header:
return ""
jar = http.cookies.SimpleCookie()
try:
jar.load(header)
except http.cookies.CookieError:
return ""
morsel = jar.get(auth.COOKIE_NAME)
return morsel.value if morsel else ""
# ------------------------------------------------------------- session
def _open(self):
conn = db.connect()
return conn, Settings(conn)
def _secret(self, settings: Settings) -> str:
secret = settings.raw("session_secret")
if not secret:
secret = auth.new_secret()
settings.set("session_secret", secret)
return secret
def _authenticated(self, settings: Settings) -> str | None:
"""Return the session token if the request is signed in, else None."""
token = self._cookie_token()
if token and auth.verify_session(self._secret(settings), token):
return token
return None
def _check_csrf(self, settings: Settings, token: str, form: dict) -> bool:
return auth.verify_csrf(self._secret(settings), token, form.get("csrf", ""))
# ---------------------------------------------------------------- GET
def do_GET(self) -> None: # noqa: N802 - stdlib signature
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
conn, settings = self._open()
try:
token = self._authenticated(settings)
# /health was specced as unauthenticated back when the UI was going
# to be tailnet-only. On a public hostname it is gratuitous
# fingerprinting surface (yt-dlp version, queue depth), so it needs
# a session like everything else.
if path == "/health":
if not token:
return self._json(401, {"error": "authentication required"})
return self._health(conn, settings)
if path == "/login":
if token:
return self._redirect("/")
return self._send(200, templates.login_page(self._login_hint(settings)))
if not token:
return self._redirect("/login")
if path == "/":
return self._send(200, self._render_index(conn, settings, token))
return self._send(404, templates.page("Not found", "<h1>Not found</h1>"))
finally:
conn.close()
def _login_hint(self, settings: Settings) -> str | None:
if not settings.raw("admin_password_hash"):
return "No password is set yet. Run `youtube-automate set-password` on susan."
return None
def _health(self, conn, settings: Settings) -> None:
try:
ytdlp_version = ytdlp.version()
except Exception as exc: # noqa: BLE001
ytdlp_version = f"error: {exc}"
try:
ytdlp.pot_provider_ping(settings.get_str("pot_provider_url"))
pot_up = True
except Exception: # noqa: BLE001
pot_up = False
self._json(
200,
{
"yt_dlp_version": ytdlp_version,
"pot_provider_up": pot_up,
"last_run_at": settings.raw("last_run_at") or None,
"queue_depth": videos.queue_depth(conn),
"channels": len(channels.all_channels(conn)),
},
)
# --------------------------------------------------------------- POST
def do_POST(self) -> None: # noqa: N802 - stdlib signature
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
conn, settings = self._open()
try:
form = self._form()
if path == "/login":
return self._login(settings, form)
token = self._authenticated(settings)
if not token:
return self._redirect("/login")
if not self._check_csrf(settings, token, form):
log.warning("CSRF check failed for %s from %s", path, self._client_key())
return self._send(
400,
templates.page(
"Bad request",
"<h1>Bad request</h1><p>Invalid form token. "
'<a href="/">Go back</a> and try again.</p>',
),
)
if path == "/logout":
return self._redirect("/login", {"Set-Cookie": auth.clear_cookie_header()})
if path == "/channels":
return self._add_channel(conn, settings, token, form)
if path == "/settings":
return self._save_settings(conn, settings, token, form)
parts = path.strip("/").split("/")
if len(parts) == 3 and parts[0] == "channels" and parts[1].isdigit():
pk = int(parts[1])
if parts[2] == "delete":
return self._delete_channel(conn, settings, pk)
if parts[2] == "retention":
return self._set_retention(conn, pk, form)
if parts[2] == "rescan":
return self._rescan(conn, settings, pk)
return self._send(404, templates.page("Not found", "<h1>Not found</h1>"))
finally:
conn.close()
def _login(self, settings: Settings, form: dict) -> None:
key = self._client_key()
if self.server.throttle.locked(key):
return self._send(
429, templates.login_page("Too many attempts. Wait a minute.")
)
stored = settings.raw("admin_password_hash")
if stored and auth.verify_password(stored, form.get("password", "")):
self.server.throttle.record_success(key)
token = auth.issue_session(self._secret(settings))
return self._redirect(
"/",
{
"Set-Cookie": auth.cookie_header(
token, secure=self.server.secure_cookies
)
},
)
self.server.throttle.record_failure(key)
log.warning("failed login from %s", key)
return self._send(
401, templates.login_page(self._login_hint(settings) or "Wrong password.")
)
# ------------------------------------------------------------ actions
def _add_channel(self, conn, settings: Settings, token: str, form: dict) -> None:
url = (form.get("url") or "").strip()
try:
row = channels.subscribe(conn, settings, url)
except channels.ResolutionError as exc:
body = self._render_index(conn, settings, token, add_error=str(exc))
return self._send(400, body)
self._spawn_backfill(row["id"])
return self._redirect("/")
def _spawn_backfill(self, channel_pk: int) -> None:
"""Kick off discovery immediately rather than waiting for the hourly cron."""
try:
subprocess.Popen( # noqa: S603
[sys.executable, "-m", "youtube_automate", "run", "--channel",
str(channel_pk)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except OSError as exc: # pragma: no cover
log.warning("could not spawn backfill for channel %d: %s", channel_pk, exc)
def _delete_channel(self, conn, settings: Settings, pk: int) -> None:
try:
channels.unsubscribe(conn, pk)
except LookupError:
return self._send(404, templates.page("Not found", "<h1>Not found</h1>"))
jellyfin.from_settings(settings).refresh()
return self._redirect("/")
def _set_retention(self, conn, pk: int, form: dict) -> None:
raw = (form.get("days") or "").strip()
value: int | None
if not raw:
value = None
else:
try:
value = max(1, int(raw))
except ValueError:
return self._redirect("/")
with conn:
conn.execute(
"UPDATE channel SET retention_days = ? WHERE id = ?", (value, pk)
)
return self._redirect("/")
def _rescan(self, conn, settings: Settings, pk: int) -> None:
channel = channels.get(conn, pk)
if channel is None:
return self._send(404, templates.page("Not found", "<h1>Not found</h1>"))
discovery.rescan_channel(conn, settings, channel)
self._spawn_backfill(pk)
return self._redirect("/")
def _save_settings(self, conn, settings: Settings, token: str, form: dict) -> None:
submitted = {key: form.get(key, "") for key in EDITABLE if key in form}
# A blank masked field means "keep what is stored", not "clear it".
for key in MASKED_KEYS:
if key in submitted and not submitted[key].strip():
submitted.pop(key)
errors = validate_all(submitted)
if errors:
body = self._render_index(
conn, settings, token, settings_errors=errors, submitted=submitted
)
return self._send(400, body)
for key, value in submitted.items():
settings.set(key, value.strip())
return self._redirect("/")
# ------------------------------------------------------------- render
def _render_index(
self,
conn,
settings: Settings,
token: str,
*,
add_error: str | None = None,
settings_errors: dict | None = None,
submitted: dict | None = None,
) -> bytes:
global_retention = settings.get_int("retention_days")
rows = []
for channel in channels.all_channels(conn):
stats = conn.execute(
"SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS bytes, "
"MAX(upload_date) AS latest FROM video "
"WHERE channel_pk = ? AND state = ?",
(channel["id"], videos.DOWNLOADED),
).fetchone()
latest_any = conn.execute(
"SELECT MAX(upload_date) AS latest FROM video WHERE channel_pk = ?",
(channel["id"],),
).fetchone()
rows.append(
{
"id": channel["id"],
"title": channel["title"],
"handle": channel["handle"],
"channel_id": channel["channel_id"],
"retention_days": channel["retention_days"],
"global_retention": global_retention,
"last_polled_at": channel["last_polled_at"],
"last_poll_ok": channel["last_poll_ok"],
"consecutive_poll_failures": channel["consecutive_poll_failures"],
"downloaded": stats["n"],
"bytes": stats["bytes"],
"latest": latest_any["latest"],
}
)
values = settings.all_editable()
if submitted:
values.update(submitted)
return templates.index_page(
channels=rows,
settings_values=values,
settings_errors=settings_errors or {},
csrf=auth.csrf_token(self._secret(settings), token),
add_error=add_error,
queue_depth=videos.queue_depth(conn),
)
def serve(host: str = "127.0.0.1", port: int = 8085, *, secure_cookies: bool = True) -> None:
util.apply_umask()
server = AdminServer((host, port), Handler, secure_cookies=secure_cookies)
log.info("admin server listening on http://%s:%d", host, port)
try:
server.serve_forever()
except KeyboardInterrupt: # pragma: no cover
pass
finally:
server.server_close()
+246
View File
@@ -0,0 +1,246 @@
"""Server-rendered HTML. One embedded stylesheet, no JavaScript beyond a
confirm() on the destructive buttons."""
from __future__ import annotations
import html
from datetime import date
from .. import util
from ..settings import DEFAULTS, EDITABLE, MASKED_KEYS
STYLE = """
:root {
--bg: #14161a; --panel: #1c1f26; --line: #2c313b; --text: #e6e8ec;
--muted: #99a0ae; --accent: #6aa9ff; --warn: #ffb454; --bad: #ff6b6b;
--good: #6ade9b;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text);
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
main { max-width: 62rem; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
h1 { font-size: 1.4rem; margin: 0; }
h2 { font-size: 1.05rem; margin: 2rem 0 .75rem; color: var(--muted);
text-transform: uppercase; letter-spacing: .06em; }
header { display: flex; align-items: baseline; justify-content: space-between;
gap: 1rem; border-bottom: 1px solid var(--line); padding-bottom: .75rem; }
header .sub { color: var(--muted); font-size: .85rem; }
a { color: var(--accent); }
.panel { background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: 1rem; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: .55rem .5rem; border-bottom: 1px solid var(--line);
vertical-align: middle; }
th { color: var(--muted); font-weight: 600; font-size: .78rem;
text-transform: uppercase; letter-spacing: .05em; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.muted { color: var(--muted); }
.badge { display: inline-block; padding: .1rem .45rem; border-radius: 999px;
font-size: .75rem; border: 1px solid currentColor; }
.badge.warn { color: var(--warn); }
.badge.good { color: var(--good); }
.badge.bad { color: var(--bad); }
input[type=text], input[type=password], input[type=number], select {
background: #12141a; color: var(--text); border: 1px solid var(--line);
border-radius: 7px; padding: .45rem .55rem; font: inherit; width: 100%; }
button { background: var(--accent); color: #0b1017; border: 0; border-radius: 7px;
padding: .5rem .9rem; font: inherit; font-weight: 600; cursor: pointer; }
button.secondary { background: #2b3140; color: var(--text); }
button.danger { background: transparent; color: var(--bad);
border: 1px solid var(--bad); font-weight: 500; padding: .3rem .6rem; }
button.link { background: transparent; color: var(--accent); border: 0;
padding: .3rem .4rem; font-weight: 500; }
form.inline { display: inline; }
.row { display: flex; gap: .6rem; align-items: center; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: .85rem; }
label { display: block; font-size: .82rem; color: var(--muted);
margin-bottom: .25rem; }
.field { margin-bottom: .3rem; }
.error { color: var(--bad); font-size: .8rem; margin-top: .2rem; }
.flash { border-radius: 8px; padding: .6rem .8rem; margin-bottom: 1rem;
border: 1px solid; }
.flash.ok { color: var(--good); border-color: var(--good); }
.flash.bad { color: var(--bad); border-color: var(--bad); }
.login { max-width: 21rem; margin: 6rem auto; }
footer { margin-top: 2.5rem; color: var(--muted); font-size: .8rem; }
@media (max-width: 40rem) {
th.hide, td.hide { display: none; }
}
"""
def _e(value) -> str:
return html.escape("" if value is None else str(value), quote=True)
def page(title: str, body: str) -> bytes:
return f"""<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{_e(title)}</title>
<style>{STYLE}</style>
</head><body><main>{body}</main></body></html>""".encode("utf-8")
def login_page(error: str | None = None) -> bytes:
alert = f'<div class="flash bad">{_e(error)}</div>' if error else ""
body = f"""
<div class="login">
<h1>youtube-automate</h1>
<p class="muted">Sign in to manage subscriptions.</p>
{alert}
<form method="post" action="/login" class="panel">
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" autofocus
autocomplete="current-password">
</div>
<div style="margin-top:.8rem"><button type="submit">Sign in</button></div>
</form>
<footer>You will stay signed in on this device for a year.</footer>
</div>"""
return page("Sign in — youtube-automate", body)
def _channel_row(channel: dict, csrf: str) -> str:
failures = channel["consecutive_poll_failures"]
if failures > 2:
badge = f'<span class="badge bad">{failures} failed polls</span>'
elif channel["last_poll_ok"] == 0:
badge = '<span class="badge warn">last poll failed</span>'
else:
badge = ""
retention = channel["retention_days"]
retention_value = "" if retention is None else str(retention)
placeholder = f"default ({channel['global_retention']})"
return f"""
<tr>
<td>
<strong>{_e(channel['title'])}</strong> {badge}<br>
<span class="muted">{_e(channel['handle'] or channel['channel_id'])}</span>
</td>
<td class="num">{channel['downloaded']}</td>
<td class="num hide">{_e(util.human_bytes(channel['bytes']))}</td>
<td class="hide muted">{_e(channel['latest'] or '')}</td>
<td class="hide muted">{_e(channel['last_polled_at'] or 'never')}</td>
<td>
<form method="post" action="/channels/{channel['id']}/retention" class="row">
<input type="hidden" name="csrf" value="{_e(csrf)}">
<input type="number" name="days" min="1" style="width:6.5rem"
value="{_e(retention_value)}" placeholder="{_e(placeholder)}">
<button class="link" type="submit">save</button>
</form>
</td>
<td>
<form method="post" action="/channels/{channel['id']}/rescan" class="inline">
<input type="hidden" name="csrf" value="{_e(csrf)}">
<button class="link" type="submit"
title="Re-queue videos previously skipped as too old that the current
retention window now covers">rescan</button>
</form>
<form method="post" action="/channels/{channel['id']}/delete" class="inline"
onsubmit="return confirm('Permanently delete {_e(channel['title'])} and every video downloaded for it? This cannot be undone.');">
<input type="hidden" name="csrf" value="{_e(csrf)}">
<button class="danger" type="submit">remove</button>
</form>
</td>
</tr>"""
def _settings_form(values: dict, errors: dict, csrf: str) -> str:
fields = []
for key in EDITABLE:
value = values.get(key, DEFAULTS[key])
error = errors.get(key)
if key in MASKED_KEYS and value:
shown, placeholder = "", "stored — leave blank to keep"
else:
shown, placeholder = value, ""
input_type = "password" if key in MASKED_KEYS else "text"
fields.append(
f"""<div class="field">
<label for="{_e(key)}">{_e(key.replace('_', ' '))}</label>
<input type="{input_type}" id="{_e(key)}" name="{_e(key)}"
value="{_e(shown)}" placeholder="{_e(placeholder)}" autocomplete="off">
{f'<div class="error">{_e(error)}</div>' if error else ''}
</div>"""
)
return f"""
<form method="post" action="/settings" class="panel">
<input type="hidden" name="csrf" value="{_e(csrf)}">
<div class="grid">{''.join(fields)}</div>
<div style="margin-top:1rem"><button type="submit">Save settings</button></div>
</form>"""
def index_page(
*,
channels: list[dict],
settings_values: dict,
settings_errors: dict,
csrf: str,
flash: tuple[str, str] | None = None,
add_error: str | None = None,
queue_depth: int = 0,
) -> bytes:
flash_html = ""
if flash:
kind, message = flash
flash_html = f'<div class="flash {kind}">{_e(message)}</div>'
if channels:
rows = "".join(_channel_row(channel, csrf) for channel in channels)
table = f"""
<div class="panel">
<table>
<thead><tr>
<th>Channel</th><th class="num">On disk</th><th class="num hide">Size</th>
<th class="hide">Latest upload</th><th class="hide">Last poll</th>
<th>Retention (days)</th><th></th>
</tr></thead>
<tbody>{rows}</tbody>
</table>
</div>"""
else:
table = '<div class="panel muted">No channels yet. Add one below.</div>'
add_error_html = f'<div class="error">{_e(add_error)}</div>' if add_error else ""
body = f"""
<header>
<h1>youtube-automate</h1>
<div class="sub">
{len(channels)} channel(s) · {queue_depth} queued
· <form method="post" action="/logout" class="inline">
<input type="hidden" name="csrf" value="{_e(csrf)}">
<button class="link" type="submit">sign out</button>
</form>
</div>
</header>
{flash_html}
<h2>Channels</h2>
{table}
<h2>Add a channel</h2>
<form method="post" action="/channels" class="panel">
<input type="hidden" name="csrf" value="{_e(csrf)}">
<div class="row">
<input type="text" name="url" placeholder="https://www.youtube.com/@handle, @handle, or UC..." autocomplete="off">
<button type="submit">Add</button>
</div>
{add_error_html}
</form>
<h2>Settings</h2>
{_settings_form(settings_values, settings_errors, csrf)}
<footer>Downloads run hourly. Videos are deleted once they pass the retention
window for their channel — this is a DVR, not an archive.</footer>"""
return page("youtube-automate", body)
+109
View File
@@ -0,0 +1,109 @@
"""Thin wrapper around the venv's yt-dlp binary.
Everything that shells out to yt-dlp goes through here so the PATH handling (Deno
must be discoverable — see specs.md §3) and the shared extractor args live in one
place.
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import urllib.request
from pathlib import Path
from . import config
log = logging.getLogger(__name__)
class YtdlpError(RuntimeError):
pass
def binary() -> Path:
return config.VENV_BIN / "yt-dlp"
def environment() -> dict[str, str]:
"""Env for a yt-dlp subprocess.
yt-dlp locates the JS runtime by searching PATH, so the venv's bin directory
must come first — that is where Deno lives.
"""
env = dict(os.environ)
env["PATH"] = f"{config.VENV_BIN}:{env.get('PATH', '')}"
return env
def version() -> str:
result = subprocess.run(
[str(binary()), "--version"],
capture_output=True,
text=True,
env=environment(),
timeout=60,
)
if result.returncode != 0:
raise YtdlpError(result.stderr.strip() or "yt-dlp --version failed")
return result.stdout.strip()
def extractor_args(pot_provider_url: str) -> list[str]:
return [
"--extractor-args",
"youtube:player_client=default,mweb",
"--extractor-args",
f"youtubepot-bgutilhttp:base_url={pot_provider_url}",
]
def run_json(args: list[str], timeout: int = 300) -> dict:
"""Run yt-dlp with -J and parse the single JSON document it prints."""
cmd = [str(binary()), *args]
log.debug("yt-dlp %s", " ".join(args))
result = subprocess.run(
cmd, capture_output=True, text=True, env=environment(), timeout=timeout
)
if result.returncode != 0:
raise YtdlpError(first_error(result.stderr) or "yt-dlp failed")
try:
return json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise YtdlpError(f"could not parse yt-dlp output: {exc}") from exc
def run(args: list[str], timeout: int = 7200) -> subprocess.CompletedProcess:
"""Run yt-dlp for its side effects, returning the completed process."""
cmd = [str(binary()), *args]
log.debug("yt-dlp %s", " ".join(args))
return subprocess.run(
cmd, capture_output=True, text=True, env=environment(), timeout=timeout
)
def first_error(stderr: str) -> str:
for line in (stderr or "").splitlines():
if line.startswith("ERROR:"):
return line[len("ERROR:") :].strip()
return (stderr or "").strip().splitlines()[-1] if stderr.strip() else ""
def pot_provider_ping(base_url: str, timeout: float = 5.0) -> dict:
"""GET /ping on the bgutil provider. Raises on any failure."""
url = base_url.rstrip("/") + "/ping"
request = urllib.request.Request(url, headers={"User-Agent": config.USER_AGENT})
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def plugin_version() -> str | None:
"""Installed bgutil plugin version, for comparison against the server's."""
try:
from importlib.metadata import version as pkg_version
return pkg_version("bgutil-ytdlp-pot-provider")
except Exception: # pragma: no cover - only when the plugin is absent
return None