Compare commits

..
8 Commits
Author SHA1 Message Date
root 9df3c31e43 Emby-kanalen som førsteklasses kilde: set/favorit virker uden YouTube-bibliotek
CD / deploy (push) Successful in 16s
CI / check (push) Successful in 24s
Biblioteket er nu valgfrit. Findes hverken kanal eller bibliotek, er svaret
ok=False, og fejler opslaget for en bruger, kastes der, så cyklus-sletningen
aldrig kører uden alle brugeres favoritter.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 21:37:07 +02:00
root 6ee54c1248 Indeks: ffprobe-strømme og bitrate pr. video, ryd .playlists med tomme kanaler
Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 18:24:20 +02:00
agw 77f33f7c9e feat(yt): indeks over kanaler, playlister og videoer til Emby-kanal-plugin'et
Emby kan kun vise serie, sæson og afsnit, men på YouTube har en kanal både
videoer og playlister, og en video kan ligge i flere playlister. Kanal-plugin'et
(agw/emby-yt-kanaler) viser Kanal: Alle videoer og Playlister, og det læser
dette indeks, som portalen skriver.

- skriv_indeks(): bygger DOWNLOAD_DIR/.weircon-index.json (version 1) ud fra
  NFO'er, billeder og undertekster på disken og playliste-cachen. Kun hentede
  videoer kommer med i playlisterne, i YouTubes rækkefølge. Skrives atomisk og
  efter hver hentning og sletning (via refresh_library).
- opdater_playlister(): henter kanalernes playlister med yt-dlp (højst
  ugentligt pr. kanal) plus portalens egne playliste-job og -abonnementer, og
  playlistens miniature til <Kanal>/.playlists/<id>.jpg. Køres natligt.
