Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
776c314b0f |
+259
@@ -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)
|
||||
@@ -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:
|
||||
# <Kanal>/tvshow.nfo, poster.jpg (kanalens profilbillede), fanart.jpg (banner)
|
||||
# <Kanal>/Season ÅÅÅÅ/<Kanal> - sÅÅÅÅeMMDDnn - <Titel> [<id>].mp4
|
||||
# + .nfo (titel, beskrivelse, upload-dato) + -thumb.jpg + .<sprog>.srt
|
||||
# Afsnitsnummeret er upload-datoen (MMDD) + et løbenummer, så Emby sorterer
|
||||
# efter hvornår videoen kom på YouTube, ikke hvornår vi hentede den.
|
||||
STAGING_NAVN = ".staging"
|
||||
KANAL_FILER = ("tvshow.nfo", "poster.jpg", "fanart.jpg", "banner.jpg")
|
||||
_UGYLDIG = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
|
||||
|
||||
|
||||
def staging_dir() -> str:
|
||||
d = os.path.join(config.YT_DOWNLOAD_DIR, STAGING_NAVN)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _rent(navn: str, maks: int = 120) -> str:
|
||||
s = re.sub(r"\s+", " ", _UGYLDIG.sub("", navn or "")).strip().rstrip(". ")
|
||||
return s[:maks].rstrip(". ") or "Ukendt"
|
||||
|
||||
|
||||
def _dato(d: str) -> str:
|
||||
return f"{d[:4]}-{d[4:6]}-{d[6:8]}" if d and len(d) == 8 and d.isdigit() else ""
|
||||
|
||||
|
||||
def _skriv_tekst(sti: str, tekst: str) -> None:
|
||||
tmp = sti + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(tekst)
|
||||
os.replace(tmp, sti)
|
||||
|
||||
|
||||
def _nfo(rod: str, felter: list) -> str:
|
||||
linjer = ['<?xml version="1.0" encoding="utf-8" standalone="yes"?>', f"<{rod}>"]
|
||||
for tag, v, attr in felter:
|
||||
if v not in (None, ""):
|
||||
linjer.append(f" <{tag}{attr}>{escape(str(v))}</{tag}>")
|
||||
linjer += [" <lockdata>true</lockdata>", f"</{rod}>", ""]
|
||||
return "\n".join(linjer)
|
||||
|
||||
|
||||
async def _hent_billede(url: str, sti: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as c:
|
||||
r = await c.get(url)
|
||||
if r.status_code == 200 and r.content:
|
||||
with open(sti + ".tmp", "wb") as f:
|
||||
f.write(r.content)
|
||||
os.replace(sti + ".tmp", sti)
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
async def _kanal_info(kanal_dir: str, kanal: str, info: dict) -> None:
|
||||
"""tvshow.nfo + plakat/baggrund for kanalen (kun når de mangler)."""
|
||||
nfo = os.path.join(kanal_dir, "tvshow.nfo")
|
||||
if os.path.exists(nfo) and os.path.exists(os.path.join(kanal_dir, "poster.jpg")):
|
||||
return
|
||||
url = info.get("channel_url") or (
|
||||
f"https://www.youtube.com/channel/{info['channel_id']}" if info.get("channel_id") else "")
|
||||
meta = {}
|
||||
if url:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
config.YT_DLP_BIN, "--dump-single-json", "--flat-playlist", "--playlist-items", "0",
|
||||
"--no-warnings", url, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=120)
|
||||
meta = json.loads(out.decode("utf-8", "replace") or "{}")
|
||||
except Exception: # noqa: BLE001
|
||||
meta = {}
|
||||
if not os.path.exists(nfo):
|
||||
_skriv_tekst(nfo, _nfo("tvshow", [
|
||||
("title", kanal, ""), ("plot", meta.get("description") or "", ""),
|
||||
("studio", "YouTube", ""),
|
||||
("uniqueid", info.get("channel_id") or "", ' type="youtube" default="true"')]))
|
||||
thumbs = meta.get("thumbnails") or []
|
||||
def find(id_, pred):
|
||||
for t in thumbs:
|
||||
if t.get("id") == id_ and t.get("url"):
|
||||
return t["url"]
|
||||
kandidater = [t for t in thumbs if t.get("url") and pred(t)]
|
||||
kandidater.sort(key=lambda t: (t.get("width") or 0), reverse=True)
|
||||
return kandidater[0]["url"] if kandidater else ""
|
||||
avatar = find("avatar_uncropped", lambda t: t.get("width") and t.get("width") == t.get("height"))
|
||||
banner = find("banner_uncropped", lambda t: (t.get("width") or 0) > 2 * (t.get("height") or 1))
|
||||
if avatar and not os.path.exists(os.path.join(kanal_dir, "poster.jpg")):
|
||||
await _hent_billede(avatar, os.path.join(kanal_dir, "poster.jpg"))
|
||||
if banner and not os.path.exists(os.path.join(kanal_dir, "fanart.jpg")):
|
||||
await _hent_billede(banner, os.path.join(kanal_dir, "fanart.jpg"))
|
||||
|
||||
|
||||
async def organiser(staging_path: str, info: dict | None = None) -> dict:
|
||||
"""Flyt en hentet video fra staging ind i kanalens serie, med NFO og billede.
|
||||
Returnerer {path, title, upload_date}."""
|
||||
sd = os.path.dirname(staging_path)
|
||||
vid = os.path.basename(staging_path).split(".")[0]
|
||||
if info is None:
|
||||
try:
|
||||
with open(os.path.join(sd, f"{vid}.info.json"), encoding="utf-8") as f:
|
||||
info = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
info = {}
|
||||
kanal = _rent(info.get("channel") or info.get("uploader") or "Ukendt kanal", 80)
|
||||
dato = info.get("upload_date") or info.get("release_date") or time.strftime("%Y%m%d")
|
||||
aar, md = dato[:4], dato[4:8]
|
||||
kanal_dir = os.path.join(config.YT_DOWNLOAD_DIR, kanal)
|
||||
saeson_dir = os.path.join(kanal_dir, f"Season {aar}")
|
||||
os.makedirs(saeson_dir, exist_ok=True)
|
||||
await _kanal_info(kanal_dir, kanal, info)
|
||||
|
||||
# Samme video igen (fx nyt forsøg): genbrug dens afsnitsnummer.
|
||||
ep, brugt = 0, set()
|
||||
for f in os.listdir(saeson_dir):
|
||||
m = re.search(rf" - s{aar}e(\d{{6}}) - ", f)
|
||||
if not m:
|
||||
continue
|
||||
if f"[{vid}]" in f:
|
||||
ep = int(m.group(1))
|
||||
brugt.add(int(m.group(1)))
|
||||
if not ep:
|
||||
base = int(md) * 100 if md.isdigit() else 0
|
||||
ep = next(base + n for n in range(1, 100) if base + n not in brugt)
|
||||
titel = info.get("title") or vid
|
||||
stem = f"{kanal} - s{aar}e{ep:06d} - {_rent(titel)} [{vid}]"
|
||||
final = os.path.join(saeson_dir, stem + os.path.splitext(staging_path)[1])
|
||||
os.replace(staging_path, final)
|
||||
for f in os.listdir(sd):
|
||||
if not f.startswith(vid + "."):
|
||||
continue
|
||||
src, rest = os.path.join(sd, f), f[len(vid) + 1:]
|
||||
if rest == "info.json":
|
||||
os.remove(src)
|
||||
elif rest in ("jpg", "jpeg", "png", "webp"):
|
||||
os.replace(src, os.path.join(saeson_dir, f"{stem}-thumb.{'jpg' if rest == 'jpeg' else rest}"))
|
||||
elif not rest.endswith(".part"):
|
||||
os.replace(src, os.path.join(saeson_dir, f"{stem}.{rest}"))
|
||||
varighed = info.get("duration")
|
||||
_skriv_tekst(os.path.join(saeson_dir, stem + ".nfo"), _nfo("episodedetails", [
|
||||
("title", titel, ""), ("showtitle", kanal, ""), ("season", aar, ""), ("episode", ep, ""),
|
||||
("plot", info.get("description") or "", ""), ("aired", _dato(dato), ""),
|
||||
("premiered", _dato(dato), ""), ("year", aar, ""),
|
||||
("runtime", int(varighed // 60) or "" if varighed else "", ""), ("studio", "YouTube", ""),
|
||||
("uniqueid", vid, ' type="youtube" default="true"')]))
|
||||
return {"path": final, "title": titel, "upload_date": dato}
|
||||
|
||||
|
||||
def _ryd_tomme_mapper(saeson_dir: str) -> None:
|
||||
"""Efter en sletning: fjern tom sæsonmappe, og hele kanalen når den ikke har
|
||||
flere videoer. Rører kun mapper under download-mappen og kun kendte filer."""
|
||||
rod = os.path.realpath(config.YT_DOWNLOAD_DIR)
|
||||
s = os.path.realpath(saeson_dir)
|
||||
if s == rod or not s.startswith(rod + os.sep):
|
||||
return
|
||||
try:
|
||||
kanal = os.path.dirname(s) if os.path.basename(s).startswith("Season ") else s
|
||||
if s != kanal and not os.listdir(s):
|
||||
os.rmdir(s)
|
||||
if kanal == rod or not kanal.startswith(rod + os.sep) or os.path.basename(kanal) == STAGING_NAVN:
|
||||
return
|
||||
for _, _, fs in os.walk(kanal):
|
||||
if any(f.lower().endswith((".mp4", ".mkv", ".webm")) for f in fs):
|
||||
return
|
||||
for f in KANAL_FILER:
|
||||
if os.path.exists(os.path.join(kanal, f)):
|
||||
os.remove(os.path.join(kanal, f))
|
||||
for d in os.listdir(kanal):
|
||||
dd = os.path.join(kanal, d)
|
||||
if d.startswith("Season ") and os.path.isdir(dd) and not os.listdir(dd):
|
||||
os.rmdir(dd)
|
||||
if not os.listdir(kanal):
|
||||
os.rmdir(kanal)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _slet_video(path: str) -> int:
|
||||
"""Slet videoen med alle sidecars og ryd tomme mapper. Returnerer bytes."""
|
||||
size = 0
|
||||
for side in _sidecars(path):
|
||||
size += os.path.getsize(side)
|
||||
os.remove(side)
|
||||
if os.path.exists(path):
|
||||
size += os.path.getsize(path)
|
||||
os.remove(path)
|
||||
_ryd_tomme_mapper(os.path.dirname(path))
|
||||
return size
|
||||
|
||||
|
||||
# Fejl der ofte går over af sig selv: YouTube afviser en enkelt forespørgsel
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user