Compare commits

...
8 Commits
Author SHA1 Message Date
agw 1b85698856 fix(yt): en dræbt hentning er midlertidig, ikke "ukendt fejl"
CD / deploy (push) Successful in 17s
CI / check (push) Successful in 25s
Når portalen genstarter midt i en hentning, bliver yt-dlp dræbt uden en
fejltekst, og jobbet endte som "ukendt fejl". Et negativt returkode (signal)
hedder nu "afbrudt" og prøves igen som de andre midlertidige fejl.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 13:57:54 +02:00
agw 8751ae5722 fix(yt): nye forsøg med samme klient og længere pause
CD / deploy (push) Successful in 15s
CI / check (push) Successful in 23s
Test mod en video der fik 403: kun standardklienten gav 1080p; android_vr
faldt til 360p, og tv, web_safari, mweb og tv_simply gav ingen brugbare
formater. Nye forsøg bruger derfor standardklienten med 30 s og 2 min pause.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 13:56:22 +02:00
agw ff76660910 fix(yt): én hentning ad gangen, nye forsøg ved 403, genoptag afbrudte
CD / deploy (push) Successful in 20s
CI / check (push) Successful in 30s
Tre fejl, der forstærkede hinanden:
- Hver tilføjelse startede sin egen kø-tømning, så flere hentninger kørte
  samtidig (og natlig timer kunne køre oveni). Mange samtidige hentninger fra
  samme IP er netop det YouTube svarer 403 på. Køen har nu en fil-lås
  (yt_koe.lock), så kun én kørsel henter, også på tværs af processer.
- yt-dlp gentager ikke selv en 403. Midlertidige fejl (403/429/timeout) prøves
  nu igen op til to gange med pause og en anden YouTube-klient
  (tv,web_safari og mweb). Permanente fejl prøves ikke igen.
- Et job der stod som "running", da portalen genstartede, blev hængende for
  altid. Med låsen ved vi, at et sådant job er afbrudt, og det lægges i kø
  igen ved næste kørsel.

Fejlede job har en "Prøv igen"-knap (og "Prøv alle fejlede igen"), og den
natlige kørsel opdaterer yt-dlp, før den henter.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 13:54:07 +02:00
agw fbdb257079 feat(yt): slet-knap på hentede videoer
CD / deploy (push) Successful in 14s
CI / check (push) Successful in 23s
Hver række under "Hentede videoer" på /admin/yt har nu en Slet-knap (med
bekræftelse; nævner det, hvis videoen er favorit). Sletningen bruger samme
sikkerhedsnet som oprydningen: kun filer portalen selv har hentet, og
undertekster/miniature slettes med. Er det jobbets sidste fil, markeres jobbet
som slettet, og Emby-biblioteket opdateres. Video-id'et bliver i
abonnementets "seen", så en overvåget kanal ikke henter den igen.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 13:38:56 +02:00
agw 265b48905a fix(yt): manglende import asyncio gav 500 på overvågning og tilføjelse
CI / check (push) Successful in 33s
CD / deploy (push) Successful in 49s
main.py brugte asyncio.create_task i YouTube-ruterne (tilføj video, tilføj
og kør overvågede kanaler), men importen på modulniveau forsvandt 2026-09-20
(9675a5b); kun to funktioner havde en lokal import. Siden da gav "tilføj
overvåget kanal" og "tilføj video" 500 (NameError).

CI kører nu pyflakes og fejler på udefinerede navne, så det fanges før drift.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 13:34:32 +02:00
agw 902ab6244e fix(podcast): Messenger på iPhone og vandret scroll
CD / deploy (push) Successful in 16s
CI / check (push) Successful in 23s
Messenger på iPhone blokerer også x-safari-https://, så "Åbn i Safari" gjorde
ingenting. I stedet vises to veje, der virker: menuen ⋯ → "Åbn i ekstern
browser", eller "Kopiér adressen" og tilføj feedet i Apple Podcasts
(Bibliotek → ⋯ → Følg en serie via URL). Kopieringen falder tilbage til
execCommand/prompt, hvis clipboard-API'et er spærret. Android beholder
intent://-knappen.

