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>
247 lines
9.2 KiB
Python
247 lines
9.2 KiB
Python
"""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)
|