Compare commits

...
2 Commits
Author SHA1 Message Date
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
4 changed files with 192 additions and 10 deletions
+31
View File
@@ -1069,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)
+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>
+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']}")
+123 -8
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,16 +143,20 @@ def _build_cmd(job: dict, settings: dict, with_subs: bool) -> list:
# påvirkes ikke (yt-dlp henter dem fra SponsorBlock-API'et pr. video). # 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:
# Nyt forsøg efter en 403: bed YouTube om videoen som en anden klient.
cmd += ["--extractor-args", f"youtube:player_client={klient}"]
if not job.get("playlist"): 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:
@@ -177,19 +182,47 @@ 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")
# Forsøg: (pause før, YouTube-klient). Andet og tredje forsøg beder om videoen
# som en anden klient, hvilket typisk kommer uden om en 403.
FORSOEG = [(0, ""), (30, "tv,web_safari"), (90, "mweb")]
def midlertidig(err: str) -> bool:
e = (err or "").lower()
return any(m in e for m in _MIDLERTIDIG)
async def _run_ytdlp(job: dict, settings: dict) -> dict: 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 pause
og en anden YouTube-klient (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 +247,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 +565,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