")
+ return "\n".join(out)
+
+
+@app.get("/")
+def index():
+ with state_lock:
+ pending = load_json("pending.json", {})
+ shows = indie_shows()
+ cards = "\n".join(card_html(v, shows) for v in reversed(list(pending.values())))
+ if not pending:
+ cards = "
nothing pending. enjoy the silence.
"
+ if wall_active():
+ cards = ("
youtube has bot-walled this ip — downloads "
+ "are paused and will resume automatically once the wall lifts "
+ "(probed every few hours). queueing still works.
" + cards)
+ return PAGE.format(npending=len(pending), cards=cards,
+ jobs=jobs_html(), shows_json=json.dumps(indie_shows()))
+
+
+@app.get("/jobs.html")
+def jobs_partial():
+ return Response(jobs_html(), mimetype="text/html")
+
+
+@app.post("/retry")
+def retry():
+ jid = request.form["job"]
+ with state_lock:
+ jobs = load_json("jobs.json", [])
+ job = next((j for j in jobs if j["id"] == jid), None)
+ if job:
+ job.update(status="queued", progress="")
+ save_json("jobs.json", jobs)
+ if job:
+ job_queue.put(job)
+ return redirect("/")
+
+
+@app.post("/skip")
+def skip():
+ with state_lock:
+ pending = load_json("pending.json", {})
+ pending.pop(request.form["vid"], None)
+ save_json("pending.json", pending)
+ return redirect("/")
+
+
+@app.post("/add")
+def add():
+ # accepts one url or a whole batch separated by whitespace/commas. cards
+ # appear instantly as stubs and resolve in the background — they can be
+ # ingested before the title/thumbnail has arrived.
+ urls = [u for u in re.split(r"[\s,]+", request.form["url"].strip()) if u]
+ with state_lock:
+ pending = load_json("pending.json", {})
+ for i, url in enumerate(urls):
+ stub_id = f"u{int(time.time() * 1000)}{i}"
+ pending[stub_id] = {"id": stub_id, "title": url, "channel": "",
+ "published": "", "thumb": "", "link": url,
+ "unresolved": True}
+ threading.Thread(target=resolve_and_update, args=(stub_id, url),
+ daemon=True).start()
+ save_json("pending.json", pending)
+ return redirect("/")
+
+
+@app.post("/ingest")
+def ingest():
+ vid = request.form["vid"]
+ with state_lock:
+ pending = load_json("pending.json", {})
+ v = pending.get(vid)
+ if not v:
+ # a stub may have resolved (new key) between page load and submit
+ v = next((p for p in pending.values() if p.get("stub_id") == vid), None)
+ if not v:
+ return Response("video not in pending queue", 404, mimetype="text/plain")
+ dest = request.form["dest"]
+ job = {"id": f"j{int(time.time() * 1000)}", "url": v["link"], "title": v["title"],
+ "channel": v.get("channel", "unknown"), "published": v.get("published", ""),
+ "unresolved": v.get("unresolved", False), "status": "queued", "progress": ""}
+ if dest.startswith("indie|"):
+ show = request.form.get("new_show", "").strip() if dest == "indie|__new__" else dest[6:]
+ if not show:
+ return Response("missing show name", 400, mimetype="text/plain")
+ # a custom episode title; left equal to the card title means "use the
+ # video title" (which, for a still-resolving stub, arrives later)
+ ep_title = request.form.get("ep_title", "").strip()
+ job.update(dest="indie", show=show,
+ ep_title=ep_title if ep_title and ep_title != v["title"] else "",
+ season=int(request.form["season"]), episode=int(request.form["episode"]))
+ job["dest_label"] = f"{show} S{job['season']:02d}E{job['episode']:02d}"
+ elif dest == "independent":
+ job.update(dest="independent", dest_label="Independent")
+ else:
+ job.update(dest="music", dest_label="music_intake")
+ with state_lock:
+ pending.pop(v["id"], None)
+ save_json("pending.json", pending)
+ jobs = load_json("jobs.json", [])
+ jobs.insert(0, job)
+ save_json("jobs.json", jobs[:50])
+ job_queue.put(job)
+ return redirect("/")
+
+
+if __name__ == "__main__":
+ # jobs interrupted by a container restart pick up where they left off
+ with state_lock:
+ for j in reversed(load_json("jobs.json", [])):
+ if j.get("status") in ("queued", "resolving", "downloading", "retrying"):
+ job_queue.put(j)
+ threading.Thread(target=poller, daemon=True).start()
+ threading.Thread(target=worker, daemon=True).start()
+ threading.Thread(target=wall_prober, daemon=True).start()
+ app.run(host="0.0.0.0", port=80, threaded=True)
diff --git a/config/yt/archive-loop.sh b/config/yt/archive-loop.sh
new file mode 100644
index 0000000000000000000000000000000000000000..cce5ccf2815b829cd77d033d45f91bd75b1fb7fc
--- /dev/null
+++ b/config/yt/archive-loop.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+# subscription pass on a 6h cycle — but if youtube has bot-walled the ip
+# ("sign in to confirm you're not a bot"), back off for a whole day instead
+# of hammering it every cycle, which prolongs the wall.
+while true; do
+ ytdl-sub --config /config-yt/config.yaml sub /config-yt/subscriptions.yaml 2>&1 | tee /tmp/last-pass.log
+ if grep -q "confirm you.re not a bot" /tmp/last-pass.log; then
+ echo "[archive-loop] bot wall detected; sleeping 24h"
+ sleep 86400
+ else
+ sleep 21600
+ fi
+done
diff --git a/config/yt/config.yaml b/config/yt/config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..76009bbb1ff6a88756fd558da9c9d898ced7ef58
--- /dev/null
+++ b/config/yt/config.yaml
@@ -0,0 +1,28 @@
+# ytdl-sub tool configuration — the channel list lives in subscriptions.yaml
+configuration:
+ working_directory: "/config/work"
+
+presets:
+ # per-channel backlog control: only download uploads on/after download_after
+ # (yyyymmdd). subscriptions.yaml sets the default and per-channel values.
+ only-after:
+ date_range:
+ after: "{download_after}"
+ overrides:
+ download_after: "19700101"
+
+ # output for the Independent library, which is a jellyfin "home videos"
+ # library: flat files (no Season folder), movie-style nfo (that
+ # library type parses nfo with the movie parser, not episodedetails),
+ # title without the date prefix, and the thumbnail named exactly like the
+ # video so jellyfin uses it as the card image.
+ flat-videos:
+ nfo_tags:
+ nfo_root: "movie"
+ tags:
+ title: "{title}"
+ premiered: "{episode_date_standardized}"
+ overrides:
+ episode_file_path: "{episode_file_name_sanitized}"
+ episode_file_name: "{upload_date_standardized} - {file_title}"
+ thumbnail_file_name: "{episode_file_path}.jpg"
diff --git a/config/yt/feed.yaml b/config/yt/feed.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..360cc5f53e948ddb4286e47c8f3b444be4e57b89
--- /dev/null
+++ b/config/yt/feed.yaml
@@ -0,0 +1,22 @@
+# channels whose new videos land in the triage queue at yt. for
+# manual sorting into shows/music/creators (home-infra issue #6). each also
+# sends a notification email with a link to the review page.
+# adding a channel is one line; any youtube channel url or @handle url works.
+channels:
+ "ArrowType": "https://www.youtube.com/@ArrowType"
+ "SethBling": "https://www.youtube.com/@SethBling"
+ "Voidstar": "https://www.youtube.com/@voidstar-digital"
+ "MallBat": "https://www.youtube.com/@mallbat"
+ "Early Eyes": "https://www.youtube.com/@earlyeyes"
+ "Otaku-Vs": "https://www.youtube.com/@OtakuVs"
+ "Something Witty Entertainment": "https://www.youtube.com/@SWE"
+ "Ethan Niser": "https://www.youtube.com/@ethanniser"
+ "V3rb": "https://www.youtube.com/@VerbDoesStuff"
+ "dyc3": "https://www.youtube.com/@rollthedyc3"
+ "Technology Connections": "https://www.youtube.com/@TechnologyConnections"
+ "jan Misali": "https://www.youtube.com/@HBMmaster"
+ "awe": https://www.youtube.com/@whyawe
+ "JJBlair": "https://www.youtube.com/@JJBlairrecording"
+ "2 Mello": "https://www.youtube.com/@2mello"
+ "XavierWolf": "https://www.youtube.com/@xavierwolfy"
+ "chaosyumi": "https://www.youtube.com/@willburtz"
diff --git a/config/yt/subscriptions.yaml b/config/yt/subscriptions.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..d93540d6727765caab50ff3592b8cb1eb67b7aee
--- /dev/null
+++ b/config/yt/subscriptions.yaml
@@ -0,0 +1,69 @@
+# channels that are archived automatically (home-infra issue #6).
+# every upload lands in media/jellyfin/Independent// with
+# .nfo metadata + thumbnails so jellyfin shows each channel as a series.
+#
+# to add a channel: one line under the preset, then
+# sh sync.sh && sh docker.sh restart ytdl-sub
+# (or just sync and wait for the next 6h pass)
+
+__preset__:
+ overrides:
+ tv_show_directory: "/media/jellyfin/Independent"
+ # default backlog policy: new uploads only. set download_after on a
+ # channel (the "~name" form) to backfill from a date, or to "19700101"
+ # for the entire backlog.
+ download_after: "20260611"
+
+Jellyfin TV Show by Date | only-after | flat-videos:
+ = Independent Creators:
+ # full archive
+ "~Retro Game Mechanics Explained":
+ url: "https://www.youtube.com/@RGMechEx"
+ download_after: "19700101"
+ # skip videos whose title contains any of these (case-insensitive
+ # substrings). works on any channel entry. for videos already
+ # downloaded, just delete the files — the download archive remembers
+ # them and won't re-fetch.
+ title_exclude_keywords:
+ - "q&a session"
+ - "channel trailer"
+ - "launching memberships"
+ - "subscriber milestone"
+ "~Franco Citera":
+ url: "https://www.youtube.com/@francocitera"
+ download_after: "19700101"
+ # partial backlog
+ "~bill wurtz":
+ url: "https://www.youtube.com/@billwurtz"
+ download_after: "20260401" # 'i'm going off the map' onward
+ "~Coffeezilla":
+ url: "https://www.youtube.com/@Coffeezilla"
+ download_after: "20260609" # 'I Found The $200,000 Missing Lego' onward
+ "~classic j":
+ url: "https://www.youtube.com/@classicj7094"
+ download_after: "20240801"
+ "~JUNIA":
+ url: "https://www.youtube.com/@butterflywife"
+ download_after: "19700101"
+ "~hbomberguy":
+ url: https://www.youtube.com/@hbomberguy
+ download_after: "20190210"
+ # new uploads only (default policy)
+ "t3ssel8r": "https://www.youtube.com/@t3ssel8r"
+ "Nes": "https://www.youtube.com/@nesorion6"
+ "4096": "https://www.youtube.com/@4096"
+ "MegaLag": "https://www.youtube.com/@MegaLag"
+ "Stuff Made Here": "https://www.youtube.com/@StuffMadeHere"
+ "mali potka": "https://www.youtube.com/@malipotka4294"
+ "Michael Reeves": "https://www.youtube.com/@MichaelReeves"
+ "Patrick Foley": "https://www.youtube.com/@patrickfoley489"
+ "orchard phobia": "https://www.youtube.com/@orchardphobia"
+ "Captain Disillusion": "https://www.youtube.com/@CaptainDisillusion"
+ "mnmira": "https://www.youtube.com/@mnmira"
+ "EmpLemon": "https://www.youtube.com/@EmperorLemon"
+ "andMo'": "https://www.youtube.com/@andMo"
+ "~CGP Grey":
+ url: "https://www.youtube.com/@CGPGrey"
+ # members-only preview posts can't download and abort the queue
+ title_exclude_keywords:
+ - "early preview for bonnie bees"