Compare commits

...

30 Commits
0.1.1 ... main

Author SHA1 Message Date
evilchili
2f5d5105c0 update README 2025-09-14 15:51:42 -07:00
evilchili
0539ce321f clean up dependencies 2025-09-14 15:23:15 -07:00
evilchili
7a4cd1ba50 Replace shoutcast implementation with vlc+gtk app 2025-09-14 14:46:38 -07:00
evilchili
263e439a6d Rebuild audio streamer event handlers 2025-08-28 23:07:50 -07:00
evilchili
16f246cd30 fix end-of-stream bug 2024-09-02 09:54:11 -07:00
evilchili
a4e05cbed1 typo 2024-06-03 00:10:00 -07:00
evilchili
ddea04a58d formatting 2024-06-03 00:09:15 -07:00
evilchili
26aa401bfe formatting 2024-06-02 23:57:37 -07:00
evilchili
1fbe833d39 frame-aligned chunks 2024-06-02 23:55:12 -07:00
evilchili
f4fa1b1690 simplified streamer code 2024-04-27 11:47:25 -07:00
evilchili
7ded43476e restructure project for poetry-slam 2024-03-26 00:51:54 -07:00
evilchili
c94fb127ed added buffered reads from disk io and transcoding 2024-03-17 15:19:33 -07:00
evilchili
4ee4fb4a73 adding unit tests of streamer 2024-03-17 14:44:36 -07:00
evilchili
a5cf97870b remove dead code, add pidfile tests 2024-03-10 12:08:45 -07:00
evilchili
205177dca3 adding transcoder test coverage 2024-03-10 11:45:24 -07:00
evilchili
d97faca0f7 updating readme 2024-03-10 00:27:34 -08:00
evilchili
9fb8d1f248 updating readme 2024-03-10 00:25:57 -08:00
evilchili
351b17db69 readme update 2024-03-10 00:17:38 -08:00
evilchili
f6afd06575 updating setup defaults 2024-03-10 00:06:38 -08:00
evilchili
27164358ae Adding transcoader / recoverable errors 2024-03-10 00:05:24 -08:00
evilchili
912d3fccd7 rescuing CPU from endless loop hell on interactive console 2024-03-08 14:40:44 -08:00
evilchili
d2f4a85cd5 adding tests 2024-03-08 13:18:25 -08:00
evilchili
f3fd8215f0 adding playlist tests 2024-03-07 21:51:48 -08:00
evilchili
7417baeeb1 Eliminated need for Controller class 2024-03-06 20:09:59 -08:00
evilchili
0e6812d6a9 bumping version 2024-03-05 23:42:10 -08:00
evilchili
aca24f6a4d docs 2024-03-05 23:25:21 -08:00
evilchili
affcf2d7dc updating readme 2024-03-05 22:51:04 -08:00
evilchili
5f4418bbf6 logging configs 2024-03-05 22:21:56 -08:00
evilchili
c9eab2d2c0 formatting updates 2024-03-05 22:15:51 -08:00
evilchili
ddeb91f77d dropping bottle/liquidsoap
Replacing both bottle and liquidsoap with a native TCPServer.
2024-03-05 22:05:26 -08:00
32 changed files with 753 additions and 568 deletions

26
.coveragerc Normal file
View 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

132
README.md
View File

@ -1,16 +1,36 @@
# Croaker # Croaker
A command-and-control web application for icecast / liquidaudio, with helpers for 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 is controlled by a bunch of bash scripts I cobbled together which are functional but brittle. 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. A web-based commmand-and-control app lets me use vanilla HTTP requests to control liquidsoap. 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.
``` ```
% mkdir -p ~/.dnd/croaker % mkdir -p ~/.dnd/croaker
@ -22,35 +42,121 @@ 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
Daemonizing webserver on http://0.0.0.0:8003, pidfile and output in ~/.dnd/croaker INFO Daemonizing controller on (localhost, 8003); pidfile and logs in ~/.dnd/croaker
``` ```
## Quick Start (Client) Connnect to the command & control server:
```bash
% telnet localhost 8003
Trying 127.0.0.1...
Connected to croaker.local.
Escape character is '^]'.
help
PLAY PLAYLIST - Load and play the specified playlist.
LIST [PLAYLIST] - List all lplaylists or the contents of a single playlist.
BACK - Return to the previous track in the playlist
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.
``` ```
% mkdir -p ~/.dnd/croaker
% croaker setup > ~/.dnd/croaker/defaults # only the client config is required List available playlists:
% vi ~/.dnd/croaker/defaults # adjust to taste
```
list
battle
adventure
session_start
``` ```
Switch to battle music -- roll initiative! Switch to battle music -- roll initiative!
``` ```
% croaker play battle play battle
OK OK
``` ```
Skip this track and move on to the next: Skip this track and move on to the next:
``` ```
% croaker skip ffwd
OK OK
``` ```
Stop the server: Stop the music:
``` ```
% croaker stop stop
OK OK
``` ```
Disconnect:
```
kthx
KBAI
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')
```