- emby_yt_items(): lægger set/favorit fra Emby-kanalen "YouTube" oven i
  biblioteket for samme fil, så slettereglen og fredningen virker, uanset hvor
  videoen blev set.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 17:56:37 +02:00
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
5 changed files with 1076 additions and 54 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)
@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)
+23 -3
View File
@@ -54,7 +54,7 @@
<div class="setgrid" style="margin-top:.8rem">
<label class="fld"><span>Karens efter set (dage)</span><input type="number" name="grace_days" min="0" max="365" value="{{ settings.grace_days }}"></label>
<label class="fld"><span>Undertekst-sprog</span><input name="sub_langs" value="{{ settings.sub_langs }}"></label>
<label class="fld"><span>Emby-bibliotek</span><input name="library_name" value="{{ settings.library_name }}"></label>
<label class="fld"><span>Emby-kanal/bibliotek</span><input name="library_name" value="{{ settings.library_name }}"></label>
<label class="fld"><span>Kvalitet (yt-dlp -f)</span><input name="quality" value="{{ settings.quality }}"></label>
</div>
<p style="margin-top:1rem;display:flex;gap:.6rem;flex-wrap:wrap">
@@ -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>
+30
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']}")
@@ -35,6 +51,20 @@ async def main():
for e in cyc["errors"]:
print(" cyklus-fejl:", e)
# Kanal-plugin'ets indeks: playlister (højst ugentligt pr. kanal) og så indekset.
try:
pl = await ytdl.opdater_playlister()
print(f"yt_job: playlister opdateret for {pl['kanaler']} kanaler, {pl['playlister']} i alt")
for e in pl["fejl"][:10]:
print(" playliste-fejl:", e)
except Exception as e: # noqa: BLE001
print(f"yt_job: playlister kunne ikke opdateres: {e}")
try:
ix = ytdl.skriv_indeks()
print(f"yt_job: indeks: {ix['channels']} kanaler, {ix['videos']} videoer, {ix['playlists']} playlister")
except Exception as e: # noqa: BLE001
print(f"yt_job: indekset kunne ikke skrives: {e}")
res = await ytdl.run_cycle(dry_run=False)
if res.get("error"):
print("yt_job FEJL:", res["error"])
+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)
+733 -51
View File
@@ -10,11 +10,18 @@ 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 functools
import json
import os
import re
import shutil
import subprocess
import time
import uuid
import xml.etree.ElementTree as ET
from datetime import datetime
from xml.sax.saxutils import escape
import httpx
@@ -110,11 +117,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 +128,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 +151,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 +183,545 @@ 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))
# Playlisternes miniaturer (skrevet af opdater_playlister) hører også til kanalen.
shutil.rmtree(os.path.join(kanal, ".playlists"), ignore_errors=True)
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
# ---------------- indeks til Emby-kanal-plugin'et ----------------
# Emby kan kun vise serie → sæson → afsnit. Kanal-plugin'et (agw/emby-yt-kanaler)
# viser i stedet Kanal → [Alle videoer | Playlister → Playliste] → videoer, hvor
# en video kan ligge i flere playlister. Det læser dette indeks, som portalen
# skriver ud fra disken (NFO'er og billeder) og playliste-cachen. Plugin'et har
# ingen egen logik for YouTube; alt det ligger her.
INDEKS_NAVN = ".weircon-index.json"
PLAYLISTER_PATH = config.DATA / "yt_playlister.json"
PLAYLISTE_MAKS_ALDER = 7 * 86400
_VIDEO_ENDELSER = (".mp4", ".mkv", ".webm")
_SRT_RE = re.compile(r"\.([A-Za-z0-9_-]+)\.(srt|vtt)$")
def indeks_sti() -> str:
return os.path.join(config.YT_DOWNLOAD_DIR, INDEKS_NAVN)
def _laes_nfo(sti: str) -> dict:
"""Felter fra en NFO (episodedetails/tvshow): tag -> tekst, uniqueid pr. type."""
try:
rod = ET.parse(sti).getroot()
except (OSError, ET.ParseError):
return {}
ud = {}
for el in rod:
if el.tag == "uniqueid":
ud[f"uniqueid:{el.get('type') or ''}"] = (el.text or "").strip()
elif el.tag not in ud:
ud[el.tag] = (el.text or "").strip()
return ud
@functools.lru_cache(maxsize=1024)
def _varighed_mp4(sti: str, mtime: float):
try:
from mutagen import File as _MFile
m = _MFile(sti)
return int(m.info.length) if m and m.info and m.info.length else None
except Exception: # noqa: BLE001
return None
FFPROBE_PATH = config.DATA / "yt_ffprobe.json"
_ffprobe = {"data": None, "aendret": False}
def _brok(v):
try:
t, n = str(v).split("/")
return round(float(t) / float(n), 3) if float(n) else None
except (ValueError, ZeroDivisionError):
return None
def _heltal(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def _stroemme(sti: str, st_) -> dict:
"""Video-/lydspor (codec, opløsning, bitrate) via ffprobe, så Emby-plugin'et kan
sige til apps at filen kan afspilles direkte. Caches pr. sti+mtime+størrelse."""
if _ffprobe["data"] is None:
_ffprobe["data"] = _load(FFPROBE_PATH, {}) or {}
noegle = f"{sti}|{int(st_.st_mtime)}|{st_.st_size}"
if noegle in _ffprobe["data"]:
return _ffprobe["data"][noegle]
ud = {"bitrate": None, "streams": []}
try:
r = subprocess.run(
[shutil.which("ffprobe") or "ffprobe", "-v", "error", "-of", "json", "-show_entries",
"stream=codec_type,codec_name,profile,width,height,pix_fmt,avg_frame_rate,bit_rate,"
"channels,sample_rate:stream_disposition=attached_pic:stream_tags=language:format=bit_rate", sti],
capture_output=True, text=True, timeout=60)
d = json.loads(r.stdout or "{}")
ud["bitrate"] = _heltal((d.get("format") or {}).get("bit_rate"))
for s_ in d.get("streams", []):
if (s_.get("disposition") or {}).get("attached_pic"):
continue # det indlejrede coverbillede er ikke et videospor
if s_.get("codec_type") == "video":
ud["streams"].append({"type": "video", "codec": s_.get("codec_name"), "profile": s_.get("profile"),
"width": s_.get("width"), "height": s_.get("height"),
"fps": _brok(s_.get("avg_frame_rate")), "bitrate": _heltal(s_.get("bit_rate")),
"pix_fmt": s_.get("pix_fmt")})
elif s_.get("codec_type") == "audio":
ud["streams"].append({"type": "audio", "codec": s_.get("codec_name"), "profile": s_.get("profile"),
"channels": s_.get("channels"), "sample_rate": _heltal(s_.get("sample_rate")),
"bitrate": _heltal(s_.get("bit_rate")),
"language": (s_.get("tags") or {}).get("language")})
except Exception: # noqa: BLE001
return ud # prøv igen næste gang
_ffprobe["data"][noegle] = ud
_ffprobe["aendret"] = True
return ud
def _laes_playlister() -> dict:
return _load(PLAYLISTER_PATH, {}) or {}
def skriv_indeks() -> dict:
"""Skriv indekset (DOWNLOAD_DIR/.weircon-index.json) ud fra disken. Ingen netværk.
Returnerer en lille statistik."""
rod = config.YT_DOWNLOAD_DIR
kanaler, videoer = [], {}
try:
navne = sorted(os.listdir(rod), key=str.lower)
except OSError:
navne = []
for navn in navne:
kdir = os.path.join(rod, navn)
if navn.startswith(".") or not os.path.isdir(kdir):
continue
tv = _laes_nfo(os.path.join(kdir, "tvshow.nfo"))
kid = tv.get("uniqueid:youtube") or f"navn:{navn}"
mine = []
for d, dirs, fs in os.walk(kdir):
dirs[:] = [x for x in dirs if not x.startswith(".")]
for f in fs:
if not f.lower().endswith(_VIDEO_ENDELSER) or f.startswith("."):
continue
sti = os.path.join(d, f)
stem = f.rsplit(".", 1)[0]
nfo = _laes_nfo(os.path.join(d, stem + ".nfo"))
m = re.search(r"\[([A-Za-z0-9_-]{11})\]$", stem)
vid = nfo.get("uniqueid:youtube") or (m.group(1) if m else "")
if not vid:
continue
st_ = os.stat(sti)
thumb = os.path.join(d, stem + "-thumb.jpg")
subs = []
for side in sorted(fs):
if side.startswith(stem + "."):
sm = _SRT_RE.search(side[len(stem):])
if sm:
subs.append({"path": os.path.join(d, side), "lang": sm.group(1)})
videoer[vid] = {
"title": nfo.get("title") or stem, "channel": kid, "path": sti,
"thumb": thumb if os.path.exists(thumb) else None,
"description": nfo.get("plot") or "",
"upload_date": nfo.get("aired") or nfo.get("premiered") or "",
"duration": _varighed_mp4(sti, st_.st_mtime), "added": int(st_.st_mtime),
"subtitles": subs, **_stroemme(sti, st_)}
mine.append(vid)
if not mine:
continue
mine.sort(key=lambda v: (videoer[v]["upload_date"], videoer[v]["added"]), reverse=True)
poster, fanart = os.path.join(kdir, "poster.jpg"), os.path.join(kdir, "fanart.jpg")
kanaler.append({"id": kid, "name": tv.get("title") or navn, "description": tv.get("plot") or "",
"poster": poster if os.path.exists(poster) else None,
"fanart": fanart if os.path.exists(fanart) else None,
"videos": mine, "playlists": [], "_dir": kdir})
# Playlister fra cachen; kun hentede videoer, i YouTubes rækkefølge.
cache = _laes_playlister()
for k in kanaler:
for pl in (cache.get(k["id"]) or {}).get("playlists", []):
vids = [v for v in pl.get("videos", []) if v in videoer]
if not vids:
continue
thumb = os.path.join(k["_dir"], ".playlists", f"{pl['id']}.jpg")
k["playlists"].append({"id": pl["id"], "title": pl.get("title") or pl["id"],
"thumb": thumb if os.path.exists(thumb) else None, "videos": vids})
k.pop("_dir", None)
data = {"version": 1, "generated": datetime.now().astimezone().isoformat(timespec="seconds"),
"channels": kanaler, "videos": videoer}
sti = indeks_sti()
tmp = sti + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=1)
os.chmod(tmp, 0o644)
os.replace(tmp, sti)
if _ffprobe["aendret"] and _ffprobe["data"] is not None:
stier = {v["path"] for v in videoer.values()}
_ffprobe["data"] = {k: v for k, v in _ffprobe["data"].items() if k.split("|", 1)[0] in stier}
_save(FFPROBE_PATH, _ffprobe["data"])
_ffprobe["aendret"] = False
return {"channels": len(kanaler), "videos": len(videoer),
"playlists": sum(len(k["playlists"]) for k in kanaler)}
async def _ytdlp_json(url: str) -> dict:
"""yt-dlp --flat-playlist -J med samme nye forsøg som hentning (403/429)."""
err = ""
for pause, _ in FORSOEG:
if pause:
await asyncio.sleep(pause)
proc = await asyncio.create_subprocess_exec(
config.YT_DLP_BIN, "--flat-playlist", "-J", "--no-warnings", "--ignore-errors", url,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
out, e = await proc.communicate()
try:
return json.loads(out.decode("utf-8", "replace") or "{}")
except ValueError:
err = e.decode("utf-8", "replace")
if not midlertidig(err):
break
raise RuntimeError((err.strip().splitlines() or ["ukendt fejl"])[-1][:200])
def _stoerste_thumb(thumbs: list) -> str:
gode = [t for t in thumbs or [] if t.get("url")]
gode.sort(key=lambda t: (t.get("width") or 0) * (t.get("height") or 0), reverse=True)
return gode[0]["url"] if gode else ""
async def _hent_playliste(pl_url: str) -> dict:
d = await _ytdlp_json(pl_url)
return {"id": d.get("id") or "", "title": d.get("title") or "",
"channel_id": d.get("channel_id") or d.get("uploader_id") or "",
"thumb_url": _stoerste_thumb(d.get("thumbnails")),
"videos": [e["id"] for e in (d.get("entries") or []) if e and e.get("id")]}
async def opdater_playlister(kun_nye: bool = False) -> dict:
"""Hent kanalernes playlister (og deres video-id'er) til cachen.
Hver kanal opdateres højst hver 7. dag; kun_nye=True tager kun kanaler uden
cache. Portalens egne playliste-job og -abonnementer tages altid med og
knyttes til playlistens ejer-kanal. Bagefter skrives indekset.
"""
res = {"kanaler": 0, "playlister": 0, "fejl": []}
cache = _laes_playlister()
rod = config.YT_DOWNLOAD_DIR
kanal_dirs = {}
for navn in sorted(os.listdir(rod)):
kdir = os.path.join(rod, navn)
if navn.startswith(".") or not os.path.isdir(kdir):
continue
kid = _laes_nfo(os.path.join(kdir, "tvshow.nfo")).get("uniqueid:youtube")
if kid:
kanal_dirs[kid] = kdir
nu = time.time()
for kid in kanal_dirs:
gl = cache.get(kid) or {}
if kun_nye and gl:
continue
if gl and nu - gl.get("updated", 0) < PLAYLISTE_MAKS_ALDER:
continue
try:
d = await _ytdlp_json(f"https://www.youtube.com/channel/{kid}/playlists")
pls = []
for e in d.get("entries") or []:
if not e or not e.get("id"):
continue
await asyncio.sleep(2) # skån YouTube
try:
pls.append(await _hent_playliste(
e.get("url") or f"https://www.youtube.com/playlist?list={e['id']}"))
except Exception as ex: # noqa: BLE001
res["fejl"].append(f"{e.get('title') or e['id']}: {ex}")
# Bevar egne playlister (fra job/abonnementer) der ikke står på kanalens fane.
egne = [p for p in gl.get("playlists", []) if p.get("egen")]
ids = {p["id"] for p in pls}
cache[kid] = {"updated": nu, "playlists": pls + [p for p in egne if p["id"] not in ids]}
res["kanaler"] += 1
except Exception as ex: # noqa: BLE001
res["fejl"].append(f"{kid}: {ex}")
await asyncio.sleep(2)
# Portalens egne playlister (job med list= og playliste-abonnementer).
st = get_state()
urls = {j["url"] for j in st["jobs"].values() if "list=" in (j.get("url") or "")}
urls |= {s["url"] for s in st["subs"].values() if s.get("kind") == "playlist"}
kendte = {p["id"] for c in cache.values() for p in c.get("playlists", [])}
for url in sorted(urls):
m = re.search(r"list=([A-Za-z0-9_-]+)", url)
if m and m.group(1) in kendte:
# Allerede i en kanals cache; markér den som egen, så den bevares.
for c in cache.values():
for p in c.get("playlists", []):
if p["id"] == m.group(1):
p["egen"] = True
continue
try:
await asyncio.sleep(2)
pl = await _hent_playliste(
f"https://www.youtube.com/playlist?list={m.group(1)}" if m else url)
pl["egen"] = True
ejer = pl.get("channel_id") or ""
c = cache.setdefault(ejer, {"updated": 0, "playlists": []})
c["playlists"] = [p for p in c["playlists"] if p["id"] != pl["id"]] + [pl]
except Exception as ex: # noqa: BLE001
res["fejl"].append(f"{url}: {ex}")
# Miniaturer til <Kanal>/.playlists/<id>.jpg (kun når de mangler).
for kid, c in cache.items():
kdir = kanal_dirs.get(kid)
if not kdir:
continue
for p in c.get("playlists", []):
mappe = os.path.join(kdir, ".playlists")
sti = os.path.join(mappe, f"{p['id']}.jpg")
if p.get("thumb_url") and not os.path.exists(sti):
os.makedirs(mappe, exist_ok=True)
await _hent_billede(p["thumb_url"], sti)
res["playlister"] += 1
_save(PLAYLISTER_PATH, cache)
skriv_indeks()
return res
# 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 +730,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 +768,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 +1075,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 +1091,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
@@ -516,35 +1153,79 @@ def _marked_played(ud: dict) -> bool:
return bool((ud or {}).get("Played"))
async def _kanal_brugerdata(c, users: list, merged: dict, navn: str) -> bool:
"""Læg set/favorit fra Emby-kanalen (kanal-plugin'et) ind i `merged`, nøglet på
filens sti. Kanalens elementer bærer video-id'et i ProviderIds["youtube"]; stien
findes via indekset. Samme video ligger i "Alle videoer" og i playlister, og
kopierne deler brugerdata, så det er ligegyldigt hvilken vi læser.
False hvis kanalen eller indekset mangler. Fejler opslaget for en bruger, kastes
der: uden alle brugeres favoritter må slettereglen ikke køre."""
try:
with open(indeks_sti(), encoding="utf-8") as f:
stier = {vid: os.path.realpath(v["path"]) for vid, v in json.load(f)["videos"].items()}
except (OSError, ValueError, KeyError):
return False
if not users:
return False
kanaler = await _emby(c, "/Channels", UserId=users[0])
kanal = next((k for k in (kanaler.get("Items") or [])
if (k.get("Name") or "").lower() == navn.lower()), None)
if not kanal:
return False
for uid in users:
data = await _emby(c, f"/Users/{uid}/Items", ParentId=kanal["Id"],
Recursive="true", IncludeItemTypes="Video", Fields="ProviderIds,UserData")
for it in data.get("Items", []):
pid = it.get("ProviderIds") or {}
vid = pid.get("youtube") or pid.get("YouTube") or pid.get("Youtube")
sti = stier.get(vid or "")
if not sti:
continue
m = merged.setdefault(sti, {"id": it["Id"], "name": it.get("Name") or os.path.basename(sti),
"path": sti, "played": False, "favorite": False})
ud = it.get("UserData") or {}
if _marked_played(ud):
m["played"] = True
if ud.get("IsFavorite"):
m["favorite"] = True
return True
async def emby_yt_items() -> dict:
"""Alle videoer i YouTube-biblioteket med set/favorit aggregeret over ALLE brugere."""
"""Alle videoer i Emby med set/favorit aggregeret over ALLE brugere.
Kilden er kanalen fra kanal-plugin'et og/eller et bibliotek med samme navn
(standard "YouTube"). Findes ingen af dem, er svaret ok=False, så intet slettes."""
settings = get_settings()
navn = settings.get("library_name") or "YouTube"
out = {"ok": False, "items": [], "error": ""}
try:
async with httpx.AsyncClient(timeout=45) as c:
lib = await _library_id(c, settings.get("library_name") or "YouTube")
if not lib:
out["error"] = (f"Fandt ikke Emby-biblioteket "
f"'{settings.get('library_name')}'")
return out
users = [u["Id"] for u in await _emby(c, "/Users")]
merged = {}
for uid in users:
data = await _emby(c, f"/Users/{uid}/Items", ParentId=lib,
Recursive="true", Fields="Path,UserData")
for it in data.get("Items", []):
path = it.get("Path") or ""
if not path:
continue
key = os.path.realpath(path)
m = merged.setdefault(key, {
"id": it["Id"], "name": it.get("Name") or os.path.basename(path),
"path": key, "played": False, "favorite": False})
ud = it.get("UserData") or {}
if _marked_played(ud):
m["played"] = True
if ud.get("IsFavorite"):
m["favorite"] = True
lib = await _library_id(c, navn)
if lib:
for uid in users:
data = await _emby(c, f"/Users/{uid}/Items", ParentId=lib,
Recursive="true", Fields="Path,UserData")
for it in data.get("Items", []):
path = it.get("Path") or ""
if not path:
continue
key = os.path.realpath(path)
m = merged.setdefault(key, {
"id": it["Id"], "name": it.get("Name") or os.path.basename(path),
"path": key, "played": False, "favorite": False})
ud = it.get("UserData") or {}
if _marked_played(ud):
m["played"] = True
if ud.get("IsFavorite"):
m["favorite"] = True
kanal = await _kanal_brugerdata(c, users, merged, navn)
if not lib and not kanal:
out["error"] = f"Fandt hverken Emby-kanalen eller -biblioteket '{navn}'"
return out
out["items"] = list(merged.values())
out["ok"] = True
except Exception as e: # noqa: BLE001
@@ -553,6 +1234,11 @@ async def emby_yt_items() -> dict:
async def refresh_library():
# Kanal-plugin'ets indeks følger altid disken: efter hentning og sletning.
try:
skriv_indeks()
except Exception: # noqa: BLE001
pass
try:
async with httpx.AsyncClient(timeout=30) as c:
await c.post(f"{config.EMBY_URL}/emby/Library/Refresh",
@@ -628,11 +1314,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():