408 lines
16 KiB
Python
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}
|