Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df3c31e43 | ||
|
|
6ee54c1248 | ||
|
|
77f33f7c9e | ||
|
|
776c314b0f | ||
|
|
1b85698856 | ||
|
|
8751ae5722 | ||
|
|
ff76660910 | ||
|
|
fbdb257079 | ||
|
|
265b48905a | ||
|
|
902ab6244e | ||
|
|
6d32fd2b71 | ||
|
|
176859b15a | ||
|
|
70bc700a11 | ||
|
|
94e5a0ca01 |
@@ -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,8 +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.
|
||||
PODCAST_GROUP = os.environ.get("PODCAST_GROUP", "Familie")
|
||||
# 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", "")
|
||||
|
||||
@@ -7,6 +7,8 @@ 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
|
||||
|
||||
@@ -71,14 +73,14 @@ def require_admin(request: Request):
|
||||
|
||||
|
||||
def require_familie(request: Request):
|
||||
"""Logget ind og i PODCAST_GROUP (eller admin). Uden login: login og tilbage hertil."""
|
||||
"""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_GROUP in (user.get("groups") or [])):
|
||||
if not (is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or [])):
|
||||
raise HTTPException(403, "Kun for familien")
|
||||
return user
|
||||
|
||||
@@ -132,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:
|
||||
@@ -153,7 +154,7 @@ async def dashboard_content(request: Request):
|
||||
|
||||
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_GROUP in (user.get("groups") or [])):
|
||||
if not (is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or [])):
|
||||
return []
|
||||
ud = []
|
||||
for name in podcast.shows():
|
||||
@@ -235,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", {
|
||||
@@ -398,23 +398,37 @@ async def admin_secrets_delete(request: Request, navn: str):
|
||||
|
||||
|
||||
# ---------------- 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)
|
||||
base = str(request.base_url).rstrip("/")
|
||||
udsendelser = []
|
||||
for name in podcast.shows():
|
||||
s = podcast.show(name)
|
||||
feed = f"{base}/podcast/{key}/{name}.xml"
|
||||
uden = feed.split("://", 1)[1]
|
||||
udsendelser.append({**s, "feed": feed, "uden_skema": uden,
|
||||
"feed_enc": urllib.parse.quote(feed, safe=""),
|
||||
"episoder": podcast.episodes(name, med_kladder=admin)})
|
||||
rod = f"/podcast/{key}"
|
||||
return templates.TemplateResponse(request, "podcast.html", {
|
||||
"request": request, "user": user, "is_admin": admin, "key": key,
|
||||
"udsendelser": udsendelser, "saved": saved})
|
||||
"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")
|
||||
@@ -425,12 +439,102 @@ async def podcast_ny_noegle(request: Request):
|
||||
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:
|
||||
raise HTTPException(404)
|
||||
xml = podcast.feed(name, key, str(request.base_url).rstrip("/"), med_kladder=bool(v.get("admin")))
|
||||
_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",
|
||||
@@ -438,17 +542,18 @@ async def podcast_feed(request: Request, key: str, name: str):
|
||||
|
||||
|
||||
@app.get("/podcast/{key}/{name}/cover.jpg")
|
||||
async def podcast_cover(key: str, name: str):
|
||||
if not podcast.KEY.match(key) or not any(k["key"] == key and not k.get("tilbagekaldt") for k in podcast.alle_noegler()):
|
||||
raise HTTPException(404)
|
||||
async def podcast_cover(request: Request, key: str, name: str):
|
||||
p = podcast.cover(name)
|
||||
if not p:
|
||||
raise HTTPException(404)
|
||||
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.
|
||||
@@ -458,7 +563,7 @@ async def podcast_mp3(request: Request, key: str, name: str, slug: str):
|
||||
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:
|
||||
raise HTTPException(404)
|
||||
_ikke_fundet(request)
|
||||
meta = podcast.episode_meta(name, slug) or {}
|
||||
if meta.get("kladde") and not v.get("admin"):
|
||||
raise HTTPException(404)
|
||||
@@ -471,10 +576,27 @@ 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(),
|
||||
"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)
|
||||
@@ -947,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)
|
||||
|
||||
+73
-4
@@ -11,6 +11,7 @@ Data (ikke i git, persondata):
|
||||
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.
|
||||
@@ -29,6 +30,7 @@ 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}$")
|
||||
@@ -113,8 +115,9 @@ def saet_kladde(name: str, slug: str, kladde: bool) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def cover(name: str) -> Path | None:
|
||||
p = ROOT / name / "cover.jpg"
|
||||
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
|
||||
|
||||
|
||||
@@ -213,11 +216,12 @@ def _hms(sek: int) -> str:
|
||||
return f"{sek // 3600:d}:{sek // 60 % 60:02d}:{sek % 60:02d}"
|
||||
|
||||
|
||||
def feed(name: str, key: str, base: str, med_kladder: bool) -> str | None:
|
||||
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}/podcast/{key}/{name}"
|
||||
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"])
|
||||
@@ -254,3 +258,68 @@ def feed(name: str, key: str, base: str, med_kladder: bool) -> str | None:
|
||||
</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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -34,6 +34,35 @@
|
||||
</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>
|
||||
|
||||
+23
-3
@@ -54,7 +54,7 @@
|
||||
<div class="setgrid" style="margin-top:.8rem">
|
||||
<label class="fld"><span>Karens efter set (dage)</span><input type="number" name="grace_days" min="0" max="365" value="{{ settings.grace_days }}"></label>
|
||||
<label class="fld"><span>Undertekst-sprog</span><input name="sub_langs" value="{{ settings.sub_langs }}"></label>
|
||||
<label class="fld"><span>Emby-bibliotek</span><input name="library_name" value="{{ settings.library_name }}"></label>
|
||||
<label class="fld"><span>Emby-kanal/bibliotek</span><input name="library_name" value="{{ settings.library_name }}"></label>
|
||||
<label class="fld"><span>Kvalitet (yt-dlp -f)</span><input name="quality" value="{{ settings.quality }}"></label>
|
||||
</div>
|
||||
<p style="margin-top:1rem;display:flex;gap:.6rem;flex-wrap:wrap">
|
||||
@@ -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,19 +12,6 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
{% if error %}<div class="err">{{ error }}</div>{% endif %}
|
||||
{% if groups %}
|
||||
<div class="toolbar">
|
||||
<div class="filter"><span class="mag">⌕</span>
|
||||
<input id="appfilter" type="search" placeholder="Filtrér…" autocomplete="off" aria-label="Filtrér tjenester"></div>
|
||||
<div class="viewtoggle" role="group" aria-label="Visning">
|
||||
<button type="button" data-view="grouped" class="{{ 'on' if view != 'alpha' }}">Grupperet</button>
|
||||
<button type="button" data-view="alpha" class="{{ 'on' if view == 'alpha' }}">Alfabetisk</button>
|
||||
</div>
|
||||
</div>
|
||||
<section id="favsec" class="appgroup"{% if not fav_apps %} style="display:none"{% endif %}>
|
||||
<h2>★ Favoritter</h2>
|
||||
<div class="grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
|
||||
</section>
|
||||
{% if podcasts %}
|
||||
<section class="appgroup" id="podsec">
|
||||
<h2>🎙️ Familiens podcast</h2>
|
||||
@@ -40,6 +27,19 @@
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if groups %}
|
||||
<div class="toolbar">
|
||||
<div class="filter"><span class="mag">⌕</span>
|
||||
<input id="appfilter" type="search" placeholder="Filtrér…" autocomplete="off" aria-label="Filtrér tjenester"></div>
|
||||
<div class="viewtoggle" role="group" aria-label="Visning">
|
||||
<button type="button" data-view="grouped" class="{{ 'on' if view != 'alpha' }}">Grupperet</button>
|
||||
<button type="button" data-view="alpha" class="{{ 'on' if view == 'alpha' }}">Alfabetisk</button>
|
||||
</div>
|
||||
</div>
|
||||
<section id="favsec" class="appgroup"{% if not fav_apps %} style="display:none"{% endif %}>
|
||||
<h2>★ Favoritter</h2>
|
||||
<div class="grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
|
||||
</section>
|
||||
{% if mc_servers %}
|
||||
<section class="appgroup" id="mcsec">
|
||||
<h2>⛏️ Minecraft-servere</h2>
|
||||
|
||||
+52
-6
@@ -1,6 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Podcast{% endblock %}
|
||||
{% 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>
|
||||
@@ -10,7 +28,7 @@
|
||||
<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="/podcast/{{ key }}/{{ u.slug }}/cover.jpg" alt="" width="160" height="160"
|
||||
<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">
|
||||
@@ -21,7 +39,20 @@
|
||||
|
||||
<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>
|
||||
<p class="wt-muted" style="margin:.2rem 0 .8rem">Tryk på din app. Nye episoder kommer så af sig selv. Adressen er din egen, så del den ikke; familien får hver sin.</p>
|
||||
{% 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>
|
||||
@@ -48,9 +79,9 @@
|
||||
{% 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">{{ e.beskrivelse }}</p>{% endif %}
|
||||
{% 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="/podcast/{{ key }}/{{ u.slug }}/{{ e.slug }}.mp3"></audio>
|
||||
src="{{ rod }}/{{ u.slug }}/{{ e.slug }}.mp3"></audio>
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen episoder endnu.</p>
|
||||
@@ -58,7 +89,7 @@
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
{% if udsendelser %}
|
||||
{% 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>
|
||||
@@ -66,6 +97,20 @@
|
||||
</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);
|
||||
@@ -76,4 +121,5 @@ document.querySelectorAll('[data-kopier]').forEach(function (b) {
|
||||
});
|
||||
});
|
||||
</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']}")
|
||||
@@ -35,6 +51,20 @@ async def main():
|
||||
for e in cyc["errors"]:
|
||||
print(" cyklus-fejl:", e)
|
||||
|
||||
# Kanal-plugin'ets indeks: playlister (højst ugentligt pr. kanal) og så indekset.
|
||||
try:
|
||||
pl = await ytdl.opdater_playlister()
|
||||
print(f"yt_job: playlister opdateret for {pl['kanaler']} kanaler, {pl['playlister']} i alt")
|
||||
for e in pl["fejl"][:10]:
|
||||
print(" playliste-fejl:", e)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"yt_job: playlister kunne ikke opdateres: {e}")
|
||||
try:
|
||||
ix = ytdl.skriv_indeks()
|
||||
print(f"yt_job: indeks: {ix['channels']} kanaler, {ix['videos']} videoer, {ix['playlists']} playlister")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"yt_job: indekset kunne ikke skrives: {e}")
|
||||
|
||||
res = await ytdl.run_cycle(dry_run=False)
|
||||
if res.get("error"):
|
||||
print("yt_job FEJL:", res["error"])
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
"""Engangs-flytning af YouTube-biblioteket til kanal = serie (2026-10).
|
||||
|
||||
Før lå alle videoer fladt i download-mappen i et Emby "hjemmevideo"-bibliotek:
|
||||
ingen kanaler, ingen billeder, og datoen var hentetidspunktet. Nu er hver kanal
|
||||
en serie (se ytdl.organiser). Dette script flytter de eksisterende videoer:
|
||||
|
||||
python yt_migrer.py flyt # gem Emby-status, hent info/miniature, flyt, opdatér state
|
||||
python yt_migrer.py emby # genopret biblioteket som "Serier", scan, sæt status på igen
|
||||
|
||||
`flyt` tager kø-låsen, så intet hentes imens. Emby-status (set, favorit, hvor
|
||||
langt man er nået) gemmes i DATA/yt_migrering.json før noget flyttes, fordi
|
||||
Emby ser flyttede filer som nye; `emby` sætter den på igen ud fra video-id'et.
|
||||
Begge trin kan køres igen uden skade.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
import config
|
||||
import ytdl
|
||||
|
||||
MAPPE = config.YT_DOWNLOAD_DIR
|
||||
GEM = config.DATA / "yt_migrering.json"
|
||||
ID_RE = re.compile(r"\[([A-Za-z0-9_-]{11})\]\.(mp4|mkv|webm)$")
|
||||
|
||||
|
||||
def _gem(d):
|
||||
tmp = str(GEM) + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(d, f, ensure_ascii=False, indent=1)
|
||||
os.replace(tmp, GEM)
|
||||
|
||||
|
||||
def _laes():
|
||||
try:
|
||||
return json.loads(GEM.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {"status": {}, "flyttet": {}}
|
||||
|
||||
|
||||
async def _emby_status() -> dict:
|
||||
"""video-id -> {bruger-id: {played, favorite, pos}} for alt i YouTube-biblioteket."""
|
||||
ud = {}
|
||||
async with httpx.AsyncClient(timeout=60) as c:
|
||||
lib = await ytdl._library_id(c, ytdl.get_settings().get("library_name") or "YouTube")
|
||||
if not lib:
|
||||
return ud
|
||||
for u in await ytdl._emby(c, "/Users"):
|
||||
data = await ytdl._emby(c, f"/Users/{u['Id']}/Items", ParentId=lib, Recursive="true",
|
||||
Fields="Path,UserData", IncludeItemTypes="Video,Episode,Movie")
|
||||
for it in data.get("Items", []):
|
||||
m = ID_RE.search(it.get("Path") or "")
|
||||
ud_ = it.get("UserData") or {}
|
||||
if not m or not (ud_.get("Played") or ud_.get("IsFavorite") or ud_.get("PlaybackPositionTicks")):
|
||||
continue
|
||||
ud.setdefault(m.group(1), {})[u["Id"]] = {
|
||||
"played": bool(ud_.get("Played")), "favorite": bool(ud_.get("IsFavorite")),
|
||||
"pos": int(ud_.get("PlaybackPositionTicks") or 0)}
|
||||
return ud
|
||||
|
||||
|
||||
async def _info(vid: str) -> dict:
|
||||
"""Info + miniature fra YouTube uden at hente videoen (med samme nye forsøg som hentning)."""
|
||||
sd = ytdl.staging_dir()
|
||||
for pause, _ in ytdl.FORSOEG:
|
||||
if pause:
|
||||
await asyncio.sleep(pause)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
config.YT_DLP_BIN, "--skip-download", "--write-info-json", "--write-thumbnail",
|
||||
"--convert-thumbnails", "jpg", "--no-warnings", "--no-playlist",
|
||||
"-o", os.path.join(sd, "%(id)s.%(ext)s"), f"https://www.youtube.com/watch?v={vid}",
|
||||
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE)
|
||||
_, err = await proc.communicate()
|
||||
try:
|
||||
with open(os.path.join(sd, f"{vid}.info.json"), encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if not ytdl.midlertidig(err.decode("utf-8", "replace")):
|
||||
break
|
||||
return {}
|
||||
|
||||
|
||||
async def _playliste_ids(url: str) -> set:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
config.YT_DLP_BIN, "--flat-playlist", "--ignore-errors", "--print", "id", "--no-warnings", url,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
||||
out, _ = await proc.communicate()
|
||||
return set(out.decode().split())
|
||||
|
||||
|
||||
async def flyt():
|
||||
laas = ytdl._koe_laas()
|
||||
while laas is None:
|
||||
print("venter på at køen bliver ledig ...")
|
||||
await asyncio.sleep(30)
|
||||
laas = ytdl._koe_laas()
|
||||
try:
|
||||
gem = _laes()
|
||||
if not gem["status"]:
|
||||
gem["status"] = await _emby_status()
|
||||
_gem(gem)
|
||||
print(f"Emby-status gemt for {len(gem['status'])} videoer")
|
||||
|
||||
flade = sorted(f for f in os.listdir(MAPPE)
|
||||
if os.path.isfile(os.path.join(MAPPE, f)) and ID_RE.search(f))
|
||||
print(f"{len(flade)} videoer ligger stadig fladt")
|
||||
sd = ytdl.staging_dir()
|
||||
for i, f in enumerate(flade, 1):
|
||||
vid = ID_RE.search(f).group(1)
|
||||
gammel = os.path.realpath(os.path.join(MAPPE, f))
|
||||
info = await _info(vid)
|
||||
if not info:
|
||||
# Privat/slettet på YouTube: brug filnavnet, så den stadig får en plads.
|
||||
info = {"title": f.rsplit(" [", 1)[0], "channel": "Ukendt kanal"}
|
||||
stem = f.rsplit(".", 1)[0]
|
||||
for side in os.listdir(MAPPE):
|
||||
if side != f and (side.startswith(stem + ".") or side.startswith(stem + "-thumb.")):
|
||||
rest = side[len(stem) + 1:] if side.startswith(stem + ".") else side[len(stem) + 7:]
|
||||
if rest in ("jpg", "jpeg", "png", "webp") and os.path.exists(os.path.join(sd, f"{vid}.jpg")):
|
||||
os.remove(os.path.join(MAPPE, side)) # YouTubes miniature er bedre
|
||||
continue
|
||||
os.replace(os.path.join(MAPPE, side), os.path.join(sd, f"{vid}.{rest}"))
|
||||
staging = os.path.join(sd, f"{vid}{os.path.splitext(f)[1]}")
|
||||
os.replace(gammel, staging)
|
||||
o = await ytdl.organiser(staging, info)
|
||||
gem["flyttet"][gammel] = {"ny": os.path.realpath(o["path"]), "id": vid,
|
||||
"upload_date": o["upload_date"]}
|
||||
_gem(gem)
|
||||
print(f" {i}/{len(flade)} {o['title'][:60]}")
|
||||
await asyncio.sleep(2) # skån YouTube, det er netop mange kald der giver 403
|
||||
|
||||
# Opdatér portalens state: stier i jobs og "på vej ud".
|
||||
st = ytdl.get_state()
|
||||
flyttet = {k: v["ny"] for k, v in gem["flyttet"].items()}
|
||||
ny_af = lambda p: flyttet.get(os.path.realpath(p), p) if p else p # noqa: E731
|
||||
for j in st["jobs"].values():
|
||||
if j.get("path"):
|
||||
j["path"] = ny_af(j["path"])
|
||||
if j.get("paths"):
|
||||
j["paths"] = [ny_af(p) for p in j["paths"]]
|
||||
for v in j.get("videos") or []:
|
||||
v["path"] = ny_af(v.get("path"))
|
||||
st["leaving"] = {ny_af(k): v for k, v in st["leaving"].items()}
|
||||
|
||||
# Videoer fra en playliste, hvis job fejlede på én privat video, men hvor
|
||||
# resten blev hentet: knyt dem til jobbet, så de er portalens egne igen.
|
||||
# Ejet = stier i færdige job i den state vi lige har opdateret (ikke den på disken).
|
||||
ejet = {os.path.realpath(p) for j in st["jobs"].values() if j["status"] == "done"
|
||||
for p in (j.get("paths") or ([j["path"]] if j.get("path") else [])) if p}
|
||||
uejede = {v["id"]: v for k, v in gem["flyttet"].items() if v["ny"] not in ejet}
|
||||
for j in st["jobs"].values():
|
||||
if not uejede or not j.get("playlist") or j["status"] != "error":
|
||||
continue
|
||||
ids = await _playliste_ids(j["url"])
|
||||
mine = [v for vid, v in uejede.items() if vid in ids]
|
||||
if mine:
|
||||
j.update(status="done", error="", finished=time.time(),
|
||||
note=f"{len(mine)} videoer knyttet til jobbet ved flytningen; private sprunget over",
|
||||
paths=[v["ny"] for v in mine],
|
||||
videos=[{"id": v["id"], "upload_date": v["upload_date"], "path": v["ny"]} for v in mine],
|
||||
path=mine[0]["ny"])
|
||||
print(f"knyttede {len(mine)} videoer til playliste-jobbet {j['id']}")
|
||||
ytdl._log(st, f"Flyttet {len(gem['flyttet'])} videoer til kanal/sæson-struktur")
|
||||
ytdl.save_state(st)
|
||||
print("state opdateret")
|
||||
finally:
|
||||
laas.close()
|
||||
|
||||
|
||||
async def _emby_kald(c, metode, sti, **kw):
|
||||
params = kw.pop("params", {})
|
||||
params["api_key"] = config.EMBY_API_KEY
|
||||
r = await c.request(metode, f"{config.EMBY_URL}/emby{sti}", params=params, **kw)
|
||||
r.raise_for_status()
|
||||
return r
|
||||
|
||||
|
||||
async def emby():
|
||||
navn = ytdl.get_settings().get("library_name") or "YouTube"
|
||||
async with httpx.AsyncClient(timeout=120) as c:
|
||||
mapper = (await _emby_kald(c, "GET", "/Library/VirtualFolders")).json()
|
||||
gl = next((m for m in mapper if (m.get("Name") or "").lower() == navn.lower()), None)
|
||||
if gl and gl.get("CollectionType") != "tvshows":
|
||||
opts = gl.get("LibraryOptions") or {}
|
||||
await _emby_kald(c, "DELETE", "/Library/VirtualFolders",
|
||||
params={"name": gl["Name"], "refreshLibrary": "false"})
|
||||
print(f"fjernede {gl['Name']} ({gl.get('CollectionType')}); filerne røres ikke")
|
||||
else:
|
||||
opts = (gl or {}).get("LibraryOptions") or {}
|
||||
if not gl or gl.get("CollectionType") != "tvshows":
|
||||
# Kun lokale data: NFO'erne og billederne ved siden af filerne. Ingen
|
||||
# opslag hos TheTVDB/TMDb, der ville prøve at gætte kanalerne som tv-serier.
|
||||
opts.update({"EnableInternetProviders": False, "SaveLocalMetadata": False,
|
||||
"MetadataSavers": [], "EnableRealtimeMonitor": True,
|
||||
"EnableAutomaticSeriesGrouping": False,
|
||||
"TypeOptions": [{"Type": t, "MetadataFetchers": [], "ImageFetchers": [],
|
||||
"MetadataFetcherOrder": [], "ImageFetcherOrder": []}
|
||||
for t in ("Series", "Season", "Episode")],
|
||||
"PathInfos": [{"Path": MAPPE}]})
|
||||
await _emby_kald(c, "POST", "/Library/VirtualFolders",
|
||||
params={"name": navn, "collectionType": "tvshows", "refreshLibrary": "true",
|
||||
"paths": MAPPE},
|
||||
json={"Name": navn, "CollectionType": "tvshows", "RefreshLibrary": True,
|
||||
"Paths": [MAPPE], "LibraryOptions": opts})
|
||||
print(f"oprettede {navn} som Serier på {MAPPE}")
|
||||
|
||||
# Vent på at scanningen har fundet afsnittene.
|
||||
antal = sum(1 for _, _, fs in os.walk(MAPPE) for f in fs if ID_RE.search(f)
|
||||
and not f.startswith("."))
|
||||
lib, d = None, {}
|
||||
for _ in range(60):
|
||||
lib = await ytdl._library_id(c, navn)
|
||||
if lib:
|
||||
d = (await _emby_kald(c, "GET", "/Items", params={
|
||||
"ParentId": lib, "Recursive": "true", "IncludeItemTypes": "Episode",
|
||||
"Limit": "0"})).json()
|
||||
if d.get("TotalRecordCount", 0) >= antal:
|
||||
break
|
||||
await asyncio.sleep(10)
|
||||
print(f"Emby ser {d.get('TotalRecordCount', 0)} af {antal} afsnit")
|
||||
|
||||
status = _laes()["status"]
|
||||
sat = 0
|
||||
for uid in {u for v in status.values() for u in v}:
|
||||
data = (await _emby_kald(c, "GET", f"/Users/{uid}/Items", params={
|
||||
"ParentId": lib, "Recursive": "true", "IncludeItemTypes": "Episode",
|
||||
"Fields": "Path"})).json()
|
||||
for it in data.get("Items", []):
|
||||
m = ID_RE.search(it.get("Path") or "")
|
||||
s = status.get(m.group(1), {}).get(uid) if m else None
|
||||
if not s:
|
||||
continue
|
||||
if s["played"]:
|
||||
await _emby_kald(c, "POST", f"/Users/{uid}/PlayedItems/{it['Id']}")
|
||||
if s["favorite"]:
|
||||
await _emby_kald(c, "POST", f"/Users/{uid}/FavoriteItems/{it['Id']}")
|
||||
if s["pos"] and not s["played"]:
|
||||
await _emby_kald(c, "POST", f"/Users/{uid}/Items/{it['Id']}/UserData",
|
||||
json={"PlaybackPositionTicks": s["pos"]})
|
||||
sat += 1
|
||||
print(f"status sat på igen: {sat} (bruger, video)-par af "
|
||||
f"{sum(len(v) for v in status.values())}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
trin = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
if trin == "flyt":
|
||||
asyncio.run(flyt())
|
||||
elif trin == "emby":
|
||||
asyncio.run(emby())
|
||||
else:
|
||||
print(__doc__)
|
||||
sys.exit(2)
|
||||
@@ -10,11 +10,18 @@ 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 functools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -110,11 +117,10 @@ def _set_job(jid: str, **kw):
|
||||
save_state(st)
|
||||
|
||||
|
||||
def _build_cmd(job: dict, settings: dict, with_subs: bool) -> 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.
|
||||
out = os.path.join(dest, "%(title)s [%(id)s].%(ext)s")
|
||||
def _build_cmd(job: dict, settings: dict, with_subs: bool, klient: str = "") -> list:
|
||||
# yt-dlp henter til en skjult staging-mappe under video-id'et; organiser()
|
||||
# flytter bagefter videoen ind som Kanal/Season ÅÅÅÅ/... med NFO og billede.
|
||||
out = os.path.join(staging_dir(), "%(id)s.%(ext)s")
|
||||
cmd = [config.YT_DLP_BIN,
|
||||
"-f", settings.get("quality") or config.YT_DEFAULTS["quality"],
|
||||
"--merge-output-format", "mp4",
|
||||
@@ -122,6 +128,9 @@ def _build_cmd(job: dict, settings: dict, with_subs: bool) -> list:
|
||||
"--concurrent-fragments", "4",
|
||||
"--retries", "5", "--sleep-requests", "1",
|
||||
"--print", "after_move:%(id)s|%(upload_date)s|%(filepath)s",
|
||||
# Titel, kanal, beskrivelse og upload-dato til NFO'en, og YouTubes
|
||||
# miniature som afsnitsbillede i Emby.
|
||||
"--write-info-json", "--write-thumbnail", "--convert-thumbnails", "jpg",
|
||||
"-o", out]
|
||||
|
||||
if settings.get("metadata", True):
|
||||
@@ -142,21 +151,28 @@ 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:
|
||||
# Valgfrit: bed YouTube om videoen som en anden klient (se FORSOEG).
|
||||
cmd += ["--extractor-args", f"youtube:player_client={klient}"]
|
||||
|
||||
if not job.get("playlist"):
|
||||
cmd.append("--no-playlist")
|
||||
else:
|
||||
# En enkelt privat/slettet video må ikke vælte resten af playlisten.
|
||||
cmd.append("--ignore-errors")
|
||||
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:
|
||||
err = (stderr.decode("utf-8", "replace").strip().splitlines() or ["ukendt fejl"])[-1]
|
||||
return {"ok": False, "error": err[:300]}
|
||||
if proc.returncode is not None and proc.returncode < 0:
|
||||
# Dræbt udefra, typisk fordi portalen genstartede midt i hentningen.
|
||||
return {"ok": False, "error": f"afbrudt (signal {-proc.returncode})"}
|
||||
|
||||
# --print after_move giver én linje pr. hentet fil: id|upload_date|sti.
|
||||
# Video-id og upload-dato gemmes, fordi cyklussen skal kunne sortere efter
|
||||
@@ -167,14 +183,545 @@ async def _attempt(job: dict, settings: dict, with_subs: bool) -> dict:
|
||||
if len(parts) == 3 and parts[2].startswith(dest):
|
||||
videos.append({"id": parts[0], "upload_date": parts[1].strip("NA "),
|
||||
"path": parts[2]})
|
||||
err = (stderr.decode("utf-8", "replace").strip().splitlines()
|
||||
or [f"ukendt fejl (kode {proc.returncode})"])[-1][:300]
|
||||
if proc.returncode != 0 and not (job.get("playlist") and videos):
|
||||
return {"ok": False, "error": err}
|
||||
if not videos:
|
||||
return {"ok": False, "error": "yt-dlp meldte ingen fil — allerede hentet?"}
|
||||
|
||||
# Ind i Kanal/Season ÅÅÅÅ/ med NFO og billede, så Emby viser det som en serie.
|
||||
titler = []
|
||||
for v in videos:
|
||||
o = await organiser(v["path"])
|
||||
v["path"] = o["path"]
|
||||
v["upload_date"] = o["upload_date"] or v["upload_date"]
|
||||
titler.append(o["title"])
|
||||
paths = [v["path"] for v in videos]
|
||||
size = sum(os.path.getsize(p) for p in paths if os.path.exists(p))
|
||||
title = os.path.basename(paths[0]).rsplit(".", 1)[0]
|
||||
size = sum(file_size(p) for p in paths if os.path.exists(p))
|
||||
title = titler[0] if len(titler) == 1 else f"{titler[0]} (+{len(titler) - 1} fra playlisten)"
|
||||
noter = [] if with_subs else ["uden undertekster"]
|
||||
if proc.returncode != 0:
|
||||
noter.append(f"nogle videoer i playlisten blev sprunget over ({err[:120]})")
|
||||
return {"ok": True, "title": title, "path": paths[0], "paths": paths,
|
||||
"videos": videos, "size": size,
|
||||
"note": "" if with_subs else "uden undertekster"}
|
||||
"videos": videos, "size": size, "note": "; ".join(noter)}
|
||||
|
||||
|
||||
# ---------------- Emby-struktur: kanal = serie ----------------
|
||||
# Hver kanal bliver en serie i et Emby "Serier"-bibliotek:
|
||||
# <Kanal>/tvshow.nfo, poster.jpg (kanalens profilbillede), fanart.jpg (banner)
|
||||
# <Kanal>/Season ÅÅÅÅ/<Kanal> - sÅÅÅÅeMMDDnn - <Titel> [<id>].mp4
|
||||
# + .nfo (titel, beskrivelse, upload-dato) + -thumb.jpg + .<sprog>.srt
|
||||
# Afsnitsnummeret er upload-datoen (MMDD) + et løbenummer, så Emby sorterer
|
||||
# efter hvornår videoen kom på YouTube, ikke hvornår vi hentede den.
|
||||
STAGING_NAVN = ".staging"
|
||||
KANAL_FILER = ("tvshow.nfo", "poster.jpg", "fanart.jpg", "banner.jpg")
|
||||
_UGYLDIG = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
|
||||
|
||||
|
||||
def staging_dir() -> str:
|
||||
d = os.path.join(config.YT_DOWNLOAD_DIR, STAGING_NAVN)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _rent(navn: str, maks: int = 120) -> str:
|
||||
s = re.sub(r"\s+", " ", _UGYLDIG.sub("", navn or "")).strip().rstrip(". ")
|
||||
return s[:maks].rstrip(". ") or "Ukendt"
|
||||
|
||||
|
||||
def _dato(d: str) -> str:
|
||||
return f"{d[:4]}-{d[4:6]}-{d[6:8]}" if d and len(d) == 8 and d.isdigit() else ""
|
||||
|
||||
|
||||
def _skriv_tekst(sti: str, tekst: str) -> None:
|
||||
tmp = sti + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(tekst)
|
||||
os.replace(tmp, sti)
|
||||
|
||||
|
||||
def _nfo(rod: str, felter: list) -> str:
|
||||
linjer = ['<?xml version="1.0" encoding="utf-8" standalone="yes"?>', f"<{rod}>"]
|
||||
for tag, v, attr in felter:
|
||||
if v not in (None, ""):
|
||||
linjer.append(f" <{tag}{attr}>{escape(str(v))}</{tag}>")
|
||||
linjer += [" <lockdata>true</lockdata>", f"</{rod}>", ""]
|
||||
return "\n".join(linjer)
|
||||
|
||||
|
||||
async def _hent_billede(url: str, sti: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as c:
|
||||
r = await c.get(url)
|
||||
if r.status_code == 200 and r.content:
|
||||
with open(sti + ".tmp", "wb") as f:
|
||||
f.write(r.content)
|
||||
os.replace(sti + ".tmp", sti)
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
async def _kanal_info(kanal_dir: str, kanal: str, info: dict) -> None:
|
||||
"""tvshow.nfo + plakat/baggrund for kanalen (kun når de mangler)."""
|
||||
nfo = os.path.join(kanal_dir, "tvshow.nfo")
|
||||
if os.path.exists(nfo) and os.path.exists(os.path.join(kanal_dir, "poster.jpg")):
|
||||
return
|
||||
url = info.get("channel_url") or (
|
||||
f"https://www.youtube.com/channel/{info['channel_id']}" if info.get("channel_id") else "")
|
||||
meta = {}
|
||||
if url:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
config.YT_DLP_BIN, "--dump-single-json", "--flat-playlist", "--playlist-items", "0",
|
||||
"--no-warnings", url, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=120)
|
||||
meta = json.loads(out.decode("utf-8", "replace") or "{}")
|
||||
except Exception: # noqa: BLE001
|
||||
meta = {}
|
||||
if not os.path.exists(nfo):
|
||||
_skriv_tekst(nfo, _nfo("tvshow", [
|
||||
("title", kanal, ""), ("plot", meta.get("description") or "", ""),
|
||||
("studio", "YouTube", ""),
|
||||
("uniqueid", info.get("channel_id") or "", ' type="youtube" default="true"')]))
|
||||
thumbs = meta.get("thumbnails") or []
|
||||
def find(id_, pred):
|
||||
for t in thumbs:
|
||||
if t.get("id") == id_ and t.get("url"):
|
||||
return t["url"]
|
||||
kandidater = [t for t in thumbs if t.get("url") and pred(t)]
|
||||
kandidater.sort(key=lambda t: (t.get("width") or 0), reverse=True)
|
||||
return kandidater[0]["url"] if kandidater else ""
|
||||
avatar = find("avatar_uncropped", lambda t: t.get("width") and t.get("width") == t.get("height"))
|
||||
banner = find("banner_uncropped", lambda t: (t.get("width") or 0) > 2 * (t.get("height") or 1))
|
||||
if avatar and not os.path.exists(os.path.join(kanal_dir, "poster.jpg")):
|
||||
await _hent_billede(avatar, os.path.join(kanal_dir, "poster.jpg"))
|
||||
if banner and not os.path.exists(os.path.join(kanal_dir, "fanart.jpg")):
|
||||
await _hent_billede(banner, os.path.join(kanal_dir, "fanart.jpg"))
|
||||
|
||||
|
||||
async def organiser(staging_path: str, info: dict | None = None) -> dict:
|
||||
"""Flyt en hentet video fra staging ind i kanalens serie, med NFO og billede.
|
||||
Returnerer {path, title, upload_date}."""
|
||||
sd = os.path.dirname(staging_path)
|
||||
vid = os.path.basename(staging_path).split(".")[0]
|
||||
if info is None:
|
||||
try:
|
||||
with open(os.path.join(sd, f"{vid}.info.json"), encoding="utf-8") as f:
|
||||
info = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
info = {}
|
||||
kanal = _rent(info.get("channel") or info.get("uploader") or "Ukendt kanal", 80)
|
||||
dato = info.get("upload_date") or info.get("release_date") or time.strftime("%Y%m%d")
|
||||
aar, md = dato[:4], dato[4:8]
|
||||
kanal_dir = os.path.join(config.YT_DOWNLOAD_DIR, kanal)
|
||||
saeson_dir = os.path.join(kanal_dir, f"Season {aar}")
|
||||
os.makedirs(saeson_dir, exist_ok=True)
|
||||
await _kanal_info(kanal_dir, kanal, info)
|
||||
|
||||
# Samme video igen (fx nyt forsøg): genbrug dens afsnitsnummer.
|
||||
ep, brugt = 0, set()
|
||||
for f in os.listdir(saeson_dir):
|
||||
m = re.search(rf" - s{aar}e(\d{{6}}) - ", f)
|
||||
if not m:
|
||||
continue
|
||||
if f"[{vid}]" in f:
|
||||
ep = int(m.group(1))
|
||||
brugt.add(int(m.group(1)))
|
||||
if not ep:
|
||||
base = int(md) * 100 if md.isdigit() else 0
|
||||
ep = next(base + n for n in range(1, 100) if base + n not in brugt)
|
||||
titel = info.get("title") or vid
|
||||
stem = f"{kanal} - s{aar}e{ep:06d} - {_rent(titel)} [{vid}]"
|
||||
final = os.path.join(saeson_dir, stem + os.path.splitext(staging_path)[1])
|
||||
os.replace(staging_path, final)
|
||||
for f in os.listdir(sd):
|
||||
if not f.startswith(vid + "."):
|
||||
continue
|
||||
src, rest = os.path.join(sd, f), f[len(vid) + 1:]
|
||||
if rest == "info.json":
|
||||
os.remove(src)
|
||||
elif rest in ("jpg", "jpeg", "png", "webp"):
|
||||
os.replace(src, os.path.join(saeson_dir, f"{stem}-thumb.{'jpg' if rest == 'jpeg' else rest}"))
|
||||
elif not rest.endswith(".part"):
|
||||
os.replace(src, os.path.join(saeson_dir, f"{stem}.{rest}"))
|
||||
varighed = info.get("duration")
|
||||
_skriv_tekst(os.path.join(saeson_dir, stem + ".nfo"), _nfo("episodedetails", [
|
||||
("title", titel, ""), ("showtitle", kanal, ""), ("season", aar, ""), ("episode", ep, ""),
|
||||
("plot", info.get("description") or "", ""), ("aired", _dato(dato), ""),
|
||||
("premiered", _dato(dato), ""), ("year", aar, ""),
|
||||
("runtime", int(varighed // 60) or "" if varighed else "", ""), ("studio", "YouTube", ""),
|
||||
("uniqueid", vid, ' type="youtube" default="true"')]))
|
||||
return {"path": final, "title": titel, "upload_date": dato}
|
||||
|
||||
|
||||
def _ryd_tomme_mapper(saeson_dir: str) -> None:
|
||||
"""Efter en sletning: fjern tom sæsonmappe, og hele kanalen når den ikke har
|
||||
flere videoer. Rører kun mapper under download-mappen og kun kendte filer."""
|
||||
rod = os.path.realpath(config.YT_DOWNLOAD_DIR)
|
||||
s = os.path.realpath(saeson_dir)
|
||||
if s == rod or not s.startswith(rod + os.sep):
|
||||
return
|
||||
try:
|
||||
kanal = os.path.dirname(s) if os.path.basename(s).startswith("Season ") else s
|
||||
if s != kanal and not os.listdir(s):
|
||||
os.rmdir(s)
|
||||
if kanal == rod or not kanal.startswith(rod + os.sep) or os.path.basename(kanal) == STAGING_NAVN:
|
||||
return
|
||||
for _, _, fs in os.walk(kanal):
|
||||
if any(f.lower().endswith((".mp4", ".mkv", ".webm")) for f in fs):
|
||||
return
|
||||
for f in KANAL_FILER:
|
||||
if os.path.exists(os.path.join(kanal, f)):
|
||||
os.remove(os.path.join(kanal, f))
|
||||
# Playlisternes miniaturer (skrevet af opdater_playlister) hører også til kanalen.
|
||||
shutil.rmtree(os.path.join(kanal, ".playlists"), ignore_errors=True)
|
||||
for d in os.listdir(kanal):
|
||||
dd = os.path.join(kanal, d)
|
||||
if d.startswith("Season ") and os.path.isdir(dd) and not os.listdir(dd):
|
||||
os.rmdir(dd)
|
||||
if not os.listdir(kanal):
|
||||
os.rmdir(kanal)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _slet_video(path: str) -> int:
|
||||
"""Slet videoen med alle sidecars og ryd tomme mapper. Returnerer bytes."""
|
||||
size = 0
|
||||
for side in _sidecars(path):
|
||||
size += os.path.getsize(side)
|
||||
os.remove(side)
|
||||
if os.path.exists(path):
|
||||
size += os.path.getsize(path)
|
||||
os.remove(path)
|
||||
_ryd_tomme_mapper(os.path.dirname(path))
|
||||
return size
|
||||
|
||||
|
||||
# ---------------- indeks til Emby-kanal-plugin'et ----------------
|
||||
# Emby kan kun vise serie → sæson → afsnit. Kanal-plugin'et (agw/emby-yt-kanaler)
|
||||
# viser i stedet Kanal → [Alle videoer | Playlister → Playliste] → videoer, hvor
|
||||
# en video kan ligge i flere playlister. Det læser dette indeks, som portalen
|
||||
# skriver ud fra disken (NFO'er og billeder) og playliste-cachen. Plugin'et har
|
||||
# ingen egen logik for YouTube; alt det ligger her.
|
||||
INDEKS_NAVN = ".weircon-index.json"
|
||||
PLAYLISTER_PATH = config.DATA / "yt_playlister.json"
|
||||
PLAYLISTE_MAKS_ALDER = 7 * 86400
|
||||
_VIDEO_ENDELSER = (".mp4", ".mkv", ".webm")
|
||||
_SRT_RE = re.compile(r"\.([A-Za-z0-9_-]+)\.(srt|vtt)$")
|
||||
|
||||
|
||||
def indeks_sti() -> str:
|
||||
return os.path.join(config.YT_DOWNLOAD_DIR, INDEKS_NAVN)
|
||||
|
||||
|
||||
def _laes_nfo(sti: str) -> dict:
|
||||
"""Felter fra en NFO (episodedetails/tvshow): tag -> tekst, uniqueid pr. type."""
|
||||
try:
|
||||
rod = ET.parse(sti).getroot()
|
||||
except (OSError, ET.ParseError):
|
||||
return {}
|
||||
ud = {}
|
||||
for el in rod:
|
||||
if el.tag == "uniqueid":
|
||||
ud[f"uniqueid:{el.get('type') or ''}"] = (el.text or "").strip()
|
||||
elif el.tag not in ud:
|
||||
ud[el.tag] = (el.text or "").strip()
|
||||
return ud
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1024)
|
||||
def _varighed_mp4(sti: str, mtime: float):
|
||||
try:
|
||||
from mutagen import File as _MFile
|
||||
m = _MFile(sti)
|
||||
return int(m.info.length) if m and m.info and m.info.length else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
FFPROBE_PATH = config.DATA / "yt_ffprobe.json"
|
||||
_ffprobe = {"data": None, "aendret": False}
|
||||
|
||||
|
||||
def _brok(v):
|
||||
try:
|
||||
t, n = str(v).split("/")
|
||||
return round(float(t) / float(n), 3) if float(n) else None
|
||||
except (ValueError, ZeroDivisionError):
|
||||
return None
|
||||
|
||||
|
||||
def _heltal(v):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _stroemme(sti: str, st_) -> dict:
|
||||
"""Video-/lydspor (codec, opløsning, bitrate) via ffprobe, så Emby-plugin'et kan
|
||||
sige til apps at filen kan afspilles direkte. Caches pr. sti+mtime+størrelse."""
|
||||
if _ffprobe["data"] is None:
|
||||
_ffprobe["data"] = _load(FFPROBE_PATH, {}) or {}
|
||||
noegle = f"{sti}|{int(st_.st_mtime)}|{st_.st_size}"
|
||||
if noegle in _ffprobe["data"]:
|
||||
return _ffprobe["data"][noegle]
|
||||
ud = {"bitrate": None, "streams": []}
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[shutil.which("ffprobe") or "ffprobe", "-v", "error", "-of", "json", "-show_entries",
|
||||
"stream=codec_type,codec_name,profile,width,height,pix_fmt,avg_frame_rate,bit_rate,"
|
||||
"channels,sample_rate:stream_disposition=attached_pic:stream_tags=language:format=bit_rate", sti],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
d = json.loads(r.stdout or "{}")
|
||||
ud["bitrate"] = _heltal((d.get("format") or {}).get("bit_rate"))
|
||||
for s_ in d.get("streams", []):
|
||||
if (s_.get("disposition") or {}).get("attached_pic"):
|
||||
continue # det indlejrede coverbillede er ikke et videospor
|
||||
if s_.get("codec_type") == "video":
|
||||
ud["streams"].append({"type": "video", "codec": s_.get("codec_name"), "profile": s_.get("profile"),
|
||||
"width": s_.get("width"), "height": s_.get("height"),
|
||||
"fps": _brok(s_.get("avg_frame_rate")), "bitrate": _heltal(s_.get("bit_rate")),
|
||||
"pix_fmt": s_.get("pix_fmt")})
|
||||
elif s_.get("codec_type") == "audio":
|
||||
ud["streams"].append({"type": "audio", "codec": s_.get("codec_name"), "profile": s_.get("profile"),
|
||||
"channels": s_.get("channels"), "sample_rate": _heltal(s_.get("sample_rate")),
|
||||
"bitrate": _heltal(s_.get("bit_rate")),
|
||||
"language": (s_.get("tags") or {}).get("language")})
|
||||
except Exception: # noqa: BLE001
|
||||
return ud # prøv igen næste gang
|
||||
_ffprobe["data"][noegle] = ud
|
||||
_ffprobe["aendret"] = True
|
||||
return ud
|
||||
|
||||
|
||||
def _laes_playlister() -> dict:
|
||||
return _load(PLAYLISTER_PATH, {}) or {}
|
||||
|
||||
|
||||
def skriv_indeks() -> dict:
|
||||
"""Skriv indekset (DOWNLOAD_DIR/.weircon-index.json) ud fra disken. Ingen netværk.
|
||||
Returnerer en lille statistik."""
|
||||
rod = config.YT_DOWNLOAD_DIR
|
||||
kanaler, videoer = [], {}
|
||||
try:
|
||||
navne = sorted(os.listdir(rod), key=str.lower)
|
||||
except OSError:
|
||||
navne = []
|
||||
for navn in navne:
|
||||
kdir = os.path.join(rod, navn)
|
||||
if navn.startswith(".") or not os.path.isdir(kdir):
|
||||
continue
|
||||
tv = _laes_nfo(os.path.join(kdir, "tvshow.nfo"))
|
||||
kid = tv.get("uniqueid:youtube") or f"navn:{navn}"
|
||||
mine = []
|
||||
for d, dirs, fs in os.walk(kdir):
|
||||
dirs[:] = [x for x in dirs if not x.startswith(".")]
|
||||
for f in fs:
|
||||
if not f.lower().endswith(_VIDEO_ENDELSER) or f.startswith("."):
|
||||
continue
|
||||
sti = os.path.join(d, f)
|
||||
stem = f.rsplit(".", 1)[0]
|
||||
nfo = _laes_nfo(os.path.join(d, stem + ".nfo"))
|
||||
m = re.search(r"\[([A-Za-z0-9_-]{11})\]$", stem)
|
||||
vid = nfo.get("uniqueid:youtube") or (m.group(1) if m else "")
|
||||
if not vid:
|
||||
continue
|
||||
st_ = os.stat(sti)
|
||||
thumb = os.path.join(d, stem + "-thumb.jpg")
|
||||
subs = []
|
||||
for side in sorted(fs):
|
||||
if side.startswith(stem + "."):
|
||||
sm = _SRT_RE.search(side[len(stem):])
|
||||
if sm:
|
||||
subs.append({"path": os.path.join(d, side), "lang": sm.group(1)})
|
||||
videoer[vid] = {
|
||||
"title": nfo.get("title") or stem, "channel": kid, "path": sti,
|
||||
"thumb": thumb if os.path.exists(thumb) else None,
|
||||
"description": nfo.get("plot") or "",
|
||||
"upload_date": nfo.get("aired") or nfo.get("premiered") or "",
|
||||
"duration": _varighed_mp4(sti, st_.st_mtime), "added": int(st_.st_mtime),
|
||||
"subtitles": subs, **_stroemme(sti, st_)}
|
||||
mine.append(vid)
|
||||
if not mine:
|
||||
continue
|
||||
mine.sort(key=lambda v: (videoer[v]["upload_date"], videoer[v]["added"]), reverse=True)
|
||||
poster, fanart = os.path.join(kdir, "poster.jpg"), os.path.join(kdir, "fanart.jpg")
|
||||
kanaler.append({"id": kid, "name": tv.get("title") or navn, "description": tv.get("plot") or "",
|
||||
"poster": poster if os.path.exists(poster) else None,
|
||||
"fanart": fanart if os.path.exists(fanart) else None,
|
||||
"videos": mine, "playlists": [], "_dir": kdir})
|
||||
|
||||
# Playlister fra cachen; kun hentede videoer, i YouTubes rækkefølge.
|
||||
cache = _laes_playlister()
|
||||
for k in kanaler:
|
||||
for pl in (cache.get(k["id"]) or {}).get("playlists", []):
|
||||
vids = [v for v in pl.get("videos", []) if v in videoer]
|
||||
if not vids:
|
||||
continue
|
||||
thumb = os.path.join(k["_dir"], ".playlists", f"{pl['id']}.jpg")
|
||||
k["playlists"].append({"id": pl["id"], "title": pl.get("title") or pl["id"],
|
||||
"thumb": thumb if os.path.exists(thumb) else None, "videos": vids})
|
||||
k.pop("_dir", None)
|
||||
|
||||
data = {"version": 1, "generated": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"channels": kanaler, "videos": videoer}
|
||||
sti = indeks_sti()
|
||||
tmp = sti + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=1)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, sti)
|
||||
if _ffprobe["aendret"] and _ffprobe["data"] is not None:
|
||||
stier = {v["path"] for v in videoer.values()}
|
||||
_ffprobe["data"] = {k: v for k, v in _ffprobe["data"].items() if k.split("|", 1)[0] in stier}
|
||||
_save(FFPROBE_PATH, _ffprobe["data"])
|
||||
_ffprobe["aendret"] = False
|
||||
return {"channels": len(kanaler), "videos": len(videoer),
|
||||
"playlists": sum(len(k["playlists"]) for k in kanaler)}
|
||||
|
||||
|
||||
async def _ytdlp_json(url: str) -> dict:
|
||||
"""yt-dlp --flat-playlist -J med samme nye forsøg som hentning (403/429)."""
|
||||
err = ""
|
||||
for pause, _ in FORSOEG:
|
||||
if pause:
|
||||
await asyncio.sleep(pause)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
config.YT_DLP_BIN, "--flat-playlist", "-J", "--no-warnings", "--ignore-errors", url,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
out, e = await proc.communicate()
|
||||
try:
|
||||
return json.loads(out.decode("utf-8", "replace") or "{}")
|
||||
except ValueError:
|
||||
err = e.decode("utf-8", "replace")
|
||||
if not midlertidig(err):
|
||||
break
|
||||
raise RuntimeError((err.strip().splitlines() or ["ukendt fejl"])[-1][:200])
|
||||
|
||||
|
||||
def _stoerste_thumb(thumbs: list) -> str:
|
||||
gode = [t for t in thumbs or [] if t.get("url")]
|
||||
gode.sort(key=lambda t: (t.get("width") or 0) * (t.get("height") or 0), reverse=True)
|
||||
return gode[0]["url"] if gode else ""
|
||||
|
||||
|
||||
async def _hent_playliste(pl_url: str) -> dict:
|
||||
d = await _ytdlp_json(pl_url)
|
||||
return {"id": d.get("id") or "", "title": d.get("title") or "",
|
||||
"channel_id": d.get("channel_id") or d.get("uploader_id") or "",
|
||||
"thumb_url": _stoerste_thumb(d.get("thumbnails")),
|
||||
"videos": [e["id"] for e in (d.get("entries") or []) if e and e.get("id")]}
|
||||
|
||||
|
||||
async def opdater_playlister(kun_nye: bool = False) -> dict:
|
||||
"""Hent kanalernes playlister (og deres video-id'er) til cachen.
|
||||
|
||||
Hver kanal opdateres højst hver 7. dag; kun_nye=True tager kun kanaler uden
|
||||
cache. Portalens egne playliste-job og -abonnementer tages altid med og
|
||||
knyttes til playlistens ejer-kanal. Bagefter skrives indekset.
|
||||
"""
|
||||
res = {"kanaler": 0, "playlister": 0, "fejl": []}
|
||||
cache = _laes_playlister()
|
||||
rod = config.YT_DOWNLOAD_DIR
|
||||
kanal_dirs = {}
|
||||
for navn in sorted(os.listdir(rod)):
|
||||
kdir = os.path.join(rod, navn)
|
||||
if navn.startswith(".") or not os.path.isdir(kdir):
|
||||
continue
|
||||
kid = _laes_nfo(os.path.join(kdir, "tvshow.nfo")).get("uniqueid:youtube")
|
||||
if kid:
|
||||
kanal_dirs[kid] = kdir
|
||||
nu = time.time()
|
||||
|
||||
for kid in kanal_dirs:
|
||||
gl = cache.get(kid) or {}
|
||||
if kun_nye and gl:
|
||||
continue
|
||||
if gl and nu - gl.get("updated", 0) < PLAYLISTE_MAKS_ALDER:
|
||||
continue
|
||||
try:
|
||||
d = await _ytdlp_json(f"https://www.youtube.com/channel/{kid}/playlists")
|
||||
pls = []
|
||||
for e in d.get("entries") or []:
|
||||
if not e or not e.get("id"):
|
||||
continue
|
||||
await asyncio.sleep(2) # skån YouTube
|
||||
try:
|
||||
pls.append(await _hent_playliste(
|
||||
e.get("url") or f"https://www.youtube.com/playlist?list={e['id']}"))
|
||||
except Exception as ex: # noqa: BLE001
|
||||
res["fejl"].append(f"{e.get('title') or e['id']}: {ex}")
|
||||
# Bevar egne playlister (fra job/abonnementer) der ikke står på kanalens fane.
|
||||
egne = [p for p in gl.get("playlists", []) if p.get("egen")]
|
||||
ids = {p["id"] for p in pls}
|
||||
cache[kid] = {"updated": nu, "playlists": pls + [p for p in egne if p["id"] not in ids]}
|
||||
res["kanaler"] += 1
|
||||
except Exception as ex: # noqa: BLE001
|
||||
res["fejl"].append(f"{kid}: {ex}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Portalens egne playlister (job med list= og playliste-abonnementer).
|
||||
st = get_state()
|
||||
urls = {j["url"] for j in st["jobs"].values() if "list=" in (j.get("url") or "")}
|
||||
urls |= {s["url"] for s in st["subs"].values() if s.get("kind") == "playlist"}
|
||||
kendte = {p["id"] for c in cache.values() for p in c.get("playlists", [])}
|
||||
for url in sorted(urls):
|
||||
m = re.search(r"list=([A-Za-z0-9_-]+)", url)
|
||||
if m and m.group(1) in kendte:
|
||||
# Allerede i en kanals cache; markér den som egen, så den bevares.
|
||||
for c in cache.values():
|
||||
for p in c.get("playlists", []):
|
||||
if p["id"] == m.group(1):
|
||||
p["egen"] = True
|
||||
continue
|
||||
try:
|
||||
await asyncio.sleep(2)
|
||||
pl = await _hent_playliste(
|
||||
f"https://www.youtube.com/playlist?list={m.group(1)}" if m else url)
|
||||
pl["egen"] = True
|
||||
ejer = pl.get("channel_id") or ""
|
||||
c = cache.setdefault(ejer, {"updated": 0, "playlists": []})
|
||||
c["playlists"] = [p for p in c["playlists"] if p["id"] != pl["id"]] + [pl]
|
||||
except Exception as ex: # noqa: BLE001
|
||||
res["fejl"].append(f"{url}: {ex}")
|
||||
|
||||
# Miniaturer til <Kanal>/.playlists/<id>.jpg (kun når de mangler).
|
||||
for kid, c in cache.items():
|
||||
kdir = kanal_dirs.get(kid)
|
||||
if not kdir:
|
||||
continue
|
||||
for p in c.get("playlists", []):
|
||||
mappe = os.path.join(kdir, ".playlists")
|
||||
sti = os.path.join(mappe, f"{p['id']}.jpg")
|
||||
if p.get("thumb_url") and not os.path.exists(sti):
|
||||
os.makedirs(mappe, exist_ok=True)
|
||||
await _hent_billede(p["thumb_url"], sti)
|
||||
res["playlister"] += 1
|
||||
_save(PLAYLISTER_PATH, cache)
|
||||
skriv_indeks()
|
||||
return res
|
||||
|
||||
|
||||
# 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", "afbrudt")
|
||||
# Forsøg: (pause før, YouTube-klient). Samme klient med stigende pause: testet
|
||||
# 2026-10-06 gav kun standardklienten 1080p (android_vr faldt til 360p, tv,
|
||||
# web_safari, mweb og tv_simply gav slet ingen formater), og 403'erne kom af
|
||||
# mange samtidige hentninger, som kø-låsen nu forhindrer. Klientfeltet bliver
|
||||
# stående, hvis en anden klient senere viser sig bedre.
|
||||
FORSOEG = [(0, ""), (30, ""), (120, "")]
|
||||
|
||||
|
||||
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:
|
||||
@@ -183,13 +730,27 @@ async def _run_ytdlp(job: dict, settings: dict) -> dict:
|
||||
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
|
||||
stigende pause (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
|
||||
|
||||
|
||||
@@ -207,17 +768,62 @@ def _sidecars(path: str) -> list:
|
||||
try:
|
||||
for f in os.listdir(folder):
|
||||
full = os.path.join(folder, f)
|
||||
if full != path and f.startswith(base + ".") and os.path.isfile(full):
|
||||
if full != path and (f.startswith(base + ".") or f.startswith(base + "-thumb.")) \
|
||||
and os.path.isfile(full):
|
||||
out.append(full)
|
||||
except OSError:
|
||||
pass
|
||||
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"]
|
||||
@@ -469,10 +1075,7 @@ async def enforce_cycles() -> dict:
|
||||
files.sort(key=lambda f: (f["upload_date"] or "", f["finished"]), reverse=True)
|
||||
for f in files[int(sub["cycle"]):]:
|
||||
try:
|
||||
size = file_size(f["path"])
|
||||
for side in _sidecars(f["path"]):
|
||||
os.remove(side)
|
||||
os.remove(f["path"])
|
||||
size = _slet_video(f["path"])
|
||||
st["leaving"].pop(f["path"], None)
|
||||
job = st["jobs"].get(f["job"])
|
||||
if job:
|
||||
@@ -488,6 +1091,40 @@ 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 = _slet_video(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
|
||||
@@ -516,35 +1153,79 @@ def _marked_played(ud: dict) -> bool:
|
||||
return bool((ud or {}).get("Played"))
|
||||
|
||||
|
||||
async def _kanal_brugerdata(c, users: list, merged: dict, navn: str) -> bool:
|
||||
"""Læg set/favorit fra Emby-kanalen (kanal-plugin'et) ind i `merged`, nøglet på
|
||||
filens sti. Kanalens elementer bærer video-id'et i ProviderIds["youtube"]; stien
|
||||
findes via indekset. Samme video ligger i "Alle videoer" og i playlister, og
|
||||
kopierne deler brugerdata, så det er ligegyldigt hvilken vi læser.
|
||||
|
||||
False hvis kanalen eller indekset mangler. Fejler opslaget for en bruger, kastes
|
||||
der: uden alle brugeres favoritter må slettereglen ikke køre."""
|
||||
try:
|
||||
with open(indeks_sti(), encoding="utf-8") as f:
|
||||
stier = {vid: os.path.realpath(v["path"]) for vid, v in json.load(f)["videos"].items()}
|
||||
except (OSError, ValueError, KeyError):
|
||||
return False
|
||||
if not users:
|
||||
return False
|
||||
kanaler = await _emby(c, "/Channels", UserId=users[0])
|
||||
kanal = next((k for k in (kanaler.get("Items") or [])
|
||||
if (k.get("Name") or "").lower() == navn.lower()), None)
|
||||
if not kanal:
|
||||
return False
|
||||
for uid in users:
|
||||
data = await _emby(c, f"/Users/{uid}/Items", ParentId=kanal["Id"],
|
||||
Recursive="true", IncludeItemTypes="Video", Fields="ProviderIds,UserData")
|
||||
for it in data.get("Items", []):
|
||||
pid = it.get("ProviderIds") or {}
|
||||
vid = pid.get("youtube") or pid.get("YouTube") or pid.get("Youtube")
|
||||
sti = stier.get(vid or "")
|
||||
if not sti:
|
||||
continue
|
||||
m = merged.setdefault(sti, {"id": it["Id"], "name": it.get("Name") or os.path.basename(sti),
|
||||
"path": sti, "played": False, "favorite": False})
|
||||
ud = it.get("UserData") or {}
|
||||
if _marked_played(ud):
|
||||
m["played"] = True
|
||||
if ud.get("IsFavorite"):
|
||||
m["favorite"] = True
|
||||
return True
|
||||
|
||||
|
||||
async def emby_yt_items() -> dict:
|
||||
"""Alle videoer i YouTube-biblioteket med set/favorit aggregeret over ALLE brugere."""
|
||||
"""Alle videoer i Emby med set/favorit aggregeret over ALLE brugere.
|
||||
|
||||
Kilden er kanalen fra kanal-plugin'et og/eller et bibliotek med samme navn
|
||||
(standard "YouTube"). Findes ingen af dem, er svaret ok=False, så intet slettes."""
|
||||
settings = get_settings()
|
||||
navn = settings.get("library_name") or "YouTube"
|
||||
out = {"ok": False, "items": [], "error": ""}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=45) as c:
|
||||
lib = await _library_id(c, settings.get("library_name") or "YouTube")
|
||||
if not lib:
|
||||
out["error"] = (f"Fandt ikke Emby-biblioteket "
|
||||
f"'{settings.get('library_name')}'")
|
||||
return out
|
||||
users = [u["Id"] for u in await _emby(c, "/Users")]
|
||||
merged = {}
|
||||
for uid in users:
|
||||
data = await _emby(c, f"/Users/{uid}/Items", ParentId=lib,
|
||||
Recursive="true", Fields="Path,UserData")
|
||||
for it in data.get("Items", []):
|
||||
path = it.get("Path") or ""
|
||||
if not path:
|
||||
continue
|
||||
key = os.path.realpath(path)
|
||||
m = merged.setdefault(key, {
|
||||
"id": it["Id"], "name": it.get("Name") or os.path.basename(path),
|
||||
"path": key, "played": False, "favorite": False})
|
||||
ud = it.get("UserData") or {}
|
||||
if _marked_played(ud):
|
||||
m["played"] = True
|
||||
if ud.get("IsFavorite"):
|
||||
m["favorite"] = True
|
||||
lib = await _library_id(c, navn)
|
||||
if lib:
|
||||
for uid in users:
|
||||
data = await _emby(c, f"/Users/{uid}/Items", ParentId=lib,
|
||||
Recursive="true", Fields="Path,UserData")
|
||||
for it in data.get("Items", []):
|
||||
path = it.get("Path") or ""
|
||||
if not path:
|
||||
continue
|
||||
key = os.path.realpath(path)
|
||||
m = merged.setdefault(key, {
|
||||
"id": it["Id"], "name": it.get("Name") or os.path.basename(path),
|
||||
"path": key, "played": False, "favorite": False})
|
||||
ud = it.get("UserData") or {}
|
||||
if _marked_played(ud):
|
||||
m["played"] = True
|
||||
if ud.get("IsFavorite"):
|
||||
m["favorite"] = True
|
||||
kanal = await _kanal_brugerdata(c, users, merged, navn)
|
||||
if not lib and not kanal:
|
||||
out["error"] = f"Fandt hverken Emby-kanalen eller -biblioteket '{navn}'"
|
||||
return out
|
||||
out["items"] = list(merged.values())
|
||||
out["ok"] = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -553,6 +1234,11 @@ async def emby_yt_items() -> dict:
|
||||
|
||||
|
||||
async def refresh_library():
|
||||
# Kanal-plugin'ets indeks følger altid disken: efter hentning og sletning.
|
||||
try:
|
||||
skriv_indeks()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
await c.post(f"{config.EMBY_URL}/emby/Library/Refresh",
|
||||
@@ -628,11 +1314,7 @@ async def run_cycle(dry_run: bool = False) -> dict:
|
||||
continue
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
for side in _sidecars(path):
|
||||
res["freed"] += os.path.getsize(side)
|
||||
os.remove(side)
|
||||
os.remove(path)
|
||||
res["freed"] += e.get("size") or 0
|
||||
res["freed"] += _slet_video(path)
|
||||
st["leaving"].pop(path, None)
|
||||
# marker jobbet som slettet så owned_paths ikke tæller den igen
|
||||
for j in st["jobs"].values():
|
||||
|
||||
Reference in New Issue
Block a user