fix(yt): én hentning ad gangen, nye forsøg ved 403, genoptag afbrudte
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
This commit is contained in:
@@ -1069,6 +1069,23 @@ 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)
|
||||
|
||||
+14
-1
@@ -158,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>
|
||||
@@ -169,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,11 +13,27 @@ import asyncio
|
||||
import ytdl
|
||||
|
||||
|
||||
async def opdater_ytdlp():
|
||||
"""YouTube ændrer ofte noget, der giver 403, indtil yt-dlp er opdateret.
|
||||
Derfor opdateres den hver nat, før der hentes."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
ytdl.config.YT_DLP_BIN, "-U",
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=180)
|
||||
sidste = (out.decode("utf-8", "replace").strip().splitlines() or [""])[-1]
|
||||
print(f"yt_job: yt-dlp -U: {sidste[:160]}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"yt_job: yt-dlp kunne ikke opdateres: {e}")
|
||||
|
||||
|
||||
async def main():
|
||||
if not ytdl.configured():
|
||||
print("yt_job: ikke konfigureret (mangler EMBY-env, yt-dlp eller download-mappe)")
|
||||
return
|
||||
|
||||
await opdater_ytdlp()
|
||||
|
||||
sub = await ytdl.sync_subs()
|
||||
if sub["checked"] or sub["errors"]:
|
||||
print(f"yt_job: abonnementer tjekket={sub['checked']} nye_i_koe={sub['queued']}")
|
||||
|
||||
@@ -10,6 +10,7 @@ selv har hentet**, registreret med fuld sti i yt_state.json. Alt andet i YouTube
|
||||
biblioteket — fx det der lå i /slow/shared/yt i forvejen — er urørligt.
|
||||
"""
|
||||
import asyncio
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -110,7 +111,7 @@ def _set_job(jid: str, **kw):
|
||||
save_state(st)
|
||||
|
||||
|
||||
def _build_cmd(job: dict, settings: dict, with_subs: bool) -> list:
|
||||
def _build_cmd(job: dict, settings: dict, with_subs: bool, klient: str = "") -> list:
|
||||
dest = config.YT_DOWNLOAD_DIR
|
||||
# %(title)s beholder æøå; [%(id)s] gør navnet unikt og gør det muligt at
|
||||
# genkende videoen igen uden at gemme en sidevogn af metadata.
|
||||
@@ -142,16 +143,20 @@ def _build_cmd(job: dict, settings: dict, with_subs: bool) -> list:
|
||||
# påvirkes ikke (yt-dlp henter dem fra SponsorBlock-API'et pr. video).
|
||||
cmd += ["--sponsorblock-remove", "default", "--sponsorblock-mark", "all"]
|
||||
|
||||
if klient:
|
||||
# Nyt forsøg efter en 403: bed YouTube om videoen som en anden klient.
|
||||
cmd += ["--extractor-args", f"youtube:player_client={klient}"]
|
||||
|
||||
if not job.get("playlist"):
|
||||
cmd.append("--no-playlist")
|
||||
cmd.append(job["url"])
|
||||
return cmd
|
||||
|
||||
|
||||
async def _attempt(job: dict, settings: dict, with_subs: bool) -> dict:
|
||||
async def _attempt(job: dict, settings: dict, with_subs: bool, klient: str = "") -> dict:
|
||||
dest = config.YT_DOWNLOAD_DIR
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*_build_cmd(job, settings, with_subs),
|
||||
*_build_cmd(job, settings, with_subs, klient),
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
@@ -177,19 +182,47 @@ async def _attempt(job: dict, settings: dict, with_subs: bool) -> dict:
|
||||
"note": "" if with_subs else "uden undertekster"}
|
||||
|
||||
|
||||
# Fejl der ofte går over af sig selv: YouTube afviser en enkelt forespørgsel
|
||||
# (403/429), eller forbindelsen falder. yt-dlp gentager ikke selv en 403.
|
||||
_MIDLERTIDIG = ("403", "forbidden", "429", "too many requests", "timed out",
|
||||
"connection reset", "remote end closed", "temporary failure")
|
||||
# Forsøg: (pause før, YouTube-klient). Andet og tredje forsøg beder om videoen
|
||||
# som en anden klient, hvilket typisk kommer uden om en 403.
|
||||
FORSOEG = [(0, ""), (30, "tv,web_safari"), (90, "mweb")]
|
||||
|
||||
|
||||
def midlertidig(err: str) -> bool:
|
||||
e = (err or "").lower()
|
||||
return any(m in e for m in _MIDLERTIDIG)
|
||||
|
||||
|
||||
async def _run_ytdlp(job: dict, settings: dict) -> dict:
|
||||
"""Kør yt-dlp for ét job. Returnerer {ok, title, path, size, error}.
|
||||
|
||||
Undertekster er et gode, ikke et krav: YouTube rate-limiter dem (HTTP 429)
|
||||
uafhængigt af selve videoen, og uden dette fald-tilbage ville en 429 på en
|
||||
undertekstfil vælte hele hentningen. Vi prøver derfor én gang mere uden dem.
|
||||
|
||||
Midlertidige fejl (403/429/timeout) prøves igen op til to gange med pause
|
||||
og en anden YouTube-klient (FORSOEG).
|
||||
"""
|
||||
want_subs = bool(settings.get("subtitles", True))
|
||||
res = await _attempt(job, settings, with_subs=want_subs)
|
||||
if not res["ok"] and want_subs and "subtitle" in res["error"].lower():
|
||||
res = await _attempt(job, settings, with_subs=False)
|
||||
res = {"ok": False, "error": "ingen forsøg"}
|
||||
for nr, (pause, klient) in enumerate(FORSOEG, start=1):
|
||||
if pause:
|
||||
await asyncio.sleep(pause)
|
||||
res = await _attempt(job, settings, with_subs=want_subs, klient=klient)
|
||||
if not res["ok"] and want_subs and "subtitle" in res["error"].lower():
|
||||
res = await _attempt(job, settings, with_subs=False, klient=klient)
|
||||
if res["ok"]:
|
||||
res["note"] = "undertekster sprunget over (YouTube afviste dem lige nu)"
|
||||
if res["ok"]:
|
||||
res["note"] = "undertekster sprunget over (YouTube afviste dem lige nu)"
|
||||
if nr > 1:
|
||||
res["note"] = "; ".join(x for x in (res.get("note"), f"lykkedes i forsøg {nr}") if x)
|
||||
return res
|
||||
if not midlertidig(res["error"]):
|
||||
return res
|
||||
res["error"] = f"{res['error']} (opgivet efter {len(FORSOEG)} forsøg)"
|
||||
return res
|
||||
|
||||
|
||||
@@ -214,10 +247,54 @@ def _sidecars(path: str) -> list:
|
||||
return out
|
||||
|
||||
|
||||
def _koe_laas():
|
||||
"""Én kørsel af køen ad gangen, også på tværs af processer (web + timer).
|
||||
Mange samtidige hentninger fra samme IP er netop det, YouTube svarer 403 på."""
|
||||
f = open(os.path.join(config.DATA, "yt_koe.lock"), "w")
|
||||
try:
|
||||
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
f.close()
|
||||
return None
|
||||
return f
|
||||
|
||||
|
||||
def retry_job(jid: str) -> bool:
|
||||
"""Læg et fejlet job i kø igen (knappen "Prøv igen")."""
|
||||
st = get_state()
|
||||
j = st["jobs"].get(jid)
|
||||
if not j or j["status"] != "error":
|
||||
return False
|
||||
j.update(status="queued", error="", finished=0)
|
||||
_log(st, f"Prøver igen: {(j.get('title') or j['url'])[:80]}")
|
||||
save_state(st)
|
||||
return True
|
||||
|
||||
|
||||
async def process_queue() -> dict:
|
||||
"""Hent alt der står i kø. Kaldes både fra web-ruten og fra timeren."""
|
||||
"""Hent alt der står i kø, ét job ad gangen. Kaldes både fra web-ruten og
|
||||
fra timeren; kører en anden allerede køen, gør denne ingenting."""
|
||||
laas = _koe_laas()
|
||||
if laas is None:
|
||||
return {"done": 0, "failed": 0, "busy": True}
|
||||
try:
|
||||
return await _process_queue()
|
||||
finally:
|
||||
laas.close()
|
||||
|
||||
|
||||
async def _process_queue() -> dict:
|
||||
settings = get_settings()
|
||||
done, failed = 0, 0
|
||||
# Vi har låsen, så et job der står som "running" blev afbrudt (fx af en
|
||||
# genstart af portalen midt i hentningen). Læg det i kø igen.
|
||||
st = get_state()
|
||||
haengt = [j for j in st["jobs"].values() if j["status"] == "running"]
|
||||
for j in haengt:
|
||||
j["status"] = "queued"
|
||||
_log(st, f"Genoptager afbrudt hentning: {(j.get('title') or j['url'])[:80]}")
|
||||
if haengt:
|
||||
save_state(st)
|
||||
while True:
|
||||
st = get_state()
|
||||
queued = [j for j in st["jobs"].values() if j["status"] == "queued"]
|
||||
|
||||
Reference in New Issue
Block a user