Files

408 lines
16 KiB
Python

"""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}