Files
weircon-front/ytdl.py
T
agw ff76660910
CD / deploy (push) Successful in 20s
CI / check (push) Successful in 30s
fix(yt): én hentning ad gangen, nye forsøg ved 403, genoptag afbrudte
Tre fejl, der forstærkede hinanden:
- Hver tilføjelse startede sin egen kø-tømning, så flere hentninger kørte
  samtidig (og natlig timer kunne køre oveni). Mange samtidige hentninger fra
  samme IP er netop det YouTube svarer 403 på. Køen har nu en fil-lås
  (yt_koe.lock), så kun én kørsel henter, også på tværs af processer.
- yt-dlp gentager ikke selv en 403. Midlertidige fejl (403/429/timeout) prøves
  nu igen op til to gange med pause og en anden YouTube-klient
  (tv,web_safari og mweb). Permanente fejl prøves ikke igen.
- Et job der stod som "running", da portalen genstartede, blev hængende for
  altid. Med låsen ved vi, at et sådant job er afbrudt, og det lægges i kø
  igen ved næste kørsel.

Fejlede job har en "Prøv igen"-knap (og "Prøv alle fejlede igen"), og den
natlige kørsel opdaterer yt-dlp, før den henter.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 13:54:07 +02:00

777 lines
30 KiB
Python

"""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 fcntl
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, klient: str = "") -> 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 klient:
# Nyt forsøg efter en 403: bed YouTube om videoen som en anden klient.
cmd += ["--extractor-args", f"youtube:player_client={klient}"]
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, klient: str = "") -> dict:
dest = config.YT_DOWNLOAD_DIR
proc = await asyncio.create_subprocess_exec(
*_build_cmd(job, settings, with_subs, klient),
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"}
# Fejl der ofte går over af sig selv: YouTube afviser en enkelt forespørgsel
# (403/429), eller forbindelsen falder. yt-dlp gentager ikke selv en 403.
_MIDLERTIDIG = ("403", "forbidden", "429", "too many requests", "timed out",
"connection reset", "remote end closed", "temporary failure")
# Forsøg: (pause før, YouTube-klient). Andet og tredje forsøg beder om videoen
# som en anden klient, hvilket typisk kommer uden om en 403.
FORSOEG = [(0, ""), (30, "tv,web_safari"), (90, "mweb")]
def midlertidig(err: str) -> bool:
e = (err or "").lower()
return any(m in e for m in _MIDLERTIDIG)
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.
Midlertidige fejl (403/429/timeout) prøves igen op til to gange med pause
og en anden YouTube-klient (FORSOEG).
"""
want_subs = bool(settings.get("subtitles", True))
res = {"ok": False, "error": "ingen forsøg"}
for nr, (pause, klient) in enumerate(FORSOEG, start=1):
if pause:
await asyncio.sleep(pause)
res = await _attempt(job, settings, with_subs=want_subs, klient=klient)
if not res["ok"] and want_subs and "subtitle" in res["error"].lower():
res = await _attempt(job, settings, with_subs=False, klient=klient)
if res["ok"]:
res["note"] = "undertekster sprunget over (YouTube afviste dem lige nu)"
if res["ok"]:
if nr > 1:
res["note"] = "; ".join(x for x in (res.get("note"), f"lykkedes i forsøg {nr}") if x)
return res
if not midlertidig(res["error"]):
return res
res["error"] = f"{res['error']} (opgivet efter {len(FORSOEG)} forsøg)"
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
def _koe_laas():
"""Én kørsel af køen ad gangen, også på tværs af processer (web + timer).
Mange samtidige hentninger fra samme IP er netop det, YouTube svarer 403 på."""
f = open(os.path.join(config.DATA, "yt_koe.lock"), "w")
try:
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
f.close()
return None
return f
def retry_job(jid: str) -> bool:
"""Læg et fejlet job i kø igen (knappen "Prøv igen")."""
st = get_state()
j = st["jobs"].get(jid)
if not j or j["status"] != "error":
return False
j.update(status="queued", error="", finished=0)
_log(st, f"Prøver igen: {(j.get('title') or j['url'])[:80]}")
save_state(st)
return True
async def process_queue() -> dict:
"""Hent alt der står i kø, ét job ad gangen. Kaldes både fra web-ruten og
fra timeren; kører en anden allerede køen, gør denne ingenting."""
laas = _koe_laas()
if laas is None:
return {"done": 0, "failed": 0, "busy": True}
try:
return await _process_queue()
finally:
laas.close()
async def _process_queue() -> dict:
settings = get_settings()
done, failed = 0, 0
# Vi har låsen, så et job der står som "running" blev afbrudt (fx af en
# genstart af portalen midt i hentningen). Læg det i kø igen.
st = get_state()
haengt = [j for j in st["jobs"].values() if j["status"] == "running"]
for j in haengt:
j["status"] = "queued"
_log(st, f"Genoptager afbrudt hentning: {(j.get('title') or j['url'])[:80]}")
if haengt:
save_state(st)
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
def delete_video(path: str) -> dict:
"""Slet én video manuelt (knappen på /admin/yt), med samme sikkerhedsnet som
oprydningen: KUN filer portalen selv har hentet, og sidecars med.
Video-id'et bliver stående i abonnementets `seen`, så en overvåget kanal ikke
henter den igen. Er det jobbets sidste fil, markeres jobbet som slettet.
"""
real = os.path.realpath(path or "")
if real not in owned_paths():
return {"ok": False, "error": "Den video har portalen ikke selv hentet, så den røres ikke"}
st = get_state()
try:
size = file_size(real)
for side in _sidecars(real):
os.remove(side)
if os.path.exists(real):
os.remove(real)
except OSError as e:
return {"ok": False, "error": f"Kunne ikke slette: {e}"}
st["leaving"].pop(real, None)
titel = os.path.basename(real)
for j in st["jobs"].values():
paths = [p for p in (j.get("paths") or ([j["path"]] if j.get("path") else [])) if p]
if real not in {os.path.realpath(p) for p in paths}:
continue
titel = j.get("title") or titel
rest = [p for p in paths if os.path.realpath(p) != real]
if j.get("paths") is not None:
j["paths"] = rest
if j.get("videos"):
j["videos"] = [v for v in j["videos"] if os.path.realpath(v.get("path") or "") != real]
if not rest:
j["status"] = "deleted"
_log(st, f"Slettet manuelt: {titel} ({_human(size)})")
save_state(st)
return {"ok": True, "title": titel, "size": size}
# ---------------- 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