feat(mcp): eksponér YouTube-offline i portalens MCP + importér MCP-laget i repoet

Fem nye værktøjer i portal-navnerummet: yt_status, yt_download, yt_watch,
yt_unwatch, yt_check_subs. Gatewayen monterer mcp_server under namespace
"portal", så de arver portal_-præfikset og de scopes nøglerne allerede har —
ingen ekstra registrering.

Download og abonnement-tjek returnerer med det samme og arbejder videre i
baggrunden, så et MCP-kald ikke blokerer i minutter på en lang video.

Importerer samtidig mcp_server.py, mcp_gateway.py, mcpkeys.py og
templates/admin_mcp_keys.html, som kun har eksisteret i prod (CT151) og aldrig
i repoet.

ÅRSAG TIL IMPORTEN: den manglende viden i repoet kostede nedetid. Da jeg
udrullede repoets main.py og config.py til prod i går, overskrev jeg
MCP-monteringen (mcp_app.lifespan + route/middleware-overførsel), hele
/admin/mcp-keys-modulet og konstanterne MCP_REGISTRY/MCP_SELF_URL.
weircon.dk/mcp svarede 404 i ~13 timer. Rettet ved at genopbygge begge filer
fra prod-backuppen plus YouTube-ændringerne, ikke omvendt.

Repo og prod er nu identiske bortset fra static/portal.css og
templates/admin.html, hvor repoet er FORAN (wt-*-temaklasser, aldrig udrullet).

