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.
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)])
|
||||||
|
|
|
@ -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
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]
|
[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"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user