Compare commits
25 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2f5d5105c0 | ||
![]() |
0539ce321f | ||
![]() |
7a4cd1ba50 | ||
![]() |
263e439a6d | ||
![]() |
16f246cd30 | ||
![]() |
a4e05cbed1 | ||
![]() |
ddea04a58d | ||
![]() |
26aa401bfe | ||
![]() |
1fbe833d39 | ||
![]() |
f4fa1b1690 | ||
![]() |
7ded43476e | ||
![]() |
c94fb127ed | ||
![]() |
4ee4fb4a73 | ||
![]() |
a5cf97870b | ||
![]() |
205177dca3 | ||
![]() |
d97faca0f7 | ||
![]() |
9fb8d1f248 | ||
![]() |
351b17db69 | ||
![]() |
f6afd06575 | ||
![]() |
27164358ae | ||
![]() |
912d3fccd7 | ||
![]() |
d2f4a85cd5 | ||
![]() |
f3fd8215f0 | ||
![]() |
7417baeeb1 | ||
![]() |
0e6812d6a9 |
26
.coveragerc
Normal file
26
.coveragerc
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
# .coveragerc to control coverage.py
|
||||||
|
[run]
|
||||||
|
branch = True
|
||||||
|
|
||||||
|
[report]
|
||||||
|
# Regexes for lines to exclude from consideration
|
||||||
|
exclude_lines =
|
||||||
|
# Have to re-enable the standard pragma
|
||||||
|
pragma: no cover
|
||||||
|
|
||||||
|
# Don't complain about missing debug-only code:
|
||||||
|
def __repr__
|
||||||
|
if self\.debug
|
||||||
|
|
||||||
|
# Don't complain if tests don't hit defensive assertion code:
|
||||||
|
raise AssertionError
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# Don't complain if non-runnable code isn't run:
|
||||||
|
if 0:
|
||||||
|
if __name__ == .__main__.:
|
||||||
|
|
||||||
|
# Don't complain about abstract methods, they aren't run:
|
||||||
|
@(abc\.)?abstractmethod
|
||||||
|
|
||||||
|
ignore_errors = True
|
131
README.md
131
README.md
|
@ -1,19 +1,38 @@
|
||||||
# Croaker
|
# Croaker
|
||||||
|
|
||||||
A shoutcast audio playlist designed for serving D&D session music.
|
Croaker is a Linux desktop audio player controlled from a TCP server. It is designed specifically to play background music during TTRPG sessions.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Audio playback using VLC
|
||||||
|
* Playlists are built using symlinks
|
||||||
|
* Randomizes playlist order the first time it is cached
|
||||||
|
* Always plays `_theme.mp3` first upon switching to a playlist, if it exists
|
||||||
|
* Controlled by issuing commands over a TCP socket
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
* A functioning shoutcast / icecast server
|
||||||
|
* Python >= 3.11
|
||||||
|
* python3.11-dev
|
||||||
|
|
||||||
|
|
||||||
## What? Why?
|
## What? Why?
|
||||||
|
|
||||||
Because I run an online D&D game, which includes a background music stream for my players. The stream used to be served by liquidsoap and controlled by a bunch of bash scripts I cobbled together which are functional but brittle, and liquidsoap is a nightmare for the small use case. Also, this currently requires me to have a terminal window open to my media server to control liquidsoap directly, and I'd rather integrate the music controls directly with the rest of my DM tools, all of which run on my laptop.
|
I run an online D&D game. For years I have provided my players with an internet radio station playing the session background music. The first version was built using liquidsoap and icecast. The second version replaced liquidsoap with a custom streamer implementation (which is still available on the `shoutcast` branch, warts and all).
|
||||||
|
|
||||||
|
Both of these solutions were functional but high maintenance, and I wanted something simpler both for me and my players.
|
||||||
|
|
||||||
|
This version of Croaker usees VLC (via python-vlc) to play audio locally, and pops up a read-only desktop interface to display what is playing. I share this app using screen sharing during our online games, and control it using my DM tools.
|
||||||
|
|
||||||
*Now that is a powerful yak! -- Aesop Rock (misquoted)*
|
*Now that is a powerful yak! -- Aesop Rock (misquoted)*
|
||||||
|
|
||||||
|
|
||||||
## Quick Start (Server)
|
## Quick Start (Server)
|
||||||
|
|
||||||
This assumes you have a functioning icecast2 installation already.
|
This assumes you have a functioning icecast2/whatever 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,6 +42,8 @@ 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:
|
||||||
|
|
||||||
|
## Controlling The Server
|
||||||
|
|
||||||
```
|
```
|
||||||
% croaker start
|
% croaker start
|
||||||
INFO Daemonizing controller on (localhost, 8003); pidfile and logs in ~/.dnd/croaker
|
INFO Daemonizing controller on (localhost, 8003); pidfile and logs in ~/.dnd/croaker
|
||||||
|
@ -30,38 +51,112 @@ INFO Daemonizing controller on (localhost, 8003); pidfile and logs in ~/.dnd/cro
|
||||||
|
|
||||||
Connnect to the command & control server:
|
Connnect to the command & control server:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
% telnet localhost 8003
|
% telnet localhost 8003
|
||||||
Trying 127.0.0.1...
|
Trying 127.0.0.1...
|
||||||
Connected to croaker.local.
|
Connected to croaker.local.
|
||||||
Escape character is '^]'.
|
Escape character is '^]'.
|
||||||
HELP
|
|
||||||
PLAY $PLAYLIST_NAME - Switch to the specified playlist.
|
help
|
||||||
FFWD - Skip to the next track in the playlist.
|
|
||||||
HELP - Display command help.
|
PLAY PLAYLIST - Load and play the specified playlist.
|
||||||
KTHX - Close the current connection.
|
LIST [PLAYLIST] - List all lplaylists or the contents of a single playlist.
|
||||||
STOP - Stop Croaker.
|
BACK - Return to the previous track in the playlist
|
||||||
OK
|
FFWD - Skip to the next track in the playlist.
|
||||||
|
HELP - Display command help.
|
||||||
|
KTHX - Close the current connection.
|
||||||
|
STOP - Stop the current track and stream silence.
|
||||||
|
STFU - Terminate the Croaker server.
|
||||||
|
```
|
||||||
|
|
||||||
|
List available playlists:
|
||||||
|
|
||||||
|
```
|
||||||
|
list
|
||||||
|
|
||||||
|
battle
|
||||||
|
adventure
|
||||||
|
session_start
|
||||||
```
|
```
|
||||||
|
|
||||||
Switch to battle music -- roll initiative!
|
Switch to battle music -- roll initiative!
|
||||||
|
|
||||||
```
|
```
|
||||||
PLAY battle
|
play battle
|
||||||
OK
|
OK
|
||||||
```
|
```
|
||||||
|
|
||||||
Skip this track and move on to the next:
|
Skip this track and move on to the next:
|
||||||
|
|
||||||
```
|
```
|
||||||
FFWD
|
ffwd
|
||||||
OK
|
OK
|
||||||
```
|
```
|
||||||
|
|
||||||
Stop the server:
|
Stop the music:
|
||||||
|
|
||||||
```
|
```
|
||||||
STOP
|
stop
|
||||||
Shutting down.
|
OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Disconnect:
|
||||||
|
|
||||||
|
```
|
||||||
|
kthx
|
||||||
|
KBAI
|
||||||
Connection closed by foreign host.
|
Connection closed by foreign host.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Python Client Implementation
|
||||||
|
|
||||||
|
Here's a sample client using Ye Olde Socket Library:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import socket
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import cached_property
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CroakerClient():
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def playlists(self):
|
||||||
|
return self.send("LIST").split("\n")
|
||||||
|
|
||||||
|
def list(self, *args):
|
||||||
|
if not args:
|
||||||
|
return self.playlists
|
||||||
|
return self.send(f"LIST {args[0]}")
|
||||||
|
|
||||||
|
def play(self, *args):
|
||||||
|
if not args:
|
||||||
|
return "Error: Must specify the playlist to play."
|
||||||
|
return self.send(f"PLAY {args[0]}")
|
||||||
|
|
||||||
|
def ffwd(self, *args):
|
||||||
|
return self.send("FFWD")
|
||||||
|
|
||||||
|
def stop(self, *args):
|
||||||
|
return self.send("STOP")
|
||||||
|
|
||||||
|
def send(self, msg: str):
|
||||||
|
BUFSIZE = 4096
|
||||||
|
data = bytearray()
|
||||||
|
with socket.create_connection((self.host, self.port)) as sock:
|
||||||
|
sock.sendall(f"{msg}\n".encode())
|
||||||
|
while True:
|
||||||
|
buf = sock.recv(BUFSIZE)
|
||||||
|
data.extend(buf)
|
||||||
|
if len(buf) < BUFSIZE:
|
||||||
|
break
|
||||||
|
sock.sendall(b'KTHX\n')
|
||||||
|
return data.decode()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
client = CroakerClient(host='localhost', port=1234)
|
||||||
|
client.play('session_start')
|
||||||
|
```
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
import logging
|
|
||||||
import queue
|
|
||||||
import threading
|
|
||||||
|
|
||||||
from croaker.playlist import load_playlist
|
|
||||||
from croaker.streamer import AudioStreamer
|
|
||||||
|
|
||||||
logger = logging.getLogger('controller')
|
|
||||||
|
|
||||||
|
|
||||||
class Controller(threading.Thread):
|
|
||||||
"""
|
|
||||||
A background thread started by the CroakerServer instance that controls a
|
|
||||||
shoutcast source streamer. The primary purpose of this class is to allow
|
|
||||||
the command and control server to interrupt streaming operations to
|
|
||||||
skip to a new track or load a new playlist.
|
|
||||||
"""
|
|
||||||
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__()
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
if self._streamer:
|
|
||||||
logging.debug("Sending STOP signal to streamer...")
|
|
||||||
self.stop_event.set()
|
|
||||||
self.playlist = None
|
|
||||||
|
|
||||||
def load(self, playlist_name: str):
|
|
||||||
self.playlist = load_playlist(playlist_name)
|
|
||||||
logger.debug(f"Switching to {self.playlist = }")
|
|
||||||
for track in self.playlist.tracks:
|
|
||||||
self._streamer_queue.put(str(track).encode())
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
logger.debug("Starting AudioStreamer...")
|
|
||||||
self.streamer.start()
|
|
||||||
self.load("session_start")
|
|
||||||
while True:
|
|
||||||
data = self._control_queue.get()
|
|
||||||
logger.debug(f"{data = }")
|
|
||||||
self.process_request(data)
|
|
||||||
|
|
||||||
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:
|
|
||||||
logger.debug("Ignoring invalid command: {cmd} = }")
|
|
||||||
return
|
|
||||||
handler(args)
|
|
||||||
|
|
||||||
def handle_PLAY(self, args):
|
|
||||||
return self.load(args[0])
|
|
||||||
|
|
||||||
def handle_FFWD(self, args):
|
|
||||||
logger.debug("Sending SKIP signal to streamer...")
|
|
||||||
self.skip_event.set()
|
|
||||||
|
|
||||||
def handle_STOP(self):
|
|
||||||
return self.stop()
|
|
|
@ -1,16 +0,0 @@
|
||||||
class APIHandlingException(Exception):
|
|
||||||
"""
|
|
||||||
An API reqeust could not be encoded or decoded.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationError(Exception):
|
|
||||||
"""
|
|
||||||
An error was discovered with the Groove on Demand configuration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidPathError(Exception):
|
|
||||||
"""
|
|
||||||
The specified path was invalid -- either it was not the expected type or wasn't accessible.
|
|
||||||
"""
|
|
|
@ -1,129 +0,0 @@
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import queue
|
|
||||||
import socketserver
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import daemon
|
|
||||||
|
|
||||||
from croaker import path
|
|
||||||
from croaker.controller import Controller
|
|
||||||
from croaker.pidfile import pidfile
|
|
||||||
|
|
||||||
logger = logging.getLogger('server')
|
|
||||||
|
|
||||||
|
|
||||||
class RequestHandler(socketserver.StreamRequestHandler):
|
|
||||||
"""
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
supported_commands = {
|
|
||||||
# command # help text
|
|
||||||
"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):
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
while True:
|
|
||||||
self.data = self.rfile.readline().strip().decode()
|
|
||||||
logger.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()
|
|
||||||
|
|
||||||
|
|
||||||
class CroakerServer(socketserver.TCPServer):
|
|
||||||
"""
|
|
||||||
A Daemonized TCP Server that also starts a Shoutcast source client.
|
|
||||||
"""
|
|
||||||
allow_reuse_address = True
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._context = daemon.DaemonContext()
|
|
||||||
self._queue = queue.Queue()
|
|
||||||
self.controller = Controller(self._queue)
|
|
||||||
|
|
||||||
def _pidfile(self):
|
|
||||||
return pidfile(path.root() / "croaker.pid")
|
|
||||||
|
|
||||||
def tell_controller(self, msg):
|
|
||||||
"""
|
|
||||||
Enqueue a message for the shoutcast controller.
|
|
||||||
"""
|
|
||||||
self._queue.put(msg)
|
|
||||||
|
|
||||||
def bind_address(self):
|
|
||||||
return (os.environ["HOST"], int(os.environ["PORT"]))
|
|
||||||
|
|
||||||
def daemonize(self) -> None:
|
|
||||||
"""
|
|
||||||
Daemonize the current process, start the shoutcast controller
|
|
||||||
background thread and then begin listening for connetions.
|
|
||||||
"""
|
|
||||||
logger.info(f"Daemonizing controller on {self.bind_address()}; pidfile and output in {path.root()}")
|
|
||||||
super().__init__(self.bind_address(), RequestHandler)
|
|
||||||
|
|
||||||
self._context.pidfile = self._pidfile()
|
|
||||||
self._context.stdout = open(path.root() / Path("croaker.out"), "wb", buffering=0)
|
|
||||||
self._context.stderr = open(path.root() / Path("croaker.err"), "wb", buffering=0)
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
self._context.files_preserve = [self.fileno()]
|
|
||||||
self._context.open()
|
|
||||||
try:
|
|
||||||
self.controller.start()
|
|
||||||
self.serve_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
logger.info("Shutting down.")
|
|
||||||
self.stop()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
self._pidfile()
|
|
||||||
|
|
||||||
|
|
||||||
server = CroakerServer()
|
|
|
@ -1,68 +0,0 @@
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
from functools import cached_property
|
|
||||||
from pathlib import Path
|
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
import shout
|
|
||||||
|
|
||||||
logger = logging.getLogger('streamer')
|
|
||||||
|
|
||||||
|
|
||||||
class AudioStreamer(threading.Thread):
|
|
||||||
"""
|
|
||||||
Receive filenames from the controller thread and stream the contents of
|
|
||||||
those files to the icecast server.
|
|
||||||
"""
|
|
||||||
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):
|
|
||||||
logger.debug("Initialized")
|
|
||||||
self._shout.open()
|
|
||||||
while not self.stop_requested.is_set():
|
|
||||||
self._shout.get_connected()
|
|
||||||
track = self.queue.get()
|
|
||||||
logger.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()
|
|
||||||
logger.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,34 +1,38 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "croaker"
|
name = "croaker"
|
||||||
version = "0.1.3"
|
version = "0.9.2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["evilchili <evilchili@gmail.com>"]
|
authors = ["evilchili <evilchili@gmail.com>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
packages = [
|
packages = [
|
||||||
{ include = "croaker" }
|
{ include = "*", from = "src" }
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.8"
|
python = ">=3.11,<4.0"
|
||||||
prompt-toolkit = "^3.0.38"
|
prompt-toolkit = "^3.0.38"
|
||||||
typer = "^0.9.0"
|
python-dotenv = "^1.1.1"
|
||||||
python-dotenv = "^0.21.0"
|
|
||||||
rich = "^13.7.0"
|
|
||||||
pyyaml = "^6.0.1"
|
pyyaml = "^6.0.1"
|
||||||
paste = "^3.7.1"
|
paste = "^3.7.1"
|
||||||
python-daemon = "^3.0.1"
|
|
||||||
requests = "^2.31.0"
|
requests = "^2.31.0"
|
||||||
psutil = "^5.9.8"
|
python-vlc = "^3.0.21203"
|
||||||
exscript = "^2.6.28"
|
pygobject = "3.50.0"
|
||||||
python-shout = "^0.2.8"
|
pytest-cov = "^7.0.0"
|
||||||
|
rich = "^14.1.0"
|
||||||
|
typer = "^0.17.4"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
croaker = "croaker.cli:app"
|
croaker = "croaker.cli:app"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
black = "^23.3.0"
|
pytest = "^8.1.1"
|
||||||
isort = "^5.12.0"
|
|
||||||
pyproject-autoflake = "^1.0.2"
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
|
||||||
|
### SLAM
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 120
|
line-length = 120
|
||||||
|
@ -48,7 +52,8 @@ ignore-init-module-imports = true # exclude __init__.py when removing unused
|
||||||
remove-duplicate-keys = true # remove all duplicate keys in objects
|
remove-duplicate-keys = true # remove all duplicate keys in objects
|
||||||
remove-unused-variables = true # remove unused variables
|
remove-unused-variables = true # remove unused variables
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
log_cli_level = "DEBUG"
|
||||||
|
addopts = "--cov=src --cov-report=term-missing"
|
||||||
|
|
||||||
[build-system]
|
### ENDSLAM
|
||||||
requires = ["poetry-core"]
|
|
||||||
build-backend = "poetry.core.masonry.api"
|
|
||||||
|
|
BIN
src/croaker/assets/froghat.png
Normal file
BIN
src/croaker/assets/froghat.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 52 KiB |
16
src/croaker/assets/style.css
Normal file
16
src/croaker/assets/style.css
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
window {
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork {
|
||||||
|
background: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now_playing {
|
||||||
|
color: #FFF;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
|
@ -10,53 +10,38 @@ import typer
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from typing_extensions import Annotated
|
from typing_extensions import Annotated
|
||||||
|
|
||||||
import croaker.path
|
from croaker import path
|
||||||
from croaker.exceptions import ConfigurationError
|
from croaker.player import Player
|
||||||
from croaker.playlist import Playlist
|
from croaker.playlist import Playlist
|
||||||
from croaker.server import server
|
|
||||||
|
|
||||||
SETUP_HELP = """
|
SETUP_HELP = f"""
|
||||||
# Root directory for croaker configuration and logs. See also croaker --root.
|
# Root directory for croaker configuration and logs. See also croaker --root.
|
||||||
CROAKER_ROOT=~/.dnd/croaker
|
CROAKER_ROOT={path.root()}
|
||||||
|
|
||||||
# where to store playlist sources
|
# where to store playlist sources
|
||||||
#PLAYLIST_ROOT=$CROAKER_ROOT/playlists
|
#PLAYLIST_ROOT={path.root()}/playlists
|
||||||
|
|
||||||
# where to cache transcoded media files
|
|
||||||
#CACHE_ROOT=$CROAKER_ROOT/cache
|
|
||||||
|
|
||||||
# Where the record the daemon's PID
|
# Where the record the daemon's PID
|
||||||
#PIDFILE=$CROAKER_ROOT/croaker.pid
|
#PIDFILE={path.root()}/croaker.pid
|
||||||
|
|
||||||
# Command and Control TCP Server bind address
|
# Command and Control TCP Server bind address
|
||||||
HOST=0.0.0.0
|
HOST=127.0.0.1
|
||||||
PORT=8003
|
PORT=8003
|
||||||
|
|
||||||
# the kinds of files to add to playlists
|
# the kinds of files to add to playlists
|
||||||
MEDIA_GLOB=*.mp3,*.flac,*.m4a
|
MEDIA_GLOB=*.mp3,*.flac,*.m4a
|
||||||
|
|
||||||
# If defined, transcode media before streaming it, and cache it to disk. The
|
|
||||||
# strings INFILE and OUTFILE will be replaced with the media source file and
|
|
||||||
# the cached output location, respectively.
|
|
||||||
TRANSCODER=/usr/bin/ffmpeg -i INFILE '-hide_banner -loglevel error -codec:v copy -codec:a libmp3lame -q:a 2' OUTFILE
|
|
||||||
|
|
||||||
# Icecast2 configuration for Liquidsoap
|
|
||||||
ICECAST_PASSWORD=
|
|
||||||
ICECAST_MOUNT=
|
|
||||||
ICECAST_HOST=
|
|
||||||
ICECAST_PORT=
|
|
||||||
ICECAST_URL=
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
app = typer.Typer()
|
app = typer.Typer()
|
||||||
app_state = {}
|
app_state = {}
|
||||||
|
|
||||||
logger = logging.getLogger('cli')
|
logger = logging.getLogger("cli")
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
@app.callback(invoke_without_command=True)
|
||||||
def main(
|
def main(
|
||||||
context: typer.Context,
|
ctx: typer.Context,
|
||||||
root: Optional[Path] = typer.Option(
|
root: Optional[Path] = typer.Option(
|
||||||
Path("~/.dnd/croaker"),
|
Path("~/.dnd/croaker"),
|
||||||
help="Path to the Croaker environment",
|
help="Path to the Croaker environment",
|
||||||
|
@ -75,44 +60,29 @@ def main(
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(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,
|
||||||
)
|
)
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
try:
|
return start()
|
||||||
croaker.path.root()
|
|
||||||
croaker.path.playlist_root()
|
|
||||||
except ConfigurationError as e:
|
|
||||||
sys.stderr.write(f"{e}\n\n{SETUP_HELP}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def setup(context: typer.Context):
|
def setup():
|
||||||
"""
|
"""
|
||||||
(Re)Initialize Croaker.
|
(Re)Initialize Croaker.
|
||||||
"""
|
"""
|
||||||
sys.stderr.write("Interactive setup is not yet available. Sorry!\n")
|
sys.stderr.write(
|
||||||
|
"Interactive setup is not available, but you can redirect "
|
||||||
|
"this command's output to a defaults file of your choice.\n"
|
||||||
|
)
|
||||||
print(dedent(SETUP_HELP))
|
print(dedent(SETUP_HELP))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def start(
|
def start():
|
||||||
context: typer.Context,
|
|
||||||
daemonize: bool = typer.Option(True, help="Daemonize the server."),
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Start the Croaker command and control server.
|
Start the Croaker audio player.
|
||||||
"""
|
"""
|
||||||
if daemonize:
|
player = Player()
|
||||||
server.daemonize()
|
player.run()
|
||||||
else:
|
|
||||||
server.start()
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def stop():
|
|
||||||
"""
|
|
||||||
Terminate the server.
|
|
||||||
"""
|
|
||||||
server.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
|
@ -141,4 +111,4 @@ def add(
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.main()
|
app()
|
185
src/croaker/gui.py
Normal file
185
src/croaker/gui.py
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import gi
|
||||||
|
import vlc
|
||||||
|
|
||||||
|
from croaker import path
|
||||||
|
from croaker.playlist import Playlist, load_playlist
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("Gdk", "4.0")
|
||||||
|
from gi.repository import Gdk, GLib, GObject, Gtk, Pango # noqa E402
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerWindow(Gtk.ApplicationWindow):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self._max_width = 300
|
||||||
|
self._max_height = 330
|
||||||
|
self._artwork_width = self._max_width
|
||||||
|
self._artwork_height = 248
|
||||||
|
|
||||||
|
css_provider = Gtk.CssProvider()
|
||||||
|
css_provider.load_from_path(str(path.assets() / "style.css"))
|
||||||
|
Gtk.StyleContext.add_provider_for_display(
|
||||||
|
Gdk.Display.get_default(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||||
|
)
|
||||||
|
self.set_title("Croaker Radio")
|
||||||
|
self._root = Gtk.Fixed()
|
||||||
|
self._root.set_size_request(self._max_width, self._max_height)
|
||||||
|
self.set_child(self._root)
|
||||||
|
|
||||||
|
self._artwork = Gtk.Fixed()
|
||||||
|
self._track = None
|
||||||
|
self._artist = None
|
||||||
|
self._album = None
|
||||||
|
|
||||||
|
self._draw_window()
|
||||||
|
|
||||||
|
def _draw_window(self):
|
||||||
|
margin_size = 8
|
||||||
|
label_width = self._max_width - (2 * margin_size)
|
||||||
|
label_height = 16
|
||||||
|
label_spacing = 8
|
||||||
|
|
||||||
|
self._artwork.set_size_request(self._artwork_width, self._artwork_height)
|
||||||
|
self._root.put(self._artwork, 0, 0)
|
||||||
|
self.draw_artwork()
|
||||||
|
|
||||||
|
def label(text: str):
|
||||||
|
l = Gtk.Label()
|
||||||
|
l.set_ellipsize(Pango.EllipsizeMode.END)
|
||||||
|
l.add_css_class("label")
|
||||||
|
l.set_text(text)
|
||||||
|
l.set_size_request(label_width, label_height)
|
||||||
|
l.set_justify(Gtk.Justification.LEFT)
|
||||||
|
l.set_hexpand(True)
|
||||||
|
l.set_xalign(0)
|
||||||
|
return l
|
||||||
|
|
||||||
|
self._track = label("CROAKER RADIO")
|
||||||
|
self._track.add_css_class("now_playing")
|
||||||
|
self._root.put(self._track, margin_size, self._artwork_height + label_spacing)
|
||||||
|
|
||||||
|
self._artist = label("Artist")
|
||||||
|
self._root.put(self._artist, margin_size, self._artwork_height + (2 * label_spacing) + label_height)
|
||||||
|
|
||||||
|
self._album = label("Album")
|
||||||
|
self._root.put(self._album, margin_size, self._artwork_height + (3 * label_spacing) + (2 * label_height))
|
||||||
|
|
||||||
|
def now_playing(self, track: str, artist: str, album: str):
|
||||||
|
self._track.set_text(f"🎵 {track}")
|
||||||
|
self._artist.set_text(f"🐸 {artist}")
|
||||||
|
self._album.set_text(f"💿 {album}")
|
||||||
|
|
||||||
|
def draw_artwork(self):
|
||||||
|
image1 = Gtk.Image()
|
||||||
|
image1.set_from_file(str(path.assets() / "froghat.png"))
|
||||||
|
image1.set_size_request(self._artwork_width, self._artwork_height)
|
||||||
|
image1.add_css_class("artwork")
|
||||||
|
self._artwork.put(image1, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class GUI(Gtk.Application):
|
||||||
|
"""
|
||||||
|
A simple GTK application that instaniates a VLC player and listens for commands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self._playlist: Playlist | None = None
|
||||||
|
|
||||||
|
self._vlc_instance = vlc.Instance("--loop")
|
||||||
|
self._media_list_player = vlc.MediaListPlayer()
|
||||||
|
self._player.audio_set_volume(30)
|
||||||
|
|
||||||
|
self._signal_handler = threading.Thread(target=self._wait_for_signals)
|
||||||
|
self._signal_handler.daemon = True
|
||||||
|
|
||||||
|
self.play_requested = threading.Event()
|
||||||
|
self.back_requested = threading.Event()
|
||||||
|
self.ffwd_requested = threading.Event()
|
||||||
|
self.stop_requested = threading.Event()
|
||||||
|
self.load_requested = threading.Event()
|
||||||
|
self.clear_requested = threading.Event()
|
||||||
|
self.shutdown_requested = threading.Event()
|
||||||
|
|
||||||
|
GLib.set_application_name("Croaker Radio")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _player(self):
|
||||||
|
return self._media_list_player.get_media_player()
|
||||||
|
|
||||||
|
def do_activate(self):
|
||||||
|
self._signal_handler.start()
|
||||||
|
self._window = PlayerWindow(application=self)
|
||||||
|
self._window.present()
|
||||||
|
|
||||||
|
def load(self, playlist_name: str):
|
||||||
|
self.clear()
|
||||||
|
self._playlist = load_playlist(playlist_name)
|
||||||
|
|
||||||
|
media = self._vlc_instance.media_list_new()
|
||||||
|
for track in self._playlist.tracks:
|
||||||
|
media.add_media(self._vlc_instance.media_new(track))
|
||||||
|
|
||||||
|
self._media_list_player.set_media_list(media)
|
||||||
|
self._media_list_player.play()
|
||||||
|
self._update_now_playing()
|
||||||
|
events = self._player.event_manager()
|
||||||
|
events.event_attach(vlc.EventType.MediaPlayerMediaChanged, self._update_now_playing)
|
||||||
|
|
||||||
|
def _update_now_playing(self, event=None):
|
||||||
|
track = "[NOTHING PLAYING]"
|
||||||
|
artist = "artist"
|
||||||
|
album = "album"
|
||||||
|
media = self._player.get_media()
|
||||||
|
if media:
|
||||||
|
media.parse()
|
||||||
|
track = media.get_meta(vlc.Meta.Title)
|
||||||
|
artist = media.get_meta(vlc.Meta.Artist)
|
||||||
|
album = media.get_meta(vlc.Meta.Album)
|
||||||
|
self._window.now_playing(track, artist, album)
|
||||||
|
|
||||||
|
def _wait_for_signals(self):
|
||||||
|
while not self.shutdown_requested.is_set():
|
||||||
|
if self.play_requested.is_set():
|
||||||
|
self.play_requested.clear()
|
||||||
|
GLib.idle_add(self._media_list_player.play)
|
||||||
|
|
||||||
|
if self.back_requested.is_set():
|
||||||
|
self.back_requested.clear()
|
||||||
|
GLib.idle_add(self._media_list_player.previous)
|
||||||
|
|
||||||
|
if self.ffwd_requested.is_set():
|
||||||
|
self.ffwd_requested.clear()
|
||||||
|
GLib.idle_add(self._media_list_player.next)
|
||||||
|
|
||||||
|
if self.stop_requested.is_set():
|
||||||
|
self.stop_requested.clear()
|
||||||
|
GLib.idle_add(self._media_list_player.stop)
|
||||||
|
|
||||||
|
if self.load_requested.is_set():
|
||||||
|
self.load_requested.clear()
|
||||||
|
GLib.idle_add(self._media_list_player.load)
|
||||||
|
|
||||||
|
if self.clear_requested.is_set():
|
||||||
|
self.clear_requested.clear()
|
||||||
|
GLib.idle_add(self.clear)
|
||||||
|
|
||||||
|
time.sleep(0.25)
|
||||||
|
GLib.idle_add(self.quit)
|
||||||
|
exit()
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
if self._media_list_player:
|
||||||
|
self._media_list_player.stop()
|
||||||
|
self._playlist = None
|
||||||
|
|
||||||
|
def quit(self):
|
||||||
|
self.clear()
|
||||||
|
self._vlc_instance.release()
|
||||||
|
exit()
|
|
@ -9,9 +9,8 @@ def root():
|
||||||
return Path(os.environ.get("CROAKER_ROOT", "~/.dnd/croaker")).expanduser()
|
return Path(os.environ.get("CROAKER_ROOT", "~/.dnd/croaker")).expanduser()
|
||||||
|
|
||||||
|
|
||||||
def cache_root():
|
def assets():
|
||||||
path = Path(os.environ.get("CACHE_ROOT", root() / "cache")).expanduser()
|
return Path(__file__).parent / "assets"
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def playlist_root():
|
def playlist_root():
|
|
@ -5,7 +5,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from daemon import pidfile as _pidfile
|
from daemon import pidfile as _pidfile
|
||||||
|
|
||||||
logger = logging.getLogger('daemon')
|
logger = logging.getLogger("daemon")
|
||||||
|
|
||||||
|
|
||||||
def pidfile(pidfile_path: Path, sig=signal.SIGQUIT, terminate_if_running: bool = True):
|
def pidfile(pidfile_path: Path, sig=signal.SIGQUIT, terminate_if_running: bool = True):
|
31
src/croaker/player.py
Normal file
31
src/croaker/player.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
from croaker.gui import GUI
|
||||||
|
from croaker.server import Controller
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import GLib, GObject, Gtk # noqa E402
|
||||||
|
|
||||||
|
logger = logging.getLogger("player")
|
||||||
|
|
||||||
|
|
||||||
|
class Player(GUI):
|
||||||
|
"""
|
||||||
|
A GTK GUI application with a TCP command and control server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._controller = threading.Thread(target=self._start_controller)
|
||||||
|
self._controller.daemon = True
|
||||||
|
|
||||||
|
def do_activate(self):
|
||||||
|
self._controller.start()
|
||||||
|
super().do_activate()
|
||||||
|
self.load("session_start")
|
||||||
|
|
||||||
|
def _start_controller(self):
|
||||||
|
Controller(self).serve_forever(poll_interval=0.25)
|
|
@ -9,12 +9,10 @@ from typing import List
|
||||||
|
|
||||||
import croaker.path
|
import croaker.path
|
||||||
|
|
||||||
logger = logging.getLogger('playlist')
|
logger = logging.getLogger("playlist")
|
||||||
|
|
||||||
playlists = {}
|
playlists = {}
|
||||||
|
|
||||||
NowPlaying = None
|
|
||||||
|
|
||||||
|
|
||||||
def _stripped(name):
|
def _stripped(name):
|
||||||
name.replace('"', "")
|
name.replace('"', "")
|
||||||
|
@ -25,21 +23,16 @@ 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")
|
||||||
|
|
||||||
@property
|
|
||||||
def current(self):
|
|
||||||
return self.tracks[self.position]
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def path(self):
|
def path(self):
|
||||||
return croaker.path.playlist_root() / Path(self.name)
|
return self._get_path()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def tracks(self):
|
def tracks(self):
|
||||||
if not self.path.exists():
|
if not self.path.exists():
|
||||||
raise RuntimeError(f"Playlist {self.name} not found at {self.path}.")
|
raise RuntimeError(f"Playlist {self.name} not found at {self.path}.") # pragma: no cover
|
||||||
|
|
||||||
entries = []
|
entries = []
|
||||||
theme = self.path / self.theme
|
theme = self.path / self.theme
|
||||||
|
@ -51,25 +44,17 @@ class Playlist:
|
||||||
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
|
||||||
logging.debug(f"Getting files matching {os.environ['MEDIA_GLOB']} from {path}")
|
logging.debug(f"Getting files matching {os.environ['MEDIA_GLOB']} from {path}")
|
||||||
pats = os.environ["MEDIA_GLOB"].split(",")
|
pats = os.environ["MEDIA_GLOB"].split(",")
|
||||||
return chain(*[list(path.glob(pat)) for pat in pats])
|
return chain(*[list(path.rglob(pat)) for pat in pats])
|
||||||
|
|
||||||
def _add_track(self, target: Path, source: Path, make_theme: bool = False):
|
def _get_path(self):
|
||||||
if source.is_dir():
|
return croaker.path.playlist_root() / self.name
|
||||||
for file in self.get_audio_files(source):
|
|
||||||
self._add_track(self.path / _stripped(file.name), file)
|
def _add_track(self, target: Path, source: Path):
|
||||||
return
|
|
||||||
if target.exists():
|
if target.exists():
|
||||||
if not target.is_symlink():
|
if not target.is_symlink():
|
||||||
logging.warning(f"{target}: target already exists and is not a symlink; skipping.")
|
logging.warning(f"{target}: target already exists and is not a symlink; skipping.")
|
||||||
|
@ -77,14 +62,26 @@ class Playlist:
|
||||||
target.unlink()
|
target.unlink()
|
||||||
target.symlink_to(source)
|
target.symlink_to(source)
|
||||||
|
|
||||||
def add(self, tracks: List[Path], make_theme: bool = False):
|
def add(self, paths: List[Path], make_theme: bool = False):
|
||||||
|
logger.debug(f"Adding everything from {paths = }")
|
||||||
self.path.mkdir(parents=True, exist_ok=True)
|
self.path.mkdir(parents=True, exist_ok=True)
|
||||||
if make_theme:
|
for path in paths:
|
||||||
target = self.path / "_theme.mp3"
|
if path.is_dir():
|
||||||
source = tracks.pop(0)
|
files = list(self.get_audio_files(path))
|
||||||
self._add_track(target, source, make_theme=True)
|
if make_theme:
|
||||||
for track in tracks:
|
logger.debug(f"Adding first file from dir as theme: {files[0] = }")
|
||||||
self._add_track(target=self.path / _stripped(track.name), source=track)
|
self._add_track(self.path / "_theme.mp3", files.pop(0))
|
||||||
|
make_theme = False
|
||||||
|
for file in files:
|
||||||
|
logger.debug(f"Adding {file = }")
|
||||||
|
self._add_track(target=self.path / _stripped(file.name), source=file)
|
||||||
|
elif make_theme:
|
||||||
|
logger.debug(f"Adding path as theme: {path = }")
|
||||||
|
self._add_track(self.path / "_theme.mp3", path)
|
||||||
|
make_theme = False
|
||||||
|
else:
|
||||||
|
logger.debug(f"Adding {path = }")
|
||||||
|
self._add_track(target=self.path / _stripped(path.name), source=path)
|
||||||
return sorted(self.get_audio_files())
|
return sorted(self.get_audio_files())
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
@ -93,7 +90,7 @@ class Playlist:
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def load_playlist(name: str):
|
def load_playlist(name: str): # pragma: no cover
|
||||||
if name not in playlists:
|
if name not in playlists:
|
||||||
playlists[name] = Playlist(name=name)
|
playlists[name] = Playlist(name=name)
|
||||||
return playlists[name]
|
return playlists[name]
|
118
src/croaker/server.py
Normal file
118
src/croaker/server.py
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import time
|
||||||
|
|
||||||
|
from croaker.gui import GUI
|
||||||
|
from croaker.path import playlist_root
|
||||||
|
from croaker.playlist import load_playlist
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestHandler(socketserver.StreamRequestHandler):
|
||||||
|
"""
|
||||||
|
Instantiated by the TCPServer when a request is received. Implements the
|
||||||
|
command and control protocol and issues commands to the GUI application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
supported_commands = {
|
||||||
|
# command # help text
|
||||||
|
"PLAY": "PLAYLIST - Switch to the specified playlist.",
|
||||||
|
"LIST": "[PLAYLIST] - List playlists or contents of the specified list.",
|
||||||
|
"FFWD": " - Skip to the next track in the playlist.",
|
||||||
|
"HELP": " - Display command help.",
|
||||||
|
"KTHX": " - Close the current connection.",
|
||||||
|
"STOP": " - Stop the current track and stream silence.",
|
||||||
|
"STFU": " - Terminate the Croaker server.",
|
||||||
|
}
|
||||||
|
|
||||||
|
should_listen = True
|
||||||
|
|
||||||
|
def handle(self):
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
while self.should_listen:
|
||||||
|
time.sleep(0.01)
|
||||||
|
self.data = self.rfile.readline().strip().decode()
|
||||||
|
logger.debug(f"Received: {self.data}")
|
||||||
|
try:
|
||||||
|
cmd = self.data[0:4].strip().upper()
|
||||||
|
if not cmd:
|
||||||
|
continue
|
||||||
|
elif cmd not in self.supported_commands:
|
||||||
|
self.send(f"ERR Unknown Command '{cmd}'")
|
||||||
|
except IndexError:
|
||||||
|
self.send(f"ERR Command not understood '{cmd}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
args = self.data[5:]
|
||||||
|
if cmd == "KTHX":
|
||||||
|
return self.send("KBAI")
|
||||||
|
|
||||||
|
handler = getattr(self, f"handle_{cmd}", None)
|
||||||
|
if not handler:
|
||||||
|
self.send(f"ERR No handler for {cmd}.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
handler(args)
|
||||||
|
|
||||||
|
def send(self, msg):
|
||||||
|
return self.wfile.write(msg.encode() + b"\n")
|
||||||
|
|
||||||
|
def handle_PLAY(self, args):
|
||||||
|
self.server.player.load(args)
|
||||||
|
return self.send("OK")
|
||||||
|
|
||||||
|
def handle_BACK(self, args):
|
||||||
|
self.server.player.back_requested.set()
|
||||||
|
return self.send("OK")
|
||||||
|
|
||||||
|
def handle_FFWD(self, args):
|
||||||
|
self.server.player.ffwd_requested.set()
|
||||||
|
return self.send("OK")
|
||||||
|
|
||||||
|
def handle_LIST(self, args):
|
||||||
|
return self.send(self.server.list(args))
|
||||||
|
|
||||||
|
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):
|
||||||
|
return self.server.player.stop_requested.set()
|
||||||
|
|
||||||
|
def handle_STFU(self, args):
|
||||||
|
self.send("Shutting down.")
|
||||||
|
self.server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
class Controller(socketserver.TCPServer):
|
||||||
|
"""
|
||||||
|
A TCP Server that listens for commands and proxies the GUI audio player.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, player: GUI):
|
||||||
|
self.player = player
|
||||||
|
super().__init__((os.environ["HOST"], int(os.environ["PORT"])), RequestHandler)
|
||||||
|
|
||||||
|
def server_bind(self):
|
||||||
|
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
self.socket.bind(self.server_address)
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self.player.shutdown_requested.set()
|
||||||
|
exit()
|
||||||
|
|
||||||
|
def list(self, playlist_name: str = None):
|
||||||
|
if playlist_name:
|
||||||
|
return str(load_playlist(playlist_name))
|
||||||
|
return "\n".join([str(p.name) for p in playlist_root().iterdir()])
|
BIN
src/croaker/silence.mp3
Normal file
BIN
src/croaker/silence.mp3
Normal file
Binary file not shown.
21
test/conftest.py
Normal file
21
test/conftest.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_env(monkeypatch):
|
||||||
|
fixtures = Path(__file__).parent / "fixtures"
|
||||||
|
monkeypatch.setenv("CROAKER_ROOT", str(fixtures))
|
||||||
|
monkeypatch.setenv("MEDIA_GLOB", "*.mp3,*.foo,*.bar")
|
||||||
|
monkeypatch.setenv("ICECAST_URL", "http://127.0.0.1")
|
||||||
|
monkeypatch.setenv("ICECAST_HOST", "localhost")
|
||||||
|
monkeypatch.setenv("ICECAST_MOUNT", "mount")
|
||||||
|
monkeypatch.setenv("ICECAST_PORT", "6523")
|
||||||
|
monkeypatch.setenv("ICECAST_PASSWORD", "password")
|
||||||
|
monkeypatch.setenv("DEBUG", "1")
|
||||||
|
|
||||||
|
logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.DEBUG)
|
||||||
|
# logging.getLogger('transcoder').setLevel(logging.INFO)
|
||||||
|
# logging.getLogger('root').setLevel(logging.INFO)
|
1
test/fixtures/playlists/test_playlist/_theme.mp3
vendored
Normal file
1
test/fixtures/playlists/test_playlist/_theme.mp3
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
_theme.mp3
|
0
test/fixtures/playlists/test_playlist/one.baz
vendored
Normal file
0
test/fixtures/playlists/test_playlist/one.baz
vendored
Normal file
0
test/fixtures/playlists/test_playlist/one.foo
vendored
Normal file
0
test/fixtures/playlists/test_playlist/one.foo
vendored
Normal file
1
test/fixtures/playlists/test_playlist/one.mp3
vendored
Normal file
1
test/fixtures/playlists/test_playlist/one.mp3
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
one.mp3
|
1
test/fixtures/playlists/test_playlist/two.mp3
vendored
Normal file
1
test/fixtures/playlists/test_playlist/two.mp3
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
two.mp3
|
0
test/fixtures/sources/album/"one" - two'.mp3
vendored
Normal file
0
test/fixtures/sources/album/"one" - two'.mp3
vendored
Normal file
0
test/fixtures/sources/one.mp3
vendored
Normal file
0
test/fixtures/sources/one.mp3
vendored
Normal file
0
test/fixtures/sources/two.mp3
vendored
Normal file
0
test/fixtures/sources/two.mp3
vendored
Normal file
BIN
test/fixtures/transcoded_silence.mp3
vendored
Normal file
BIN
test/fixtures/transcoded_silence.mp3
vendored
Normal file
Binary file not shown.
44
test/test_playlist.py
Normal file
44
test/test_playlist.py
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import croaker.path
|
||||||
|
import croaker.playlist
|
||||||
|
|
||||||
|
|
||||||
|
def test_playlist_loading():
|
||||||
|
pl = croaker.playlist.Playlist(name="test_playlist")
|
||||||
|
path = str(pl.path)
|
||||||
|
tracks = [str(t) for t in pl.tracks]
|
||||||
|
|
||||||
|
assert path == str(croaker.path.playlist_root() / pl.name)
|
||||||
|
assert pl.name == "test_playlist"
|
||||||
|
assert tracks[0] == f"{path}/_theme.mp3"
|
||||||
|
assert f"{path}/one.mp3" in tracks
|
||||||
|
assert f"{path}/two.mp3" in tracks
|
||||||
|
assert f"{path}/one.foo" in tracks
|
||||||
|
assert f"{path}/one.baz" not in tracks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"paths, make_theme, expected_count",
|
||||||
|
[
|
||||||
|
(["test_playlist"], True, 4),
|
||||||
|
(["test_playlist"], False, 4),
|
||||||
|
(["test_playlist", "sources/one.mp3"], True, 5),
|
||||||
|
(["test_playlist", "sources/one.mp3"], False, 5),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_playlist_creation(monkeypatch, paths, make_theme, expected_count):
|
||||||
|
new_symlinks = []
|
||||||
|
|
||||||
|
def symlink(target):
|
||||||
|
new_symlinks.append(target)
|
||||||
|
|
||||||
|
pl = croaker.playlist.Playlist(name="foo")
|
||||||
|
monkeypatch.setattr(croaker.playlist.Path, "unlink", MagicMock())
|
||||||
|
monkeypatch.setattr(croaker.playlist.Path, "symlink_to", MagicMock(side_effect=symlink))
|
||||||
|
monkeypatch.setattr(croaker.playlist.Path, "mkdir", MagicMock())
|
||||||
|
|
||||||
|
pl.add([croaker.path.playlist_root() / p for p in paths], make_theme)
|
||||||
|
assert len(new_symlinks) == expected_count
|
Loading…
Reference in New Issue
Block a user