""" 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}"'}, )