reconcile: import current prod state from CT151 (media-cleanup, access admin, ops versioning, newer dashboard)
This commit is contained in:
+407
@@ -0,0 +1,407 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user