View File

@ -1,207 +0,0 @@
import io
import logging
import os
import sys
from pathlib import Path
from textwrap import dedent
from typing import List, Optional
import typer
from dotenv import load_dotenv
from typing_extensions import Annotated
import croaker.path
from croaker import client, controller, server
from croaker.exceptions import ConfigurationError
from croaker.playlist import Playlist
SETUP_HELP = """
# Root directory for croaker configuration and logs. See also croaker --root.
CROAKER_ROOT=~/.dnd/croaker
## COMMAND AND CONTROL WEBSERVER
# Please make sure you set SECRET_KEY in your environment if you are running
# the command and control webserver. Clients do not need this.
SECRET_KEY=
# Where the record the webserver daemon's PID
PIDFILE=~/.dnd/croaker/croaker.pid
# Web interface configuration
HOST=127.0.0.1
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
PLAYLIST_ROOT=~/.dnd/croaker/playlists
# where to cache transcoded media files
CACHE_ROOT=~/.dnd/croaker/cache
# the kinds of files to add to playlists
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
## LIQUIDSOAP AND ICECAST
# The liquidsoap executable
LIQUIDSOAP=/usr/bin/liquidsoap
# Icecast2 configuration for Liquidsoap
ICECAST_PASSWORD=
ICECAST_MOUNT=
ICECAST_HOST=
ICECAST_PORT=
ICECAST_URL=
"""
app = typer.Typer()
app_state = {}
@app.callback()
def main(
context: typer.Context,
root: Optional[Path] = typer.Option(
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
else:
del os.environ["DEBUG"]
logging.basicConfig(
format="%(message)s",
level=logging.DEBUG if debug else logging.INFO,
)
try:
croaker.path.media_root()
croaker.path.cache_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):
"""
(Re)Initialize Croaker.
"""
sys.stderr.write("Interactive setup is not yet available. Sorry!\n")
print(dedent(SETUP_HELP))
@app.command()
def start(
context: typer.Context,
daemonize: bool = typer.Option(True, help="Daemonize the webserver."),
):
"""
Start the Croaker command and control webserver.
"""
controller.start()
if daemonize:
server.daemonize()
else:
server.start()
@app.command()
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(
...,
help="Playlist name",
),
theme: Optional[bool] = typer.Option(False, help="Make the first track the theme song."),
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.
If --theme is specified, the first track will be designated the playlist
"theme." Theme songs get played first whenever the playlist is loaded,
after which the playlist order is randomized.
"""
pl = Playlist(name=playlist)
pl.add(tracks, make_theme=theme)
print(pl)
if __name__ == "__main__":
app.main()

View File

@ -1,43 +0,0 @@
import logging
from dataclasses import dataclass
from functools import cached_property
import bottle
import requests
# needs to be imported to attach routes to the default app
from croaker import routes
assert routes
@dataclass
class Client:
host: str
port: int
@cached_property
def _session(self):
return requests.Session()
@property
def _routes(self):
return [r.callback.__name__ for r in bottle.default_app().routes]
def get(self, uri: str, *args, **params):
url = f"http://{self.host}:{self.port}/{uri}"
if args:
url += "/" + "/".join(args)
res = self._session.get(url, params=params)
logging.debug(f"{url = }, {res = }")
return res
def __getattr__(self, attr):
if attr in self._routes:
def dispatch(*args, **kwargs):
logging.debug(f"calling attr, {args = }, {kwargs = }")
return self.get(attr, *args, **kwargs)
return dispatch
return self.__getattribute__(attr)

View File

@ -1,141 +0,0 @@
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"])
# deeebuggin
set("log.file.path","{debug_log}")
# set up the stream
stream = crossfade(normalize(playlist.safe(
id='stream',
reload_mode='watch',
mode='normal',
'{playlist_root}/now_playing',
)))
# 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
# apply the metadata parser
stream = map_metadata(apply_metadata, stream)
# define the source. ignore errors and provide no infallibale fallback. yolo.
radio = fallback(track_sensitive=false, [stream])
# 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 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 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()

View File

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

View File

@ -1,47 +0,0 @@
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."
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}")
return path
def playlist_root():
path = Path(os.environ.get("PLAYLIST_ROOT", root() / Path("playlsits"))).expanduser()
logging.debug(f"Playlist root is {path}")
return path
def media(relpath):
path = media_root() / Path(relpath)
return path
def transcoded_media(relpath):
path = cache_root() / Path(relpath + ".webm")
return path

View File

@ -1,17 +0,0 @@
from bottle import route
from croaker import controller
@route("/play/<playlist_name>")
def play(playlist_name=None):
if not controller.play_next(playlist_name):
return
return "OK"
@route("/skip")
def skip():
if not controller.play_next():
return
return "OK"

View File

@ -1,43 +0,0 @@
import logging
import os
from pathlib import Path
import bottle
import daemon
from croaker import path, routes
from croaker.pidfile import pidfile
assert routes
app = bottle.default_app()
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)
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)
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)

View File

@ -1,34 +1,38 @@
[tool.poetry] [tool.poetry]
name = "croaker" name = "croaker"
version = "0.1.1" 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"
bottle = "^0.12.25"
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"
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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -0,0 +1,16 @@
window {
background: #000;
}
.artwork {
background: #FFF;
}
.label {
color: #888;
}
.now_playing {
color: #FFF;
font-weight: bold;
}

114
src/croaker/cli.py Normal file
View File

@ -0,0 +1,114 @@
import io
import logging
import os
import sys
from pathlib import Path
from textwrap import dedent
from typing import List, Optional
import typer
from dotenv import load_dotenv
from typing_extensions import Annotated
from croaker import path
from croaker.player import Player
from croaker.playlist import Playlist
SETUP_HELP = f"""
# Root directory for croaker configuration and logs. See also croaker --root.
CROAKER_ROOT={path.root()}
# where to store playlist sources
#PLAYLIST_ROOT={path.root()}/playlists
# Where the record the daemon's PID
#PIDFILE={path.root()}/croaker.pid
# Command and Control TCP Server bind address
HOST=127.0.0.1
PORT=8003
# the kinds of files to add to playlists
MEDIA_GLOB=*.mp3,*.flac,*.m4a
"""
app = typer.Typer()
app_state = {}
logger = logging.getLogger("cli")
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
root: Optional[Path] = typer.Option(
Path("~/.dnd/croaker"),
help="Path to the Croaker environment",
),
debug: Optional[bool] = typer.Option(None, help="Enable debugging output"),
):
load_dotenv(root.expanduser() / Path("defaults"))
load_dotenv(stream=io.StringIO(SETUP_HELP))
if debug is not None:
if debug:
os.environ["DEBUG"] = "1"
else:
del os.environ["DEBUG"]
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.DEBUG if debug else logging.INFO,
)
if ctx.invoked_subcommand is None:
return start()
@app.command()
def setup():
"""
(Re)Initialize Croaker.
"""
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))
@app.command()
def start():
"""
Start the Croaker audio player.
"""
player = Player()
player.run()
@app.command()
def add(
playlist: str = typer.Argument(
...,
help="Playlist name",
),
theme: Optional[bool] = typer.Option(False, help="Make the first track the theme song."),
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.
If --theme is specified, the first track will be designated the playlist
"theme." Theme songs get played first whenever the playlist is loaded,
after which the playlist order is randomized.
"""
pl = Playlist(name=playlist)
pl.add(tracks, make_theme=theme)
print(pl)
if __name__ == "__main__":
app()

185
src/croaker/gui.py Normal file
View 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()

18
src/croaker/path.py Normal file
View File

@ -0,0 +1,18 @@
import os
from pathlib import Path
_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 Croaker to fix this error."
def root():
return Path(os.environ.get("CROAKER_ROOT", "~/.dnd/croaker")).expanduser()
def assets():
return Path(__file__).parent / "assets"
def playlist_root():
path = Path(os.environ.get("PLAYLIST_ROOT", root() / "playlists")).expanduser()
return path

View File

@ -5,15 +5,17 @@ from pathlib import Path
from daemon import pidfile as _pidfile from daemon import pidfile as _pidfile
logger = logging.getLogger("daemon")
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}") logger.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.") logger.debug(f"PID {pid} not running; breaking lock.")
pf.break_lock() pf.break_lock()
return pf return pf

