diff --git a/README.md b/README.md index c6c8b3b..6eca91c 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,21 @@ At the default cap of 10 new songs/day with Lyria Pro, projected max spend is ** | GET | `/api/songs` | Saved library | | POST | `/api/songs/{id}/play` | Play a saved track | | GET | `/stream/{file}` | MP3 stream | -| GET | `/player` | Web UI | +| GET | `/player` | Full DJ dashboard | +| GET | `/winamp` | Winamp-style HTML player (streams `/stream/{file}` locally) | + +## Winamp player (pure HTML) + +Lightweight **Winamp 2.x–style** skin — no React, no API required for playback. Shuffles saved songs from the embedded playlist. + +| Where | URL | +|-------|-----| +| **Local** (with server) | **http://127.0.0.1:8787/winamp** — streams via `/stream/{file}` | +| **Public** (tinqs.com LFS) | **https://tinqs.com/tinqs/live-radio/src/branch/main/gateway/winamp.html** | + +Controls: play/pause, stop, prev/next, shuffle, volume, clickable playlist. Same `/*__PLAYLIST__*/` embed as `gateway/index.html` — updated by `python -m ozan_radio export-web` or automatically when the server saves a track. + +Full dashboard (`/player`) still needed for DJ chat, Lyria compose, and settings. ## Magenta RealTime 2 (optional live layer) @@ -172,7 +186,7 @@ After generating new tracks locally, refresh the public page: ```powershell python -m ozan_radio export-web -git add gateway/index.html gateway/playlist.json +git add gateway/index.html gateway/winamp.html gateway/playlist.json git commit -m "Update public radio playlist" git push ``` diff --git a/gateway/index.html b/gateway/index.html index 562c556..fefe1fa 100644 --- a/gateway/index.html +++ b/gateway/index.html @@ -143,7 +143,7 @@ description: Auto-play AI radio — desert dub and Anadolu psych. Streams saved + + diff --git a/songs/74646b3e.meta.json b/songs/74646b3e.meta.json new file mode 100644 index 0000000..71f8b8d --- /dev/null +++ b/songs/74646b3e.meta.json @@ -0,0 +1,25 @@ +{ + "id": "74646b3e", + "title": "Echoes of the Sahel", + "mood": "warm, dusty, spacious, hypnotic", + "dj_line": "From the desert edge to the dub chamber \u2014 here's a late-night caravan drenched in spring reverb.", + "lyria_prompt": "Downtempo dubtronica, 90 BPM, D minor. Kora melody with tape echo, melodica delay, sub bass stepper, darbuka and hand percussion, warm analog synths, spacious arrangement, wordless male baritone hum layer, no electric guitar. Sparse, hypnotic, desert warmth.", + "lyrics": "[[A0]]\n[[B1]]\n[16.0:] Huuuuummmmmm...\n[:] (Huuuuummmmmm...)\n[:] Oooooohhhhhhh...\n[:] (Oooooohhhhhhh...)\n[:] Haaaaaahhhhhhh...\n[[C2]]\n[48.0:] Hmmmmmmm-aaaaaahhhhhh!\n[:] Hmmmmmmm-ooooooooh!\n[:] Hmmmmmmm-ehhhhhhh!\n[[B3]]\n[80.0:] (Huh-huh-huh...)\n[:] (Huh-huh-huh...)\n[:] Shhhhhhh-khhhhhh...\n[:] (Huh-huh-huh...)\n[[C4]]\n[112.0:] Hmmmmmmm-aaaaaahhhhhh!\n[:] Hmmmmmmm-ooooooooh!\n[:] Hmmmmmmm-ehhhhhhh!\n[[D5]]\n[144.0:] Mmmmmmmmmm...\n[:] Hmmmmmmm...", + "file": "74646b3e_Echoes_of_the_Sahel.mp3", + "saved_at": "2026-06-07T15:37:46.860278+00:00", + "generation": {}, + "structure": {}, + "instruments_detected": [], + "tags": [], + "curation": { + "rating": "unrated", + "shuffle_weight": 1.0, + "public_playlist": true, + "listener": "ozan", + "notes": "", + "loved": [], + "disliked": [], + "avoid_in_successors": [], + "clone_prompt_hints": "" + } +} \ No newline at end of file diff --git a/songs/74646b3e_Echoes_of_the_Sahel.mp3 b/songs/74646b3e_Echoes_of_the_Sahel.mp3 new file mode 100644 index 0000000..f1cead6 --- /dev/null +++ b/songs/74646b3e_Echoes_of_the_Sahel.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6f070040179933de4ff1de5743ac5ed7234805ceb66c64ff4e959c13eb87bd5 +size 3980868 diff --git a/songs/manifest.json b/songs/manifest.json index 8ade0db..904a0da 100644 --- a/songs/manifest.json +++ b/songs/manifest.json @@ -1,6 +1,6 @@ { - "index": 0, - "count": 7, + "index": 7, + "count": 8, "tracks": [ { "id": "82595273", @@ -64,6 +64,15 @@ "lyria_prompt": "90 BPM D minor. Sparse, cold cinematic gothic dubtronica. Start with a distant, breathy Icelandic female vocal floating over a minimal piano motif and sub bass pulse. No saz or ney. Add spring reverb on piano, tape delay on vocals. At 0:25, a slow, hypnotic darbuka pattern enters with a warm, sub-heavy bassline. Maintain spaciousness throughout \u2014 wide stereo field, analog warmth. Avoid any electric guitar, fuzz, or desert blues elements. Build slowly, never exceeding a meditative energy. Vocals: ethereal, wordless textures and sparse phrases in a Nordic language (fictional or real). End with piano and sub bass fading into delay trails.", "lyrics": "[[A0]]\n[0.0:] \u00cdsfj\u00f6ll, bl\u00e1svart haf,\n[:] (bl\u00e1svart haf)\n[:] draumar \u00ed dvala.\n[:] (\u00ed dvala)\n[[B1]]\n[32.0:] Ah-ee-ah...\n[[A2]]\n[53.3:] Hlj\u00f3\u00f0ar raddir,\n[:] kaldur andi,\n[:] kaldur andi.\n[[B3]]\n[85.3:] Mmm-ohm...\n[:] Ahh...\n[[C4]]\n[[A5]]\n[117.3:] Dj\u00fapi\u00f0 kallar,\n[:] svefninn er n\u00e6r,\n[:] svefninn er n\u00e6r.\n[[D6]]\n[149.3:] (Mmm-mmm...)", "file": "71cfdfea_Frostbite_Dub.mp3" + }, + { + "id": "74646b3e", + "title": "Echoes of the Sahel", + "mood": "warm, dusty, spacious, hypnotic", + "dj_line": "From the desert edge to the dub chamber \u2014 here's a late-night caravan drenched in spring reverb.", + "lyria_prompt": "Downtempo dubtronica, 90 BPM, D minor. Kora melody with tape echo, melodica delay, sub bass stepper, darbuka and hand percussion, warm analog synths, spacious arrangement, wordless male baritone hum layer, no electric guitar. Sparse, hypnotic, desert warmth.", + "lyrics": "[[A0]]\n[[B1]]\n[16.0:] Huuuuummmmmm...\n[:] (Huuuuummmmmm...)\n[:] Oooooohhhhhhh...\n[:] (Oooooohhhhhhh...)\n[:] Haaaaaahhhhhhh...\n[[C2]]\n[48.0:] Hmmmmmmm-aaaaaahhhhhh!\n[:] Hmmmmmmm-ooooooooh!\n[:] Hmmmmmmm-ehhhhhhh!\n[[B3]]\n[80.0:] (Huh-huh-huh...)\n[:] (Huh-huh-huh...)\n[:] Shhhhhhh-khhhhhh...\n[:] (Huh-huh-huh...)\n[[C4]]\n[112.0:] Hmmmmmmm-aaaaaahhhhhh!\n[:] Hmmmmmmm-ooooooooh!\n[:] Hmmmmmmm-ehhhhhhh!\n[[D5]]\n[144.0:] Mmmmmmmmmm...\n[:] Hmmmmmmm...", + "file": "74646b3e_Echoes_of_the_Sahel.mp3" } ] } \ No newline at end of file diff --git a/src/ozan_radio/dj.py b/src/ozan_radio/dj.py index f0b4575..cde8c6b 100644 --- a/src/ozan_radio/dj.py +++ b/src/ozan_radio/dj.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re import uuid from dataclasses import dataclass @@ -41,28 +42,47 @@ Your job: 6. Stay in the listener's lane unless they ask for something else in chat. 7. If the request references a saved track ("more like Sahara's Saz", "add vocals to X"), compose a fresh successor — same palette, new title. Never imply remixing an MP3. -8. For vocals Lyria accepts well: "wordless vocal textures", "Sahel blues male chants", - "melodic call-and-response". Avoid "vocal sample" / "griot sample" phrasing. +8. Lyria-safe vocal wording only: "wordless vocal textures", "warm male hum", + "melodic call-and-response", "low overtone hum layer". Never use griot, throat + singing, kargyraa, cultural sample, or named ethnic vocal styles in lyria_prompt. 9. Read listener curation metadata — clone what they loved, hard-avoid what they disliked (e.g. fuzz electric guitar on vocal tracks, long guitar-only intros before saz). 10. Station mission: TECHNO-ETHNIC — beautiful electronica meets world dub. Big inspirations: Bonobo (lush melodic downtempo), Jon Hopkins Singularity, Jamaica - dub (spring reverb, bass culture), Sahara/Sahel warmth, Mongolian overtone throat - textures (wordless ethereal layer), Urdu poetic vocal colour. Blend many cultures + dub (spring reverb, bass culture), desert warmth, wordless low overtone hum, + poetic wordless male vocal colour. Blend many cultures in one gorgeous track — Jamaica + Sahara + Mongolia + Urdu + synth beauty. -11. NOT Turkish folk, NOT slow country homages, NOT Indian pop. Clone keepers: +11. NOT Turkish folk — never write Anatolian, Ottoman, Turkish, or bağlama in + lyria_prompt (use plucked lute, breathy flute). NOT slow country homages, + NOT Indian pop. Clone keepers: ceremonial-dub (Chac's Dub), gregorian-ether (Frostbite Dub). Forward tempo 88+ BPM. -Respond with JSON only: +Respond with JSON only — valid JSON, no markdown fences: { "title": "short track title", "mood": "one-line mood", "dj_line": "what you'd say on air (1 sentence)", - "lyria_prompt": "detailed prompt for Lyria 3 Pro — structure, instruments, tempo, energy" + "lyria_prompt": "single-line prompt for Lyria 3 Pro — structure, instruments, tempo, energy; escape any double quotes inside this string" } """ +def parse_llm_json(text: str) -> dict: + """Parse DeepSeek JSON; strip fences and tolerate a wrapped object.""" + raw = text.strip() + if raw.startswith("```"): + raw = re.sub(r"^```(?:json)?\s*", "", raw, flags=re.IGNORECASE) + raw = re.sub(r"\s*```\s*$", "", raw) + try: + return json.loads(raw) + except json.JSONDecodeError: + start = raw.find("{") + end = raw.rfind("}") + if start >= 0 and end > start: + return json.loads(raw[start : end + 1]) + raise + + @dataclass class TrackPlan: id: str @@ -117,6 +137,23 @@ class DeepSeekDJ: resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] + async def _completion_json(self, messages: list[dict]) -> dict: + text = await self._completion(messages, json_mode=True) + try: + return parse_llm_json(text) + except json.JSONDecodeError: + repair = messages + [ + {"role": "assistant", "content": text}, + { + "role": "user", + "content": ( + "That was invalid JSON. Reply with ONLY a valid JSON object. " + "Keep lyria_prompt on one line; escape double quotes inside strings." + ), + }, + ] + return parse_llm_json(await self._completion(repair, json_mode=True)) + async def chat( self, message: str, @@ -135,7 +172,7 @@ class DeepSeekDJ: messages.append({"role": role, "content": turn["content"]}) messages.append({"role": "user", "content": "\n".join(context)}) - data = json.loads(await self._completion(messages, json_mode=True)) + data = await self._completion_json(messages) return ChatReply( reply=data.get("reply", "Copy that — stay tuned."), action=data.get("action", "none"), @@ -165,7 +202,7 @@ class DeepSeekDJ: {"role": "system", "content": DJ_SYSTEM}, {"role": "user", "content": "\n".join(user_parts)}, ] - data = json.loads(await self._completion(messages, json_mode=True)) + data = await self._completion_json(messages) return TrackPlan( id=str(uuid.uuid4())[:8], title=data.get("title", "Untitled Transmission"), diff --git a/src/ozan_radio/lyria.py b/src/ozan_radio/lyria.py index 0b6484d..5911caf 100644 --- a/src/ozan_radio/lyria.py +++ b/src/ozan_radio/lyria.py @@ -112,7 +112,7 @@ def _build_lyria_prompt(plan: TrackPlan, lyria_cfg) -> str: parts.append("Instrumental only, no vocals. High fidelity, stereo, radio-ready mix.") elif mode == "mix": parts.append( - "Wordless vocal textures, Sahel-style chants, and whispered layers are welcome. " + "Wordless vocal textures, warm hums, and whispered layers are welcome. " "No full lead lyrics unless already specified above. " "High fidelity, stereo, radio-ready mix." ) @@ -133,14 +133,30 @@ def _sanitize_for_retry(prompt: str) -> str: """Soften wording that sometimes trips Lyria safety filters.""" softened = prompt replacements = { - "griot vocal sample": "Sahel blues vocal texture", - "griot-style": "Sahel storytelling vocal style", - "West African griot": "Sahel blues male vocal", - "griot": "Sahel blues vocal", + "Mongolian overtone throat singing": "wordless low overtone hum layer", + "mongolian overtone throat": "wordless low overtone hum", + "overtone throat singing": "wordless low overtone hum", + "throat singing": "wordless low overtone hum", + "kargyraa": "deep wordless hum", + "griot vocal sample": "warm wordless male vocal texture", + "griot-style": "warm storytelling vocal texture", + "West African griot": "warm wordless male vocal", + "griot": "warm wordless vocal", + "Urdu male vocal": "poetic wordless male vocal colour", + "Urdu vocal": "poetic wordless vocal colour", "vocal sample": "wordless vocal texture", + "cultural sample": "organic texture sample", + "Anatolian saz": "plucked lute melody", + "Anatolian": "desert", + "Turkish": "desert", + "Ottoman": "ceremonial", + "bağlama": "plucked lute", + "baglama": "plucked lute", } for old, new in replacements.items(): softened = softened.replace(old, new) + softened = softened.replace(old.lower(), new) + softened = softened.replace(old.title(), new.title() if new else new) if _wants_vocals(softened): softened += ( "\n\nVocals should be melodic chants or wordless textures — " @@ -149,6 +165,33 @@ def _sanitize_for_retry(prompt: str) -> str: return softened +def _instrumental_fallback(prompt: str) -> str: + """Last-resort prompt when Lyria blocks vocal or cultural wording.""" + lines = [ + ln + for ln in prompt.splitlines() + if ln.strip() + and not any( + x in ln.lower() + for x in ( + "vocal", + "singer", + "chant", + "lyrics", + "hum layer", + "baritone", + "wordless", + ) + ) + ] + base = "\n".join(lines) if lines else prompt.splitlines()[0] + return ( + f"{base}\n\n" + "Instrumental only, no vocals. Lush Bonobo-style electronica with dub spring " + "reverb, sub bass, and organic percussion. High fidelity, stereo, radio-ready mix." + ) + + def _empty_response_message(response) -> str: block = _block_reason(response) if block and block != "BLOCK_REASON_UNSPECIFIED": @@ -191,7 +234,15 @@ class LyriaEngine: lyria_cfg = load_lyria_settings() model = lyria_cfg.model or self._config.lyria_model - prompt = _build_lyria_prompt(plan, lyria_cfg) + plan_prompt = _sanitize_for_retry(plan.lyria_prompt) + plan = TrackPlan( + id=plan.id, + title=plan.title, + mood=plan.mood, + dj_line=plan.dj_line, + lyria_prompt=plan_prompt, + ) + prompt = _sanitize_for_retry(_build_lyria_prompt(plan, lyria_cfg)) try: response = self._call_lyria( @@ -204,8 +255,20 @@ class LyriaEngine: audio_bytes = _extract_audio(parts) if not audio_bytes: - retry_prompt = _sanitize_for_retry(prompt) - if retry_prompt != prompt: + built = _build_lyria_prompt(plan, lyria_cfg) + tried = {prompt} + for retry_prompt in ( + _sanitize_for_retry(built), + ( + f"{_sanitize_for_retry(built)}\n\n" + "Soft instrumental bed with brief wordless hum accents only. " + "No lyrics, speech, or named cultural vocal styles." + ), + _instrumental_fallback(built), + ): + if not retry_prompt or retry_prompt in tried: + continue + tried.add(retry_prompt) try: response = self._call_lyria( retry_prompt, model=model, output_format=lyria_cfg.output_format @@ -214,6 +277,8 @@ class LyriaEngine: audio_bytes = _extract_audio(parts) except (genai_errors.ClientError, genai_errors.ServerError) as exc: raise RuntimeError(friendly_lyria_error(exc)) from exc + if audio_bytes: + break if not audio_bytes: raise RuntimeError(_empty_response_message(response)) diff --git a/src/ozan_radio/server.py b/src/ozan_radio/server.py index 24c1b4f..b053686 100644 --- a/src/ozan_radio/server.py +++ b/src/ozan_radio/server.py @@ -223,6 +223,7 @@ async def root() -> dict: "lyria": "/api/lyria", "shuffle": "POST /api/shuffle/next", "player": "/player", + "winamp": "/winamp", }, } @@ -481,6 +482,14 @@ async def player_page() -> HTMLResponse: return HTMLResponse("

