diff --git a/config.py b/config.py index 19adf91..431ee52 100644 --- a/config.py +++ b/config.py @@ -56,6 +56,27 @@ MEDIA_DEFAULTS = { "do_series": True, } + +# ---- YouTube offline (yt-dlp + Emby) ---- +YT_DOWNLOAD_DIR = os.environ.get("YT_DOWNLOAD_DIR", "/media/yt-offline") +YT_DLP_BIN = os.environ.get("YT_DLP_BIN", "/usr/local/bin/yt-dlp") +YT_STATE_PATH = DATA / "yt_state.json" +YT_SETTINGS_PATH = DATA / "yt_settings.json" +# Standardindstillinger - kan redigeres i /admin/yt: +YT_DEFAULTS = { + "enabled": True, # slet sete videoer automatisk + "grace_days": 7, # dage fra markeret-som-set til sletning + # H.264 foretrukket: Pascal-GPU'en i CT100 kan ikke afkode AV1, og AV1 kan + # mange afspillere slet ikke offline. Falder tilbage til alt-andet hvis + # YouTube ikke har H.264 i den oenskede oploesning. + "quality": "bestvideo[vcodec^=avc1][height<=1080]+bestaudio[acodec^=mp4a]/bestvideo[vcodec^=avc1][height<=1080]+bestaudio/best[vcodec^=avc1][height<=1080]/bestvideo[height<=1080]+bestaudio/best", + "sponsorblock": True, # klip sponsorater ud, behold resten som kapitler + "subtitles": True, # rigtige undertekster, ellers YouTubes automatiske + "sub_langs": "da,en,-live_chat", + "metadata": True, # titel/kanal/dato + miniature + kapitler i filen + "library_name": "YouTube", +} + GROUP_ORDER = {n: i for i, n in enumerate( ["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])} @@ -78,4 +99,6 @@ ADMIN_MODULES = [ "desc": "Hvilke brugere og grupper kan tilgå hver enkelt service."}, {"key": "media", "title": "Medie-oprydning", "icon": "🎬", "desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."}, + {"key": "yt", "title": "YouTube offline", "icon": "📺", + "desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."}, ] diff --git a/main.py b/main.py index e990c46..cd595b6 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware +import asyncio import time import authentik as ak @@ -16,6 +17,7 @@ import mediaclean as mc import services as svc import store import versions +import ytdl as yt app = FastAPI(title="Weircon Portal") app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=True, @@ -508,7 +510,8 @@ async def search_index(request: Request): {"type": "page", "title": "App-beskrivelser", "url": "/admin/apps", "extra": "apps"}, {"type": "page", "title": "IP → label & land", "url": "/admin/ip-labels", "extra": "ip geo"}, {"type": "page", "title": "Statistik", "url": "/admin/stats", "extra": "matomo"}, - {"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"}] + {"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"}, + {"type": "page", "title": "YouTube offline", "url": "/admin/yt", "extra": "youtube yt-dlp download offline fly emby"}] try: for a in await ak.fetch_user_apps(user["username"]): items.append({"type": "app", "title": a["name"], "url": a["url"], @@ -521,3 +524,84 @@ async def search_index(request: Request): @app.get("/healthz") async def healthz(): return {"ok": True, "configured": bool(config.CLIENT_ID and config.API_TOKEN)} + + +# ---------------- YouTube offline ---------------- +@app.get("/admin/yt", response_class=HTMLResponse) +async def admin_yt(request: Request, saved: str = ""): + user = require_admin(request) + st = yt.get_state() + jobs = sorted(st["jobs"].values(), key=lambda j: j["added"], reverse=True) + ctx = {"request": request, "user": user, "is_admin": True, "saved": saved, + "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": "", + "queued": sum(1 for j in jobs if j["status"] == "queued"), + "running": any(j["status"] == "running" for j in jobs)} + if not yt.configured(): + ctx["unconfigured"] = True + return templates.TemplateResponse(request, "admin_yt.html", ctx) + inv = cache.get("yt:inv") + if inv is None: + inv = await yt.emby_yt_items() + cache.put("yt:inv", inv, 60) + ctx["inv_error"] = inv.get("error") or "" + owned = yt.owned_paths() + ours = [{**it, "size": yt.file_size(it["path"]), + "delete_after": (st["leaving"].get(it["path"]) or {}).get("delete_after")} + for it in inv.get("items", []) if it["path"] in owned] + ours.sort(key=lambda v: v["name"].lower()) + ctx["ours"] = ours + ctx["ours_size"] = sum(v["size"] for v in ours) + return templates.TemplateResponse(request, "admin_yt.html", ctx) + + +async def _yt_drain(): + """Tøm download-køen i baggrunden, så web-svaret ikke venter på yt-dlp. + Timeren tager den igen hvis portalen genstarter midt i en hentning.""" + try: + await yt.process_queue() + cache.clear("yt:") + except Exception: + pass + + +@app.post("/admin/yt/add") +async def admin_yt_add(request: Request, url: str = Form(""), playlist: 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) + yt.add_job(url, playlist=bool(playlist)) + asyncio.create_task(_yt_drain()) + return RedirectResponse("/admin/yt?saved=Lagt i kø — hentningen er i gang", status_code=303) + + +@app.post("/admin/yt/settings") +async def admin_yt_settings(request: Request): + require_admin(request) + f = await request.form() + patch = {k: bool(f.get(k)) for k in ("enabled", "sponsorblock", "subtitles", "metadata")} + try: + patch["grace_days"] = max(0, min(365, int(f.get("grace_days") or 7))) + except ValueError: + pass + for k in ("sub_langs", "library_name", "quality"): + if f.get(k): + patch[k] = str(f.get(k)).strip() + yt.save_settings(patch) + cache.clear("yt:") + return RedirectResponse("/admin/yt?saved=Indstillinger gemt", status_code=303) + + +@app.post("/admin/yt/run") +async def admin_yt_run(request: Request): + require_admin(request) + res = await yt.run_cycle(dry_run=False) + cache.clear("yt:") + if res.get("error"): + return RedirectResponse(f"/admin/yt?saved=Fejl: {res['error']}", status_code=303) + 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) diff --git a/templates/admin_yt.html b/templates/admin_yt.html new file mode 100644 index 0000000..8614222 --- /dev/null +++ b/templates/admin_yt.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} +{% block title %}Admin — YouTube offline{% endblock %} +{% block body %} +

