Initial commit, prod ready
This commit is contained in:
133
backend/main.py
Normal file
133
backend/main.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
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}"'},
|
||||
)
|
||||
Reference in New Issue
Block a user