Nyt admin-modul /admin/yt: indsæt en YouTube-URL, og videoen hentes med yt-dlp til Emby-biblioteket "YouTube" (/media/yt-offline) — klar til offline-brug. Oprydningen er bevidst modsat mediaclean.py: dér tæller selv delvis afspilning som "set" for at frede alt der er rørt, her slettes kun videoer der er eksplicit markeret som set (Emby UserData.Played). Halvt sete videoer bliver liggende. Favoritter slettes aldrig, og en favoritering i karensperioden freder igen. Sikkerhedsnet: oprydningen rører kun filer portalen selv har hentet (registreret med fuld sti i yt_state.json). Det eksisterende YT-bibliotek er urørligt, og download-træet er lagt uden for det, så Emby ikke indekserer dobbelt. Standardvalg: - H.264/AAC foretrækkes frem for AV1. Pascal-GPU'en i CT100 kan ikke afkode AV1, og mange afspillere kan det slet ikke offline. Koster ~1,8x diskplads. - SponsorBlock klipper sponsorater ud og beholder resten som kapitler. - Undertekster hentes hvis de findes, men er aldrig et krav: YouTube rate-limiter undertekst-endpointet uafhængigt af videoen, så en 429 dér ville ellers vælte hele hentningen. Fald-tilbage henter videoen uden undertekster. - Metadata, miniature og kapitler indlejres, så Emby har noget at vise. Alt kan redigeres i UI'et. Natlig timer (yt-offline.timer) tømmer køen og rydder op. Claude-Session: https://claude.ai/code/session_011WxG8pbLj61jYU1MMaD7VC
608 lines
25 KiB
Python
608 lines
25 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 HTMLResponse, JSONResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
import asyncio
|
|
import time
|
|
|
|
import authentik as ak
|
|
import cache
|
|
import config
|
|
import mediaclean as mc
|
|
import services as svc
|
|
import store
|
|
import versions
|
|
import ytdl as yt
|
|
|
|
app = FastAPI(title="Weircon Portal")
|
|
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 is_admin(user):
|
|
raise HTTPException(403, "Kun for administratorer")
|
|
return user
|
|
|
|
|
|
# ---------------- auth ----------------
|
|
@app.get("/auth/login")
|
|
async def login(request: Request):
|
|
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("/", 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)
|
|
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"],
|
|
"mc_servers": mc_servers, "disco_url": config.DISCO_PANEL_URL})
|
|
|
|
|
|
# ---------------- 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)
|
|
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)
|
|
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)
|
|
|
|
|
|
@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"}]
|
|
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)}
|
|
|
|
|
|
# ---------------- 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": "",
|
|
"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)
|
|
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/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)
|