dropping bottle/liquidsoap

Replacing both bottle and liquidsoap with a native TCPServer.
This commit is contained in:
evilchili 2024-03-04 17:56:32 -08:00
parent 5033ddde6e
commit ddeb91f77d
10 changed files with 257 additions and 258 deletions

View File

@ -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. This assumes you have a functioning icecast2 installation already.
``` ```
% sudo apt install libshout3-dev
% mkdir -p ~/.dnd/croaker % mkdir -p ~/.dnd/croaker
% croaker setup > ~/.dnd/croaker/defaults % croaker setup > ~/.dnd/croaker/defaults
% vi ~/.dnd/croaker/defaults # adjust to taste % 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: 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 Daemonizing webserver on http://0.0.0.0:8003, pidfile and output in ~/.dnd/croaker
``` ```

View File

@ -11,7 +11,7 @@ from dotenv import load_dotenv
from typing_extensions import Annotated from typing_extensions import Annotated
import croaker.path import croaker.path
from croaker import client, controller, server from croaker.server import server
from croaker.exceptions import ConfigurationError from croaker.exceptions import ConfigurationError
from croaker.playlist import Playlist from croaker.playlist import Playlist
@ -28,16 +28,9 @@ SECRET_KEY=
# Where the record the webserver daemon's PID # Where the record the webserver daemon's PID
PIDFILE=~/.dnd/croaker/croaker.pid PIDFILE=~/.dnd/croaker/croaker.pid
# Web interface configuration HOST=0.0.0.0
HOST=127.0.0.1
PORT=8003 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 ## MEDIA
# where to store playlist sources # where to store playlist sources
@ -67,7 +60,6 @@ ICECAST_PORT=
ICECAST_URL= ICECAST_URL=
""" """
app = typer.Typer() app = typer.Typer()
app_state = {} app_state = {}
@ -79,48 +71,28 @@ def main(
Path("~/.dnd/croaker"), Path("~/.dnd/croaker"),
help="Path to the Croaker environment", 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"), debug: Optional[bool] = typer.Option(None, help="Enable debugging output"),
): ):
load_dotenv(root.expanduser() / Path("defaults")) load_dotenv(root.expanduser() / Path("defaults"))
load_dotenv(stream=io.StringIO(SETUP_HELP)) 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 is not None:
if debug: if debug:
os.environ["DEBUG"] = 1 os.environ["DEBUG"] = '1'
else: else:
del os.environ["DEBUG"] del os.environ["DEBUG"]
logging.basicConfig( logging.basicConfig(
format="%(message)s", format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.DEBUG if debug else logging.INFO, level=logging.DEBUG if debug else logging.INFO,
) )
try: try:
croaker.path.media_root() croaker.path.root()
croaker.path.cache_root() croaker.path.playlist_root()
except ConfigurationError as e: except ConfigurationError as e:
sys.stderr.write(f"{e}\n\n{SETUP_HELP}") sys.stderr.write(f"{e}\n\n{SETUP_HELP}")
sys.exit(1) 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() @app.command()
def setup(context: typer.Context): def setup(context: typer.Context):
@ -139,7 +111,8 @@ def start(
""" """
Start the Croaker command and control webserver. Start the Croaker command and control webserver.
""" """
controller.start() logging.debug("Switching to session_start playlist...")
logging.debug("Starting server...")
if daemonize: if daemonize:
server.daemonize() server.daemonize()
else: else:
@ -151,35 +124,9 @@ def stop():
""" """
Terminate the webserver process and liquidsoap. Terminate the webserver process and liquidsoap.
""" """
controller.stop()
server.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() @app.command()
def add( def add(
playlist: str = typer.Argument( playlist: str = typer.Argument(
@ -190,9 +137,11 @@ def add(
tracks: Annotated[Optional[List[Path]], typer.Argument()] = None, tracks: Annotated[Optional[List[Path]], typer.Argument()] = None,
): ):
""" """
Recursively add one or more paths to the specified playlist. Tracks can be Recursively add one or more paths to the specified playlist.
any combination of individual audio files and directories containing audio
files; anything not already on the playlist will be added to it. 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 If --theme is specified, the first track will be designated the playlist
"theme." Theme songs get played first whenever the playlist is loaded, "theme." Theme songs get played first whenever the playlist is loaded,

View File

@ -1,141 +1,64 @@
import logging import logging
import os import threading
from pathlib import Path import queue
from subprocess import Popen from croaker.playlist import load_playlist
from time import sleep from croaker.streamer import AudioStreamer
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 class Controller(threading.Thread):
set("log.file.path","{debug_log}") 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 @property
stream = crossfade(normalize(playlist.safe( def streamer(self):
id='stream', if not self._streamer:
reload_mode='watch', self._streamer_queue = queue.Queue()
mode='normal', self._streamer = AudioStreamer(self._streamer_queue, self.skip_event, self.stop_event)
'{playlist_root}/now_playing', return self._streamer
)))
# if source files don't contain metadata tags, use the filename def run(self):
def apply_metadata(m) = logging.debug("Starting AudioStreamer...")
title = m["title"] self.streamer.start()
print("Now Playing: #{{m['filename']}}") self.load('session_start')
if (title == "") then while True:
[("title", "#{{path.remove_extension(path.basename(m['filename']))}}")] data = self._control_queue.get()
else logging.debug(f"{data = }")
[("title", "#{{title}}")] self.process_request(data)
end
end
# apply the metadata parser def process_request(self, data):
stream = map_metadata(apply_metadata, stream) 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. def handle_PLAY(self, args):
radio = fallback(track_sensitive=false, [stream]) return self.load(args[0])
# transcode to icecast def handle_FFWD(self, args):
output.icecast( logging.debug("Sending SKIP signal to streamer...")
%mp3.vbr(quality=3), self.skip_event.set()
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_STOP(self):
return self.stop()
def generate_liquidsoap_config(): def stop(self):
log = path.root() / "liquidsoap.log" if self._streamer:
if log.exists(): logging.debug("Sending STOP signal to streamer...")
log.unlink() self.stop_event.set()
log.touch() self.playlist = None
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 load(self, playlist_name: str):
def start_liquidsoap(): self.playlist = load_playlist(playlist_name)
logging.debug("Staring liquidsoap...") logging.debug(f"Switching to {self.playlist = }")
pf = _pidfile(terminate_if_running=False) for track in self.playlist.tracks:
pid = pf.read_pid() self._streamer_queue.put(str(track).encode())
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()

View File

@ -2,8 +2,6 @@ import logging
import os import os
from pathlib import Path 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." _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." _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() 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(): def cache_root():
path = Path(os.environ.get("CACHE_ROOT", root() / Path("cache"))).expanduser() path = Path(os.environ.get("CACHE_ROOT", root() / Path("cache"))).expanduser()
logging.debug(f"Media cache root is {path}") logging.debug(f"Media cache root is {path}")
@ -37,11 +22,6 @@ def playlist_root():
return path return path
def media(relpath):
path = media_root() / Path(relpath)
return path
def transcoded_media(relpath): def transcoded_media(relpath):
path = cache_root() / Path(relpath + ".webm") path = cache_root() / Path(relpath + ".webm")
return path return path

View File

@ -6,13 +6,13 @@ from pathlib import Path
from daemon import pidfile as _pidfile 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) pf = _pidfile.TimeoutPIDLockFile(str(pidfile_path.expanduser()), 30)
pid = pf.read_pid() pid = pf.read_pid()
if pid and terminate_if_running: if pid and terminate_if_running:
try: try:
logging.debug(f"Stopping PID {pid}") logging.debug(f"Stopping PID {pid}")
os.kill(pid, signal.SIGTERM) os.kill(pid, sig)
except ProcessLookupError: except ProcessLookupError:
logging.debug(f"PID {pid} not running; breaking lock.") logging.debug(f"PID {pid} not running; breaking lock.")
pf.break_lock() pf.break_lock()

View File

@ -11,6 +11,8 @@ import croaker.path
playlists = {} playlists = {}
NowPlaying = None
def _stripped(name): def _stripped(name):
name.replace('"', "") name.replace('"', "")
@ -21,8 +23,12 @@ def _stripped(name):
@dataclass @dataclass
class Playlist: class Playlist:
name: str name: str
position: int = 0
theme: Path = Path("_theme.mp3") theme: Path = Path("_theme.mp3")
current_track: int = 0
@property
def current(self):
return self.tracks[self.position]
@cached_property @cached_property
def path(self): def path(self):
@ -36,13 +42,20 @@ class Playlist:
entries = [] entries = []
theme = self.path / self.theme theme = self.path / self.theme
if theme.exists(): if theme.exists():
entries[0] = theme entries.append(theme)
files = [e for e in self.get_audio_files() if e.name != "_theme.mp3"] files = [e for e in self.get_audio_files() if e.name != "_theme.mp3"]
if files: if files:
shuffle(files) shuffle(files)
entries += files entries += files
return entries 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): def get_audio_files(self, path: Path = None):
if not path: if not path:
path = self.path path = self.path
@ -65,8 +78,6 @@ class Playlist:
def add(self, tracks: List[Path], make_theme: bool = False): def add(self, tracks: List[Path], make_theme: bool = False):
self.path.mkdir(parents=True, exist_ok=True) self.path.mkdir(parents=True, exist_ok=True)
if make_theme: if make_theme:
if source.is_dir():
raise RuntimeError(f"Cannot create a playlist theme from a directory: {source}")
target = self.path / "_theme.mp3" target = self.path / "_theme.mp3"
source = tracks.pop(0) source = tracks.pop(0)
self._add_track(target, source, make_theme=True) self._add_track(target, source, make_theme=True)

View File

@ -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/<playlist_name>") @route("/play/<playlist_name>")
def play(playlist_name=None): def play(playlist_name=None):
if not controller.play_next(playlist_name): if not streamer.load(playlist_name):
return return
return "OK" return "OK"
@route("/skip") @route("/skip")
def skip(): def skip():
if not controller.play_next(): if not streamer.play_next():
return return
return "OK" 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)])

