From 2c146b07118e8d111343017a5c84ea35cf51fc9c Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sat, 19 Sep 2026 22:32:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(yt):=20overv=C3=A5g=20playlister=20og=20ka?= =?UTF-8?q?naler,=20med=20cycle=20X=20p=C3=A5=20kanaler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playliste-abonnement henter nye videoer efterhånden som de lægges i playlisten. Kanal-abonnement holder en rullende buffer: cycle X = de X nyeste videoer ligger klar, og kommer der en ny, ryger den ældste ud. X tæller kun kanal-abonnementets egne hentninger. Manuelle hentninger, playliste-abonnementer og favoritter tæller ikke med — og favoritter slettes aldrig, så en favoritering frigør en plads i cyklussen til næste video. Tre ting der måtte designes udenom: 1. `seen` husker hvert video-id abonnementet nogensinde har hentet, også efter videoen er slettet igen. Uden det ville set-reglen slette en video efter 7 dage, hvorefter kanalen stadig har den blandt sine nyeste og henter den igen — i en uendelig løkke. 2. Playliste og kanal primer forskelligt ved oprettelse. En playliste kan have hundredvis af videoer, og ønsket var NYE videoer, så første tjek markerer det eksisterende som kendt uden at hente. En kanal henter derimod de X nyeste med det samme; det er hele pointen med cycle X. Resten af bagkataloget markeres som kendt, så den aldrig begynder at hente gammelt indhold. 3. enforce_cycles rører intet hvis Emby ikke svarer. Uden favorit-data kunne den komme til at slette noget fredet — samme konservative holdning som mediaclean. Downloads gemmer nu video-id og upload-dato, så cyklussen kan sortere efter hvor ny videoen ER, ikke hvornår vi tilfældigvis hentede den. Timeren kører i rækkefølgen: tjek abonnementer -> hent -> håndhæv cyklus -> slet sete. Cyklussen skal håndhæves efter hentningen, ellers ser den et halvt bibliotek. Claude-Session: https://claude.ai/code/session_011WxG8pbLj61jYU1MMaD7VC --- main.py | 83 ++++++++++++- templates/admin_yt.html | 58 ++++++++++ yt_job.py | 26 ++++- ytdl.py | 251 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 405 insertions(+), 13 deletions(-) diff --git a/main.py b/main.py index cd595b6..8de3f37 100644 --- a/main.py +++ b/main.py @@ -536,7 +536,7 @@ async def admin_yt(request: Request, saved: str = ""): "settings": yt.get_settings(), "human": yt._human, "now": time.time(), "download_dir": config.YT_DOWNLOAD_DIR, "unconfigured": False, "ours": [], "ours_size": 0, "leaving": st["leaving"], "log": st["log"][:15], - "jobs": jobs[:25], "inv_error": "", + "jobs": jobs[:25], "inv_error": "", "subs": [], "queued": sum(1 for j in jobs if j["status"] == "queued"), "running": any(j["status"] == "running" for j in jobs)} if not yt.configured(): @@ -554,6 +554,16 @@ async def admin_yt(request: Request, saved: str = ""): ours.sort(key=lambda v: v["name"].lower()) ctx["ours"] = ours ctx["ours_size"] = sum(v["size"] for v in ours) + favs = {it["path"] for it in inv.get("items", []) if it["favorite"]} + subs = [] + for sb in st["subs"].values(): + files = yt.sub_files(st, sb["id"]) + subs.append({**sb, + "on_disk": len(files), + "in_cycle": sum(1 for f in files if f["path"] not in favs), + "favorites": sum(1 for f in files if f["path"] in favs)}) + subs.sort(key=lambda x: (x["kind"], x["title"].lower())) + ctx["subs"] = subs return templates.TemplateResponse(request, "admin_yt.html", ctx) @@ -605,3 +615,74 @@ async def admin_yt_run(request: Request): msg = (f"Kørt — {res['seen']} af vores videoer · {len(res['new'])} nye på vej ud · " f"{len(res['spared'])} fredet · {len(res['deleted'])} slettet") return RedirectResponse(f"/admin/yt?saved={msg}", status_code=303) + + +@app.post("/admin/yt/sub/add") +async def admin_yt_sub_add(request: Request, url: str = Form(""), kind: str = Form(""), + cycle: str = Form("")): + require_admin(request) + url = (url or "").strip() + if not url.startswith(("http://", "https://")): + return RedirectResponse("/admin/yt?saved=Afvist: det ligner ikke en URL", status_code=303) + kind = kind if kind in ("playlist", "channel") else yt.sub_kind_from_url(url) + try: + n = int(cycle or 0) + except ValueError: + n = 0 + if kind == "channel" and n < 1: + return RedirectResponse( + "/admin/yt?saved=Afvist: en kanal skal have et cycle-tal (hvor mange nye videoer du vil have liggende)", + status_code=303) + sub = await yt.add_sub(url, kind=kind, cycle=n if kind == "channel" else 0) + asyncio.create_task(_yt_subs_run()) + cache.clear("yt:") + return RedirectResponse( + f"/admin/yt?saved=Overvåger nu {sub['title']} — henter i baggrunden", status_code=303) + + +@app.post("/admin/yt/sub/{sid}/save") +async def admin_yt_sub_save(request: Request, sid: str, cycle: str = Form(""), + enabled: str = Form("")): + require_admin(request) + patch = {"enabled": bool(enabled)} + try: + if cycle != "": + patch["cycle"] = max(0, int(cycle)) + except ValueError: + pass + yt.set_sub(sid, **patch) + cache.clear("yt:") + return RedirectResponse("/admin/yt?saved=Abonnement gemt", status_code=303) + + +@app.post("/admin/yt/sub/{sid}/del") +async def admin_yt_sub_del(request: Request, sid: str): + require_admin(request) + yt.del_sub(sid) + cache.clear("yt:") + return RedirectResponse( + "/admin/yt?saved=Abonnement fjernet — allerede hentede videoer bliver liggende", + status_code=303) + + +async def _yt_subs_run(): + """Tjek abonnementer, hent det nye, og hold kanalernes cyklus.""" + try: + await yt.sync_subs() + await yt.process_queue() + await yt.enforce_cycles() + cache.clear("yt:") + except Exception: + pass + + +@app.post("/admin/yt/subs/check") +async def admin_yt_subs_check(request: Request): + require_admin(request) + res = await yt.sync_subs() + asyncio.create_task(_yt_subs_run()) + cache.clear("yt:") + msg = f"Tjekkede {res['checked']} abonnementer — {res['queued']} nye videoer i kø" + if res["errors"]: + msg += f" · {len(res['errors'])} fejl: {res['errors'][0][:80]}" + return RedirectResponse(f"/admin/yt?saved={msg}", status_code=303) diff --git a/templates/admin_yt.html b/templates/admin_yt.html index 8614222..0dfdf7d 100644 --- a/templates/admin_yt.html +++ b/templates/admin_yt.html @@ -64,6 +64,64 @@ +{# ---- abonnementer ---- #} +

