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:
@@ -8,7 +8,7 @@ description: Operate Live Ozan Radio — DeepSeek DJ plans tracks, Google Lyria
|
|||||||
## When to use
|
## When to use
|
||||||
|
|
||||||
- User says "ozan radio", "live radio", "generate a track", "what's playing"
|
- User says "ozan radio", "live radio", "generate a track", "what's playing"
|
||||||
- Operating or debugging `tinqs/live-ozan-radio`
|
- Operating or debugging `tinqs/live-radio`
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ SPOTIFY_REFRESH_TOKEN=
|
|||||||
# Radio server
|
# Radio server
|
||||||
RADIO_HOST=127.0.0.1
|
RADIO_HOST=127.0.0.1
|
||||||
RADIO_PORT=8787
|
RADIO_PORT=8787
|
||||||
RADIO_OUTPUT_DIR=./radio_cache
|
RADIO_OUTPUT_DIR=./songs
|
||||||
|
|
||||||
# Generation defaults
|
# Generation defaults
|
||||||
LYRIA_MODEL=lyria-3-pro-preview
|
LYRIA_MODEL=lyria-3-pro-preview
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[attr]lfs filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|
||||||
|
# Generated song audio — Git LFS on tinqs.com
|
||||||
|
*.mp3 lfs
|
||||||
|
*.wav lfs
|
||||||
|
|
||||||
|
* text=auto
|
||||||
+1
-3
@@ -10,10 +10,8 @@ dist/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
# Generated audio (cache — regenerate on demand)
|
# Legacy cache (migrated to songs/)
|
||||||
radio_cache/
|
radio_cache/
|
||||||
*.mp3
|
|
||||||
*.wav
|
|
||||||
|
|
||||||
# IDE / OS
|
# IDE / OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# AGENTS.md — Live Ozan Radio
|
# AGENTS.md — Live Ozan Radio
|
||||||
|
|
||||||
Public demo repo under `tinqs/live-ozan-radio`. AI agents run the station — humans listen.
|
Public repo under `tinqs/live-radio`. AI agents run the station — humans listen.
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Read `AGENTS.md` and `README.md` first.
|
|||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
Ozan's personal AI radio. DeepSeek DJ + Google Lyria 3. Public repo on `tinqs/live-ozan-radio`.
|
Ozan's personal AI radio. DeepSeek DJ + Google Lyria 3. Public repo on `tinqs/live-radio`.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ Inspired by [Magenta RealTime 2](https://magenta.withgoogle.com/magenta-realtime
|
|||||||
| Player | FastAPI + `gateway/player.html` | Stream generated queue |
|
| Player | FastAPI + `gateway/player.html` | Stream generated queue |
|
||||||
| Live (optional) | Magenta RealTime 2 | Apple Silicon only — see below |
|
| Live (optional) | Magenta RealTime 2 | Apple Silicon only — see below |
|
||||||
|
|
||||||
|
## Saved songs
|
||||||
|
|
||||||
|
Every track is written to `./songs/` and **committed via Git LFS** (audio) + plain git (metadata):
|
||||||
|
|
||||||
|
| File | Storage | Contents |
|
||||||
|
|------|---------|----------|
|
||||||
|
| `{id}_{title}.mp3` | LFS | Audio |
|
||||||
|
| `{id}.meta.json` | git | Title, mood, DJ line, prompt, lyrics, timestamp |
|
||||||
|
|
||||||
|
Browse in the player under **Saved songs**, or `GET /api/songs`. After clone: `git lfs install` then `git lfs pull`.
|
||||||
|
|
||||||
## Quick start (Forge / Windows)
|
## Quick start (Forge / Windows)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -80,19 +91,20 @@ Wire MRT2 as a bridge between tracks or as a live “bed” under the Lyria queu
|
|||||||
|
|
||||||
1. On Git Studio: **+ → New Repository**
|
1. On Git Studio: **+ → New Repository**
|
||||||
- Owner: `tinqs`
|
- Owner: `tinqs`
|
||||||
- Name: `live-ozan-radio`
|
- Name: `live-radio`
|
||||||
- Visibility: **Public**
|
- Visibility: **Public**
|
||||||
2. Push:
|
2. Push (with LFS for song MP3s):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git lfs install
|
||||||
git init
|
git init
|
||||||
git remote add origin git@ssh.tinqs.com:tinqs/live-ozan-radio.git
|
git remote add origin git@ssh.tinqs.com:tinqs/live-radio.git
|
||||||
git add .
|
git add .
|
||||||
git commit -m "Live Ozan Radio — DeepSeek DJ + Lyria 3"
|
git commit -m "Live Ozan Radio — DeepSeek DJ + Lyria 3"
|
||||||
git push -u origin main
|
git push -u origin main
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Preview the player: `https://tinqs.com/tinqs/live-ozan-radio/src/branch/main/gateway/player.html` (static shell; audio streams from your running server).
|
3. Preview the player: `https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/player.html` (static shell; audio streams from your running server).
|
||||||
|
|
||||||
## Agent usage
|
## Agent usage
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,44 @@
|
|||||||
min-width: 72px;
|
min-width: 72px;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
}
|
}
|
||||||
|
.library {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
border-top: 1px solid #2a2a3a;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
.library h2 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.library-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
max-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.song-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
background: #1a1a28;
|
||||||
|
border: 1px solid #2a2a3a;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.song-row:hover { border-color: var(--accent); }
|
||||||
|
.song-row.playing { border-color: var(--accent); background: rgba(255,107,53,0.08); }
|
||||||
|
.song-meta { color: var(--muted); font-size: 0.72rem; }
|
||||||
|
.library-empty { color: var(--muted); font-size: 0.8rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -232,6 +270,13 @@
|
|||||||
|
|
||||||
<div class="status" id="status">Connecting…</div>
|
<div class="status" id="status">Connecting…</div>
|
||||||
|
|
||||||
|
<section class="library">
|
||||||
|
<h2>Saved songs</h2>
|
||||||
|
<div class="library-list" id="libraryList">
|
||||||
|
<div class="library-empty">Loading library…</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="chat">
|
<section class="chat">
|
||||||
<h2>Talk to the DJ</h2>
|
<h2>Talk to the DJ</h2>
|
||||||
<div class="chat-log" id="chatLog"></div>
|
<div class="chat-log" id="chatLog"></div>
|
||||||
@@ -261,6 +306,48 @@
|
|||||||
const chatForm = document.getElementById('chatForm');
|
const chatForm = document.getElementById('chatForm');
|
||||||
const chatInput = document.getElementById('chatInput');
|
const chatInput = document.getElementById('chatInput');
|
||||||
const chatSend = document.getElementById('chatSend');
|
const chatSend = document.getElementById('chatSend');
|
||||||
|
const libraryList = document.getElementById('libraryList');
|
||||||
|
let currentTrackId = null;
|
||||||
|
|
||||||
|
async function loadLibrary() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/songs`);
|
||||||
|
const data = await res.json();
|
||||||
|
libraryList.innerHTML = '';
|
||||||
|
if (!data.songs || data.songs.length === 0) {
|
||||||
|
libraryList.innerHTML = '<div class="library-empty">No saved songs yet — generate one!</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const song of data.songs) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'song-row' + (song.id === currentTrackId ? ' playing' : '');
|
||||||
|
const sizeMb = song.size_bytes ? (song.size_bytes / 1e6).toFixed(1) + ' MB' : '';
|
||||||
|
btn.innerHTML = `<span>${song.title}</span><span class="song-meta">${sizeMb}</span>`;
|
||||||
|
btn.addEventListener('click', () => playSong(song.id));
|
||||||
|
libraryList.appendChild(btn);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
libraryList.innerHTML = '<div class="library-empty">Library offline</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playSong(trackId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/songs/${trackId}/play`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'ok' && data.track) {
|
||||||
|
currentTrackId = data.track.track_id;
|
||||||
|
titleEl.textContent = data.track.title;
|
||||||
|
moodEl.textContent = data.track.mood ? `Mood: ${data.track.mood}` : '';
|
||||||
|
djEl.textContent = data.track.dj_line ? `"${data.track.dj_line}"` : '';
|
||||||
|
player.src = `${API}${data.track.audio_url}`;
|
||||||
|
player.play().catch(() => {});
|
||||||
|
statusEl.textContent = `Playing · ${data.track.track_id}`;
|
||||||
|
loadLibrary();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
function addBubble(role, text) {
|
function addBubble(role, text) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
@@ -329,6 +416,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const t = data.track;
|
const t = data.track;
|
||||||
|
currentTrackId = t.track_id;
|
||||||
titleEl.textContent = t.title;
|
titleEl.textContent = t.title;
|
||||||
moodEl.textContent = t.mood ? `Mood: ${t.mood}` : '';
|
moodEl.textContent = t.mood ? `Mood: ${t.mood}` : '';
|
||||||
djEl.textContent = t.dj_line ? `"${t.dj_line}"` : '';
|
djEl.textContent = t.dj_line ? `"${t.dj_line}"` : '';
|
||||||
@@ -352,6 +440,7 @@
|
|||||||
statusEl.textContent = data.message;
|
statusEl.textContent = data.message;
|
||||||
} else if (data.status === 'ok') {
|
} else if (data.status === 'ok') {
|
||||||
await refreshNow();
|
await refreshNow();
|
||||||
|
await loadLibrary();
|
||||||
} else {
|
} else {
|
||||||
statusEl.textContent = 'Generation failed — check server logs';
|
statusEl.textContent = 'Generation failed — check server logs';
|
||||||
}
|
}
|
||||||
@@ -385,6 +474,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
refreshNow();
|
refreshNow();
|
||||||
|
loadLibrary();
|
||||||
loadChat();
|
loadChat();
|
||||||
setInterval(refreshNow, 15000);
|
setInterval(refreshNow, 15000);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
|
Before
After
|
@@ -0,0 +1,18 @@
|
|||||||
|
# Song library
|
||||||
|
|
||||||
|
Every generated track is saved here:
|
||||||
|
|
||||||
|
| File | Git | Contents |
|
||||||
|
|------|-----|----------|
|
||||||
|
| `{id}_{title}.mp3` | **LFS** | Audio (~2 MB per track) |
|
||||||
|
| `{id}.meta.json` | plain git | Title, mood, DJ line, Lyria prompt, lyrics, timestamp |
|
||||||
|
| `manifest.json` | plain git | Queue index |
|
||||||
|
|
||||||
|
**Clone with LFS:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git lfs install
|
||||||
|
git clone git@ssh.tinqs.com:tinqs/live-radio.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Friends get the same saved songs after pull. New generations are committed the same way — `*.mp3` via LFS, metadata as normal files.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "b51b1cb2",
|
||||||
|
"title": "Sahara Moon",
|
||||||
|
"mood": "",
|
||||||
|
"dj_line": "Restored from cache.",
|
||||||
|
"lyria_prompt": "",
|
||||||
|
"lyrics": "",
|
||||||
|
"file": "b51b1cb2_Sahara_Moon.mp3",
|
||||||
|
"saved_at": "2026-06-07T13:04:14.135971+00:00"
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "b548d6a6",
|
||||||
|
"title": "Dune Chant",
|
||||||
|
"mood": "rolling desert pulse with call-and-response",
|
||||||
|
"dj_line": "Sahel breeze right through the speakers \u2014 let the sand dance.",
|
||||||
|
"lyria_prompt": "A 1-2 minute instrumental track in the style of Sahelian desert blues. Acoustic guitar (kora-like fingerpicking), hand percussion (djembe and calabash), and a warm bass line. Slow-medium tempo (90 BPM), spiritual yet danceable energy. Call-and-response vocal samples woven into the texture, evoking griot traditions. Build from sparse to full, then ease out. No sharp transitions, keep a rolling, hypnotic groove.",
|
||||||
|
"lyrics": "[[A0]]\n[[B1]]\n[16.0:] Ah-lay-la, ay-oh... (ay-oh)\n[:] Ah-lay-la, ay-oh... (ay-oh)\n[:] The spirits wake in the morning light,\n[:] Moving through the sand,\n[:] (Moving through the sand).\n[[C2]]\n[48.0:] Oh-lay-ka, ho-lay-ka!\n[:] Oh-lay-ka, ho-lay-ka!\n[:] Feel the heartbeat in the soil!\n[:] Feel the heartbeat in the soil!\n[:] From the river to the dune!\n[:] We are chanting with the sun!\n[:] We are chanting with the sun!\n[[D3]]\n[80.0:] (Mmm-hmmm...)\n[:] (Aaaa-la-ma...)\n[:] (Aaaa-la-ma...)",
|
||||||
|
"file": "b548d6a6_Dune_Chant.mp3",
|
||||||
|
"saved_at": "2026-06-07T13:06:11.955590+00:00"
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"count": 2,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "b51b1cb2",
|
||||||
|
"title": "Sahara Moon",
|
||||||
|
"mood": "",
|
||||||
|
"dj_line": "Restored from cache.",
|
||||||
|
"lyria_prompt": "",
|
||||||
|
"lyrics": "",
|
||||||
|
"file": "b51b1cb2_Sahara_Moon.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b548d6a6",
|
||||||
|
"title": "Dune Chant",
|
||||||
|
"mood": "rolling desert pulse with call-and-response",
|
||||||
|
"dj_line": "Sahel breeze right through the speakers \u2014 let the sand dance.",
|
||||||
|
"lyria_prompt": "A 1-2 minute instrumental track in the style of Sahelian desert blues. Acoustic guitar (kora-like fingerpicking), hand percussion (djembe and calabash), and a warm bass line. Slow-medium tempo (90 BPM), spiritual yet danceable energy. Call-and-response vocal samples woven into the texture, evoking griot traditions. Build from sparse to full, then ease out. No sharp transitions, keep a rolling, hypnotic groove.",
|
||||||
|
"lyrics": "[[A0]]\n[[B1]]\n[16.0:] Ah-lay-la, ay-oh... (ay-oh)\n[:] Ah-lay-la, ay-oh... (ay-oh)\n[:] The spirits wake in the morning light,\n[:] Moving through the sand,\n[:] (Moving through the sand).\n[[C2]]\n[48.0:] Oh-lay-ka, ho-lay-ka!\n[:] Oh-lay-ka, ho-lay-ka!\n[:] Feel the heartbeat in the soil!\n[:] Feel the heartbeat in the soil!\n[:] From the river to the dune!\n[:] We are chanting with the sun!\n[:] We are chanting with the sun!\n[[D3]]\n[80.0:] (Mmm-hmmm...)\n[:] (Aaaa-la-ma...)\n[:] (Aaaa-la-ma...)",
|
||||||
|
"file": "b548d6a6_Dune_Chant.mp3"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -25,8 +25,11 @@ class Config:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> Config:
|
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)
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
from ozan_radio.library import migrate_legacy_cache
|
||||||
|
|
||||||
|
migrate_legacy_cache(output)
|
||||||
return cls(
|
return cls(
|
||||||
gemini_api_key=os.getenv("GEMINI_API_KEY", ""),
|
gemini_api_key=os.getenv("GEMINI_API_KEY", ""),
|
||||||
deepseek_base_url=os.getenv(
|
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 pathlib import Path
|
||||||
|
|
||||||
from ozan_radio.dj import TrackPlan
|
from ozan_radio.dj import TrackPlan
|
||||||
|
from ozan_radio.library import save_track_record
|
||||||
from ozan_radio.lyria import GeneratedTrack
|
from ozan_radio.lyria import GeneratedTrack
|
||||||
|
|
||||||
|
|
||||||
@@ -96,9 +97,18 @@ class RadioQueue:
|
|||||||
return len(self._tracks)
|
return len(self._tracks)
|
||||||
|
|
||||||
def add(self, track: GeneratedTrack) -> None:
|
def add(self, track: GeneratedTrack) -> None:
|
||||||
|
save_track_record(track, self._output_dir)
|
||||||
self._tracks.append(track)
|
self._tracks.append(track)
|
||||||
self._save_manifest()
|
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
|
@property
|
||||||
def recent_titles(self) -> list[str]:
|
def recent_titles(self) -> list[str]:
|
||||||
return [t.plan.title for t in self._tracks]
|
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.queue import RadioQueue
|
||||||
from ozan_radio.spotify import SpotifyTaste
|
from ozan_radio.spotify import SpotifyTaste
|
||||||
from ozan_radio.chat_store import ChatStore
|
from ozan_radio.chat_store import ChatStore
|
||||||
|
from ozan_radio.library import list_saved_songs
|
||||||
from ozan_radio.taste import load_taste_seeds
|
from ozan_radio.taste import load_taste_seeds
|
||||||
|
|
||||||
app = FastAPI(title="Live Ozan Radio", version="0.1.0")
|
app = FastAPI(title="Live Ozan Radio", version="0.1.0")
|
||||||
@@ -86,6 +87,7 @@ async def root() -> dict:
|
|||||||
"generate": "POST /api/generate",
|
"generate": "POST /api/generate",
|
||||||
"chat": "POST /api/chat",
|
"chat": "POST /api/chat",
|
||||||
"chat_log": "/api/chat",
|
"chat_log": "/api/chat",
|
||||||
|
"songs": "/api/songs",
|
||||||
"player": "/player",
|
"player": "/player",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -154,6 +156,42 @@ async def chat_with_dj(body: ChatRequest, background: BackgroundTasks) -> dict:
|
|||||||
return result
|
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")
|
@app.post("/api/skip")
|
||||||
async def skip_track() -> dict:
|
async def skip_track() -> dict:
|
||||||
q = _get_queue()
|
q = _get_queue()
|
||||||
|
|||||||
Reference in New Issue
Block a user