View File

@ -1,43 +1,96 @@
import logging
import os import os
import logging
import queue
import socketserver
from pathlib import Path from pathlib import Path
import bottle
import daemon import daemon
from croaker import path, routes from croaker import path
from croaker.pidfile import pidfile from croaker.pidfile import pidfile
from croaker.controller import Controller
assert routes
app = bottle.default_app()
def _pidfile(terminate_if_running: bool = True): class RequestHandler(socketserver.StreamRequestHandler):
pf = os.environ.get("PIDFILE", None) supported_commands = {
if pf: 'PLAY': "$PLAYLIST_NAME - Switch to the specified playlist.",
pf = Path(pf) 'FFWD': " - Skip to the next track in the playlist.",
else: 'HELP': " - Display command help.",
pf = path.root() / "croaker.pid" 'KTHX': " - Close the current connection.",
return pidfile(pf, terminate_if_running=terminate_if_running) '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 class CroakerServer(socketserver.TCPServer):
logging.info(f"Daemonizing webserver on http://{host}:{port}, pidfile and output in {path.root()}") allow_reuse_address = True
context = daemon.DaemonContext()
context.pidfile = _pidfile() def __init__(self):
context.stdout = open(path.root() / Path("croaker.out"), "wb") self._context = daemon.DaemonContext()
context.stderr = open(path.root() / Path("croaker.err"), "wb", buffering=0) self._queue = queue.Queue()
context.open() self.controller = Controller(self._queue)
start(host, port, debug)
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(): server = CroakerServer()
_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)

68
croaker/streamer.py Normal file
View File

@ -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()

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "croaker" name = "croaker"
version = "0.1.1" version = "0.1.3"
description = "" description = ""
authors = ["evilchili <evilchili@gmail.com>"] authors = ["evilchili <evilchili@gmail.com>"]
readme = "README.md" readme = "README.md"
@ -15,12 +15,12 @@ typer = "^0.9.0"
python-dotenv = "^0.21.0" python-dotenv = "^0.21.0"
rich = "^13.7.0" rich = "^13.7.0"
pyyaml = "^6.0.1" pyyaml = "^6.0.1"
bottle = "^0.12.25"
paste = "^3.7.1" paste = "^3.7.1"
python-daemon = "^3.0.1" python-daemon = "^3.0.1"
requests = "^2.31.0" requests = "^2.31.0"
psutil = "^5.9.8" psutil = "^5.9.8"
exscript = "^2.6.28" exscript = "^2.6.28"
python-shout = "^0.2.8"
[tool.poetry.scripts] [tool.poetry.scripts]
croaker = "croaker.cli:app" croaker = "croaker.cli:app"