Claude-Session: https://claude.ai/code/session_011WxG8pbLj61jYU1MMaD7VC
This commit is contained in:
agw
2026-09-20 09:20:35 +02:00
parent 2c146b0711
commit 9675a5b76b
6 changed files with 943 additions and 4 deletions
+200
View File
@@ -0,0 +1,200 @@
"""Portalens egen MCP-server (FastMCP) — read-only indblik i portal/homelab-data
plus en ntfy-notify-handling. Monteres in-process i main.py (uvicorn-lifespan).
Tools genbruger de eksisterende portal-moduler. Alt pakkes i try/except, så en
downstream-fejl giver en pæn fejl frem for at vælte MCP-kaldet.
"""
import asyncio
from fastmcp import FastMCP
import authentik as ak
import config
import mediaclean as mc
import services as svc
import store
import versions
import ytdl as yt
mcp = FastMCP("weircon-portal")
def _safe(v):
return v
@mcp.tool
async def service_versions() -> dict:
"""Versions/opdaterings-overblik for de overvågede tjenester (current vs latest)."""
try:
return {"services": await versions.all_versions()}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def matomo_summary() -> dict:
"""Kort Matomo-trafikoverblik for weircon.dk."""
try:
return await svc.matomo_summary()
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def egress_status() -> dict:
"""Random-proxy (CT140) egress-status: tunneler, rollover, inflight."""
try:
return await svc.rp_status()
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def portal_apps() -> dict:
"""Katalog over Authentik-apps (navn, gruppe, host) som portalen kender."""
try:
return {"apps": await ak.fetch_all_apps_admin()}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
def media_cleanup_status() -> dict:
"""Status for medie-oprydningen: indstillinger, state (på-vej-ud/behold) og om den er konfigureret."""
try:
return {"configured": mc.configured(), "settings": mc.get_settings(),
"state": mc.get_state()}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
def ip_labels() -> dict:
"""IP/præfiks → label & land som vist i statistikken."""
try:
return {"labels": store.load_json(config.IP_LABELS_LOCAL, {})}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def notify(message: str, title: str = "Claude") -> dict:
"""Send en push-notifikation til dig via ntfy."""
try:
await mc.ntfy(title, message)
return {"sent": True}
except Exception as e:
return {"sent": False, "error": str(e)[:200]}
# ---------------- YouTube offline ----------------
@mcp.tool
def yt_status() -> dict:
"""Status for YouTube-offline: hentede videoer (set/favorit/hvornår de slettes),
download-kø, overvågede playlister/kanaler og de aktuelle indstillinger.
Videoer slettes 7 dage efter de er markeret som set i Emby. Favoritter slettes aldrig.
"""
try:
st = yt.get_state()
owned = yt.owned_paths()
leaving = st["leaving"]
videos = [{"titel": t, "sti": p, "stoerrelse": yt._human(yt.file_size(p)),
"slettes_efter": (leaving.get(p) or {}).get("delete_after")}
for p, t in owned.items()]
jobs = sorted(st["jobs"].values(), key=lambda j: j["added"], reverse=True)
return {
"konfigureret": yt.configured(),
"indstillinger": yt.get_settings(),
"videoer": videos,
"paa_vej_ud": len(leaving),
"koe": [{"url": j["url"], "status": j["status"], "titel": j.get("title"),
"fejl": j.get("error")} for j in jobs[:15]
if j["status"] in ("queued", "running", "error")],
"abonnementer": [
{"id": sb["id"], "navn": sb["title"], "type": sb["kind"],
"cycle": sb.get("cycle") or None, "aktiv": sb.get("enabled", True),
"paa_disken": len(yt.sub_files(st, sb["id"]))}
for sb in st["subs"].values()],
}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def yt_download(url: str, playlist: bool = False) -> dict:
"""Hent en YouTube-video ned til Emby-biblioteket, klar til offline-brug.
Returnerer med det samme; selve hentningen kører i baggrunden (kan tage minutter
for en lang video). Brug yt_status til at følge den. Sæt playlist=True for at
hente hele playlisten bag URL'en i stedet for kun den enkelte video.
"""
try:
url = (url or "").strip()
if not url.startswith(("http://", "https://")):
return {"error": "Ikke en URL"}
job = yt.add_job(url, playlist=bool(playlist))
asyncio.create_task(yt.process_queue())
return {"lagt_i_koe": True, "job": job["id"], "url": job["url"],
"bemaerk": "hentes i baggrunden — se yt_status"}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def yt_watch(url: str, kind: str = "", cycle: int = 0) -> dict:
"""Overvåg en playliste eller kanal og hent nye videoer automatisk.
kind = "playlist" eller "channel" (tom = gæt ud fra URL'en).
For en kanal SKAL cycle sættes: cycle=X holder de X nyeste videoer liggende,
og en ny video skubber den ældste ud. Favoritter tæller ikke med i X og slettes
aldrig. En playliste henter kun videoer der lægges i den fremover — ikke dem
der allerede ligger der.
"""
try:
url = (url or "").strip()
if not url.startswith(("http://", "https://")):
return {"error": "Ikke en URL"}
kind = kind if kind in ("playlist", "channel") else yt.sub_kind_from_url(url)
if kind == "channel" and int(cycle or 0) < 1:
return {"error": "En kanal kraever cycle >= 1 (antal nyeste videoer der holdes)"}
sub = await yt.add_sub(url, kind=kind, cycle=int(cycle or 0) if kind == "channel" else 0)
asyncio.create_task(_yt_sync_all())
return {"overvaages": True, "id": sub["id"], "navn": sub["title"],
"type": sub["kind"], "cycle": sub["cycle"] or None,
"bemaerk": "tjekker og henter i baggrunden"}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
def yt_unwatch(sub_id: str) -> dict:
"""Stop med at overvåge en playliste/kanal. Allerede hentede videoer bliver
liggende og følger herefter kun den almindelige set-regel. Id'et fås fra yt_status."""
try:
return {"fjernet": yt.del_sub(sub_id)}
except Exception as e:
return {"error": str(e)[:200]}
@mcp.tool
async def yt_check_subs() -> dict:
"""Tjek alle overvågede playlister/kanaler nu i stedet for at vente på natkørslen."""
try:
res = await yt.sync_subs()
asyncio.create_task(_yt_sync_all())
return {"tjekket": res["checked"], "nye_i_koe": res["queued"],
"fejl": res["errors"]}
except Exception as e:
return {"error": str(e)[:200]}
async def _yt_sync_all():
"""Baggrundsarbejde: hent kø og haandhaev kanal-cyklus. Fejl slugges bevidst —
den natlige timer koerer det samme igen."""
try:
await yt.process_queue()
await yt.enforce_cycles()
except Exception:
pass