Recursive album discovery + Jellyfin Playlist Generator integration
scanner.py: collect_album_dirs() now recursively finds album dirs - Dirs with audio files at root → album - Dirs with disc subdirs (CD1/CD2) and no root audio → multi-CD album - Container dirs without audio → recurse into subdirs music_enricher.py: - After execute_album(), auto-discovers jellyfin_playlist_generator.py in ../Jellyfin_Playlist_Generator/ (or via --playlist-generator PATH) - Calls generate_playlist() directly via importlib — no subprocess, no destructive cleanup_all_playlists, targeted to the enriched album - New --playlist-generator CLI option for custom generator path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1cb5a8fb8d
commit
776c977573
2 changed files with 96 additions and 8 deletions
|
|
@ -5,10 +5,12 @@ KI-gestützter Musik-Metadaten-Enricher für Jellyfin-Bibliotheken.
|
||||||
|
|
||||||
Pipeline pro Album:
|
Pipeline pro Album:
|
||||||
Scan → HintExtractor → MetadataResolver → CoverHandler → Review → Executor
|
Scan → HintExtractor → MetadataResolver → CoverHandler → Review → Executor
|
||||||
|
→ (optional) Jellyfin Playlist Generator
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import importlib.util
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -32,6 +34,46 @@ def maybe_tqdm(iterable, show: bool, **kwargs):
|
||||||
return tqdm(iterable, **kwargs) if show else iterable
|
return tqdm(iterable, **kwargs) if show else iterable
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Jellyfin Playlist Generator integration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _find_jellyfin_generator(album_dir: Path, explicit: Optional[Path]) -> Optional[Path]:
|
||||||
|
"""Sucht jellyfin_playlist_generator.py — explizit oder im Geschwister-Verzeichnis."""
|
||||||
|
if explicit:
|
||||||
|
return explicit.expanduser().resolve() if explicit.exists() else None
|
||||||
|
# Auto-Discover: ../Jellyfin_Playlist_Generator/ relativ zum Album-Root
|
||||||
|
candidate = album_dir.parent / "Jellyfin_Playlist_Generator" / "jellyfin_playlist_generator.py"
|
||||||
|
return candidate if candidate.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_jellyfin_generator(album_dir: Path, generator_path: Path) -> None:
|
||||||
|
"""
|
||||||
|
Importiert den Jellyfin Playlist Generator und erstellt die Playlist für album_dir.
|
||||||
|
Kein subprocess, kein cleanup_all_playlists — nur gezielt dieses eine Album.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location("jellyfin_pg", generator_path)
|
||||||
|
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
|
||||||
|
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
media_files = mod.collect_media_recursive(album_dir)
|
||||||
|
if not media_files:
|
||||||
|
print(f" ⚠️ Jellyfin-Generator: keine Mediendateien in {album_dir.name}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
deduped = sorted(set(media_files), key=mod.natural_sort_key)
|
||||||
|
tracks = mod.enrich_tracks(
|
||||||
|
[mod.TrackInfo(p, p.stem, p.suffix.lower()) for p in deduped],
|
||||||
|
album_dir,
|
||||||
|
)
|
||||||
|
tracks = mod.sort_tracks_for_playlist(tracks, album_dir)
|
||||||
|
pl_path = mod.generate_playlist(album_dir, tracks, None, dry_run=False)
|
||||||
|
print(f" 🎵 Jellyfin-Playlist erstellt: {pl_path.name}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Jellyfin-Generator-Fehler ({album_dir.name}): {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Review / Display
|
# Review / Display
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -166,6 +208,11 @@ def process_album(
|
||||||
for k, v in album_stats.items():
|
for k, v in album_stats.items():
|
||||||
stats[k] = stats.get(k, 0) + v
|
stats[k] = stats.get(k, 0) + v
|
||||||
|
|
||||||
|
# Jellyfin Playlist Generator aufrufen
|
||||||
|
generator_path = _find_jellyfin_generator(album_dir, getattr(args, "playlist_generator", None))
|
||||||
|
if generator_path:
|
||||||
|
_run_jellyfin_generator(album_dir, generator_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
stats["errors"] += 1
|
stats["errors"] += 1
|
||||||
print(f" ❌ Fehler in {album_dir.name}: {e}", file=sys.stderr)
|
print(f" ❌ Fehler in {album_dir.name}: {e}", file=sys.stderr)
|
||||||
|
|
@ -181,7 +228,7 @@ def main() -> None:
|
||||||
formatter_class=argparse.RawTextHelpFormatter,
|
formatter_class=argparse.RawTextHelpFormatter,
|
||||||
)
|
)
|
||||||
parser.add_argument("paths", nargs="*",
|
parser.add_argument("paths", nargs="*",
|
||||||
help="Root-Verzeichnisse (direkte Unterordner = Alben)")
|
help="Root-Verzeichnisse (rekursiv nach Alben durchsucht)")
|
||||||
parser.add_argument("--album", type=Path,
|
parser.add_argument("--album", type=Path,
|
||||||
help="Einzelnes Album-Verzeichnis verarbeiten")
|
help="Einzelnes Album-Verzeichnis verarbeiten")
|
||||||
parser.add_argument("--dry-run", action="store_true",
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
|
|
@ -191,7 +238,7 @@ def main() -> None:
|
||||||
parser.add_argument("--confidence", type=float, default=0.85,
|
parser.add_argument("--confidence", type=float, default=0.85,
|
||||||
help="Min-Konfidenz für --auto (default: 0.85)")
|
help="Min-Konfidenz für --auto (default: 0.85)")
|
||||||
parser.add_argument("--rename", action="store_true",
|
parser.add_argument("--rename", action="store_true",
|
||||||
help="Dateien nach Schema umbenennen: TT - Artist - Titel.ext")
|
help="Dateien nach Schema umbenennen: TT_-_Artist_-_Titel.ext")
|
||||||
parser.add_argument("--embed-cover", action="store_true",
|
parser.add_argument("--embed-cover", action="store_true",
|
||||||
help="Cover-Art in Audiodatei einbetten")
|
help="Cover-Art in Audiodatei einbetten")
|
||||||
parser.add_argument("--backup", type=Path,
|
parser.add_argument("--backup", type=Path,
|
||||||
|
|
@ -206,6 +253,9 @@ def main() -> None:
|
||||||
help="Kein Cover-Art-Download")
|
help="Kein Cover-Art-Download")
|
||||||
parser.add_argument("--no-tqdm", action="store_true",
|
parser.add_argument("--no-tqdm", action="store_true",
|
||||||
help="Fortschrittsanzeige deaktivieren")
|
help="Fortschrittsanzeige deaktivieren")
|
||||||
|
parser.add_argument("--playlist-generator", type=Path, dest="playlist_generator",
|
||||||
|
help="Pfad zu jellyfin_playlist_generator.py\n"
|
||||||
|
"(Standard: ../Jellyfin_Playlist_Generator/jellyfin_playlist_generator.py)")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
|
||||||
50
scanner.py
50
scanner.py
|
|
@ -89,11 +89,49 @@ def _scan_dir(current: Path, album_dir: Path, result: AlbumScan, recurse: bool)
|
||||||
|
|
||||||
|
|
||||||
def collect_album_dirs(root: Path) -> List[Path]:
|
def collect_album_dirs(root: Path) -> List[Path]:
|
||||||
dirs: List[Path] = []
|
"""
|
||||||
|
Findet rekursiv alle Album-Verzeichnisse unterhalb von root.
|
||||||
|
Ein Verzeichnis gilt als Album wenn:
|
||||||
|
- es direkt Audio-Dateien enthält, ODER
|
||||||
|
- es keine direkten Audio-Dateien hat aber Disc-Unterordner mit Audio (Multi-CD).
|
||||||
|
Container-Verzeichnisse ohne Audio werden rekursiv durchsucht.
|
||||||
|
"""
|
||||||
|
result: List[Path] = []
|
||||||
|
_find_albums(root, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _has_local_audio(path: Path) -> bool:
|
||||||
try:
|
try:
|
||||||
for item in sorted(root.iterdir()):
|
return any(
|
||||||
if item.is_dir() and not _is_hidden(item.name):
|
e.suffix.lower() in AUDIO_EXTENSIONS
|
||||||
dirs.append(item)
|
for e in path.iterdir()
|
||||||
|
if e.is_file() and not _is_hidden(e.name)
|
||||||
|
)
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _find_albums(current: Path, result: List[Path]) -> None:
|
||||||
|
try:
|
||||||
|
entries = sorted(current.iterdir())
|
||||||
|
subdirs = [e for e in entries if e.is_dir() and not _is_hidden(e.name)]
|
||||||
except (PermissionError, OSError) as e:
|
except (PermissionError, OSError) as e:
|
||||||
print(f"⚠️ Lesefehler {root}: {e}", file=sys.stderr)
|
print(f"⚠️ Lesefehler {current}: {e}", file=sys.stderr)
|
||||||
return dirs
|
return
|
||||||
|
|
||||||
|
# Verzeichnis enthält direkt Audio → Album
|
||||||
|
if any(e.is_file() and not _is_hidden(e.name) and e.suffix.lower() in AUDIO_EXTENSIONS
|
||||||
|
for e in entries):
|
||||||
|
result.append(current)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Disc-Unterordner mit Audio → Multi-CD-Album (scan_album übernimmt die Disc-Logik)
|
||||||
|
disc_dirs = [d for d in subdirs if _is_disc_dir(d.name)]
|
||||||
|
if disc_dirs and any(_has_local_audio(d) for d in disc_dirs):
|
||||||
|
result.append(current)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Container-Verzeichnis → rekursiv weiter suchen
|
||||||
|
for subdir in subdirs:
|
||||||
|
_find_albums(subdir, result)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue