import os import urllib.parse from fastapi import FastAPI, Request, HTTPException, Query from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.responses import FileResponse from pydantic import BaseModel from typing import List, Dict, Any, Optional from app.config import settings from app.db import init_db, get_recent_tracks, get_tracks_count, get_db from app.queue import sync_manager from app.core.search import search_all from app.core.tagger import get_track_dest_path from app.wave.rotator import wave_rotator from app.wave.playlist import ensure_smart_playlist app = FastAPI(title="Navidrome Music Sync", version="1.0.0") # Mount static and templates BASE_DIR = os.path.dirname(os.path.abspath(__file__)) app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static") templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) class DownloadRequest(BaseModel): url: str @app.on_event("startup") def on_startup(): init_db() try: ensure_smart_playlist() except Exception as e: print(f"Warning: Failed to ensure smart playlist: {e}") @app.get("/") def read_root(request: Request): return templates.TemplateResponse(request=request, name="index.html") @app.get("/api/search") async def search_music(q: str = Query(..., min_length=1)): """Search tracks and albums across Yandex, Apple Music and YouTube.""" results = await search_all(q.strip()) return results @app.post("/api/download") async def start_download(req: DownloadRequest): url = req.url.strip() if not url: raise HTTPException(status_code=400, detail="URL is required") task = await sync_manager.start_sync_task(url) return {"task_id": task.id, "status": task.status} @app.get("/api/direct-download") async def direct_download(url: str = Query(...)): """Download track directly to user's browser/device.""" provider = sync_manager.get_provider(url) tracks = await provider.resolve(url) if not tracks: raise HTTPException(status_code=404, detail="Track not found") track = tracks[0] dest_path = get_track_dest_path( settings.MUSIC_DIR, artist=track.artist, album=track.album or "Singles", track_num=track.track_number, title=track.title, ext="mp3" ) if not os.path.exists(dest_path): success = await provider.download_track(track, dest_path) if not success and track.service == "yandex": success = await sync_manager.universal_provider.download_track(track, dest_path) if not success: raise HTTPException(status_code=500, detail="Failed to download track") filename = f"{track.artist} - {track.title}.mp3" encoded_filename = urllib.parse.quote(filename) return FileResponse( dest_path, media_type="audio/mpeg", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"} ) @app.get("/api/stream") async def stream_audio(url: str = Query(...)): """Stream audio track for in-browser playback and seeking with HTTP Range support.""" if url.startswith("/") and os.path.exists(url) and os.path.realpath(url).startswith(os.path.realpath(settings.MUSIC_DIR)): return FileResponse( url, media_type="audio/mpeg", headers={"Accept-Ranges": "bytes", "Content-Disposition": "inline"} ) provider = sync_manager.get_provider(url) tracks = await provider.resolve(url) if not tracks: raise HTTPException(status_code=404, detail="Track not found") track = tracks[0] dest_path = get_track_dest_path( settings.MUSIC_DIR, artist=track.artist, album=track.album or "Singles", track_num=track.track_number, title=track.title, ext="mp3" ) if not os.path.exists(dest_path): success = await provider.download_track(track, dest_path) if not success and track.service == "yandex": success = await sync_manager.universal_provider.download_track(track, dest_path) if not success: raise HTTPException(status_code=500, detail="Failed to fetch track audio") return FileResponse( dest_path, media_type="audio/mpeg", headers={ "Accept-Ranges": "bytes", "Content-Disposition": "inline" } ) @app.post("/api/sync/yandex-likes") async def sync_yandex_likes(): if not settings.YANDEX_MUSIC_TOKEN: raise HTTPException(status_code=400, detail="Yandex Music Token is not configured.") task = await sync_manager.start_sync_task("yandex:likes") return {"task_id": task.id, "status": task.status} @app.get("/api/tasks") def list_tasks(): return list(sync_manager.tasks.values()) @app.get("/api/tasks/{task_id}") def get_task(task_id: str): if task_id not in sync_manager.tasks: raise HTTPException(status_code=404, detail="Task not found") return sync_manager.tasks[task_id] @app.get("/api/history") def get_history(limit: int = 50): tracks = get_recent_tracks(limit) total = get_tracks_count() return {"total": total, "tracks": tracks} @app.get("/api/health") def healthcheck(): return {"status": "ok", "yandex_configured": bool(settings.YANDEX_MUSIC_TOKEN)} # --- Smart Wave Endpoints --- @app.get("/api/wave/status") def get_wave_status(): """Return status of Smart Wave buffer.""" return wave_rotator.get_status() @app.post("/api/wave/generate") async def generate_wave(): """Discover candidates and fill Smart Wave buffer up to capacity.""" result = await wave_rotator.fill_buffer() return result @app.post("/api/wave/rotate") def rotate_wave(): """Promote starred tracks and purge expired tracks in Smart Wave buffer.""" promoted = wave_rotator.promote_starred_tracks() purged = wave_rotator.purge_expired_tracks() ensure_smart_playlist() return { "promoted_count": len(promoted), "promoted_tracks": promoted, "purged_count": len(purged), "purged_tracks": purged, "current_status": wave_rotator.get_status() }