Tre fejl, der forstærkede hinanden: - Hver tilføjelse startede sin egen kø-tømning, så flere hentninger kørte samtidig (og natlig timer kunne køre oveni). Mange samtidige hentninger fra samme IP er netop det YouTube svarer 403 på. Køen har nu en fil-lås (yt_koe.lock), så kun én kørsel henter, også på tværs af processer. - yt-dlp gentager ikke selv en 403. Midlertidige fejl (403/429/timeout) prøves nu igen op til to gange med pause og en anden YouTube-klient (tv,web_safari og mweb). Permanente fejl prøves ikke igen. - Et job der stod som "running", da portalen genstartede, blev hængende for altid. Med låsen ved vi, at et sådant job er afbrudt, og det lægges i kø igen ved næste kørsel. Fejlede job har en "Prøv igen"-knap (og "Prøv alle fejlede igen"), og den natlige kørsel opdaterer yt-dlp, før den henter. Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
1201 lines
52 KiB
Python
1201 lines
52 KiB
Python
"""Weircon Portal — FastAPI-app: OIDC-login, async dashboard + udvidbar admin.
|
|
Forretningslogik ligger i moduler: config, cache, store, services, authentik."""
|
|
from authlib.integrations.starlette_client import OAuth
|
|
from fastapi import FastAPI, Form, HTTPException, Request
|
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
import asyncio
|
|
import re
|
|
import time
|
|
import urllib.parse
|
|
|
|
import authentik as ak
|
|
import cache
|
|
import config
|
|
import mediaclean as mc
|
|
import services as svc
|
|
import store
|
|
import versions
|
|
import ytdl as yt
|
|
import mcpkeys
|
|
import mcp_server
|
|
import mcp_gateway
|
|
import secretdrop
|
|
import claudelogin
|
|
import podcast
|
|
|
|
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
|
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
|
|
mcp_app = mcp_gateway.gateway.http_app(path="/mcp", json_response=True)
|
|
app = FastAPI(title="Weircon Portal", lifespan=mcp_app.lifespan)
|
|
for _mcp_route in mcp_app.routes:
|
|
app.router.routes.append(_mcp_route)
|
|
# route-extend copies routes but NOT middleware — carry FastMCP's auth/context
|
|
# middleware over so the /mcp auth (OIDC + static dual-auth) actually runs.
|
|
for _mcp_mw in reversed(mcp_app.user_middleware):
|
|
app.add_middleware(_mcp_mw.cls, *_mcp_mw.args, **_mcp_mw.kwargs)
|
|
app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=True,
|
|
same_site="lax", max_age=60 * 60 * 12)
|
|
app.mount("/static", StaticFiles(directory=config.BASE / "static"), name="static")
|
|
templates = Jinja2Templates(directory=str(config.BASE / "templates"))
|
|
|
|
oauth = OAuth()
|
|
oauth.register(
|
|
name="authentik",
|
|
server_metadata_url=f"{config.AUTHENTIK}/application/o/weircon-portal/.well-known/openid-configuration",
|
|
client_id=config.CLIENT_ID, client_secret=config.CLIENT_SECRET,
|
|
client_kwargs={"scope": "openid email profile groups"})
|
|
|
|
|
|
def current_user(request: Request):
|
|
return request.session.get("user")
|
|
|
|
|
|
def is_admin(user) -> bool:
|
|
return bool(user) and config.ADMIN_GROUP in (user.get("groups") or [])
|
|
|
|
|
|
def require_admin(request: Request):
|
|
user = current_user(request)
|
|
if not user and request.method == "GET" and "text/html" in request.headers.get("accept", ""):
|
|
# Ikke logget ind: login og tilbage hertil (fx et link fra en ntfy),
|
|
# i stedet for en 403-blindgyde.
|
|
nxt = request.url.path + (f"?{request.url.query}" if request.url.query else "")
|
|
raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")})
|
|
if not user:
|
|
raise HTTPException(401, "Log ind først")
|
|
if not is_admin(user):
|
|
raise HTTPException(403, "Kun for administratorer")
|
|
return user
|
|
|
|
|
|
def require_familie(request: Request):
|
|
"""Logget ind og i en af PODCAST_GROUPS (eller admin). Uden login: login og tilbage hertil."""
|
|
user = current_user(request)
|
|
if not user and request.method == "GET":
|
|
nxt = request.url.path + (f"?{request.url.query}" if request.url.query else "")
|
|
raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")})
|
|
if not user:
|
|
raise HTTPException(401, "Log ind først")
|
|
if not (is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or [])):
|
|
raise HTTPException(403, "Kun for familien")
|
|
return user
|
|
|
|
|
|
def _sikker_next(nxt: str | None) -> str:
|
|
# Kun stier på denne side; "//host" og "/\\host" ville sende videre ud af huset.
|
|
if nxt and nxt.startswith("/") and not nxt.startswith(("//", "/\\")):
|
|
return nxt
|
|
return "/"
|
|
|
|
|
|
# ---------------- auth ----------------
|
|
@app.get("/auth/login")
|
|
async def login(request: Request, next: str = ""):
|
|
request.session["next"] = _sikker_next(next)
|
|
return await oauth.authentik.authorize_redirect(request, config.REDIRECT_URI)
|
|
|
|
|
|
@app.get("/auth/callback")
|
|
async def callback(request: Request):
|
|
try:
|
|
token = await oauth.authentik.authorize_access_token(request)
|
|
except Exception as e:
|
|
return HTMLResponse(f"Login fejlede: {e}", status_code=400)
|
|
info = token.get("userinfo") or {}
|
|
request.session["user"] = {
|
|
"name": info.get("name") or info.get("preferred_username"),
|
|
"username": info.get("preferred_username"), "email": info.get("email"),
|
|
"groups": info.get("groups") or []}
|
|
return RedirectResponse(_sikker_next(request.session.pop("next", "/")), status_code=302)
|
|
|
|
|
|
@app.get("/auth/logout")
|
|
async def logout(request: Request):
|
|
request.session.clear()
|
|
return RedirectResponse("/", status_code=302)
|
|
|
|
|
|
# ---------------- forside (async: shell straks, indhold via /dashboard-content) ----------------
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def home(request: Request, saved: str = ""):
|
|
user = current_user(request)
|
|
if not user:
|
|
return templates.TemplateResponse(request, "login.html", {"request": request})
|
|
return templates.TemplateResponse(request, "dashboard.html", {
|
|
"request": request, "user": user, "is_admin": is_admin(user), "saved": saved})
|
|
|
|
|
|
@app.get("/dashboard-content", response_class=HTMLResponse)
|
|
async def dashboard_content(request: Request):
|
|
user = current_user(request)
|
|
if not user:
|
|
raise HTTPException(401)
|
|
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"],
|
|
"mc_servers": mc_servers, "disco_url": config.DISCO_PANEL_URL,
|
|
"podcasts": _podcast_kort(user)})
|
|
|
|
|
|
def _podcast_kort(user) -> list[dict]:
|
|
"""Kort til forsiden: én pr. podcast med udgivne episoder (admins ser også kladder)."""
|
|
if not (is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or [])):
|
|
return []
|
|
ud = []
|
|
for name in podcast.shows():
|
|
eps = podcast.episodes(name, med_kladder=is_admin(user))
|
|
if eps:
|
|
ud.append({**podcast.show(name), "antal": len(eps), "nyeste": eps[0]})
|
|
return ud
|
|
|
|
|
|
# ---------------- admin ----------------
|
|
@app.get("/admin", response_class=HTMLResponse)
|
|
async def admin_home(request: Request):
|
|
user = require_admin(request)
|
|
return templates.TemplateResponse(request, "admin.html", {
|
|
"request": request, "user": user, "modules": config.ADMIN_MODULES, "is_admin": True})
|
|
|
|
|
|
@app.get("/admin/ip-labels", response_class=HTMLResponse)
|
|
async def admin_iplabels(request: Request, saved: str = ""):
|
|
user = require_admin(request)
|
|
data = store.load_json(config.IP_LABELS_LOCAL, {})
|
|
rows = [{"ip": k, "label": (v or {}).get("label", ""), "country": (v or {}).get("country", "")}
|
|
for k, v in data.items() if isinstance(v, dict)]
|
|
rows.sort(key=lambda r: r["ip"])
|
|
raw = config.IP_LABELS_LOCAL.read_text("utf-8") if config.IP_LABELS_LOCAL.exists() else "{}"
|
|
return templates.TemplateResponse(request, "admin_iplabels.html", {
|
|
"request": request, "user": user, "is_admin": True, "rows": rows, "raw": raw, "saved": saved})
|
|
|
|
|
|
@app.post("/admin/ip-labels")
|
|
async def admin_iplabels_save(request: Request):
|
|
require_admin(request)
|
|
form = await request.form()
|
|
data = {}
|
|
for ip, label, country in zip(form.getlist("ip"), form.getlist("label"), form.getlist("country")):
|
|
ip = (ip or "").strip()
|
|
if not ip:
|
|
continue
|
|
entry = {}
|
|
if (label or "").strip():
|
|
entry["label"] = label.strip()
|
|
if (country or "").strip():
|
|
entry["country"] = country.strip().lower()
|
|
if entry:
|
|
data[ip] = entry
|
|
return RedirectResponse(f"/admin/ip-labels?saved={store.save_ip_labels(data)}", status_code=303)
|
|
|
|
|
|
@app.get("/admin/cookies", response_class=HTMLResponse)
|
|
async def admin_cookies(request: Request, saved: str = ""):
|
|
user = require_admin(request)
|
|
s = store.load_json(config.SETTINGS_PATH, {})
|
|
return templates.TemplateResponse(request, "admin_cookies.html", {
|
|
"request": request, "user": user, "is_admin": True,
|
|
"banner": s.get("cookie_banner", "Denne side bruger cookies til at forbedre din oplevelse og lave anonym statistik over besøg. Ved at bruge siden accepterer du det."),
|
|
"page": s.get("cookie_page", "Vi bruger Matomo (self-hosted) til anonym statistik og et par funktionelle cookies. Intet deles med tredjeparter."),
|
|
"saved": saved})
|
|
|
|
|
|
@app.post("/admin/cookies")
|
|
async def admin_cookies_save(request: Request, cookie_banner: str = Form(""), cookie_page: str = Form("")):
|
|
require_admin(request)
|
|
import json
|
|
s = store.load_json(config.SETTINGS_PATH, {})
|
|
s["cookie_banner"] = cookie_banner
|
|
s["cookie_page"] = cookie_page
|
|
config.SETTINGS_PATH.write_text(json.dumps(s, ensure_ascii=False, indent=2), "utf-8")
|
|
return RedirectResponse("/admin/cookies?saved=Gemt", status_code=303)
|
|
|
|
|
|
@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})
|
|
|
|
|
|
@app.get("/ops-content", response_class=HTMLResponse)
|
|
async def ops_content(request: Request):
|
|
require_admin(request)
|
|
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)
|
|
async def admin_stats(request: Request):
|
|
user = require_admin(request)
|
|
return templates.TemplateResponse(request, "admin_stats.html", {
|
|
"request": request, "user": user, "is_admin": True,
|
|
"stats": await svc.matomo_summary(), "matomo": config.MATOMO_BASE})
|
|
|
|
|
|
@app.get("/admin/apps", response_class=HTMLResponse)
|
|
async def admin_apps(request: Request, saved: str = ""):
|
|
user = require_admin(request)
|
|
return templates.TemplateResponse(request, "admin_apps.html", {
|
|
"request": request, "user": user, "is_admin": True,
|
|
"rows": await ak.fetch_all_apps_admin(), "saved": saved})
|
|
|
|
|
|
@app.post("/admin/apps")
|
|
async def admin_apps_save(request: Request):
|
|
require_admin(request)
|
|
form = await request.form()
|
|
rows = [{"slug": sl, "desc": (d or "").strip(), "group": (g or "").strip()}
|
|
for sl, d, g in zip(form.getlist("slug"), form.getlist("desc"), form.getlist("group")) if sl]
|
|
n = await ak.patch_apps(rows)
|
|
cache.clear("apps")
|
|
return RedirectResponse(f"/?saved=Gemt {n} apps", status_code=303)
|
|
|
|
|
|
@app.get("/admin/users", response_class=HTMLResponse)
|
|
async def admin_users(request: Request, saved: str = ""):
|
|
user = require_admin(request)
|
|
return templates.TemplateResponse(request, "admin_users.html", {
|
|
"request": request, "user": user, "is_admin": True,
|
|
"users": await ak.list_users(), "groups": await ak.list_groups(), "saved": saved})
|
|
|
|
|
|
@app.post("/admin/users")
|
|
async def admin_users_create(request: Request):
|
|
require_admin(request)
|
|
f = await request.form()
|
|
username = (f.get("username") or "").strip()
|
|
if not username:
|
|
return RedirectResponse("/admin/users?saved=Brugernavn mangler", status_code=303)
|
|
ok, res = await ak.create_user(username, (f.get("name") or "").strip(),
|
|
(f.get("email") or "").strip(), f.get("password") or "", f.getlist("groups"))
|
|
cache.clear("users", "apps")
|
|
msg = f"Bruger '{username}' oprettet" if ok else f"Fejl: {res}"
|
|
return RedirectResponse(f"/admin/users?saved={msg}", status_code=303)
|
|
|
|
|
|
@app.get("/admin/users/{pk}", response_class=HTMLResponse)
|
|
async def admin_user_edit(request: Request, pk: int):
|
|
user = require_admin(request)
|
|
return templates.TemplateResponse(request, "admin_user_edit.html", {
|
|
"request": request, "user": user, "is_admin": True,
|
|
"u": await ak.get_user(pk), "groups": await ak.list_groups()})
|
|
|
|
|
|
@app.post("/admin/users/{pk}")
|
|
async def admin_user_save(request: Request, pk: int):
|
|
user = require_admin(request)
|
|
f = await request.form()
|
|
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)
|
|
|
|
|
|
INV_VARIGHED = [(30, "30 minutter"), (120, "2 timer"), (1440, "1 dag"), (4320, "3 dage"),
|
|
(10080, "7 dage"), (20160, "14 dage"), (43200, "30 dage")]
|
|
|
|
|
|
@app.get("/admin/invitations", response_class=HTMLResponse)
|
|
async def admin_invitations(request: Request, ny: str = "", saved: str = ""):
|
|
user = require_admin(request)
|
|
grupper = [g for g in await ak.list_groups() if g["name"] not in ak.INV_SKJUL]
|
|
navn = {str(g["pk"]): g["name"] for g in await ak.list_groups()}
|
|
invs = await ak.list_invitations()
|
|
from datetime import datetime, timezone
|
|
nu = datetime.now(timezone.utc)
|
|
for i in invs:
|
|
i["gruppenavne"] = [navn.get(str(x), "?") for x in i["grupper"]]
|
|
try:
|
|
rest = datetime.fromisoformat(i["expires"].replace("Z", "+00:00")) - nu
|
|
sek = int(rest.total_seconds())
|
|
i["udloebet"] = sek <= 0
|
|
i["rest"] = ("udløbet" if sek <= 0 else f"{sek // 86400} dage" if sek >= 86400
|
|
else f"{sek // 3600} timer" if sek >= 3600 else f"{max(sek // 60, 1)} min")
|
|
except Exception:
|
|
i["udloebet"], i["rest"] = False, "?"
|
|
nyt = next((i for i in invs if str(i["pk"]) == ny), None)
|
|
return templates.TemplateResponse(request, "admin_invitations.html", {
|
|
"request": request, "user": user, "is_admin": True, "grupper": grupper,
|
|
"invitationer": invs, "nyt": nyt, "varigheder": INV_VARIGHED, "saved": saved})
|
|
|
|
|
|
@app.post("/admin/invitations")
|
|
async def admin_invitations_create(request: Request):
|
|
require_admin(request)
|
|
f = await request.form()
|
|
grupper = f.getlist("grupper")
|
|
minutter = int(f.get("minutter") or 20160)
|
|
if minutter not in {m for m, _ in INV_VARIGHED}:
|
|
minutter = 20160
|
|
if not grupper:
|
|
return RedirectResponse("/admin/invitations?saved=Vælg mindst én gruppe", status_code=303)
|
|
ok, res = await ak.create_invitation((f.get("label") or "").strip(), grupper, minutter,
|
|
engangs=bool(f.get("engangs")))
|
|
if not ok:
|
|
return RedirectResponse(f"/admin/invitations?saved=Kunne ikke oprette: {res[:120]}", status_code=303)
|
|
return RedirectResponse(f"/admin/invitations?ny={res}", status_code=303)
|
|
|
|
|
|
@app.post("/admin/invitations/{pk}/slet")
|
|
async def admin_invitations_delete(request: Request, pk: str):
|
|
require_admin(request)
|
|
ok = await ak.delete_invitation(pk)
|
|
return RedirectResponse(f"/admin/invitations?saved={'Invitationen er tilbagekaldt' if ok else 'Kunne ikke tilbagekalde'}",
|
|
status_code=303)
|
|
|
|
|
|
@app.get("/admin/secrets", response_class=HTMLResponse)
|
|
async def admin_secrets(request: Request, saved: str = "", gemt: str = ""):
|
|
user = require_admin(request)
|
|
return templates.TemplateResponse(request, "admin_secrets.html", {
|
|
"request": request, "user": user, "is_admin": True,
|
|
"ventende": secretdrop.pending(), "saved": saved, "gemt": gemt})
|
|
|
|
|
|
@app.post("/admin/secrets")
|
|
async def admin_secrets_store(request: Request):
|
|
user = require_admin(request)
|
|
f = await request.form()
|
|
navn = (f.get("navn") or "").strip().lower()
|
|
fejl = secretdrop.store(navn, f.get("vaerdi") or "", f.get("note") or "",
|
|
(user or {}).get("preferred_username") or (user or {}).get("email") or "?")
|
|
if fejl:
|
|
return RedirectResponse(f"/admin/secrets?saved={fejl}", status_code=303)
|
|
# Værdien sendes aldrig tilbage; kun navnet, så siden kan bekræfte det.
|
|
return RedirectResponse(f"/admin/secrets?gemt={navn}", status_code=303)
|
|
|
|
|
|
@app.post("/admin/secrets/{navn}/slet")
|
|
async def admin_secrets_delete(request: Request, navn: str):
|
|
require_admin(request)
|
|
ok = secretdrop.delete(navn)
|
|
return RedirectResponse(f"/admin/secrets?saved={'Slettet' if ok else 'Fandtes ikke'}", status_code=303)
|
|
|
|
|
|
# ---------------- podcast (Slægtens Stemmer) ----------------
|
|
def _klient_ip(request: Request) -> str:
|
|
return request.headers.get("x-real-ip") or (request.client.host if request.client else "?")
|
|
|
|
|
|
def _podcast_kontekst(request: Request, rod: str, med_kladder: bool) -> list[dict]:
|
|
"""Shows til podcast.html; rod = "/podcast/<nøgle>" eller "/podcast/f/<fælles nøgle>"."""
|
|
base = str(request.base_url).rstrip("/")
|
|
ud = []
|
|
for name in podcast.shows():
|
|
s = podcast.show(name)
|
|
feed = f"{base}{rod}/{name}.xml"
|
|
ud.append({**s, "feed": feed, "uden_skema": feed.split("://", 1)[1],
|
|
"feed_enc": urllib.parse.quote(feed, safe=""),
|
|
"episoder": podcast.episodes(name, med_kladder=med_kladder)})
|
|
return ud
|
|
|
|
|
|
def _ikke_fundet(request: Request):
|
|
podcast.forkert(_klient_ip(request))
|
|
raise HTTPException(404)
|
|
|
|
|
|
@app.get("/podcast", response_class=HTMLResponse)
|
|
async def podcast_side(request: Request, saved: str = ""):
|
|
user = require_familie(request)
|
|
admin = is_admin(user)
|
|
key = podcast.noegle_til(user["username"], admin)
|
|
rod = f"/podcast/{key}"
|
|
return templates.TemplateResponse(request, "podcast.html", {
|
|
"request": request, "user": user, "is_admin": admin, "rod": rod, "delt": False,
|
|
"udsendelser": _podcast_kontekst(request, rod, med_kladder=admin), "saved": saved})
|
|
|
|
|
|
@app.post("/podcast/ny-noegle")
|
|
async def podcast_ny_noegle(request: Request):
|
|
user = require_familie(request)
|
|
podcast.ny_noegle(user["username"], is_admin(user))
|
|
return RedirectResponse("/podcast?saved=Ny+adresse+lavet.+Abonnér+igen+i+din+app;+den+gamle+virker+ikke+længere.",
|
|
status_code=303)
|
|
|
|
|
|
# Det fælles familielink: ingen login, ingen kladder, noindex. Står før de
|
|
# personlige ruter, så "f" aldrig forveksles med en nøgle.
|
|
# Messenger/Facebooks link-forhåndsvisning og deres indbyggede browsere.
|
|
_FORHAANDSVISNING = re.compile(r"facebookexternalhit|meta-external(agent|fetcher)|Facebot|WhatsApp", re.I)
|
|
_INDBYGGET = re.compile(r"FBAN|FBAV|FB_IAB|FBIOS|Messenger|Instagram|LinkedInApp", re.I)
|
|
|
|
|
|
@app.get("/podcast/f/{key}/", response_class=HTMLResponse)
|
|
async def podcast_faelles_side(request: Request, key: str):
|
|
ua = request.headers.get("user-agent", "")
|
|
forhaandsvisning = bool(_FORHAANDSVISNING.search(ua))
|
|
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key, "" if forhaandsvisning else "side"):
|
|
_ikke_fundet(request)
|
|
rod = f"/podcast/f/{key}"
|
|
base = str(request.base_url).rstrip("/")
|
|
udsendelser = _podcast_kontekst(request, rod, med_kladder=False)
|
|
s = udsendelser[0] if udsendelser else {"titel": "Podcast", "slug": "", "har_cover": False}
|
|
# Et lille omslag (300 px), så Messenger viser en miniature ved teksten i
|
|
# stedet for et billede i hele beskedens bredde.
|
|
lille = bool(s.get("slug")) and podcast.cover(s["slug"], lille=True) is not None
|
|
og = {"titel": s["titel"], "url": f"{base}{rod}/",
|
|
"beskrivelse": "Familiens podcast. Lyt i browseren, eller abonnér i din podcast-app.",
|
|
"billede": (f"{base}{rod}/{s['slug']}/cover-lille.jpg" if lille else
|
|
f"{base}{rod}/{s['slug']}/cover.jpg" if s.get("har_cover") else ""),
|
|
"str": 300 if lille else 3000}
|
|
headers = {"X-Robots-Tag": "noindex, nofollow, noarchive", "Referrer-Policy": "no-referrer",
|
|
"Cache-Control": "private, no-store"}
|
|
if forhaandsvisning:
|
|
# Forhåndsvisningen får kun titel, en neutral tekst og omslaget, aldrig
|
|
# episodelisten (den nævner familiemedlemmer ved navn).
|
|
return templates.TemplateResponse(request, "podcast_og.html", {"request": request, "og": og}, headers=headers)
|
|
indbygget = None
|
|
if _INDBYGGET.search(ua):
|
|
# Messenger på iPhone blokerer også x-safari-https://, så dér er der ingen
|
|
# knap ud af appen: vejledning til menuen + kopiér feedet til Apple Podcasts.
|
|
# Android-udgaven af Facebooks browser følger intent://-links.
|
|
sti = f"{request.url.hostname}{rod}/"
|
|
if re.search(r"iPhone|iPad|iOS", ua):
|
|
indbygget = {"platform": "ios"}
|
|
elif "Android" in ua:
|
|
indbygget = {"platform": "android", "href": f"intent://{sti}#Intent;scheme=https;end"}
|
|
else:
|
|
indbygget = {"platform": "andet"}
|
|
return templates.TemplateResponse(request, "podcast.html", {
|
|
"request": request, "user": None, "is_admin": False, "rod": rod, "delt": True, "og": og,
|
|
"indbygget": indbygget, "udsendelser": udsendelser, "saved": ""}, headers=headers)
|
|
|
|
|
|
@app.get("/podcast/f/{key}/{name}.xml")
|
|
async def podcast_faelles_feed(request: Request, key: str, name: str):
|
|
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key, "feed"):
|
|
_ikke_fundet(request)
|
|
xml = podcast.feed(name, f"/podcast/f/{key}", str(request.base_url).rstrip("/"), med_kladder=False)
|
|
if xml is None:
|
|
raise HTTPException(404)
|
|
return Response(xml, media_type="application/rss+xml; charset=utf-8",
|
|
headers={"Cache-Control": "private, max-age=300", "X-Robots-Tag": "noindex, nofollow"})
|
|
|
|
|
|
@app.get("/podcast/f/{key}/{name}/cover-lille.jpg")
|
|
async def podcast_faelles_cover_lille(request: Request, key: str, name: str):
|
|
p = podcast.cover(name, lille=True)
|
|
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key) or not p:
|
|
_ikke_fundet(request)
|
|
return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"})
|
|
|
|
|
|
@app.get("/podcast/f/{key}/{name}/cover.jpg")
|
|
async def podcast_faelles_cover(request: Request, key: str, name: str):
|
|
p = podcast.cover(name)
|
|
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key) or not p:
|
|
_ikke_fundet(request)
|
|
return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"})
|
|
|
|
|
|
@app.get("/podcast/f/{key}/{name}/{slug}.mp3")
|
|
async def podcast_faelles_mp3(request: Request, key: str, name: str, slug: str):
|
|
rng = request.headers.get("range", "")
|
|
fra_start = not rng or rng.replace(" ", "").startswith("bytes=0-")
|
|
p = podcast.episode_fil(name, slug)
|
|
meta = podcast.episode_meta(name, slug) or {}
|
|
if (podcast.blokeret(_klient_ip(request)) or not p or meta.get("kladde")
|
|
or not podcast.faelles_slaa_op(key, slug if fra_start else "")):
|
|
_ikke_fundet(request)
|
|
return FileResponse(p, media_type="audio/mpeg",
|
|
headers={"Cache-Control": "private, max-age=86400", "X-Robots-Tag": "noindex, nofollow"})
|
|
|
|
|
|
@app.get("/podcast/{key}/{name}.xml")
|
|
async def podcast_feed(request: Request, key: str, name: str):
|
|
if podcast.blokeret(_klient_ip(request)):
|
|
_ikke_fundet(request)
|
|
v = podcast.slaa_op(key, request.headers.get("user-agent", ""), "feed")
|
|
if not v:
|
|
_ikke_fundet(request)
|
|
xml = podcast.feed(name, f"/podcast/{key}", str(request.base_url).rstrip("/"), med_kladder=bool(v.get("admin")))
|
|
if xml is None:
|
|
raise HTTPException(404)
|
|
return Response(xml, media_type="application/rss+xml; charset=utf-8",
|
|
headers={"Cache-Control": "private, max-age=300", "X-Robots-Tag": "noindex, nofollow"})
|
|
|
|
|
|
@app.get("/podcast/{key}/{name}/cover.jpg")
|
|
async def podcast_cover(request: Request, key: str, name: str):
|
|
p = podcast.cover(name)
|
|
if (podcast.blokeret(_klient_ip(request)) or not p or not podcast.KEY.match(key)
|
|
or not any(k["key"] == key and not k.get("tilbagekaldt") for k in podcast.alle_noegler())):
|
|
_ikke_fundet(request)
|
|
return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"})
|
|
|
|
|
|
@app.get("/podcast/{key}/{name}/{slug}.mp3")
|
|
async def podcast_mp3(request: Request, key: str, name: str, slug: str):
|
|
if podcast.blokeret(_klient_ip(request)):
|
|
_ikke_fundet(request)
|
|
p = podcast.episode_fil(name, slug)
|
|
rng = request.headers.get("range", "")
|
|
# Apps henter i bidder; kun en hentning fra filens start tælles som en afspilning.
|
|
fra_start = not rng or rng.replace(" ", "").startswith("bytes=0-")
|
|
if fra_start:
|
|
v = podcast.slaa_op(key, request.headers.get("user-agent", ""), slug)
|
|
else:
|
|
v = next((k for k in podcast.alle_noegler() if k["key"] == key and not k.get("tilbagekaldt")), None)
|
|
if not v or not p:
|
|
_ikke_fundet(request)
|
|
meta = podcast.episode_meta(name, slug) or {}
|
|
if meta.get("kladde") and not v.get("admin"):
|
|
raise HTTPException(404)
|
|
return FileResponse(p, media_type="audio/mpeg",
|
|
headers={"Cache-Control": "private, max-age=86400", "X-Robots-Tag": "noindex, nofollow"})
|
|
|
|
|
|
@app.get("/admin/podcast", response_class=HTMLResponse)
|
|
async def admin_podcast(request: Request, saved: str = ""):
|
|
user = require_admin(request)
|
|
return templates.TemplateResponse(request, "admin_podcast.html", {
|
|
"request": request, "user": user, "is_admin": True, "saved": saved,
|
|
"noegler": podcast.alle_noegler(), "faelles": podcast.faelles(),
|
|
"base": str(request.base_url).rstrip("/"),
|
|
"udsendelser": [{**podcast.show(n), "episoder": podcast.episodes(n, med_kladder=True)} for n in podcast.shows()]})
|
|
|
|
|
|
@app.post("/admin/podcast/faelles")
|
|
async def admin_podcast_faelles(request: Request):
|
|
require_admin(request)
|
|
f = await request.form()
|
|
handling = f.get("handling")
|
|
if handling == "skift":
|
|
podcast.faelles_skift()
|
|
besked = "Nyt fælles link lavet. Det gamle virker ikke længere."
|
|
elif handling in ("til", "fra"):
|
|
podcast.faelles_aktiv(handling == "til")
|
|
besked = "Fælles link slået til" if handling == "til" else "Fælles link slået fra"
|
|
else:
|
|
besked = "Ukendt handling"
|
|
return RedirectResponse(f"/admin/podcast?saved={urllib.parse.quote(besked)}", status_code=303)
|
|
|
|
|
|
@app.post("/admin/podcast/noegle/{key}")
|
|
async def admin_podcast_noegle(request: Request, key: str):
|
|
require_admin(request)
|
|
f = await request.form()
|
|
ok = podcast.tilbagekald(key, aktiv=f.get("aktiv") == "1")
|
|
return RedirectResponse(f"/admin/podcast?saved={'Gemt' if ok else 'Ukendt nøgle'}", status_code=303)
|
|
|
|
|
|
@app.post("/admin/podcast/{name}/{slug}/udgiv")
|
|
async def admin_podcast_udgiv(request: Request, name: str, slug: str):
|
|
require_admin(request)
|
|
f = await request.form()
|
|
kladde = f.get("kladde") == "1"
|
|
ok = podcast.saet_kladde(name, slug, kladde)
|
|
besked = ("Lagt tilbage som kladde" if kladde else "Udgivet til familien") if ok else "Ukendt episode"
|
|
return RedirectResponse(f"/admin/podcast?saved={besked}", status_code=303)
|
|
|
|
|
|
@app.get("/admin/claude-login", response_class=HTMLResponse)
|
|
async def admin_claude_login(request: Request):
|
|
user = require_admin(request)
|
|
return templates.TemplateResponse(request, "admin_claude_login.html", {
|
|
"request": request, "user": user, "is_admin": True, "st": claudelogin.state()})
|
|
|
|
|
|
@app.get("/admin/claude-login/status")
|
|
async def admin_claude_login_status(request: Request):
|
|
require_admin(request)
|
|
return JSONResponse(claudelogin.state(), headers={"Cache-Control": "no-store"})
|
|
|
|
|
|
@app.post("/admin/claude-login/start")
|
|
async def admin_claude_login_start(request: Request):
|
|
user = require_admin(request)
|
|
claudelogin.request((user or {}).get("username") or "?")
|
|
return JSONResponse(claudelogin.state())
|
|
|
|
|
|
@app.post("/admin/claude-login/kode")
|
|
async def admin_claude_login_code(request: Request):
|
|
require_admin(request)
|
|
f = await request.form()
|
|
fejl = claudelogin.submit_code(f.get("kode") or "")
|
|
return JSONResponse({"error": fejl, **claudelogin.state()}, status_code=400 if fejl else 200)
|
|
|
|
|
|
@app.get("/admin/groups", response_class=HTMLResponse)
|
|
async def admin_groups(request: Request, saved: str = ""):
|
|
user = require_admin(request)
|
|
return templates.TemplateResponse(request, "admin_groups.html", {
|
|
"request": request, "user": user, "is_admin": True,
|
|
"groups": await ak.list_groups(), "saved": saved})
|
|
|
|
|
|
@app.post("/admin/groups")
|
|
async def admin_groups_create(request: Request):
|
|
require_admin(request)
|
|
f = await request.form()
|
|
name = (f.get("name") or "").strip()
|
|
if name:
|
|
await ak.create_group(name)
|
|
cache.clear("groups")
|
|
return RedirectResponse(f"/admin/groups?saved=Gruppe '{name}' oprettet", status_code=303)
|
|
|
|
|
|
@app.get("/admin/groups/{gpk}", response_class=HTMLResponse)
|
|
async def admin_group_edit(request: Request, gpk: str):
|
|
user = require_admin(request)
|
|
return templates.TemplateResponse(request, "admin_group_edit.html", {
|
|
"request": request, "user": user, "is_admin": True,
|
|
"g": await ak.group_members(gpk), "users": await ak.list_users()})
|
|
|
|
|
|
@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()
|
|
|
|
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 ----------------
|
|
@app.get("/api/settings")
|
|
async def api_settings():
|
|
return JSONResponse(store.load_json(config.SETTINGS_PATH, {}))
|
|
|
|
|
|
@app.post("/api/favorite")
|
|
async def api_favorite(request: Request):
|
|
user = current_user(request)
|
|
if not user:
|
|
raise HTTPException(401)
|
|
body = await request.json()
|
|
favs = set(store.get_prefs(user["username"])["favorites"])
|
|
slug = str(body.get("slug") or "")
|
|
favs.add(slug) if body.get("on") else favs.discard(slug)
|
|
store.set_prefs(user["username"], favorites=sorted(favs))
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/api/view")
|
|
async def api_view(request: Request):
|
|
user = current_user(request)
|
|
if not user:
|
|
raise HTTPException(401)
|
|
v = (await request.json()).get("view")
|
|
if v in ("grouped", "alpha"):
|
|
store.set_prefs(user["username"], view=v)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/search-index")
|
|
async def search_index(request: Request):
|
|
user = current_user(request)
|
|
if not user:
|
|
return JSONResponse([])
|
|
items = [{"type": "page", "title": "Forside", "url": "/"}]
|
|
if is_admin(user):
|
|
items += [
|
|
{"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": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"},
|
|
{"type": "page", "title": "YouTube offline", "url": "/admin/yt", "extra": "youtube yt-dlp download offline fly emby"},
|
|
{"type": "page", "title": "MCP-nøgler", "url": "/admin/mcp-keys", "extra": "mcp api nøgler token claude"}]
|
|
try:
|
|
for a in await ak.fetch_user_apps(user["username"]):
|
|
items.append({"type": "app", "title": a["name"], "url": a["url"],
|
|
"extra": f'{a["desc"]} {a["host"]}'.strip()})
|
|
except Exception:
|
|
pass
|
|
return JSONResponse(items)
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz():
|
|
return {"ok": True, "configured": bool(config.CLIENT_ID and config.API_TOKEN)}
|
|
|
|
|
|
# ---------------- MCP-nøgler (central registry) ----------------
|
|
@app.get("/admin/mcp-keys", response_class=HTMLResponse)
|
|
async def admin_mcp_keys(request: Request, saved: str = ""):
|
|
user = require_admin(request)
|
|
external = [{**e, "command": mcpkeys.build_command(
|
|
mcp_id=e["id"], transport=e.get("transport", "http"), url=e.get("url"),
|
|
token=e.get("token"), header=e.get("auth_header", "Authorization"),
|
|
command=e.get("command"))} for e in mcpkeys.list_external()]
|
|
portal_tokens = mcpkeys.list_portal_tokens()
|
|
new_token = request.session.pop("mcp_new", None)
|
|
oauth_access = mcpkeys.get_oauth_access()
|
|
configured = oauth_access.get("users") or {}
|
|
# Service accounts (outposts etc.) are not people and would only clutter the
|
|
# picker. If Authentik is unreachable the page still renders — the rows fall
|
|
# back to the bare username.
|
|
try:
|
|
people = [u for u in await ak.list_users()
|
|
if not u["username"].startswith("ak-outpost-")
|
|
and (u.get("type") or "internal") != "service_account"]
|
|
except Exception:
|
|
people = []
|
|
by_name = {u["username"]: u for u in people}
|
|
|
|
def _label(username):
|
|
u = by_name.get(username)
|
|
full = (u or {}).get("name") or ""
|
|
return f"{full} ({username})" if full else username
|
|
|
|
oauth_rows = [{"username": u, "allowed": v, "label": _label(u)}
|
|
for u, v in sorted(configured.items())]
|
|
ak_users_available = sorted(
|
|
({"username": u["username"], "label": _label(u["username"])}
|
|
for u in people if u["username"] not in configured),
|
|
key=lambda x: x["label"].lower())
|
|
return templates.TemplateResponse(request, "admin_mcp_keys.html", {
|
|
"request": request, "user": user, "is_admin": True, "external": external,
|
|
"portal_tokens": portal_tokens, "self_url": mcpkeys.SELF_URL, "saved": saved,
|
|
"new_token": new_token, "namespaces": mcpkeys.namespaces(),
|
|
"oauth_access": oauth_access, "oauth_rows": oauth_rows,
|
|
"ak_users_available": ak_users_available})
|
|
|
|
|
|
@app.post("/admin/mcp-keys")
|
|
async def admin_mcp_keys_post(request: Request):
|
|
require_admin(request)
|
|
form = await request.form()
|
|
action = form.get("action", "")
|
|
if action == "save_external":
|
|
eid = (form.get("id") or "").strip()
|
|
if eid:
|
|
mcpkeys.upsert_external({
|
|
"id": eid, "label": (form.get("label") or eid).strip(),
|
|
"transport": (form.get("transport") or "http").strip(),
|
|
"url": (form.get("url") or "").strip(),
|
|
"auth_header": (form.get("auth_header") or "Authorization").strip(),
|
|
"token": (form.get("token") or "").strip(),
|
|
"command": (form.get("command") or "").strip(),
|
|
"notes": (form.get("notes") or "").strip()})
|
|
return RedirectResponse("/admin/mcp-keys?saved=Gemt", status_code=303)
|
|
if action == "delete_external":
|
|
mcpkeys.delete_external((form.get("id") or "").strip())
|
|
return RedirectResponse("/admin/mcp-keys?saved=Slettet", status_code=303)
|
|
if action == "set_allowed":
|
|
mcpkeys.set_token_allowed((form.get("name") or "").strip(),
|
|
form.getlist("allowed"))
|
|
return RedirectResponse("/admin/mcp-keys?saved=Adgang gemt", status_code=303)
|
|
if action == "set_oauth_default":
|
|
mcpkeys.set_oauth_default(form.getlist("default"))
|
|
return RedirectResponse("/admin/mcp-keys?saved=Standard gemt", status_code=303)
|
|
if action == "set_oauth_user":
|
|
who = (form.get("name") or "").strip()
|
|
if who:
|
|
mcpkeys.set_oauth_user(who, form.getlist("allowed"))
|
|
return RedirectResponse("/admin/mcp-keys?saved=Adgang gemt", status_code=303)
|
|
if action == "del_oauth_user":
|
|
mcpkeys.del_oauth_user((form.get("name") or "").strip())
|
|
return RedirectResponse("/admin/mcp-keys?saved=Fjernet", status_code=303)
|
|
if action == "mint_portal":
|
|
name = (form.get("name") or "").strip() or "ny-nøgle"
|
|
tok = mcpkeys.mint_portal_token(name, form.getlist("allowed"))
|
|
cmd = mcpkeys.build_command(mcp_id="weircon", transport="http",
|
|
url=mcpkeys.SELF_URL, token=tok)
|
|
request.session["mcp_new"] = {"name": name, "token": tok, "command": cmd}
|
|
return RedirectResponse("/admin/mcp-keys?saved=Token oprettet — kopiér det nu, det vises kun én gang", status_code=303)
|
|
if action == "revoke_portal":
|
|
mcpkeys.revoke_portal_token((form.get("name") or "").strip())
|
|
return RedirectResponse("/admin/mcp-keys?saved=Token spærret", status_code=303)
|
|
if action == "healthcheck":
|
|
eid = (form.get("id") or "").strip()
|
|
for e in mcpkeys.list_external():
|
|
if e["id"] == eid:
|
|
e["last_check"] = await mcpkeys.health_check(e)
|
|
mcpkeys.upsert_external(e)
|
|
return RedirectResponse("/admin/mcp-keys?saved=Tjekket", status_code=303)
|
|
return RedirectResponse("/admin/mcp-keys", status_code=303)
|
|
|
|
|
|
# ---------------- YouTube offline ----------------
|
|
@app.get("/admin/yt", response_class=HTMLResponse)
|
|
async def admin_yt(request: Request, saved: str = ""):
|
|
user = require_admin(request)
|
|
st = yt.get_state()
|
|
jobs = sorted(st["jobs"].values(), key=lambda j: j["added"], reverse=True)
|
|
ctx = {"request": request, "user": user, "is_admin": True, "saved": saved,
|
|
"settings": yt.get_settings(), "human": yt._human, "now": time.time(),
|
|
"download_dir": config.YT_DOWNLOAD_DIR, "unconfigured": False,
|
|
"ours": [], "ours_size": 0, "leaving": st["leaving"], "log": st["log"][:15],
|
|
"jobs": jobs[:25], "inv_error": "", "subs": [],
|
|
"queued": sum(1 for j in jobs if j["status"] == "queued"),
|
|
"running": any(j["status"] == "running" for j in jobs)}
|
|
if not yt.configured():
|
|
ctx["unconfigured"] = True
|
|
return templates.TemplateResponse(request, "admin_yt.html", ctx)
|
|
inv = cache.get("yt:inv")
|
|
if inv is None:
|
|
inv = await yt.emby_yt_items()
|
|
cache.put("yt:inv", inv, 60)
|
|
ctx["inv_error"] = inv.get("error") or ""
|
|
owned = yt.owned_paths()
|
|
ours = [{**it, "size": yt.file_size(it["path"]),
|
|
"delete_after": (st["leaving"].get(it["path"]) or {}).get("delete_after")}
|
|
for it in inv.get("items", []) if it["path"] in owned]
|
|
ours.sort(key=lambda v: v["name"].lower())
|
|
ctx["ours"] = ours
|
|
ctx["ours_size"] = sum(v["size"] for v in ours)
|
|
favs = {it["path"] for it in inv.get("items", []) if it["favorite"]}
|
|
subs = []
|
|
for sb in st["subs"].values():
|
|
files = yt.sub_files(st, sb["id"])
|
|
subs.append({**sb,
|
|
"on_disk": len(files),
|
|
"in_cycle": sum(1 for f in files if f["path"] not in favs),
|
|
"favorites": sum(1 for f in files if f["path"] in favs)})
|
|
subs.sort(key=lambda x: (x["kind"], x["title"].lower()))
|
|
ctx["subs"] = subs
|
|
return templates.TemplateResponse(request, "admin_yt.html", ctx)
|
|
|
|
|
|
async def _yt_drain():
|
|
"""Tøm download-køen i baggrunden, så web-svaret ikke venter på yt-dlp.
|
|
Timeren tager den igen hvis portalen genstarter midt i en hentning."""
|
|
try:
|
|
await yt.process_queue()
|
|
cache.clear("yt:")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@app.post("/admin/yt/add")
|
|
async def admin_yt_add(request: Request, url: str = Form(""), playlist: str = Form("")):
|
|
require_admin(request)
|
|
url = (url or "").strip()
|
|
if not url.startswith(("http://", "https://")):
|
|
return RedirectResponse("/admin/yt?saved=Afvist: det ligner ikke en URL", status_code=303)
|
|
yt.add_job(url, playlist=bool(playlist))
|
|
asyncio.create_task(_yt_drain())
|
|
return RedirectResponse("/admin/yt?saved=Lagt i kø — hentningen er i gang", status_code=303)
|
|
|
|
|
|
@app.post("/admin/yt/igen")
|
|
async def admin_yt_retry(request: Request):
|
|
"""Prøv ét fejlet job igen (jid) eller alle fejlede (uden jid)."""
|
|
require_admin(request)
|
|
f = await request.form()
|
|
jid = f.get("jid") or ""
|
|
if jid:
|
|
ids = [jid]
|
|
else:
|
|
ids = [j["id"] for j in yt.get_state()["jobs"].values() if j["status"] == "error"]
|
|
n = sum(1 for i in ids if yt.retry_job(i))
|
|
if n:
|
|
asyncio.create_task(_yt_drain())
|
|
msg = f"Prøver {n} igen" if n else "Intet at prøve igen"
|
|
return RedirectResponse(f"/admin/yt?saved={urllib.parse.quote(msg)}", status_code=303)
|
|
|
|
|
|
@app.post("/admin/yt/slet")
|
|
async def admin_yt_delete(request: Request):
|
|
require_admin(request)
|
|
f = await request.form()
|
|
res = yt.delete_video(f.get("path") or "")
|
|
if not res["ok"]:
|
|
return RedirectResponse(f"/admin/yt?saved={urllib.parse.quote(res['error'])}", status_code=303)
|
|
cache.clear("yt:")
|
|
await yt.refresh_library()
|
|
return RedirectResponse(
|
|
"/admin/yt?saved=" + urllib.parse.quote(f"Slettet: {res['title']} ({yt._human(res['size'])})"),
|
|
status_code=303)
|
|
|
|
|
|
@app.post("/admin/yt/settings")
|
|
async def admin_yt_settings(request: Request):
|
|
require_admin(request)
|
|
f = await request.form()
|
|
patch = {k: bool(f.get(k)) for k in ("enabled", "sponsorblock", "subtitles", "metadata")}
|
|
try:
|
|
patch["grace_days"] = max(0, min(365, int(f.get("grace_days") or 7)))
|
|
except ValueError:
|
|
pass
|
|
for k in ("sub_langs", "library_name", "quality"):
|
|
if f.get(k):
|
|
patch[k] = str(f.get(k)).strip()
|
|
yt.save_settings(patch)
|
|
cache.clear("yt:")
|
|
return RedirectResponse("/admin/yt?saved=Indstillinger gemt", status_code=303)
|
|
|
|
|
|
@app.post("/admin/yt/run")
|
|
async def admin_yt_run(request: Request):
|
|
require_admin(request)
|
|
res = await yt.run_cycle(dry_run=False)
|
|
cache.clear("yt:")
|
|
if res.get("error"):
|
|
return RedirectResponse(f"/admin/yt?saved=Fejl: {res['error']}", status_code=303)
|
|
msg = (f"Kørt — {res['seen']} af vores videoer · {len(res['new'])} nye på vej ud · "
|
|
f"{len(res['spared'])} fredet · {len(res['deleted'])} slettet")
|
|
return RedirectResponse(f"/admin/yt?saved={msg}", status_code=303)
|
|
|
|
|
|
@app.post("/admin/yt/sub/add")
|
|
async def admin_yt_sub_add(request: Request, url: str = Form(""), kind: str = Form(""),
|
|
cycle: str = Form("")):
|
|
require_admin(request)
|
|
url = (url or "").strip()
|
|
if not url.startswith(("http://", "https://")):
|
|
return RedirectResponse("/admin/yt?saved=Afvist: det ligner ikke en URL", status_code=303)
|
|
kind = kind if kind in ("playlist", "channel") else yt.sub_kind_from_url(url)
|
|
try:
|
|
n = int(cycle or 0)
|
|
except ValueError:
|
|
n = 0
|
|
if kind == "channel" and n < 1:
|
|
return RedirectResponse(
|
|
"/admin/yt?saved=Afvist: en kanal skal have et cycle-tal (hvor mange nye videoer du vil have liggende)",
|
|
status_code=303)
|
|
sub = await yt.add_sub(url, kind=kind, cycle=n if kind == "channel" else 0)
|
|
asyncio.create_task(_yt_subs_run())
|
|
cache.clear("yt:")
|
|
return RedirectResponse(
|
|
f"/admin/yt?saved=Overvåger nu {sub['title']} — henter i baggrunden", status_code=303)
|
|
|
|
|
|
@app.post("/admin/yt/sub/{sid}/save")
|
|
async def admin_yt_sub_save(request: Request, sid: str, cycle: str = Form(""),
|
|
enabled: str = Form("")):
|
|
require_admin(request)
|
|
patch = {"enabled": bool(enabled)}
|
|
try:
|
|
if cycle != "":
|
|
patch["cycle"] = max(0, int(cycle))
|
|
except ValueError:
|
|
pass
|
|
yt.set_sub(sid, **patch)
|
|
cache.clear("yt:")
|
|
return RedirectResponse("/admin/yt?saved=Abonnement gemt", status_code=303)
|
|
|
|
|
|
@app.post("/admin/yt/sub/{sid}/del")
|
|
async def admin_yt_sub_del(request: Request, sid: str):
|
|
require_admin(request)
|
|
yt.del_sub(sid)
|
|
cache.clear("yt:")
|
|
return RedirectResponse(
|
|
"/admin/yt?saved=Abonnement fjernet — allerede hentede videoer bliver liggende",
|
|
status_code=303)
|
|
|
|
|
|
async def _yt_subs_run():
|
|
"""Tjek abonnementer, hent det nye, og hold kanalernes cyklus."""
|
|
try:
|
|
await yt.sync_subs()
|
|
await yt.process_queue()
|
|
await yt.enforce_cycles()
|
|
cache.clear("yt:")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@app.post("/admin/yt/subs/check")
|
|
async def admin_yt_subs_check(request: Request):
|
|
require_admin(request)
|
|
res = await yt.sync_subs()
|
|
asyncio.create_task(_yt_subs_run())
|
|
cache.clear("yt:")
|
|
msg = f"Tjekkede {res['checked']} abonnementer — {res['queued']} nye videoer i kø"
|
|
if res["errors"]:
|
|
msg += f" · {len(res['errors'])} fejl: {res['errors'][0][:80]}"
|
|
return RedirectResponse(f"/admin/yt?saved={msg}", status_code=303)
|