Initial commit
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
/export/pdf - MusicXML to PDF via music21 + LilyPond (or Audiveris)
|
||||
/export/mp3 - WAV buffer to MP3 via FFmpeg
|
||||
"""
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# C2 - size caps
|
||||
MAX_B64_BYTES = 300 * 1024 * 1024 # 300 MB base64 string (≈ 200 MB decoded)
|
||||
MAX_WAV_BYTES = 200 * 1024 * 1024 # 200 MB decoded WAV
|
||||
|
||||
|
||||
MAX_MUSICXML_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
# M5 - WAV magic bytes: "RIFF....WAVE"
|
||||
_WAV_RIFF = b"RIFF"
|
||||
_WAV_WAVE = b"WAVE"
|
||||
|
||||
|
||||
class ExportPdfRequest(BaseModel):
|
||||
musicxml: str
|
||||
|
||||
|
||||
class ExportMp3Request(BaseModel):
|
||||
wav_base64: str # base64-encoded WAV
|
||||
|
||||
|
||||
def _rm(path: str) -> None:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/pdf")
|
||||
async def export_pdf(req: ExportPdfRequest, background_tasks: BackgroundTasks):
|
||||
"""Convert MusicXML to PDF sheet music."""
|
||||
if len(req.musicxml.encode()) > MAX_MUSICXML_BYTES:
|
||||
raise HTTPException(status_code=413, detail="MusicXML payload too large (max 10 MB)")
|
||||
try:
|
||||
from services.music21_svc import musicxml_to_pdf
|
||||
pdf_path = await musicxml_to_pdf(req.musicxml)
|
||||
background_tasks.add_task(_rm, pdf_path) # H5
|
||||
|
||||
return FileResponse(
|
||||
pdf_path,
|
||||
media_type="application/pdf",
|
||||
filename="sheet.pdf",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": 'attachment; filename="sheet.pdf"',
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("export_pdf failed")
|
||||
raise HTTPException(status_code=500, detail="PDF export failed. Please try again.")
|
||||
|
||||
|
||||
@router.post("/mp3")
|
||||
async def export_mp3(req: ExportMp3Request, background_tasks: BackgroundTasks):
|
||||
"""Convert base64 WAV to MP3 using FFmpeg."""
|
||||
# L6 - size-check before decoding; catch malformed base64 explicitly
|
||||
if len(req.wav_base64) > MAX_B64_BYTES:
|
||||
raise HTTPException(status_code=413, detail="WAV payload too large (max 200 MB)")
|
||||
|
||||
try:
|
||||
wav_bytes = base64.b64decode(req.wav_base64, validate=True)
|
||||
except binascii.Error:
|
||||
raise HTTPException(status_code=422, detail="Invalid base64 encoding")
|
||||
|
||||
# C2 - check decoded size too
|
||||
if len(wav_bytes) > MAX_WAV_BYTES:
|
||||
raise HTTPException(status_code=413, detail="WAV data too large (max 200 MB)")
|
||||
|
||||
# M5 - validate WAV magic bytes: "RIFF....WAVE"
|
||||
if len(wav_bytes) < 12 or wav_bytes[:4] != _WAV_RIFF or wav_bytes[8:12] != _WAV_WAVE:
|
||||
raise HTTPException(status_code=422, detail="Not a valid WAV file")
|
||||
|
||||
try:
|
||||
wav_tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_tmp.write(wav_bytes)
|
||||
wav_tmp.close()
|
||||
|
||||
mp3_tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
|
||||
mp3_tmp.close()
|
||||
|
||||
# H5 - register cleanup regardless of success/failure
|
||||
background_tasks.add_task(_rm, wav_tmp.name)
|
||||
background_tasks.add_task(_rm, mp3_tmp.name)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y",
|
||||
"-i", wav_tmp.name,
|
||||
"-codec:a", "libmp3lame",
|
||||
"-q:a", "2",
|
||||
mp3_tmp.name,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("ffmpeg failed: %s", result.stderr.decode(errors="replace")[:500])
|
||||
raise RuntimeError("ffmpeg conversion failed")
|
||||
|
||||
return FileResponse(
|
||||
mp3_tmp.name,
|
||||
media_type="audio/mpeg",
|
||||
filename="export.mp3",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": 'attachment; filename="export.mp3"',
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("export_mp3 failed")
|
||||
raise HTTPException(status_code=500, detail="MP3 export failed. Please try again.")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
/fingering/generate - MusicXML to annotated MusicXML with finger numbers
|
||||
"""
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
MAX_MUSICXML_BYTES = 10 * 1024 * 1024 # 10 MB - large scores are ~500 KB
|
||||
|
||||
|
||||
class FingeringRequest(BaseModel):
|
||||
musicxml: str
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_fingering(req: FingeringRequest):
|
||||
"""Run pianoplayer Parncutt algorithm on MusicXML, returns annotated MusicXML."""
|
||||
if len(req.musicxml.encode()) > MAX_MUSICXML_BYTES:
|
||||
raise HTTPException(status_code=413, detail="MusicXML payload too large (max 10 MB)")
|
||||
try:
|
||||
from services.pianoplayer_svc import annotate_fingering
|
||||
annotated = await annotate_fingering(req.musicxml)
|
||||
return JSONResponse(
|
||||
{"musicxml": annotated},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("generate_fingering failed")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Fingering generation failed. Please try again.",
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
/omr/pdf - PDF sheet music to MusicXML via Audiveris
|
||||
/omr/image - Image (PNG/JPG) sheet music to MusicXML via Audiveris
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# C2 - file size cap
|
||||
MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
# H4 - extension allowlists
|
||||
_PDF_EXTS = {".pdf"}
|
||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".tiff", ".tif"}
|
||||
|
||||
# M5 - magic bytes
|
||||
_PDF_MAGIC = b"%PDF"
|
||||
_PNG_MAGIC = b"\x89PNG"
|
||||
_JPEG_MAGIC = b"\xff\xd8\xff"
|
||||
_TIFF_MAGIC = (b"II*\x00", b"MM\x00*")
|
||||
|
||||
|
||||
async def _read_with_limit(file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES) -> bytes:
|
||||
"""Stream-read upload in 64 KB chunks; reject as soon as total exceeds limit.
|
||||
|
||||
Unlike ``await file.read()`` followed by a size check, this never buffers
|
||||
an arbitrarily large payload into memory (H2: memory-DoS prevention).
|
||||
"""
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(65_536)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=413, detail="Upload too large (max 50 MB)")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _check_ext(filename: str, allowed: set[str]) -> None:
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unsupported file type. Allowed: {', '.join(sorted(allowed))}",
|
||||
)
|
||||
|
||||
|
||||
def _check_pdf_magic(data: bytes) -> None:
|
||||
if not data.startswith(_PDF_MAGIC):
|
||||
raise HTTPException(status_code=422, detail="Not a valid PDF file")
|
||||
|
||||
|
||||
def _check_image_magic(data: bytes, ext: str) -> None:
|
||||
if ext in {".png"} and not data.startswith(_PNG_MAGIC):
|
||||
raise HTTPException(status_code=422, detail="Not a valid PNG file")
|
||||
if ext in {".jpg", ".jpeg"} and not data.startswith(_JPEG_MAGIC):
|
||||
raise HTTPException(status_code=422, detail="Not a valid JPEG file")
|
||||
if ext in {".tiff", ".tif"} and not any(data.startswith(m) for m in _TIFF_MAGIC):
|
||||
raise HTTPException(status_code=422, detail="Not a valid TIFF file")
|
||||
|
||||
|
||||
@router.post("/pdf")
|
||||
async def omr_pdf(file: UploadFile):
|
||||
"""Run OMR on a PDF sheet music file to MusicXML."""
|
||||
try:
|
||||
_check_ext(file.filename or "score.pdf", _PDF_EXTS)
|
||||
data = await _read_with_limit(file)
|
||||
_check_pdf_magic(data)
|
||||
|
||||
from services.audiveris import pdf_to_musicxml
|
||||
musicxml = await pdf_to_musicxml(data, suffix=".pdf")
|
||||
return JSONResponse({"musicxml": musicxml}, headers={"Cache-Control": "no-store"})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("omr_pdf failed")
|
||||
raise HTTPException(status_code=500, detail="OMR processing failed. Please try again.")
|
||||
|
||||
|
||||
@router.post("/image")
|
||||
async def omr_image(file: UploadFile):
|
||||
"""Run OMR on an image (PNG/JPG) of sheet music to MusicXML."""
|
||||
try:
|
||||
filename = file.filename or "image.png"
|
||||
_check_ext(filename, _IMAGE_EXTS)
|
||||
data = await _read_with_limit(file)
|
||||
ext = Path(filename).suffix.lower()
|
||||
_check_image_magic(data, ext)
|
||||
|
||||
from services.audiveris import pdf_to_musicxml
|
||||
musicxml = await pdf_to_musicxml(data, suffix=ext)
|
||||
return JSONResponse({"musicxml": musicxml}, headers={"Cache-Control": "no-store"})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("omr_image failed")
|
||||
raise HTTPException(status_code=500, detail="OMR processing failed. Please try again.")
|
||||
|
||||
|
||||
@router.post("/pdf2midi")
|
||||
async def omr_pdf_to_midi(file: UploadFile):
|
||||
"""PDF sheet music, OMR to MusicXML + MIDI in one round-trip.
|
||||
|
||||
Returns JSON: { musicxml: str, midi_b64: str, filename: str }
|
||||
Both artifacts arrive together so the frontend can render sheet music
|
||||
AND enable playback without a second network request.
|
||||
"""
|
||||
import base64
|
||||
|
||||
try:
|
||||
_check_ext(file.filename or "score.pdf", _PDF_EXTS)
|
||||
data = await _read_with_limit(file)
|
||||
_check_pdf_magic(data)
|
||||
|
||||
from services.audiveris import pdf_to_musicxml_and_midi
|
||||
musicxml, midi_bytes = await pdf_to_musicxml_and_midi(data, suffix=".pdf")
|
||||
midi_b64 = base64.b64encode(midi_bytes).decode("ascii")
|
||||
|
||||
return JSONResponse(
|
||||
{"musicxml": musicxml, "midi_b64": midi_b64, "filename": "score.mid"},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except RuntimeError as exc:
|
||||
# User-safe messages from audiveris.py (no JAR, no output, etc.)
|
||||
logger.warning("omr_pdf_to_midi: %s", exc)
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except Exception:
|
||||
logger.exception("omr_pdf_to_midi failed unexpectedly")
|
||||
raise HTTPException(status_code=500, detail="PDF processing failed. Please try again.")
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
/transcribe/midi2musicxml - MIDI to MusicXML via music21
|
||||
/transcribe/musicxml2midi - MusicXML to MIDI via music21
|
||||
/transcribe/mp3 - MP3 to MIDI via Basic Pitch (Spotify)
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# C2 - file size cap
|
||||
MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
# H4 - extension allowlists
|
||||
_MIDI_EXTS = {".mid", ".midi"}
|
||||
_XML_EXTS = {".xml", ".mxl"}
|
||||
_AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"}
|
||||
|
||||
# M5 - MIDI magic bytes: "MThd"
|
||||
_MIDI_MAGIC = b"MThd"
|
||||
|
||||
|
||||
# ── Shared guards ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _read_with_limit(file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES) -> bytes:
|
||||
"""Stream-read upload in 64 KB chunks; reject as soon as total exceeds limit.
|
||||
|
||||
Unlike ``await file.read()`` followed by a size check, this never buffers
|
||||
an arbitrarily large payload into memory (H2: memory-DoS prevention).
|
||||
"""
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(65_536)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=413, detail="Upload too large (max 50 MB)")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _check_ext(filename: str, allowed: set[str]) -> None:
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unsupported file type. Allowed: {', '.join(sorted(allowed))}",
|
||||
)
|
||||
|
||||
|
||||
def _check_midi_magic(data: bytes) -> None:
|
||||
if not data.startswith(_MIDI_MAGIC):
|
||||
raise HTTPException(status_code=422, detail="Not a valid MIDI file")
|
||||
|
||||
|
||||
def _safe_title(raw: str) -> str:
|
||||
"""M2 - strip control chars and cap length.
|
||||
HTML-escaping for XML embedding is done by music21_svc, not here,
|
||||
to avoid double-escaping (&amp; etc.)."""
|
||||
cleaned = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", raw)
|
||||
return cleaned[:200]
|
||||
|
||||
|
||||
def _rm(path: str) -> None:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/midi2musicxml")
|
||||
async def midi_to_musicxml(file: UploadFile, title: str = Query(default="")):
|
||||
"""Convert a MIDI file to MusicXML for sheet music rendering."""
|
||||
try:
|
||||
_check_ext(file.filename or "track.mid", _MIDI_EXTS)
|
||||
data = await _read_with_limit(file)
|
||||
_check_midi_magic(data)
|
||||
|
||||
from services.music21_svc import midi_bytes_to_musicxml
|
||||
safe_title = _safe_title(title)
|
||||
musicxml = await midi_bytes_to_musicxml(data, title=safe_title)
|
||||
|
||||
# L2 - prevent caching of user data
|
||||
return JSONResponse({"musicxml": musicxml}, headers={"Cache-Control": "no-store"})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("midi_to_musicxml failed")
|
||||
raise HTTPException(status_code=500, detail="Conversion failed. Please try again.")
|
||||
|
||||
|
||||
@router.post("/musicxml2midi")
|
||||
async def musicxml_to_midi(file: UploadFile, background_tasks: BackgroundTasks):
|
||||
"""Convert a MusicXML (.xml or .mxl) file to MIDI for playback."""
|
||||
try:
|
||||
filename = file.filename or "score.xml"
|
||||
_check_ext(filename, _XML_EXTS)
|
||||
data = await _read_with_limit(file)
|
||||
|
||||
from services.music21_svc import musicxml_bytes_to_midi
|
||||
midi_bytes = await musicxml_bytes_to_midi(data, filename)
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False)
|
||||
tmp.write(midi_bytes)
|
||||
tmp.close()
|
||||
background_tasks.add_task(_rm, tmp.name) # H5
|
||||
|
||||
return FileResponse(
|
||||
tmp.name,
|
||||
media_type="audio/midi",
|
||||
filename="score.mid",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": 'attachment; filename="score.mid"',
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("musicxml_to_midi failed")
|
||||
raise HTTPException(status_code=500, detail="Conversion failed. Please try again.")
|
||||
|
||||
|
||||
@router.post("/mp3")
|
||||
async def transcribe_mp3(file: UploadFile, background_tasks: BackgroundTasks):
|
||||
"""Transcribe an MP3/audio file to MIDI using Basic Pitch."""
|
||||
try:
|
||||
_check_ext(file.filename or "audio.mp3", _AUDIO_EXTS)
|
||||
data = await _read_with_limit(file)
|
||||
|
||||
from services.basic_pitch import transcribe_audio_to_midi
|
||||
midi_bytes = await transcribe_audio_to_midi(data, file.filename or "audio.mp3")
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False)
|
||||
tmp.write(midi_bytes)
|
||||
tmp.close()
|
||||
background_tasks.add_task(_rm, tmp.name) # H5
|
||||
|
||||
return FileResponse(
|
||||
tmp.name,
|
||||
media_type="audio/midi",
|
||||
filename="transcribed.mid",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": 'attachment; filename="transcribed.mid"',
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("transcribe_mp3 failed")
|
||||
raise HTTPException(status_code=500, detail="Transcription failed. Please try again.")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
/youtube/extract - Download audio from YouTube URL, transcribe to MIDI
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# C1 - validate video IDs: exactly 11 URL-safe chars
|
||||
_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
|
||||
|
||||
|
||||
class YoutubeExtractRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
@router.post("/extract")
|
||||
async def youtube_extract(req: YoutubeExtractRequest, background_tasks: BackgroundTasks):
|
||||
"""Download best audio from YouTube, run Basic Pitch, return MIDI."""
|
||||
# C1 - extract video ID from user URL, reconstruct a safe canonical URL.
|
||||
# The raw req.url is NEVER forwarded to yt-dlp (SSRF prevention).
|
||||
match = re.search(r"(?:v=|youtu\.be/)([A-Za-z0-9_-]{11})", req.url)
|
||||
if not match or not _VIDEO_ID_RE.match(match.group(1)):
|
||||
raise HTTPException(status_code=422, detail="Invalid or unsupported YouTube URL")
|
||||
|
||||
video_id = match.group(1)
|
||||
safe_url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
try:
|
||||
from services.ytdlp import download_audio
|
||||
from services.basic_pitch import transcribe_audio_to_midi
|
||||
|
||||
audio_path, audio_tmpdir = await download_audio(safe_url)
|
||||
# H5 - register audio dir cleanup immediately so it runs even if
|
||||
# transcription fails (don't wait until after the whole try block).
|
||||
background_tasks.add_task(_rm_dir, audio_tmpdir)
|
||||
|
||||
midi_bytes = await transcribe_audio_to_midi(
|
||||
audio_path.read_bytes(), audio_path.name
|
||||
)
|
||||
|
||||
# H5 - write MIDI to a named temp file; register cleanup in background
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False)
|
||||
tmp.write(midi_bytes)
|
||||
tmp.close()
|
||||
background_tasks.add_task(_rm_file, tmp.name)
|
||||
|
||||
# L2 - no-store so the browser never caches the MIDI file
|
||||
return FileResponse(
|
||||
tmp.name,
|
||||
media_type="audio/midi",
|
||||
filename=f"{video_id}.mid",
|
||||
headers={
|
||||
"X-Video-Id": video_id,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": f'attachment; filename="{video_id}.mid"',
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("youtube_extract failed for video_id=%s", video_id)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Audio extraction failed. Please try again.",
|
||||
)
|
||||
|
||||
|
||||
# ── Cleanup helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def _rm_file(path: str) -> None:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _rm_dir(path: str) -> None:
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove temp directory %s: %s", path, e)
|
||||
Reference in New Issue
Block a user