From d9d5a12df122ec44eec6f477920eb373a2782da8 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Mon, 15 Jun 2026 12:27:24 +0200 Subject: [PATCH] reconcile: import current prod state from CT151 (media-cleanup, access admin, ops versioning, newer dashboard) --- authentik.py | 119 +++++++++ config.py | 33 +++ main.py | 218 ++++++++++++++++- media_job.py | 25 ++ mediaclean.py | 407 +++++++++++++++++++++++++++++++ services.py | 50 +++- templates/admin_access.html | 57 +++++ templates/admin_access_edit.html | 123 ++++++++++ templates/admin_media.html | 257 +++++++++++++++++++ templates/admin_ops.html | 175 +++++++++---- templates/admin_ops_body.html | 87 +++++++ templates/dashboard.html | 55 +++-- templates/dashboard_body.html | 57 +++-- versions.py | 303 +++++++++++++++++++++++ 14 files changed, 1875 insertions(+), 91 deletions(-) create mode 100644 media_job.py create mode 100644 mediaclean.py create mode 100644 templates/admin_access.html create mode 100644 templates/admin_access_edit.html create mode 100644 templates/admin_media.html create mode 100644 templates/admin_ops_body.html create mode 100644 versions.py 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 %} +

← Admin

+

Adgang til services

+

Styr hvilke brugere og grupper der kan tilgå hver enkelt service. + Klik på en service for at redigere adgangen.

+{% if saved %}
{{ saved }}
{% endif %} + +
+ ⌕ + +
+ +
+ {% for a in apps %} + +
+ {% if a.icon %}{% else %}{{ (a.name or '?')[0]|upper }}{% endif %} + {{ a.name }} +
+
+ {% if a.open %} + 🌐 åben for alle + {% else %} + {% if a.n_groups %}{{ a.n_groups }} gruppe{{ 'r' if a.n_groups != 1 }}{% endif %} + {% if a.n_users %}{{ a.n_users }} bruger{{ 'e' if a.n_users != 1 }}{% endif %} + {% if a.n_other %}+{{ a.n_other }} regel{% endif %} + {% endif %} +
+
Redigér adgang →
+
+ {% endfor %} +
+ + + + +{% endblock %} diff --git a/templates/admin_access_edit.html b/templates/admin_access_edit.html new file mode 100644 index 0000000..7cf34d5 --- /dev/null +++ b/templates/admin_access_edit.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} +{% block title %}Adgang — {{ app.name }}{% endblock %} + +{% macro gcard(g) %} + +{% endmacro %} + +{% macro pcard(u) %} + +{% endmacro %} + +{% block body %} +

← Adgang til services

+

{{ app.name }}

+

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 %} +
Denne service er i dag åben for alle der er logget ind. + Markerer du nogen her, bliver adgangen begrænset til kun de valgte.
+{% endif %} +{% if app.n_other %} +
Bemærk: servicen har {{ app.n_other }} ekstra regel (policy/negate) som styres i Authentik + og ikke kan ændres her — de bevares uændret.
+{% endif %} + +{% set people = users|rejectattr('type','in',['service_account','internal_service_account'])|list %} +{% set svcs = users|selectattr('type','in',['service_account','internal_service_account'])|list %} + +
+
+ ⌕ + +
+ +

Grupper

+
+ {% for g in groups %}{{ gcard(g) }}{% endfor %} +
+ +

Enkeltbrugere

+
+ {% for u in people %}{{ pcard(u) }}{% endfor %} +
+ {% if svcs %} +
+ Service-konti ({{ svcs|length }}) +
{% for u in svcs %}{{ pcard(u) }}{% endfor %}
+
+ {% endif %} + +

+ Annullér

+
+ + + + + + +{% endblock %} diff --git a/templates/admin_media.html b/templates/admin_media.html new file mode 100644 index 0000000..1181665 --- /dev/null +++ b/templates/admin_media.html @@ -0,0 +1,257 @@ +{% extends "base.html" %} +{% block title %}Admin — medie-oprydning{% endblock %} +{% block body %} +

