diff --git a/authentik.py b/authentik.py index 7be9013..da0746e 100644 --- a/authentik.py +++ b/authentik.py @@ -162,3 +162,122 @@ async def set_group_members(gpk, user_pks): ops += [c.post(f"/api/v3/core/groups/{gpk}/remove_user/", json={"pk": int(p)}) for p in cur - desired] await asyncio.gather(*ops) return True + + +async def admin_group_pk(name): + """UUID for ADMIN_GROUP-navnet (til guardrails). Cachet via list_groups().""" + for g in await list_groups(): + if g["name"] == name: + return g["pk"] + return None + + +async def user_by_username(username): + """Slå nuværende bruger op (pk + gruppe-uuids) til guardrail-tjek.""" + for u in await list_users(): + if u["username"] == username: + return {"pk": str(u["pk"]), "groups": set(u["groups"])} + return None + + +# ---------------- per-app adgang (Authentik policy-bindings) ---------------- +# Adgang til en app styres af bindings på app'en: ingen bindings = åben for alle +# autentificerede; ≥1 binding = kun de bundne grupper/brugere. Vi administrerer +# KUN simple (negate=false) gruppe-/bruger-bindings og lader policy-/negate- +# bindings være urørte (de styres bevidst i Authentik selv). + +async def _all_app_bindings(c): + out, page = {}, 1 + while True: + r = (await c.get("/api/v3/policies/bindings/", + params={"page_size": 100, "page": page})).json() + for b in r.get("results", []): + out.setdefault(b.get("target"), []).append(b) + if not (r.get("pagination") or {}).get("next"): + break + page += 1 + return out + + +def _split_bindings(blist): + """→ (gruppe-uuids, bruger-pk-strenge, antal andre bindings vi ikke rører).""" + groups, users, other = set(), set(), 0 + for b in blist or []: + if b.get("negate") or b.get("policy"): + other += 1 + elif b.get("group"): + groups.add(str(b["group"])) + elif b.get("user") is not None: + users.add(str(b["user"])) + else: + other += 1 + return groups, users, other + + +async def list_apps_access(): + """Alle apps + adgangs-resumé (åben / antal principaler). Cachet 30s.""" + hit = cache.get("access:list") + if hit is not None: + return hit + async with _client(20) as c: + r = await c.get("/api/v3/core/applications/", + params={"page_size": 100, "superuser_full_list": "true"}) + apps = r.json().get("results", []) + bindings = await _all_app_bindings(c) + out = [] + for a in apps: + if a.get("slug") == "weircon-portal": + continue + blist = bindings.get(a["pk"], []) + g, u, other = _split_bindings(blist) + out.append({"slug": a["slug"], "name": a.get("name"), "pk": a["pk"], + "group": a.get("group") or "", "icon": a.get("meta_icon") or "", + "open": not blist, "n_groups": len(g), "n_users": len(u), + "n_other": other}) + out.sort(key=lambda x: ((x["group"] or "~~").lower(), (x["name"] or "").lower())) + cache.put("access:list", out, 30) + return out + + +async def app_access(slug): + """Bundne grupper/brugere for én app (til redigering).""" + async with _client(20) as c: + r = (await c.get("/api/v3/core/applications/", + params={"slug": slug, "superuser_full_list": "true"})).json() + results = r.get("results", []) + if not results: + return None + app_ = results[0] + b = (await c.get("/api/v3/policies/bindings/", + params={"target": app_["pk"], "page_size": 100})).json().get("results", []) + g, u, other = _split_bindings(b) + return {"slug": app_["slug"], "name": app_.get("name"), "pk": app_["pk"], + "icon": app_.get("meta_icon") or "", "groups": g, "users": u, "n_other": other} + + +async def set_app_access(app_pk, want_groups, want_users): + """Diff & anvend simple gruppe-/bruger-bindings for én app. + Returnerer (antal_tilføjet, antal_fjernet). Rører ikke policy-/negate-bindings.""" + want_g = set(str(x) for x in want_groups) + want_u = set(str(x) for x in want_users) + async with _client(25) as c: + cur = (await c.get("/api/v3/policies/bindings/", + params={"target": app_pk, "page_size": 100})).json().get("results", []) + cur_g = {str(b["group"]): b["pk"] for b in cur if b.get("group") and not b.get("negate")} + cur_u = {str(b["user"]): b["pk"] for b in cur if b.get("user") is not None and not b.get("negate")} + orders = [b.get("order") or 0 for b in cur] + nxt = (max(orders) + 1) if orders else 0 + ops = [] + for gid in want_g - set(cur_g): + ops.append(c.post("/api/v3/policies/bindings/", + json={"target": app_pk, "group": gid, "order": nxt, "enabled": True})) + nxt += 1 + for uid in want_u - set(cur_u): + ops.append(c.post("/api/v3/policies/bindings/", + json={"target": app_pk, "user": int(uid), "order": nxt, "enabled": True})) + nxt += 1 + dels = [cur_g[g] for g in set(cur_g) - want_g] + [cur_u[u] for u in set(cur_u) - want_u] + for pk in dels: + ops.append(c.delete(f"/api/v3/policies/bindings/{pk}/")) + await asyncio.gather(*ops) + return len(want_g - set(cur_g)) + len(want_u - set(cur_u)), len(dels) diff --git a/config.py b/config.py index e0b2700..19adf91 100644 --- a/config.py +++ b/config.py @@ -21,12 +21,41 @@ RP_BASE_URL = os.environ.get("RP_BASE_URL", "https://rp.weircon.dk").rstrip("/") RP_API_KEY = os.environ.get("RP_API_KEY", "") ASSETS_VERSION_URL = os.environ.get("ASSETS_VERSION_URL", "https://assets.weircon.dk/VERSION") GEOIP_DB = os.environ.get("GEOIP_DB", str(DATA / "dbip-country.mmdb")) +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") # valgfri: hæver GitHub-rate-limit 60→5000/t +NTFY_URL = os.environ.get("NTFY_URL", "") # fx https://ntfy.weircon.dk +NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "ops") +NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "") # tk_… publish-token +DISCO_BASE_URL = os.environ.get("DISCO_BASE_URL", "") # DiscoPanel Connect-RPC (CT137) +DISCO_API_TOKEN = os.environ.get("DISCO_API_TOKEN", "") +DISCO_PANEL_URL = os.environ.get("DISCO_PANEL_URL", "") # bruger-vendt panel-URL til forsiden USERPREFS = DATA / "userprefs.json" IP_LABELS_LOCAL = DATA / "ip_labels.json" IP_LABELS_REMOTE = os.environ.get("IP_LABELS_REMOTE", "") SETTINGS_PATH = DATA / "settings.json" +# ---- Medie-oprydning (Emby + Sonarr/Radarr) ---- +EMBY_URL = os.environ.get("EMBY_URL", "").rstrip("/") +EMBY_API_KEY = os.environ.get("EMBY_API_KEY", "") +RADARR_URL = os.environ.get("RADARR_URL", "").rstrip("/") +RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "") +SONARR_URL = os.environ.get("SONARR_URL", "").rstrip("/") +SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "") +NTFY_MEDIA_TOPIC = os.environ.get("NTFY_MEDIA_TOPIC", "media") +MEDIA_STATE_PATH = DATA / "media_state.json" +MEDIA_SETTINGS_PATH = DATA / "media_settings.json" +# Standardindstillinger — kan redigeres i /admin/media: +MEDIA_DEFAULTS = { + "enabled": False, # dry-run indtil du selv slår sletning til + "age_days": 90, # tilføjet for mindst så længe siden + "grace_days": 14, # "på vej ud"-vindue før sletning + "disk_throttle": True, # slet kun når lageret er over disk_high % + "disk_high": 85, # start sletning over denne fyldningsgrad + "disk_low": 80, # stop igen når under denne + "do_movies": True, + "do_series": True, +} + GROUP_ORDER = {n: i for i, n in enumerate( ["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])} @@ -45,4 +74,8 @@ ADMIN_MODULES = [ "desc": "Opret og administrér brugere + adgang (via grupper)."}, {"key": "groups", "title": "Grupper", "icon": "👥", "desc": "Grupper og medlemmer — styrer hvilke services brugere har adgang til."}, + {"key": "access", "title": "Adgang til services", "icon": "🔐", + "desc": "Hvilke brugere og grupper kan tilgå hver enkelt service."}, + {"key": "media", "title": "Medie-oprydning", "icon": "🎬", + "desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."}, ] diff --git a/main.py b/main.py index 7dc6d58..e990c46 100644 --- a/main.py +++ b/main.py @@ -7,11 +7,15 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware +import time + import authentik as ak import cache import config +import mediaclean as mc import services as svc import store +import versions app = FastAPI(title="Weircon Portal") app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=True, @@ -83,18 +87,22 @@ async def dashboard_content(request: Request): user = current_user(request) if not user: raise HTTPException(401) + import asyncio + mc_task = asyncio.create_task(svc.minecraft_servers()) apps, error = [], None try: apps = await ak.fetch_user_apps(user["username"]) except Exception as e: error = f"Kunne ikke hente apps fra Authentik: {e}" + mc_servers = await mc_task prefs = store.get_prefs(user["username"]) fav_set = set(prefs["favorites"]) by_name = sorted(apps, key=lambda x: (x["name"] or "").lower()) return templates.TemplateResponse(request, "dashboard_body.html", { "request": request, "error": error, "groups": ak.group_apps(apps), "apps_sorted": by_name, "fav_apps": [a for a in by_name if a["slug"] in fav_set], - "favorites": list(fav_set), "view": prefs["view"]}) + "favorites": list(fav_set), "view": prefs["view"], + "mc_servers": mc_servers, "disco_url": config.DISCO_PANEL_URL}) # ---------------- admin ---------------- @@ -160,10 +168,20 @@ async def admin_cookies_save(request: Request, cookie_banner: str = Form(""), co @app.get("/admin/ops", response_class=HTMLResponse) async def admin_ops(request: Request): + # Hurtig shell uden data — indholdet hentes async fra /ops-content og pollers hvert 10s. user = require_admin(request) return templates.TemplateResponse(request, "admin_ops.html", { - "request": request, "user": user, "is_admin": True, - "version": await svc.assets_version(), "rp": await svc.rp_status()}) + "request": request, "user": user, "is_admin": True}) + + +@app.get("/ops-content", response_class=HTMLResponse) +async def ops_content(request: Request): + require_admin(request) + import asyncio + version, rp, vers = await asyncio.gather( + svc.assets_version(), svc.rp_status(), versions.all_versions()) + return templates.TemplateResponse(request, "admin_ops_body.html", { + "request": request, "version": version, "rp": rp, "versions": vers}) @app.get("/admin/stats", response_class=HTMLResponse) @@ -225,11 +243,19 @@ async def admin_user_edit(request: Request, pk: int): @app.post("/admin/users/{pk}") async def admin_user_save(request: Request, pk: int): - require_admin(request) + user = require_admin(request) f = await request.form() - await ak.update_user(pk, f.getlist("groups"), f.get("is_active") == "on", f.get("password") or "") - cache.clear("users", "apps") - return RedirectResponse("/admin/users?saved=Bruger gemt", status_code=303) + groups = f.getlist("groups") + # Guardrail: du kan ikke fjerne dig selv fra admin-gruppen (andre kan godt). + me = await ak.user_by_username(user["username"]) + admin_pk = await ak.admin_group_pk(config.ADMIN_GROUP) + note = "" + if me and str(pk) == me["pk"] and admin_pk in me["groups"] and admin_pk not in groups: + groups = groups + [admin_pk] + note = " (du kan ikke fjerne din egen admin-rolle — beholdt)" + await ak.update_user(pk, groups, f.get("is_active") == "on", f.get("password") or "") + cache.clear("users", "apps", "access") + return RedirectResponse(f"/admin/users?saved=Bruger gemt{note}", status_code=303) @app.get("/admin/groups", response_class=HTMLResponse) @@ -261,11 +287,179 @@ async def admin_group_edit(request: Request, gpk: str): @app.post("/admin/groups/{gpk}") async def admin_group_save(request: Request, gpk: str): + user = require_admin(request) + f = await request.form() + members = f.getlist("members") + # Guardrail: du kan ikke fjerne dig selv fra admin-gruppen (andre kan godt). + me = await ak.user_by_username(user["username"]) + admin_pk = await ak.admin_group_pk(config.ADMIN_GROUP) + note = "" + if me and str(gpk) == str(admin_pk) and admin_pk in me["groups"] and me["pk"] not in members: + members = members + [me["pk"]] + note = " (du kan ikke fjerne dig selv fra admin-gruppen — beholdt)" + await ak.set_group_members(gpk, members) + cache.clear("groups", "users", "apps", "access") + return RedirectResponse(f"/admin/groups?saved=Medlemmer gemt{note}", status_code=303) + + +# ---------------- adgang til services (per-app policy-bindings) ---------------- +@app.get("/admin/access", response_class=HTMLResponse) +async def admin_access(request: Request, saved: str = ""): + user = require_admin(request) + return templates.TemplateResponse(request, "admin_access.html", { + "request": request, "user": user, "is_admin": True, + "apps": await ak.list_apps_access(), "saved": saved}) + + +@app.get("/admin/access/{slug}", response_class=HTMLResponse) +async def admin_access_edit(request: Request, slug: str): + user = require_admin(request) + app_ = await ak.app_access(slug) + if not app_: + raise HTTPException(404, "Ukendt app") + admin_pk = await ak.admin_group_pk(config.ADMIN_GROUP) + me = await ak.user_by_username(user["username"]) + # "Følsom" udgangspunkt = adgang i dag KUN for admin-gruppen, eller KUN for den + # indloggede selv → udvidelse skal bekræftes (rød modal). + only_admins = app_["groups"] == {str(admin_pk)} and not app_["users"] + only_me = bool(me) and app_["users"] == {me["pk"]} and not app_["groups"] + sensitive = only_admins or only_me + return templates.TemplateResponse(request, "admin_access_edit.html", { + "request": request, "user": user, "is_admin": True, "app": app_, + "groups": await ak.list_groups(), "users": await ak.list_users(), + "admin_pk": str(admin_pk), "sensitive": sensitive, + "orig_groups": sorted(app_["groups"]), "orig_users": sorted(app_["users"])}) + + +@app.post("/admin/access/{slug}") +async def admin_access_save(request: Request, slug: str): + user = require_admin(request) + app_ = await ak.app_access(slug) + if not app_: + raise HTTPException(404, "Ukendt app") + f = await request.form() + want_groups = set(f.getlist("groups")) + want_users = set(f.getlist("users")) + # Guardrail: du må ikke fjerne din egen adgang til en service. + me = await ak.user_by_username(user["username"]) + if (want_groups or want_users) and me: + covered = bool(want_groups & me["groups"]) or me["pk"] in want_users + if not covered: + return RedirectResponse( + f"/admin/access/{slug}?saved=Afvist: du ville fjerne din egen adgang til '{app_['name']}'", + status_code=303) + added, removed = await ak.set_app_access(app_["pk"], want_groups, want_users) + cache.clear("access", "apps") + parts = [] + if added: + parts.append(f"{added} tilføjet") + if removed: + parts.append(f"{removed} fjernet") + detail = " · ".join(parts) if parts else "ingen ændringer" + return RedirectResponse(f"/admin/access?saved=Adgang til '{app_['name']}' gemt — {detail}", status_code=303) + + +# ---------------- medie-oprydning ---------------- +@app.get("/admin/media", response_class=HTMLResponse) +async def admin_media(request: Request, saved: str = ""): + user = require_admin(request) + ctx = {"request": request, "user": user, "is_admin": True, "saved": saved, + "settings": mc.get_settings(), "human": mc._human} + if not mc.configured(): + ctx["unconfigured"] = True + return templates.TemplateResponse(request, "admin_media.html", ctx) + inv = cache.get("media:inv") + if inv is None: + inv = await mc.inventory(ctx["settings"]) + cache.put("media:inv", inv, 120) + st = mc.get_state() + ctx.update({"unconfigured": False, "items": inv["items"], "watch_ok": inv["watch_ok"], + "collections": inv.get("collections", []), + "leaving": st["leaving"], "log": st["log"][:15], "disk": await mc.disk_usage(), + "now": time.time()}) + return templates.TemplateResponse(request, "admin_media.html", ctx) + + +@app.post("/api/media/keep") +async def api_media_keep(request: Request): + require_admin(request) + body = await request.json() + key, on = str(body.get("key") or ""), bool(body.get("on")) + kind, _, sid = key.partition(":") + if kind not in ("movie", "series") or not sid.isdigit(): + raise HTTPException(400, "Ugyldig nøgle") + st = mc.get_state() + if on: + st["keep"][key] = True + st["leaving"].pop(key, None) + else: + st["keep"].pop(key, None) + mc.save_state(st) + tag_ok, err = True, None + try: + await mc.set_keep_tag(kind, int(sid), on) + except Exception as e: + tag_ok, err = False, str(e)[:120] + cache.clear("media:") + return {"ok": True, "key": key, "on": on, "tag_ok": tag_ok, "error": err} + + +@app.post("/api/media/collection-keep") +async def api_media_collection_keep(request: Request): + require_admin(request) + body = await request.json() + tmdb, on = str(body.get("tmdb") or ""), bool(body.get("on")) + title = str(body.get("title") or "") + if not tmdb.isdigit(): + raise HTTPException(400, "Ugyldig samling") + st = mc.get_state() + if on: + st["keep_collections"][tmdb] = title + else: + st["keep_collections"].pop(tmdb, None) + mc.save_state(st) + cache.clear("media:") + return {"ok": True, "tmdb": tmdb, "on": on} + + +@app.post("/admin/media/run") +async def admin_media_run(request: Request): + require_admin(request) + inv = await mc.inventory(mc.get_settings()) + cache.put("media:inv", inv, 120) + if not inv["watch_ok"]: + return RedirectResponse("/admin/media?saved=Emby utilgængelig — kunne ikke beregne", status_code=303) + cands = [i for i in inv["items"] if i["status"] == "candidate"] + total = sum(i["size"] for i in cands) + s = mc.get_settings() + mode = "sletning AKTIV" if s["enabled"] else "dry-run (sletning slået fra)" + return RedirectResponse( + f"/admin/media?saved=Simulering: {len(cands)} kandidater · frigør {mc._human(total)} · {mode}", + status_code=303) + + +@app.post("/admin/media/settings") +async def admin_media_settings(request: Request): require_admin(request) f = await request.form() - await ak.set_group_members(gpk, f.getlist("members")) - cache.clear("groups", "users", "apps") - return RedirectResponse("/admin/groups?saved=Medlemmer gemt", status_code=303) + + def num(name, default, lo, hi): + try: + return max(lo, min(hi, int(f.get(name)))) + except (TypeError, ValueError): + return default + mc.save_settings({ + "enabled": f.get("enabled") == "on", + "do_movies": f.get("do_movies") == "on", + "do_series": f.get("do_series") == "on", + "disk_throttle": f.get("disk_throttle") == "on", + "age_days": num("age_days", 90, 1, 3650), + "grace_days": num("grace_days", 14, 0, 365), + "disk_high": num("disk_high", 85, 5, 100), + "disk_low": num("disk_low", 80, 1, 99), + }) + cache.clear("media:") + return RedirectResponse("/admin/media?saved=Indstillinger gemt", status_code=303) # ---------------- api ---------------- @@ -309,10 +503,12 @@ async def search_index(request: Request): {"type": "page", "title": "Admin-panel", "url": "/admin", "extra": "admin"}, {"type": "page", "title": "Brugere", "url": "/admin/users", "extra": "users brugere"}, {"type": "page", "title": "Grupper", "url": "/admin/groups", "extra": "groups grupper"}, + {"type": "page", "title": "Adgang til services", "url": "/admin/access", "extra": "access adgang bindings rettigheder"}, {"type": "page", "title": "Drift / egress", "url": "/admin/ops", "extra": "proxy version"}, {"type": "page", "title": "App-beskrivelser", "url": "/admin/apps", "extra": "apps"}, {"type": "page", "title": "IP → label & land", "url": "/admin/ip-labels", "extra": "ip geo"}, - {"type": "page", "title": "Statistik", "url": "/admin/stats", "extra": "matomo"}] + {"type": "page", "title": "Statistik", "url": "/admin/stats", "extra": "matomo"}, + {"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"}] try: for a in await ak.fetch_user_apps(user["username"]): items.append({"type": "app", "title": a["name"], "url": a["url"], diff --git a/media_job.py b/media_job.py new file mode 100644 index 0000000..f4085d5 --- /dev/null +++ b/media_job.py @@ -0,0 +1,25 @@ +"""Natlig medie-oprydningskørsel (systemd-timer). Genberegner kandidater, vedligeholder +karens-listen og sletter udløbne (hvis sletning er slået til i /admin/media). Sender ntfy.""" +import asyncio + +import mediaclean as mc + + +async def main(): + if not mc.configured(): + print("media_job: ikke konfigureret (mangler EMBY/RADARR/SONARR env) — springer over") + return + res = await mc.run_cycle(dry_run=False) + if res.get("error"): + print("media_job FEJL:", res["error"]) + return + print(f"media_job: kandidater={res['candidates']} nye={len(res['new'])} " + f"fjernet={len(res['removed'])} slettet={len(res['deleted'])} " + f"frigjort={mc._human(res['freed'])} throttled={res['throttled']} " + f"sletning={'til' if res['enabled'] else 'fra(dry)'}") + for e in res.get("errors", []): + print(" fejl:", e) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mediaclean.py b/mediaclean.py new file mode 100644 index 0000000..64f1217 --- /dev/null +++ b/mediaclean.py @@ -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} diff --git a/services.py b/services.py index 11682d4..9c77648 100644 --- a/services.py +++ b/services.py @@ -1,8 +1,9 @@ -"""Eksterne tjenester: geoip, static-assets-version, random-proxy status, Matomo.""" +"""Eksterne tjenester: geoip, static-assets-version, random-proxy status, Matomo, DiscoPanel.""" import httpx -from config import (ASSETS_VERSION_URL, GEOIP_DB, MATOMO_BASE, MATOMO_SITE, - MATOMO_TOKEN, RP_API_KEY, RP_BASE_URL) +import cache +from config import (ASSETS_VERSION_URL, DISCO_API_TOKEN, DISCO_BASE_URL, GEOIP_DB, + MATOMO_BASE, MATOMO_SITE, MATOMO_TOKEN, RP_API_KEY, RP_BASE_URL) _geoip_reader = None @@ -50,6 +51,49 @@ async def rp_status(): return {"error": str(exc)[:200]} +_MC_STATUS = { # DiscoPanel ServerStatus → (dansk label, css-klasse) + "SERVER_STATUS_RUNNING": ("Kører", "run"), + "SERVER_STATUS_STARTING": ("Starter…", "warn"), + "SERVER_STATUS_RESTARTING": ("Genstarter…", "warn"), + "SERVER_STATUS_CREATING": ("Oprettes…", "warn"), + "SERVER_STATUS_STOPPING": ("Stopper…", "warn"), + "SERVER_STATUS_STOPPED": ("Stoppet", "off"), + "SERVER_STATUS_ERROR": ("Fejl", "err"), + "SERVER_STATUS_UNHEALTHY": ("Usund", "err"), +} + + +async def minecraft_servers(): + """Serverliste fra DiscoPanel (CT 137, Connect-RPC). None = ikke konfigureret/utilgængelig.""" + if not DISCO_API_TOKEN: + return None + hit = cache.get("mc:servers") + if hit is not None: + return hit + try: + async with httpx.AsyncClient(timeout=6) as c: + r = await c.post(f"{DISCO_BASE_URL}/discopanel.v1.ServerService/ListServers", + json={}, headers={"Authorization": f"Bearer {DISCO_API_TOKEN}", + "Content-Type": "application/json"}) + r.raise_for_status() + data = r.json() + servers = [] + for s in data.get("servers", []): + label, css = _MC_STATUS.get(s.get("status"), ("Ukendt", "off")) + servers.append({ + "name": s.get("name") or "?", + "status": label, "css": css, + "online": s.get("status") == "SERVER_STATUS_RUNNING", + "players": int(s.get("playersOnline") or 0), + "max_players": int(s.get("maxPlayers") or 0), + "version": s.get("serverVersion") or s.get("mcVersion") or ""}) + servers.sort(key=lambda x: (not x["online"], x["name"].lower())) + cache.put("mc:servers", servers, 15) + return servers + except Exception: + return None + + async def matomo_summary(): if not MATOMO_TOKEN: return None diff --git a/templates/admin_access.html b/templates/admin_access.html new file mode 100644 index 0000000..d36e4cc --- /dev/null +++ b/templates/admin_access.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}Admin — adgang til services{% endblock %} +{% block body %} +
+Styr hvilke brugere og grupper der kan tilgå hver enkelt service. + Klik på en service for at redigere adgangen.
+{% if saved %}Vælg de grupper og brugere der skal have adgang. + Ingen markering = åben for alle autentificerede brugere.
+ +{% if not app.groups and not app.users and not app.n_other %} +Finder film & serier, der aldrig er set af nogen og blev tilføjet for længe siden,
+ og sletter dem automatisk efter en karensperiode — medmindre du beholder dem. Behold via Emby-favorit,
+ keep-tag i Sonarr/Radarr, hele samlinger eller knappen her; alle vises samlet.
EMBY_URL/EMBY_API_KEY,
+ RADARR_URL/RADARR_API_KEY og SONARR_URL/SONARR_API_KEY i /etc/portal.env.Markér en samling, så alle dens film — også dem du tilføjer senere — aldrig slettes.
+| Titel | Type | Størrelse | Tilføjet | Status |
|---|
| {{ l.title }} | {{ 'Film' if l.type=='movie' else 'Serie' }} | {{ human(l.size) }} |
Live driftsoverblik. Genindlæser automatisk hvert 20. sekund.
+Live driftsoverblik — versioner, opdateringer og random-proxy. + Opdaterer automatisk hvert 10. sekund i baggrunden.
-| Tjeneste | Din version | Nyeste | Status | Support (EOL) |
|---|---|---|---|---|
| {{ v.name }} | +{{ v.current or '—' }} | +{{ v.latest or '—' }} | ++ {% if v.status == 'ok' %}✓ opdateret + {% elif v.status == 'update' %}↑ {{ v.level or 'opdatering' }}: {{ v.latest }} + {% elif v.status == 'latest-only' %}nyeste: {{ v.latest or '—' }} + {% else %}ukendt{% endif %} + | ++ {% if not v.eol %}— + {% elif v.eol.state == 'eol' %}⚠ EOL{% if v.eol.date %} siden {{ v.eol.date }}{% endif %} + {% elif v.eol.state == 'soon' %}EOL {{ v.eol.date }}{% if v.eol.days is defined %} ({{ v.eol.days }}d){% endif %} + {% else %}{% if v.eol.date %}✓ til {{ v.eol.date }}{% else %}✓ supporteret{% endif %}{% endif %} + {% if v.eol and v.eol.state in ('eol','soon') and v.eol.next %}→ {{ v.eol.next.cycle }}{% if v.eol.next.latest %} ({{ v.eol.next.latest }}){% endif %}{% endif %} + | +
Tjenester du har adgang til.
-{% if saved %}Tjenester du har adgang til.
+{% if saved %}