dropping bottle/liquidsoap
Replacing both bottle and liquidsoap with a native TCPServer.
This commit is contained in:
parent
5033ddde6e
commit
ddeb91f77d
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)])
|
||||
|
|
|
@ -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
68
croaker/streamer.py
Normal 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()
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue
Block a user