Add Live Ozan Radio — DeepSeek DJ, Lyria 3, and player chat.
Personal AI station that generates tracks from taste seeds or Spotify; safe to share — secrets and cache are gitignored. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
|
||||
from ozan_radio.config import Config
|
||||
from ozan_radio.dj import DeepSeekDJ
|
||||
from ozan_radio.lyria import LyriaEngine
|
||||
from ozan_radio.queue import RadioQueue
|
||||
from ozan_radio.spotify import SpotifyTaste
|
||||
from ozan_radio.chat_store import ChatStore
|
||||
from ozan_radio.taste import load_taste_seeds
|
||||
|
||||
app = FastAPI(title="Live Ozan Radio", version="0.1.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
_config: Config | None = None
|
||||
_queue: RadioQueue | None = None
|
||||
_generating = False
|
||||
_chat = ChatStore()
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str = Field(min_length=1, max_length=500)
|
||||
|
||||
|
||||
async def _compose_track(request: str | None = None) -> dict:
|
||||
global _generating
|
||||
if _generating:
|
||||
return {"status": "busy", "message": "Already generating a track"}
|
||||
|
||||
_generating = True
|
||||
try:
|
||||
cfg = _get_config()
|
||||
taste = await SpotifyTaste(cfg).fetch_taste()
|
||||
seeds = None if taste else load_taste_seeds()
|
||||
q = _get_queue()
|
||||
vibe = request or _chat.take_vibe_hint()
|
||||
plan = await DeepSeekDJ(cfg).plan_next(
|
||||
taste, q.recent_titles, seeds, request=vibe or None
|
||||
)
|
||||
track = LyriaEngine(cfg).generate(plan)
|
||||
q.add(track)
|
||||
np = q.now_playing()
|
||||
return {
|
||||
"status": "ok",
|
||||
"track": np.__dict__ if np else None,
|
||||
"taste_used": taste.summary if taste else (seeds.summary if seeds else None),
|
||||
}
|
||||
finally:
|
||||
_generating = False
|
||||
|
||||
|
||||
def _get_config() -> Config:
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = Config.from_env()
|
||||
return _config
|
||||
|
||||
|
||||
def _get_queue() -> RadioQueue:
|
||||
global _queue
|
||||
if _queue is None:
|
||||
_queue = RadioQueue(_get_config().output_dir)
|
||||
return _queue
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> dict:
|
||||
return {
|
||||
"station": "Live Ozan Radio",
|
||||
"tagline": "No catalog. No repeats. DeepSeek DJ + Google Lyria 3.",
|
||||
"endpoints": {
|
||||
"now": "/api/now",
|
||||
"queue": "/api/queue",
|
||||
"generate": "POST /api/generate",
|
||||
"chat": "POST /api/chat",
|
||||
"chat_log": "/api/chat",
|
||||
"player": "/player",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/now")
|
||||
async def now_playing() -> dict:
|
||||
q = _get_queue()
|
||||
np = q.now_playing()
|
||||
if not np:
|
||||
return {"status": "idle", "message": "Queue empty — POST /api/generate to start"}
|
||||
return {"status": "playing", "track": np.__dict__}
|
||||
|
||||
|
||||
@app.get("/api/queue")
|
||||
async def queue_status() -> dict:
|
||||
return _get_queue().to_dict()
|
||||
|
||||
|
||||
@app.get("/stream/{filename}")
|
||||
async def stream_track(filename: str) -> FileResponse:
|
||||
cfg = _get_config()
|
||||
path = (cfg.output_dir / filename).resolve()
|
||||
if not str(path).startswith(str(cfg.output_dir.resolve())):
|
||||
raise HTTPException(403, "Invalid path")
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Track not found")
|
||||
return FileResponse(path, media_type="audio/mpeg")
|
||||
|
||||
|
||||
@app.post("/api/generate")
|
||||
async def generate_track() -> dict:
|
||||
return await _compose_track()
|
||||
|
||||
|
||||
@app.get("/api/chat")
|
||||
async def chat_log() -> dict:
|
||||
return {"messages": _chat.public_log()}
|
||||
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def chat_with_dj(body: ChatRequest, background: BackgroundTasks) -> dict:
|
||||
cfg = _get_config()
|
||||
q = _get_queue()
|
||||
np = q.now_playing()
|
||||
now = f"{np.title} — {np.mood}" if np else None
|
||||
|
||||
reply = await DeepSeekDJ(cfg).chat(body.message, _chat.history(), now_playing=now)
|
||||
_chat.add("user", body.message)
|
||||
_chat.add("dj", reply.reply)
|
||||
|
||||
result = {
|
||||
"status": "ok",
|
||||
"reply": reply.reply,
|
||||
"action": reply.action,
|
||||
"generating": False,
|
||||
}
|
||||
|
||||
if reply.action == "generate":
|
||||
if reply.vibe_hint:
|
||||
_chat.pending_vibe = reply.vibe_hint
|
||||
if not _generating:
|
||||
result["generating"] = True
|
||||
background.add_task(_compose_track, reply.vibe_hint or None)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/skip")
|
||||
async def skip_track() -> dict:
|
||||
q = _get_queue()
|
||||
track = q.advance()
|
||||
if not track:
|
||||
return {"status": "idle"}
|
||||
np = q.now_playing()
|
||||
return {"status": "ok", "track": np.__dict__ if np else None}
|
||||
|
||||
|
||||
@app.get("/player", response_class=HTMLResponse)
|
||||
async def player_page() -> HTMLResponse:
|
||||
gateway = Path(__file__).resolve().parents[2] / "gateway" / "player.html"
|
||||
if gateway.exists():
|
||||
return HTMLResponse(gateway.read_text(encoding="utf-8"))
|
||||
return HTMLResponse("<h1>Live Ozan Radio</h1><p>gateway/player.html missing</p>")
|
||||
|
||||
|
||||
async def _autofill_queue(target: int = 2) -> None:
|
||||
"""Background: keep a small buffer of generated tracks."""
|
||||
while True:
|
||||
q = _get_queue()
|
||||
if q.length < target and not _generating:
|
||||
try:
|
||||
await generate_track()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
return app
|
||||
Reference in New Issue
Block a user