"""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)