Files
youtube-automate/youtube_automate/videos.py
T
Tom FluxandClaude Opus 5 18bb2e420b 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>
2026-08-11 21:42:48 +01:00

210 lines
6.4 KiB
Python

"""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]