Live Ozan Radio

gateway/player.html missing

") +@app.get("/winamp", response_class=HTMLResponse) +async def winamp_page() -> HTMLResponse: + gateway = Path(__file__).resolve().parents[2] / "gateway" / "winamp.html" + if gateway.exists(): + return HTMLResponse(gateway.read_text(encoding="utf-8")) + return HTMLResponse("

Live Ozan Radio

gateway/winamp.html missing

") + + async def _background_compose(request: str | None = None) -> None: """Chat-triggered generation — never raises; errors land in _generation_state.""" result = await _compose_track(request) diff --git a/src/ozan_radio/web_playlist.py b/src/ozan_radio/web_playlist.py index 6479990..36fc680 100644 --- a/src/ozan_radio/web_playlist.py +++ b/src/ozan_radio/web_playlist.py @@ -39,14 +39,28 @@ def build_playlist_payload(songs_dir: Path) -> dict: tracks.reverse() return { "station": "Live Ozan Radio", - "tagline": "No catalog. AI-composed desert dub + Anadolu psych.", + "tagline": "Techno-ethnic AI radio — no catalog tracks.", "media_base": MEDIA_BASE, "share_url": f"https://tinqs.com/{REPO_SLUG}/src/branch/main/gateway/index.html", + "winamp_url": f"https://tinqs.com/{REPO_SLUG}/src/branch/main/gateway/winamp.html", "updated": datetime.now(timezone.utc).isoformat(), "tracks": tracks, } +def embed_playlist_in_html(html: str, payload: dict) -> str: + """Replace embedded /*__PLAYLIST__*/ JSON block in a gateway HTML file.""" + if PLAYLIST_MARKER not in html: + return html + replacement = json.dumps(payload, ensure_ascii=False) + return re.sub( + rf"{re.escape(PLAYLIST_MARKER)}[\s\S]*?{re.escape(PLAYLIST_MARKER)}", + f"{PLAYLIST_MARKER}\n{replacement}\n{PLAYLIST_MARKER}", + html, + count=1, + ) + + def export_gateway_playlist(repo_root: Path | None = None) -> Path | None: root = repo_root or Path(__file__).resolve().parents[2] songs_dir = root / "songs" @@ -60,16 +74,13 @@ def export_gateway_playlist(repo_root: Path | None = None) -> Path | None: payload = build_playlist_payload(songs_dir) playlist_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") - html = index_path.read_text(encoding="utf-8") - if PLAYLIST_MARKER not in html: - return playlist_path + for html_name in ("index.html", "winamp.html"): + html_path = gateway / html_name + if not html_path.exists(): + continue + html = html_path.read_text(encoding="utf-8") + if PLAYLIST_MARKER not in html: + continue + html_path.write_text(embed_playlist_in_html(html, payload), encoding="utf-8") - replacement = json.dumps(payload, ensure_ascii=False) - html = re.sub( - rf"{re.escape(PLAYLIST_MARKER)}[\s\S]*?{re.escape(PLAYLIST_MARKER)}", - f"{PLAYLIST_MARKER}\n{replacement}\n{PLAYLIST_MARKER}", - html, - count=1, - ) - index_path.write_text(html, encoding="utf-8") return playlist_path diff --git a/tests/test_dj.py b/tests/test_dj.py new file mode 100644 index 0000000..15516ea --- /dev/null +++ b/tests/test_dj.py @@ -0,0 +1,25 @@ +import json + +import pytest + +from ozan_radio.dj import parse_llm_json + + +def test_parse_llm_json_plain(): + data = parse_llm_json('{"title": "Test", "lyria_prompt": "one line"}') + assert data["title"] == "Test" + + +def test_parse_llm_json_fenced(): + raw = '```json\n{"title": "Fenced"}\n```' + assert parse_llm_json(raw)["title"] == "Fenced" + + +def test_parse_llm_json_wrapped_text(): + raw = 'Here is the plan:\n{"title": "Wrapped", "mood": "calm"}\nThanks.' + assert parse_llm_json(raw)["title"] == "Wrapped" + + +def test_parse_llm_json_invalid_raises(): + with pytest.raises(json.JSONDecodeError): + parse_llm_json("not json at all") diff --git a/tests/test_lyria.py b/tests/test_lyria.py index 869e97c..31510b2 100644 --- a/tests/test_lyria.py +++ b/tests/test_lyria.py @@ -79,10 +79,32 @@ def test_build_lyria_prompt_hindi_language(sample_plan): def test_sanitize_for_retry_softens_griot(): raw = "West African griot vocal sample over dub" soft = _sanitize_for_retry(raw) - assert "griot" not in soft.lower() or "Sahel" in soft + assert "griot" not in soft.lower() assert soft != raw +def test_sanitize_for_retry_softens_throat_singing(): + raw = "Mongolian overtone throat singing over dub bass" + soft = _sanitize_for_retry(raw) + assert "throat singing" not in soft.lower() + assert "wordless" in soft.lower() + + +def test_sanitize_for_retry_softens_anatolian(): + raw = "Anatolian saz and ney with dub delay" + soft = _sanitize_for_retry(raw) + assert "anatolian" not in soft.lower() + + +def test_instrumental_fallback_strips_vocals(): + from ozan_radio.lyria import _instrumental_fallback + + raw = "88 BPM dub.\nWordless male baritone hum layer.\nSub bass pulse." + out = _instrumental_fallback(raw) + assert "baritone" not in out.lower() + assert "Instrumental only" in out + + def test_response_parts_from_candidates(): part = SimpleNamespace(text=None, inline_data=SimpleNamespace(data=b"audio")) content = SimpleNamespace(parts=[part]) diff --git a/tests/test_server.py b/tests/test_server.py index 9368605..e0c6591 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -16,6 +16,7 @@ def client(tmp_path: Path, monkeypatch): gateway = tmp_path / "gateway" gateway.mkdir() (gateway / "index.html").write_text("", encoding="utf-8") + (gateway / "winamp.html").write_text("Winamp", encoding="utf-8") (tmp_path / "vocal_cues.json").write_text( json.dumps( { @@ -88,6 +89,13 @@ def test_root_lists_lyria_endpoint(client: TestClient): r = client.get("/") assert r.status_code == 200 assert r.json()["endpoints"]["lyria"] == "/api/lyria" + assert r.json()["endpoints"]["winamp"] == "/winamp" + + +def test_winamp_page(client: TestClient): + r = client.get("/winamp") + assert r.status_code == 200 + assert "Winamp" in r.text def test_lyria_capabilities_without_api_key(client: TestClient): diff --git a/tests/test_web_playlist.py b/tests/test_web_playlist.py index b6be761..b446b13 100644 --- a/tests/test_web_playlist.py +++ b/tests/test_web_playlist.py @@ -3,7 +3,12 @@ from __future__ import annotations import json from pathlib import Path -from ozan_radio.web_playlist import PLAYLIST_MARKER, build_playlist_payload, export_gateway_playlist +from ozan_radio.web_playlist import ( + PLAYLIST_MARKER, + build_playlist_payload, + embed_playlist_in_html, + export_gateway_playlist, +) def test_build_playlist_payload_excludes_skip(songs_dir: Path): @@ -14,6 +19,13 @@ def test_build_playlist_payload_excludes_skip(songs_dir: Path): assert payload["tracks"][0]["url"].startswith("https://tinqs.com/tinqs/live-radio/media/") +def test_embed_playlist_in_html_replaces_marker(): + html = f"" + out = embed_playlist_in_html(html, {"tracks": [{"id": "abc"}]}) + assert '"abc"' in out + assert PLAYLIST_MARKER in out + + def test_export_gateway_playlist_writes_json_and_embeds(tmp_path: Path, songs_dir: Path): gateway = tmp_path / "gateway" gateway.mkdir() @@ -22,6 +34,11 @@ def test_export_gateway_playlist_writes_json_and_embeds(tmp_path: Path, songs_di f"\n", encoding="utf-8", ) + winamp = gateway / "winamp.html" + winamp.write_text( + f"\n", + encoding="utf-8", + ) (tmp_path / "songs").mkdir(exist_ok=True) for f in songs_dir.iterdir(): dest = tmp_path / "songs" / f.name @@ -34,3 +51,5 @@ def test_export_gateway_playlist_writes_json_and_embeds(tmp_path: Path, songs_di html = index.read_text(encoding="utf-8") assert "11111111" in html assert PLAYLIST_MARKER in html + whtml = winamp.read_text(encoding="utf-8") + assert "11111111" in whtml