diff --git a/croaker/__init__.py b/croaker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/croaker/cli.py b/croaker/cli.py new file mode 100644 index 0000000..58483bb --- /dev/null +++ b/croaker/cli.py @@ -0,0 +1,207 @@ +import io +import logging +import os +import sys +from pathlib import Path +from textwrap import dedent +from typing import List, Optional + +import typer +from dotenv import load_dotenv +from typing_extensions import Annotated + +import croaker.path +from croaker import client, controller, server +from croaker.exceptions import ConfigurationError +from croaker.playlist import Playlist + +SETUP_HELP = """ +# Root directory for croaker configuration and logs. See also croaker --root. +CROAKER_ROOT=~/.dnd/croaker + +## COMMAND AND CONTROL WEBSERVER + +# Please make sure you set SECRET_KEY in your environment if you are running +# the command and control webserver. Clients do not need this. +SECRET_KEY= + +# Where the record the webserver daemon's PID +PIDFILE=~/.dnd/croaker/croaker.pid + +# Web interface configuration +HOST=127.0.0.1 +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 +PLAYLIST_ROOT=~/.dnd/croaker/playlists + +# where to cache transcoded media files +CACHE_ROOT=~/.dnd/croaker/cache + +# the kinds of files to add to playlists +MEDIA_GLOB=*.mp3,*.flac,*.m4a + +# If defined, transcode media before streaming it, and cache it to disk. The +# strings INFILE and OUTFILE will be replaced with the media source file and +# the cached output location, respectively. +TRANSCODER=/usr/bin/ffmpeg -i INFILE '-hide_banner -loglevel error -codec:v copy -codec:a libmp3lame -q:a 2' OUTFILE + +## LIQUIDSOAP AND ICECAST + +# The liquidsoap executable +LIQUIDSOAP=/usr/bin/liquidsoap + +# Icecast2 configuration for Liquidsoap +ICECAST_PASSWORD= +ICECAST_MOUNT= +ICECAST_HOST= +ICECAST_PORT= +ICECAST_URL= +""" + + +app = typer.Typer() +app_state = {} + + +@app.callback() +def main( + context: typer.Context, + root: Optional[Path] = typer.Option( + 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 + else: + del os.environ["DEBUG"] + + logging.basicConfig( + format="%(message)s", + level=logging.DEBUG if debug else logging.INFO, + ) + + try: + croaker.path.media_root() + croaker.path.cache_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): + """ + (Re)Initialize Croaker. + """ + sys.stderr.write("Interactive setup is not yet available. Sorry!\n") + print(dedent(SETUP_HELP)) + + +@app.command() +def start( + context: typer.Context, + daemonize: bool = typer.Option(True, help="Daemonize the webserver."), +): + """ + Start the Croaker command and control webserver. + """ + controller.start() + if daemonize: + server.daemonize() + else: + server.start() + + +@app.command() +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( + ..., + help="Playlist name", + ), + theme: Optional[bool] = typer.Option(False, help="Make the first track the theme song."), + 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. + + If --theme is specified, the first track will be designated the playlist + "theme." Theme songs get played first whenever the playlist is loaded, + after which the playlist order is randomized. + """ + pl = Playlist(name=playlist) + pl.add(tracks, make_theme=theme) + print(pl) + + +if __name__ == "__main__": + app.main() diff --git a/croaker/client.py b/croaker/client.py new file mode 100644 index 0000000..f980150 --- /dev/null +++ b/croaker/client.py @@ -0,0 +1,43 @@ +import logging +from dataclasses import dataclass +from functools import cached_property + +import bottle +import requests + +# needs to be imported to attach routes to the default app +from croaker import routes + +assert routes + + +@dataclass +class Client: + host: str + port: int + + @cached_property + def _session(self): + return requests.Session() + + @property + def _routes(self): + return [r.callback.__name__ for r in bottle.default_app().routes] + + def get(self, uri: str, *args, **params): + url = f"http://{self.host}:{self.port}/{uri}" + if args: + url += "/" + "/".join(args) + res = self._session.get(url, params=params) + logging.debug(f"{url = }, {res = }") + return res + + def __getattr__(self, attr): + if attr in self._routes: + + def dispatch(*args, **kwargs): + logging.debug(f"calling attr, {args = }, {kwargs = }") + return self.get(attr, *args, **kwargs) + + return dispatch + return self.__getattribute__(attr) diff --git a/croaker/controller.py b/croaker/controller.py new file mode 100644 index 0000000..f5e1525 --- /dev/null +++ b/croaker/controller.py @@ -0,0 +1,141 @@ +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"]) + + +# deeebuggin +set("log.file.path","{debug_log}") + +# set up the stream +stream = crossfade(normalize(playlist.safe( + id='stream', + reload_mode='watch', + mode='normal', + '{playlist_root}/now_playing', +))) + +# 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 + +# apply the metadata parser +stream = map_metadata(apply_metadata, stream) + +# define the source. ignore errors and provide no infallibale fallback. yolo. +radio = fallback(track_sensitive=false, [stream]) + +# 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 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 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() diff --git a/croaker/exceptions.py b/croaker/exceptions.py new file mode 100644 index 0000000..4ecbf84 --- /dev/null +++ b/croaker/exceptions.py @@ -0,0 +1,16 @@ +class APIHandlingException(Exception): + """ + An API reqeust could not be encoded or decoded. + """ + + +class ConfigurationError(Exception): + """ + An error was discovered with the Groove on Demand configuration. + """ + + +class InvalidPathError(Exception): + """ + The specified path was invalid -- either it was not the expected type or wasn't accessible. + """ diff --git a/croaker/path.py b/croaker/path.py new file mode 100644 index 0000000..3d2b48e --- /dev/null +++ b/croaker/path.py @@ -0,0 +1,47 @@ +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." + + +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}") + return path + + +def playlist_root(): + path = Path(os.environ.get("PLAYLIST_ROOT", root() / Path("playlsits"))).expanduser() + logging.debug(f"Playlist root is {path}") + 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 new file mode 100644 index 0000000..4ff4663 --- /dev/null +++ b/croaker/pidfile.py @@ -0,0 +1,19 @@ +import logging +import os +import signal +from pathlib import Path + +from daemon import pidfile as _pidfile + + +def pidfile(pidfile_path: Path, terminate_if_running: bool = True): + pf = _pidfile.TimeoutPIDLockFile(pidfile_path, 30) + pid = pf.read_pid() + if pid and terminate_if_running: + try: + logging.debug(f"Stopping PID {pid}") + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + logging.debug(f"PID {pid} not running; breaking lock.") + pf.break_lock() + return pf diff --git a/croaker/playlist.py b/croaker/playlist.py new file mode 100644 index 0000000..dfd4deb --- /dev/null +++ b/croaker/playlist.py @@ -0,0 +1,86 @@ +import logging +import os +from dataclasses import dataclass +from functools import cached_property +from itertools import chain +from pathlib import Path +from random import shuffle +from typing import List + +import croaker.path + +playlists = {} + + +def _stripped(name): + name.replace('"', "") + name.replace("'", "") + return name + + +@dataclass +class Playlist: + name: str + theme: Path = Path("_theme.mp3") + current_track: int = 0 + + @cached_property + def path(self): + return croaker.path.playlist_root() / Path(self.name) + + @cached_property + def tracks(self): + if not self.path.exists(): + raise RuntimeError(f"Playlist {self.name} not found at {self.path}.") + + entries = [] + theme = self.path / self.theme + if theme.exists(): + entries[0] = theme + files = [e for e in self.get_audio_files() if e.name != "_theme.mp3"] + if files: + shuffle(files) + entries += files + return entries + + def get_audio_files(self, path: Path = None): + if not path: + path = self.path + logging.debug(f"Getting files matching {os.environ['MEDIA_GLOB']} from {path}") + pats = os.environ["MEDIA_GLOB"].split(",") + return chain(*[list(path.glob(pat)) for pat in pats]) + + def _add_track(self, target: Path, source: Path, make_theme: bool = False): + if source.is_dir(): + for file in self.get_audio_files(source): + self._add_track(self.path / _stripped(file.name), file) + return + if target.exists(): + if not target.is_symlink(): + logging.warning(f"{target}: target already exists and is not a symlink; skipping.") + return + target.unlink() + target.symlink_to(source) + + 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) + for track in tracks: + self._add_track(target=self.path / _stripped(track.name), source=track) + return sorted(self.get_audio_files()) + + def __repr__(self): + lines = [f"Playlist {self.name}"] + lines += [f" * {track}" for track in self.tracks] + return "\n".join(lines) + + +def load_playlist(name: str): + if name not in playlists: + playlists[name] = Playlist(name=name) + return playlists[name] diff --git a/croaker/routes.py b/croaker/routes.py new file mode 100644 index 0000000..fa0ccbc --- /dev/null +++ b/croaker/routes.py @@ -0,0 +1,17 @@ +from bottle import route + +from croaker import controller + + +@route("/play/") +def play(playlist_name=None): + if not controller.play_next(playlist_name): + return + return "OK" + + +@route("/skip") +def skip(): + if not controller.play_next(): + return + return "OK" diff --git a/croaker/server.py b/croaker/server.py new file mode 100644 index 0000000..e8585a0 --- /dev/null +++ b/croaker/server.py @@ -0,0 +1,43 @@ +import logging +import os +from pathlib import Path + +import bottle +import daemon + +from croaker import path, routes +from croaker.pidfile import pidfile + +assert routes +app = bottle.default_app() + + +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) + + +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) + + +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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1750b43 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[tool.poetry] +name = "croaker" +version = "0.1.0" +description = "" +authors = ["evilchili "] +readme = "README.md" +packages = [ + { include = "croaker" } +] + +[tool.poetry.dependencies] +python = "^3.10" +prompt-toolkit = "^3.0.38" +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" + +[tool.poetry.scripts] +croaker = "croaker.cli:app" + +[tool.poetry.dev-dependencies] +black = "^23.3.0" +isort = "^5.12.0" +pyproject-autoflake = "^1.0.2" + +[tool.black] +line-length = 120 +target-version = ['py310'] + +[tool.isort] +multi_line_output = 3 +line_length = 120 +include_trailing_comma = true + +[tool.autoflake] +check = false # return error code if changes are needed +in-place = true # make changes to files instead of printing diffs +recursive = true # drill down directories recursively +remove-all-unused-imports = true # remove all unused imports (not just those from the standard library) +ignore-init-module-imports = true # exclude __init__.py when removing unused imports +remove-duplicate-keys = true # remove all duplicate keys in objects +remove-unused-variables = true # remove unused variables + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api"