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>
364 lines
13 KiB
Python
364 lines
13 KiB
Python
"""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
|