2024-03-04 17:56:32 -08:00
|
|
|
import logging
|
2024-03-05 22:15:51 -08:00
|
|
|
import os
|
2024-03-04 17:56:32 -08:00
|
|
|
import queue
|
|
|
|
import socketserver
|
2024-03-01 01:00:17 -08:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
import daemon
|
|
|
|
|
2024-03-04 17:56:32 -08:00
|
|
|
from croaker import path
|
|
|
|
from croaker.controller import Controller
|
2024-03-05 22:15:51 -08:00
|
|
|
from croaker.pidfile import pidfile
|
2024-03-04 17:56:32 -08:00
|
|
|
|
2024-03-05 22:21:56 -08:00
|
|
|
logger = logging.getLogger('server')
|
|
|
|
|
2024-03-04 17:56:32 -08:00
|
|
|
|
|
|
|
class RequestHandler(socketserver.StreamRequestHandler):
|
2024-03-05 23:25:21 -08:00
|
|
|
"""
|
|
|
|
Instantiated by the TCPServer when a request is received. Implements the
|
|
|
|
command and control protocol and sends commands to the shoutcast controller
|
|
|
|
on behalf of the user.
|
|
|
|
"""
|
2024-03-04 17:56:32 -08:00
|
|
|
supported_commands = {
|
2024-03-05 23:25:21 -08:00
|
|
|
# command # help text
|
2024-03-05 22:15:51 -08:00
|
|
|
"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.",
|
2024-03-04 17:56:32 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
def handle(self):
|
2024-03-05 23:25:21 -08:00
|
|
|
"""
|
|
|
|
Start a command and control session. Commands are read one line at a
|
|
|
|
time; the format is:
|
|
|
|
|
|
|
|
Byte Definition
|
|
|
|
-------------------
|
|
|
|
0-3 Command
|
|
|
|
4 Ignored
|
|
|
|
5+ Arguments
|
|
|
|
"""
|
2024-03-04 17:56:32 -08:00
|
|
|
while True:
|
|
|
|
self.data = self.rfile.readline().strip().decode()
|
2024-03-05 22:21:56 -08:00
|
|
|
logger.debug(f"{self.data = }")
|
2024-03-04 17:56:32 -08:00
|
|
|
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}'")
|
|
|
|
|
2024-03-05 22:15:51 -08:00
|
|
|
if cmd == "KTHX":
|
|
|
|
return self.send("KBAI")
|
2024-03-04 17:56:32 -08:00
|
|
|
|
|
|
|
handler = getattr(self, f"handle_{cmd}", None)
|
|
|
|
if handler:
|
|
|
|
handler(args)
|
|
|
|
else:
|
|
|
|
self.default_handler(cmd, args)
|
|
|
|
|
|
|
|
def send(self, msg):
|
2024-03-05 22:15:51 -08:00
|
|
|
return self.wfile.write(msg.encode() + b"\n")
|
2024-03-04 17:56:32 -08:00
|
|
|
|
|
|
|
def default_handler(self, cmd, args):
|
|
|
|
self.server.tell_controller(f"{cmd} {args}")
|
2024-03-05 22:15:51 -08:00
|
|
|
return self.send("OK")
|
2024-03-04 17:56:32 -08:00
|
|
|
|
|
|
|
def handle_HELP(self, args):
|
2024-03-05 22:15:51 -08:00
|
|
|
return self.send("\n".join(f"{cmd} {txt}" for cmd, txt in self.supported_commands.items()))
|
2024-03-04 17:56:32 -08:00
|
|
|
|
|
|
|
def handle_STOP(self, args):
|
|
|
|
self.send("Shutting down.")
|
|
|
|
self.server.stop()
|
|
|
|
|
2024-03-01 01:00:17 -08:00
|
|
|
|
2024-03-04 17:56:32 -08:00
|
|
|
class CroakerServer(socketserver.TCPServer):
|
2024-03-05 23:25:21 -08:00
|
|
|
"""
|
|
|
|
A Daemonized TCP Server that also starts a Shoutcast source client.
|
|
|
|
"""
|
2024-03-04 17:56:32 -08:00
|
|
|
allow_reuse_address = True
|
2024-03-01 01:00:17 -08:00
|
|
|
|
2024-03-04 17:56:32 -08:00
|
|
|
def __init__(self):
|
|
|
|
self._context = daemon.DaemonContext()
|
|
|
|
self._queue = queue.Queue()
|
|
|
|
self.controller = Controller(self._queue)
|
2024-03-01 01:00:17 -08:00
|
|
|
|
2024-03-05 23:25:21 -08:00
|
|
|
def _pidfile(self):
|
|
|
|
return pidfile(path.root() / "croaker.pid")
|
2024-03-01 01:00:17 -08:00
|
|
|
|
2024-03-04 17:56:32 -08:00
|
|
|
def tell_controller(self, msg):
|
2024-03-05 23:25:21 -08:00
|
|
|
"""
|
|
|
|
Enqueue a message for the shoutcast controller.
|
|
|
|
"""
|
2024-03-04 17:56:32 -08:00
|
|
|
self._queue.put(msg)
|
2024-03-01 01:00:17 -08:00
|
|
|
|
2024-03-05 22:51:04 -08:00
|
|
|
def bind_address(self):
|
|
|
|
return (os.environ["HOST"], int(os.environ["PORT"]))
|
|
|
|
|
2024-03-04 17:56:32 -08:00
|
|
|
def daemonize(self) -> None:
|
2024-03-05 23:25:21 -08:00
|
|
|
"""
|
|
|
|
Daemonize the current process, start the shoutcast controller
|
|
|
|
background thread and then begin listening for connetions.
|
|
|
|
"""
|
2024-03-05 22:51:04 -08:00
|
|
|
logger.info(f"Daemonizing controller on {self.bind_address()}; pidfile and output in {path.root()}")
|
|
|
|
super().__init__(self.bind_address(), RequestHandler)
|
2024-03-01 01:00:17 -08:00
|
|
|
|
2024-03-04 17:56:32 -08:00
|
|
|
self._context.pidfile = self._pidfile()
|
2024-03-05 23:25:21 -08:00
|
|
|
self._context.stdout = open(path.root() / Path("croaker.out"), "wb", buffering=0)
|
2024-03-04 17:56:32 -08:00
|
|
|
self._context.stderr = open(path.root() / Path("croaker.err"), "wb", buffering=0)
|
2024-03-05 23:25:21 -08:00
|
|
|
|
|
|
|
# when open() is called, all open file descriptors will be closed, as
|
|
|
|
# befits a good daemon. However this will also close the socket on
|
|
|
|
# which the TCPServer is listening! So let's keep that one open.
|
2024-03-04 17:56:32 -08:00
|
|
|
self._context.files_preserve = [self.fileno()]
|
|
|
|
self._context.open()
|
|
|
|
try:
|
|
|
|
self.controller.start()
|
|
|
|
self.serve_forever()
|
|
|
|
except KeyboardInterrupt:
|
2024-03-05 22:21:56 -08:00
|
|
|
logger.info("Shutting down.")
|
2024-03-04 17:56:32 -08:00
|
|
|
self.stop()
|
2024-03-01 01:00:17 -08:00
|
|
|
|
2024-03-04 17:56:32 -08:00
|
|
|
def stop(self) -> None:
|
|
|
|
self._pidfile()
|
2024-03-01 01:00:17 -08:00
|
|
|
|
|
|
|
|
2024-03-04 17:56:32 -08:00
|
|
|
server = CroakerServer()
|