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 %}
+
+ | Titel | Størrelse | Status | Slettes |
+
+ {% for v in ours %}
+
+ | {{ 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 %}
+ |
+
+ {% endfor %}
+
+
+{% endif %}
+
+{# ---- kø & historik ---- #}
+{% if jobs %}
+Kø & historik
+
+ | URL / titel | Status | Besked |
+
+ {% for j in jobs %}
+
+ | {{ 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 }} |
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% if log %}
+Log
+
+ {% for l in log %}- {{ l.text }}
{% endfor %}
+
+{% 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