e032a9df1c
- jobs.meta (Migration in beiden db.py): Disc-Metadaten wandern in den Job —
Quelle fuer Job-Detail-Popup (GET /jobs/{id}/detail), Ordner-Benennung, NFO.
- medien.py (Worker, mit Tests): Zielordner 'Titel (Jahr)' statt Job-UUID;
movie.nfo/tvshow.nfo (Kodi-Schema, kodi.wiki/view/NFO_files) + poster.jpg
fuer jellyfin/emby/kodi; plex nur Benennung; Kollision -> Job-ID-Suffix.
- notify.py (identisch in API+Worker, mit Tests): Webhook bei Job-Ende —
Discord ({content}, discord.com/developers), Slack ({text},
api.slack.com/messaging/webhooks), ntfy (Rohtext + ?title=, docs.ntfy.sh),
generisches JSON. Vorher war das Setting ein Placebo: nichts sendete je.
POST /notifications/test beweist die Anbindung sofort.
- mounts.py: NT_STATUS-Fehler -> handelbarer Klartext (ACCESS_DENIED ohne
Credentials = Gast-Abfrage verweigert), Timeout-Meldung, mount-Hinweis
bei error(13). Mit Tests.
- tasks.py: Platz-Check per Disc-Groesse (ioctl BLKGETSIZE64) VOR dem Rip;
Arbeitsverzeichnis workDir (unter /app/media, z.B. NAS) statt fix
/app/temp/raw — 4K-UHD-Rohdaten (bis 100 GB) sprengen sonst die VM-Platte;
MakeMKV-Key aus UI-Settings (~/.MakeMKV/settings.conf, Format wie
entrypoint.sh) gilt ab dem naechsten Rip ohne Rebuild.
- caps.py: Werkzeug-Versionen (MakeMKV aus ENV MAKEMKV_VERSION im Image,
makemkvcon hat keinen --version-Schalter lt. usage.txt; HandBrakeCLI
--version lt. handbrake.fr/docs) + Key-Quelle; workers.info-Spalte.
- /browse liefert jetzt auch DATEIEN (Name + Groesse) — der 'leere'
bluray-Ordner war voll, der Browser zeigte nur Unterordner.
- GET /system/info: Versionen, Plattenplatz, Key-/Webhook-Status.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
108 lines
4.1 KiB
Python
108 lines
4.1 KiB
Python
"""Media-Server-Aufbereitung: sprechende Ordnernamen + NFO + Poster.
|
|
|
|
Commander-Wunsch 24.07.: Rippy soll sich auf das Zielsystem (Jellyfin, Plex,
|
|
Emby, Kodi) vorbereiten. Das Setting `mediaServer` steuert:
|
|
- ALLE Systeme: Zielordner heißt „<Titel> (Jahr)" statt Job-UUID — daran
|
|
erkennen Jellyfin & Co. den Film (Namensschema laut jellyfin.org/docs/
|
|
general/server/media/movies bzw. support.plex.tv „Naming and Organizing").
|
|
- jellyfin / emby / kodi: zusätzlich movie.nfo bzw. tvshow.nfo (Kodi-Schema,
|
|
kodi.wiki/view/NFO_files) + poster.jpg — diese Server lesen NFO nativ.
|
|
- plex: NUR Benennung (Plex liest NFO ohne Zusatz-Agent nicht).
|
|
- none: Verhalten wie bisher (UUID-Ordner, keine Extras).
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
from xml.sax.saxutils import escape
|
|
|
|
import requests
|
|
|
|
NFO_SERVER = ("jellyfin", "emby", "kodi")
|
|
# Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen
|
|
_VERBOTEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
|
|
|
|
|
def sicherer_name(titel: str, jahr=None) -> str:
|
|
"""Pure Funktion (testbar): Titel → Dateisystem-tauglicher Ordnername."""
|
|
name = _VERBOTEN.sub("", titel or "").strip().rstrip(".")
|
|
name = re.sub(r"\s+", " ", name)[:150].strip()
|
|
if not name:
|
|
return ""
|
|
if jahr:
|
|
name = f"{name} ({jahr})"
|
|
return name
|
|
|
|
|
|
def zielordner(basis: str, titel: str, jahr, job_id: str) -> str:
|
|
"""Zielordner unter `basis`: sprechender Name, UUID nur als Fallback.
|
|
|
|
Existiert der Ordner schon (Disc doppelt gerippt), wird die Job-ID
|
|
angehängt statt fremde Dateien zu überschreiben.
|
|
"""
|
|
name = sicherer_name(titel, jahr)
|
|
if not name:
|
|
return os.path.join(basis, job_id)
|
|
pfad = os.path.join(basis, name)
|
|
if os.path.exists(pfad):
|
|
pfad = os.path.join(basis, f"{name} [{job_id[:8]}]")
|
|
return pfad
|
|
|
|
|
|
def baue_nfo(meta: dict, titel: str, jahr=None) -> str:
|
|
"""Pure Funktion (testbar): minimales movie.nfo/tvshow.nfo im Kodi-Schema."""
|
|
ist_serie = (meta.get("type") == "tv")
|
|
wurzel = "tvshow" if ist_serie else "movie"
|
|
zeilen = [
|
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
|
f"<{wurzel}>",
|
|
f" <title>{escape(titel or '')}</title>",
|
|
]
|
|
if jahr:
|
|
zeilen.append(f" <year>{escape(str(jahr))}</year>")
|
|
if meta.get("overview"):
|
|
zeilen.append(f" <plot>{escape(meta['overview'])}</plot>")
|
|
for genre in meta.get("genres") or []:
|
|
zeilen.append(f" <genre>{escape(str(genre))}</genre>")
|
|
if meta.get("runtime"):
|
|
zeilen.append(f" <runtime>{escape(str(meta['runtime']))}</runtime>")
|
|
quelle = meta.get("source") or "Rippy"
|
|
zeilen.append(f" <details>Source: {escape(quelle)}</details>")
|
|
zeilen.append(f"</{wurzel}>")
|
|
return "\n".join(zeilen) + "\n"
|
|
|
|
|
|
def aufbereiten(ordner: str, media_server: str, titel: str, jahr, meta: dict) -> list:
|
|
"""Schreibt NFO + Poster in den fertigen Ordner (best effort).
|
|
|
|
Rückgabe: Liste von Meldungen fürs Log. Wirft NIE — ein fehlendes Poster
|
|
darf einen gelungenen Rip nicht auf 'failed' drehen.
|
|
"""
|
|
meldungen = []
|
|
if media_server not in NFO_SERVER:
|
|
return meldungen
|
|
meta = meta or {}
|
|
|
|
try:
|
|
ist_serie = (meta.get("type") == "tv")
|
|
nfo_name = "tvshow.nfo" if ist_serie else "movie.nfo"
|
|
with open(os.path.join(ordner, nfo_name), "w", encoding="utf-8") as f:
|
|
f.write(baue_nfo(meta, titel, jahr))
|
|
meldungen.append(f"{nfo_name} geschrieben")
|
|
except OSError as e:
|
|
meldungen.append(f"NFO fehlgeschlagen: {e}")
|
|
|
|
poster = meta.get("poster_path") or ""
|
|
if poster:
|
|
if not poster.startswith("http"):
|
|
poster = f"https://image.tmdb.org/t/p/w500{poster}"
|
|
try:
|
|
antwort = requests.get(poster, timeout=15)
|
|
if antwort.status_code == 200 and antwort.content:
|
|
with open(os.path.join(ordner, "poster.jpg"), "wb") as f:
|
|
f.write(antwort.content)
|
|
meldungen.append("poster.jpg gespeichert")
|
|
except requests.RequestException as e:
|
|
meldungen.append(f"Poster fehlgeschlagen: {e}")
|
|
|
|
return meldungen
|