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