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.
```
% 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
```

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

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>")
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)])

View File

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

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]
name = "croaker"
version = "0.1.1"
version = "0.1.3"
description = ""
authors = ["evilchili <evilchili@gmail.com>"]
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"