Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff76660910 | ||
|
|
fbdb257079 | ||
|
|
265b48905a | ||
|
|
902ab6244e | ||
|
|
6d32fd2b71 | ||
|
|
176859b15a | ||
|
|
70bc700a11 | ||
|
|
94e5a0ca01 | ||
|
|
4274047158 | ||
|
|
58471b1a4d | ||
|
|
11399bb860 |
@@ -15,9 +15,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tooling
|
||||
run: apt-get update -qq && apt-get install -y -qq --no-install-recommends python3 python3-jinja2
|
||||
run: apt-get update -qq && apt-get install -y -qq --no-install-recommends python3 python3-jinja2 python3-pyflakes
|
||||
- name: Python compiles
|
||||
run: python3 -m compileall -q .
|
||||
- name: No undefined names
|
||||
# Fanger fx et manglende "import asyncio", der ellers først viser sig som
|
||||
# en 500 i drift (sket for YouTube-modulet 2026-09-20 til 2026-10-06).
|
||||
run: |
|
||||
python3 -m pyflakes *.py > pyflakes.txt || true
|
||||
if grep -E "undefined name|unable to detect undefined names" pyflakes.txt; then exit 1; fi
|
||||
- name: Templates parse
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
|
||||
@@ -13,6 +13,9 @@ API_TOKEN = os.environ["AUTHENTIK_API_TOKEN"]
|
||||
SECRET_KEY = os.environ["SESSION_SECRET"]
|
||||
REDIRECT_URI = os.environ.get("OIDC_REDIRECT_URI", "https://weircon.dk/auth/callback")
|
||||
ADMIN_GROUP = os.environ.get("ADMIN_GROUP", "authentik Admins")
|
||||
# Hvem der må høre familiens podcasts (/podcast); admins kan altid. Kommasepareret:
|
||||
# Familie er den nære familie, Gramps er slægten der kun har Gramps + podcast + portal.
|
||||
PODCAST_GROUPS = {g.strip() for g in os.environ.get("PODCAST_GROUP", "Familie,Gramps").split(",") if g.strip()}
|
||||
|
||||
MATOMO_BASE = os.environ.get("MATOMO_BASE", "https://matomo.weircon.dk").rstrip("/")
|
||||
MATOMO_TOKEN = os.environ.get("MATOMO_TOKEN", "")
|
||||
@@ -106,6 +109,8 @@ ADMIN_MODULES = [
|
||||
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."},
|
||||
{"key": "secrets", "title": "Hemmeligheder til Claude", "icon": "🔏",
|
||||
"desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."},
|
||||
{"key": "podcast", "title": "Podcast", "icon": "🎙️",
|
||||
"desc": "Slægtens Stemmer: udgiv kladder og se/træk lytternes personlige feed-adresser tilbage."},
|
||||
{"key": "claude-login", "title": "Claude-login", "icon": "🤖",
|
||||
"desc": "Log Claude på serveren ind igen fra telefonen, uden terminal."},
|
||||
{"key": "mcp-keys", "title": "MCP-nøgler", "icon": "🔑",
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
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.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
|
||||
@@ -22,6 +25,7 @@ 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.
|
||||
@@ -56,14 +60,42 @@ def is_admin(user) -> bool:
|
||||
|
||||
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):
|
||||
async def login(request: Request, next: str = ""):
|
||||
request.session["next"] = _sikker_next(next)
|
||||
return await oauth.authentik.authorize_redirect(request, config.REDIRECT_URI)
|
||||
|
||||
|
||||
@@ -78,7 +110,7 @@ async def callback(request: Request):
|
||||
"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)
|
||||
return RedirectResponse(_sikker_next(request.session.pop("next", "/")), status_code=302)
|
||||
|
||||
|
||||
@app.get("/auth/logout")
|
||||
@@ -102,7 +134,6 @@ 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:
|
||||
@@ -117,7 +148,20 @@ async def dashboard_content(request: Request):
|
||||
"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})
|
||||
"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 ----------------
|
||||
@@ -192,7 +236,6 @@ async def admin_ops(request: Request):
|
||||
@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", {
|
||||
@@ -354,6 +397,224 @@ async def admin_secrets_delete(request: Request, navn: str):
|
||||
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)
|
||||
@@ -370,7 +631,7 @@ async def admin_claude_login_status(request: Request):
|
||||
@app.post("/admin/claude-login/start")
|
||||
async def admin_claude_login_start(request: Request):
|
||||
user = require_admin(request)
|
||||
claudelogin.request((user or {}).get("preferred_username") or "?")
|
||||
claudelogin.request((user or {}).get("username") or "?")
|
||||
return JSONResponse(claudelogin.state())
|
||||
|
||||
|
||||
@@ -808,6 +1069,37 @@ async def admin_yt_add(request: Request, url: str = Form(""), playlist: str = Fo
|
||||
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)
|
||||
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
"""Private podcasts (Slægtens Stemmer): side, personlige feeds og lydfiler.
|
||||
|
||||
Podcast-apps kan ikke logge ind med Authentik, så hver lytter får en personlig,
|
||||
hemmelig nøgle i adressen, som Patreon og Apple gør det. Nøglen hører til
|
||||
Authentik-brugeren og kan trækkes tilbage i /admin/podcast uden at de andre
|
||||
mærker noget. Både feedet og mp3-filerne ligger under nøglen.
|
||||
|
||||
Data (ikke i git, persondata):
|
||||
DATA/podcast/<show>/show.json titel, beskrivelse, forfatter
|
||||
DATA/podcast/<show>/cover.jpg kvadratisk, 1400-3000 px
|
||||
DATA/podcast/<show>/episodes/<slug>.mp3 selve episoden
|
||||
DATA/podcast/<show>/episodes/<slug>.json titel, beskrivelse, nr, udgivet, kladde
|
||||
DATA/podcast_keys.json nøgle -> bruger, statistik
|
||||
DATA/podcast_faelles.json det fælles familielink (én nøgle, kan slås fra)
|
||||
|
||||
En episode med "kladde": true ses kun af administratorer (i deres eget feed og
|
||||
på siden), så den kan høres i en rigtig podcast-app før familien får den.
|
||||
"""
|
||||
import email.utils
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape, quoteattr
|
||||
|
||||
import config
|
||||
|
||||
ROOT = config.DATA / "podcast"
|
||||
KEYS = config.DATA / "podcast_keys.json"
|
||||
DELT = config.DATA / "podcast_faelles.json"
|
||||
SHOW = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||||
SLUG = re.compile(r"^[a-z0-9][a-z0-9._-]{0,120}$")
|
||||
KEY = re.compile(r"^[A-Za-z0-9_-]{32,64}$")
|
||||
|
||||
|
||||
# ---------------- shows og episoder ----------------
|
||||
|
||||
def shows() -> list[str]:
|
||||
if not ROOT.exists():
|
||||
return []
|
||||
return sorted(p.name for p in ROOT.iterdir() if p.is_dir() and SHOW.match(p.name) and (p / "show.json").exists())
|
||||
|
||||
|
||||
def show(name: str) -> dict | None:
|
||||
if not SHOW.match(name or ""):
|
||||
return None
|
||||
try:
|
||||
s = json.loads((ROOT / name / "show.json").read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
s["slug"] = name
|
||||
s["har_cover"] = (ROOT / name / "cover.jpg").exists()
|
||||
return s
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def _varighed(sti: str, mtime: float) -> int:
|
||||
try:
|
||||
from mutagen.mp3 import MP3
|
||||
return int(MP3(sti).info.length)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def episodes(name: str, med_kladder: bool) -> list[dict]:
|
||||
"""Episoder, nyeste først. Kladder kun med med_kladder."""
|
||||
d = ROOT / name / "episodes"
|
||||
if not SHOW.match(name or "") or not d.exists():
|
||||
return []
|
||||
ud = []
|
||||
for meta in d.glob("*.json"):
|
||||
slug = meta.stem
|
||||
mp3 = d / f"{slug}.mp3"
|
||||
if not SLUG.match(slug) or not mp3.exists():
|
||||
continue
|
||||
try:
|
||||
e = json.loads(meta.read_text())
|
||||
except ValueError:
|
||||
continue
|
||||
if e.get("kladde") and not med_kladder:
|
||||
continue
|
||||
st = mp3.stat()
|
||||
e.update(slug=slug, bytes=st.st_size, varighed=_varighed(str(mp3), st.st_mtime))
|
||||
ud.append(e)
|
||||
return sorted(ud, key=lambda e: (e.get("nr", 0), e.get("udgivet", "")), reverse=True)
|
||||
|
||||
|
||||
def episode_fil(name: str, slug: str) -> Path | None:
|
||||
if not SHOW.match(name or "") or not SLUG.match(slug or ""):
|
||||
return None
|
||||
p = ROOT / name / "episodes" / f"{slug}.mp3"
|
||||
return p if p.exists() else None
|
||||
|
||||
|
||||
def episode_meta(name: str, slug: str) -> dict | None:
|
||||
if not episode_fil(name, slug):
|
||||
return None
|
||||
try:
|
||||
return json.loads((ROOT / name / "episodes" / f"{slug}.json").read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def saet_kladde(name: str, slug: str, kladde: bool) -> bool:
|
||||
e = episode_meta(name, slug)
|
||||
if e is None:
|
||||
return False
|
||||
e["kladde"] = kladde
|
||||
if not kladde and not e.get("udgivet"):
|
||||
e["udgivet"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||
_skriv(ROOT / name / "episodes" / f"{slug}.json", e)
|
||||
return True
|
||||
|
||||
|
||||
def cover(name: str, lille: bool = False) -> Path | None:
|
||||
"""Omslaget; lille = 300 px-udgaven til link-forhåndsvisninger (cover-lille.jpg)."""
|
||||
p = ROOT / name / ("cover-lille.jpg" if lille else "cover.jpg")
|
||||
return p if SHOW.match(name or "") and p.exists() else None
|
||||
|
||||
|
||||
# ---------------- nøgler ----------------
|
||||
|
||||
def _skriv(p: Path, data) -> None:
|
||||
tmp = p.with_name(f".{p.name}.tmp")
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=1)
|
||||
os.replace(tmp, p)
|
||||
|
||||
|
||||
def _noegler() -> dict:
|
||||
try:
|
||||
return json.loads(KEYS.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def noegle_til(user: str, admin: bool) -> str:
|
||||
"""Brugerens aktive nøgle; laves første gang. Admin-status opdateres her,
|
||||
fordi den afgør om feedet viser kladder."""
|
||||
k = _noegler()
|
||||
for key, v in k.items():
|
||||
if v["user"] == user and not v.get("tilbagekaldt"):
|
||||
if v.get("admin") != admin:
|
||||
v["admin"] = admin
|
||||
_skriv(KEYS, k)
|
||||
return key
|
||||
key = secrets.token_urlsafe(32)
|
||||
k[key] = {"user": user, "admin": admin, "oprettet": int(time.time()), "hentninger": 0}
|
||||
_skriv(KEYS, k)
|
||||
return key
|
||||
|
||||
|
||||
def ny_noegle(user: str, admin: bool) -> str:
|
||||
k = _noegler()
|
||||
for v in k.values():
|
||||
if v["user"] == user and not v.get("tilbagekaldt"):
|
||||
v["tilbagekaldt"] = int(time.time())
|
||||
_skriv(KEYS, k)
|
||||
return noegle_til(user, admin)
|
||||
|
||||
|
||||
def slaa_op(key: str, ua: str = "", hvad: str = "") -> dict | None:
|
||||
"""Gyldig nøgle -> dens post (og registrér hentningen), ellers None."""
|
||||
if not KEY.match(key or ""):
|
||||
return None
|
||||
k = _noegler()
|
||||
v = k.get(key)
|
||||
if not v or v.get("tilbagekaldt"):
|
||||
return None
|
||||
v["hentninger"] = v.get("hentninger", 0) + 1
|
||||
v["sidst"] = int(time.time())
|
||||
v["sidst_ua"] = (ua or "")[:160]
|
||||
if hvad:
|
||||
v.setdefault("hentet", {})
|
||||
v["hentet"][hvad] = v["hentet"].get(hvad, 0) + 1
|
||||
_skriv(KEYS, k)
|
||||
return v
|
||||
|
||||
|
||||
def alle_noegler() -> list[dict]:
|
||||
return sorted(({"key": key, **v} for key, v in _noegler().items()),
|
||||
key=lambda v: (bool(v.get("tilbagekaldt")), -(v.get("sidst") or 0)))
|
||||
|
||||
|
||||
def tilbagekald(key: str, aktiv: bool) -> bool:
|
||||
k = _noegler()
|
||||
if key not in k:
|
||||
return False
|
||||
if aktiv:
|
||||
# Genaktivering: brugeren må kun have én aktiv nøgle.
|
||||
for other, v in k.items():
|
||||
if other != key and v["user"] == k[key]["user"] and not v.get("tilbagekaldt"):
|
||||
v["tilbagekaldt"] = int(time.time())
|
||||
k[key].pop("tilbagekaldt", None)
|
||||
else:
|
||||
k[key]["tilbagekaldt"] = int(time.time())
|
||||
_skriv(KEYS, k)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------- feed ----------------
|
||||
|
||||
def _rfc822(iso: str) -> str:
|
||||
try:
|
||||
t = time.mktime(time.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S"))
|
||||
except (ValueError, TypeError):
|
||||
t = time.time()
|
||||
return email.utils.formatdate(t, localtime=True)
|
||||
|
||||
|
||||
def _hms(sek: int) -> str:
|
||||
return f"{sek // 3600:d}:{sek // 60 % 60:02d}:{sek % 60:02d}"
|
||||
|
||||
|
||||
def feed(name: str, rod: str, base: str, med_kladder: bool) -> str | None:
|
||||
"""rod = stien foran showet, fx "/podcast/<nøgle>" eller "/podcast/f/<fælles nøgle>"."""
|
||||
s = show(name)
|
||||
if not s:
|
||||
return None
|
||||
pre = f"{base}{rod}/{name}"
|
||||
items = []
|
||||
for e in episodes(name, med_kladder):
|
||||
titel = ("[Kladde] " if e.get("kladde") else "") + e.get("titel", e["slug"])
|
||||
items.append(f""" <item>
|
||||
<title>{escape(titel)}</title>
|
||||
<description>{escape(e.get("beskrivelse", ""))}</description>
|
||||
<guid isPermaLink="false">{escape(name)}/{escape(e["slug"])}</guid>
|
||||
<pubDate>{_rfc822(e.get("udgivet", ""))}</pubDate>
|
||||
<enclosure url={quoteattr(f"{pre}/{e['slug']}.mp3")} length="{e['bytes']}" type="audio/mpeg"/>
|
||||
<itunes:duration>{_hms(e["varighed"])}</itunes:duration>
|
||||
<itunes:episode>{int(e.get("nr", 0))}</itunes:episode>
|
||||
<itunes:episodeType>full</itunes:episodeType>
|
||||
</item>""")
|
||||
cover_url = f"{pre}/cover.jpg"
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||
xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>{escape(s.get("titel", name))}</title>
|
||||
<link>{escape(base)}/podcast</link>
|
||||
<atom:link href={quoteattr(f"{pre}.xml")} rel="self" type="application/rss+xml"/>
|
||||
<language>{escape(s.get("sprog", "da"))}</language>
|
||||
<description>{escape(s.get("beskrivelse", ""))}</description>
|
||||
<itunes:summary>{escape(s.get("beskrivelse", ""))}</itunes:summary>
|
||||
<itunes:author>{escape(s.get("forfatter", ""))}</itunes:author>
|
||||
<itunes:image href={quoteattr(cover_url)}/>
|
||||
<image><url>{escape(cover_url)}</url><title>{escape(s.get("titel", name))}</title><link>{escape(base)}/podcast</link></image>
|
||||
<itunes:category text="History"/>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
<itunes:type>serial</itunes:type>
|
||||
<itunes:block>Yes</itunes:block>
|
||||
<podcast:locked>yes</podcast:locked>
|
||||
{chr(10).join(items)}
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
|
||||
|
||||
# ---------------- fælles familielink ----------------
|
||||
# Ét link uden login til fx familiens Messenger-gruppe: /podcast/f/<nøgle>/ med
|
||||
# afspiller og feed. Viser aldrig kladder. Kan skiftes (det gamle dør med det
|
||||
# samme) eller slås helt fra i /admin/podcast.
|
||||
|
||||
def faelles() -> dict:
|
||||
try:
|
||||
return json.loads(DELT.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def faelles_skift() -> str:
|
||||
d = faelles()
|
||||
d.update(key=secrets.token_urlsafe(32), aktiv=True, oprettet=int(time.time()), hentninger=0, hentet={})
|
||||
d.pop("sidst", None)
|
||||
_skriv(DELT, d)
|
||||
return d["key"]
|
||||
|
||||
|
||||
def faelles_aktiv(aktiv: bool) -> None:
|
||||
d = faelles()
|
||||
if not d.get("key"):
|
||||
faelles_skift()
|
||||
d = faelles()
|
||||
d["aktiv"] = aktiv
|
||||
_skriv(DELT, d)
|
||||
|
||||
|
||||
def faelles_slaa_op(key: str, hvad: str = "") -> bool:
|
||||
d = faelles()
|
||||
if not (KEY.match(key or "") and d.get("aktiv") and d.get("key")
|
||||
and secrets.compare_digest(key, d["key"])):
|
||||
return False
|
||||
if hvad:
|
||||
d["hentninger"] = d.get("hentninger", 0) + 1
|
||||
d["sidst"] = int(time.time())
|
||||
d.setdefault("hentet", {})
|
||||
d["hentet"][hvad] = d["hentet"].get(hvad, 0) + 1
|
||||
_skriv(DELT, d)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------- gæt på nøgler ----------------
|
||||
# Nøglerne er 256 bit og kan ikke gættes, men en klient der bliver ved med at
|
||||
# ramme forkerte nøgler, får 404 uden at vi overhovedet slår op (og samme svar
|
||||
# som en ukendt sti, så den ikke røber noget).
|
||||
_MISS: dict[str, list[float]] = {}
|
||||
MISS_GRAENSE, MISS_VINDUE = 20, 600
|
||||
|
||||
|
||||
def blokeret(ip: str) -> bool:
|
||||
nu = time.time()
|
||||
t = [x for x in _MISS.get(ip, []) if nu - x < MISS_VINDUE]
|
||||
_MISS[ip] = t
|
||||
return len(t) >= MISS_GRAENSE
|
||||
|
||||
|
||||
def forkert(ip: str) -> None:
|
||||
_MISS.setdefault(ip, []).append(time.time())
|
||||
if len(_MISS) > 5000: # hold hukommelsen nede ved en storm
|
||||
for k in list(_MISS)[:2500]:
|
||||
_MISS.pop(k, None)
|
||||
@@ -6,3 +6,4 @@ httpx
|
||||
itsdangerous
|
||||
python-multipart
|
||||
maxminddb
|
||||
mutagen
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
};
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var timer = null;
|
||||
// Efter et tryk på Start venter vi i op til et minut på en status, serveren har
|
||||
// skrevet EFTER trykket; en ældre hvile-status betyder bare, at den ikke er nået dertil.
|
||||
var klik = 0;
|
||||
|
||||
function dato(t) {
|
||||
var d = new Date(t * 1000);
|
||||
@@ -56,6 +59,8 @@
|
||||
|
||||
function vis(st) {
|
||||
var s = st.status || "ukendt";
|
||||
var hvile = s === "ok" || s === "snart" || s === "udloebet" || s === "ukendt";
|
||||
if (hvile && klik && Date.now() - klik < 60000 && (st.updated || 0) * 1000 < klik) s = "starter";
|
||||
var linje = TEKST[s] || s;
|
||||
if (st.expires && (s === "ok" || s === "snart")) {
|
||||
var dage = Math.floor((st.expires * 1000 - Date.now()) / 86400000);
|
||||
@@ -93,6 +98,7 @@
|
||||
|
||||
$("cl-start-btn").addEventListener("click", function () {
|
||||
$("cl-start-btn").disabled = true;
|
||||
klik = Date.now();
|
||||
fetch("/admin/claude-login/start", { method: "POST", credentials: "same-origin" })
|
||||
.then(function (r) { return r.json(); }).then(vis)
|
||||
.finally(function () { $("cl-start-btn").disabled = false; });
|
||||
|
||||
@@ -24,13 +24,18 @@
|
||||
|
||||
<p style="margin:.9rem 0 .4rem"><strong>Grupper de skal med i</strong></p>
|
||||
<div class="wt-grid wt-grid--tight">
|
||||
{% set gruppe_hjaelp = {
|
||||
"Familie": "den nære familie: Sønderlund, Mealie, Emby, Gramps (redigér), podcast m.m.",
|
||||
"Gramps": "kun Gramps (læse), familiens podcast og portalen",
|
||||
"Crawlkit-team": "kun crawlkit",
|
||||
} %}
|
||||
{% for g in grupper %}
|
||||
<label class="wt-select-card">
|
||||
<input type="checkbox" name="grupper" value="{{ g.pk }}" {% if g.name == "Familie" %}checked{% endif %}>
|
||||
<span class="wt-select-card-ini">{{ g.name[0]|upper }}</span>
|
||||
<span class="wt-select-card-body">
|
||||
<span class="wt-select-card-name">{{ g.name }}</span>
|
||||
<span class="wt-select-card-sub">{{ g.members }} medlemmer{% if g.name == "authentik Admins" %} · ⚠️ fuld admin-adgang{% endif %}</span>
|
||||
<span class="wt-select-card-sub">{{ g.members }} medlemmer{% if g.name == "authentik Admins" %} · ⚠️ fuld admin-adgang{% elif g.name in gruppe_hjaelp %} · {{ gruppe_hjaelp[g.name] }}{% endif %}</span>
|
||||
</span>
|
||||
<span class="wt-select-card-chk">✓</span>
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin — podcast{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="/admin" class="wt-muted">← Admin</a></p>
|
||||
<h1>Podcast</h1>
|
||||
<p class="wt-lead">Kladder ses kun af administratorer, også i deres podcast-app. Udgiv en episode, når den er klar til familien. Lytternes adresser er personlige; trækker du en tilbage, holder den op med at virke, og de andre mærker intet. Personen får en ny adresse næste gang de åbner siden, så længe de er i gruppen Familie; skal nogen helt ud, så fjern dem fra gruppen i Authentik. Siden for familien er <a href="/podcast">/podcast</a>.</p>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
|
||||
{% for u in udsendelser %}
|
||||
<h2 style="font-size:1.15rem;margin-top:1.6rem">{{ u.titel }}</h2>
|
||||
<table class="wt-table">
|
||||
<thead><tr><th>Nr</th><th>Titel</th><th>Varighed</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in u.episoder %}
|
||||
<tr>
|
||||
<td>{{ e.nr }}</td>
|
||||
<td>{{ e.titel }}<div class="wt-muted" style="font-size:.8rem">{{ e.slug }}.mp3 · {{ (e.bytes / 1048576) | round(1) }} MB</div></td>
|
||||
<td>{{ (e.varighed // 60) }}:{{ '%02d' % (e.varighed % 60) }}</td>
|
||||
<td>{% if e.kladde %}Kladde{% else %}Udgivet {{ (e.udgivet or '')[:10] }}{% endif %}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/podcast/{{ u.slug }}/{{ e.slug }}/udgiv" style="margin:0">
|
||||
{% if e.kladde %}
|
||||
<button class="wt-btn wt-btn--primary" type="submit">Udgiv</button>
|
||||
{% else %}
|
||||
<input type="hidden" name="kladde" value="1"><button class="wt-btn" type="submit">Gør til kladde</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="wt-muted">Ingen episoder. Udgiv med publish.py i skillen slaegtens-stemmer.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:2rem">Fælles familielink</h2>
|
||||
<p class="wt-muted" style="margin:.3rem 0 .8rem">Ét link uden login, fx til familiens Messenger-gruppe: afspiller og feed i ét. Kladder vises aldrig her. Skifter du nøglen, holder det gamle link op med at virke med det samme, også for dem der abonnerer via det.</p>
|
||||
<div class="wt-card" style="padding:1rem 1.2rem;max-width:48rem">
|
||||
{% if faelles.key %}
|
||||
<p style="margin:0 0 .6rem"><strong>{{ 'Slået til' if faelles.aktiv else 'Slået fra' }}</strong>
|
||||
· {{ faelles.hentninger or 0 }} hentninger{% if faelles.sidst %} · sidst <span data-ts="{{ faelles.sidst }}"></span>{% endif %}</p>
|
||||
{% if faelles.aktiv %}
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:.8rem">
|
||||
<input class="wt-input" readonly id="faelles-link" value="{{ base }}/podcast/f/{{ faelles.key }}/"
|
||||
style="flex:1 1 22rem;font-family:ui-monospace,monospace;font-size:.85rem" onclick="this.select()">
|
||||
<button class="wt-btn" type="button" onclick="var i=document.getElementById('faelles-link');i.select();navigator.clipboard&&navigator.clipboard.writeText(i.value);this.textContent='Kopieret'">Kopiér</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/podcast/faelles" style="display:flex;gap:.5rem;flex-wrap:wrap;margin:0">
|
||||
<button class="wt-btn" type="submit" name="handling" value="skift"
|
||||
onclick="return confirm('Det gamle fælles link holder op med at virke. Fortsæt?')">Skift nøgle</button>
|
||||
{% if faelles.aktiv %}
|
||||
<button class="wt-btn" type="submit" name="handling" value="fra">Slå fra</button>
|
||||
{% else %}
|
||||
<button class="wt-btn wt-btn--primary" type="submit" name="handling" value="til">Slå til</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/admin/podcast/faelles" style="margin:0">
|
||||
<button class="wt-btn wt-btn--primary" type="submit" name="handling" value="skift">Lav fælles link</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:2rem">Lytternes adresser</h2>
|
||||
<table class="wt-table">
|
||||
<thead><tr><th>Bruger</th><th>Oprettet</th><th>Sidst hentet</th><th>Hentninger</th><th>App</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for k in noegler %}
|
||||
<tr{% if k.tilbagekaldt %} style="opacity:.55"{% endif %}>
|
||||
<td>{{ k.user }}{% if k.admin %} <span class="wt-muted">(admin)</span>{% endif %}</td>
|
||||
<td data-ts="{{ k.oprettet }}"></td>
|
||||
<td data-ts="{{ k.sidst or '' }}"></td>
|
||||
<td title="{% for h, n in (k.hentet or {}).items() %}{{ h }}: {{ n }} {% endfor %}">{{ k.hentninger or 0 }}</td>
|
||||
<td class="wt-muted" style="font-size:.8rem;max-width:16rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ k.sidst_ua or '' }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/podcast/noegle/{{ k.key }}" style="margin:0">
|
||||
{% if k.tilbagekaldt %}
|
||||
<input type="hidden" name="aktiv" value="1"><button class="wt-btn" type="submit">Genaktivér</button>
|
||||
{% else %}
|
||||
<button class="wt-btn" type="submit" onclick="return confirm('Træk {{ k.user }}s adresse tilbage?')">Træk tilbage</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="wt-muted">Ingen endnu. En lytter får sin adresse første gang de åbner /podcast.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<script>
|
||||
document.querySelectorAll('[data-ts]').forEach(function (td) {
|
||||
var t = parseInt(td.dataset.ts, 10);
|
||||
td.textContent = t ? new Date(t * 1000).toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'short' }) : '–';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
+22
-2
@@ -126,7 +126,7 @@
|
||||
<h2 style="margin-top:2rem">Hentede videoer</h2>
|
||||
{% if not ours %}<p class="muted">Ingen endnu — indsæt en URL ovenfor.</p>{% else %}
|
||||
<table class="wt-table" style="margin-top:.8rem">
|
||||
<thead><tr><th>Titel</th><th style="width:7rem">Størrelse</th><th style="width:9rem">Status</th><th style="width:11rem">Slettes</th></tr></thead>
|
||||
<thead><tr><th>Titel</th><th style="width:7rem">Størrelse</th><th style="width:9rem">Status</th><th style="width:11rem">Slettes</th><th style="width:5.5rem"></th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in ours %}
|
||||
<tr>
|
||||
@@ -142,6 +142,13 @@
|
||||
{% elif v.delete_after %}om {{ ((v.delete_after - now) / 86400) | round(0, 'ceil') | int }} dage
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/yt/slet" style="margin:0"
|
||||
onsubmit="return confirm({{ ('Slet „' ~ v.name ~ '“ nu?' ~ (' Den er markeret som favorit.' if v.favorite else '')) | tojson | forceescape }})">
|
||||
<input type="hidden" name="path" value="{{ v.path }}">
|
||||
<button class="wt-btn" type="submit">Slet</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -151,6 +158,12 @@
|
||||
{# ---- kø & historik ---- #}
|
||||
{% if jobs %}
|
||||
<h2 style="margin-top:2rem">Kø & historik</h2>
|
||||
{% set fejlede = jobs | selectattr("status", "equalto", "error") | list %}
|
||||
{% if fejlede | length > 1 %}
|
||||
<form method="post" action="/admin/yt/igen" style="margin:.6rem 0 0">
|
||||
<button class="wt-btn" type="submit">Prøv alle {{ fejlede | length }} fejlede igen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<table class="wt-table" style="margin-top:.8rem">
|
||||
<thead><tr><th>URL / titel</th><th style="width:8rem">Status</th><th>Besked</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -162,7 +175,14 @@
|
||||
{% elif j.status == 'running' %}i gang…
|
||||
{% elif j.status == 'deleted' %}<span class="muted">slettet</span>
|
||||
{% else %}i kø{% endif %}</td>
|
||||
<td class="muted">{{ j.error }}</td>
|
||||
<td class="muted">{{ j.error }}{% if j.note %}{{ ' · ' if j.error }}{{ j.note }}{% endif %}
|
||||
{% if j.status == 'error' %}
|
||||
<form method="post" action="/admin/yt/igen" style="margin:.3rem 0 0">
|
||||
<input type="hidden" name="jid" value="{{ j.id }}">
|
||||
<button class="wt-btn" type="submit">Prøv igen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<link rel="stylesheet" href="https://assets.weircon.dk/css/weirsoe-theme.css" />
|
||||
<link rel="stylesheet" href="/static/portal.css" />
|
||||
<script defer src="https://assets.weircon.dk/js/weirsoe-theme.js"></script>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header class="wt-header">
|
||||
|
||||
@@ -12,6 +12,21 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
{% if error %}<div class="err">{{ error }}</div>{% endif %}
|
||||
{% if podcasts %}
|
||||
<section class="appgroup" id="podsec">
|
||||
<h2>🎙️ Familiens podcast</h2>
|
||||
<div class="grid">
|
||||
{% for p in podcasts %}
|
||||
<div class="card" data-search="podcast {{ (p.titel ~ ' ' ~ p.nyeste.titel)|lower }}">
|
||||
<a class="card-link" href="/podcast" aria-label="{{ p.titel }}"></a>
|
||||
<div class="head"><span class="ico">🎙️</span><span class="name">{{ p.titel }}</span></div>
|
||||
<div class="sub" title="{{ p.nyeste.titel }}">{{ p.antal }} episode{{ 'r' if p.antal != 1 }} · nyeste: {{ p.nyeste.titel }}</div>
|
||||
<div class="go">Lyt →</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if groups %}
|
||||
<div class="toolbar">
|
||||
<div class="filter"><span class="mag">⌕</span>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if delt and udsendelser %}{{ udsendelser[0].titel }}{% else %}Podcast{% endif %}{% endblock %}
|
||||
{% block head %}{% if delt %}<meta name="robots" content="noindex, nofollow, noarchive">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
{% if og %}<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{{ og.titel }}">
|
||||
<meta property="og:description" content="{{ og.beskrivelse }}">
|
||||
<meta property="og:url" content="{{ og.url }}">
|
||||
{% if og.billede %}<meta property="og:image" content="{{ og.billede }}">{% endif %}{% endif %}{% endif %}{% endblock %}
|
||||
{% block body %}
|
||||
<style>
|
||||
/* Ingen vandret scroll på iPhone: afspiller og felter må aldrig blive bredere end
|
||||
kortet, og felter skal have mindst 16 px skrift, ellers zoomer iOS ind. */
|
||||
html, body { overflow-x: hidden; }
|
||||
.pod-side { max-width: 100%; overflow-wrap: anywhere; }
|
||||
.pod-side img, .pod-side audio, .pod-side input, .pod-side .wt-card { max-width: 100%; box-sizing: border-box; }
|
||||
.pod-side audio { display: block; width: 100%; min-width: 0; }
|
||||
.pod-side input { font-size: 16px !important; min-width: 0; }
|
||||
.pod-side .wt-btn { white-space: normal; }
|
||||
</style>
|
||||
<div class="pod-side">
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
{% if not udsendelser %}
|
||||
<h1>Podcast</h1>
|
||||
<p class="wt-muted">Der er ingen podcasts endnu.</p>
|
||||
{% endif %}
|
||||
{% for u in udsendelser %}
|
||||
<section style="margin:1rem 0 2.5rem">
|
||||
<div style="display:flex;gap:1.2rem;align-items:flex-start;flex-wrap:wrap">
|
||||
{% if u.har_cover %}
|
||||
<img src="{{ rod }}/{{ u.slug }}/cover.jpg" alt="" width="160" height="160"
|
||||
style="border-radius:14px;flex:0 0 auto;box-shadow:0 4px 18px rgba(0,0,0,.25)">
|
||||
{% endif %}
|
||||
<div style="flex:1 1 18rem;min-width:0">
|
||||
<h1 style="margin:.1rem 0 .4rem">{{ u.titel }}</h1>
|
||||
<p class="wt-lead" style="margin:0">{{ u.beskrivelse }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wt-card" style="margin:1.4rem 0;padding:1rem 1.2rem">
|
||||
<h2 style="font-size:1.05rem;margin:0 0 .5rem">Abonnér i din podcast-app</h2>
|
||||
{% if indbygget %}
|
||||
<div class="wt-flash" style="margin:.4rem 0 .9rem">
|
||||
<p style="margin:0 0 .5rem">Du har åbnet siden inde i Messenger, og den kan ikke åbne podcast-apps. Du kan stadig lytte herunder. Vil du abonnere:</p>
|
||||
{% if indbygget.platform == "android" %}
|
||||
<p style="margin:.4rem 0"><a class="wt-btn wt-btn--primary" href="{{ indbygget.href }}">Åbn i Chrome</a></p>
|
||||
<p style="margin:.4rem 0">Virker det ikke: tryk på ⋮ øverst til højre og vælg "Åbn i ekstern browser".</p>
|
||||
{% else %}
|
||||
<p style="margin:.4rem 0"><strong>1.</strong> Tryk på ⋯ øverst til højre og vælg "Åbn i ekstern browser" (eller "Åbn i Safari"). Så virker knapperne herunder.</p>
|
||||
<p style="margin:.6rem 0 .3rem"><strong>2.</strong> Eller kopiér adressen og tilføj den selv i Apple Podcasts: <em>Bibliotek → ⋯ øverst → Følg en serie via URL</em>.</p>
|
||||
<button class="wt-btn wt-btn--primary" type="button" data-kopier-tekst="{{ udsendelser[0].feed if udsendelser else '' }}">Kopiér adressen</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="wt-muted" style="margin:.2rem 0 .8rem">{% if delt %}Tryk på din app, så kommer nye episoder af sig selv. Linket er familiens fælles, så del det kun inden for familien.{% else %}Tryk på din app. Nye episoder kommer så af sig selv. Adressen er din egen, så del den ikke; familien får hver sin.{% endif %}</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.5rem">
|
||||
<a class="wt-btn wt-btn--primary" href="podcast://{{ u.uden_skema }}">Apple Podcasts</a>
|
||||
<a class="wt-btn" href="overcast://x-callback-url/add?url={{ u.feed_enc }}">Overcast</a>
|
||||
<a class="wt-btn" href="pktc://subscribe/{{ u.uden_skema }}">Pocket Casts</a>
|
||||
<a class="wt-btn" href="https://antennapod.org/deeplink/subscribe?url={{ u.feed_enc }}">AntennaPod</a>
|
||||
</div>
|
||||
<details style="margin-top:.9rem">
|
||||
<summary class="wt-muted" style="cursor:pointer">Anden app? Kopiér adressen</summary>
|
||||
<div style="display:flex;gap:.5rem;margin-top:.5rem;flex-wrap:wrap">
|
||||
<input class="wt-input" readonly value="{{ u.feed }}" id="feed-{{ u.slug }}"
|
||||
style="flex:1 1 20rem;font-family:ui-monospace,monospace;font-size:.85rem" onclick="this.select()">
|
||||
<button class="wt-btn" type="button" data-kopier="feed-{{ u.slug }}">Kopiér</button>
|
||||
</div>
|
||||
<p class="wt-muted" style="font-size:.85rem;margin:.5rem 0 0">Spotify kan ikke abonnere på private podcasts. På Android virker AntennaPod og Pocket Casts.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1.15rem">Episoder</h2>
|
||||
{% for e in u.episoder %}
|
||||
<article class="wt-card" style="margin:.8rem 0;padding:1rem 1.2rem">
|
||||
<div style="display:flex;justify-content:space-between;gap:1rem;flex-wrap:wrap">
|
||||
<h3 style="margin:0;font-size:1.05rem">{% if e.nr %}{{ e.nr }}. {% endif %}{{ e.titel }}</h3>
|
||||
<span class="wt-muted" style="font-size:.85rem">
|
||||
{% if e.kladde %}<strong>Kladde, kun du kan se den</strong> · {% endif %}{{ (e.varighed // 60) }} min
|
||||
</span>
|
||||
</div>
|
||||
{% if e.beskrivelse %}<p style="margin:.5rem 0;white-space:pre-line">{{ e.beskrivelse }}</p>{% endif %}
|
||||
<audio controls preload="none" style="width:100%;margin-top:.4rem"
|
||||
src="{{ rod }}/{{ u.slug }}/{{ e.slug }}.mp3"></audio>
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen episoder endnu.</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
{% if udsendelser and not delt %}
|
||||
<form method="post" action="/podcast/ny-noegle" style="margin:2rem 0"
|
||||
onsubmit="return confirm('Din gamle adresse holder op med at virke, og du skal abonnere igen i din app. Fortsæt?')">
|
||||
<button class="wt-btn" type="submit">Lav en ny adresse</button>
|
||||
<span class="wt-muted" style="font-size:.85rem">Hvis du har delt din adresse ved et uheld.</span>
|
||||
</form>
|
||||
{% endif %}
|
||||
<script>
|
||||
function kopierTekst(t, knap) {
|
||||
var ok = function () { knap.textContent = 'Kopieret'; };
|
||||
var fallback = function () {
|
||||
var ta = document.createElement('textarea'); ta.value = t; ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed'; ta.style.top = '0'; ta.style.opacity = '0'; ta.style.fontSize = '16px';
|
||||
document.body.appendChild(ta); ta.focus(); ta.setSelectionRange(0, t.length);
|
||||
try { document.execCommand('copy'); ok(); } catch (e) { prompt('Kopiér adressen:', t); }
|
||||
document.body.removeChild(ta);
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(ok, fallback); else fallback();
|
||||
}
|
||||
document.querySelectorAll('[data-kopier-tekst]').forEach(function (b) {
|
||||
b.addEventListener('click', function () { kopierTekst(b.dataset.kopierTekst, b); });
|
||||
});
|
||||
document.querySelectorAll('[data-kopier]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var i = document.getElementById(b.dataset.kopier);
|
||||
i.select();
|
||||
(navigator.clipboard ? navigator.clipboard.writeText(i.value) : Promise.reject())
|
||||
.catch(function () { document.execCommand('copy'); })
|
||||
.finally(function () { b.textContent = 'Kopieret'; });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="da">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ og.titel }}</title>
|
||||
<meta name="robots" content="noindex, nofollow, noarchive">
|
||||
<meta name="description" content="{{ og.beskrivelse }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{{ og.titel }}">
|
||||
<meta property="og:description" content="{{ og.beskrivelse }}">
|
||||
<meta property="og:url" content="{{ og.url }}">
|
||||
<meta property="og:locale" content="da_DK">
|
||||
{% if og.billede %}<meta property="og:image" content="{{ og.billede }}">
|
||||
<meta property="og:image:width" content="{{ og.str }}">
|
||||
<meta property="og:image:height" content="{{ og.str }}">
|
||||
<meta name="twitter:card" content="summary">{% endif %}
|
||||
</head>
|
||||
<body><h1>{{ og.titel }}</h1><p>{{ og.beskrivelse }}</p></body>
|
||||
</html>
|
||||
@@ -13,11 +13,27 @@ import asyncio
|
||||
import ytdl
|
||||
|
||||
|
||||
async def opdater_ytdlp():
|
||||
"""YouTube ændrer ofte noget, der giver 403, indtil yt-dlp er opdateret.
|
||||
Derfor opdateres den hver nat, før der hentes."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
ytdl.config.YT_DLP_BIN, "-U",
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=180)
|
||||
sidste = (out.decode("utf-8", "replace").strip().splitlines() or [""])[-1]
|
||||
print(f"yt_job: yt-dlp -U: {sidste[:160]}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"yt_job: yt-dlp kunne ikke opdateres: {e}")
|
||||
|
||||
|
||||
async def main():
|
||||
if not ytdl.configured():
|
||||
print("yt_job: ikke konfigureret (mangler EMBY-env, yt-dlp eller download-mappe)")
|
||||
return
|
||||
|
||||
await opdater_ytdlp()
|
||||
|
||||
sub = await ytdl.sync_subs()
|
||||
if sub["checked"] or sub["errors"]:
|
||||
print(f"yt_job: abonnementer tjekket={sub['checked']} nye_i_koe={sub['queued']}")
|
||||
|
||||
@@ -10,6 +10,7 @@ selv har hentet**, registreret med fuld sti i yt_state.json. Alt andet i YouTube
|
||||
biblioteket — fx det der lå i /slow/shared/yt i forvejen — er urørligt.
|
||||
"""
|
||||
import asyncio
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -110,7 +111,7 @@ def _set_job(jid: str, **kw):
|
||||
save_state(st)
|
||||
|
||||
|
||||
def _build_cmd(job: dict, settings: dict, with_subs: bool) -> list:
|
||||
def _build_cmd(job: dict, settings: dict, with_subs: bool, klient: str = "") -> list:
|
||||
dest = config.YT_DOWNLOAD_DIR
|
||||
# %(title)s beholder æøå; [%(id)s] gør navnet unikt og gør det muligt at
|
||||
# genkende videoen igen uden at gemme en sidevogn af metadata.
|
||||
@@ -142,16 +143,20 @@ def _build_cmd(job: dict, settings: dict, with_subs: bool) -> list:
|
||||
# påvirkes ikke (yt-dlp henter dem fra SponsorBlock-API'et pr. video).
|
||||
cmd += ["--sponsorblock-remove", "default", "--sponsorblock-mark", "all"]
|
||||
|
||||
if klient:
|
||||
# Nyt forsøg efter en 403: bed YouTube om videoen som en anden klient.
|
||||
cmd += ["--extractor-args", f"youtube:player_client={klient}"]
|
||||
|
||||
if not job.get("playlist"):
|
||||
cmd.append("--no-playlist")
|
||||
cmd.append(job["url"])
|
||||
return cmd
|
||||
|
||||
|
||||
async def _attempt(job: dict, settings: dict, with_subs: bool) -> dict:
|
||||
async def _attempt(job: dict, settings: dict, with_subs: bool, klient: str = "") -> dict:
|
||||
dest = config.YT_DOWNLOAD_DIR
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*_build_cmd(job, settings, with_subs),
|
||||
*_build_cmd(job, settings, with_subs, klient),
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
@@ -177,19 +182,47 @@ async def _attempt(job: dict, settings: dict, with_subs: bool) -> dict:
|
||||
"note": "" if with_subs else "uden undertekster"}
|
||||
|
||||
|
||||
# Fejl der ofte går over af sig selv: YouTube afviser en enkelt forespørgsel
|
||||
# (403/429), eller forbindelsen falder. yt-dlp gentager ikke selv en 403.
|
||||
_MIDLERTIDIG = ("403", "forbidden", "429", "too many requests", "timed out",
|
||||
"connection reset", "remote end closed", "temporary failure")
|
||||
# Forsøg: (pause før, YouTube-klient). Andet og tredje forsøg beder om videoen
|
||||
# som en anden klient, hvilket typisk kommer uden om en 403.
|
||||
FORSOEG = [(0, ""), (30, "tv,web_safari"), (90, "mweb")]
|
||||
|
||||
|
||||
def midlertidig(err: str) -> bool:
|
||||
e = (err or "").lower()
|
||||
return any(m in e for m in _MIDLERTIDIG)
|
||||
|
||||
|
||||
async def _run_ytdlp(job: dict, settings: dict) -> dict:
|
||||
"""Kør yt-dlp for ét job. Returnerer {ok, title, path, size, error}.
|
||||
|
||||
Undertekster er et gode, ikke et krav: YouTube rate-limiter dem (HTTP 429)
|
||||
uafhængigt af selve videoen, og uden dette fald-tilbage ville en 429 på en
|
||||
undertekstfil vælte hele hentningen. Vi prøver derfor én gang mere uden dem.
|
||||
|
||||
Midlertidige fejl (403/429/timeout) prøves igen op til to gange med pause
|
||||
og en anden YouTube-klient (FORSOEG).
|
||||
"""
|
||||
want_subs = bool(settings.get("subtitles", True))
|
||||
res = await _attempt(job, settings, with_subs=want_subs)
|
||||
if not res["ok"] and want_subs and "subtitle" in res["error"].lower():
|
||||
res = await _attempt(job, settings, with_subs=False)
|
||||
res = {"ok": False, "error": "ingen forsøg"}
|
||||
for nr, (pause, klient) in enumerate(FORSOEG, start=1):
|
||||
if pause:
|
||||
await asyncio.sleep(pause)
|
||||
res = await _attempt(job, settings, with_subs=want_subs, klient=klient)
|
||||
if not res["ok"] and want_subs and "subtitle" in res["error"].lower():
|
||||
res = await _attempt(job, settings, with_subs=False, klient=klient)
|
||||
if res["ok"]:
|
||||
res["note"] = "undertekster sprunget over (YouTube afviste dem lige nu)"
|
||||
if res["ok"]:
|
||||
res["note"] = "undertekster sprunget over (YouTube afviste dem lige nu)"
|
||||
if nr > 1:
|
||||
res["note"] = "; ".join(x for x in (res.get("note"), f"lykkedes i forsøg {nr}") if x)
|
||||
return res
|
||||
if not midlertidig(res["error"]):
|
||||
return res
|
||||
res["error"] = f"{res['error']} (opgivet efter {len(FORSOEG)} forsøg)"
|
||||
return res
|
||||
|
||||
|
||||
@@ -214,10 +247,54 @@ def _sidecars(path: str) -> list:
|
||||
return out
|
||||
|
||||
|
||||
def _koe_laas():
|
||||
"""Én kørsel af køen ad gangen, også på tværs af processer (web + timer).
|
||||
Mange samtidige hentninger fra samme IP er netop det, YouTube svarer 403 på."""
|
||||
f = open(os.path.join(config.DATA, "yt_koe.lock"), "w")
|
||||
try:
|
||||
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
f.close()
|
||||
return None
|
||||
return f
|
||||
|
||||
|
||||
def retry_job(jid: str) -> bool:
|
||||
"""Læg et fejlet job i kø igen (knappen "Prøv igen")."""
|
||||
st = get_state()
|
||||
j = st["jobs"].get(jid)
|
||||
if not j or j["status"] != "error":
|
||||
return False
|
||||
j.update(status="queued", error="", finished=0)
|
||||
_log(st, f"Prøver igen: {(j.get('title') or j['url'])[:80]}")
|
||||
save_state(st)
|
||||
return True
|
||||
|
||||
|
||||
async def process_queue() -> dict:
|
||||
"""Hent alt der står i kø. Kaldes både fra web-ruten og fra timeren."""
|
||||
"""Hent alt der står i kø, ét job ad gangen. Kaldes både fra web-ruten og
|
||||
fra timeren; kører en anden allerede køen, gør denne ingenting."""
|
||||
laas = _koe_laas()
|
||||
if laas is None:
|
||||
return {"done": 0, "failed": 0, "busy": True}
|
||||
try:
|
||||
return await _process_queue()
|
||||
finally:
|
||||
laas.close()
|
||||
|
||||
|
||||
async def _process_queue() -> dict:
|
||||
settings = get_settings()
|
||||
done, failed = 0, 0
|
||||
# Vi har låsen, så et job der står som "running" blev afbrudt (fx af en
|
||||
# genstart af portalen midt i hentningen). Læg det i kø igen.
|
||||
st = get_state()
|
||||
haengt = [j for j in st["jobs"].values() if j["status"] == "running"]
|
||||
for j in haengt:
|
||||
j["status"] = "queued"
|
||||
_log(st, f"Genoptager afbrudt hentning: {(j.get('title') or j['url'])[:80]}")
|
||||
if haengt:
|
||||
save_state(st)
|
||||
while True:
|
||||
st = get_state()
|
||||
queued = [j for j in st["jobs"].values() if j["status"] == "queued"]
|
||||
@@ -488,6 +565,44 @@ async def enforce_cycles() -> dict:
|
||||
return res
|
||||
|
||||
|
||||
def delete_video(path: str) -> dict:
|
||||
"""Slet én video manuelt (knappen på /admin/yt), med samme sikkerhedsnet som
|
||||
oprydningen: KUN filer portalen selv har hentet, og sidecars med.
|
||||
|
||||
Video-id'et bliver stående i abonnementets `seen`, så en overvåget kanal ikke
|
||||
henter den igen. Er det jobbets sidste fil, markeres jobbet som slettet.
|
||||
"""
|
||||
real = os.path.realpath(path or "")
|
||||
if real not in owned_paths():
|
||||
return {"ok": False, "error": "Den video har portalen ikke selv hentet, så den røres ikke"}
|
||||
st = get_state()
|
||||
try:
|
||||
size = file_size(real)
|
||||
for side in _sidecars(real):
|
||||
os.remove(side)
|
||||
if os.path.exists(real):
|
||||
os.remove(real)
|
||||
except OSError as e:
|
||||
return {"ok": False, "error": f"Kunne ikke slette: {e}"}
|
||||
st["leaving"].pop(real, None)
|
||||
titel = os.path.basename(real)
|
||||
for j in st["jobs"].values():
|
||||
paths = [p for p in (j.get("paths") or ([j["path"]] if j.get("path") else [])) if p]
|
||||
if real not in {os.path.realpath(p) for p in paths}:
|
||||
continue
|
||||
titel = j.get("title") or titel
|
||||
rest = [p for p in paths if os.path.realpath(p) != real]
|
||||
if j.get("paths") is not None:
|
||||
j["paths"] = rest
|
||||
if j.get("videos"):
|
||||
j["videos"] = [v for v in j["videos"] if os.path.realpath(v.get("path") or "") != real]
|
||||
if not rest:
|
||||
j["status"] = "deleted"
|
||||
_log(st, f"Slettet manuelt: {titel} ({_human(size)})")
|
||||
save_state(st)
|
||||
return {"ok": True, "title": titel, "size": size}
|
||||
|
||||
|
||||
# ---------------- Emby ----------------
|
||||
async def _emby(client, path, **params):
|
||||
params["api_key"] = config.EMBY_API_KEY
|
||||
|
||||
Reference in New Issue
Block a user