Siden kunne skubbes til siden på iPhone. Afspiller og felter må nu aldrig
blive bredere end kortet, felter har 16 px skrift (ellers zoomer iOS ind),
lange ord brydes, og html/body skjuler vandret overløb.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 08:03:19 +02:00
agw 6d32fd2b71 fix(podcast): lille omslag i link-forhåndsvisningen
CD / deploy (push) Successful in 15s
CI / check (push) Successful in 23s
Med det store omslag (3000 px) viste Messenger et kvadratisk billede i hele
beskedens bredde. Forhåndsvisningen bruger nu cover-lille.jpg (300 px), når
den findes, så den vises som en miniature ved teksten.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 07:54:54 +02:00
agw 176859b15a fix(podcast): fælles link virker i Messenger
CD / deploy (push) Successful in 16s
CI / check (push) Successful in 23s
Messengers indbyggede browser kan ikke åbne podcast-apps (podcast://,
overcast:// osv.), så abonnér-knapperne gjorde ingenting. Siden genkender nu
Facebook-, Messenger- og Instagram-browseren og viser en knap, der åbner
siden i Safari (x-safari-https) eller på Android i den almindelige browser
(intent://), plus en vejledning til menuen. Afspilleren virker stadig direkte.

Link-forhåndsvisningen (facebookexternalhit m.fl.) får en minimal side med
titel, en neutral tekst og omslaget som og:-tags, aldrig episodelisten, og
den tælles ikke som en hentning. Den fulde side har de samme og:-tags.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 07:52:11 +02:00
8 changed files with 323 additions and 22 deletions
+7 -1
View File
@@ -15,9 +15,15 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Tooling - 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 - name: Python compiles
run: python3 -m compileall -q . 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 - name: Templates parse
run: | run: |
python3 - <<'PY' python3 - <<'PY'
+80 -7
View File
@@ -7,6 +7,8 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
import asyncio
import re
import time import time
import urllib.parse import urllib.parse
@@ -132,7 +134,6 @@ async def dashboard_content(request: Request):
user = current_user(request) user = current_user(request)
if not user: if not user:
raise HTTPException(401) raise HTTPException(401)
import asyncio
mc_task = asyncio.create_task(svc.minecraft_servers()) mc_task = asyncio.create_task(svc.minecraft_servers())
apps, error = [], None apps, error = [], None
try: try:
@@ -235,7 +236,6 @@ async def admin_ops(request: Request):
@app.get("/ops-content", response_class=HTMLResponse) @app.get("/ops-content", response_class=HTMLResponse)
async def ops_content(request: Request): async def ops_content(request: Request):
require_admin(request) require_admin(request)
import asyncio
version, rp, vers = await asyncio.gather( version, rp, vers = await asyncio.gather(
svc.assets_version(), svc.rp_status(), versions.all_versions()) svc.assets_version(), svc.rp_status(), versions.all_versions())
return templates.TemplateResponse(request, "admin_ops_body.html", { return templates.TemplateResponse(request, "admin_ops_body.html", {
@@ -441,16 +441,50 @@ async def podcast_ny_noegle(request: Request):
# Det fælles familielink: ingen login, ingen kladder, noindex. Står før de # Det fælles familielink: ingen login, ingen kladder, noindex. Står før de
# personlige ruter, så "f" aldrig forveksles med en nøgle. # 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) @app.get("/podcast/f/{key}/", response_class=HTMLResponse)
async def podcast_faelles_side(request: Request, key: str): async def podcast_faelles_side(request: Request, key: str):
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key, "side"): 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) _ikke_fundet(request)
rod = f"/podcast/f/{key}" 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", { return templates.TemplateResponse(request, "podcast.html", {
"request": request, "user": None, "is_admin": False, "rod": rod, "delt": True, "request": request, "user": None, "is_admin": False, "rod": rod, "delt": True, "og": og,
"udsendelser": _podcast_kontekst(request, rod, med_kladder=False), "saved": ""}, "indbygget": indbygget, "udsendelser": udsendelser, "saved": ""}, headers=headers)
headers={"X-Robots-Tag": "noindex, nofollow, noarchive", "Referrer-Policy": "no-referrer",
"Cache-Control": "private, no-store"})
@app.get("/podcast/f/{key}/{name}.xml") @app.get("/podcast/f/{key}/{name}.xml")
@@ -464,6 +498,14 @@ async def podcast_faelles_feed(request: Request, key: str, name: str):
headers={"Cache-Control": "private, max-age=300", "X-Robots-Tag": "noindex, nofollow"}) 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") @app.get("/podcast/f/{key}/{name}/cover.jpg")
async def podcast_faelles_cover(request: Request, key: str, name: str): async def podcast_faelles_cover(request: Request, key: str, name: str):
p = podcast.cover(name) p = podcast.cover(name)
@@ -1027,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) 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") @app.post("/admin/yt/settings")
async def admin_yt_settings(request: Request): async def admin_yt_settings(request: Request):
require_admin(request) require_admin(request)
+3 -2
View File
@@ -115,8 +115,9 @@ def saet_kladde(name: str, slug: str, kladde: bool) -> bool:
return True return True
def cover(name: str) -> Path | None: def cover(name: str, lille: bool = False) -> Path | None:
p = ROOT / name / "cover.jpg" """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 return p if SHOW.match(name or "") and p.exists() else None
+22 -2
View File
@@ -126,7 +126,7 @@
<h2 style="margin-top:2rem">Hentede videoer</h2> <h2 style="margin-top:2rem">Hentede videoer</h2>
{% if not ours %}<p class="muted">Ingen endnu — indsæt en URL ovenfor.</p>{% else %} {% if not ours %}<p class="muted">Ingen endnu — indsæt en URL ovenfor.</p>{% else %}
<table class="wt-table" style="margin-top:.8rem"> <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> <tbody>
{% for v in ours %} {% for v in ours %}
<tr> <tr>
@@ -142,6 +142,13 @@
{% elif v.delete_after %}om {{ ((v.delete_after - now) / 86400) | round(0, 'ceil') | int }} dage {% elif v.delete_after %}om {{ ((v.delete_after - now) / 86400) | round(0, 'ceil') | int }} dage
{% else %}—{% endif %} {% else %}—{% endif %}
</td> </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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -151,6 +158,12 @@
{# ---- kø & historik ---- #} {# ---- kø & historik ---- #}
{% if jobs %} {% if jobs %}
<h2 style="margin-top:2rem">Kø &amp; historik</h2> <h2 style="margin-top:2rem">Kø &amp; 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"> <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> <thead><tr><th>URL / titel</th><th style="width:8rem">Status</th><th>Besked</th></tr></thead>
<tbody> <tbody>
@@ -162,7 +175,14 @@
{% elif j.status == 'running' %}i gang… {% elif j.status == 'running' %}i gang…
{% elif j.status == 'deleted' %}<span class="muted">slettet</span> {% elif j.status == 'deleted' %}<span class="muted">slettet</span>
{% else %}i kø{% endif %}</td> {% 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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
+45 -1
View File
@@ -1,8 +1,24 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{% if delt and udsendelser %}{{ udsendelser[0].titel }}{% else %}Podcast{% endif %}{% 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"> {% block head %}{% if delt %}<meta name="robots" content="noindex, nofollow, noarchive">
<meta name="referrer" content="no-referrer">{% endif %}{% endblock %} <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 %} {% 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 saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
{% if not udsendelser %} {% if not udsendelser %}
<h1>Podcast</h1> <h1>Podcast</h1>
@@ -23,6 +39,19 @@
<div class="wt-card" style="margin:1.4rem 0;padding:1rem 1.2rem"> <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> <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> <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"> <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 wt-btn--primary" href="podcast://{{ u.uden_skema }}">Apple Podcasts</a>
@@ -68,6 +97,20 @@
</form> </form>
{% endif %} {% endif %}
<script> <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) { document.querySelectorAll('[data-kopier]').forEach(function (b) {
b.addEventListener('click', function () { b.addEventListener('click', function () {
var i = document.getElementById(b.dataset.kopier); var i = document.getElementById(b.dataset.kopier);
@@ -78,4 +121,5 @@ document.querySelectorAll('[data-kopier]').forEach(function (b) {
}); });
}); });
</script> </script>
</div>
{% endblock %} {% endblock %}
+19
View File
@@ -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>
+16
View File
@@ -13,11 +13,27 @@ import asyncio
import ytdl 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(): async def main():
if not ytdl.configured(): if not ytdl.configured():
print("yt_job: ikke konfigureret (mangler EMBY-env, yt-dlp eller download-mappe)") print("yt_job: ikke konfigureret (mangler EMBY-env, yt-dlp eller download-mappe)")
return return
await opdater_ytdlp()
sub = await ytdl.sync_subs() sub = await ytdl.sync_subs()
if sub["checked"] or sub["errors"]: if sub["checked"] or sub["errors"]:
print(f"yt_job: abonnementer tjekket={sub['checked']} nye_i_koe={sub['queued']}") print(f"yt_job: abonnementer tjekket={sub['checked']} nye_i_koe={sub['queued']}")
+131 -9
View File
@@ -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. biblioteket — fx det der lå i /slow/shared/yt i forvejen — er urørligt.
""" """
import asyncio import asyncio
import fcntl
import json import json
import os import os
import shutil import shutil
@@ -110,7 +111,7 @@ def _set_job(jid: str, **kw):
save_state(st) 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 dest = config.YT_DOWNLOAD_DIR
# %(title)s beholder æøå; [%(id)s] gør navnet unikt og gør det muligt at # %(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. # genkende videoen igen uden at gemme en sidevogn af metadata.
@@ -142,20 +143,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). # påvirkes ikke (yt-dlp henter dem fra SponsorBlock-API'et pr. video).
cmd += ["--sponsorblock-remove", "default", "--sponsorblock-mark", "all"] 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"): if not job.get("playlist"):
cmd.append("--no-playlist") cmd.append("--no-playlist")
cmd.append(job["url"]) cmd.append(job["url"])
return cmd 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 dest = config.YT_DOWNLOAD_DIR
proc = await asyncio.create_subprocess_exec( 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=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
stdout, stderr = await proc.communicate() stdout, stderr = await proc.communicate()
if proc.returncode != 0: if proc.returncode != 0:
err = (stderr.decode("utf-8", "replace").strip().splitlines() or ["ukendt fejl"])[-1] if proc.returncode < 0:
# Dræbt udefra, typisk fordi portalen genstartede midt i hentningen.
return {"ok": False, "error": f"afbrudt (signal {-proc.returncode})"}
err = (stderr.decode("utf-8", "replace").strip().splitlines()
or [f"ukendt fejl (kode {proc.returncode})"])[-1]
return {"ok": False, "error": err[:300]} return {"ok": False, "error": err[:300]}
# --print after_move giver én linje pr. hentet fil: id|upload_date|sti. # --print after_move giver én linje pr. hentet fil: id|upload_date|sti.
@@ -177,19 +186,50 @@ async def _attempt(job: dict, settings: dict, with_subs: bool) -> dict:
"note": "" if with_subs else "uden undertekster"} "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", "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: async def _run_ytdlp(job: dict, settings: dict) -> dict:
"""Kør yt-dlp for ét job. Returnerer {ok, title, path, size, error}. """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) 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 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. 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)) want_subs = bool(settings.get("subtitles", True))
res = await _attempt(job, settings, with_subs=want_subs) res = {"ok": False, "error": "ingen forsøg"}
if not res["ok"] and want_subs and "subtitle" in res["error"].lower(): for nr, (pause, klient) in enumerate(FORSOEG, start=1):
res = await _attempt(job, settings, with_subs=False) 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"]: 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 return res
@@ -214,10 +254,54 @@ def _sidecars(path: str) -> list:
return out 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: 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() settings = get_settings()
done, failed = 0, 0 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: while True:
st = get_state() st = get_state()
queued = [j for j in st["jobs"].values() if j["status"] == "queued"] queued = [j for j in st["jobs"].values() if j["status"] == "queued"]
@@ -488,6 +572,44 @@ async def enforce_cycles() -> dict:
return res 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 ---------------- # ---------------- Emby ----------------
async def _emby(client, path, **params): async def _emby(client, path, **params):
params["api_key"] = config.EMBY_API_KEY params["api_key"] = config.EMBY_API_KEY