authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-11 20:23:55-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-11 23:03:39-07:00
logc6666b14acb9d62bb70b34275156feb553e400f8
tree47dffd99a28ca1a0c7679afcaf6532d9fca6f055
parentb5be7880a0bc4f37c5f32340a232891c36a08661
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: jellyfin youtube


10 files changed, 841 insertions(+), 5 deletions(-)

compose.yaml+49
...@@ -501,6 +501,55 @@ services:...@@ -501,6 +501,55 @@ services:
501 net.paperclover.list.domain: rdr501 net.paperclover.list.domain: rdr
502 net.paperclover.list.priority: 50502 net.paperclover.list.priority: 50
503 net.paperclover.list.access: media-manage503 net.paperclover.list.access: media-manage
504 # youtube archival (issue #6) — channel list in config/yt/subscriptions.yaml
505 ytdl-sub:
506 container_name: ytdl-sub
507 image: ghcr.io/jmbannon/ytdl-sub:latest
508 user: "$USER_ID:$GROUP_ID"
509 # bypass the image's s6 init (it only handles PUID/PGID, which user: covers)
510 # and run the subscription pass on a fixed cycle; the yaml stays read-only
511 # in this repo instead of living in the container's /config
512 entrypoint: ["/bin/sh", "-c"]
513 command: ["sh /config-yt/archive-loop.sh"]
514 environment:
515 HOME: /config
516 volumes:
517 - ./config/yt:/config-yt:ro
518 # the image hardcodes its lock file at /config, so state lives there
519 - "${APP_ROOT}/ytdl-sub:/config"
520 - "${MEDIA_ROOT}:/media"
521 restart: unless-stopped
522 labels:
523 net.paperclover.list.name: ytdl-sub YouTube Archiver
524 net.paperclover.list.web: "false"
525 # new-video triage (issue #6): polls channels in config/yt/feed.yaml, emails
526 # a notification per upload, and serves the review page on yt.* where each
527 # video is ingested into indie shows / independent / music — or skipped
528 yt-feed: # port 80
529 container_name: yt-feed
530 build:
531 context: config/yt-feed
532 dockerfile: Dockerfile
533 pull_policy: build
534 user: "$USER_ID:$GROUP_ID"
535 environment:
536 SMTP_HOST: "${MAILER_ADDRESS:?}"
537 SMTP_PORT: "465"
538 SMTP_USER: "${MAILER_USERNAME:?}"
539 SMTP_PASS: "${MAILER_PASSWORD:?}"
540 MAIL_FROM: "yt-feed@${HOME_DOMAIN:?}"
541 MAIL_TO: "${ADMIN_EMAIL:?}"
542 BASE_URL: "https://yt.${HOME_DOMAIN:?}"
543 volumes:
544 - ./config/yt:/config-yt:ro
545 - "${APP_ROOT}/yt-feed:/data"
546 - "${MEDIA_ROOT}:/media"
547 restart: unless-stopped
548 labels:
549 net.paperclover.list.name: YouTube Triage
550 net.paperclover.list.domain: yt
551 net.paperclover.list.priority: 53
552 net.paperclover.list.access: media-manage
504 # language models553 # language models
505 opencode: # port 4096554 opencode: # port 4096
506 container_name: opencode555 container_name: opencode
config/Caddyfile+3
...@@ -223,6 +223,9 @@ speedtest.{$HOME_DOMAIN} {...@@ -223,6 +223,9 @@ speedtest.{$HOME_DOMAIN} {
223sync.{$HOME_DOMAIN} {223sync.{$HOME_DOMAIN} {
224 import reverse_proxy_auth "http://syncthing:8384" admin224 import reverse_proxy_auth "http://syncthing:8384" admin
225}225}
226yt.{$HOME_DOMAIN} {
227 import reverse_proxy_auth "http://yt-feed" media-manage
228}
226xmpp.{$HOME_DOMAIN} {229xmpp.{$HOME_DOMAIN} {
227 tls {230 tls {
228 on_demand231 on_demand
config/copyparty.conf-5
...@@ -44,11 +44,6 @@...@@ -44,11 +44,6 @@
44 r.: *44 r.: *
45 rwm: @acct45 rwm: @acct
46 rwmd: snow46 rwmd: snow
47[/mirrors]
48 /w/mirrors
49 accs:
50 r.: *
51 rwmd: snow
52[/logs]47[/logs]
53 /w/logs48 /w/logs
54 accs:49 accs:
config/static/apps.html+3
...@@ -81,6 +81,9 @@ li::before {...@@ -81,6 +81,9 @@ li::before {
81 <li><a href="https://opencode.paperclover.net">opencode</a> <span class="domain">opencode.paperclover.net</span></li>81 <li><a href="https://opencode.paperclover.net">opencode</a> <span class="domain">opencode.paperclover.net</span></li>
82 {{ end }}82 {{ end }}
83 {{ if contains "role:media-manage" $groups }}83 {{ if contains "role:media-manage" $groups }}
84 <li><a href="https://yt.paperclover.net">youtube triage</a> <span class="domain">yt.paperclover.net</span></li>
85 {{ end }}
86 {{ if contains "role:media-manage" $groups }}
84 <li><a href="https://qbt.paperclover.net">qbittorrent</a> <span class="domain">qbt.paperclover.net</span></li>87 <li><a href="https://qbt.paperclover.net">qbittorrent</a> <span class="domain">qbt.paperclover.net</span></li>
85 {{ end }}88 {{ end }}
86 {{ if contains "role:media-manage" $groups }}89 {{ if contains "role:media-manage" $groups }}
config/yt-feed/Dockerfile created+9
...@@ -0,0 +1,9 @@
1# based on the ytdl-sub image so yt-dlp + ffmpeg stay maintained upstream —
2# "pull the image" fixes youtube breakage here the same as for the archiver
3FROM ghcr.io/jmbannon/ytdl-sub:latest
4# always take the newest yt-dlp at build time — the youtube bot-wall
5# cat-and-mouse moves faster than the base image
6RUN pip install --no-cache-dir -U yt-dlp && pip install --no-cache-dir flask pyyaml
7COPY app.py /app/app.py
8ENTRYPOINT []
9CMD ["python3", "-u", "/app/app.py"]
config/yt-feed/app.py created+645
...@@ -0,0 +1,645 @@
1#!/usr/bin/env python3
2# yt-triage: polls youtube rss for the channels in feed.yaml, queues new
3# uploads for manual review, emails a notification (thumbnail + link back
4# here), and serves the review page where each video is ingested into the
5# right library — an indie show season (proper S/E naming + episode nfo),
6# the flat Independent creators library, or music intake — or skipped.
7import json
8import os
9import queue
10import re
11import smtplib
12import subprocess
13import threading
14import time
15import urllib.request
16import xml.etree.ElementTree as ET
17from email.message import EmailMessage
18from email.utils import formatdate
19from xml.sax.saxutils import escape
20
21import yaml
22from flask import Flask, Response, redirect, request
23
24FEED_CONFIG = os.environ.get("FEED_CONFIG", "/config-yt/feed.yaml")
25STATE_DIR = os.environ.get("STATE_DIR", "/data")
26INTERVAL = int(os.environ.get("CHECK_INTERVAL", "1800"))
27SMTP_HOST = os.environ["SMTP_HOST"]
28SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
29SMTP_USER = os.environ["SMTP_USER"]
30SMTP_PASS = os.environ["SMTP_PASS"]
31MAIL_FROM = os.environ["MAIL_FROM"]
32MAIL_TO = os.environ["MAIL_TO"]
33BASE_URL = os.environ.get("BASE_URL", "").rstrip("/")
34
35INDIE_DIR = "/media/jellyfin/Indie Shows"
36INDEP_DIR = "/media/jellyfin/Independent"
37MUSIC_DIR = "/media/music_intake"
38VIDEO_EXTS = (".webm", ".mp4", ".mkv")
39
40ATOM = "{http://www.w3.org/2005/Atom}"
41YT = "{http://www.youtube.com/xml/schemas/2015}"
42MEDIA = "{http://search.yahoo.com/mrss/}"
43UA = {"User-Agent": "Mozilla/5.0 (yt-triage; +https://paperclover.net)"}
44SEEN_CAP = 300
45
46state_lock = threading.Lock()
47job_queue = queue.Queue()
48
49# youtube sometimes bot-walls the whole home ip after heavy traffic. when the
50# signature error appears, downloads pause and a cheap probe every few hours
51# resumes them once the wall lifts (rss polling is unaffected by walls).
52WALL_RE = re.compile(r"confirm you.re not a bot", re.I)
53WALL_PROBE_INTERVAL = int(os.environ.get("WALL_PROBE_INTERVAL", "10800"))
54PROBE_VIDEO = "https://www.youtube.com/watch?v=jNQXAC9IVRw"
55
56
57class WallError(Exception):
58 pass
59
60
61def wall_active():
62 return load_json("wall.json", {}).get("walled", False)
63
64
65def set_wall(walled):
66 with state_lock:
67 save_json("wall.json", {"walled": walled, "since": int(time.time())})
68 log(f"bot wall {'detected — downloads paused' if walled else 'lifted — downloads resumed'}")
69
70
71def wall_prober():
72 while True:
73 time.sleep(WALL_PROBE_INTERVAL if wall_active() else 600)
74 if not wall_active():
75 continue
76 probe = subprocess.run(
77 ["yt-dlp", "--simulate", "--print", "%(id)s", PROBE_VIDEO],
78 capture_output=True, text=True, timeout=120)
79 if probe.returncode == 0:
80 set_wall(False)
81 resolve_stragglers()
82 else:
83 log("wall probe: still walled")
84
85
86def resolve_stragglers():
87 with state_lock:
88 pending = load_json("pending.json", {})
89 for v in pending.values():
90 if v.get("unresolved"):
91 threading.Thread(target=resolve_and_update, args=(v["id"], v["link"]),
92 daemon=True).start()
93
94
95def log(msg):
96 print(msg, flush=True)
97
98
99def fetch(url):
100 req = urllib.request.Request(url, headers=UA)
101 with urllib.request.urlopen(req, timeout=30) as resp:
102 return resp.read().decode("utf-8", errors="replace")
103
104
105def state_path(name):
106 return os.path.join(STATE_DIR, name)
107
108
109def load_json(name, fallback):
110 try:
111 with open(state_path(name)) as f:
112 return json.load(f)
113 except (FileNotFoundError, json.JSONDecodeError):
114 return fallback
115
116
117def save_json(name, data):
118 tmp = state_path(name) + ".tmp"
119 with open(tmp, "w") as f:
120 json.dump(data, f, indent=1)
121 os.replace(tmp, state_path(name))
122
123
124def safe_name(name):
125 return re.sub(r'[/\\:*?"<>|]', "-", name).strip() or "untitled"
126
127
128# ---------------------------------------------------------------- feed poller
129
130def channel_id_for(url, cache):
131 if url in cache:
132 return cache[url]
133 m = re.search(r"/channel/(UC[0-9A-Za-z_-]{22})", url)
134 if not m:
135 html = fetch(url)
136 m = re.search(r"channel_id=(UC[0-9A-Za-z_-]{22})", html) or re.search(
137 r'"channelId":"(UC[0-9A-Za-z_-]{22})"', html
138 )
139 if not m:
140 raise ValueError(f"could not resolve channel id for {url}")
141 cache[url] = m.group(1)
142 return cache[url]
143
144
145def feed_entries(channel_id):
146 text = fetch(f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}")
147 entries = []
148 for e in ET.fromstring(text).findall(ATOM + "entry"):
149 vid = e.find(YT + "videoId")
150 title = e.find(ATOM + "title")
151 link = e.find(ATOM + "link")
152 published = e.find(ATOM + "published")
153 thumb = e.find(f"{MEDIA}group/{MEDIA}thumbnail")
154 if vid is None or vid.text is None or title is None:
155 continue
156 entries.append({
157 "id": vid.text,
158 "title": title.text or "(untitled)",
159 "link": link.get("href") if link is not None else f"https://youtu.be/{vid.text}",
160 "published": published.text[:10] if published is not None and published.text else "",
161 "thumb": thumb.get("url") if thumb is not None else "",
162 })
163 return entries
164
165
166def send_notification(channel, entry):
167 msg = EmailMessage()
168 slug = re.sub(r"[^a-z0-9]+", "-", channel.lower()).strip("-")
169 review = f"{BASE_URL}/#v-{entry['id']}" if BASE_URL else ""
170 msg["Subject"] = f"[yt] {channel}: {entry['title']}"
171 msg["From"] = MAIL_FROM
172 msg["To"] = MAIL_TO
173 msg["Date"] = formatdate(localtime=True)
174 msg["Message-ID"] = f"<yt-{entry['id']}@yt-feed.paperclover.net>"
175 msg["References"] = f"<yt-channel-{slug}@yt-feed.paperclover.net>"
176 msg["In-Reply-To"] = f"<yt-channel-{slug}@yt-feed.paperclover.net>"
177 msg.set_content(
178 f"{channel} uploaded: {entry['title']}\n\n"
179 f" {entry['link']}\n published {entry['published']}\n\n"
180 f"review and ingest: {review}\n"
181 )
182 h = escape(entry["title"])
183 msg.add_alternative(
184 f'<div style="font-family:sans-serif">'
185 f'<p><b>{escape(channel)}</b> uploaded:</p>'
186 f'<p><a href="{review or entry["link"]}">'
187 f'<img src="{entry["thumb"]}" alt="" width="320" style="display:block;border-radius:8px"></a></p>'
188 f'<p><a href="{review or entry["link"]}">{h}</a> · {entry["published"]}</p>'
189 f'<p><a href="{review}">review &amp; ingest →</a> · <a href="{entry["link"]}">watch</a></p>'
190 f"</div>",
191 subtype="html",
192 )
193 with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as s:
194 s.login(SMTP_USER, SMTP_PASS)
195 s.send_message(msg)
196
197
198def poll_once():
199 with open(FEED_CONFIG) as f:
200 channels = (yaml.safe_load(f) or {}).get("channels") or {}
201 with state_lock:
202 ids = load_json("channel-ids.json", {})
203 seen = load_json("seen.json", {})
204 pending = load_json("pending.json", {})
205 for name, url in channels.items():
206 try:
207 cid = channel_id_for(url, ids)
208 entries = feed_entries(cid)
209 except Exception as e:
210 log(f"{name}: fetch failed: {e}")
211 continue
212 if cid not in seen:
213 seen[cid] = [e["id"] for e in entries]
214 log(f"{name}: now tracking ({len(entries)} existing videos skipped)")
215 continue
216 known = set(seen[cid])
217 for entry in reversed(entries):
218 if entry["id"] in known:
219 continue
220 entry["channel"] = name
221 pending[entry["id"]] = entry
222 seen[cid].append(entry["id"])
223 known.add(entry["id"])
224 log(f"{name}: queued {entry['title']!r}")
225 try:
226 send_notification(name, entry)
227 except Exception as e:
228 log(f"{name}: notification failed: {e}")
229 seen[cid] = seen[cid][-SEEN_CAP:]
230 with state_lock:
231 save_json("channel-ids.json", ids)
232 save_json("seen.json", seen)
233 save_json("pending.json", pending)
234
235
236def poller():
237 while True:
238 try:
239 poll_once()
240 except Exception as e:
241 log(f"poll failed: {e}")
242 time.sleep(INTERVAL)
243
244
245# ------------------------------------------------------------ download worker
246
247def update_job(job_id, **fields):
248 with state_lock:
249 jobs = load_json("jobs.json", [])
250 for j in jobs:
251 if j["id"] == job_id:
252 j.update(fields)
253 save_json("jobs.json", jobs)
254
255
256def resolve_url(url):
257 out = subprocess.run(
258 ["yt-dlp", "--no-playlist", "--print",
259 "%(id)s\t%(title)s\t%(channel)s\t%(upload_date>%Y-%m-%d)s\t%(thumbnail)s", url],
260 capture_output=True, text=True, timeout=90)
261 if out.returncode != 0:
262 if WALL_RE.search(out.stderr):
263 raise WallError(url)
264 raise ValueError(out.stderr[-300:])
265 vid, title, channel, published, thumb = out.stdout.strip().split("\t")
266 return {"id": vid, "title": title, "channel": channel, "published": published,
267 "thumb": thumb, "link": f"https://www.youtube.com/watch?v={vid}"}
268
269
270def resolve_and_update(stub_id, url):
271 try:
272 entry = resolve_url(url)
273 except WallError:
274 set_wall(True)
275 return
276 except Exception as e:
277 log(f"resolve failed for {url}: {e}")
278 return
279 with state_lock:
280 pending = load_json("pending.json", {})
281 # if the stub is gone the user already ingested or skipped it
282 if stub_id in pending:
283 del pending[stub_id]
284 entry["stub_id"] = stub_id
285 pending[entry["id"]] = entry
286 save_json("pending.json", pending)
287
288
289def write_nfo(filepath, root, tags):
290 base, _ = os.path.splitext(filepath)
291 body = "\n".join(f" <{k}>{escape(str(v))}</{k}>" for k, v in tags.items() if v != "")
292 with open(base + ".nfo", "w") as f:
293 f.write(f"<?xml version='1.0' encoding='utf-8'?>\n<{root}>\n{body}\n</{root}>\n")
294
295
296def run_job(job):
297 # youtube intermittently bot-walls the home ip after heavy traffic, so
298 # transient failures get retried with backoff before declaring an error
299 for delay in (0, 30, 90):
300 if delay:
301 update_job(job["id"], status="retrying", progress="")
302 time.sleep(delay)
303 try:
304 if run_job_once(job):
305 return
306 except WallError:
307 raise
308 except Exception as e:
309 log(f"job {job['id']} attempt failed: {e}")
310 update_job(job["id"], status="error", progress="")
311
312
313def run_job_once(job):
314 if job.get("unresolved"):
315 update_job(job["id"], status="resolving")
316 meta = resolve_url(job["url"])
317 job.update(title=meta["title"], channel=meta["channel"],
318 published=meta["published"], url=meta["link"], unresolved=False)
319 update_job(job["id"], title=meta["title"])
320 dest, url = job["dest"], job["url"]
321 if dest == "indie":
322 outdir = os.path.join(INDIE_DIR, safe_name(job["show"]), f"Season {job['season']}")
323 prefix = f"S{job['season']:02d}E{job['episode']:02d}"
324 ep_name = safe_name(job.get("ep_title") or job["title"])
325 out = f"{outdir}/{prefix} - {ep_name}.%(ext)s"
326 thumb_out = f"thumbnail:{outdir}/{prefix} - {ep_name}-thumb.%(ext)s"
327 elif dest == "independent":
328 outdir = os.path.join(INDEP_DIR, safe_name(job["channel"]))
329 out = f"{outdir}/%(upload_date>%Y-%m-%d)s - %(title)s.%(ext)s"
330 thumb_out = f"thumbnail:{outdir}/%(upload_date>%Y-%m-%d)s - %(title)s.%(ext)s"
331 else:
332 outdir = os.path.join(MUSIC_DIR, safe_name(job["channel"]))
333 out = f"{outdir}/%(title)s.%(ext)s"
334 thumb_out = None
335 os.makedirs(outdir, exist_ok=True)
336 cmd = ["yt-dlp", "--newline", "--no-playlist", "--embed-chapters",
337 "--sleep-requests", "0.75",
338 "--print", "after_move:filepath", "--no-simulate", "-o", out]
339 if dest == "music":
340 cmd += ["-x"]
341 else:
342 cmd += ["-f", "bv*+ba/b", "--write-thumbnail", "--convert-thumbnails", "jpg",
343 "-o", thumb_out]
344 cmd.append(url)
345 update_job(job["id"], status="downloading")
346 filepath = None
347 tail = []
348 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
349 for line in proc.stdout:
350 line = line.rstrip()
351 tail = (tail + [line])[-30:]
352 m = re.search(r"\[download\]\s+([\d.]+%)", line)
353 if m:
354 update_job(job["id"], progress=m.group(1))
355 elif line.startswith("/"):
356 filepath = line
357 proc.wait()
358 if proc.returncode != 0 or (dest != "music" and not filepath):
359 if WALL_RE.search("\n".join(tail)):
360 raise WallError(url)
361 log(f"job {job['id']} attempt failed (exit {proc.returncode})")
362 return False
363 if dest == "indie":
364 write_nfo(filepath, "episodedetails", {
365 "title": job.get("ep_title") or job["title"],
366 "season": job["season"], "episode": job["episode"],
367 "aired": job.get("published", ""), "plot": url,
368 })
369 elif dest == "independent":
370 write_nfo(filepath, "movie", {
371 "title": job["title"], "premiered": job.get("published", ""), "plot": url,
372 })
373 update_job(job["id"], status="done", progress="")
374 log(f"job {job['id']} done: {filepath or job['title']}")
375 return True
376
377
378def worker():
379 while True:
380 job = job_queue.get()
381 if wall_active():
382 update_job(job["id"], status="waiting", progress="")
383 while wall_active():
384 time.sleep(30)
385 try:
386 run_job(job)
387 except WallError:
388 set_wall(True)
389 update_job(job["id"], status="queued", progress="")
390 job_queue.put(job)
391 except Exception as e:
392 update_job(job["id"], status="error")
393 log(f"job {job['id']} crashed: {e}")
394
395
396# -------------------------------------------------------------------- web app
397
398app = Flask(__name__)
399
400
401def indie_shows():
402 shows = {}
403 try:
404 names = sorted(os.listdir(INDIE_DIR))
405 except FileNotFoundError:
406 names = []
407 for name in names:
408 path = os.path.join(INDIE_DIR, name)
409 if not os.path.isdir(path):
410 continue
411 seasons = {}
412 for sub in os.listdir(path):
413 m = re.fullmatch(r"Season (\d+)", sub)
414 if m and os.path.isdir(os.path.join(path, sub)):
415 count = sum(1 for f in os.listdir(os.path.join(path, sub))
416 if f.lower().endswith(VIDEO_EXTS))
417 seasons[int(m.group(1))] = count
418 shows[name] = seasons
419 return shows
420
421
422PAGE = """<!doctype html>
423<html lang="en"><head>
424<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
425<title>yt triage</title>
426<style>
427:root {{ color-scheme: light dark; }}
428body {{ font-family: system-ui, sans-serif; max-width: 32rem; margin: 0 auto;
429 padding: 1rem 1rem 3rem; background: light-dark(#e8eefa, #152c42);
430 color: light-dark(#000, #fff); }}
431h1 {{ font-size: 1.2rem; font-weight: 500; }}
432.card {{ background: light-dark(#fff, #1e3a55); border-radius: 12px;
433 padding: 0.9rem; margin-bottom: 0.8rem; }}
434.card img {{ width: 100%; border-radius: 8px; }}
435.title {{ font-weight: 600; margin: 0.4rem 0 0.1rem; }}
436.meta {{ color: light-dark(#555, #aac); font-size: 0.85rem; margin: 0 0 0.5rem; }}
437select, input {{ width: 100%; box-sizing: border-box; margin: 0.15rem 0;
438 padding: 0.45rem; border-radius: 8px;
439 border: 1px solid light-dark(#bbb, #456);
440 background: light-dark(#fff, #152c42); color: inherit; }}
441.row {{ display: flex; gap: 0.4rem; }}
442.row > * {{ flex: 1; min-width: 0; }}
443button {{ padding: 0.5rem 1rem; border-radius: 8px; border: none; cursor: pointer;
444 background: #1a46cd; color: #fff; font-size: 0.95rem; }}
445button.skip {{ background: transparent; color: light-dark(#555, #aac); }}
446.jobs p {{ font-size: 0.9rem; margin: 0.25rem 0; }}
447.done {{ color: light-dark(#2a7a2a, #8fd48f); }}
448.error {{ color: light-dark(#b03030, #f0a0a0); }}
449</style></head><body>
450<h1>yt triage <small style="font-weight:400">· {npending} pending</small></h1>
451{cards}
452<form method="post" action="/add" class="card row">
453 <input name="url" placeholder="paste youtube url(s)…" style="flex:3">
454 <button>add</button>
455</form>
456<div class="jobs" id="jobs">{jobs}</div>
457<script>
458const SHOWS = {shows_json};
459function destChanged(sel) {{
460 const f = sel.form;
461 const indie = sel.value.startsWith("indie|");
462 f.querySelectorAll(".indie-fields").forEach(el => el.style.display = indie ? "" : "none");
463 f.querySelector(".new-show").style.display = sel.value === "indie|__new__" ? "" : "none";
464 if (indie) seasonOptions(f, sel.value.slice(6));
465}}
466function seasonOptions(f, show) {{
467 const seasons = SHOWS[show] || {{}};
468 const nums = Object.keys(seasons).map(Number).sort((a, b) => a - b);
469 const sel = f.querySelector("select[name=season]");
470 sel.innerHTML = "";
471 for (const n of nums) sel.add(new Option(`Season ${{n}} (${{seasons[n]}} eps)`, n));
472 const next = nums.length ? Math.max(...nums) + 1 : 1;
473 sel.add(new Option(`new Season ${{next}}`, next));
474 sel.onchange = () => f.querySelector("input[name=episode]").value = (seasons[sel.value] || 0) + 1;
475 sel.onchange();
476}}
477document.querySelectorAll("select[name=dest]").forEach(destChanged);
478setInterval(async () => {{
479 const r = await fetch("/jobs.html");
480 document.getElementById("jobs").innerHTML = await r.text();
481}}, 4000);
482</script>
483</body></html>"""
484
485
486def card_html(v, shows):
487 opts = ['<option value="independent" selected>Independent (creators)</option>',
488 '<option value="music">music_intake (audio)</option>']
489 for s in shows:
490 opts.append(f'<option value="indie|{escape(s)}">Indie Shows ▸ {escape(s)}</option>')
491 opts.append('<option value="indie|__new__">Indie Shows ▸ new show…</option>')
492 img = (f'<a href="{escape(v["link"])}"><img src="{escape(v["thumb"])}" alt=""></a>'
493 if v.get("thumb") else "")
494 meta = ("resolving…" if v.get("unresolved")
495 else f"{escape(v.get('channel', '?'))} · {escape(v.get('published', ''))}")
496 return f"""<form method="post" action="/ingest" class="card" id="v-{v['id']}">
497 {img}
498 <p class="title">{escape(v['title'])}</p>
499 <p class="meta">{meta}</p>
500 <input type="hidden" name="vid" value="{escape(v['id'])}">
501 <select name="dest" onchange="destChanged(this)">{''.join(opts)}</select>
502 <input class="new-show" name="new_show" placeholder="new show name" style="display:none">
503 <input class="indie-fields" name="ep_title" value="{escape(v['title'])}" placeholder="episode title">
504 <div class="indie-fields row">
505 <select name="season"></select>
506 <input name="episode" type="number" min="1" title="episode #">
507 </div>
508 <div class="row" style="margin-top:0.4rem">
509 <button>ingest</button>
510 <button class="skip" formaction="/skip">skip</button>
511 </div>
512</form>"""
513
514
515def jobs_html():
516 with state_lock:
517 jobs = load_json("jobs.json", [])
518 icon = {"queued": "·", "resolving": "…", "downloading": "↓", "retrying": "↻",
519 "waiting": "⏸", "done": "✓", "error": "✗"}
520 out = ["<p style='opacity:.6;font-size:.85rem'>recent jobs</p>"] if jobs else []
521 for j in jobs[:15]:
522 cls = j["status"] if j["status"] in ("done", "error") else ""
523 prog = f" {j.get('progress', '')}" if j["status"] == "downloading" else ""
524 retry = ("" if j["status"] != "error" else
525 f' <form method="post" action="/retry" style="display:inline">'
526 f'<input type="hidden" name="job" value="{j["id"]}">'
527 f'<button class="skip" style="padding:0.05rem 0.5rem">retry</button></form>')
528 out.append(f"<p class='{cls}'>{icon.get(j['status'], '·')} "
529 f"{escape(j['title'])} → {escape(j['dest_label'])}{prog}{retry}</p>")
530 return "\n".join(out)
531
532
533@app.get("/")
534def index():
535 with state_lock:
536 pending = load_json("pending.json", {})
537 shows = indie_shows()
538 cards = "\n".join(card_html(v, shows) for v in reversed(list(pending.values())))
539 if not pending:
540 cards = "<p style='opacity:.6'>nothing pending. enjoy the silence.</p>"
541 if wall_active():
542 cards = ("<div class='card error'>youtube has bot-walled this ip — downloads "
543 "are paused and will resume automatically once the wall lifts "
544 "(probed every few hours). queueing still works.</div>" + cards)
545 return PAGE.format(npending=len(pending), cards=cards,
546 jobs=jobs_html(), shows_json=json.dumps(indie_shows()))
547
548
549@app.get("/jobs.html")
550def jobs_partial():
551 return Response(jobs_html(), mimetype="text/html")
552
553
554@app.post("/retry")
555def retry():
556 jid = request.form["job"]
557 with state_lock:
558 jobs = load_json("jobs.json", [])
559 job = next((j for j in jobs if j["id"] == jid), None)
560 if job:
561 job.update(status="queued", progress="")
562 save_json("jobs.json", jobs)
563 if job:
564 job_queue.put(job)
565 return redirect("/")
566
567
568@app.post("/skip")
569def skip():
570 with state_lock:
571 pending = load_json("pending.json", {})
572 pending.pop(request.form["vid"], None)
573 save_json("pending.json", pending)
574 return redirect("/")
575
576
577@app.post("/add")
578def add():
579 # accepts one url or a whole batch separated by whitespace/commas. cards
580 # appear instantly as stubs and resolve in the background — they can be
581 # ingested before the title/thumbnail has arrived.
582 urls = [u for u in re.split(r"[\s,]+", request.form["url"].strip()) if u]
583 with state_lock:
584 pending = load_json("pending.json", {})
585 for i, url in enumerate(urls):
586 stub_id = f"u{int(time.time() * 1000)}{i}"
587 pending[stub_id] = {"id": stub_id, "title": url, "channel": "",
588 "published": "", "thumb": "", "link": url,
589 "unresolved": True}
590 threading.Thread(target=resolve_and_update, args=(stub_id, url),
591 daemon=True).start()
592 save_json("pending.json", pending)
593 return redirect("/")
594
595
596@app.post("/ingest")
597def ingest():
598 vid = request.form["vid"]
599 with state_lock:
600 pending = load_json("pending.json", {})
601 v = pending.get(vid)
602 if not v:
603 # a stub may have resolved (new key) between page load and submit
604 v = next((p for p in pending.values() if p.get("stub_id") == vid), None)
605 if not v:
606 return Response("video not in pending queue", 404, mimetype="text/plain")
607 dest = request.form["dest"]
608 job = {"id": f"j{int(time.time() * 1000)}", "url": v["link"], "title": v["title"],
609 "channel": v.get("channel", "unknown"), "published": v.get("published", ""),
610 "unresolved": v.get("unresolved", False), "status": "queued", "progress": ""}
611 if dest.startswith("indie|"):
612 show = request.form.get("new_show", "").strip() if dest == "indie|__new__" else dest[6:]
613 if not show:
614 return Response("missing show name", 400, mimetype="text/plain")
615 # a custom episode title; left equal to the card title means "use the
616 # video title" (which, for a still-resolving stub, arrives later)
617 ep_title = request.form.get("ep_title", "").strip()
618 job.update(dest="indie", show=show,
619 ep_title=ep_title if ep_title and ep_title != v["title"] else "",
620 season=int(request.form["season"]), episode=int(request.form["episode"]))
621 job["dest_label"] = f"{show} S{job['season']:02d}E{job['episode']:02d}"
622 elif dest == "independent":
623 job.update(dest="independent", dest_label="Independent")
624 else:
625 job.update(dest="music", dest_label="music_intake")
626 with state_lock:
627 pending.pop(v["id"], None)
628 save_json("pending.json", pending)
629 jobs = load_json("jobs.json", [])
630 jobs.insert(0, job)
631 save_json("jobs.json", jobs[:50])
632 job_queue.put(job)
633 return redirect("/")
634
635
636if __name__ == "__main__":
637 # jobs interrupted by a container restart pick up where they left off
638 with state_lock:
639 for j in reversed(load_json("jobs.json", [])):
640 if j.get("status") in ("queued", "resolving", "downloading", "retrying"):
641 job_queue.put(j)
642 threading.Thread(target=poller, daemon=True).start()
643 threading.Thread(target=worker, daemon=True).start()
644 threading.Thread(target=wall_prober, daemon=True).start()
645 app.run(host="0.0.0.0", port=80, threaded=True)
config/yt/archive-loop.sh created+13
...@@ -0,0 +1,13 @@
1#!/bin/sh
2# subscription pass on a 6h cycle — but if youtube has bot-walled the ip
3# ("sign in to confirm you're not a bot"), back off for a whole day instead
4# of hammering it every cycle, which prolongs the wall.
5while true; do
6 ytdl-sub --config /config-yt/config.yaml sub /config-yt/subscriptions.yaml 2>&1 | tee /tmp/last-pass.log
7 if grep -q "confirm you.re not a bot" /tmp/last-pass.log; then
8 echo "[archive-loop] bot wall detected; sleeping 24h"
9 sleep 86400
10 else
11 sleep 21600
12 fi
13done
config/yt/config.yaml created+28
...@@ -0,0 +1,28 @@
1# ytdl-sub tool configuration — the channel list lives in subscriptions.yaml
2configuration:
3 working_directory: "/config/work"
4
5presets:
6 # per-channel backlog control: only download uploads on/after download_after
7 # (yyyymmdd). subscriptions.yaml sets the default and per-channel values.
8 only-after:
9 date_range:
10 after: "{download_after}"
11 overrides:
12 download_after: "19700101"
13
14 # output for the Independent library, which is a jellyfin "home videos"
15 # library: flat files (no Season <year> folder), movie-style nfo (that
16 # library type parses nfo with the movie parser, not episodedetails),
17 # title without the date prefix, and the thumbnail named exactly like the
18 # video so jellyfin uses it as the card image.
19 flat-videos:
20 nfo_tags:
21 nfo_root: "movie"
22 tags:
23 title: "{title}"
24 premiered: "{episode_date_standardized}"
25 overrides:
26 episode_file_path: "{episode_file_name_sanitized}"
27 episode_file_name: "{upload_date_standardized} - {file_title}"
28 thumbnail_file_name: "{episode_file_path}.jpg"
config/yt/feed.yaml created+22
...@@ -0,0 +1,22 @@
1# channels whose new videos land in the triage queue at yt.<domain> for
2# manual sorting into shows/music/creators (home-infra issue #6). each also
3# sends a notification email with a link to the review page.
4# adding a channel is one line; any youtube channel url or @handle url works.
5channels:
6 "ArrowType": "https://www.youtube.com/@ArrowType"
7 "SethBling": "https://www.youtube.com/@SethBling"
8 "Voidstar": "https://www.youtube.com/@voidstar-digital"
9 "MallBat": "https://www.youtube.com/@mallbat"
10 "Early Eyes": "https://www.youtube.com/@earlyeyes"
11 "Otaku-Vs": "https://www.youtube.com/@OtakuVs"
12 "Something Witty Entertainment": "https://www.youtube.com/@SWE"
13 "Ethan Niser": "https://www.youtube.com/@ethanniser"
14 "V3rb": "https://www.youtube.com/@VerbDoesStuff"
15 "dyc3": "https://www.youtube.com/@rollthedyc3"
16 "Technology Connections": "https://www.youtube.com/@TechnologyConnections"
17 "jan Misali": "https://www.youtube.com/@HBMmaster"
18 "awe": https://www.youtube.com/@whyawe
19 "JJBlair": "https://www.youtube.com/@JJBlairrecording"
20 "2 Mello": "https://www.youtube.com/@2mello"
21 "XavierWolf": "https://www.youtube.com/@xavierwolfy"
22 "chaosyumi": "https://www.youtube.com/@willburtz"
config/yt/subscriptions.yaml created+69
...@@ -0,0 +1,69 @@
1# channels that are archived automatically (home-infra issue #6).
2# every upload lands in media/jellyfin/Independent/<Channel Name>/ with
3# .nfo metadata + thumbnails so jellyfin shows each channel as a series.
4#
5# to add a channel: one line under the preset, then
6# sh sync.sh && sh docker.sh restart ytdl-sub
7# (or just sync and wait for the next 6h pass)
8
9__preset__:
10 overrides:
11 tv_show_directory: "/media/jellyfin/Independent"
12 # default backlog policy: new uploads only. set download_after on a
13 # channel (the "~name" form) to backfill from a date, or to "19700101"
14 # for the entire backlog.
15 download_after: "20260611"
16
17Jellyfin TV Show by Date | only-after | flat-videos:
18 = Independent Creators:
19 # full archive
20 "~Retro Game Mechanics Explained":
21 url: "https://www.youtube.com/@RGMechEx"
22 download_after: "19700101"
23 # skip videos whose title contains any of these (case-insensitive
24 # substrings). works on any channel entry. for videos already
25 # downloaded, just delete the files — the download archive remembers
26 # them and won't re-fetch.
27 title_exclude_keywords:
28 - "q&a session"
29 - "channel trailer"
30 - "launching memberships"
31 - "subscriber milestone"
32 "~Franco Citera":
33 url: "https://www.youtube.com/@francocitera"
34 download_after: "19700101"
35 # partial backlog
36 "~bill wurtz":
37 url: "https://www.youtube.com/@billwurtz"
38 download_after: "20260401" # 'i'm going off the map' onward
39 "~Coffeezilla":
40 url: "https://www.youtube.com/@Coffeezilla"
41 download_after: "20260609" # 'I Found The $200,000 Missing Lego' onward
42 "~classic j":
43 url: "https://www.youtube.com/@classicj7094"
44 download_after: "20240801"
45 "~JUNIA":
46 url: "https://www.youtube.com/@butterflywife"
47 download_after: "19700101"
48 "~hbomberguy":
49 url: https://www.youtube.com/@hbomberguy
50 download_after: "20190210"
51 # new uploads only (default policy)
52 "t3ssel8r": "https://www.youtube.com/@t3ssel8r"
53 "Nes": "https://www.youtube.com/@nesorion6"
54 "4096": "https://www.youtube.com/@4096"
55 "MegaLag": "https://www.youtube.com/@MegaLag"
56 "Stuff Made Here": "https://www.youtube.com/@StuffMadeHere"
57 "mali potka": "https://www.youtube.com/@malipotka4294"
58 "Michael Reeves": "https://www.youtube.com/@MichaelReeves"
59 "Patrick Foley": "https://www.youtube.com/@patrickfoley489"
60 "orchard phobia": "https://www.youtube.com/@orchardphobia"
61 "Captain Disillusion": "https://www.youtube.com/@CaptainDisillusion"
62 "mnmira": "https://www.youtube.com/@mnmira"
63 "EmpLemon": "https://www.youtube.com/@EmperorLemon"
64 "andMo'": "https://www.youtube.com/@andMo"
65 "~CGP Grey":
66 url: "https://www.youtube.com/@CGPGrey"
67 # members-only preview posts can't download and abort the queue
68 title_exclude_keywords:
69 - "early preview for bonnie bees"