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:
+144
-7
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user