Overvågede playlister & kanaler

+

+ En playliste henter nye videoer efterhånden som de lægges i den — + det der allerede ligger i playlisten, når du opretter overvågningen, hentes ikke. + En kanal holder de X nyeste videoer liggende: kommer der en ny, + ryger den ældste ud. Favoritter tæller ikke med i X og slettes aldrig, så en + favoritering frigør en plads. Manuelle hentninger og playlister tæller heller ikke med. +

+ +
+ + + + + +
+ +{% if subs %} + + + + + {% for sb in subs %} + + + + + + + + {% endfor %} + +
NavnTypeCyklusSidst tjekket
{{ sb.title }}
{{ sb.url }}
{% if sb.kind == 'channel' %}Kanal{% else %}Playliste{% endif %} + {% if sb.kind == 'channel' and sb.cycle %} + {{ sb.in_cycle }} af {{ sb.cycle }} i cyklus + {% if sb.favorites %}
+ {{ sb.favorites }} favorit{% if sb.favorites != 1 %}ter{% endif %} (fredet){% endif %} + {% else %} + {{ sb.on_disk }} hentet + {% endif %} +
{% if sb.last_check %}{{ ((now - sb.last_check) / 3600) | round(0) | int }} t siden{% else %}aldrig{% endif %} +
+ {% if sb.kind == 'channel' %}{% endif %} + + + +
+
+{% else %} +

Ingen overvågninger endnu.

