Initial commit
This commit is contained in:
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/downloads/.gitkeep
Normal file
1
backend/downloads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
183
backend/main.py
Normal file
183
backend/main.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.services.converter import (
|
||||
ConversionError,
|
||||
clean_source_files,
|
||||
convert_audio,
|
||||
download_audio,
|
||||
normalize_format,
|
||||
normalize_quality,
|
||||
sanitize_filename,
|
||||
)
|
||||
from backend.services.youtube import YouTubeServiceError, fetch_video_info, validate_youtube_url
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_DIR = BASE_DIR.parent
|
||||
FRONTEND_DIR = PROJECT_DIR / "frontend"
|
||||
DOWNLOADS_DIR = BASE_DIR / "downloads"
|
||||
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app = FastAPI(title="Local YouTube Audio Converter")
|
||||
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||
|
||||
executor = ThreadPoolExecutor(max_workers=2)
|
||||
jobs_lock = Lock()
|
||||
jobs: dict[str, "Job"] = {}
|
||||
|
||||
|
||||
class InfoRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class ConvertRequest(BaseModel):
|
||||
url: str
|
||||
format: str
|
||||
quality: str | None = "192"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
id: str
|
||||
url: str
|
||||
format: str
|
||||
quality: str
|
||||
status: str = "queued"
|
||||
message: str = "Queued"
|
||||
metadata: dict[str, Any] | None = None
|
||||
filename: str | None = None
|
||||
file_path: str | None = None
|
||||
error: str | None = None
|
||||
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index() -> FileResponse:
|
||||
return FileResponse(FRONTEND_DIR / "index.html")
|
||||
|
||||
|
||||
@app.post("/api/info")
|
||||
def api_info(payload: InfoRequest) -> dict[str, Any]:
|
||||
try:
|
||||
validate_youtube_url(payload.url)
|
||||
return fetch_video_info(payload.url)
|
||||
except YouTubeServiceError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/convert")
|
||||
def api_convert(payload: ConvertRequest) -> dict[str, str]:
|
||||
try:
|
||||
valid_url = validate_youtube_url(payload.url)
|
||||
format_name = normalize_format(payload.format)
|
||||
quality = normalize_quality(format_name, payload.quality)
|
||||
except (YouTubeServiceError, ConversionError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
job_id = str(uuid4())
|
||||
job = Job(id=job_id, url=valid_url, format=format_name, quality=quality)
|
||||
|
||||
with jobs_lock:
|
||||
jobs[job_id] = job
|
||||
|
||||
executor.submit(_run_conversion, job_id)
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
@app.get("/api/status/{job_id}")
|
||||
def api_status(job_id: str) -> dict[str, Any]:
|
||||
job = _get_job(job_id)
|
||||
payload = asdict(job)
|
||||
payload.pop("file_path", None)
|
||||
if job.status == "complete":
|
||||
payload["download_url"] = f"/api/download/{job_id}"
|
||||
return payload
|
||||
|
||||
|
||||
@app.get("/api/download/{job_id}")
|
||||
def api_download(job_id: str) -> FileResponse:
|
||||
job = _get_job(job_id)
|
||||
|
||||
if job.status != "complete" or not job.file_path or not job.filename:
|
||||
raise HTTPException(status_code=404, detail="This conversion is not ready for download.")
|
||||
|
||||
path = Path(job.file_path)
|
||||
if not path.exists() or not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="The converted file no longer exists.")
|
||||
|
||||
return FileResponse(path, filename=job.filename, media_type="application/octet-stream")
|
||||
|
||||
|
||||
def _run_conversion(job_id: str) -> None:
|
||||
job = _get_job(job_id)
|
||||
workdir = DOWNLOADS_DIR / job_id
|
||||
|
||||
try:
|
||||
_update_job(job_id, status="validating", message="Validating URL")
|
||||
validate_youtube_url(job.url)
|
||||
|
||||
_update_job(job_id, status="fetching_info", message="Fetching video info")
|
||||
metadata = fetch_video_info(job.url)
|
||||
_update_job(job_id, metadata=metadata)
|
||||
|
||||
base_name = sanitize_filename(metadata.get("title") or "youtube-audio")
|
||||
filename = f"{base_name}.{job.format}"
|
||||
output_path = workdir / filename
|
||||
|
||||
_update_job(job_id, status="downloading", message="Downloading audio")
|
||||
source_path = download_audio(job.url, workdir)
|
||||
|
||||
_update_job(job_id, status="converting", message="Converting")
|
||||
final_path = convert_audio(source_path, output_path, job.format, job.quality)
|
||||
clean_source_files(workdir)
|
||||
|
||||
_update_job(
|
||||
job_id,
|
||||
status="complete",
|
||||
message="Ready to download",
|
||||
filename=filename,
|
||||
file_path=str(final_path),
|
||||
error=None,
|
||||
)
|
||||
except (YouTubeServiceError, ConversionError) as exc:
|
||||
clean_source_files(workdir)
|
||||
_update_job(job_id, status="error", message="Error", error=str(exc))
|
||||
except Exception as exc:
|
||||
clean_source_files(workdir)
|
||||
traceback.print_exc()
|
||||
_update_job(job_id, status="error", message="Error", error=f"Unexpected error: {exc}")
|
||||
|
||||
|
||||
def _get_job(job_id: str) -> Job:
|
||||
with jobs_lock:
|
||||
job = jobs.get(job_id)
|
||||
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found.")
|
||||
return job
|
||||
|
||||
|
||||
def _update_job(job_id: str, **changes: Any) -> None:
|
||||
with jobs_lock:
|
||||
job = jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
|
||||
for key, value in changes.items():
|
||||
setattr(job, key, value)
|
||||
job.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
1
backend/services/__init__.py
Normal file
1
backend/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
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
|
||||
116
backend/services/youtube.py
Normal file
116
backend/services/youtube.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
class YouTubeServiceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
YOUTUBE_HOSTS = {
|
||||
"youtube.com",
|
||||
"www.youtube.com",
|
||||
"m.youtube.com",
|
||||
"music.youtube.com",
|
||||
"youtu.be",
|
||||
"www.youtu.be",
|
||||
"youtube-nocookie.com",
|
||||
"www.youtube-nocookie.com",
|
||||
}
|
||||
|
||||
|
||||
def validate_youtube_url(url: str) -> str:
|
||||
candidate = (url or "").strip()
|
||||
parsed = urlparse(candidate)
|
||||
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise YouTubeServiceError("Enter a full YouTube URL starting with http:// or https://.")
|
||||
|
||||
host = (parsed.netloc or "").lower().removesuffix(".")
|
||||
if host not in YOUTUBE_HOSTS:
|
||||
raise YouTubeServiceError("Only YouTube video URLs are supported.")
|
||||
|
||||
path = parsed.path or ""
|
||||
query = parse_qs(parsed.query)
|
||||
|
||||
if host in {"youtu.be", "www.youtu.be"} and path.strip("/"):
|
||||
return candidate
|
||||
|
||||
if host.endswith("youtube.com") or host.endswith("youtube-nocookie.com"):
|
||||
if path == "/watch" and query.get("v", [""])[0].strip():
|
||||
return candidate
|
||||
parts = [part for part in path.split("/") if part]
|
||||
if len(parts) >= 2 and parts[0] in {"shorts", "live", "embed"} and parts[1].strip():
|
||||
return candidate
|
||||
|
||||
raise YouTubeServiceError("That does not look like a supported YouTube video URL.")
|
||||
|
||||
|
||||
def fetch_video_info(url: str) -> dict[str, Any]:
|
||||
valid_url = validate_youtube_url(url)
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"yt_dlp",
|
||||
"--dump-single-json",
|
||||
"--no-playlist",
|
||||
"--skip-download",
|
||||
"--no-warnings",
|
||||
valid_url,
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise YouTubeServiceError("Fetching video info timed out.") from exc
|
||||
|
||||
if result.returncode != 0:
|
||||
raise YouTubeServiceError(_friendly_process_error(result.stderr, "Could not fetch video info."))
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise YouTubeServiceError("yt-dlp returned video info in an unreadable format.") from exc
|
||||
|
||||
return {
|
||||
"title": data.get("title") or "Untitled YouTube video",
|
||||
"channel": data.get("channel") or data.get("uploader") or "Unknown channel",
|
||||
"duration": _format_duration(data.get("duration")),
|
||||
"duration_seconds": data.get("duration"),
|
||||
"thumbnail": data.get("thumbnail"),
|
||||
"webpage_url": data.get("webpage_url") or valid_url,
|
||||
}
|
||||
|
||||
|
||||
def _format_duration(seconds: Any) -> str:
|
||||
if not isinstance(seconds, (int, float)) or seconds < 0:
|
||||
return "Unknown"
|
||||
|
||||
total = int(seconds)
|
||||
hours, remainder = divmod(total, 3600)
|
||||
minutes, secs = divmod(remainder, 60)
|
||||
|
||||
if hours:
|
||||
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
|
||||
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
|
||||
|
||||
message = 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