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
This commit is contained in:
agw
2026-10-06 17:13:34 +02:00
parent 1b85698856
commit 776c314b0f
2 changed files with 488 additions and 30 deletions
+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)