Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b85698856 | ||
|
|
8751ae5722 | ||
|
|
ff76660910 | ||
|
|
fbdb257079 | ||
|
|
265b48905a | ||
|
|
902ab6244e | ||
|
|
6d32fd2b71 |
@@ -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'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 re
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -133,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:
|
||||||
@@ -236,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", {
|
||||||
@@ -457,9 +456,14 @@ async def podcast_faelles_side(request: Request, key: str):
|
|||||||
base = str(request.base_url).rstrip("/")
|
base = str(request.base_url).rstrip("/")
|
||||||
udsendelser = _podcast_kontekst(request, rod, med_kladder=False)
|
udsendelser = _podcast_kontekst(request, rod, med_kladder=False)
|
||||||
s = udsendelser[0] if udsendelser else {"titel": "Podcast", "slug": "", "har_cover": 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}/",
|
og = {"titel": s["titel"], "url": f"{base}{rod}/",
|
||||||
"beskrivelse": "Familiens podcast. Lyt i browseren, eller abonnér i din podcast-app.",
|
"beskrivelse": "Familiens podcast. Lyt i browseren, eller abonnér i din podcast-app.",
|
||||||
"billede": f"{base}{rod}/{s['slug']}/cover.jpg" if s.get("har_cover") else ""}
|
"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",
|
headers = {"X-Robots-Tag": "noindex, nofollow, noarchive", "Referrer-Policy": "no-referrer",
|
||||||
"Cache-Control": "private, no-store"}
|
"Cache-Control": "private, no-store"}
|
||||||
if forhaandsvisning:
|
if forhaandsvisning:
|
||||||
@@ -468,13 +472,16 @@ async def podcast_faelles_side(request: Request, key: str):
|
|||||||
return templates.TemplateResponse(request, "podcast_og.html", {"request": request, "og": og}, headers=headers)
|
return templates.TemplateResponse(request, "podcast_og.html", {"request": request, "og": og}, headers=headers)
|
||||||
indbygget = None
|
indbygget = None
|
||||||
if _INDBYGGET.search(ua):
|
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}/"
|
sti = f"{request.url.hostname}{rod}/"
|
||||||
if re.search(r"iPhone|iPad|iOS", ua):
|
if re.search(r"iPhone|iPad|iOS", ua):
|
||||||
indbygget = {"knap": "Åbn i Safari", "href": f"x-safari-https://{sti}"}
|
indbygget = {"platform": "ios"}
|
||||||
elif "Android" in ua:
|
elif "Android" in ua:
|
||||||
indbygget = {"knap": "Åbn i Chrome", "href": f"intent://{sti}#Intent;scheme=https;end"}
|
indbygget = {"platform": "android", "href": f"intent://{sti}#Intent;scheme=https;end"}
|
||||||
else:
|
else:
|
||||||
indbygget = {"knap": "", "href": ""}
|
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, "og": og,
|
"request": request, "user": None, "is_admin": False, "rod": rod, "delt": True, "og": og,
|
||||||
"indbygget": indbygget, "udsendelser": udsendelser, "saved": ""}, headers=headers)
|
"indbygget": indbygget, "udsendelser": udsendelser, "saved": ""}, headers=headers)
|
||||||
@@ -491,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)
|
||||||
@@ -1054,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
@@ -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
@@ -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ø & historik</h2>
|
<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">
|
<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>
|
||||||
|
|||||||
+35
-5
@@ -8,6 +8,17 @@
|
|||||||
<meta property="og:url" content="{{ og.url }}">
|
<meta property="og:url" content="{{ og.url }}">
|
||||||
{% if og.billede %}<meta property="og:image" content="{{ og.billede }}">{% endif %}{% endif %}{% endif %}{% endblock %}
|
{% 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>
|
||||||
@@ -30,11 +41,15 @@
|
|||||||
<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 %}
|
{% if indbygget %}
|
||||||
<div class="wt-flash" style="margin:.4rem 0 .9rem">
|
<div class="wt-flash" style="margin:.4rem 0 .9rem">
|
||||||
Du har åbnet siden inde i Messenger, og den kan ikke åbne podcast-apps.
|
<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.href %}Tryk her for at åbne siden i din almindelige browser, og abonnér derfra:
|
{% if indbygget.platform == "android" %}
|
||||||
<div style="margin-top:.6rem"><a class="wt-btn wt-btn--primary" href="{{ indbygget.href }}">{{ indbygget.knap }}</a></div>
|
<p style="margin:.4rem 0"><a class="wt-btn wt-btn--primary" href="{{ indbygget.href }}">Åbn i Chrome</a></p>
|
||||||
{% else %}Åbn siden i din almindelige browser (menuen ⋯ øverst), og abonnér derfra.{% endif %}
|
<p style="margin:.4rem 0">Virker det ikke: tryk på ⋮ øverst til højre og vælg "Åbn i ekstern browser".</p>
|
||||||
<div class="wt-muted" style="font-size:.85rem;margin-top:.5rem">Virker knappen ikke: tryk på ⋯ øverst og vælg "Åbn i ekstern browser". Du kan også bare lytte herunder.</div>
|
{% 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>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
@@ -82,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);
|
||||||
@@ -92,4 +121,5 @@ document.querySelectorAll('[data-kopier]').forEach(function (b) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
<meta property="og:url" content="{{ og.url }}">
|
<meta property="og:url" content="{{ og.url }}">
|
||||||
<meta property="og:locale" content="da_DK">
|
<meta property="og:locale" content="da_DK">
|
||||||
{% if og.billede %}<meta property="og:image" content="{{ og.billede }}">
|
{% if og.billede %}<meta property="og:image" content="{{ og.billede }}">
|
||||||
<meta property="og:image:width" content="3000">
|
<meta property="og:image:width" content="{{ og.str }}">
|
||||||
<meta property="og:image:height" content="3000">
|
<meta property="og:image:height" content="{{ og.str }}">
|
||||||
<meta name="twitter:card" content="summary">{% endif %}
|
<meta name="twitter:card" content="summary">{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body><h1>{{ og.titel }}</h1><p>{{ og.beskrivelse }}</p></body>
|
<body><h1>{{ og.titel }}</h1><p>{{ og.beskrivelse }}</p></body>
|
||||||
|
|||||||
@@ -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']}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user