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:
@@ -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
|
||||
Reference in New Issue
Block a user