+{% endif %} + {# ---- vores videoer ---- #}

Hentede videoer

{% if not ours %}

Ingen endnu — indsæt en URL ovenfor.

{% else %} diff --git a/yt_job.py b/yt_job.py index 3024d0c..0f5f91f 100644 --- a/yt_job.py +++ b/yt_job.py @@ -1,7 +1,12 @@ -"""Natlig YouTube-kørsel (systemd-timer): tøm download-køen og ryd op efter sete videoer. +"""Natlig YouTube-kørsel (systemd-timer). -Køen tømmes også her — ikke kun fra web-ruten — så et job overlever en genstart af -portalen midt i en hentning. +Rækkefølgen er ikke tilfældig: + 1. sync_subs — find nye videoer i playliste-/kanal-abonnementer + 2. process_queue — hent alt i køen (også jobs der hang efter en genstart) + 3. enforce_cycles — hold hver kanal på sine X nyeste (favoritter tæller ikke med) + 4. run_cycle — slet videoer der er markeret som set, efter karensen + +Cyklussen håndhæves EFTER hentningen, ellers ville den se et halvt bibliotek. """ import asyncio @@ -12,9 +17,24 @@ async def main(): if not ytdl.configured(): print("yt_job: ikke konfigureret (mangler EMBY-env, yt-dlp eller download-mappe)") return + + sub = await ytdl.sync_subs() + if sub["checked"] or sub["errors"]: + print(f"yt_job: abonnementer tjekket={sub['checked']} nye_i_koe={sub['queued']}") + for e in sub["errors"]: + print(" abonnement-fejl:", e) + q = await ytdl.process_queue() if q["done"] or q["failed"]: print(f"yt_job: hentet={q['done']} fejlet={q['failed']}") + + cyc = await ytdl.enforce_cycles() + if cyc["evicted"] or cyc["errors"]: + print(f"yt_job: ude_af_cyklus={len(cyc['evicted'])} " + f"frigjort={ytdl._human(cyc['freed'])}") + for e in cyc["errors"]: + print(" cyklus-fejl:", e) + res = await ytdl.run_cycle(dry_run=False) if res.get("error"): print("yt_job FEJL:", res["error"]) diff --git a/ytdl.py b/ytdl.py index b892b6a..9311978 100644 --- a/ytdl.py +++ b/ytdl.py @@ -53,6 +53,7 @@ def get_state() -> dict: st.setdefault("jobs", {}) # id -> {url,status,title,path,size,added,finished,error} st.setdefault("leaving", {}) # sti -> {title,size,first_seen,delete_after} st.setdefault("log", []) # seneste hændelser + st.setdefault("subs", {}) # id -> abonnement (playliste/kanal), se add_sub() return st @@ -80,13 +81,20 @@ def _human(n: int) -> str: # ---------------- download ---------------- -def add_job(url: str, playlist: bool = False) -> dict: - """Læg en URL i køen. Selve hentningen sker i process_queue().""" +def add_job(url: str, playlist: bool = False, sub_id: str = "", + video_id: str = "") -> dict: + """Læg en URL i køen. Selve hentningen sker i process_queue(). + + `sub_id` binder jobbet til et abonnement. Det er dét felt kanal-cyklussen + tæller på — manuelle hentninger (sub_id="") og playliste-abonnementer tæller + aldrig med i en kanals X, præcis som aftalt. + """ st = get_state() jid = uuid.uuid4().hex[:12] job = {"id": jid, "url": url.strip(), "playlist": bool(playlist), "status": "queued", "title": "", "path": "", "size": 0, "added": time.time(), - "finished": 0, "error": ""} + "finished": 0, "error": "", "sub_id": sub_id, "video_id": video_id, + "videos": []} st["jobs"][jid] = job _log(st, f"Lagt i kø: {url.strip()[:80]}") save_state(st) @@ -113,7 +121,7 @@ def _build_cmd(job: dict, settings: dict, with_subs: bool) -> list: "--no-progress", "--newline", "--no-warnings", "--concurrent-fragments", "4", "--retries", "5", "--sleep-requests", "1", - "--print", "after_move:%(filepath)s", + "--print", "after_move:%(id)s|%(upload_date)s|%(filepath)s", "-o", out] if settings.get("metadata", True): @@ -150,14 +158,22 @@ async def _attempt(job: dict, settings: dict, with_subs: bool) -> dict: err = (stderr.decode("utf-8", "replace").strip().splitlines() or ["ukendt fejl"])[-1] return {"ok": False, "error": err[:300]} - # --print after_move giver én linje pr. hentet fil (flere ved playlister). - paths = [ln for ln in stdout.decode("utf-8", "replace").splitlines() - if ln.startswith(dest)] - if not paths: + # --print after_move giver én linje pr. hentet fil: id|upload_date|sti. + # Video-id og upload-dato gemmes, fordi cyklussen skal kunne sortere efter + # hvor ny videoen ER, ikke hvornår vi tilfældigvis hentede den. + videos = [] + for ln in stdout.decode("utf-8", "replace").splitlines(): + parts = ln.split("|", 2) + if len(parts) == 3 and parts[2].startswith(dest): + videos.append({"id": parts[0], "upload_date": parts[1].strip("NA "), + "path": parts[2]}) + if not videos: return {"ok": False, "error": "yt-dlp meldte ingen fil — allerede hentet?"} + paths = [v["path"] for v in videos] size = sum(os.path.getsize(p) for p in paths if os.path.exists(p)) title = os.path.basename(paths[0]).rsplit(".", 1)[0] - return {"ok": True, "title": title, "path": paths[0], "paths": paths, "size": size, + return {"ok": True, "title": title, "path": paths[0], "paths": paths, + "videos": videos, "size": size, "note": "" if with_subs else "uden undertekster"} @@ -221,6 +237,7 @@ async def process_queue() -> dict: # Playlister giver flere filer; registrér dem alle så oprydningen # kender dem (ellers ville kun den første være "vores"). j["paths"] = res.get("paths") or [res["path"]] + j["videos"] = res.get("videos") or [] note = f" — {res['note']}" if res.get("note") else "" _log(st, f"Hentet: {res['title']} ({_human(res['size'])}){note}") j["note"] = res.get("note") or "" @@ -255,6 +272,222 @@ def owned_paths() -> dict: return out +# ---------------- abonnementer (playliste / kanal) ---------------- +def sub_kind_from_url(url: str) -> str: + """Gæt om en URL er en playliste eller en kanal.""" + u = url.lower() + if "list=" in u or "/playlist" in u: + return "playlist" + return "channel" + + +def _remote_url(sub: dict) -> str: + """Kanal-URL'er skal pege på videofanen, ellers lister yt-dlp fanerne selv.""" + url = sub["url"] + if sub["kind"] != "channel": + return url + tail = url.rstrip("/").rsplit("/", 1)[-1].lower() + if tail in ("videos", "shorts", "streams", "live"): + return url + return url.rstrip("/") + "/videos" + + +async def _ytdlp_lines(args: list) -> list: + proc = await asyncio.create_subprocess_exec( + config.YT_DLP_BIN, *args, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + err = (stderr.decode("utf-8", "replace").strip().splitlines() or ["ukendt fejl"])[-1] + raise RuntimeError(err[:200]) + return [ln for ln in stdout.decode("utf-8", "replace").splitlines() if ln.strip()] + + +async def list_remote(sub: dict, limit: int = 0) -> list: + """Videoer i playlisten/kanalen, nyeste først. --flat-playlist henter kun + listen, ikke videoerne, så et tjek er billigt.""" + args = ["--flat-playlist", "--no-warnings", "--print", "%(id)s|%(title)s"] + if limit: + args += ["--playlist-end", str(int(limit))] + args.append(_remote_url(sub)) + out = [] + for ln in await _ytdlp_lines(args): + vid, _, title = ln.partition("|") + if vid and vid != "NA": + out.append({"id": vid, "title": title or vid}) + return out + + +async def add_sub(url: str, kind: str = "", cycle: int = 0) -> dict: + """Opret et abonnement. Titlen hentes fra YouTube så listen er læsbar.""" + url = url.strip() + kind = kind or sub_kind_from_url(url) + sid = uuid.uuid4().hex[:12] + sub = {"id": sid, "url": url, "kind": kind, "cycle": int(cycle or 0), + "title": url, "enabled": True, "added": time.time(), "last_check": 0, + "primed": False, "seen": {}} + try: + lines = await _ytdlp_lines(["--flat-playlist", "--playlist-end", "1", + "--no-warnings", "--print", + "%(playlist_title)s", _remote_url(sub)]) + if lines and lines[0] not in ("NA", ""): + sub["title"] = lines[0] + except Exception: + pass + st = get_state() + st["subs"][sid] = sub + _log(st, f"Abonnement tilføjet: {sub['title']}" + + (f" (cycle {sub['cycle']})" if sub["cycle"] else "")) + save_state(st) + return sub + + +def del_sub(sid: str) -> bool: + """Fjern abonnementet. Allerede hentede videoer bliver liggende — de er dine, + og de følger herefter kun den almindelige set-regel.""" + st = get_state() + sub = st["subs"].pop(sid, None) + if sub: + for j in st["jobs"].values(): + if j.get("sub_id") == sid: + j["sub_id"] = "" + _log(st, f"Abonnement fjernet: {sub['title']}") + save_state(st) + return bool(sub) + + +def set_sub(sid: str, **kw) -> dict: + st = get_state() + sub = st["subs"].get(sid) + if sub: + sub.update(kw) + save_state(st) + return sub or {} + + +def sub_files(st: dict, sid: str) -> list: + """Filer der stadig ligger på disken og stammer fra dette abonnement.""" + out = [] + for j in st["jobs"].values(): + if j.get("sub_id") != sid or j["status"] != "done": + continue + for v in (j.get("videos") or []): + path = os.path.realpath(v.get("path") or "") + if path and os.path.exists(path): + out.append({"path": path, "upload_date": v.get("upload_date") or "", + "title": j.get("title") or os.path.basename(path), + "job": j["id"], "finished": j.get("finished") or 0}) + return out + + +async def sync_subs() -> dict: + """Tjek alle aktive abonnementer og læg nye videoer i køen. + + Asymmetrien mellem de to typer er med vilje: + · **playliste** — første tjek markerer alt eksisterende som kendt uden at hente. + En playliste kan indeholde hundredvis af videoer, og du bad om *nye*. + · **kanal** — første tjek henter de X nyeste med det samme; det er hele pointen + med "cycle X". Resten af kanalens bagkatalog markeres som kendt, så den + aldrig begynder at hente gammelt indhold. + + `seen` husker hvert video-id vi nogensinde har hentet — også efter videoen er + slettet igen. Uden det ville kanalovervågningen genhente en video, som + set-reglen lige har ryddet væk, i en uendelig løkke. + """ + res = {"checked": 0, "queued": 0, "errors": []} + for sid in list(get_state()["subs"]): + st = get_state() + sub = st["subs"].get(sid) + if not sub or not sub.get("enabled", True): + continue + cycle = int(sub.get("cycle") or 0) + try: + limit = (cycle + 10) if (sub["kind"] == "channel" and cycle) else 0 + remote = await list_remote(sub, limit) + except Exception as e: # noqa: BLE001 + res["errors"].append(f"{sub['title']}: {e}") + continue + + st = get_state() + sub = st["subs"][sid] + sub["last_check"] = time.time() + seen = sub.setdefault("seen", {}) + first = not sub.get("primed") + unseen = [v for v in remote if v["id"] not in seen] + + if sub["kind"] == "channel" and cycle: + # Hent højst én cyklus ad gangen: er der pludselig 30 nye videoer, + # er det spild at hente alle 30 og straks smide de 27 ud igen. + take = unseen[:cycle] + elif first: + take = [] # playliste: prim uden at hente + else: + take = unseen + + for v in take: + seen[v["id"]] = time.time() + if first: + # Alt andet der findes lige nu regnes som bagkatalog. + for v in unseen: + seen.setdefault(v["id"], time.time()) + sub["primed"] = True + save_state(st) + + for v in reversed(take): # ældste først, så rækkefølgen er naturlig + add_job(f"https://www.youtube.com/watch?v={v['id']}", + sub_id=sid, video_id=v["id"]) + res["queued"] += 1 + res["checked"] += 1 + return res + + +async def enforce_cycles() -> dict: + """Hold hver kanals cyklus på X videoer. + + Tæller KUN videoer hentet af selve kanal-abonnementet. Manuelle hentninger, + playliste-abonnementer og favoritter tæller ikke med — og favoritter slettes + aldrig, så en favoritering frigør en plads i cyklussen til næste video. + """ + res = {"evicted": [], "freed": 0, "errors": []} + st = get_state() + chans = [s for s in st["subs"].values() + if s["kind"] == "channel" and int(s.get("cycle") or 0) > 0] + if not chans: + return res + + inv = await emby_yt_items() + if not inv["ok"]: + # Uden favorit-data kunne vi komme til at slette noget fredet. Rør intet. + res["errors"].append(f"Emby svarede ikke ({inv['error']}) — cyklus står stille") + return res + favs = {it["path"] for it in inv["items"] if it["favorite"]} + + st = get_state() + for sub in chans: + files = [f for f in sub_files(st, sub["id"]) if f["path"] not in favs] + # nyeste først: upload-dato hvis vi har den, ellers hentetidspunkt + files.sort(key=lambda f: (f["upload_date"] or "", f["finished"]), reverse=True) + for f in files[int(sub["cycle"]):]: + try: + size = file_size(f["path"]) + for side in _sidecars(f["path"]): + os.remove(side) + os.remove(f["path"]) + st["leaving"].pop(f["path"], None) + job = st["jobs"].get(f["job"]) + if job: + job["status"] = "deleted" + res["evicted"].append(f["title"]) + res["freed"] += size + _log(st, f"Ude af cyklus ({sub['title']}): {f['title']} ({_human(size)})") + except Exception as ex: # noqa: BLE001 + res["errors"].append(f"{f['title']}: {ex}") + save_state(st) + if res["evicted"]: + await refresh_library() + return res + + # ---------------- Emby ---------------- async def _emby(client, path, **params): params["api_key"] = config.EMBY_API_KEY