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:
2026-06-07 14:15:42 +01:00
commit 4924db5617
19 changed files with 1551 additions and 0 deletions
+188
View File
@@ -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