Fix Jellyfin playlist integration and tracklist matching for single-CD albums

- hint_extractor: add _normalize_vertical_tracklist() to handle bare-number/
  title/duration format (Tufaranka-style tracklists)
- hint_extractor: fix level-1 tracklist match — allow disc_num=None (single-CD)
  by assuming disc=1; previously no tracklist title was ever applied to single-
  CD tracks because the guard required disc_num to be set
- music_enricher: register module in sys.modules before exec_module() so
  @dataclass definitions in jellyfin_playlist_generator work correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-04-29 07:58:41 +02:00
commit 701b05a75d
2 changed files with 55 additions and 4 deletions

View file

@ -138,7 +138,56 @@ def _read_tags(path: Path) -> Tuple[Dict[str, str], Optional[float]]:
return {}, None return {}, None
_STANDALONE_NUM_RE = re.compile(r"^\d{1,3}$")
_DURATION_ONLY_RE = re.compile(r"^\d{1,2}:\d{2}$")
def _normalize_vertical_tracklist(text: str) -> Optional[str]:
"""
Erkennt 'vertikales' Format:
1
Katka dovádí
3:22
2
Záludná
konvertiert zu '1. Katka dovádí 3:22\\n2. Záludná ...'
"""
non_empty = [l.strip() for l in text.splitlines() if l.strip()]
# Mindestens 3 Standalone-Zahlen als Heuristik
num_lines = sum(1 for l in non_empty if _STANDALONE_NUM_RE.match(l))
if num_lines < 3:
return None
result = []
i = 0
while i < len(non_empty):
line = non_empty[i]
if _STANDALONE_NUM_RE.match(line) and i + 1 < len(non_empty):
title_candidate = non_empty[i + 1]
# Nächste Zeile darf selbst keine Zahl und keine Dauer sein
if not _STANDALONE_NUM_RE.match(title_candidate) and not _DURATION_ONLY_RE.match(title_candidate):
duration = ""
skip = 2
if i + 2 < len(non_empty) and _DURATION_ONLY_RE.match(non_empty[i + 2]):
duration = non_empty[i + 2]
skip = 3
entry = f"{line}. {title_candidate}"
if duration:
entry += f" {duration}"
result.append(entry)
i += skip
continue
i += 1
return "\n".join(result) if len(result) >= 3 else None
def _parse_tracklist(text: str) -> List[Dict[str, str]]: def _parse_tracklist(text: str) -> List[Dict[str, str]]:
# Vertikales Format normalisieren bevor das reguläre Parsing läuft
normalized = _normalize_vertical_tracklist(text)
if normalized:
text = normalized
tracks: List[Dict[str, str]] = [] tracks: List[Dict[str, str]] = []
current_disc = 1 current_disc = 1
@ -522,13 +571,14 @@ def extract_hints(scan: AlbumScan, use_ocr: bool = True) -> AlbumHints:
if parsed_tracklist: if parsed_tracklist:
matched_tl: Optional[Dict[str, str]] = None matched_tl: Optional[Dict[str, str]] = None
# 1. Exakt per Tracknummer + Disc (nur wenn beides aus Tag/Dateiname bekannt) # 1. Exakt per Tracknummer + Disc (disc_num=None → Single-CD, assume 1)
if track_num and disc_num: if track_num:
assumed_disc = disc_num if disc_num else 1
for tl_entry in parsed_tracklist: for tl_entry in parsed_tracklist:
tl_track = tl_entry.get("track") tl_track = tl_entry.get("track")
tl_disc = tl_entry.get("disc", "1") tl_disc = int(tl_entry.get("disc", "1"))
if (tl_track and int(tl_track) == track_num if (tl_track and int(tl_track) == track_num
and int(tl_disc) == disc_num): and tl_disc == assumed_disc):
matched_tl = tl_entry matched_tl = tl_entry
break break

View file

@ -55,6 +55,7 @@ def _run_jellyfin_generator(album_dir: Path, generator_path: Path) -> None:
try: try:
spec = importlib.util.spec_from_file_location("jellyfin_pg", generator_path) spec = importlib.util.spec_from_file_location("jellyfin_pg", generator_path)
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
sys.modules["jellyfin_pg"] = mod # muss vor exec_module stehen (für @dataclass)
spec.loader.exec_module(mod) # type: ignore[union-attr] spec.loader.exec_module(mod) # type: ignore[union-attr]
media_files = mod.collect_media_recursive(album_dir) media_files = mod.collect_media_recursive(album_dir)