"""YouTube-offline: hent videoer med yt-dlp, servér dem via Emby, og ryd op når de er SET. Bevidst forskel fra mediaclean.py: dér tæller selv delvis afspilning som "set", fordi formålet dér er at frede alt der er rørt. Her er det modsat — en video slettes KUN når den er eksplicit **markeret som set** (Emby `UserData.Played`). Halvt sete videoer og videoer der er lagt fra sig midtvejs bliver liggende. Favoritter slettes aldrig. Sikkerhedsnettet er lige så vigtigt som reglen: oprydningen rører **kun filer portalen selv har hentet**, registreret med fuld sti i yt_state.json. Alt andet i YouTube- biblioteket — fx det der lå i /slow/shared/yt i forvejen — er urørligt. """ import asyncio import json import os import shutil import time import uuid import httpx import config TICKS = 10_000_000 # Emby: 1 sek = 10^7 ticks # ---------------- state + settings ---------------- def _load(path, default): try: return json.loads(path.read_text("utf-8")) except Exception: return default def _save(path, data): path.write_text(json.dumps(data, ensure_ascii=False, indent=2), "utf-8") def get_settings() -> dict: s = dict(config.YT_DEFAULTS) s.update(_load(config.YT_SETTINGS_PATH, {}) or {}) return s def save_settings(patch: dict) -> dict: s = get_settings() s.update(patch) _save(config.YT_SETTINGS_PATH, s) return s def get_state() -> dict: st = _load(config.YT_STATE_PATH, {}) or {} 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 def save_state(st: dict): _save(config.YT_STATE_PATH, st) def _log(st: dict, text: str): st["log"].insert(0, {"t": time.time(), "text": text}) del st["log"][60:] def configured() -> bool: return bool(config.EMBY_URL and config.EMBY_API_KEY and shutil.which(config.YT_DLP_BIN) and os.path.isdir(config.YT_DOWNLOAD_DIR)) def _human(n: int) -> str: n = float(n or 0) for unit in ("B", "KB", "MB", "GB", "TB"): if n < 1024 or unit == "TB": return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B" n /= 1024 # ---------------- download ---------------- 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": "", "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) return job def _set_job(jid: str, **kw): st = get_state() job = st["jobs"].get(jid) if not job: return job.update(kw) save_state(st) def _build_cmd(job: dict, settings: dict, with_subs: bool) -> list: dest = config.YT_DOWNLOAD_DIR # %(title)s beholder æøå; [%(id)s] gør navnet unikt og gør det muligt at # genkende videoen igen uden at gemme en sidevogn af metadata. out = os.path.join(dest, "%(title)s [%(id)s].%(ext)s") cmd = [config.YT_DLP_BIN, "-f", settings.get("quality") or config.YT_DEFAULTS["quality"], "--merge-output-format", "mp4", "--no-progress", "--newline", "--no-warnings", "--concurrent-fragments", "4", "--retries", "5", "--sleep-requests", "1", "--print", "after_move:%(id)s|%(upload_date)s|%(filepath)s", "-o", out] if settings.get("metadata", True): # Titel/kanal/dato ind i filen + miniature som cover, så Emby har noget at # vise uden at skulle gætte ud fra filnavnet. cmd += ["--embed-metadata", "--embed-thumbnail", "--embed-chapters"] if with_subs: # "hvis de er der": rigtige undertekster først, ellers YouTubes automatiske. # Både indlejret (rejser med filen når Emby-appen henter den ned) og som # .srt-sidecar. Sidecars ryddes med i slettetrinnet. cmd += ["--sub-langs", settings.get("sub_langs") or config.YT_DEFAULTS["sub_langs"], "--write-subs", "--write-auto-subs", "--convert-subs", "srt", "--embed-subs"] if settings.get("sponsorblock", True): # Klip sponsorater/selvpromovering ud, og behold resten som kapitler man # selv kan springe i. Kræver ffmpeg. Videoer uden indsendte segmenter # påvirkes ikke (yt-dlp henter dem fra SponsorBlock-API'et pr. video). cmd += ["--sponsorblock-remove", "default", "--sponsorblock-mark", "all"] if not job.get("playlist"): cmd.append("--no-playlist") cmd.append(job["url"]) return cmd async def _attempt(job: dict, settings: dict, with_subs: bool) -> dict: dest = config.YT_DOWNLOAD_DIR proc = await asyncio.create_subprocess_exec( *_build_cmd(job, settings, with_subs), 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] return {"ok": False, "error": err[:300]} # --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, "videos": videos, "size": size, "note": "" if with_subs else "uden undertekster"} async def _run_ytdlp(job: dict, settings: dict) -> dict: """Kør yt-dlp for ét job. Returnerer {ok, title, path, size, error}. Undertekster er et gode, ikke et krav: YouTube rate-limiter dem (HTTP 429) uafhængigt af selve videoen, og uden dette fald-tilbage ville en 429 på en undertekstfil vælte hele hentningen. Vi prøver derfor én gang mere uden dem. """ want_subs = bool(settings.get("subtitles", True)) res = await _attempt(job, settings, with_subs=want_subs) if not res["ok"] and want_subs and "subtitle" in res["error"].lower(): res = await _attempt(job, settings, with_subs=False) if res["ok"]: res["note"] = "undertekster sprunget over (YouTube afviste dem lige nu)" return res def _sidecars(path: str) -> list: """Filer der hører til videoen (undertekster, miniature) og skal slettes med den. yt-dlp navngiver dem efter samme stamme: "Titel [id].da.srt", "... .en.srt", "... .jpg". Uden dette trin ville en slettet video efterlade sine .srt-filer, og Emby ville blive ved med at vise et tomt punkt i biblioteket. """ stem = path.rsplit(".", 1)[0] folder = os.path.dirname(path) or "." base = os.path.basename(stem) out = [] try: for f in os.listdir(folder): full = os.path.join(folder, f) if full != path and f.startswith(base + ".") and os.path.isfile(full): out.append(full) except OSError: pass return out async def process_queue() -> dict: """Hent alt der står i kø. Kaldes både fra web-ruten og fra timeren.""" settings = get_settings() done, failed = 0, 0 while True: st = get_state() queued = [j for j in st["jobs"].values() if j["status"] == "queued"] if not queued: break job = sorted(queued, key=lambda j: j["added"])[0] _set_job(job["id"], status="running") try: res = await _run_ytdlp(job, settings) except Exception as e: # noqa: BLE001 res = {"ok": False, "error": str(e)[:300]} st = get_state() j = st["jobs"].get(job["id"], {}) if res.get("ok"): j.update(status="done", title=res["title"], path=res["path"], size=res["size"], finished=time.time(), error="") # 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 "" done += 1 else: j.update(status="error", error=res.get("error") or "", finished=time.time()) _log(st, f"FEJL: {job['url'][:60]} — {j['error'][:120]}") failed += 1 save_state(st) if done: await refresh_library() return {"done": done, "failed": failed} def file_size(path: str) -> int: """Størrelse af videoen inkl. dens undertekst-/miniature-sidecars.""" try: return os.path.getsize(path) + sum(os.path.getsize(x) for x in _sidecars(path)) except OSError: return 0 def owned_paths() -> dict: """Sti -> jobtitel for alt portalen selv har hentet (og som stadig ligger der).""" out = {} for j in get_state()["jobs"].values(): if j["status"] != "done": continue for p in (j.get("paths") or ([j["path"]] if j.get("path") else [])): if p: out[os.path.realpath(p)] = j.get("title") or os.path.basename(p) 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 r = await client.get(f"{config.EMBY_URL}/emby{path}", params=params) r.raise_for_status() return r.json() async def _library_id(client, name: str): try: folders = await _emby(client, "/Library/VirtualFolders") except Exception: return None for f in folders: if (f.get("Name") or "").lower() == name.lower(): return f.get("ItemId") return None def _marked_played(ud: dict) -> bool: """SET = eksplicit markeret som set. Ikke 50%, ikke 'påbegyndt'. Derfor kun UserData.Played — bevidst uden PlaybackPositionTicks/PlayedPercentage, som mediaclean.py netop tæller med i sin modsatte hensigt. """ return bool((ud or {}).get("Played")) async def emby_yt_items() -> dict: """Alle videoer i YouTube-biblioteket med set/favorit aggregeret over ALLE brugere.""" settings = get_settings() out = {"ok": False, "items": [], "error": ""} try: async with httpx.AsyncClient(timeout=45) as c: lib = await _library_id(c, settings.get("library_name") or "YouTube") if not lib: out["error"] = (f"Fandt ikke Emby-biblioteket " f"'{settings.get('library_name')}'") return out users = [u["Id"] for u in await _emby(c, "/Users")] merged = {} for uid in users: data = await _emby(c, f"/Users/{uid}/Items", ParentId=lib, Recursive="true", Fields="Path,UserData") for it in data.get("Items", []): path = it.get("Path") or "" if not path: continue key = os.path.realpath(path) m = merged.setdefault(key, { "id": it["Id"], "name": it.get("Name") or os.path.basename(path), "path": key, "played": False, "favorite": False}) ud = it.get("UserData") or {} if _marked_played(ud): m["played"] = True if ud.get("IsFavorite"): m["favorite"] = True out["items"] = list(merged.values()) out["ok"] = True except Exception as e: # noqa: BLE001 out["error"] = str(e)[:200] return out async def refresh_library(): try: async with httpx.AsyncClient(timeout=30) as c: await c.post(f"{config.EMBY_URL}/emby/Library/Refresh", params={"api_key": config.EMBY_API_KEY}) except Exception: pass async def ntfy(title, message, click=None, tags=None): if not config.NTFY_URL: return headers = {"Title": title} if tags: headers["Tags"] = tags if click: headers["Click"] = click headers["Actions"] = f"view, Åbn admin, {click}" if config.NTFY_TOKEN: headers["Authorization"] = f"Bearer {config.NTFY_TOKEN}" try: async with httpx.AsyncClient(timeout=10) as c: await c.post(f"{config.NTFY_URL}/{config.NTFY_MEDIA_TOPIC}", content=message.encode("utf-8"), headers=headers) except Exception: pass # ---------------- oprydning ---------------- async def run_cycle(dry_run: bool = False) -> dict: """Markér sete videoer til sletning efter karens, og slet de udløbne. Rører kun stier portalen selv har hentet. Favorit = fredet, og en video der favoritteres i karensperioden fjernes igen fra listen. """ settings = get_settings() res = {"seen": 0, "new": [], "spared": [], "deleted": [], "freed": 0, "enabled": bool(settings.get("enabled")) and not dry_run, "errors": [], "error": ""} inv = await emby_yt_items() if not inv["ok"]: res["error"] = inv["error"] return res owned = owned_paths() st = get_state() now = time.time() grace = float(settings.get("grace_days") or 7) * 86400 for it in inv["items"]: path = it["path"] if path not in owned: continue # ikke vores fil — rør den aldrig res["seen"] += 1 entry = st["leaving"].get(path) if it["favorite"] or not it["played"]: # Favorit eller ikke markeret som set → skal ikke slettes. if entry: st["leaving"].pop(path, None) res["spared"].append(it["name"]) _log(st, f"Fredet igen: {it['name']}" + (" (favorit)" if it["favorite"] else " (ikke længere markeret set)")) continue if not entry: size = os.path.getsize(path) if os.path.exists(path) else 0 st["leaving"][path] = {"title": it["name"], "size": size, "first_seen": now, "delete_after": now + grace} res["new"].append({"title": it["name"], "size": size}) # slet udløbne for path, e in list(st["leaving"].items()): if now < e["delete_after"]: continue if not res["enabled"]: continue try: if os.path.exists(path): for side in _sidecars(path): res["freed"] += os.path.getsize(side) os.remove(side) os.remove(path) res["freed"] += e.get("size") or 0 st["leaving"].pop(path, None) # marker jobbet som slettet så owned_paths ikke tæller den igen for j in st["jobs"].values(): paths = j.get("paths") or ([j.get("path")] if j.get("path") else []) if os.path.realpath(path) in [os.path.realpath(p) for p in paths if p]: j["status"] = "deleted" res["deleted"].append(e["title"]) _log(st, f"Slettet efter karens: {e['title']} ({_human(e.get('size') or 0)})") except Exception as ex: # noqa: BLE001 res["errors"].append(f"{e['title']}: {ex}") save_state(st) if res["deleted"]: await refresh_library() await ntfy(f"Slettede {len(res['deleted'])} sete videoer", "Frigjorde " + _human(res["freed"]) + ":\n" + "\n".join("· " + t for t in res["deleted"][:15]), click="https://weircon.dk/admin/yt", tags="wastebasket") elif res["new"]: await ntfy(f"{len(res['new'])} sete videoer på vej ud", f"Slettes om {int(settings.get('grace_days') or 7)} dage, " "medmindre du favoritter dem:\n" + "\n".join(f"· {i['title']}" for i in res["new"][:15]), click="https://weircon.dk/admin/yt", tags="hourglass") return res