Add shuffle dashboard with cost tracking and daily generation limits.

Player settings panel, stats API, and README document how saved and new tracks mix under a per-day Lyria cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-07 14:22:39 +01:00
parent feb8731366
commit 41bb4d6b29
10 changed files with 803 additions and 66 deletions
+144 -7
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import random
from pathlib import Path
from fastapi import BackgroundTasks, FastAPI, HTTPException
@@ -14,7 +15,11 @@ 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.dj import TrackPlan
from ozan_radio.library import list_saved_songs
from ozan_radio.lyria import GeneratedTrack
from ozan_radio.radio_settings import load_radio_settings, save_radio_settings_patch
from ozan_radio.stats import cost_per_track, record_generation, today_stats
from ozan_radio.taste import load_taste_seeds
app = FastAPI(title="Live Ozan Radio", version="0.1.0")
@@ -35,7 +40,54 @@ class ChatRequest(BaseModel):
message: str = Field(min_length=1, max_length=500)
async def _compose_track(request: str | None = None) -> dict:
class SettingsPatch(BaseModel):
playback: dict | None = None
limits: dict | None = None
costs: dict | None = None
def _can_generate_today(cfg: Config) -> tuple[bool, dict]:
rs = load_radio_settings(lyria_model=cfg.lyria_model)
stats = today_stats(cfg.output_dir)
remaining = rs.limits.max_new_songs_per_day - stats["generated"]
return remaining > 0, {
**stats,
"max_per_day": rs.limits.max_new_songs_per_day,
"remaining": max(0, remaining),
}
def _dashboard_stats(cfg: Config) -> dict:
rs = load_radio_settings(lyria_model=cfg.lyria_model)
budget = _can_generate_today(cfg)[1]
per_track = rs.per_track_cost()
return {
"today": budget,
"costs": rs.costs.__dict__ | {"per_track_estimate_usd": per_track},
"playback": rs.playback.__dict__,
"projected_daily_max_usd": round(per_track * rs.limits.max_new_songs_per_day, 2),
}
def _play_library_entry(q: RadioQueue, cfg: Config, entry: dict) -> dict:
track = q.play_id(entry["id"])
if not track:
plan = TrackPlan(
id=entry["id"],
title=entry.get("title", entry["id"]),
mood=entry.get("mood", ""),
dj_line=entry.get("dj_line", ""),
lyria_prompt=entry.get("lyria_prompt", ""),
)
path = cfg.output_dir / entry["file"]
restored = GeneratedTrack(plan=plan, audio_path=path, lyrics=entry.get("lyrics", ""))
q.add(restored)
q.play_id(entry["id"])
np = q.now_playing()
return {"status": "ok", "source": "library", "track": np.__dict__ if np else None}
async def _compose_track(request: str | None = None, *, check_limit: bool = True) -> dict:
global _generating
if _generating:
return {"status": "busy", "message": "Already generating a track"}
@@ -43,6 +95,14 @@ async def _compose_track(request: str | None = None) -> dict:
_generating = True
try:
cfg = _get_config()
if check_limit:
ok, budget = _can_generate_today(cfg)
if not ok:
return {
"status": "limit",
"message": f"Daily limit reached ({budget['max_per_day']} new songs)",
"budget": budget,
}
taste = await SpotifyTaste(cfg).fetch_taste()
seeds = None if taste else load_taste_seeds()
q = _get_queue()
@@ -52,11 +112,18 @@ async def _compose_track(request: str | None = None) -> dict:
)
track = LyriaEngine(cfg).generate(plan)
q.add(track)
rs = load_radio_settings(lyria_model=cfg.lyria_model)
cost = cost_per_track(cfg.lyria_model, rs.costs.__dict__)
record_generation(cfg.output_dir, cost, track.plan.id, track.plan.title)
np = q.now_playing()
_, budget = _can_generate_today(cfg)
return {
"status": "ok",
"source": "generated",
"track": np.__dict__ if np else None,
"taste_used": taste.summary if taste else (seeds.summary if seeds else None),
"cost_usd": cost,
"budget": budget,
}
finally:
_generating = False
@@ -88,6 +155,9 @@ async def root() -> dict:
"chat": "POST /api/chat",
"chat_log": "/api/chat",
"songs": "/api/songs",
"stats": "/api/stats",
"settings": "/api/settings",
"shuffle": "POST /api/shuffle/next",
"player": "/player",
},
}
@@ -118,11 +188,73 @@ async def stream_track(filename: str) -> FileResponse:
return FileResponse(path, media_type="audio/mpeg")
@app.get("/api/stats")
async def dashboard_stats() -> dict:
cfg = _get_config()
return _dashboard_stats(cfg)
@app.get("/api/settings")
async def get_settings() -> dict:
cfg = _get_config()
rs = load_radio_settings(lyria_model=cfg.lyria_model)
return rs.to_public_dict() | {"budget": _can_generate_today(cfg)[1]}
@app.patch("/api/settings")
async def patch_settings(body: SettingsPatch) -> dict:
patch = body.model_dump(exclude_none=True)
updated = save_radio_settings_patch(patch)
cfg = _get_config()
return updated | {"budget": _can_generate_today(cfg)[1]}
@app.post("/api/generate")
async def generate_track() -> dict:
return await _compose_track()
@app.post("/api/shuffle/next")
async def shuffle_next() -> dict:
cfg = _get_config()
rs = load_radio_settings(lyria_model=cfg.lyria_model)
q = _get_queue()
songs = list_saved_songs(cfg.output_dir)
can_new, budget = _can_generate_today(cfg)
want_new = False
if rs.playback.shuffle and rs.playback.mix_existing_and_new and can_new and songs:
want_new = random.random() < rs.playback.new_song_chance
elif rs.playback.shuffle and not songs and can_new:
want_new = True
if want_new:
result = await _compose_track()
if result.get("status") == "ok":
q.shuffle_to_id(result["track"]["track_id"]) if result.get("track") else None
return result
if songs:
current_id = None
np = q.now_playing()
if np:
current_id = np.track_id
pool = [s for s in songs if s["id"] != current_id] or songs
pick = random.choice(pool)
result = _play_library_entry(q, cfg, pick)
result["budget"] = budget
return result
if can_new:
return await _compose_track()
return {
"status": "limit",
"message": "No saved songs and daily generation limit reached",
"budget": budget,
}
@app.get("/api/chat")
async def chat_log() -> dict:
return {"messages": _chat.public_log()}
@@ -150,8 +282,12 @@ async def chat_with_dj(body: ChatRequest, background: BackgroundTasks) -> dict:
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)
ok, _ = _can_generate_today(cfg)
if ok:
result["generating"] = True
background.add_task(_compose_track, reply.vibe_hint or None)
else:
result["reply"] += " (Daily new-song limit reached — playing saved tracks only.)"
return result
@@ -171,9 +307,6 @@ async def play_saved(track_id: str) -> dict:
cfg = _get_config()
for entry in list_saved_songs(cfg.output_dir):
if entry["id"] == track_id:
from ozan_radio.dj import TrackPlan
from ozan_radio.lyria import GeneratedTrack
plan = TrackPlan(
id=entry["id"],
title=entry.get("title", track_id),
@@ -194,12 +327,16 @@ async def play_saved(track_id: str) -> dict:
@app.post("/api/skip")
async def skip_track() -> dict:
cfg = _get_config()
rs = load_radio_settings(lyria_model=cfg.lyria_model)
if rs.playback.shuffle:
return await shuffle_next()
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}
return {"status": "ok", "track": np.__dict__ if np else None, "source": "queue"}
@app.get("/player", response_class=HTMLResponse)