2026-02-17 17:35:34 +01:00
|
|
|
"""CD-Ripping via abcde with interactive multi-disc workflow."""
|
2026-02-15 00:47:54 +01:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
2026-02-17 17:35:34 +01:00
|
|
|
import re
|
2026-02-15 00:47:54 +01:00
|
|
|
import subprocess
|
|
|
|
|
from pathlib import Path
|
2026-02-17 17:35:34 +01:00
|
|
|
from typing import NamedTuple
|
|
|
|
|
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
|
|
|
|
from musiksammlung.config import AudioFormat
|
2026-02-15 00:47:54 +01:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-02-17 19:58:59 +01:00
|
|
|
# ANSI escape sequence pattern (e.g. arrow keys from broken readline)
|
|
|
|
|
_ANSI_ESC = re.compile(r"(\x1b|\^)\[[\d;]*[A-Za-z@]?")
|
|
|
|
|
|
2026-02-15 00:47:54 +01:00
|
|
|
|
2026-02-17 17:35:34 +01:00
|
|
|
class TrackInfo(NamedTuple):
|
|
|
|
|
"""Track information from abcde."""
|
|
|
|
|
|
|
|
|
|
track_number: int
|
|
|
|
|
artist: str
|
|
|
|
|
title: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RipperConfig(BaseModel):
|
|
|
|
|
"""Configuration for ripping process."""
|
|
|
|
|
|
|
|
|
|
device: str = "/dev/cdrom"
|
|
|
|
|
audio_format: AudioFormat = AudioFormat.FLAC
|
|
|
|
|
output_dir: Path = Path("temp")
|
|
|
|
|
quality: str = "medium" # low, medium, high
|
|
|
|
|
parallel_jobs: int = 1 # Number of parallel encoder processes
|
|
|
|
|
use_pipes: bool = False # True = faster, no WAV files
|
|
|
|
|
use_cddb: bool = True # Use CDDB lookup
|
|
|
|
|
|
|
|
|
|
|
2026-02-17 19:58:59 +01:00
|
|
|
def _clean_input(raw: str) -> str:
|
|
|
|
|
"""Strip ANSI escape codes, control characters and surrounding quotes.
|
|
|
|
|
|
|
|
|
|
Handles broken readline environments where arrow keys produce
|
|
|
|
|
literal escape sequences like ^[[D instead of moving the cursor.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
raw: Raw string from input()
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Cleaned string
|
|
|
|
|
"""
|
|
|
|
|
# Remove ANSI escape sequences (\x1b[... and ^[[...)
|
|
|
|
|
cleaned = _ANSI_ESC.sub("", raw)
|
|
|
|
|
# Remove remaining control characters (backspace \x08, etc.)
|
|
|
|
|
cleaned = re.sub(r"[\x00-\x1f\x7f]", "", cleaned)
|
|
|
|
|
# Strip surrounding whitespace and quotes
|
|
|
|
|
cleaned = cleaned.strip().strip('"\'')
|
|
|
|
|
return cleaned
|
|
|
|
|
|
|
|
|
|
|
2026-02-17 17:35:34 +01:00
|
|
|
def _sanitize_name(name: str) -> str:
|
|
|
|
|
"""Remove problematic characters and replace spaces.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
name: Original name
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Cleaned name (spaces -> underscores)
|
|
|
|
|
"""
|
|
|
|
|
# Replace spaces with underscores
|
|
|
|
|
name = name.replace(" ", "_")
|
|
|
|
|
# Keep umlauts and special characters
|
|
|
|
|
# Only remove problematic filename characters
|
|
|
|
|
name = re.sub(r'[<>:"/\\|?*]', "", name)
|
|
|
|
|
# Remove leading/trailing underscores
|
|
|
|
|
name = name.strip("_")
|
|
|
|
|
return name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_cddb_response(output: str) -> list[TrackInfo]:
|
|
|
|
|
"""Parse CDDB data from abcde output.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
output: abcde stdout/stderr output
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
List of track information
|
|
|
|
|
"""
|
|
|
|
|
tracks = []
|
|
|
|
|
# Pattern: "N: Artist - Title"
|
|
|
|
|
pattern = re.compile(r"^\s*(\d+):\s*(.+?)\s*-\s*(.+)$")
|
|
|
|
|
|
|
|
|
|
for line in output.split("\n"):
|
|
|
|
|
match = pattern.match(line)
|
|
|
|
|
if match:
|
|
|
|
|
track_num = int(match.group(1))
|
|
|
|
|
artist = match.group(2).strip()
|
|
|
|
|
title = match.group(3).strip()
|
|
|
|
|
tracks.append(TrackInfo(track_num, artist, title))
|
|
|
|
|
|
|
|
|
|
return tracks
|
|
|
|
|
|
|
|
|
|
|
2026-02-17 20:15:50 +01:00
|
|
|
def _extract_tracks(output_dir: Path, audio_format: AudioFormat) -> list[Path]:
|
|
|
|
|
"""Find abcde track files recursively and move them flat into output_dir.
|
|
|
|
|
|
|
|
|
|
abcde stores encoded files inside its temp dir as:
|
|
|
|
|
output_dir/abcde.XXXX/track01.flac
|
|
|
|
|
output_dir/abcde.XXXX/track02.flac ...
|
|
|
|
|
|
|
|
|
|
This function moves them to:
|
|
|
|
|
output_dir/track01.flac
|
|
|
|
|
output_dir/track02.flac ...
|
2026-02-17 17:35:34 +01:00
|
|
|
|
|
|
|
|
Args:
|
2026-02-17 20:15:50 +01:00
|
|
|
output_dir: Directory to search and target for flat layout
|
2026-02-17 17:35:34 +01:00
|
|
|
audio_format: Audio format
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-02-17 20:15:50 +01:00
|
|
|
Sorted list of moved files in output_dir
|
2026-02-17 17:35:34 +01:00
|
|
|
"""
|
|
|
|
|
ext = audio_format.extension.lstrip(".")
|
2026-02-17 20:15:50 +01:00
|
|
|
# abcde names files trackNN.ext (with -p: track01, track02, ...)
|
|
|
|
|
pattern = re.compile(rf"^track(\d+)\.{ext}$", re.IGNORECASE)
|
2026-02-17 17:35:34 +01:00
|
|
|
|
2026-02-17 20:15:50 +01:00
|
|
|
moved = []
|
|
|
|
|
for file in sorted(output_dir.rglob("*")):
|
2026-02-17 17:35:34 +01:00
|
|
|
if file.is_file() and pattern.match(file.name):
|
2026-02-17 20:15:50 +01:00
|
|
|
dest = output_dir / file.name
|
|
|
|
|
if file != dest:
|
|
|
|
|
file.rename(dest)
|
|
|
|
|
moved.append(dest)
|
2026-02-17 19:58:59 +01:00
|
|
|
|
2026-02-17 20:15:50 +01:00
|
|
|
return moved
|
2026-02-17 19:58:59 +01:00
|
|
|
|
|
|
|
|
|
2026-02-17 17:35:34 +01:00
|
|
|
def _rename_files(
|
|
|
|
|
output_dir: Path,
|
|
|
|
|
tracks: list[TrackInfo],
|
|
|
|
|
audio_format: AudioFormat,
|
|
|
|
|
) -> None:
|
2026-02-17 20:15:50 +01:00
|
|
|
"""Rename track files according to naming scheme.
|
2026-02-17 17:35:34 +01:00
|
|
|
|
2026-02-17 20:15:50 +01:00
|
|
|
Expected input: track01.flac, track02.flac, ...
|
|
|
|
|
Output: 01_-_title_-_artist.flac, 02_-_title_-_artist.flac, ...
|
2026-02-17 17:35:34 +01:00
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
output_dir: Directory with files
|
2026-02-17 20:15:50 +01:00
|
|
|
tracks: Track information from CDDB
|
2026-02-17 17:35:34 +01:00
|
|
|
audio_format: Audio format
|
|
|
|
|
"""
|
2026-02-17 20:15:50 +01:00
|
|
|
ext = audio_format.extension.lstrip(".")
|
|
|
|
|
# Matches track01.flac, track02.flac, ... (abcde naming)
|
|
|
|
|
abcde_pattern = re.compile(rf"^track(\d+)\.{ext}$", re.IGNORECASE)
|
2026-02-17 17:35:34 +01:00
|
|
|
|
2026-02-17 20:15:50 +01:00
|
|
|
audio_files = sorted(output_dir.glob(f"track*.{ext}"))
|
2026-02-17 17:35:34 +01:00
|
|
|
|
|
|
|
|
for track in tracks:
|
|
|
|
|
for file in audio_files:
|
|
|
|
|
match = abcde_pattern.match(file.name)
|
|
|
|
|
if match and int(match.group(1)) == track.track_number:
|
|
|
|
|
track_num_padded = f"{track.track_number:02d}"
|
|
|
|
|
artist_clean = _sanitize_name(track.artist)
|
|
|
|
|
title_clean = _sanitize_name(track.title)
|
|
|
|
|
new_name = (
|
|
|
|
|
f"{track_num_padded}_-_{title_clean}_-_"
|
|
|
|
|
f"{artist_clean}{audio_format.extension}"
|
|
|
|
|
)
|
|
|
|
|
new_path = output_dir / new_name
|
2026-02-17 19:58:59 +01:00
|
|
|
if file != new_path:
|
|
|
|
|
logger.info("Renaming: %s -> %s", file.name, new_name)
|
|
|
|
|
file.rename(new_path)
|
2026-02-17 17:35:34 +01:00
|
|
|
break
|
|
|
|
|
|
2026-02-17 20:15:50 +01:00
|
|
|
# Rename remaining track files without CDDB info (fallback: 01.flac, ...)
|
|
|
|
|
for file in sorted(output_dir.glob(f"track*.{ext}")):
|
|
|
|
|
match = abcde_pattern.match(file.name)
|
|
|
|
|
if match:
|
|
|
|
|
num = int(match.group(1))
|
|
|
|
|
new_path = output_dir / f"{num:02d}{audio_format.extension}"
|
|
|
|
|
if file != new_path:
|
|
|
|
|
logger.info("Renaming (no CDDB): %s -> %s", file.name, new_path.name)
|
|
|
|
|
file.rename(new_path)
|
|
|
|
|
|
2026-02-17 17:35:34 +01:00
|
|
|
|
|
|
|
|
def _rip_with_abcde(
|
2026-02-15 00:47:54 +01:00
|
|
|
device: str,
|
|
|
|
|
output_dir: Path,
|
2026-02-17 17:35:34 +01:00
|
|
|
audio_format: AudioFormat,
|
|
|
|
|
quality: str = "medium",
|
|
|
|
|
parallel_jobs: int = 1,
|
|
|
|
|
use_pipes: bool = False,
|
|
|
|
|
use_cddb: bool = True,
|
|
|
|
|
) -> tuple[list[Path], list[TrackInfo] | None]:
|
|
|
|
|
"""Rip a CD with abcde directly to desired format.
|
2026-02-15 00:47:54 +01:00
|
|
|
|
|
|
|
|
Args:
|
2026-02-17 17:35:34 +01:00
|
|
|
device: CD drive, e.g. '/dev/cdrom'
|
|
|
|
|
output_dir: Target directory for files
|
|
|
|
|
audio_format: Output audio format
|
|
|
|
|
quality: Quality setting (low, medium, high)
|
|
|
|
|
parallel_jobs: Number of parallel encoder processes
|
|
|
|
|
use_pipes: True = faster, no WAV files
|
|
|
|
|
use_cddb: True = use CDDB lookup
|
2026-02-15 00:47:54 +01:00
|
|
|
|
|
|
|
|
Returns:
|
2026-02-17 17:35:34 +01:00
|
|
|
Tuple (list of created files, track information or None)
|
2026-02-15 00:47:54 +01:00
|
|
|
"""
|
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
2026-02-17 17:35:34 +01:00
|
|
|
# abcde options:
|
2026-02-17 20:15:50 +01:00
|
|
|
# -a actions: cddb+read+encode+tag (no 'move' — we extract files ourselves)
|
2026-02-17 17:35:34 +01:00
|
|
|
# -p: pad track numbers with zeros
|
|
|
|
|
# -o format: output format
|
|
|
|
|
# -d device: CD drive
|
|
|
|
|
# -x: eject CD after ripping
|
2026-02-17 19:58:59 +01:00
|
|
|
# -N: non-interactive (no prompts, auto-select first CDDB match)
|
2026-02-15 00:47:54 +01:00
|
|
|
cmd = [
|
|
|
|
|
"abcde",
|
2026-02-17 17:35:34 +01:00
|
|
|
"-p",
|
|
|
|
|
"-o", audio_format.get_abcde_format(),
|
2026-02-15 00:47:54 +01:00
|
|
|
"-d", device,
|
2026-02-17 17:35:34 +01:00
|
|
|
"-x",
|
|
|
|
|
"-N",
|
2026-02-15 00:47:54 +01:00
|
|
|
]
|
|
|
|
|
|
2026-02-17 17:35:34 +01:00
|
|
|
if use_cddb:
|
2026-02-17 20:15:50 +01:00
|
|
|
cmd.extend(["-a", "cddb,read,encode,tag"])
|
2026-02-17 17:35:34 +01:00
|
|
|
else:
|
2026-02-17 20:15:50 +01:00
|
|
|
cmd.extend(["-a", "read,encode"])
|
2026-02-17 17:35:34 +01:00
|
|
|
|
|
|
|
|
# Parallel encodes
|
|
|
|
|
if parallel_jobs > 1:
|
|
|
|
|
cmd.extend(["-j", str(parallel_jobs)])
|
2026-02-15 00:47:54 +01:00
|
|
|
|
2026-02-17 17:35:34 +01:00
|
|
|
# Use pipes
|
|
|
|
|
if use_pipes:
|
|
|
|
|
cmd.append("-P")
|
|
|
|
|
|
|
|
|
|
# Encoder options for quality
|
|
|
|
|
encoder_opts = audio_format.get_encoder_options(quality)
|
|
|
|
|
if encoder_opts:
|
2026-02-17 19:58:59 +01:00
|
|
|
# abcde accepts encoder options with colon: -o format:options
|
2026-02-17 17:35:34 +01:00
|
|
|
cmd[-2] = f"{audio_format.get_abcde_format()}:{encoder_opts}"
|
|
|
|
|
|
2026-02-17 19:58:59 +01:00
|
|
|
logger.info(
|
|
|
|
|
"Starting abcde in %s (Format: %s, Quality: %s, CDDB: %s)",
|
|
|
|
|
output_dir, audio_format.value, quality, use_cddb,
|
|
|
|
|
)
|
|
|
|
|
logger.debug("Command: %s", " ".join(cmd))
|
2026-02-17 17:35:34 +01:00
|
|
|
|
2026-02-17 19:58:59 +01:00
|
|
|
# Run abcde non-interactively, capture output for CDDB parsing
|
2026-02-15 00:47:54 +01:00
|
|
|
result = subprocess.run(
|
|
|
|
|
cmd,
|
|
|
|
|
cwd=str(output_dir),
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-17 19:58:59 +01:00
|
|
|
# Log output for debugging
|
|
|
|
|
if result.stdout:
|
|
|
|
|
logger.debug("abcde stdout:\n%s", result.stdout)
|
|
|
|
|
if result.stderr:
|
|
|
|
|
logger.debug("abcde stderr:\n%s", result.stderr)
|
|
|
|
|
|
2026-02-15 00:47:54 +01:00
|
|
|
if result.returncode != 0:
|
2026-02-17 17:35:34 +01:00
|
|
|
raise RuntimeError(
|
2026-02-17 19:58:59 +01:00
|
|
|
f"abcde failed (exit {result.returncode}).\n"
|
|
|
|
|
f"{result.stderr or result.stdout}"
|
2026-02-17 17:35:34 +01:00
|
|
|
)
|
|
|
|
|
|
2026-02-17 19:58:59 +01:00
|
|
|
# Parse track info from CDDB output
|
2026-02-17 17:35:34 +01:00
|
|
|
tracks = None
|
|
|
|
|
if use_cddb:
|
2026-02-17 19:58:59 +01:00
|
|
|
combined = result.stdout + result.stderr
|
|
|
|
|
tracks = _parse_cddb_response(combined)
|
2026-02-17 17:35:34 +01:00
|
|
|
if tracks:
|
|
|
|
|
logger.info("CDDB data found: %d tracks", len(tracks))
|
|
|
|
|
|
2026-02-17 20:15:50 +01:00
|
|
|
# Extract track files from abcde's temp dir into output_dir (flat)
|
|
|
|
|
audio_files = _extract_tracks(output_dir, audio_format)
|
2026-02-17 17:35:34 +01:00
|
|
|
|
|
|
|
|
if not audio_files:
|
2026-02-17 19:58:59 +01:00
|
|
|
raise RuntimeError(
|
|
|
|
|
"No audio files found after ripping.\n"
|
|
|
|
|
"abcde output:\n" + (result.stderr or result.stdout)
|
|
|
|
|
)
|
2026-02-17 17:35:34 +01:00
|
|
|
|
|
|
|
|
logger.info("Ripping completed: %d tracks in %s", len(audio_files), output_dir)
|
|
|
|
|
return audio_files, tracks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rip_disc(
|
|
|
|
|
device: str,
|
|
|
|
|
output_dir: Path,
|
|
|
|
|
audio_format: AudioFormat = AudioFormat.FLAC,
|
|
|
|
|
quality: str = "medium",
|
|
|
|
|
parallel_jobs: int = 1,
|
|
|
|
|
use_pipes: bool = False,
|
|
|
|
|
use_cddb: bool = True,
|
|
|
|
|
) -> tuple[Path, str | None, list[TrackInfo] | None]:
|
|
|
|
|
"""Rip a CD directly to the desired format.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
device: CD drive, e.g. '/dev/cdrom'
|
|
|
|
|
output_dir: Target directory for files
|
|
|
|
|
audio_format: Output audio format
|
|
|
|
|
quality: Quality setting (low, medium, high)
|
|
|
|
|
parallel_jobs: Number of parallel encoder processes
|
|
|
|
|
use_pipes: True = faster, no WAV files
|
|
|
|
|
use_cddb: True = use CDDB lookup
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Tuple (directory path, album name or None, track information or None)
|
|
|
|
|
"""
|
|
|
|
|
_, tracks = _rip_with_abcde(
|
|
|
|
|
device, output_dir, audio_format, quality, parallel_jobs, use_pipes, use_cddb
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
album_name = None
|
|
|
|
|
if tracks:
|
2026-02-17 19:58:59 +01:00
|
|
|
album_name = tracks[0].artist
|
2026-02-17 17:35:34 +01:00
|
|
|
_rename_files(output_dir, tracks, audio_format)
|
|
|
|
|
|
|
|
|
|
return output_dir, album_name, tracks
|
|
|
|
|
|
|
|
|
|
|
2026-02-17 19:58:59 +01:00
|
|
|
def interactive_rip(config: RipperConfig) -> None:
|
2026-02-17 17:35:34 +01:00
|
|
|
"""Interactive rip workflow for multiple CDs.
|
|
|
|
|
|
|
|
|
|
Files are placed under config.output_dir:
|
2026-02-17 19:58:59 +01:00
|
|
|
Album_Name/CD1/01_-_title_-_artist.flac, ...
|
2026-02-17 17:35:34 +01:00
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
config: Ripper configuration
|
|
|
|
|
"""
|
|
|
|
|
print("\n" + "=" * 60)
|
|
|
|
|
print(" Musiksammlung - Interactive CD Ripper (abcde)")
|
|
|
|
|
print("=" * 60)
|
2026-02-17 19:58:59 +01:00
|
|
|
print(f"\nCD Drive: {config.device}")
|
|
|
|
|
print(f"Audio Format: {config.audio_format.value}")
|
|
|
|
|
print(f"Quality: {config.quality}")
|
|
|
|
|
print(f"CDDB Lookup: {config.use_cddb}")
|
|
|
|
|
print(f"Parallel Encodes: {config.parallel_jobs}")
|
|
|
|
|
print(f"Pipes: {config.use_pipes}")
|
|
|
|
|
print(f"Output Directory: {config.output_dir.absolute()}")
|
|
|
|
|
print("\nNote: Do not use arrow keys while typing — press Enter to confirm.\n")
|
2026-02-17 17:35:34 +01:00
|
|
|
|
|
|
|
|
album_counter = 1
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
print(f"\n--- Album {album_counter} ---")
|
|
|
|
|
|
2026-02-17 19:58:59 +01:00
|
|
|
raw = input("Album name (Enter = CDDB name / default 'Album{N}'): ")
|
|
|
|
|
album_name = _clean_input(raw)
|
|
|
|
|
if not album_name:
|
|
|
|
|
album_name = f"Album{album_counter}"
|
2026-02-17 17:35:34 +01:00
|
|
|
|
|
|
|
|
disc_counter = 1
|
|
|
|
|
|
|
|
|
|
while True:
|
2026-02-17 19:58:59 +01:00
|
|
|
print(f"\n Album: {album_name}")
|
2026-02-17 17:35:34 +01:00
|
|
|
print(f" CD Drive: {config.device}")
|
|
|
|
|
|
2026-02-17 19:58:59 +01:00
|
|
|
raw_disc = input(" CD number [1]: ")
|
|
|
|
|
disc_num = int(_clean_input(raw_disc)) if _clean_input(raw_disc) else 1
|
2026-02-17 17:35:34 +01:00
|
|
|
|
|
|
|
|
disc_dir = (
|
|
|
|
|
config.output_dir
|
2026-02-17 19:58:59 +01:00
|
|
|
/ _sanitize_name(album_name)
|
2026-02-17 17:35:34 +01:00
|
|
|
/ f"CD{disc_num}"
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-17 19:58:59 +01:00
|
|
|
print(f" Ripping to: {disc_dir.relative_to(config.output_dir)}")
|
2026-02-17 17:35:34 +01:00
|
|
|
print(" (Ripping in progress, please wait...)")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
_, detected_album, tracks = rip_disc(
|
|
|
|
|
device=config.device,
|
|
|
|
|
output_dir=disc_dir,
|
|
|
|
|
audio_format=config.audio_format,
|
|
|
|
|
quality=config.quality,
|
|
|
|
|
parallel_jobs=config.parallel_jobs,
|
|
|
|
|
use_pipes=config.use_pipes,
|
|
|
|
|
use_cddb=config.use_cddb,
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-17 19:58:59 +01:00
|
|
|
if tracks:
|
|
|
|
|
print(f" ✓ CD {disc_num} ripped successfully — {len(tracks)} tracks")
|
|
|
|
|
first = tracks[0]
|
|
|
|
|
last = tracks[-1]
|
|
|
|
|
print(f" {first.track_number:2d}. {first.title} — {first.artist}")
|
|
|
|
|
if last != first:
|
|
|
|
|
print(f" {last.track_number:2d}. {last.title} — {last.artist}")
|
2026-02-17 17:35:34 +01:00
|
|
|
else:
|
|
|
|
|
print(f" ✓ CD {disc_num} ripped successfully")
|
|
|
|
|
|
|
|
|
|
except RuntimeError as e:
|
|
|
|
|
print(f" ✗ Ripping error: {e}")
|
2026-02-17 19:58:59 +01:00
|
|
|
raw_retry = input(" Try again? (y/n): ")
|
|
|
|
|
if _clean_input(raw_retry).lower() != "y":
|
2026-02-17 17:35:34 +01:00
|
|
|
print(" Aborting disc.")
|
|
|
|
|
break
|
|
|
|
|
continue
|
|
|
|
|
|
2026-02-17 19:58:59 +01:00
|
|
|
raw_next = input(" Next CD for this album? (y/n): ")
|
|
|
|
|
if _clean_input(raw_next).lower() != "y":
|
2026-02-17 17:35:34 +01:00
|
|
|
break
|
|
|
|
|
|
|
|
|
|
disc_counter += 1
|
|
|
|
|
|
2026-02-17 19:58:59 +01:00
|
|
|
raw_album = input("\nNext album? (y/n): ")
|
|
|
|
|
if _clean_input(raw_album).lower() != "y":
|
2026-02-17 17:35:34 +01:00
|
|
|
break
|
|
|
|
|
|
|
|
|
|
album_counter += 1
|
2026-02-15 00:47:54 +01:00
|
|
|
|
2026-02-17 17:35:34 +01:00
|
|
|
print("\n" + "=" * 60)
|
|
|
|
|
print("Ripping completed!")
|
2026-02-17 19:58:59 +01:00
|
|
|
print(f"Files are in: {config.output_dir.absolute()}")
|
2026-02-17 17:35:34 +01:00
|
|
|
print("\nNext steps:")
|
|
|
|
|
print(" 1. Check filenames and tags")
|
|
|
|
|
if config.use_cddb:
|
2026-02-17 19:58:59 +01:00
|
|
|
print(" 2. Adjust tags/covers with 'musiksammlung apply'")
|
2026-02-17 17:35:34 +01:00
|
|
|
else:
|
2026-02-17 19:58:59 +01:00
|
|
|
print(" 2. Run 'musiksammlung scan' to extract metadata")
|
|
|
|
|
print(" 3. Run 'musiksammlung apply' to organize & tag")
|
2026-02-17 17:35:34 +01:00
|
|
|
print("=" * 60 + "\n")
|