diff --git a/yt_migrer.py b/yt_migrer.py new file mode 100644 index 0000000..0b3f803 --- /dev/null +++ b/yt_migrer.py @@ -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) diff --git a/ytdl.py b/ytdl.py index fca98a0..3515c2f 100644 --- a/ytdl.py +++ b/ytdl.py @@ -13,9 +13,11 @@ import asyncio import fcntl import json import os +import re import shutil import time import uuid +from xml.sax.saxutils import escape import httpx @@ -112,10 +114,9 @@ def _set_job(jid: str, **kw): def _build_cmd(job: dict, settings: dict, with_subs: bool, klient: str = "") -> list: - dest = config.YT_DOWNLOAD_DIR - # %(title)s beholder æøå; [%(id)s] gør navnet unikt og gør det muligt at - # genkende videoen igen uden at gemme en sidevogn af metadata. - out = os.path.join(dest, "%(title)s [%(id)s].%(ext)s") + # 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", @@ -123,6 +124,9 @@ def _build_cmd(job: dict, settings: dict, with_subs: bool, klient: str = "") -> "--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): @@ -149,6 +153,9 @@ def _build_cmd(job: dict, settings: dict, with_subs: bool, klient: str = "") -> 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 @@ -159,13 +166,9 @@ async def _attempt(job: dict, settings: dict, with_subs: bool, klient: str = "") *_build_cmd(job, settings, with_subs, klient), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) stdout, stderr = await proc.communicate() - if proc.returncode != 0: - if proc.returncode < 0: - # Dræbt udefra, typisk fordi portalen genstartede midt i hentningen. - return {"ok": False, "error": f"afbrudt (signal {-proc.returncode})"} - err = (stderr.decode("utf-8", "replace").strip().splitlines() - or [f"ukendt fejl (kode {proc.returncode})"])[-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 @@ -176,14 +179,220 @@ async def _attempt(job: dict, settings: dict, with_subs: bool, klient: str = "") 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: +# /tvshow.nfo, poster.jpg (kanalens profilbillede), fanart.jpg (banner) +# /Season ÅÅÅÅ/ - sÅÅÅÅeMMDDnn - [].mp4 +# + .nfo (titel, beskrivelse, upload-dato) + -thumb.jpg + ..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 = ['', f"<{rod}>"] + for tag, v, attr in felter: + if v not in (None, ""): + linjer.append(f" <{tag}{attr}>{escape(str(v))}") + linjer += [" true", f"", ""] + 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 @@ -247,7 +456,8 @@ 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 @@ -553,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: @@ -584,11 +791,7 @@ def delete_video(path: str) -> dict: return {"ok": False, "error": "Den video har portalen ikke selv hentet, så den røres ikke"} st = get_state() try: - size = file_size(real) - for side in _sidecars(real): - os.remove(side) - if os.path.exists(real): - os.remove(real) + size = _slet_video(real) except OSError as e: return {"ok": False, "error": f"Kunne ikke slette: {e}"} st["leaving"].pop(real, None) @@ -750,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():