YouTube offline

+

Indsæt en YouTube-URL, så hentes videoen til Emby-biblioteket + {{ settings.library_name }} — klar til at se uden net, fx i et fly. + Når du har markeret den som set i Emby, slettes den efter + {{ settings.grace_days }} dage. Favoritter slettes aldrig. + Halvt sete videoer rører oprydningen ikke.

+{% if saved %}
{{ saved }}
{% endif %} + +{% if unconfigured %} +
Ikke konfigureret endnu. Kræver EMBY_URL/EMBY_API_KEY i + /etc/portal.env, yt-dlp installeret, og at download-mappen + {{ download_dir }} findes i containeren.
+{% else %} + +{% if inv_error %}
⚠ Emby: {{ inv_error }} — oprydningen står stille indtil den kan læse se-data (sikkerhed).
{% endif %} + +{# ---- hent ny ---- #} +
+ + + +
+ +{# ---- topkort ---- #} +
+
I biblioteket
+
{{ ours|length }}
+
hentet af portalen · {{ human(ours_size) }}
+
På vej ud
+
{{ leaving|length }}
+
set · i karens
+
I kø
+
{{ queued }}
+
{% if running %}1 i gang{% else %}ingen i gang{% endif %}
+
Sletning
+
{% if settings.enabled %}AKTIV{% else %}fra{% endif %}
+
{{ settings.grace_days }} dages karens
+
+ +{# ---- indstillinger ---- #} +
+ ⚙️ Indstillinger +
+
+ + + + +
+
+ + + + +
+

+ + +

+
+
+ +{# ---- vores videoer ---- #} +

Hentede videoer

+{% if not ours %}

Ingen endnu — indsæt en URL ovenfor.

{% else %} + + + + {% for v in ours %} + + + + + + + {% endfor %} + +
TitelStørrelseStatusSlettes
{{ v.name }}{{ human(v.size) }} + {% if v.favorite %}★ favorit — fredet + {% elif v.played %}set + {% else %}ikke set{% endif %} + + {% if v.favorite %}aldrig + {% elif v.delete_after %}om {{ ((v.delete_after - now) / 86400) | round(0, 'ceil') | int }} dage + {% else %}—{% endif %} +
+{% endif %} + +{# ---- kø & historik ---- #} +{% if jobs %} +

Kø & historik

+ + + + {% for j in jobs %} + + + + + + {% endfor %} + +
URL / titelStatusBesked
{{ j.title or j.url }}{% if j.status == 'done' %}hentet + {% elif j.status == 'error' %}fejl + {% elif j.status == 'running' %}i gang… + {% elif j.status == 'deleted' %}slettet + {% else %}i kø{% endif %}{{ j.error }}
+{% endif %} + +{% if log %} +

Log

+ +{% endif %} + +{% endif %} +{% endblock %} diff --git a/yt_job.py b/yt_job.py new file mode 100644 index 0000000..3024d0c --- /dev/null +++ b/yt_job.py @@ -0,0 +1,31 @@ +"""Natlig YouTube-kørsel (systemd-timer): tøm download-køen og ryd op efter sete videoer. + +Køen tømmes også her — ikke kun fra web-ruten — så et job overlever en genstart af +portalen midt i en hentning. +""" +import asyncio + +import ytdl + + +async def main(): + if not ytdl.configured(): + print("yt_job: ikke konfigureret (mangler EMBY-env, yt-dlp eller download-mappe)") + return + q = await ytdl.process_queue() + if q["done"] or q["failed"]: + print(f"yt_job: hentet={q['done']} fejlet={q['failed']}") + res = await ytdl.run_cycle(dry_run=False) + if res.get("error"): + print("yt_job FEJL:", res["error"]) + return + print(f"yt_job: vores videoer={res['seen']} nye_paa_vej_ud={len(res['new'])} " + f"fredet={len(res['spared'])} slettet={len(res['deleted'])} " + f"frigjort={ytdl._human(res['freed'])} " + f"sletning={'til' if res['enabled'] else 'fra'}") + for e in res.get("errors", []): + print(" fejl:", e) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/ytdl.py b/ytdl.py new file mode 100644 index 0000000..b892b6a --- /dev/null +++ b/ytdl.py @@ -0,0 +1,428 @@ +"""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 + 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) -> dict: + """Læg en URL i køen. Selve hentningen sker i process_queue().""" + 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": ""} + 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:%(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 (flere ved playlister). + paths = [ln for ln in stdout.decode("utf-8", "replace").splitlines() + if ln.startswith(dest)] + if not paths: + return {"ok": False, "error": "yt-dlp meldte ingen fil — allerede hentet?"} + 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, + "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"]] + 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 + + +# ---------------- 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