Initial commit
This commit is contained in:
130
backend/services/converter.py
Normal file
130
backend/services/converter.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ConversionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
SUPPORTED_FORMATS = {"mp3", "wav", "flac", "m4a", "ogg"}
|
||||
MP3_QUALITIES = {"128", "192", "256", "320"}
|
||||
|
||||
|
||||
def normalize_format(format_name: str) -> str:
|
||||
normalized = (format_name or "").strip().lower()
|
||||
if normalized not in SUPPORTED_FORMATS:
|
||||
raise ConversionError("Choose a supported output format.")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_quality(format_name: str, quality: str | None) -> str:
|
||||
if format_name != "mp3":
|
||||
return "192"
|
||||
|
||||
normalized = (quality or "192").strip()
|
||||
if normalized not in MP3_QUALITIES:
|
||||
raise ConversionError("Choose a supported MP3 quality.")
|
||||
return normalized
|
||||
|
||||
|
||||
def sanitize_filename(name: str, fallback: str = "youtube-audio") -> str:
|
||||
normalized = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode("ascii")
|
||||
normalized = re.sub(r"[^\w\s.-]", "", normalized)
|
||||
normalized = re.sub(r"[\s_]+", "-", normalized).strip(".-")
|
||||
return (normalized or fallback)[:120]
|
||||
|
||||
|
||||
def download_audio(url: str, workdir: Path) -> Path:
|
||||
workdir.mkdir(parents=True, exist_ok=True)
|
||||
output_template = str(workdir / "source.%(ext)s")
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"yt_dlp",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"-f",
|
||||
"bestaudio/best",
|
||||
"-o",
|
||||
output_template,
|
||||
url,
|
||||
]
|
||||
|
||||
_run_process(command, "Could not download audio from YouTube.", timeout=1800)
|
||||
|
||||
source_files = sorted(
|
||||
path for path in workdir.glob("source.*") if path.is_file() and not path.name.endswith(".part")
|
||||
)
|
||||
if not source_files:
|
||||
raise ConversionError("The audio download finished, but no source file was created.")
|
||||
return source_files[0]
|
||||
|
||||
|
||||
def convert_audio(source_path: Path, output_path: Path, format_name: str, quality: str) -> Path:
|
||||
if not shutil.which("ffmpeg"):
|
||||
raise ConversionError("ffmpeg is not installed or is not available on PATH.")
|
||||
|
||||
format_name = normalize_format(format_name)
|
||||
quality = normalize_quality(format_name, quality)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
command = ["ffmpeg", "-y", "-i", str(source_path), "-vn"]
|
||||
|
||||
if format_name == "mp3":
|
||||
command.extend(["-codec:a", "libmp3lame", "-b:a", f"{quality}k"])
|
||||
elif format_name == "wav":
|
||||
command.extend(["-codec:a", "pcm_s16le"])
|
||||
elif format_name == "flac":
|
||||
command.extend(["-codec:a", "flac"])
|
||||
elif format_name == "m4a":
|
||||
command.extend(["-codec:a", "aac", "-b:a", "192k"])
|
||||
elif format_name == "ogg":
|
||||
command.extend(["-codec:a", "libvorbis", "-q:a", "5"])
|
||||
|
||||
command.append(str(output_path))
|
||||
_run_process(command, "Could not convert the downloaded audio.", timeout=1800)
|
||||
|
||||
if not output_path.exists():
|
||||
raise ConversionError("Conversion finished, but no output file was created.")
|
||||
return output_path
|
||||
|
||||
|
||||
def clean_source_files(workdir: Path) -> None:
|
||||
for path in workdir.glob("source.*"):
|
||||
if path.is_file():
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _run_process(command: list[str], fallback: str, timeout: int) -> None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise ConversionError(f"{fallback} The process timed out.") from exc
|
||||
|
||||
if result.returncode != 0:
|
||||
raise ConversionError(_friendly_process_error(result.stderr, fallback))
|
||||
|
||||
|
||||
def _friendly_process_error(stderr: str, fallback: str) -> str:
|
||||
lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return fallback
|
||||
|
||||
useful = [line for line in lines if "error" in line.lower() or line.startswith(("ERROR:", "WARNING:"))]
|
||||
message = useful[-1] if useful else lines[-1]
|
||||
|
||||
for prefix in ("ERROR:", "WARNING:"):
|
||||
if message.startswith(prefix):
|
||||
message = message.removeprefix(prefix).strip()
|
||||
|
||||
return message or fallback
|
||||
Reference in New Issue
Block a user