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>
137 lines
4.5 KiB
Python
Executable file
137 lines
4.5 KiB
Python
Executable file
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List
|
|
|
|
from models import AlbumScan, AUDIO_EXTENSIONS, IMAGE_EXTENSIONS, TRACKLIST_EXTENSIONS, PLAYLIST_EXTENSIONS
|
|
|
|
_DISC_DIR_RE = re.compile(r"(?i)^(?:cd|disc|disk|side)[_ \-]*\d{1,2}$")
|
|
|
|
|
|
def _is_hidden(name: str) -> bool:
|
|
return name.startswith(".") or name.startswith("_")
|
|
|
|
|
|
def _is_disc_dir(name: str) -> bool:
|
|
"""True für Ordner wie 'CD1', 'Disc 2', 'Side A', 'Disk_1'."""
|
|
return bool(_DISC_DIR_RE.match(name))
|
|
|
|
|
|
def scan_album(album_dir: Path) -> AlbumScan:
|
|
"""
|
|
Scannt ein Album-Verzeichnis.
|
|
|
|
Rekursions-Regel:
|
|
- Hat das Album-Verzeichnis selbst Audio-Dateien → kein Abstieg in Unterordner
|
|
(Einzelscheibe; Sub-Ordner wie Artworks, Scans, irrtümliche Kopien werden ignoriert).
|
|
- Hat der Root KEINE Audio-Dateien → Abstieg nur in Disc-Unterordner (CD1, Disc 2 …).
|
|
"""
|
|
result = AlbumScan(album_dir=album_dir)
|
|
|
|
# Erst nur die Wurzel-Ebene scannen, um zu entscheiden ob rekursiert wird
|
|
root_has_audio = any(
|
|
(album_dir / name).suffix.lower() in AUDIO_EXTENSIONS
|
|
for name in _listdir(album_dir)
|
|
if not _is_hidden(name)
|
|
)
|
|
|
|
if root_has_audio:
|
|
# Nur Root-Ebene — keine Unterordner
|
|
_scan_dir(album_dir, album_dir, result, recurse=False)
|
|
else:
|
|
# Kein Audio an der Wurzel → Multi-CD: nur Disc-Unterordner
|
|
_scan_dir(album_dir, album_dir, result, recurse=True)
|
|
|
|
result.audio_files.sort()
|
|
result.image_files.sort()
|
|
result.tracklist_files.sort()
|
|
result.playlist_files.sort()
|
|
return result
|
|
|
|
|
|
def _listdir(path: Path) -> List[str]:
|
|
try:
|
|
return [e.name for e in path.iterdir()]
|
|
except (PermissionError, OSError) as e:
|
|
print(f"⚠️ Scan-Fehler: {e}", file=sys.stderr)
|
|
return []
|
|
|
|
|
|
def _scan_dir(current: Path, album_dir: Path, result: AlbumScan, recurse: bool) -> None:
|
|
try:
|
|
entries = sorted(current.iterdir())
|
|
except (PermissionError, OSError) as e:
|
|
print(f"⚠️ Scan-Fehler {current}: {e}", file=sys.stderr)
|
|
return
|
|
|
|
for entry in entries:
|
|
name = entry.name
|
|
if _is_hidden(name):
|
|
continue
|
|
if entry.is_dir():
|
|
if recurse and _is_disc_dir(name):
|
|
_scan_dir(entry, album_dir, result, recurse=True)
|
|
# Andere Unterordner (Artworks, irrtümliche Kopien…) werden übersprungen
|
|
elif entry.is_file():
|
|
ext = entry.suffix.lower()
|
|
if ext in AUDIO_EXTENSIONS:
|
|
result.audio_files.append(entry)
|
|
elif ext in IMAGE_EXTENSIONS:
|
|
result.image_files.append(entry)
|
|
elif ext in TRACKLIST_EXTENSIONS:
|
|
result.tracklist_files.append(entry)
|
|
elif ext in PLAYLIST_EXTENSIONS:
|
|
result.playlist_files.append(entry)
|
|
else:
|
|
result.other_files.append(entry)
|
|
|
|
|
|
def collect_album_dirs(root: Path) -> 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:
|
|
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 {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)
|