31
src/croaker/player.py Normal file
View 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)

View File

@ -9,6 +9,8 @@ from typing import List
import croaker.path import croaker.path
logger = logging.getLogger("playlist")
playlists = {} playlists = {}
@ -22,21 +24,20 @@ def _stripped(name):
class Playlist: class Playlist:
name: str name: str
theme: Path = Path("_theme.mp3") theme: Path = Path("_theme.mp3")
current_track: int = 0
@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
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)
@ -48,13 +49,12 @@ class Playlist:
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.")
@ -62,16 +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:
if source.is_dir(): if path.is_dir():
raise RuntimeError(f"Cannot create a playlist theme from a directory: {source}") files = list(self.get_audio_files(path))
target = self.path / "_theme.mp3" if make_theme:
source = tracks.pop(0) logger.debug(f"Adding first file from dir as theme: {files[0] = }")
self._add_track(target, source, make_theme=True) self._add_track(self.path / "_theme.mp3", files.pop(0))
for track in tracks: make_theme = False
self._add_track(target=self.path / _stripped(track.name), source=track) 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):
@ -80,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
View 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

Binary file not shown.

21
test/conftest.py Normal file
View 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)

View File

@ -0,0 +1 @@
_theme.mp3

View File

View File

View File

@ -0,0 +1 @@
one.mp3

View File

@ -0,0 +1 @@
two.mp3

View File

0
test/fixtures/sources/one.mp3 vendored Normal file
View File

0
test/fixtures/sources/two.mp3 vendored Normal file
View File

BIN
test/fixtures/transcoded_silence.mp3 vendored Normal file

Binary file not shown.

44
test/test_playlist.py Normal file
View 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