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:
parent
29ab0be7cb
commit
f8eb0655eb
9 changed files with 247 additions and 19 deletions
12
ingest/Dockerfile
Normal file
12
ingest/Dockerfile
Normal 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"]
|
||||
5
ingest/radieo/__init__.py
Normal file
5
ingest/radieo/__init__.py
Normal 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
35
ingest/radieo/__main__.py
Normal 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
68
ingest/radieo/api.py
Normal 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
18
ingest/radieo/config.py
Normal 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
50
ingest/radieo/queue.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue