300 lines
9.0 KiB
Python
300 lines
9.0 KiB
Python
"""
|
||
fcco-Converter V2 – Konvertierungsmodul
|
||
|
||
Enthält die FFmpeg-basierte Audio-Konvertierung mit static-ffmpeg.
|
||
Unterstützt erweiterte Einstellungen (Bitrate, Sample-Rate, Kanäle, VBR/CBR).
|
||
"""
|
||
|
||
import os
|
||
import subprocess
|
||
from pathlib import Path
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from typing import Callable
|
||
|
||
import static_ffmpeg
|
||
|
||
from .metadata import apply_id3_tags
|
||
|
||
|
||
def _ensure_ffmpeg() -> tuple[str, str]:
|
||
"""Stellt sicher, dass ffmpeg und ffprobe verfügbar sind.
|
||
|
||
Returns:
|
||
(ffmpeg_path, ffprobe_path)
|
||
"""
|
||
ffmpeg_path, ffprobe_path = static_ffmpeg.run.get_or_fetch_platform_executables_else_raise()
|
||
return ffmpeg_path, ffprobe_path
|
||
|
||
|
||
def get_duration(file_path: Path, ffprobe_path: str) -> float:
|
||
"""Ermittelt die Dauer einer Audiodatei in Sekunden."""
|
||
cmd = [
|
||
ffprobe_path,
|
||
"-v", "error",
|
||
"-show_entries", "format=duration",
|
||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||
str(file_path),
|
||
]
|
||
try:
|
||
result = subprocess.check_output(
|
||
cmd, universal_newlines=True, stderr=subprocess.DEVNULL
|
||
).strip()
|
||
return float(result)
|
||
except (subprocess.CalledProcessError, ValueError):
|
||
return 0.0
|
||
|
||
|
||
def _build_ffmpeg_cmd(
|
||
ffmpeg_path: str,
|
||
input_path: Path,
|
||
output_path: Path,
|
||
audio_settings: dict,
|
||
threads: int = 0,
|
||
) -> list[str]:
|
||
"""Baut den ffmpeg-Befehl basierend auf den Audio-Einstellungen.
|
||
|
||
Args:
|
||
ffmpeg_path: Pfad zur ffmpeg-Binary.
|
||
input_path: Quell-Audiodatei.
|
||
output_path: Ziel-Ausgabedatei.
|
||
audio_settings: Dict mit Audio-Konfiguration.
|
||
threads: Thread-Anzahl (0 = auto).
|
||
|
||
Returns:
|
||
Liste der Befehlsargumente.
|
||
"""
|
||
output_format = audio_settings.get("output_format", "mp3")
|
||
encoding_mode = audio_settings.get("encoding_mode", "CBR")
|
||
bitrate = audio_settings.get("bitrate", "192")
|
||
vbr_quality = audio_settings.get("vbr_quality", "2")
|
||
sample_rate = audio_settings.get("sample_rate", "Original")
|
||
channels = audio_settings.get("channels", "Original")
|
||
overwrite = audio_settings.get("overwrite_existing", True)
|
||
|
||
cmd = [ffmpeg_path, "-i", str(input_path)]
|
||
|
||
# Threads
|
||
cmd.extend(["-threads", str(threads) if threads > 0 else "0"])
|
||
|
||
# Format-spezifische Codec-Einstellungen
|
||
if output_format == "mp3":
|
||
cmd.extend(["-c:a", "libmp3lame"])
|
||
if encoding_mode == "VBR":
|
||
cmd.extend(["-q:a", vbr_quality])
|
||
else: # CBR
|
||
cmd.extend(["-b:a", f"{bitrate}k"])
|
||
|
||
elif output_format == "flac":
|
||
cmd.extend(["-c:a", "flac"])
|
||
|
||
elif output_format == "ogg":
|
||
cmd.extend(["-c:a", "libvorbis"])
|
||
if encoding_mode == "VBR":
|
||
# OGG VBR: Qualität 0–10 (wir mappen 0–9 auf 1–10)
|
||
q = min(10, max(0, 10 - int(vbr_quality)))
|
||
cmd.extend(["-q:a", str(q)])
|
||
else:
|
||
cmd.extend(["-b:a", f"{bitrate}k"])
|
||
|
||
elif output_format == "wav":
|
||
cmd.extend(["-c:a", "pcm_s16le"])
|
||
|
||
# Sample-Rate
|
||
if sample_rate != "Original":
|
||
cmd.extend(["-ar", sample_rate])
|
||
|
||
# Kanäle
|
||
if channels == "Mono":
|
||
cmd.extend(["-ac", "1"])
|
||
elif channels == "Stereo":
|
||
cmd.extend(["-ac", "2"])
|
||
|
||
# Überschreiben
|
||
if overwrite:
|
||
cmd.append("-y")
|
||
else:
|
||
cmd.append("-n")
|
||
|
||
cmd.append(str(output_path))
|
||
return cmd
|
||
|
||
|
||
def convert_single_file(
|
||
input_path: Path,
|
||
output_path: Path,
|
||
ffmpeg_path: str,
|
||
ffprobe_path: str,
|
||
audio_settings: dict | None = None,
|
||
threads: int = 0,
|
||
progress_callback: Callable[[int], None] | None = None,
|
||
) -> None:
|
||
"""Konvertiert eine einzelne Audiodatei.
|
||
|
||
Args:
|
||
input_path: Pfad zur Quelldatei.
|
||
output_path: Pfad für die Zieldatei.
|
||
ffmpeg_path: Pfad zur ffmpeg-Binary.
|
||
ffprobe_path: Pfad zur ffprobe-Binary.
|
||
audio_settings: Dict mit Audio-Konfiguration (aus config).
|
||
threads: Anzahl der Threads (0 = auto).
|
||
progress_callback: Wird mit Prozent (0–100) aufgerufen.
|
||
|
||
Raises:
|
||
RuntimeError: Bei Fehlern in der Konvertierung.
|
||
"""
|
||
if audio_settings is None:
|
||
audio_settings = {"output_format": "mp3", "bitrate": "192", "encoding_mode": "CBR"}
|
||
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
duration = get_duration(input_path, ffprobe_path)
|
||
if duration <= 0:
|
||
raise RuntimeError(f"Dauer der Datei konnte nicht ermittelt werden: {input_path.name}")
|
||
|
||
cmd = _build_ffmpeg_cmd(ffmpeg_path, input_path, output_path, audio_settings, threads)
|
||
|
||
process = subprocess.Popen(
|
||
cmd,
|
||
stderr=subprocess.PIPE,
|
||
stdout=subprocess.DEVNULL,
|
||
universal_newlines=True,
|
||
)
|
||
|
||
while True:
|
||
line = process.stderr.readline()
|
||
if not line:
|
||
break
|
||
if "time=" in line and progress_callback:
|
||
try:
|
||
time_str = line.split("time=")[1].split(" ")[0]
|
||
h, m, s = map(float, time_str.split(":"))
|
||
current_time = h * 3600 + m * 60 + s
|
||
percentage = min(int((current_time / duration) * 100), 100)
|
||
progress_callback(percentage)
|
||
except (ValueError, IndexError):
|
||
pass
|
||
|
||
process.wait()
|
||
if process.returncode != 0:
|
||
raise RuntimeError(f"FFmpeg-Fehler (Code {process.returncode}) bei: {input_path.name}")
|
||
|
||
|
||
def convert_file_entry(
|
||
entry: dict,
|
||
index: int,
|
||
export_dir: Path,
|
||
meta: dict,
|
||
use_subdirs: bool,
|
||
ffmpeg_path: str,
|
||
ffprobe_path: str,
|
||
audio_settings: dict | None = None,
|
||
threads: int = 0,
|
||
progress_callback: Callable[[int, int, str], None] | None = None,
|
||
) -> tuple[int, str, str | None]:
|
||
"""Konvertiert eine Datei und schreibt die Metadaten.
|
||
|
||
Returns:
|
||
(index, filename, error_or_None)
|
||
"""
|
||
if audio_settings is None:
|
||
audio_settings = {"output_format": "mp3", "bitrate": "192", "encoding_mode": "CBR"}
|
||
|
||
file_path = Path(entry["full_path"])
|
||
title = file_path.stem
|
||
output_format = audio_settings.get("output_format", "mp3")
|
||
|
||
try:
|
||
if use_subdirs and entry["rel_dir"]:
|
||
output_dir = export_dir / entry["rel_dir"]
|
||
else:
|
||
output_dir = export_dir
|
||
|
||
output_file = output_dir / f"{title}.{output_format}"
|
||
|
||
def _on_progress(pct: int):
|
||
if progress_callback:
|
||
progress_callback(index, pct, title)
|
||
|
||
convert_single_file(
|
||
input_path=file_path,
|
||
output_path=output_file,
|
||
ffmpeg_path=ffmpeg_path,
|
||
ffprobe_path=ffprobe_path,
|
||
audio_settings=audio_settings,
|
||
threads=threads,
|
||
progress_callback=_on_progress,
|
||
)
|
||
|
||
# ID3-Metadaten nur für MP3 schreiben
|
||
if output_format == "mp3":
|
||
cover_path = Path(meta["cover"]) if meta.get("cover") else None
|
||
apply_id3_tags(
|
||
mp3_path=output_file,
|
||
title=title,
|
||
artist=meta.get("artist", ""),
|
||
album=meta.get("album", ""),
|
||
track_number=index + 1,
|
||
year=meta.get("year", ""),
|
||
cover_path=cover_path,
|
||
)
|
||
|
||
return (index, f"{title}.{output_format}", None)
|
||
|
||
except Exception as e:
|
||
return (index, file_path.name, str(e))
|
||
|
||
|
||
def run_batch_conversion(
|
||
entries: list[dict],
|
||
export_dir: Path,
|
||
meta: dict,
|
||
use_subdirs: bool,
|
||
audio_settings: dict | None = None,
|
||
max_workers: int = 0,
|
||
progress_callback: Callable[[int, int, str], None] | None = None,
|
||
completion_callback: Callable[[list[str], list[tuple[str, str]]], None] | None = None,
|
||
) -> None:
|
||
"""Führt die Batch-Konvertierung aller Dateien durch."""
|
||
if audio_settings is None:
|
||
audio_settings = {"output_format": "mp3", "bitrate": "192", "encoding_mode": "CBR"}
|
||
|
||
ffmpeg_path, ffprobe_path = _ensure_ffmpeg()
|
||
|
||
if max_workers <= 0:
|
||
max_workers = os.cpu_count() or 1
|
||
|
||
successful_files = []
|
||
errors = []
|
||
|
||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||
futures = {
|
||
executor.submit(
|
||
convert_file_entry,
|
||
entry=entry,
|
||
index=idx,
|
||
export_dir=export_dir,
|
||
meta=meta,
|
||
use_subdirs=use_subdirs,
|
||
ffmpeg_path=ffmpeg_path,
|
||
ffprobe_path=ffprobe_path,
|
||
audio_settings=audio_settings,
|
||
threads=0,
|
||
progress_callback=progress_callback,
|
||
): idx
|
||
for idx, entry in enumerate(entries)
|
||
}
|
||
|
||
for future in as_completed(futures):
|
||
index, filename, error = future.result()
|
||
if error:
|
||
errors.append((filename, error))
|
||
if progress_callback:
|
||
progress_callback(index, 100, filename)
|
||
else:
|
||
successful_files.append(filename)
|
||
if progress_callback:
|
||
progress_callback(index, 100, filename)
|
||
|
||
if completion_callback:
|
||
completion_callback(successful_files, errors)
|