Compare commits

..

2 commits

Author SHA1 Message Date
f4e49a3df6 Add disc count validation before apply
Check audio file count vs JSON track count per disc before processing.
Aborts with a clear error showing which discs have discrepancies and
whether tracks are missing from the JSON or audio files are missing
from the directory.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-17 23:08:24 +01:00
b599c9eb8a Fix default model, increase timeout, improve multi-column prompt
- Change default text-LLM from llama3 (not installed) to gemma3:12b
- Increase LLM timeout from 120s to 300s (large models need longer)
- Add explicit multi-column layout instruction to vision prompt to
  prevent skipping columns on dense CD back-cover tracklists

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-17 22:56:02 +01:00
4 changed files with 87 additions and 5 deletions

View file

@ -13,7 +13,7 @@ from musiksammlung.cover import copy_covers
from musiksammlung.llm_parser import parse_tracklist
from musiksammlung.models import Album
from musiksammlung.ocr import ocr_images
from musiksammlung.organizer import apply_mapping, build_mapping
from musiksammlung.organizer import apply_mapping, build_mapping, check_disc_counts
from musiksammlung.playlist import generate_playlist
from musiksammlung.ripper import RipperConfig, interactive_rip
from musiksammlung.tagger import tag_album
@ -90,7 +90,7 @@ def scan(
),
languages: str = typer.Option("deu+eng", "--lang", "-l", help="OCR-Sprachen"),
backend: str = typer.Option("ollama", "--backend", "-b", help="LLM-Backend"),
model: str = typer.Option("llama3", "--model", "-m", help="Text-LLM-Modell"),
model: str = typer.Option("gemma3:12b", "--model", "-m", help="Text-LLM-Modell"),
base_url: str = typer.Option(
"http://localhost:11434", "--url", help="LLM-API-URL"
),
@ -146,6 +146,36 @@ def apply(
raw = json.loads(album_json.read_text(encoding="utf-8"))
album = Album.model_validate(raw)
# Prüfe Track-Anzahl pro Disc
checks = check_disc_counts(album, input_dir)
problems = [c for c in checks if not c.ok]
if problems:
typer.echo(
"\nFEHLER: Track-Diskrepanz zwischen gerippten Dateien und album.json:\n",
err=True,
)
for c in checks:
status = "OK" if c.ok else "!!"
typer.echo(
f" [{status}] Disc {c.disc_number}: "
f"{c.audio_file_count} Datei(en), {c.json_track_count} JSON-Track(s)",
err=True,
)
if c.surplus_files:
typer.echo(
f"{c.surplus_files} Track(s) fehlen im JSON "
f"(Tracks {c.json_track_count + 1}{c.audio_file_count} eintragen)",
err=True,
)
elif c.surplus_json:
typer.echo(
f"{c.surplus_json} JSON-Eintrag/Einträge ohne Audiodatei "
f"(Tracks {c.audio_file_count + 1}{c.json_track_count} prüfen)",
err=True,
)
typer.echo(f"\nBitte {album_json} korrigieren und erneut aufrufen.", err=True)
raise typer.Exit(1)
mapping = build_mapping(album, input_dir, output_dir)
typer.echo(f"Mapping: {len(mapping)} Dateien")
for src, dst in mapping.items():
@ -262,7 +292,7 @@ def process(
),
languages: str = typer.Option("deu+eng", "--lang", "-l"),
backend: str = typer.Option("ollama", "--backend", "-b"),
model: str = typer.Option("llama3", "--model", "-m"),
model: str = typer.Option("gemma3:12b", "--model", "-m"),
base_url: str = typer.Option("http://localhost:11434", "--url"),
dry_run: bool = typer.Option(False, "--dry-run"),
) -> None:

View file

@ -47,7 +47,7 @@ def _call_ollama(text: str, model: str, base_url: str) -> str:
],
"stream": False,
},
timeout=120.0,
timeout=300.0,
)
response.raise_for_status()
return response.json()["message"]["content"]
@ -71,7 +71,7 @@ def _call_openai_compatible(
{"role": "user", "content": text},
],
},
timeout=120.0,
timeout=300.0,
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import logging
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
from musiksammlung.config import AUDIO_EXTENSIONS
@ -13,6 +14,29 @@ from musiksammlung.models import Album
logger = logging.getLogger(__name__)
@dataclass
class DiscCheck:
"""Ergebnis der Track-Zählung für eine einzelne Disc."""
disc_number: int
audio_file_count: int
json_track_count: int
@property
def ok(self) -> bool:
return self.audio_file_count == self.json_track_count
@property
def surplus_files(self) -> int:
"""Dateien ohne JSON-Eintrag (Tracks fehlen im JSON)."""
return max(0, self.audio_file_count - self.json_track_count)
@property
def surplus_json(self) -> int:
"""JSON-Einträge ohne Datei (Dateien fehlen im Verzeichnis)."""
return max(0, self.json_track_count - self.audio_file_count)
def _sanitize_filename(name: str) -> str:
"""Entfernt problematische Zeichen aus Dateinamen."""
return re.sub(r'[<>:"/\\|?*]', "_", name).strip()
@ -28,6 +52,31 @@ def discover_audio_files(directory: Path) -> list[Path]:
return sorted(files, key=extract_number)
def check_disc_counts(album: Album, input_dir: Path) -> list[DiscCheck]:
"""Vergleicht Dateianzahl und JSON-Track-Anzahl pro Disc.
Args:
album: Validiertes Album-Modell
input_dir: Verzeichnis mit gerippten Dateien (enthält CD1/, CD2/, ... bei Multi-Disc)
Returns:
Liste von DiscCheck-Objekten auch für korrekte Discs (ok=True).
"""
multi_disc = len(album.discs) > 1
results: list[DiscCheck] = []
for disc in album.discs:
source_dir = input_dir / f"CD{disc.disc_number}" if multi_disc else input_dir
file_count = len(discover_audio_files(source_dir)) if source_dir.exists() else 0
results.append(DiscCheck(
disc_number=disc.disc_number,
audio_file_count=file_count,
json_track_count=len(disc.tracks),
))
return results
def build_mapping(
album: Album,
input_dir: Path,

View file

@ -28,6 +28,9 @@ WICHTIG:
- Wenn "CD 1", "CD 2", "Disc 1" etc. sichtbar sind, erstelle mehrere Einträge in "discs".
- Ohne Disc-Angabe: eine Disc mit disc_number=1.
- Lasse Zeitangaben (z.B. "3:12") und Interpretenangaben pro Track weg.
- MEHRSPALTIGE LAYOUTS: CD-Rückseiten haben oft 2, 3 oder 4 Spalten nebeneinander.
Lies ALLE Spalten vollständig von oben nach unten, bevor du zur nächsten Spalte gehst.
Überspringen oder Auslassen von Spalten ist ein häufiger Fehler lies jede Spalte komplett.
Antworte NUR mit dem JSON, ohne Erklärung. Beispiel: