Files
navidrome-music-sync/app/main.py
T
Antigravity e5afeb06cc feat: Phase 3 AI Radio (DJ Host & interleaved playlist) and Like/Dislike/Delete actions
- Add Like button: promotes wave track to permanent library and refills buffer
- Add Dislike button: blacklists track in SQLite disliked_tracks, removes audio, refills buffer
- Add Delete button in history table for library file removal
- Add AI Radio module: wttr.in weather, Qwen 2.5 host monologues, speech synthesis, ID3 tags
- Generate interleaved 'AI Радио.m3u8' alongside clean 'Моя Волна.m3u8'
- Update Web UI with Radio DJ card, voice selection, and track actions
2026-10-12 16:34:46 +03:00

337 lines
12 KiB
Python

import os
import asyncio
import urllib.parse
from fastapi import FastAPI, Request, HTTPException, Query, Form, Depends, status
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import FileResponse, RedirectResponse
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, remove_downloaded_track
from app.sync_queue import sync_manager
from app.core.search import search_all
from app.core.tagger import get_track_dest_path
from app.core.auth import (
verify_credentials,
create_session_token,
get_authenticated_user,
require_auth,
COOKIE_NAME
)
from app.wave.rotator import wave_rotator
from app.wave.playlist import ensure_smart_playlist
from app.radio import radio_manager
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
class WaveGenerateRequest(BaseModel):
mood: Optional[str] = "auto"
class WaveActionRequest(BaseModel):
filename: str
class RadioGenerateRequest(BaseModel):
voice: Optional[str] = "ru-RU-DmitryNeural"
async def _wave_auto_rotation_loop():
"""Background loop to periodically rotate and refill Smart Wave buffer."""
while True:
try:
# Sleep interval in hours
await asyncio.sleep(settings.WAVE_AUTO_ROTATE_HOURS * 3600)
print(f"[AutoRotate] Running scheduled Wave rotation & refill (every {settings.WAVE_AUTO_ROTATE_HOURS}h)...")
await wave_rotator.fill_buffer(mood="auto")
except asyncio.CancelledError:
break
except Exception as e:
print(f"[AutoRotate] Error during scheduled rotation: {e}")
await asyncio.sleep(300)
@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}")
# Launch background auto-rotation task
if settings.WAVE_AUTO_ROTATE_HOURS > 0:
asyncio.create_task(_wave_auto_rotation_loop())
def get_prefix(request: Request) -> str:
"""Return URL prefix when hosted behind reverse proxy."""
prefix = request.headers.get("x-forwarded-prefix", "")
if prefix:
return prefix.rstrip("/")
if "music.qpid.pro" in request.headers.get("host", ""):
return "/sync"
return ""
# --- Authentication Routes ---
@app.get("/login")
def login_page(request: Request):
if get_authenticated_user(request):
return RedirectResponse(url=f"{get_prefix(request)}/", status_code=303)
return templates.TemplateResponse(request=request, name="login.html")
@app.post("/login")
def handle_login(request: Request, username: str = Form(...), password: str = Form(...)):
user = username.strip()
pwd = password.strip()
if verify_credentials(user, pwd):
resp = RedirectResponse(url=f"{get_prefix(request)}/", status_code=303)
token = create_session_token(user)
resp.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=30 * 86400,
httponly=True,
samesite="lax",
path="/"
)
return resp
return templates.TemplateResponse(
request=request,
name="login.html",
context={"error": "Неверное имя пользователя или пароль Navidrome"}
)
@app.get("/logout")
@app.post("/logout")
def handle_logout(request: Request):
resp = RedirectResponse(url=f"{get_prefix(request)}/login", status_code=303)
resp.delete_cookie(COOKIE_NAME, path="/")
return resp
# --- Protected Views & API Endpoints ---
@app.get("/")
def read_root(request: Request):
user = get_authenticated_user(request)
if not user:
return RedirectResponse(url=f"{get_prefix(request)}/login", status_code=303)
return templates.TemplateResponse(request=request, name="index.html", context={"user": user})
@app.get("/api/search")
async def search_music(q: str = Query(..., min_length=1), user: str = Depends(require_auth)):
"""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, user: str = Depends(require_auth)):
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(...), user: str = Depends(require_auth)):
"""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 fetch track audio")
filename = f"{track.artist} - {track.title}.mp3"
quoted_filename = urllib.parse.quote(filename)
return FileResponse(
dest_path,
media_type="audio/mpeg",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{quoted_filename}"
}
)
@app.get("/api/stream")
async def stream_audio(request: Request, url: str = Query(...)):
"""Stream audio track for in-browser playback and seeking with HTTP Range support."""
# Ensure user has access
user = get_authenticated_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
# Local file preview (e.g. Wave buffer tracks)
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(user: str = Depends(require_auth)):
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(user: str = Depends(require_auth)):
return list(sync_manager.tasks.values())
@app.get("/api/tasks/{task_id}")
def get_task(task_id: str, user: str = Depends(require_auth)):
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, user: str = Depends(require_auth)):
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),
"auth_enabled": settings.AUTH_ENABLED
}
# --- Smart Wave Endpoints (Phase 2) ---
@app.get("/api/wave/status")
def get_wave_status(user: str = Depends(require_auth)):
"""Return status of Smart Wave buffer."""
return wave_rotator.get_status()
@app.post("/api/wave/generate")
async def generate_wave(req: Optional[WaveGenerateRequest] = None, user: str = Depends(require_auth)):
"""Discover candidates and fill Smart Wave buffer up to capacity using LLM reranker."""
mood = req.mood if req else "auto"
result = await wave_rotator.fill_buffer(mood=mood)
return result
@app.post("/api/wave/rotate")
def rotate_wave(user: str = Depends(require_auth)):
"""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()
}
@app.post("/api/wave/dislike")
async def dislike_wave_track(req: WaveActionRequest, user: str = Depends(require_auth)):
"""Dislike track: remove from wave, blacklist from recommendations, refill slot."""
try:
res = await wave_rotator.dislike_track(req.filename)
return res
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Трек не найден в буфере Волны")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/wave/like")
async def like_wave_track(req: WaveActionRequest, user: str = Depends(require_auth)):
"""Like track: promote to permanent library, refill slot."""
try:
res = await wave_rotator.like_track(req.filename)
return res
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Трек не найден в буфере Волны")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/api/track")
def delete_track(file_path: str = Query(...), user: str = Depends(require_auth)):
"""Delete any track from library/disk and remove from download history."""
real_path = os.path.realpath(file_path)
music_real = os.path.realpath(settings.MUSIC_DIR)
if not real_path.startswith(music_real):
raise HTTPException(status_code=400, detail="Недопустимый путь к файлу")
if not os.path.exists(real_path):
raise HTTPException(status_code=404, detail="Файл трека не найден на диске")
try:
os.remove(real_path)
lrc_path = os.path.splitext(real_path)[0] + ".lrc"
if os.path.exists(lrc_path):
os.remove(lrc_path)
remove_downloaded_track(file_path)
ensure_smart_playlist()
return {"status": "deleted", "file_path": file_path}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка удаления: {e}")
# --- AI Radio DJ Endpoints (Phase 3) ---
@app.get("/api/radio/status")
def get_radio_status(user: str = Depends(require_auth)):
"""Return status of AI Radio host and broadcast playlist."""
return radio_manager.get_radio_status()
@app.post("/api/radio/generate")
async def generate_radio_broadcast(req: Optional[RadioGenerateRequest] = None, user: str = Depends(require_auth)):
"""Generate AI DJ speech intros and assemble the interleaved 'AI Радио.m3u8' playlist."""
voice = req.voice if req else settings.RADIO_VOICE
res = await radio_manager.generate_radio_broadcast(voice=voice)
return res