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,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")
|
||||
)
|
||||
Reference in New Issue
Block a user