Milestone 2: ingestion daemon driving the stream

Add the Python `ingest` container exposing `GET /next`, which returns the
next track as an annotated Liquidsoap URI (or an empty body when nothing is
ready). Liquidsoap switches from a static playlist to a `request.dynamic`
source pulling from the daemon, with the local cache as fallback and mksafe
for guaranteed continuous output.

For now the daemon just cycles through the files already in the cache; the
download providers (Navidrome, yt-dlp, ListenBrainz) come in later milestones.

Also commit the implementation plan (PLAN.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-07-02 16:52:49 +08:00
commit f8eb0655eb
9 changed files with 247 additions and 19 deletions

12
ingest/Dockerfile Normal file
View file

@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
# Milestone 2 uses the standard library only; third-party dependencies
# (httpx, yt-dlp, feedparser…) will be added in later milestones.
COPY radieo/ ./radieo/
ENV PYTHONUNBUFFERED=1
EXPOSE 8080
CMD ["python", "-m", "radieo"]

View file

@ -0,0 +1,5 @@
"""radieo ingestion daemon.
Decides what to play next, keeps a queue of ready tracks and exposes the next
one over HTTP for the Liquidsoap stream layer to pick up.
"""

35
ingest/radieo/__main__.py Normal file
View file

@ -0,0 +1,35 @@
"""Entry point: start the HTTP API serving the track queue."""
import logging
from . import config
from .api import IngestServer
from .queue import TrackQueue
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger("radieo")
config.CACHE_DIR.mkdir(parents=True, exist_ok=True)
server = IngestServer((config.HTTP_HOST, config.HTTP_PORT), TrackQueue())
log.info(
"ingest listening on %s:%d (cache=%s)",
config.HTTP_HOST,
config.HTTP_PORT,
config.CACHE_DIR,
)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()

68
ingest/radieo/api.py Normal file
View file

@ -0,0 +1,68 @@
"""HTTP API exposing the next track to the stream layer.
Endpoints:
GET /next -> annotated Liquidsoap URI, or an empty body when nothing is
ready (Liquidsoap then falls back to the local cache).
GET /healthz -> "ok"
"""
import logging
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from .queue import TrackQueue
log = logging.getLogger("radieo.api")
def annotate_uri(path: Path) -> str:
"""Build an annotated Liquidsoap request URI for a cache file.
Metadata is minimal for now (title derived from the filename); real
metadata will come from the providers in later milestones.
"""
def esc(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
title = esc(path.stem)
artist = esc("radieo")
return f'annotate:title="{title}",artist="{artist}":{path}'
class IngestServer(ThreadingHTTPServer):
def __init__(self, address, queue: TrackQueue):
super().__init__(address, _Handler)
self.queue = queue
class _Handler(BaseHTTPRequestHandler):
server: IngestServer
def do_GET(self): # noqa: N802 (name imposed by BaseHTTPRequestHandler)
if self.path == "/next":
self._serve_next()
elif self.path == "/healthz":
self._text(200, "ok\n")
else:
self._text(404, "not found\n")
def _serve_next(self):
track = self.server.queue.pop_next()
if track is None:
# Empty body: tells Liquidsoap to use its fallback for now.
self._text(200, "")
return
log.info("next -> %s", track.name)
self._text(200, annotate_uri(track) + "\n")
def _text(self, code: int, body: str):
data = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, fmt, *args):
log.debug("%s - %s", self.address_string(), fmt % args)

18
ingest/radieo/config.py Normal file
View file

@ -0,0 +1,18 @@
"""Runtime configuration, read from the environment.
Kept intentionally small; later milestones will add source credentials,
weights and retention settings here (or in a config file).
"""
import os
from pathlib import Path
# Directory shared with the stream container. Paths returned to Liquidsoap must
# be valid inside *that* container, so both mount the cache at the same path.
CACHE_DIR = Path(os.environ.get("RADIEO_CACHE_DIR", "/cache"))
HTTP_HOST = os.environ.get("RADIEO_HTTP_HOST", "0.0.0.0")
HTTP_PORT = int(os.environ.get("RADIEO_HTTP_PORT", "8080"))
# File extensions considered playable when scanning the cache.
AUDIO_EXTENSIONS = {".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac", ".wav"}

50
ingest/radieo/queue.py Normal file
View file

@ -0,0 +1,50 @@
"""Queue of tracks ready to be served to the stream layer.
Milestone 2: a track is simply an audio file already present in the cache
directory. When the queue drains, it is refilled with a fresh shuffle of the
available files. Later milestones will replace the refill logic with providers
that download tracks (Navidrome, yt-dlp, ) before enqueuing them.
"""
import random
import threading
from collections import deque
from pathlib import Path
from . import config
class TrackQueue:
def __init__(self):
self._lock = threading.Lock()
self._upcoming: deque[Path] = deque()
self._last_served: Path | None = None
def _available_files(self) -> list[Path]:
if not config.CACHE_DIR.is_dir():
return []
return sorted(
p
for p in config.CACHE_DIR.iterdir()
if p.is_file() and p.suffix.lower() in config.AUDIO_EXTENSIONS
)
def _refill_locked(self) -> None:
files = self._available_files()
if not files:
return
# Avoid replaying the last served track back-to-back when we can.
pool = [f for f in files if f != self._last_served] or files
random.shuffle(pool)
self._upcoming.extend(pool)
def pop_next(self) -> Path | None:
"""Return the next track to play, or None if the cache is empty."""
with self._lock:
if not self._upcoming:
self._refill_locked()
if not self._upcoming:
return None
track = self._upcoming.popleft()
self._last_served = track
return track