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 ---- #} +
+ 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 %} +| Navn | Type | Cyklus | +Sidst 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 %} | ++ + | +
Ingen overvågninger endnu.
+{% endif %} + {# ---- vores videoer ---- #}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