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:
|
||||
Scan → HintExtractor → MetadataResolver → CoverHandler → Review → Executor
|
||||
→ (optional) Jellyfin Playlist Generator
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
|
@ -32,6 +34,46 @@ def maybe_tqdm(iterable, show: bool, **kwargs):
|
|||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -166,6 +208,11 @@ def process_album(
|
|||
for k, v in album_stats.items():
|
||||
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:
|
||||
stats["errors"] += 1
|
||||
print(f" ❌ Fehler in {album_dir.name}: {e}", file=sys.stderr)
|
||||
|
|
@ -181,7 +228,7 @@ def main() -> None:
|
|||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
)
|
||||
parser.add_argument("paths", nargs="*",
|
||||
help="Root-Verzeichnisse (direkte Unterordner = Alben)")
|
||||
help="Root-Verzeichnisse (rekursiv nach Alben durchsucht)")
|
||||
parser.add_argument("--album", type=Path,
|
||||
help="Einzelnes Album-Verzeichnis verarbeiten")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
|
|
@ -191,7 +238,7 @@ def main() -> None:
|
|||
parser.add_argument("--confidence", type=float, default=0.85,
|
||||
help="Min-Konfidenz für --auto (default: 0.85)")
|
||||
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",
|
||||
help="Cover-Art in Audiodatei einbetten")
|
||||
parser.add_argument("--backup", type=Path,
|
||||
|
|
@ -206,6 +253,9 @@ def main() -> None:
|
|||
help="Kein Cover-Art-Download")
|
||||
parser.add_argument("--no-tqdm", action="store_true",
|
||||
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()
|
||||
|
||||
|
|
|
|||
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]:
|
||||
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:
|
||||
for item in sorted(root.iterdir()):
|
||||
if item.is_dir() and not _is_hidden(item.name):
|
||||
dirs.append(item)
|
||||
return any(
|
||||
e.suffix.lower() in AUDIO_EXTENSIONS
|
||||
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:
|
||||
print(f"⚠️ Lesefehler {root}: {e}", file=sys.stderr)
|
||||
return dirs
|
||||
print(f"⚠️ Lesefehler {current}: {e}", file=sys.stderr)
|
||||
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