diff --git a/README.md b/README.md index c3b1771..074b567 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Because I run an online D&D game, which includes a background music stream f This assumes you have a functioning icecast2 installation already. ``` +% sudo apt install libshout3-dev % mkdir -p ~/.dnd/croaker % croaker setup > ~/.dnd/croaker/defaults % vi ~/.dnd/croaker/defaults # adjust to taste @@ -23,7 +24,7 @@ This assumes you have a functioning icecast2 installation already. Now start the server, which will begin streaming the `session_start` playlist: ``` -% croaker start +% croaker server start Daemonizing webserver on http://0.0.0.0:8003, pidfile and output in ~/.dnd/croaker ``` diff --git a/croaker/cli.py b/croaker/cli.py index 58483bb..682303a 100644 --- a/croaker/cli.py +++ b/croaker/cli.py @@ -11,7 +11,7 @@ from dotenv import load_dotenv from typing_extensions import Annotated import croaker.path -from croaker import client, controller, server +from croaker.server import server from croaker.exceptions import ConfigurationError from croaker.playlist import Playlist @@ -28,16 +28,9 @@ SECRET_KEY= # Where the record the webserver daemon's PID PIDFILE=~/.dnd/croaker/croaker.pid -# Web interface configuration -HOST=127.0.0.1 +HOST=0.0.0.0 PORT=8003 -## CONTROLLER CLIENT - -# The host and port to use when connecting to the websever. -CONTROLLER_HOST=127.0.0.1 -CONTROLLER_PORT=8003 - ## MEDIA # where to store playlist sources @@ -67,7 +60,6 @@ ICECAST_PORT= ICECAST_URL= """ - app = typer.Typer() app_state = {} @@ -79,48 +71,28 @@ def main( Path("~/.dnd/croaker"), help="Path to the Croaker environment", ), - host: Optional[str] = typer.Option( - None, - help="bind address", - ), - port: Optional[int] = typer.Option( - None, - help="bind port", - ), debug: Optional[bool] = typer.Option(None, help="Enable debugging output"), ): load_dotenv(root.expanduser() / Path("defaults")) load_dotenv(stream=io.StringIO(SETUP_HELP)) - if host: - os.environ["HOST"] = host - if port: - os.environ["PORT"] = port if debug is not None: if debug: - os.environ["DEBUG"] = 1 + os.environ["DEBUG"] = '1' else: del os.environ["DEBUG"] logging.basicConfig( - format="%(message)s", + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.DEBUG if debug else logging.INFO, ) try: - croaker.path.media_root() - croaker.path.cache_root() + croaker.path.root() + croaker.path.playlist_root() except ConfigurationError as e: sys.stderr.write(f"{e}\n\n{SETUP_HELP}") sys.exit(1) - app_state["client"] = client.Client( - host=os.environ["CONTROLLER_HOST"], - port=os.environ["CONTROLLER_PORT"], - ) - - if not context.invoked_subcommand: - return play(context) - @app.command() def setup(context: typer.Context): @@ -139,7 +111,8 @@ def start( """ Start the Croaker command and control webserver. """ - controller.start() + logging.debug("Switching to session_start playlist...") + logging.debug("Starting server...") if daemonize: server.daemonize() else: @@ -151,35 +124,9 @@ def stop(): """ Terminate the webserver process and liquidsoap. """ - controller.stop() server.stop() -@app.command() -def play( - playlist: str = typer.Argument( - ..., - help="Playlist name", - ) -): - """ - Begin playing tracks from the directory $PLAYLIST_ROOT/[NAME]. - """ - res = app_state["client"].play(playlist) - if res.status_code == 200: - print("OK") - - -@app.command() -def skip(): - """ - Play the next track on the current playlist. - """ - res = app_state["client"].skip() - if res.status_code == 200: - print("OK") - - @app.command() def add( playlist: str = typer.Argument( @@ -190,9 +137,11 @@ def add( tracks: Annotated[Optional[List[Path]], typer.Argument()] = None, ): """ - Recursively add one or more paths to the specified playlist. Tracks can be - any combination of individual audio files and directories containing audio - files; anything not already on the playlist will be added to it. + Recursively add one or more paths to the specified playlist. + + Tracks can be any combination of individual audio files and directories + containing audio files; anything not already on the playlist will be + added to it. If --theme is specified, the first track will be designated the playlist "theme." Theme songs get played first whenever the playlist is loaded, diff --git a/croaker/controller.py b/croaker/controller.py index f5e1525..9c2ec0b 100644 --- a/croaker/controller.py +++ b/croaker/controller.py @@ -1,141 +1,64 @@ import logging -import os -from pathlib import Path -from subprocess import Popen -from time import sleep - -from Exscript.protocols import Telnet - -from croaker import path -from croaker.pidfile import pidfile -from croaker.playlist import Playlist, load_playlist - -NOW_PLAYING = None - -LIQUIDSOAP_CONFIG = """ -set("server.telnet",true) -set("request.grace_time", 1.0) -set("init.daemon.pidfile.path", "{pidfile.path}") -set("decoder.ffmpeg.codecs.alac", ["alac"]) +import threading +import queue +from croaker.playlist import load_playlist +from croaker.streamer import AudioStreamer -# deeebuggin -set("log.file.path","{debug_log}") +class Controller(threading.Thread): + def __init__(self, control_queue): + self._streamer_queue = None + self._control_queue = control_queue + self.skip_event = threading.Event() + self.stop_event = threading.Event() + self._streamer = None + super().__init__() -# set up the stream -stream = crossfade(normalize(playlist.safe( - id='stream', - reload_mode='watch', - mode='normal', - '{playlist_root}/now_playing', -))) + @property + def streamer(self): + if not self._streamer: + self._streamer_queue = queue.Queue() + self._streamer = AudioStreamer(self._streamer_queue, self.skip_event, self.stop_event) + return self._streamer -# if source files don't contain metadata tags, use the filename -def apply_metadata(m) = - title = m["title"] - print("Now Playing: #{{m['filename']}}") - if (title == "") then - [("title", "#{{path.remove_extension(path.basename(m['filename']))}}")] - else - [("title", "#{{title}}")] - end -end + def run(self): + logging.debug("Starting AudioStreamer...") + self.streamer.start() + self.load('session_start') + while True: + data = self._control_queue.get() + logging.debug(f"{data = }") + self.process_request(data) -# apply the metadata parser -stream = map_metadata(apply_metadata, stream) + def process_request(self, data): + cmd, *args = data.split(' ') + cmd = cmd.strip() + if not cmd: + return + handler = getattr(self, f"handle_{cmd}", None) + if not handler: + logging.debug("Ignoring invalid command: {cmd} = }") + return + handler(args) -# define the source. ignore errors and provide no infallibale fallback. yolo. -radio = fallback(track_sensitive=false, [stream]) + def handle_PLAY(self, args): + return self.load(args[0]) -# transcode to icecast -output.icecast( - %mp3.vbr(quality=3), - name='Croaker Radio', - description='Background music for The Frog Hat Club', - host="{icecast_host}", - port={icecast_port}, - password="{icecast_password}", - mount="{icecast_mount}", - icy_metadata="true", - url="{icecast_url}", - fallible=true, - radio -) -""" + def handle_FFWD(self, args): + logging.debug("Sending SKIP signal to streamer...") + self.skip_event.set() + def handle_STOP(self): + return self.stop() -def generate_liquidsoap_config(): - log = path.root() / "liquidsoap.log" - if log.exists(): - log.unlink() - log.touch() - ls_config = path.root() / "croaker.liq" - with ls_config.open("wt") as fh: - fh.write( - LIQUIDSOAP_CONFIG.format( - pidfile=_pidfile(terminate_if_running=False), - debug_log=log, - playlist_root=path.playlist_root(), - icecast_host=os.environ.get("ICECAST_HOST"), - icecast_port=os.environ.get("ICECAST_PORT"), - icecast_mount=os.environ.get("ICECAST_MOUNT"), - icecast_password=os.environ.get("ICECAST_PASSWORD"), - icecast_url=os.environ.get("ICECAST_URL"), - ) - ) - path.playlist_root().mkdir(exist_ok=True) + def stop(self): + if self._streamer: + logging.debug("Sending STOP signal to streamer...") + self.stop_event.set() + self.playlist = None - -def start_liquidsoap(): - logging.debug("Staring liquidsoap...") - pf = _pidfile(terminate_if_running=False) - pid = pf.read_pid() - if not pid: - logging.info("Liquidsoap does not appear to be running. Starting it...") - generate_liquidsoap_config() - Popen([os.environ["LIQUIDSOAP"], "--daemon", path.root() / "croaker.liq"]) - sleep(1) - - -def start(): - play_next("session_start") - - -def stop(): - _pidfile(terminate_if_running=True) - - -def play_next(playlist_name: str = None): - start_liquidsoap() - if playlist_name: - pl = load_playlist(playlist_name) - logging.debug(f"Loaded playlist {pl = }") - if NOW_PLAYING != pl.name: - _switch_to(pl) - _send_liquidsoap_command("skip") - - -def _pidfile(terminate_if_running: bool = True): - pf = os.environ.get("LIQUIDSOAP_PIDFILE", None) - if pf: - pf = Path(pf) - else: - pf = path.root() / "liquidsoap.pid" - return pidfile(pf, terminate_if_running=terminate_if_running) - - -def _switch_to(playlist: Playlist): - logging.debug(f"Switching to {playlist = }") - np = path.playlist_root() / Path("now_playing") - with np.open("wt") as fh: - for track in playlist.tracks: - fh.write(f"{track}\n") - playlist.name - - -def _send_liquidsoap_command(command: str): - conn = Telnet() - conn.connect("localhost", port=1234) - conn.send(f"Croaker_Radio.{command}\r") - conn.send("quit\r") - conn.close() + def load(self, playlist_name: str): + self.playlist = load_playlist(playlist_name) + logging.debug(f"Switching to {self.playlist = }") + for track in self.playlist.tracks: + self._streamer_queue.put(str(track).encode()) diff --git a/croaker/path.py b/croaker/path.py index 3d2b48e..16ec69f 100644 --- a/croaker/path.py +++ b/croaker/path.py @@ -2,8 +2,6 @@ import logging import os from pathlib import Path -from croaker.exceptions import ConfigurationError - _setup_hint = "You may be able to solve this error by running 'croaker setup' or specifying the --root parameter." _reinstall_hint = "You might need to reinstall Groove On Demand to fix this error." @@ -12,19 +10,6 @@ def root(): return Path(os.environ.get("CROAKER_ROOT", "~/.dnd/croaker")).expanduser() -def media_root(): - path = os.environ.get("MEDIA_ROOT", None) - if not path: - raise ConfigurationError(f"MEDIA_ROOT is not defined in your environment.\n\n{_setup_hint}") - path = Path(path).expanduser() - if not path.exists() or not path.is_dir(): - raise ConfigurationError( - "The media_root directory (MEDIA_ROOT) doesn't exist, or isn't a directory.\n\n{_setup_hint}" - ) - logging.debug(f"Media root is {path}") - return path - - def cache_root(): path = Path(os.environ.get("CACHE_ROOT", root() / Path("cache"))).expanduser() logging.debug(f"Media cache root is {path}") @@ -37,11 +22,6 @@ def playlist_root(): return path -def media(relpath): - path = media_root() / Path(relpath) - return path - - def transcoded_media(relpath): path = cache_root() / Path(relpath + ".webm") return path diff --git a/croaker/pidfile.py b/croaker/pidfile.py index 3f66943..3dacea1 100644 --- a/croaker/pidfile.py +++ b/croaker/pidfile.py @@ -6,13 +6,13 @@ from pathlib import Path from daemon import pidfile as _pidfile -def pidfile(pidfile_path: Path, terminate_if_running: bool = True): +def pidfile(pidfile_path: Path, sig=signal.SIGQUIT, terminate_if_running: bool = True): pf = _pidfile.TimeoutPIDLockFile(str(pidfile_path.expanduser()), 30) pid = pf.read_pid() if pid and terminate_if_running: try: logging.debug(f"Stopping PID {pid}") - os.kill(pid, signal.SIGTERM) + os.kill(pid, sig) except ProcessLookupError: logging.debug(f"PID {pid} not running; breaking lock.") pf.break_lock() diff --git a/croaker/playlist.py b/croaker/playlist.py index dfd4deb..6db07c7 100644 --- a/croaker/playlist.py +++ b/croaker/playlist.py @@ -11,6 +11,8 @@ import croaker.path playlists = {} +NowPlaying = None + def _stripped(name): name.replace('"', "") @@ -21,8 +23,12 @@ def _stripped(name): @dataclass class Playlist: name: str + position: int = 0 theme: Path = Path("_theme.mp3") - current_track: int = 0 + + @property + def current(self): + return self.tracks[self.position] @cached_property def path(self): @@ -36,13 +42,20 @@ class Playlist: entries = [] theme = self.path / self.theme if theme.exists(): - entries[0] = theme + entries.append(theme) files = [e for e in self.get_audio_files() if e.name != "_theme.mp3"] if files: shuffle(files) entries += files return entries + def skip(self): + logging.debug(f"Skipping from {self.position} on {self.name}") + if self.position == len(self.tracks) - 1: + self.position = 0 + else: + self.position += 1 + def get_audio_files(self, path: Path = None): if not path: path = self.path @@ -65,8 +78,6 @@ class Playlist: def add(self, tracks: List[Path], make_theme: bool = False): self.path.mkdir(parents=True, exist_ok=True) if make_theme: - if source.is_dir(): - raise RuntimeError(f"Cannot create a playlist theme from a directory: {source}") target = self.path / "_theme.mp3" source = tracks.pop(0) self._add_track(target, source, make_theme=True) diff --git a/croaker/routes.py b/croaker/routes.py index fa0ccbc..e43dfb9 100644 --- a/croaker/routes.py +++ b/croaker/routes.py @@ -1,17 +1,31 @@ -from bottle import route +import logging -from croaker import controller +from bottle import route, abort + +from croaker import streamer @route("/play/") def play(playlist_name=None): - if not controller.play_next(playlist_name): + if not streamer.load(playlist_name): return return "OK" @route("/skip") def skip(): - if not controller.play_next(): + if not streamer.play_next(): return return "OK" + + +@route("/next_in_queue") +def next_in_queue(): + pl = controller.now_playing() + logging.debug(pl) + if not pl: + abort() + track1 = pl.current + controller.play_next() + tracke2 = controller.now_playing().current + return '\n'.join([str(track1), str(track2)]) diff --git a/croaker/server.py b/croaker/server.py index e8585a0..cd9452d 100644 --- a/croaker/server.py +++ b/croaker/server.py @@ -1,43 +1,96 @@ -import logging import os +import logging +import queue +import socketserver from pathlib import Path -import bottle import daemon -from croaker import path, routes +from croaker import path from croaker.pidfile import pidfile - -assert routes -app = bottle.default_app() +from croaker.controller import Controller -def _pidfile(terminate_if_running: bool = True): - pf = os.environ.get("PIDFILE", None) - if pf: - pf = Path(pf) - else: - pf = path.root() / "croaker.pid" - return pidfile(pf, terminate_if_running=terminate_if_running) +class RequestHandler(socketserver.StreamRequestHandler): + supported_commands = { + 'PLAY': "$PLAYLIST_NAME - Switch to the specified playlist.", + 'FFWD': " - Skip to the next track in the playlist.", + 'HELP': " - Display command help.", + 'KTHX': " - Close the current connection.", + 'STOP': " - Stop Croaker.", + } + + def handle(self): + while True: + self.data = self.rfile.readline().strip().decode() + logging.debug(f"{self.data = }") + try: + cmd = self.data[0:4].strip().upper() + args = self.data[5:] + except IndexError: + self.send(f"ERR Command not understood '{cmd}'") + + if cmd not in self.supported_commands: + self.send(f"ERR Unknown Command '{cmd}'") + + if cmd == 'KTHX': + return self.send('KBAI') + + handler = getattr(self, f"handle_{cmd}", None) + if handler: + handler(args) + else: + self.default_handler(cmd, args) + + def send(self, msg): + return self.wfile.write(msg.encode() + b'\n') + + def default_handler(self, cmd, args): + self.server.tell_controller(f"{cmd} {args}") + return self.send('OK') + + def handle_HELP(self, args): + return self.send('\n'.join( + f"{cmd} {txt}" for cmd, txt in self.supported_commands.items() + )) + + def handle_STOP(self, args): + self.send("Shutting down.") + self.server.stop() -def daemonize(host: str = "0.0.0.0", port: int = 8003, debug: bool = False) -> None: # pragma: no cover - logging.info(f"Daemonizing webserver on http://{host}:{port}, pidfile and output in {path.root()}") - context = daemon.DaemonContext() - context.pidfile = _pidfile() - context.stdout = open(path.root() / Path("croaker.out"), "wb") - context.stderr = open(path.root() / Path("croaker.err"), "wb", buffering=0) - context.open() - start(host, port, debug) +class CroakerServer(socketserver.TCPServer): + allow_reuse_address = True + + def __init__(self): + self._context = daemon.DaemonContext() + self._queue = queue.Queue() + self.controller = Controller(self._queue) + + def _pidfile(self, terminate_if_running: bool = True): + return pidfile(path.root() / "croaker.pid", terminate_if_running=terminate_if_running) + + def tell_controller(self, msg): + self._queue.put(msg) + + def daemonize(self) -> None: + logging.info(f"Daemonizing controller; pidfile and output in {path.root()}") + super().__init__((os.environ['HOST'], int(os.environ['PORT'])), RequestHandler) + + self._context.pidfile = self._pidfile() + self._context.stdout = open(path.root() / Path("croaker.out"), "wb") + self._context.stderr = open(path.root() / Path("croaker.err"), "wb", buffering=0) + self._context.files_preserve = [self.fileno()] + self._context.open() + try: + self.controller.start() + self.serve_forever() + except KeyboardInterrupt: + logging.info("Shutting down.") + self.stop() + + def stop(self) -> None: + self._pidfile() -def stop(): - _pidfile() - - -def start(host: str = "0.0.0.0", port: int = 8003, debug: bool = False) -> None: # pragma: no cover - """ - Start the Bottle app. - """ - logging.debug(f"Configuring webserver with host={host}, port={port}, debug={debug}") - app.run(host=os.getenv("HOST", host), port=os.getenv("PORT", port), debug=debug, server="paste", quiet=True) +server = CroakerServer() diff --git a/croaker/streamer.py b/croaker/streamer.py new file mode 100644 index 0000000..078b5be --- /dev/null +++ b/croaker/streamer.py @@ -0,0 +1,68 @@ +import os +import logging +import threading +from pathlib import Path + +from functools import cached_property + +import shout + +from time import sleep + + +class AudioStreamer(threading.Thread): + def __init__(self, queue, skip_event, stop_event): + super().__init__() + self.queue = queue + self.skip_requested = skip_event + self.stop_requested = stop_event + + @cached_property + def _shout(self): + s = shout.Shout() + s.name = 'Croaker Radio' + s.url = os.environ['ICECAST_URL'] + s.mount = os.environ['ICECAST_MOUNT'] + s.host = os.environ['ICECAST_HOST'] + s.port = int(os.environ['ICECAST_PORT']) + s.password = os.environ['ICECAST_PASSWORD'] + s.protocol = 'http' + s.format = 'mp3' + s.audio_info = { + shout.SHOUT_AI_BITRATE: '192', + shout.SHOUT_AI_SAMPLERATE: '44100', + shout.SHOUT_AI_CHANNELS: '5' + } + return s + + def run(self): + logging.debug("Initialized") + self._shout.open() + while not self.stop_requested.is_set(): + self._shout.get_connected() + track = self.queue.get() + logging.debug(f"Received: {track = }") + if track: + self.play(Path(track.decode())) + continue + sleep(1) + self._shout.close() + + def play(self, track: Path): + with track.open('rb') as fh: + self._shout.get_connected() + logging.debug(f"Streaming {track.stem = }") + self._shout.set_metadata({'song': track.stem}) + input_buffer = fh.read(4096) + while not self.skip_requested.is_set(): + if self.stop_requested.is_set(): + self.stop_requested.clear() + return + buf = input_buffer + input_buffer = fh.read(4096) + if len(buf) == 0: + break + self._shout.send(buf) + self._shout.sync() + if self.skip_requested.is_set(): + self.skip_requested.clear() diff --git a/pyproject.toml b/pyproject.toml index a98af9d..aa7c9ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "croaker" -version = "0.1.1" +version = "0.1.3" description = "" authors = ["evilchili "] readme = "README.md" @@ -15,12 +15,12 @@ typer = "^0.9.0" python-dotenv = "^0.21.0" rich = "^13.7.0" pyyaml = "^6.0.1" -bottle = "^0.12.25" paste = "^3.7.1" python-daemon = "^3.0.1" requests = "^2.31.0" psutil = "^5.9.8" exscript = "^2.6.28" +python-shout = "^0.2.8" [tool.poetry.scripts] croaker = "croaker.cli:app"