Files
music-orchestrator/backend/main.py
2026-02-22 23:18:57 -05:00

134 lines
4.8 KiB
Python

"""
Music Orchestrator — FastAPI backend
"""
from typing import Any
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
import search as music_search
import youtube
import library as lib
# In production, nginx sits in front and serves the frontend statically,
# proxying /api/* to this process on 127.0.0.1:8000. CORS is not needed
# because all requests appear same-origin to the browser.
# In dev, Vite's proxy does the same job. No CORS middleware required.
app = FastAPI(title="Music Orchestrator", docs_url=None, redoc_url=None)
# ── Request / Response models ─────────────────────────────────────────────────
class DownloadRequest(BaseModel):
videoId: str
trackName: str
artistName: str
albumName: str
trackNumber: int | None = None
year: str = ""
genre: str = ""
artworkUrl: str = ""
# ── Music search ──────────────────────────────────────────────────────────────
@app.get("/api/search/music")
async def search_music(q: str = Query(..., min_length=1)) -> list[dict[str, Any]]:
"""Search iTunes for song metadata."""
try:
return await music_search.search_music(q)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"iTunes search failed: {exc}")
# ── YouTube search ────────────────────────────────────────────────────────────
@app.get("/api/search/youtube")
async def search_youtube(q: str = Query(..., min_length=1)) -> list[dict[str, Any]]:
"""Search YouTube and return top 5 results (no download)."""
try:
return await youtube.search_youtube(q)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"YouTube search failed: {exc}")
# ── Download ──────────────────────────────────────────────────────────────────
@app.post("/api/download")
async def start_download(req: DownloadRequest) -> dict[str, str]:
"""
Start an async download + tag job. Returns a job_id to poll.
Metadata comes from iTunes (req body), not from the YouTube video.
"""
job_id = await youtube.start_download(
video_id=req.videoId,
artist=req.artistName,
album=req.albumName,
track_name=req.trackName,
track_number=req.trackNumber,
year=req.year,
genre=req.genre,
artwork_url=req.artworkUrl,
)
return {"jobId": job_id}
@app.get("/api/download/{job_id}/status")
def download_status(job_id: str) -> dict[str, Any]:
"""Poll download job status. Returns { status, progress, filename, error }."""
job = youtube.get_job_status(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found.")
return {
"status": job["status"],
"progress": job["progress"],
"filename": job.get("filename"),
"error": job.get("error"),
}
# ── Library ───────────────────────────────────────────────────────────────────
@app.get("/api/library")
def get_library() -> list[dict[str, Any]]:
"""Return the full nested artist → album → tracks structure."""
return lib.get_library()
@app.delete("/api/library")
def delete_library_item(path: str = Query(...)) -> dict[str, str]:
"""
Delete a file or folder inside the music library.
`path` is relative to MUSIC_DIR, e.g. 'Artist/Album/01 - Song.mp3'
"""
try:
lib.delete_path(path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return {"deleted": path}
@app.get("/api/library/download")
def download_track(path: str = Query(...)) -> FileResponse:
"""
Stream a file from the music library to the browser as a download.
`path` is relative to MUSIC_DIR.
"""
try:
abs_path = lib.resolve_track_path(path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return FileResponse(
path=str(abs_path),
media_type="audio/mpeg",
filename=abs_path.name,
headers={"Content-Disposition": f'attachment; filename="{abs_path.name}"'},
)