"""Medie-oprydning: find aldrig-set + gammelt medie via Emby (se-data) + Sonarr/Radarr (bibliotek + sletning), med karensperiode ("på vej ud"), behold-liste (3 kilder forenet i portalen) og ntfy-notifikation. Bruges af både web-ruterne og den natlige timer. Kandidatregel (konservativ): har fil AND tilføjet > age_days siden AND aldrig afspillet af nogen Emby-bruger (heller ikke delvist) AND ikke beholdt. Behold = forening af: Emby-favorit · 'keep'-tag i Sonarr/Radarr · portalens egen behold-liste. Portalens behold-knap skriver 'keep'-tag i *arr (kanonisk) + noterer i portal-listen. """ import json import time import httpx import config ARR = { "movie": (config.RADARR_URL, config.RADARR_API_KEY, "movie", "movieIds"), "series": (config.SONARR_URL, config.SONARR_API_KEY, "series", "seriesIds"), } 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.MEDIA_DEFAULTS) s.update(_load(config.MEDIA_SETTINGS_PATH, {}) or {}) return s def save_settings(patch: dict) -> dict: s = get_settings() s.update(patch) _save(config.MEDIA_SETTINGS_PATH, s) return s def get_state() -> dict: st = _load(config.MEDIA_STATE_PATH, {}) or {} st.setdefault("leaving", {}) # key -> {type,arr_id,title,size,added,first_seen,delete_after} st.setdefault("keep", {}) # key -> True (portalens egen behold-liste) st.setdefault("keep_collections", {}) # collection-tmdb -> titel (behold hele samlingen, også fremtidige) st.setdefault("log", []) # seneste sletninger/hændelser return st def save_state(st: dict): _save(config.MEDIA_STATE_PATH, st) def configured() -> bool: return all([config.EMBY_URL, config.EMBY_API_KEY, config.RADARR_URL, config.RADARR_API_KEY, config.SONARR_URL, config.SONARR_API_KEY]) # ---------------- Emby se-data ---------------- async def _emby_items(client, uid, item_type, fields, filters=""): p = {"Recursive": "true", "IncludeItemTypes": item_type, "Fields": fields, "api_key": config.EMBY_API_KEY} if filters: p["Filters"] = filters r = await client.get(f"{config.EMBY_URL}/emby/Users/{uid}/Items", params=p) r.raise_for_status() return r.json().get("Items", []) def _pid(item, *keys): """Hent provider-id uanset store/små bogstaver (Tmdb/TMDB, Tvdb/TVDB...).""" prov = {k.lower(): v for k, v in (item.get("ProviderIds") or {}).items()} for k in keys: v = prov.get(k.lower()) if v: return str(v) return None def _watched(ud: dict) -> bool: ud = ud or {} return bool(ud.get("Played") or (ud.get("PlayCount") or 0) > 0 or (ud.get("PlaybackPositionTicks") or 0) > 0 or (ud.get("PlayedPercentage") or 0) > 0) async def emby_watch_data() -> dict: """Aggreger på tværs af ALLE Emby-brugere → sæt af set/favorit provider-id'er.""" out = {"watched_movie": set(), "fav_movie": set(), "watched_series": set(), "fav_series": set(), "ok": False} async with httpx.AsyncClient(timeout=30) as c: r = await c.get(f"{config.EMBY_URL}/emby/Users", params={"api_key": config.EMBY_API_KEY}) r.raise_for_status() users = [u["Id"] for u in r.json()] emby_series_tvdb = {} # Emby SeriesId -> tvdb for uid in users: movies = await _emby_items(c, uid, "Movie", "ProviderIds") for m in movies: tmdb = _pid(m, "Tmdb") if not tmdb: continue if (m.get("UserData") or {}).get("IsFavorite"): out["fav_movie"].add(tmdb) if _watched(m.get("UserData")): out["watched_movie"].add(tmdb) series = await _emby_items(c, uid, "Series", "ProviderIds") for s in series: tvdb = _pid(s, "Tvdb") if not tvdb: continue emby_series_tvdb[s["Id"]] = tvdb if (s.get("UserData") or {}).get("IsFavorite"): out["fav_series"].add(tvdb) if _watched(s.get("UserData")): out["watched_series"].add(tvdb) for filt in ("IsPlayed", "IsResumable"): eps = await _emby_items(c, uid, "Episode", "SeriesId", filters=filt) for e in eps: sid = e.get("SeriesId") if sid in emby_series_tvdb: out["watched_series"].add(emby_series_tvdb[sid]) out["ok"] = True return out # ---------------- *arr bibliotek ---------------- async def _arr_get(client, kind, path): url, key, _, _ = ARR[kind] r = await client.get(f"{url}/api/v3/{path}", headers={"X-Api-Key": key}) r.raise_for_status() return r.json() async def radarr_movies(client): return await _arr_get(client, "movie", "movie") async def sonarr_series(client): return await _arr_get(client, "series", "series") async def radarr_collections(client): return await _arr_get(client, "movie", "collection") async def disk_usage() -> dict: """Slow-pool fra Radarr's diskspace (/media er bind-mountet fra /slow/shared).""" try: async with httpx.AsyncClient(timeout=10) as c: ds = await _arr_get(c, "movie", "diskspace") cand = [x for x in ds if x.get("path") == "/media"] or \ sorted(ds, key=lambda x: x.get("totalSpace", 0), reverse=True) d = cand[0] total, free = d.get("totalSpace", 0), d.get("freeSpace", 0) pct = round(100 * (total - free) / total, 1) if total else 0 return {"path": d.get("path"), "total": total, "free": free, "used_pct": pct, "ok": True} except Exception as e: return {"ok": False, "error": str(e)[:200]} async def _ensure_keep_tag(client, kind) -> int: tags = await _arr_get(client, kind, "tag") for t in tags: if (t.get("label") or "").lower() == "keep": return t["id"] url, key, _, _ = ARR[kind] r = await client.post(f"{url}/api/v3/tag", headers={"X-Api-Key": key}, json={"label": "keep"}) r.raise_for_status() return r.json()["id"] async def set_keep_tag(kind, arr_id: int, on: bool): url, key, res, ids_field = ARR[kind] async with httpx.AsyncClient(timeout=20) as c: tag_id = await _ensure_keep_tag(c, kind) r = await c.put(f"{url}/api/v3/{res}/editor", headers={"X-Api-Key": key}, json={ids_field: [arr_id], "tags": [tag_id], "applyTags": "add" if on else "remove"}) r.raise_for_status() async def _keep_tag_ids(client): out = {} for kind in ("movie", "series"): tags = await _arr_get(client, kind, "tag") out[kind] = next((t["id"] for t in tags if (t.get("label") or "").lower() == "keep"), None) return out async def delete_item(kind, arr_id: int): url, key, res, _ = ARR[kind] async with httpx.AsyncClient(timeout=60) as c: r = await c.delete(f"{url}/api/v3/{res}/{arr_id}", headers={"X-Api-Key": key}, params={"deleteFiles": "true", "addImportExclusion": "false"}) r.raise_for_status() # ---------------- kandidat-beregning ---------------- def _epoch(iso: str) -> float: try: from datetime import datetime return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() except Exception: return 0.0 async def inventory(settings: dict) -> dict: """Byg fuld oversigt: hvert film/serie med status (kandidat / beholdt / set / for ny). Returnerer items + watch-ok-flag, så UI kan advare hvis Emby er nede.""" st = get_state() portal_keep = st.get("keep", {}) kept_colls = set((st.get("keep_collections") or {}).keys()) now = time.time() cutoff = now - settings["age_days"] * 86400 watch = await emby_watch_data() async with httpx.AsyncClient(timeout=30) as c: keep_tags = await _keep_tag_ids(c) movies = await radarr_movies(c) if settings["do_movies"] else [] series = await sonarr_series(c) if settings["do_series"] else [] colls = await radarr_collections(c) if settings["do_movies"] else [] items = [] def classify(key, kind, arr_id, title, size, added_iso, provider, watched, fav, has_tag, coll=None, coll_kept=False): kept_sources = [] if fav: kept_sources.append("favorit") if has_tag: kept_sources.append("tag") if portal_keep.get(key): kept_sources.append("portal") if coll_kept: kept_sources.append("samling") kept = bool(kept_sources) added = _epoch(added_iso) old = added and added < cutoff leaving = st["leaving"].get(key) # base_status = status uden hensyn til behold (så klienten kan toggle uden reload) if watched: base = "watched" elif not old: base = "tooNew" elif provider is None: base = "nofile" # ingen fil på disk → intet at slette else: base = "candidate" items.append({ "key": key, "type": kind, "arr_id": arr_id, "title": title, "size": size or 0, "added": added_iso, "provider": provider, "watched": watched, "coll": coll, "kept": kept, "keep_sources": kept_sources, "status": "kept" if kept else base, "base_status": base, "delete_after": (leaving or {}).get("delete_after"), }) coll_count = {} for m in movies: tmdb = str(m.get("tmdbId") or "") or None coll_tmdb = (m.get("collection") or {}).get("tmdbId") coll_tmdb = str(coll_tmdb) if coll_tmdb else None if coll_tmdb: coll_count[coll_tmdb] = coll_count.get(coll_tmdb, 0) + 1 has_tag = keep_tags["movie"] in (m.get("tags") or []) if keep_tags["movie"] else False classify(f"movie:{m['id']}", "movie", m["id"], m.get("title", "?"), m.get("sizeOnDisk", 0), m.get("added", ""), tmdb if m.get("hasFile") else None, watched=tmdb in watch["watched_movie"], fav=tmdb in watch["fav_movie"], has_tag=has_tag, coll=coll_tmdb, coll_kept=coll_tmdb in kept_colls) for s in series: tvdb = str(s.get("tvdbId") or "") or None stt = s.get("statistics") or {} has_files = (stt.get("episodeFileCount") or 0) > 0 has_tag = keep_tags["series"] in (s.get("tags") or []) if keep_tags["series"] else False classify(f"series:{s['id']}", "series", s["id"], s.get("title", "?"), stt.get("sizeOnDisk", 0), s.get("added", ""), tvdb if has_files else None, watched=tvdb in watch["watched_series"], fav=tvdb in watch["fav_series"], has_tag=has_tag) items.sort(key=lambda x: (x["status"] != "candidate", -x["size"])) # samlinger der faktisk har film i biblioteket (med titel + antal + behold-status) collections = [{"tmdb": str(x["tmdbId"]), "title": x.get("title", "?"), "count": coll_count.get(str(x["tmdbId"]), 0), "kept": str(x["tmdbId"]) in kept_colls} for x in colls if coll_count.get(str(x["tmdbId"]), 0) > 0] collections.sort(key=lambda x: x["title"].lower()) return {"items": items, "collections": collections, "watch_ok": watch["ok"], "settings": settings} # ---------------- kørsel (timer + dry-run) ---------------- def _human(n: int) -> str: for unit in ("B", "KB", "MB", "GB", "TB"): if n < 1024 or unit == "TB": return f"{n:.1f} {unit}" if unit != "B" else f"{n} B" n /= 1024 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 async def run_cycle(dry_run: bool = False) -> dict: """Genberegn kandidater, vedligehold karens-listen, slet udløbne (medmindre dry-run). Returnerer resumé til UI/notifikation.""" settings = get_settings() inv = await inventory(settings) if not inv["watch_ok"]: return {"error": "Emby utilgængelig — afbrød (intet slettet)", "dry_run": dry_run} st = get_state() now = time.time() cand = {i["key"]: i for i in inv["items"] if i["status"] == "candidate"} disk0 = await disk_usage() # Master-kontakt: er sletning slået fra, er det ren forhåndsvisning — ingen karens, # ingen notifikation, ingen tilstandsændring. Slå "Slet automatisk" til for at aktivere. if not settings["enabled"]: return {"dry_run": dry_run, "enabled": False, "candidates": len(cand), "reclaim": sum(i["size"] for i in cand.values()), "new": [], "removed": [], "deleted": [], "freed": 0, "errors": [], "throttled": False, "disk": disk0} new, removed = [], [] # tilføj nye kandidater til karens-listen for key, i in cand.items(): if key not in st["leaving"]: st["leaving"][key] = { "type": i["type"], "arr_id": i["arr_id"], "title": i["title"], "size": i["size"], "added": i["added"], "first_seen": now, "delete_after": now + settings["grace_days"] * 86400} new.append(st["leaving"][key]) # fjern dem der ikke længere er kandidater (fx beholdt eller set) for key in list(st["leaving"]): if key not in cand: removed.append(key) st["leaving"].pop(key) # disk-throttle gate disk = disk0 throttled = False if settings["disk_throttle"] and disk.get("ok"): if disk["used_pct"] < settings["disk_high"]: throttled = True deleted, freed, errors = [], 0, [] if not dry_run and settings["enabled"] and not throttled: # ældste delete_after først; stop ved disk_low hvis throttle er til due = sorted([(k, v) for k, v in st["leaving"].items() if v["delete_after"] <= now], key=lambda kv: kv[1]["delete_after"]) for key, v in due: if settings["disk_throttle"] and disk.get("ok"): used_now = 100 * (disk["total"] - (disk["free"] + freed)) / disk["total"] if used_now < settings["disk_low"]: break try: await delete_item(v["type"], v["arr_id"]) deleted.append(v) freed += v["size"] st["leaving"].pop(key) st["log"].insert(0, {"ts": now, "action": "deleted", "title": v["title"], "type": v["type"], "size": v["size"]}) except Exception as e: errors.append(f"{v['title']}: {str(e)[:120]}") st["log"] = st["log"][:200] if not dry_run: save_state(st) # notifikation (kun ægte kørsel) if not dry_run: link = "https://weircon.dk/admin/media" if new: tot = sum(i["size"] for i in new) await ntfy(f"{len(new)} titler på vej ud", "Nye oprydningskandidater (frigør " + _human(tot) + "):\n" + "\n".join(f"· {i['title']} ({_human(i['size'])})" for i in new[:15]) + (f"\n… +{len(new) - 15} flere" if len(new) > 15 else "") + f"\n\nBehold dem du vil beholde inden {settings['grace_days']} dage.", click=link, tags="wastebasket") if deleted: await ntfy(f"Slettede {len(deleted)} titler — frigjorde {_human(freed)}", "\n".join(f"· {i['title']} ({_human(i['size'])})" for i in deleted[:20]), click=link, tags="white_check_mark") return {"dry_run": dry_run, "candidates": len(cand), "new": new, "removed": removed, "deleted": deleted, "freed": freed, "errors": errors, "throttled": throttled, "enabled": settings["enabled"], "disk": disk}