Playliste-abonnement henter nye videoer efterhånden som de lægges i playlisten. Kanal-abonnement holder en rullende buffer: cycle X = de X nyeste videoer ligger klar, og kommer der en ny, ryger den ældste ud. X tæller kun kanal-abonnementets egne hentninger. 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. Tre ting der måtte designes udenom: 1. `seen` husker hvert video-id abonnementet nogensinde har hentet, også efter videoen er slettet igen. Uden det ville set-reglen slette en video efter 7 dage, hvorefter kanalen stadig har den blandt sine nyeste og henter den igen — i en uendelig løkke. 2. Playliste og kanal primer forskelligt ved oprettelse. En playliste kan have hundredvis af videoer, og ønsket var NYE videoer, så første tjek markerer det eksisterende som kendt uden at hente. En kanal henter derimod de X nyeste med det samme; det er hele pointen med cycle X. Resten af bagkataloget markeres som kendt, så den aldrig begynder at hente gammelt indhold. 3. enforce_cycles rører intet hvis Emby ikke svarer. Uden favorit-data kunne den komme til at slette noget fredet — samme konservative holdning som mediaclean. Downloads gemmer nu video-id og upload-dato, så cyklussen kan sortere efter hvor ny videoen ER, ikke hvornår vi tilfældigvis hentede den. Timeren kører i rækkefølgen: tjek abonnementer -> hent -> håndhæv cyklus -> slet sete. Cyklussen skal håndhæves efter hentningen, ellers ser den et halvt bibliotek. Claude-Session: https://claude.ai/code/session_011WxG8pbLj61jYU1MMaD7VC
662 lines
25 KiB
Python
662 lines
25 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 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
|