Compare commits

...
7 Commits
Author SHA1 Message Date
agw 776c314b0f feat(yt): YouTube i Emby som serier: kanal = serie, upload-dato = afsnit
CD / deploy (push) Successful in 15s
CI / check (push) Successful in 22s
Videoerne lå fladt i et Emby-hjemmevideobibliotek: ingen kanaler, ingen
billeder, og datoen var hentetidspunktet, så sortering efter premieredato gav
den rækkefølge de blev hentet i.

Nu henter yt-dlp til en skjult .staging-mappe med info-JSON og miniature, og
organiser() lægger videoen i <Kanal>/Season ÅÅÅÅ/ som
"<Kanal> - sÅÅÅÅeMMDDnn - <Titel> [<id>]" med NFO (titel, beskrivelse,
upload-dato som premiere), -thumb.jpg og undertekster. Kanalen får tvshow.nfo,
plakat (profilbillede) og baggrund (banner). Afsnitsnummeret bygger på
upload-datoen, så Emby sorterer som på YouTube.

Sletning (manuel, cyklus og karens) går nu gennem én funktion, der også tager
NFO og billede og rydder tomme sæson- og kanalmapper. Playlister springer
private/slettede videoer over (--ignore-errors), og et job med hentede videoer
regnes som gennemført, selvom yt-dlp sluttede med en fejl.

