134 lines
4.8 KiB
Python
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}"'},
|
|
)
|