Medie-oprydning

+

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.

+{% if saved %}
{{ saved }}
{% endif %} + +{% if unconfigured %} +
Ikke konfigureret endnu. Sæt EMBY_URL/EMBY_API_KEY, + RADARR_URL/RADARR_API_KEY og SONARR_URL/SONARR_API_KEY i /etc/portal.env.
+{% else %} + +{% if not watch_ok %}
⚠ Emby svarede ikke — se-data mangler, så intet markeres som kandidat (sikkerhed).
{% endif %} + +{# ---- topkort: disk + status ---- #} +{% set cands = items | selectattr('status','equalto','candidate') | list %} +{% set leavingrows = items | selectattr('delete_after') | list %} +{% set cand_size = cands | sum(attribute='size') %} +
+
+
Slow-lager
+ {% if disk.ok %} +
{{ disk.used_pct }}%
+
+
{{ human(disk.free) }} fri af {{ human(disk.total) }}
+ {% else %}
utilgængelig
{% endif %} +
+
Kandidater nu
+
{{ cands|length }}
+
frigør {{ human(cand_size) }}
+
På vej ud
+
{{ leavingrows|length }}
+
i karens
+
Sletning
+
{% if settings.enabled %}AKTIV{% else %}dry-run{% endif %}
+
{% if settings.disk_throttle %}kun over {{ settings.disk_high }}%{% else %}altid efter karens{% endif %}
+
+ +{# ---- indstillinger (redigerbare defaults) ---- #} +
+ ⚙️ Indstillinger +
+
+ + + + +
+
+ + + + +
+

+ + +

+
+
+ +{# ---- samlinger: behold hele samlinger (også fremtidige film) ---- #} +{% set kept_colls = collections | selectattr('kept') | list %} +
+ 📦 Samlinger — behold hele samlinger{% if kept_colls %} ({{ kept_colls|length }} valgt){% endif %} +

Markér en samling, så alle dens film — også dem du tilføjer senere — aldrig slettes.

+
⌕
+
+
+ + +{# ---- filter ---- #} +
+
+ ⌕ + +
+
+ + + + +
+
+ + + + +
TitelTypeStørrelseTilføjetStatus
+
+ + + +{% if log %} +

Seneste sletninger

+ + {% for l in log %}{% endfor %} +
{{ l.title }}{{ 'Film' if l.type=='movie' else 'Serie' }}{{ human(l.size) }}
+{% endif %} + + + +{% endif %} +{% endblock %} diff --git a/templates/admin_ops.html b/templates/admin_ops.html index 08a813e..5e78a6e 100644 --- a/templates/admin_ops.html +++ b/templates/admin_ops.html @@ -2,51 +2,142 @@ {% block title %}Admin — drift{% endblock %} {% block body %}

Drift / egress

-

Live driftsoverblik. Genindlæser automatisk hvert 20. sekund.

+

Live driftsoverblik — versioner, opdateringer og random-proxy. + Opdaterer automatisk hvert 10. sekund i baggrunden.

-

Static-assets

-
-
{{ version if version else '—' }}
-
deployet version (assets.weircon.dk)
-
- -

Random-proxy egress - {% if rp and not rp.error %} - {{ rp.available }}/{{ rp.proxies_total }} live · {{ rp.inflight_total }} inflight i alt{% endif %} -

-{% if rp.error %} -
Kunne ikke nå random-proxy: {{ rp.error }}
-{% else %} -{% set rot = rp.rotation %} -{% if rot %} -
-
Næste rollover
-
- slot {{ rot.next_slot }} - — {{ rot.next_egress_ip }} - {% if rot.geo and rot.geo.flag %}{{ rot.geo.flag }} {{ rot.geo.iso }}{% endif %} - ruller af om {{ '%d:%02d'|format(rot.eta_sec//60, rot.eta_sec%60) }} - (interval {{ rot.interval_sec }}s · sidst: slot {{ rot.last_slot }} for {{ rot.last_rotated_age_sec }}s siden) +
+
+ ⌕ + +
+
+ + + +
+
+ + + +
-{% endif %} -
- {% for p in rp.proxies %} -
-
- #{{ p.id }} - - {{ '⚠ down' if not p.reachable else (p.state or 'live') }} -
-
{{ p.egress_ip or '–' }}
-
- {% if p.geo and p.geo.flag %}{{ p.geo.flag }} {{ p.geo.iso }} · {% endif %}{{ p.inflight or 0 }} inflight - {% if p.latency_ms %}· {{ p.latency_ms }}ms{% endif %} -
- {% if rot and p.id == rot.next_slot %}
↻ ruller af næst
{% endif %} + +
+ - {% endfor %}
-{% endif %} - + + + {% endblock %} diff --git a/templates/admin_ops_body.html b/templates/admin_ops_body.html new file mode 100644 index 0000000..f65cdec --- /dev/null +++ b/templates/admin_ops_body.html @@ -0,0 +1,87 @@ +{# fragment — injiceres i #opscontent og pollers hvert 10s fra admin_ops.html #} +{% set updates = versions|selectattr('status','equalto','update')|list %} +{% set eol_now = versions|selectattr('eol')|selectattr('eol.state','equalto','eol')|list %} +{% set eol_soon = versions|selectattr('eol')|selectattr('eol.state','equalto','soon')|list %} + +
+

Versioner & opdateringer

+ {% if updates %}{{ updates|length }} opdatering{{ 'er' if updates|length != 1 }} klar + {% else %}alt opdateret{% endif %} + {% if eol_now %}{{ eol_now|length }} EOL{% endif %} + {% if eol_soon %}{{ eol_soon|length }} EOL snart{% endif %} +
+ + + + + {% for v in versions %} + {% set ec = v.eol.state if v.eol else 'none' %} + {% set near3 = v.eol and v.eol.state == 'soon' and v.eol.days is defined and v.eol.days < 92 %} + {% set rowclass = 'eolrow' if ec == 'eol' else ('eolsoon' if near3 else ('eolnear' if ec == 'soon' else ('updrow' if v.status == 'update' else ''))) %} + + + + + + + + {% endfor %} + +
TjenesteDin versionNyesteStatusSupport (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 %} +
+ + +

Static-assets

+
+
{{ version if version else '—' }}
+
deployet version (assets.weircon.dk)
+
+ +

Random-proxy egress + {% if rp and not rp.error %} + {{ rp.available }}/{{ rp.proxies_total }} live · {{ rp.inflight_total }} inflight i alt{% endif %} +

+{% if rp.error %} +
Kunne ikke nå random-proxy: {{ rp.error }}
+{% else %} +{% set rot = rp.rotation %} +{% if rot %} +
+
Næste rollover
+
+ slot {{ rot.next_slot }} + — {{ rot.next_egress_ip }} + {% if rot.geo and rot.geo.flag %}{{ rot.geo.flag }} {{ rot.geo.iso }}{% endif %} + ruller af om {{ '%d:%02d'|format(rot.eta_sec//60, rot.eta_sec%60) }} + (interval {{ rot.interval_sec }}s · sidst: slot {{ rot.last_slot }} for {{ rot.last_rotated_age_sec }}s siden) +
+
+{% endif %} +
+ {% for p in rp.proxies %} +
+
+ #{{ p.id }} + + {{ '⚠ down' if not p.reachable else (p.state or 'live') }} +
+
{{ p.egress_ip or '–' }}
+
+ {% if p.geo and p.geo.flag %}{{ p.geo.flag }} {{ p.geo.iso }} · {% endif %}{{ p.inflight or 0 }} inflight + {% if p.latency_ms %}· {{ p.latency_ms }}ms{% endif %} +
+ {% if rot and p.id == rot.next_slot %}
↻ ruller af næst
{% endif %} +
+ {% endfor %} +
+{% endif %} diff --git a/templates/dashboard.html b/templates/dashboard.html index af6f5d8..41ccb68 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -2,13 +2,13 @@ {% block title %}Weircon — forside{% endblock %} {% block body %}

Hej, {{ user.name }}

-

Tjenester du har adgang til.

-{% if saved %}
{{ saved }}
{% endif %} +

Tjenester du har adgang til.

+{% if saved %}
{{ saved }}
{% endif %}
- @@ -16,23 +16,46 @@ {% if is_admin %}

Administration

-
{% endif %} + {% endblock %} diff --git a/templates/dashboard_body.html b/templates/dashboard_body.html index 13285e8..ef01110 100644 --- a/templates/dashboard_body.html +++ b/templates/dashboard_body.html @@ -1,39 +1,58 @@ {% macro card(a) %} -
- +
+ -
- {% if a.icon and a.icon.startswith('http') %}{% else %}{{ (a.name or '?')[0]|upper }}{% endif %} - {{ a.name }} +
+ {% if a.icon and a.icon.startswith('http') %}{% else %}{{ (a.name or '?')[0]|upper }}{% endif %} + {{ a.name }}
-
{{ a.desc if a.desc else a.host }}
-
Åbn →
+
{{ a.desc if a.desc else a.host }}
+
Åbn →
{% endmacro %} -{% if error %}
{{ error }}
{% endif %} +{% if error %}
{{ error }}
{% endif %} {% if groups %} -
-
⌕ -
-
- - +
+
⌕ +
+
+ +
+{% if mc_servers %} +
+

⛏️ Minecraft-servere

+
+ {% for s in mc_servers %} +
+ +
⛏️{{ s.name }}
+
+ {{ s.status }} + {%- if s.online %} · {{ s.players }}/{{ s.max_players }} spillere{% endif %} + {%- if s.version %} · {{ s.version }}{% endif %} +
+
Åbn DiscoPanel →
+
+ {% endfor %} +
+
+{% endif %}
{% for g in groups %} -

{{ g.name }}

{% for a in g.apps %}{{ card(a) }}{% endfor %}
+

{{ g.name }}

{% for a in g.apps %}{{ card(a) }}{% endfor %}
{% endfor %}
-

Alle tjenester

{% for a in apps_sorted %}{{ card(a) }}{% endfor %}
+

Alle tjenester

{% for a in apps_sorted %}{{ card(a) }}{% endfor %}
- + {% elif not error %} -

Ingen apps fundet for din konto endnu.

+

Ingen apps fundet for din konto endnu.

{% endif %} diff --git a/versions.py b/versions.py new file mode 100644 index 0000000..a20ae65 --- /dev/null +++ b/versions.py @@ -0,0 +1,303 @@ +"""Version-/opdaterings-tjek for homelab-tjenester. Udvidbart register: +- 'kind' = hvor DIN kørende version hentes (authentik/gitea/matomo/assets/None). +- 'repo' = GitHub owner/repo → 'nyeste' release. None = ingen kilde. +Resultater caches 1 time (versioner ændrer sig sjældent; GitHub-rate-limit 60/t).""" +import asyncio +import json +import re + +import httpx + +import cache +from config import (API_TOKEN, AUTHENTIK, ASSETS_VERSION_URL, MATOMO_BASE, MATOMO_TOKEN, + DATA, GITHUB_TOKEN, NTFY_URL, NTFY_TOPIC, NTFY_TOKEN) + +# Kørende versioner for LXC-tjenester portalen ikke selv kan nå — indsamlet på +# Proxmox-værten af portal-versions.py (cron) og pushet hertil. Nøgler = SERVICES-navne. +LOCAL_VERSIONS = DATA / "versions_local.json" + +# Sidst-kendte-gode "nyeste"-tags pr. kilde, persisteret til disk. Et fetch-fejl +# (fx GitHub rate-limit 60/t eller en genstart) blanker dermed ALDRIG kolonnen — +# værdien opdateres kun ved et vellykket nyt opslag. +LATEST_STORE = DATA / "latest_versions.json" + + +def _load_local(): + try: + return json.loads(LOCAL_VERSIONS.read_text("utf-8")) + except Exception: + return {} + + +def _load_latest_store(): + try: + return json.loads(LATEST_STORE.read_text("utf-8")) + except Exception: + return {} + + +def _save_latest_store(d): + try: + LATEST_STORE.write_text(json.dumps(d, ensure_ascii=False, indent=2), "utf-8") + except Exception: + pass + +# (navn, kind til current-version, github-repo til nyeste). Tilføj en linje = ny tjeneste. +SERVICES = [ + ("Authentik", "authentik", "goauthentik/authentik"), + ("Gitea", "gitea", "go-gitea/gitea"), + ("Gitea runner", None, "gitea:gitea.com/gitea/runner"), + ("Matomo", "matomo", "matomo-org/matomo"), + ("Static-assets", "assets", None), + ("NPMplus", None, "ZoeyVid/NPMplus"), + ("Immich", None, "immich-app/immich"), + ("Mealie", None, "mealie-recipes/mealie"), + ("Firefly III", None, "firefly-iii/firefly-iii"), + ("Nextcloud", None, "nextcloud/server"), + ("Sonarr", None, "Sonarr/Sonarr"), + ("Radarr", None, "Radarr/Radarr"), + ("Prowlarr", None, "Prowlarr/Prowlarr"), + ("Bazarr", None, "morpheus65535/bazarr"), + ("qBittorrent", None, "qbittorrent/qBittorrent"), + ("Overleaf", None, "gitea-tag:gitea.weircon.dk/agw/overleaf-cep"), # cep-fork bruger tags (ikke releases) + ("OpenProject", None, "opf/openproject"), + ("SFTPGo", None, "drakkan/sftpgo"), + ("Opengist", None, "thomiceli/opengist"), + ("Home Assistant OS", None, "home-assistant/operating-system"), + ("Comentario", None, "gitlab:comentario/comentario"), + ("Emby", None, None), + ("MariaDB", None, None), + ("Proxmox VE", None, None), +] + + +def _norm(v): + if not v: + return "" + # behold dato-versioner (fx NPMplus 2026-04-21-r2) — strip kun v-præfiks + build-metadata + return re.sub(r"^[vV]", "", str(v).strip()).split("+")[0].strip().lower() + + +async def _latest(src, client, store): + """Nyeste release-tag. src er enten 'owner/repo' (GitHub, bagud-kompat) eller + 'gitlab:group/proj' / 'gitea:host/owner/repo'. `store` = sidst-kendte-gode-dict + (muteres in-place ved succes; bruges som fallback ved fetch-fejl).""" + if not src: + return None + provider, loc = src.split(":", 1) if ":" in src else ("github", src) + ck = f"ver:{provider}:{loc}" + hit = cache.get(ck) + if hit is not None: + return hit or store.get(src) or None + tag = None + try: + if provider == "github": + h = {"Accept": "application/vnd.github+json", "User-Agent": "weircon-portal"} + if GITHUB_TOKEN: + h["Authorization"] = f"Bearer {GITHUB_TOKEN}" + r = await client.get(f"https://api.github.com/repos/{loc}/releases/latest", headers=h) + tag = r.json().get("tag_name") if r.status_code < 300 else None + elif provider == "gitlab": + import urllib.parse + pid = urllib.parse.quote(loc, safe="") + r = await client.get(f"https://gitlab.com/api/v4/projects/{pid}/releases", + params={"per_page": 1}, headers={"User-Agent": "weircon-portal"}) + j = r.json() if r.status_code < 300 else [] + tag = j[0]["tag_name"] if isinstance(j, list) and j else None + elif provider == "gitea": + host, repo = loc.split("/", 1) + r = await client.get(f"https://{host}/api/v1/repos/{repo}/releases", + params={"limit": 1}, headers={"User-Agent": "weircon-portal"}) + j = r.json() if r.status_code < 300 else [] + tag = j[0]["tag_name"] if isinstance(j, list) and j else None + elif provider == "gitea-tag": + # repos der bruger tags i stedet for releases (fx overleaf-cep). Nyeste tag. + host, repo = loc.split("/", 1) + r = await client.get(f"https://{host}/api/v1/repos/{repo}/tags", + params={"limit": 1}, headers={"User-Agent": "weircon-portal"}) + j = r.json() if r.status_code < 300 else [] + tag = j[0]["name"] if isinstance(j, list) and j else None + except Exception: + tag = None + if tag: + store[src] = tag # opdatér sidst-kendte-gode + cache.put(ck, tag, 3600) + return tag + # fetch fejlede → fald tilbage til sidst-kendte-gode (overlever rate-limit/genstart) + cache.put(ck, "", 180) # prøv live igen snart + return store.get(src) or None + + +# endoflife.date-produkt pr. tjeneste (kun dem der faktisk er tracket der). +EOL_PRODUCTS = {"Proxmox VE": "proxmox-ve", "Nextcloud": "nextcloud", + "MariaDB": "mariadb", "Matomo": "matomo"} + + +async def _eol(name, cur, client): + """Slår kørende version op i endoflife.date → {eol-dato, state}. None hvis ikke tracket.""" + prod = EOL_PRODUCTS.get(name) + if not prod or not cur: + return None + ck = f"eol:{prod}" + data = cache.get(ck) + if data is None: + try: + r = await client.get(f"https://endoflife.date/api/{prod}.json", + headers={"User-Agent": "weircon-portal", "Accept": "application/json"}) + data = r.json() if r.status_code < 300 else [] + except Exception: + data = [] + cache.put(ck, data, 43200) # 12t — EOL-data ændrer sig sjældent + v = _norm(cur) + best = None + for c in data: + cyc = str(c.get("cycle")) + if v == cyc or v.startswith(cyc + "."): + if best is None or len(cyc) > len(str(best.get("cycle"))): + best = c + if not best: + return None + cycle = str(best.get("cycle")) + # næste major = cyklussen lige over den kørende (endoflife lister nyeste først) + nxt = None + try: + idx = data.index(best) + if idx > 0: + n = data[idx - 1] + nxt = {"cycle": str(n.get("cycle")), "latest": n.get("latest"), + "eol": n.get("eol") if isinstance(n.get("eol"), str) else None} + except Exception: + nxt = None + out = {"cycle": cycle, "next": nxt} + eol = best.get("eol") + if eol is True: + out.update(date=None, state="eol") + return out + if eol is False or eol is None: + out.update(date=None, state="supported") + return out + try: + import datetime + days = (datetime.date.fromisoformat(str(eol)) - datetime.date.today()).days + out.update(date=str(eol), days=days, + state="eol" if days <= 0 else ("soon" if days <= 183 else "supported")) + return out + except Exception: + out.update(date=str(eol), state="supported") + return out + + +async def _current(kind, client): + try: + if kind == "authentik": + r = await client.get(f"{AUTHENTIK}/api/v3/admin/version/", + headers={"Authorization": f"Bearer {API_TOKEN}"}) + d = r.json() + return d.get("version_current"), d.get("version_latest") + if kind == "gitea": + r = await client.get("https://gitea.weircon.dk/api/v1/version") + return r.json().get("version"), None + if kind == "matomo": + r = await client.get(f"{MATOMO_BASE}/index.php", params={ + "module": "API", "method": "API.getMatomoVersion", "format": "JSON", "token_auth": MATOMO_TOKEN}) + return r.json().get("value"), None + if kind == "assets": + r = await client.get(ASSETS_VERSION_URL) + return r.text.strip()[:40], None + except Exception: + pass + return None, None + + +def _level(cur, latest): + """Klassificér en tilgængelig opdatering: major / minor / patch (bugfix).""" + pa = [int(x) for x in re.findall(r"\d+", _norm(cur))] + pb = [int(x) for x in re.findall(r"\d+", _norm(latest))] + n = max(len(pa), len(pb)) + pa += [0] * (n - len(pa)) + pb += [0] * (n - len(pb)) + if not pa or not pb: + return None + if pa[0] != pb[0]: + return "major" + if n > 1 and pa[1] != pb[1]: + return "minor" + return "patch" + + +async def _one(name, kind, repo, client, local, store): + cur, lat_builtin = await _current(kind, client) + if not cur: + cur = local.get(name) # vært-indsamlet snapshot for LXC-tjenester + latest = lat_builtin or await _latest(repo, client, store) + eol = await _eol(name, cur, client) + level = None + if cur and latest: + if _norm(cur) == _norm(latest): + status = "ok" + else: + status = "update" + level = _level(cur, latest) + elif cur: + status = "unknown" # ingen 'nyeste'-kilde + else: + status = "latest-only" # kender kun nyeste, ikke din version + return {"name": name, "current": cur, "latest": latest, + "status": status, "level": level, "eol": eol} + + +ALERT_STATE = DATA / "alert_state.json" + + +async def _maybe_alert(res, client): + """Push en ntfy-besked når en NY opdatering eller EOL dukker op (ikke ved hver poll). + Første kørsel seedes tavst, så man ikke får alle eksisterende på én gang.""" + if not (NTFY_URL and NTFY_TOKEN): + return + cur, detail = set(), {} + for v in res: + if v["status"] == "update": + sig = f"upd:{v['name']}:{_norm(v['latest'])}" + cur.add(sig); detail[sig] = f"⬆ {v['name']} {v['current']} -> {v['latest']}" + e = v.get("eol") + if e and e.get("state") in ("eol", "soon"): + sig = f"eol:{v['name']}:{e.get('date')}" + cur.add(sig); detail[sig] = f"⏳ {v['name']} EOL {e.get('date')}" + try: + stored = set(json.loads(ALERT_STATE.read_text("utf-8"))) + except Exception: + stored = None + if stored is None: # første kørsel: seed tavst + ALERT_STATE.write_text(json.dumps(sorted(cur)), "utf-8") + return + new = cur - stored + if new: + body = "\n".join(detail[s] for s in sorted(new)) + try: + await client.post(f"{NTFY_URL}/{NTFY_TOPIC}", + headers={"Authorization": f"Bearer {NTFY_TOKEN}", + "Title": f"Weircon ops: {len(new)} nye", + "Tags": "package", "Click": "https://weircon.dk/admin/ops", + "Icon": "https://assets.weircon.dk/static/icons/icon.png"}, + content=body.encode("utf-8"), timeout=8) + except Exception: + pass + ALERT_STATE.write_text(json.dumps(sorted(cur)), "utf-8") + + +async def all_versions(): + hit = cache.get("ver:all") + if hit is not None: + return hit + local = _load_local() + store = _load_latest_store() + async with httpx.AsyncClient(timeout=8) as client: + res = await asyncio.gather(*[_one(n, k, r, client, local, store) for n, k, r in SERVICES]) + await _maybe_alert(res, client) + _save_latest_store(store) # persistér evt. nye sidst-kendte-gode tags + # Hvis "nyeste" mangler for tjenester der HAR en kilde (typisk rate-limit-blip), + # cache kun kort så det prøver igen snart; ellers fuld 30-min cache. + expects = {n for n, k, r in SERVICES if r or k == "authentik"} + incomplete = any(v["name"] in expects and not v["latest"] for v in res) + cache.put("ver:all", res, 180 if incomplete else 1800) + return res