yt_migrer.py flytter de eksisterende videoer ind i strukturen og genopretter
Emby-biblioteket som "Serier" med kun lokale data; Emby-status (set, favorit,
position) gemmes før og sættes på igen bagefter.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 17:13:34 +02:00
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
7 changed files with 727 additions and 42 deletions
+7 -1
View File
@@ -15,9 +15,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Tooling
run: apt-get update -qq && apt-get install -y -qq --no-install-recommends python3 python3-jinja2
run: apt-get update -qq && apt-get install -y -qq --no-install-recommends python3 python3-jinja2 python3-pyflakes
- name: Python compiles
run: python3 -m compileall -q .
- name: No undefined names
# Fanger fx et manglende "import asyncio", der ellers først viser sig som
# en 500 i drift (sket for YouTube-modulet 2026-09-20 til 2026-10-06).
run: |
python3 -m pyflakes *.py > pyflakes.txt || true
if grep -E "undefined name|unable to detect undefined names" pyflakes.txt; then exit 1; fi
- name: Templates parse
run: |
python3 - <<'PY'
+38 -5
View File
@@ -7,6 +7,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
import asyncio
import re
import time
import urllib.parse
@@ -133,7 +134,6 @@ async def dashboard_content(request: Request):
user = current_user(request)
if not user:
raise HTTPException(401)
import asyncio
mc_task = asyncio.create_task(svc.minecraft_servers())
apps, error = [], None
try:
@@ -236,7 +236,6 @@ async def admin_ops(request: Request):
@app.get("/ops-content", response_class=HTMLResponse)
async def ops_content(request: Request):
require_admin(request)
import asyncio
version, rp, vers = await asyncio.gather(
svc.assets_version(), svc.rp_status(), versions.all_versions())
return templates.TemplateResponse(request, "admin_ops_body.html", {
@@ -473,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)
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 = {"knap": "Åbn i Safari", "href": f"x-safari-https://{sti}"}
indbygget = {"platform": "ios"}
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:
indbygget = {"knap": "", "href": ""}
indbygget = {"platform": "andet"}
return templates.TemplateResponse(request, "podcast.html", {
"request": request, "user": None, "is_admin": False, "rod": rod, "delt": True, "og": og,
"indbygget": indbygget, "udsendelser": udsendelser, "saved": ""}, headers=headers)
@@ -1067,6 +1069,37 @@ async def admin_yt_add(request: Request, url: str = Form(""), playlist: str = Fo
return RedirectResponse("/admin/yt?saved=Lagt i kø — hentningen er i gang", status_code=303)
@app.post("/admin/yt/igen")
async def admin_yt_retry(request: Request):
"""Prøv ét fejlet job igen (jid) eller alle fejlede (uden jid)."""
require_admin(request)
f = await request.form()
jid = f.get("jid") or ""
if jid:
ids = [jid]
else:
ids = [j["id"] for j in yt.get_state()["jobs"].values() if j["status"] == "error"]
n = sum(1 for i in ids if yt.retry_job(i))
if n:
asyncio.create_task(_yt_drain())
msg = f"Prøver {n} igen" if n else "Intet at prøve igen"
return RedirectResponse(f"/admin/yt?saved={urllib.parse.quote(msg)}", status_code=303)
@app.post("/admin/yt/slet")
async def admin_yt_delete(request: Request):
require_admin(request)
f = await request.form()
res = yt.delete_video(f.get("path") or "")
if not res["ok"]:
return RedirectResponse(f"/admin/yt?saved={urllib.parse.quote(res['error'])}", status_code=303)
cache.clear("yt:")
await yt.refresh_library()
return RedirectResponse(
"/admin/yt?saved=" + urllib.parse.quote(f"Slettet: {res['title']} ({yt._human(res['size'])})"),
status_code=303)
@app.post("/admin/yt/settings")
async def admin_yt_settings(request: Request):
require_admin(request)
+22 -2
View File
@@ -126,7 +126,7 @@
<h2 style="margin-top:2rem">Hentede videoer</h2>
{% if not ours %}<p class="muted">Ingen endnu — indsæt en URL ovenfor.</p>{% else %}
<table class="wt-table" style="margin-top:.8rem">
<thead><tr><th>Titel</th><th style="width:7rem">Størrelse</th><th style="width:9rem">Status</th><th style="width:11rem">Slettes</th></tr></thead>
<thead><tr><th>Titel</th><th style="width:7rem">Størrelse</th><th style="width:9rem">Status</th><th style="width:11rem">Slettes</th><th style="width:5.5rem"></th></tr></thead>
<tbody>
{% for v in ours %}
<tr>
@@ -142,6 +142,13 @@
{% elif v.delete_after %}om {{ ((v.delete_after - now) / 86400) | round(0, 'ceil') | int }} dage
{% else %}—{% endif %}
</td>
<td>
<form method="post" action="/admin/yt/slet" style="margin:0"
onsubmit="return confirm({{ ('Slet „' ~ v.name ~ '“ nu?' ~ (' Den er markeret som favorit.' if v.favorite else '')) | tojson | forceescape }})">
<input type="hidden" name="path" value="{{ v.path }}">
<button class="wt-btn" type="submit">Slet</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
@@ -151,6 +158,12 @@
{# ---- kø & historik ---- #}
{% if jobs %}
<h2 style="margin-top:2rem">Kø &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">
<thead><tr><th>URL / titel</th><th style="width:8rem">Status</th><th>Besked</th></tr></thead>
<tbody>
@@ -162,7 +175,14 @@
{% elif j.status == 'running' %}i gang…
{% elif j.status == 'deleted' %}<span class="muted">slettet</span>
{% else %}i kø{% endif %}</td>
<td class="muted">{{ j.error }}</td>
<td class="muted">{{ j.error }}{% if j.note %}{{ ' · ' if j.error }}{{ j.note }}{% endif %}
{% if j.status == 'error' %}
<form method="post" action="/admin/yt/igen" style="margin:.3rem 0 0">
<input type="hidden" name="jid" value="{{ j.id }}">
<button class="wt-btn" type="submit">Prøv igen</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
+35 -5
View File
@@ -8,6 +8,17 @@
<meta property="og:url" content="{{ og.url }}">
{% if og.billede %}<meta property="og:image" content="{{ og.billede }}">{% endif %}{% endif %}{% endif %}{% endblock %}
{% block body %}
<style>
/* Ingen vandret scroll på iPhone: afspiller og felter må aldrig blive bredere end
kortet, og felter skal have mindst 16 px skrift, ellers zoomer iOS ind. */
html, body { overflow-x: hidden; }
.pod-side { max-width: 100%; overflow-wrap: anywhere; }
.pod-side img, .pod-side audio, .pod-side input, .pod-side .wt-card { max-width: 100%; box-sizing: border-box; }
.pod-side audio { display: block; width: 100%; min-width: 0; }
.pod-side input { font-size: 16px !important; min-width: 0; }
.pod-side .wt-btn { white-space: normal; }
</style>
<div class="pod-side">
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
{% if not udsendelser %}
<h1>Podcast</h1>
@@ -30,11 +41,15 @@
<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">
Du har åbnet siden inde i Messenger, og den kan ikke åbne podcast-apps.
{% if indbygget.href %}Tryk her for at åbne siden i din almindelige browser, og abonnér derfra:
<div style="margin-top:.6rem"><a class="wt-btn wt-btn--primary" href="{{ indbygget.href }}">{{ indbygget.knap }}</a></div>
{% else %}Åbn siden i din almindelige browser (menuen ⋯ øverst), og abonnér derfra.{% endif %}
<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>
<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>
@@ -82,6 +97,20 @@
</form>
{% endif %}
<script>
function kopierTekst(t, knap) {
var ok = function () { knap.textContent = 'Kopieret'; };
var fallback = function () {
var ta = document.createElement('textarea'); ta.value = t; ta.setAttribute('readonly', '');
ta.style.position = 'fixed'; ta.style.top = '0'; ta.style.opacity = '0'; ta.style.fontSize = '16px';
document.body.appendChild(ta); ta.focus(); ta.setSelectionRange(0, t.length);
try { document.execCommand('copy'); ok(); } catch (e) { prompt('Kopiér adressen:', t); }
document.body.removeChild(ta);
};
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(ok, fallback); else fallback();
}
document.querySelectorAll('[data-kopier-tekst]').forEach(function (b) {
b.addEventListener('click', function () { kopierTekst(b.dataset.kopierTekst, b); });
});
document.querySelectorAll('[data-kopier]').forEach(function (b) {
b.addEventListener('click', function () {
var i = document.getElementById(b.dataset.kopier);
@@ -92,4 +121,5 @@ document.querySelectorAll('[data-kopier]').forEach(function (b) {
});
});
</script>
</div>
{% endblock %}
+16
View File
@@ -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']}")
+259
View File
@@ -0,0 +1,259 @@
"""Engangs-flytning af YouTube-biblioteket til kanal = serie (2026-10).
Før lå alle videoer fladt i download-mappen i et Emby "hjemmevideo"-bibliotek:
ingen kanaler, ingen billeder, og datoen var hentetidspunktet. Nu er hver kanal
en serie (se ytdl.organiser). Dette script flytter de eksisterende videoer:
python yt_migrer.py flyt # gem Emby-status, hent info/miniature, flyt, opdatér state
python yt_migrer.py emby # genopret biblioteket som "Serier", scan, sæt status på igen
`flyt` tager kø-låsen, så intet hentes imens. Emby-status (set, favorit, hvor
langt man er nået) gemmes i DATA/yt_migrering.json før noget flyttes, fordi
Emby ser flyttede filer som nye; `emby` sætter den på igen ud fra video-id'et.
Begge trin kan køres igen uden skade.
"""
import asyncio
import json
import os
import re
import sys
import time
import httpx
import config
import ytdl
MAPPE = config.YT_DOWNLOAD_DIR
GEM = config.DATA / "yt_migrering.json"
ID_RE = re.compile(r"\[([A-Za-z0-9_-]{11})\]\.(mp4|mkv|webm)$")
def _gem(d):
tmp = str(GEM) + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False, indent=1)
os.replace(tmp, GEM)
def _laes():
try:
return json.loads(GEM.read_text())
except (OSError, ValueError):
return {"status": {}, "flyttet": {}}
async def _emby_status() -> dict:
"""video-id -> {bruger-id: {played, favorite, pos}} for alt i YouTube-biblioteket."""
ud = {}
async with httpx.AsyncClient(timeout=60) as c:
lib = await ytdl._library_id(c, ytdl.get_settings().get("library_name") or "YouTube")
if not lib:
return ud
for u in await ytdl._emby(c, "/Users"):
data = await ytdl._emby(c, f"/Users/{u['Id']}/Items", ParentId=lib, Recursive="true",
Fields="Path,UserData", IncludeItemTypes="Video,Episode,Movie")
for it in data.get("Items", []):
m = ID_RE.search(it.get("Path") or "")
ud_ = it.get("UserData") or {}
if not m or not (ud_.get("Played") or ud_.get("IsFavorite") or ud_.get("PlaybackPositionTicks")):
continue
ud.setdefault(m.group(1), {})[u["Id"]] = {
"played": bool(ud_.get("Played")), "favorite": bool(ud_.get("IsFavorite")),
"pos": int(ud_.get("PlaybackPositionTicks") or 0)}
return ud
async def _info(vid: str) -> dict:
"""Info + miniature fra YouTube uden at hente videoen (med samme nye forsøg som hentning)."""
sd = ytdl.staging_dir()
for pause, _ in ytdl.FORSOEG:
if pause:
await asyncio.sleep(pause)
proc = await asyncio.create_subprocess_exec(
config.YT_DLP_BIN, "--skip-download", "--write-info-json", "--write-thumbnail",
"--convert-thumbnails", "jpg", "--no-warnings", "--no-playlist",
"-o", os.path.join(sd, "%(id)s.%(ext)s"), f"https://www.youtube.com/watch?v={vid}",
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE)
_, err = await proc.communicate()
try:
with open(os.path.join(sd, f"{vid}.info.json"), encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
pass
if not ytdl.midlertidig(err.decode("utf-8", "replace")):
break
return {}
async def _playliste_ids(url: str) -> set:
proc = await asyncio.create_subprocess_exec(
config.YT_DLP_BIN, "--flat-playlist", "--ignore-errors", "--print", "id", "--no-warnings", url,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
out, _ = await proc.communicate()
return set(out.decode().split())
async def flyt():
laas = ytdl._koe_laas()
while laas is None:
print("venter på at køen bliver ledig ...")
await asyncio.sleep(30)
laas = ytdl._koe_laas()
try:
gem = _laes()
if not gem["status"]:
gem["status"] = await _emby_status()
_gem(gem)
print(f"Emby-status gemt for {len(gem['status'])} videoer")
flade = sorted(f for f in os.listdir(MAPPE)
if os.path.isfile(os.path.join(MAPPE, f)) and ID_RE.search(f))
print(f"{len(flade)} videoer ligger stadig fladt")
sd = ytdl.staging_dir()
for i, f in enumerate(flade, 1):
vid = ID_RE.search(f).group(1)
gammel = os.path.realpath(os.path.join(MAPPE, f))
info = await _info(vid)
if not info:
# Privat/slettet på YouTube: brug filnavnet, så den stadig får en plads.
info = {"title": f.rsplit(" [", 1)[0], "channel": "Ukendt kanal"}
stem = f.rsplit(".", 1)[0]
for side in os.listdir(MAPPE):
if side != f and (side.startswith(stem + ".") or side.startswith(stem + "-thumb.")):
rest = side[len(stem) + 1:] if side.startswith(stem + ".") else side[len(stem) + 7:]
if rest in ("jpg", "jpeg", "png", "webp") and os.path.exists(os.path.join(sd, f"{vid}.jpg")):
os.remove(os.path.join(MAPPE, side)) # YouTubes miniature er bedre
continue
os.replace(os.path.join(MAPPE, side), os.path.join(sd, f"{vid}.{rest}"))
staging = os.path.join(sd, f"{vid}{os.path.splitext(f)[1]}")
os.replace(gammel, staging)
o = await ytdl.organiser(staging, info)
gem["flyttet"][gammel] = {"ny": os.path.realpath(o["path"]), "id": vid,
"upload_date": o["upload_date"]}
_gem(gem)
print(f" {i}/{len(flade)} {o['title'][:60]}")
await asyncio.sleep(2) # skån YouTube, det er netop mange kald der giver 403
# Opdatér portalens state: stier i jobs og "på vej ud".
st = ytdl.get_state()
flyttet = {k: v["ny"] for k, v in gem["flyttet"].items()}
ny_af = lambda p: flyttet.get(os.path.realpath(p), p) if p else p # noqa: E731
for j in st["jobs"].values():
if j.get("path"):
j["path"] = ny_af(j["path"])
if j.get("paths"):
j["paths"] = [ny_af(p) for p in j["paths"]]
for v in j.get("videos") or []:
v["path"] = ny_af(v.get("path"))
st["leaving"] = {ny_af(k): v for k, v in st["leaving"].items()}
# Videoer fra en playliste, hvis job fejlede på én privat video, men hvor
# resten blev hentet: knyt dem til jobbet, så de er portalens egne igen.
# Ejet = stier i færdige job i den state vi lige har opdateret (ikke den på disken).
ejet = {os.path.realpath(p) for j in st["jobs"].values() if j["status"] == "done"
for p in (j.get("paths") or ([j["path"]] if j.get("path") else [])) if p}
uejede = {v["id"]: v for k, v in gem["flyttet"].items() if v["ny"] not in ejet}
for j in st["jobs"].values():
if not uejede or not j.get("playlist") or j["status"] != "error":
continue
ids = await _playliste_ids(j["url"])
mine = [v for vid, v in uejede.items() if vid in ids]
if mine:
j.update(status="done", error="", finished=time.time(),
note=f"{len(mine)} videoer knyttet til jobbet ved flytningen; private sprunget over",
paths=[v["ny"] for v in mine],
videos=[{"id": v["id"], "upload_date": v["upload_date"], "path": v["ny"]} for v in mine],
path=mine[0]["ny"])
print(f"knyttede {len(mine)} videoer til playliste-jobbet {j['id']}")
ytdl._log(st, f"Flyttet {len(gem['flyttet'])} videoer til kanal/sæson-struktur")
ytdl.save_state(st)
print("state opdateret")
finally:
laas.close()
async def _emby_kald(c, metode, sti, **kw):
params = kw.pop("params", {})
params["api_key"] = config.EMBY_API_KEY
r = await c.request(metode, f"{config.EMBY_URL}/emby{sti}", params=params, **kw)
r.raise_for_status()
return r
async def emby():
navn = ytdl.get_settings().get("library_name") or "YouTube"
async with httpx.AsyncClient(timeout=120) as c:
mapper = (await _emby_kald(c, "GET", "/Library/VirtualFolders")).json()
gl = next((m for m in mapper if (m.get("Name") or "").lower() == navn.lower()), None)
if gl and gl.get("CollectionType") != "tvshows":
opts = gl.get("LibraryOptions") or {}
await _emby_kald(c, "DELETE", "/Library/VirtualFolders",
params={"name": gl["Name"], "refreshLibrary": "false"})
print(f"fjernede {gl['Name']} ({gl.get('CollectionType')}); filerne røres ikke")
else:
opts = (gl or {}).get("LibraryOptions") or {}
if not gl or gl.get("CollectionType") != "tvshows":
# Kun lokale data: NFO'erne og billederne ved siden af filerne. Ingen
# opslag hos TheTVDB/TMDb, der ville prøve at gætte kanalerne som tv-serier.
opts.update({"EnableInternetProviders": False, "SaveLocalMetadata": False,
"MetadataSavers": [], "EnableRealtimeMonitor": True,
"EnableAutomaticSeriesGrouping": False,
"TypeOptions": [{"Type": t, "MetadataFetchers": [], "ImageFetchers": [],
"MetadataFetcherOrder": [], "ImageFetcherOrder": []}
for t in ("Series", "Season", "Episode")],
"PathInfos": [{"Path": MAPPE}]})
await _emby_kald(c, "POST", "/Library/VirtualFolders",
params={"name": navn, "collectionType": "tvshows", "refreshLibrary": "true",
"paths": MAPPE},
json={"Name": navn, "CollectionType": "tvshows", "RefreshLibrary": True,
"Paths": [MAPPE], "LibraryOptions": opts})
print(f"oprettede {navn} som Serier på {MAPPE}")
# Vent på at scanningen har fundet afsnittene.
antal = sum(1 for _, _, fs in os.walk(MAPPE) for f in fs if ID_RE.search(f)
and not f.startswith("."))
lib, d = None, {}
for _ in range(60):
lib = await ytdl._library_id(c, navn)
if lib:
d = (await _emby_kald(c, "GET", "/Items", params={
"ParentId": lib, "Recursive": "true", "IncludeItemTypes": "Episode",
"Limit": "0"})).json()
if d.get("TotalRecordCount", 0) >= antal:
break
await asyncio.sleep(10)
print(f"Emby ser {d.get('TotalRecordCount', 0)} af {antal} afsnit")
status = _laes()["status"]
sat = 0
for uid in {u for v in status.values() for u in v}:
data = (await _emby_kald(c, "GET", f"/Users/{uid}/Items", params={
"ParentId": lib, "Recursive": "true", "IncludeItemTypes": "Episode",
"Fields": "Path"})).json()
for it in data.get("Items", []):
m = ID_RE.search(it.get("Path") or "")
s = status.get(m.group(1), {}).get(uid) if m else None
if not s:
continue
if s["played"]:
await _emby_kald(c, "POST", f"/Users/{uid}/PlayedItems/{it['Id']}")
if s["favorite"]:
await _emby_kald(c, "POST", f"/Users/{uid}/FavoriteItems/{it['Id']}")
if s["pos"] and not s["played"]:
await _emby_kald(c, "POST", f"/Users/{uid}/Items/{it['Id']}/UserData",
json={"PlaybackPositionTicks": s["pos"]})
sat += 1
print(f"status sat på igen: {sat} (bruger, video)-par af "
f"{sum(len(v) for v in status.values())}")
if __name__ == "__main__":
trin = sys.argv[1] if len(sys.argv) > 1 else ""
if trin == "flyt":
asyncio.run(flyt())
elif trin == "emby":
asyncio.run(emby())
else:
print(__doc__)
sys.exit(2)
+350 -29
View File
@@ -10,11 +10,14 @@ 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 re
import shutil
import time
import uuid
from xml.sax.saxutils import escape
import httpx
@@ -110,11 +113,10 @@ def _set_job(jid: str, **kw):
save_state(st)
def _build_cmd(job: dict, settings: dict, with_subs: bool) -> list:
dest = config.YT_DOWNLOAD_DIR
# %(title)s beholder æøå; [%(id)s] gør navnet unikt og gør det muligt at
# genkende videoen igen uden at gemme en sidevogn af metadata.
out = os.path.join(dest, "%(title)s [%(id)s].%(ext)s")
def _build_cmd(job: dict, settings: dict, with_subs: bool, klient: str = "") -> list:
# yt-dlp henter til en skjult staging-mappe under video-id'et; organiser()
# flytter bagefter videoen ind som Kanal/Season ÅÅÅÅ/... med NFO og billede.
out = os.path.join(staging_dir(), "%(id)s.%(ext)s")
cmd = [config.YT_DLP_BIN,
"-f", settings.get("quality") or config.YT_DEFAULTS["quality"],
"--merge-output-format", "mp4",
@@ -122,6 +124,9 @@ def _build_cmd(job: dict, settings: dict, with_subs: bool) -> list:
"--concurrent-fragments", "4",
"--retries", "5", "--sleep-requests", "1",
"--print", "after_move:%(id)s|%(upload_date)s|%(filepath)s",
# Titel, kanal, beskrivelse og upload-dato til NFO'en, og YouTubes
# miniature som afsnitsbillede i Emby.
"--write-info-json", "--write-thumbnail", "--convert-thumbnails", "jpg",
"-o", out]
if settings.get("metadata", True):
@@ -142,21 +147,28 @@ def _build_cmd(job: dict, settings: dict, with_subs: bool) -> list:
# påvirkes ikke (yt-dlp henter dem fra SponsorBlock-API'et pr. video).
cmd += ["--sponsorblock-remove", "default", "--sponsorblock-mark", "all"]
if klient:
# Valgfrit: bed YouTube om videoen som en anden klient (se FORSOEG).
cmd += ["--extractor-args", f"youtube:player_client={klient}"]
if not job.get("playlist"):
cmd.append("--no-playlist")
else:
# En enkelt privat/slettet video må ikke vælte resten af playlisten.
cmd.append("--ignore-errors")
cmd.append(job["url"])
return cmd
async def _attempt(job: dict, settings: dict, with_subs: bool) -> dict:
async def _attempt(job: dict, settings: dict, with_subs: bool, klient: str = "") -> dict:
dest = config.YT_DOWNLOAD_DIR
proc = await asyncio.create_subprocess_exec(
*_build_cmd(job, settings, with_subs),
*_build_cmd(job, settings, with_subs, klient),
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
err = (stderr.decode("utf-8", "replace").strip().splitlines() or ["ukendt fejl"])[-1]
return {"ok": False, "error": err[:300]}
if proc.returncode is not None and proc.returncode < 0:
# Dræbt udefra, typisk fordi portalen genstartede midt i hentningen.
return {"ok": False, "error": f"afbrudt (signal {-proc.returncode})"}
# --print after_move giver én linje pr. hentet fil: id|upload_date|sti.
# Video-id og upload-dato gemmes, fordi cyklussen skal kunne sortere efter
@@ -167,14 +179,237 @@ async def _attempt(job: dict, settings: dict, with_subs: bool) -> dict:
if len(parts) == 3 and parts[2].startswith(dest):
videos.append({"id": parts[0], "upload_date": parts[1].strip("NA "),
"path": parts[2]})
err = (stderr.decode("utf-8", "replace").strip().splitlines()
or [f"ukendt fejl (kode {proc.returncode})"])[-1][:300]
if proc.returncode != 0 and not (job.get("playlist") and videos):
return {"ok": False, "error": err}
if not videos:
return {"ok": False, "error": "yt-dlp meldte ingen fil — allerede hentet?"}
# Ind i Kanal/Season ÅÅÅÅ/ med NFO og billede, så Emby viser det som en serie.
titler = []
for v in videos:
o = await organiser(v["path"])
v["path"] = o["path"]
v["upload_date"] = o["upload_date"] or v["upload_date"]
titler.append(o["title"])
paths = [v["path"] for v in videos]
size = sum(os.path.getsize(p) for p in paths if os.path.exists(p))
title = os.path.basename(paths[0]).rsplit(".", 1)[0]
size = sum(file_size(p) for p in paths if os.path.exists(p))
title = titler[0] if len(titler) == 1 else f"{titler[0]} (+{len(titler) - 1} fra playlisten)"
noter = [] if with_subs else ["uden undertekster"]
if proc.returncode != 0:
noter.append(f"nogle videoer i playlisten blev sprunget over ({err[:120]})")
return {"ok": True, "title": title, "path": paths[0], "paths": paths,
"videos": videos, "size": size,
"note": "" if with_subs else "uden undertekster"}
"videos": videos, "size": size, "note": "; ".join(noter)}
# ---------------- Emby-struktur: kanal = serie ----------------
# Hver kanal bliver en serie i et Emby "Serier"-bibliotek:
# <Kanal>/tvshow.nfo, poster.jpg (kanalens profilbillede), fanart.jpg (banner)
# <Kanal>/Season ÅÅÅÅ/<Kanal> - sÅÅÅÅeMMDDnn - <Titel> [<id>].mp4
# + .nfo (titel, beskrivelse, upload-dato) + -thumb.jpg + .<sprog>.srt
# Afsnitsnummeret er upload-datoen (MMDD) + et løbenummer, så Emby sorterer
# efter hvornår videoen kom på YouTube, ikke hvornår vi hentede den.
STAGING_NAVN = ".staging"
KANAL_FILER = ("tvshow.nfo", "poster.jpg", "fanart.jpg", "banner.jpg")
_UGYLDIG = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
def staging_dir() -> str:
d = os.path.join(config.YT_DOWNLOAD_DIR, STAGING_NAVN)
os.makedirs(d, exist_ok=True)
return d
def _rent(navn: str, maks: int = 120) -> str:
s = re.sub(r"\s+", " ", _UGYLDIG.sub("", navn or "")).strip().rstrip(". ")
return s[:maks].rstrip(". ") or "Ukendt"
def _dato(d: str) -> str:
return f"{d[:4]}-{d[4:6]}-{d[6:8]}" if d and len(d) == 8 and d.isdigit() else ""
def _skriv_tekst(sti: str, tekst: str) -> None:
tmp = sti + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write(tekst)
os.replace(tmp, sti)
def _nfo(rod: str, felter: list) -> str:
linjer = ['<?xml version="1.0" encoding="utf-8" standalone="yes"?>', f"<{rod}>"]
for tag, v, attr in felter:
if v not in (None, ""):
linjer.append(f" <{tag}{attr}>{escape(str(v))}</{tag}>")
linjer += [" <lockdata>true</lockdata>", f"</{rod}>", ""]
return "\n".join(linjer)
async def _hent_billede(url: str, sti: str) -> bool:
try:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as c:
r = await c.get(url)
if r.status_code == 200 and r.content:
with open(sti + ".tmp", "wb") as f:
f.write(r.content)
os.replace(sti + ".tmp", sti)
return True
except Exception: # noqa: BLE001
pass
return False
async def _kanal_info(kanal_dir: str, kanal: str, info: dict) -> None:
"""tvshow.nfo + plakat/baggrund for kanalen (kun når de mangler)."""
nfo = os.path.join(kanal_dir, "tvshow.nfo")
if os.path.exists(nfo) and os.path.exists(os.path.join(kanal_dir, "poster.jpg")):
return
url = info.get("channel_url") or (
f"https://www.youtube.com/channel/{info['channel_id']}" if info.get("channel_id") else "")
meta = {}
if url:
try:
proc = await asyncio.create_subprocess_exec(
config.YT_DLP_BIN, "--dump-single-json", "--flat-playlist", "--playlist-items", "0",
"--no-warnings", url, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=120)
meta = json.loads(out.decode("utf-8", "replace") or "{}")
except Exception: # noqa: BLE001
meta = {}
if not os.path.exists(nfo):
_skriv_tekst(nfo, _nfo("tvshow", [
("title", kanal, ""), ("plot", meta.get("description") or "", ""),
("studio", "YouTube", ""),
("uniqueid", info.get("channel_id") or "", ' type="youtube" default="true"')]))
thumbs = meta.get("thumbnails") or []
def find(id_, pred):
for t in thumbs:
if t.get("id") == id_ and t.get("url"):
return t["url"]
kandidater = [t for t in thumbs if t.get("url") and pred(t)]
kandidater.sort(key=lambda t: (t.get("width") or 0), reverse=True)
return kandidater[0]["url"] if kandidater else ""
avatar = find("avatar_uncropped", lambda t: t.get("width") and t.get("width") == t.get("height"))
banner = find("banner_uncropped", lambda t: (t.get("width") or 0) > 2 * (t.get("height") or 1))
if avatar and not os.path.exists(os.path.join(kanal_dir, "poster.jpg")):
await _hent_billede(avatar, os.path.join(kanal_dir, "poster.jpg"))
if banner and not os.path.exists(os.path.join(kanal_dir, "fanart.jpg")):
await _hent_billede(banner, os.path.join(kanal_dir, "fanart.jpg"))
async def organiser(staging_path: str, info: dict | None = None) -> dict:
"""Flyt en hentet video fra staging ind i kanalens serie, med NFO og billede.
Returnerer {path, title, upload_date}."""
sd = os.path.dirname(staging_path)
vid = os.path.basename(staging_path).split(".")[0]
if info is None:
try:
with open(os.path.join(sd, f"{vid}.info.json"), encoding="utf-8") as f:
info = json.load(f)
except (OSError, ValueError):
info = {}
kanal = _rent(info.get("channel") or info.get("uploader") or "Ukendt kanal", 80)
dato = info.get("upload_date") or info.get("release_date") or time.strftime("%Y%m%d")
aar, md = dato[:4], dato[4:8]
kanal_dir = os.path.join(config.YT_DOWNLOAD_DIR, kanal)
saeson_dir = os.path.join(kanal_dir, f"Season {aar}")
os.makedirs(saeson_dir, exist_ok=True)
await _kanal_info(kanal_dir, kanal, info)
# Samme video igen (fx nyt forsøg): genbrug dens afsnitsnummer.
ep, brugt = 0, set()
for f in os.listdir(saeson_dir):
m = re.search(rf" - s{aar}e(\d{{6}}) - ", f)
if not m:
continue
if f"[{vid}]" in f:
ep = int(m.group(1))
brugt.add(int(m.group(1)))
if not ep:
base = int(md) * 100 if md.isdigit() else 0
ep = next(base + n for n in range(1, 100) if base + n not in brugt)
titel = info.get("title") or vid
stem = f"{kanal} - s{aar}e{ep:06d} - {_rent(titel)} [{vid}]"
final = os.path.join(saeson_dir, stem + os.path.splitext(staging_path)[1])
os.replace(staging_path, final)
for f in os.listdir(sd):
if not f.startswith(vid + "."):
continue
src, rest = os.path.join(sd, f), f[len(vid) + 1:]
if rest == "info.json":
os.remove(src)
elif rest in ("jpg", "jpeg", "png", "webp"):
os.replace(src, os.path.join(saeson_dir, f"{stem}-thumb.{'jpg' if rest == 'jpeg' else rest}"))
elif not rest.endswith(".part"):
os.replace(src, os.path.join(saeson_dir, f"{stem}.{rest}"))
varighed = info.get("duration")
_skriv_tekst(os.path.join(saeson_dir, stem + ".nfo"), _nfo("episodedetails", [
("title", titel, ""), ("showtitle", kanal, ""), ("season", aar, ""), ("episode", ep, ""),
("plot", info.get("description") or "", ""), ("aired", _dato(dato), ""),
("premiered", _dato(dato), ""), ("year", aar, ""),
("runtime", int(varighed // 60) or "" if varighed else "", ""), ("studio", "YouTube", ""),
("uniqueid", vid, ' type="youtube" default="true"')]))
return {"path": final, "title": titel, "upload_date": dato}
def _ryd_tomme_mapper(saeson_dir: str) -> None:
"""Efter en sletning: fjern tom sæsonmappe, og hele kanalen når den ikke har
flere videoer. Rører kun mapper under download-mappen og kun kendte filer."""
rod = os.path.realpath(config.YT_DOWNLOAD_DIR)
s = os.path.realpath(saeson_dir)
if s == rod or not s.startswith(rod + os.sep):
return
try:
kanal = os.path.dirname(s) if os.path.basename(s).startswith("Season ") else s
if s != kanal and not os.listdir(s):
os.rmdir(s)
if kanal == rod or not kanal.startswith(rod + os.sep) or os.path.basename(kanal) == STAGING_NAVN:
return
for _, _, fs in os.walk(kanal):
if any(f.lower().endswith((".mp4", ".mkv", ".webm")) for f in fs):
return
for f in KANAL_FILER:
if os.path.exists(os.path.join(kanal, f)):
os.remove(os.path.join(kanal, f))
for d in os.listdir(kanal):
dd = os.path.join(kanal, d)
if d.startswith("Season ") and os.path.isdir(dd) and not os.listdir(dd):
os.rmdir(dd)
if not os.listdir(kanal):
os.rmdir(kanal)
except OSError:
pass
def _slet_video(path: str) -> int:
"""Slet videoen med alle sidecars og ryd tomme mapper. Returnerer bytes."""
size = 0
for side in _sidecars(path):
size += os.path.getsize(side)
os.remove(side)
if os.path.exists(path):
size += os.path.getsize(path)
os.remove(path)
_ryd_tomme_mapper(os.path.dirname(path))
return size
# Fejl der ofte går over af sig selv: YouTube afviser en enkelt forespørgsel
# (403/429), eller forbindelsen falder. yt-dlp gentager ikke selv en 403.
_MIDLERTIDIG = ("403", "forbidden", "429", "too many requests", "timed out",
"connection reset", "remote end closed", "temporary failure", "afbrudt")
# Forsøg: (pause før, YouTube-klient). Samme klient med stigende pause: testet
# 2026-10-06 gav kun standardklienten 1080p (android_vr faldt til 360p, tv,
# web_safari, mweb og tv_simply gav slet ingen formater), og 403'erne kom af
# mange samtidige hentninger, som kø-låsen nu forhindrer. Klientfeltet bliver
# stående, hvis en anden klient senere viser sig bedre.
FORSOEG = [(0, ""), (30, ""), (120, "")]
def midlertidig(err: str) -> bool:
e = (err or "").lower()
return any(m in e for m in _MIDLERTIDIG)
async def _run_ytdlp(job: dict, settings: dict) -> dict:
@@ -183,13 +418,27 @@ async def _run_ytdlp(job: dict, settings: dict) -> dict:
Undertekster er et gode, ikke et krav: YouTube rate-limiter dem (HTTP 429)
uafhængigt af selve videoen, og uden dette fald-tilbage ville en 429 på en
undertekstfil vælte hele hentningen. Vi prøver derfor én gang mere uden dem.
Midlertidige fejl (403/429/timeout) prøves igen op til to gange med
stigende pause (FORSOEG).
"""
want_subs = bool(settings.get("subtitles", True))
res = await _attempt(job, settings, with_subs=want_subs)
if not res["ok"] and want_subs and "subtitle" in res["error"].lower():
res = await _attempt(job, settings, with_subs=False)
res = {"ok": False, "error": "ingen forsøg"}
for nr, (pause, klient) in enumerate(FORSOEG, start=1):
if pause:
await asyncio.sleep(pause)
res = await _attempt(job, settings, with_subs=want_subs, klient=klient)
if not res["ok"] and want_subs and "subtitle" in res["error"].lower():
res = await _attempt(job, settings, with_subs=False, klient=klient)
if res["ok"]:
res["note"] = "undertekster sprunget over (YouTube afviste dem lige nu)"
if res["ok"]:
res["note"] = "undertekster sprunget over (YouTube afviste dem lige nu)"
if nr > 1:
res["note"] = "; ".join(x for x in (res.get("note"), f"lykkedes i forsøg {nr}") if x)
return res
if not midlertidig(res["error"]):
return res
res["error"] = f"{res['error']} (opgivet efter {len(FORSOEG)} forsøg)"
return res
@@ -207,17 +456,62 @@ def _sidecars(path: str) -> list:
try:
for f in os.listdir(folder):
full = os.path.join(folder, f)
if full != path and f.startswith(base + ".") and os.path.isfile(full):
if full != path and (f.startswith(base + ".") or f.startswith(base + "-thumb.")) \
and os.path.isfile(full):
out.append(full)
except OSError:
pass
return out
def _koe_laas():
"""Én kørsel af køen ad gangen, også på tværs af processer (web + timer).
Mange samtidige hentninger fra samme IP er netop det, YouTube svarer 403 på."""
f = open(os.path.join(config.DATA, "yt_koe.lock"), "w")
try:
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
f.close()
return None
return f
def retry_job(jid: str) -> bool:
"""Læg et fejlet job i kø igen (knappen "Prøv igen")."""
st = get_state()
j = st["jobs"].get(jid)
if not j or j["status"] != "error":
return False
j.update(status="queued", error="", finished=0)
_log(st, f"Prøver igen: {(j.get('title') or j['url'])[:80]}")
save_state(st)
return True
async def process_queue() -> dict:
"""Hent alt der står i kø. Kaldes både fra web-ruten og fra timeren."""
"""Hent alt der står i kø, ét job ad gangen. Kaldes både fra web-ruten og
fra timeren; kører en anden allerede køen, gør denne ingenting."""
laas = _koe_laas()
if laas is None:
return {"done": 0, "failed": 0, "busy": True}
try:
return await _process_queue()
finally:
laas.close()
async def _process_queue() -> dict:
settings = get_settings()
done, failed = 0, 0
# Vi har låsen, så et job der står som "running" blev afbrudt (fx af en
# genstart af portalen midt i hentningen). Læg det i kø igen.
st = get_state()
haengt = [j for j in st["jobs"].values() if j["status"] == "running"]
for j in haengt:
j["status"] = "queued"
_log(st, f"Genoptager afbrudt hentning: {(j.get('title') or j['url'])[:80]}")
if haengt:
save_state(st)
while True:
st = get_state()
queued = [j for j in st["jobs"].values() if j["status"] == "queued"]
@@ -469,10 +763,7 @@ async def enforce_cycles() -> dict:
files.sort(key=lambda f: (f["upload_date"] or "", f["finished"]), reverse=True)
for f in files[int(sub["cycle"]):]:
try:
size = file_size(f["path"])
for side in _sidecars(f["path"]):
os.remove(side)
os.remove(f["path"])
size = _slet_video(f["path"])
st["leaving"].pop(f["path"], None)
job = st["jobs"].get(f["job"])
if job:
@@ -488,6 +779,40 @@ async def enforce_cycles() -> dict:
return res
def delete_video(path: str) -> dict:
"""Slet én video manuelt (knappen på /admin/yt), med samme sikkerhedsnet som
oprydningen: KUN filer portalen selv har hentet, og sidecars med.
Video-id'et bliver stående i abonnementets `seen`, så en overvåget kanal ikke
henter den igen. Er det jobbets sidste fil, markeres jobbet som slettet.
"""
real = os.path.realpath(path or "")
if real not in owned_paths():
return {"ok": False, "error": "Den video har portalen ikke selv hentet, så den røres ikke"}
st = get_state()
try:
size = _slet_video(real)
except OSError as e:
return {"ok": False, "error": f"Kunne ikke slette: {e}"}
st["leaving"].pop(real, None)
titel = os.path.basename(real)
for j in st["jobs"].values():
paths = [p for p in (j.get("paths") or ([j["path"]] if j.get("path") else [])) if p]
if real not in {os.path.realpath(p) for p in paths}:
continue
titel = j.get("title") or titel
rest = [p for p in paths if os.path.realpath(p) != real]
if j.get("paths") is not None:
j["paths"] = rest
if j.get("videos"):
j["videos"] = [v for v in j["videos"] if os.path.realpath(v.get("path") or "") != real]
if not rest:
j["status"] = "deleted"
_log(st, f"Slettet manuelt: {titel} ({_human(size)})")
save_state(st)
return {"ok": True, "title": titel, "size": size}
# ---------------- Emby ----------------
async def _emby(client, path, **params):
params["api_key"] = config.EMBY_API_KEY
@@ -628,11 +953,7 @@ async def run_cycle(dry_run: bool = False) -> dict:
continue
try:
if os.path.exists(path):
for side in _sidecars(path):
res["freed"] += os.path.getsize(side)
os.remove(side)
os.remove(path)
res["freed"] += e.get("size") or 0
res["freed"] += _slet_video(path)
st["leaving"].pop(path, None)
# marker jobbet som slettet så owned_paths ikke tæller den igen
for j in st["jobs"].values():