Add song library with Git LFS, DJ chat, and tinqs/live-radio publish path.

Songs persist under songs/ (MP3 via LFS, metadata in git). Player shows saved library.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-07 14:18:17 +01:00
parent 4924db5617
commit b8ff25f370
18 changed files with 357 additions and 12 deletions
+4 -1
View File
@@ -25,8 +25,11 @@ class Config:
@classmethod
def from_env(cls) -> Config:
output = Path(os.getenv("RADIO_OUTPUT_DIR", "./radio_cache"))
output = Path(os.getenv("RADIO_OUTPUT_DIR", "./songs"))
output.mkdir(parents=True, exist_ok=True)
from ozan_radio.library import migrate_legacy_cache
migrate_legacy_cache(output)
return cls(
gemini_api_key=os.getenv("GEMINI_API_KEY", ""),
deepseek_base_url=os.getenv(
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from ozan_radio.lyria import GeneratedTrack
def migrate_legacy_cache(output_dir: Path) -> None:
"""Move radio_cache/ → songs/ on first run if needed."""
legacy = output_dir.parent / "radio_cache"
if legacy.exists() and legacy.resolve() != output_dir.resolve():
output_dir.mkdir(parents=True, exist_ok=True)
for item in legacy.iterdir():
dest = output_dir / item.name
if not dest.exists():
item.rename(dest)
manifest = output_dir / "manifest.json"
if manifest.exists():
try:
data = json.loads(manifest.read_text(encoding="utf-8"))
for entry in data.get("tracks", []):
meta_path = output_dir / f"{entry['id']}.meta.json"
if meta_path.exists():
continue
audio = output_dir / entry.get("file", "")
if not audio.exists():
continue
meta_path.write_text(
json.dumps(
{
"id": entry["id"],
"title": entry.get("title", ""),
"mood": entry.get("mood", ""),
"dj_line": entry.get("dj_line", ""),
"lyria_prompt": entry.get("lyria_prompt", ""),
"lyrics": entry.get("lyrics", ""),
"file": entry.get("file", audio.name),
"saved_at": datetime.fromtimestamp(
audio.stat().st_mtime, tz=timezone.utc
).isoformat(),
},
indent=2,
),
encoding="utf-8",
)
except (json.JSONDecodeError, OSError, KeyError):
pass
def save_track_record(track: GeneratedTrack, output_dir: Path) -> Path:
"""Write per-track metadata JSON alongside the MP3."""
meta_path = output_dir / f"{track.plan.id}.meta.json"
payload = {
"id": track.plan.id,
"title": track.plan.title,
"mood": track.plan.mood,
"dj_line": track.plan.dj_line,
"lyria_prompt": track.plan.lyria_prompt,
"lyrics": track.lyrics,
"file": track.audio_path.name,
"saved_at": datetime.now(timezone.utc).isoformat(),
}
meta_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return meta_path
def load_track_from_meta(meta_path: Path, output_dir: Path) -> dict | None:
try:
data = json.loads(meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
audio = output_dir / data.get("file", "")
if not audio.exists():
return None
return {
**data,
"audio_url": f"/stream/{audio.name}",
"size_bytes": audio.stat().st_size,
}
def list_saved_songs(output_dir: Path) -> list[dict]:
"""All persisted tracks, newest first."""
songs: list[dict] = []
seen: set[str] = set()
for meta_path in sorted(output_dir.glob("*.meta.json"), reverse=True):
entry = load_track_from_meta(meta_path, output_dir)
if entry and entry["id"] not in seen:
seen.add(entry["id"])
songs.append(entry)
# MP3s without metadata (legacy)
for mp3 in sorted(output_dir.glob("*.mp3"), reverse=True):
stem = mp3.stem
track_id = stem.split("_", 1)[0] if "_" in stem else stem[:8]
if track_id in seen:
continue
title = stem.split("_", 1)[1].replace("_", " ") if "_" in stem else stem
songs.append(
{
"id": track_id,
"title": title,
"mood": "",
"dj_line": "",
"file": mp3.name,
"audio_url": f"/stream/{mp3.name}",
"size_bytes": mp3.stat().st_size,
"saved_at": datetime.fromtimestamp(mp3.stat().st_mtime, tz=timezone.utc).isoformat(),
}
)
songs.sort(key=lambda s: s.get("saved_at", ""), reverse=True)
return songs
+10
View File
@@ -5,6 +5,7 @@ from dataclasses import asdict, dataclass
from pathlib import Path
from ozan_radio.dj import TrackPlan
from ozan_radio.library import save_track_record
from ozan_radio.lyria import GeneratedTrack
@@ -96,9 +97,18 @@ class RadioQueue:
return len(self._tracks)
def add(self, track: GeneratedTrack) -> None:
save_track_record(track, self._output_dir)
self._tracks.append(track)
self._save_manifest()
def play_id(self, track_id: str) -> GeneratedTrack | None:
for i, track in enumerate(self._tracks):
if track.plan.id == track_id:
self._index = i
self._save_manifest()
return track
return None
@property
def recent_titles(self) -> list[str]:
return [t.plan.title for t in self._tracks]
+38
View File
@@ -14,6 +14,7 @@ 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.library import list_saved_songs
from ozan_radio.taste import load_taste_seeds
app = FastAPI(title="Live Ozan Radio", version="0.1.0")
@@ -86,6 +87,7 @@ async def root() -> dict:
"generate": "POST /api/generate",
"chat": "POST /api/chat",
"chat_log": "/api/chat",
"songs": "/api/songs",
"player": "/player",
},
}
@@ -154,6 +156,42 @@ async def chat_with_dj(body: ChatRequest, background: BackgroundTasks) -> dict:
return result
@app.get("/api/songs")
async def saved_songs() -> dict:
cfg = _get_config()
songs = list_saved_songs(cfg.output_dir)
return {"count": len(songs), "songs": songs, "folder": str(cfg.output_dir)}
@app.post("/api/songs/{track_id}/play")
async def play_saved(track_id: str) -> dict:
q = _get_queue()
track = q.play_id(track_id)
if not track:
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),
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(track_id)
np = q.now_playing()
return {"status": "ok", "track": np.__dict__ if np else None}
raise HTTPException(404, "Song not found")
np = q.now_playing()
return {"status": "ok", "track": np.__dict__ if np else None}
@app.post("/api/skip")
async def skip_track() -> dict:
q = _get_queue()