Compare commits
No commits in common. "main" and "0.1.1" have entirely different histories.
26
.coveragerc
26
.coveragerc
|
|
@ -1,26 +0,0 @@
|
||||||
# .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
132
README.md
|
|
@ -1,36 +1,16 @@
|
||||||
# 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?
|
||||||
|
|
||||||
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).
|
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.
|
||||||
|
|
||||||
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/whatever installation already.
|
This assumes you have a functioning icecast2 installation already.
|
||||||
|
|
||||||
```
|
```
|
||||||
% mkdir -p ~/.dnd/croaker
|
% mkdir -p ~/.dnd/croaker
|
||||||
|
|
@ -42,121 +22,35 @@ This assumes you have a functioning icecast2/whatever 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
|
Daemonizing webserver on http://0.0.0.0:8003, pidfile and output in ~/.dnd/croaker
|
||||||
```
|
```
|
||||||
|
|
||||||
Connnect to the command & control server:
|
## Quick Start (Client)
|
||||||
|
|
||||||
```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
|
||||||
List available playlists:
|
% croaker setup > ~/.dnd/croaker/defaults # only the client config is required
|
||||||
|
% vi ~/.dnd/croaker/defaults # adjust to taste
|
||||||
```
|
|
||||||
list
|
|
||||||
|
|
||||||
battle
|
|
||||||
adventure
|
|
||||||
session_start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Switch to battle music -- roll initiative!
|
Switch to battle music -- roll initiative!
|
||||||
|
|
||||||
```
|
```
|
||||||
play battle
|
% croaker play battle
|
||||||
OK
|
OK
|
||||||
```
|
```
|
||||||
|
|
||||||
Skip this track and move on to the next:
|
Skip this track and move on to the next:
|
||||||
|
|
||||||
```
|
```
|
||||||
ffwd
|
% croaker skip
|
||||||
OK
|
OK
|
||||||
```
|
```
|
||||||
|
|
||||||
Stop the music:
|
Stop the server:
|
||||||
|
|
||||||
```
|
```
|
||||||
stop
|
% croaker 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')
|
|
||||||
```
|
|
||||||
|
|
|
||||||
207
croaker/cli.py
Normal file
207
croaker/cli.py
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
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()
|
||||||
43
croaker/client.py
Normal file
43
croaker/client.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
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)
|
||||||
141
croaker/controller.py
Normal file
141
croaker/controller.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
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()
|
||||||
16
croaker/exceptions.py
Normal file
16
croaker/exceptions.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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.
|
||||||
|
"""
|
||||||
47
croaker/path.py
Normal file
47
croaker/path.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
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
|
||||||
|
|
@ -5,17 +5,15 @@ 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:
|
||||||
logger.debug(f"Stopping PID {pid}")
|
logging.debug(f"Stopping PID {pid}")
|
||||||
os.kill(pid, sig)
|
os.kill(pid, signal.SIGTERM)
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
logger.debug(f"PID {pid} not running; breaking lock.")
|
logging.debug(f"PID {pid} not running; breaking lock.")
|
||||||
pf.break_lock()
|
pf.break_lock()
|
||||||
return pf
|
return pf
|
||||||
|
|
@ -9,8 +9,6 @@ from typing import List
|
||||||
|
|
||||||
import croaker.path
|
import croaker.path
|
||||||
|
|
||||||
logger = logging.getLogger("playlist")
|
|
||||||
|
|
||||||
playlists = {}
|
playlists = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -24,20 +22,21 @@ 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 self._get_path()
|
return croaker.path.playlist_root() / Path(self.name)
|
||||||
|
|
||||||
@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}.") # pragma: no cover
|
raise RuntimeError(f"Playlist {self.name} not found at {self.path}.")
|
||||||
|
|
||||||
entries = []
|
entries = []
|
||||||
theme = self.path / self.theme
|
theme = self.path / self.theme
|
||||||
if theme.exists():
|
if theme.exists():
|
||||||
entries.append(theme)
|
entries[0] = 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)
|
||||||
|
|
@ -49,12 +48,13 @@ 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.rglob(pat)) for pat in pats])
|
return chain(*[list(path.glob(pat)) for pat in pats])
|
||||||
|
|
||||||
def _get_path(self):
|
def _add_track(self, target: Path, source: Path, make_theme: bool = False):
|
||||||
return croaker.path.playlist_root() / self.name
|
if source.is_dir():
|
||||||
|
for file in self.get_audio_files(source):
|
||||||
def _add_track(self, target: Path, source: Path):
|
self._add_track(self.path / _stripped(file.name), file)
|
||||||
|
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,26 +62,16 @@ class Playlist:
|
||||||
target.unlink()
|
target.unlink()
|
||||||
target.symlink_to(source)
|
target.symlink_to(source)
|
||||||
|
|
||||||
def add(self, paths: List[Path], make_theme: bool = False):
|
def add(self, tracks: 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)
|
||||||
for path in paths:
|
if make_theme:
|
||||||
if path.is_dir():
|
if source.is_dir():
|
||||||
files = list(self.get_audio_files(path))
|
raise RuntimeError(f"Cannot create a playlist theme from a directory: {source}")
|
||||||
if make_theme:
|
target = self.path / "_theme.mp3"
|
||||||
logger.debug(f"Adding first file from dir as theme: {files[0] = }")
|
source = tracks.pop(0)
|
||||||
self._add_track(self.path / "_theme.mp3", files.pop(0))
|
self._add_track(target, source, make_theme=True)
|
||||||
make_theme = False
|
for track in tracks:
|
||||||
for file in files:
|
self._add_track(target=self.path / _stripped(track.name), source=track)
|
||||||
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):
|
||||||
|
|
@ -90,7 +80,7 @@ class Playlist:
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def load_playlist(name: str): # pragma: no cover
|
def load_playlist(name: str):
|
||||||
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]
|
||||||
17
croaker/routes.py
Normal file
17
croaker/routes.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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"
|
||||||
43
croaker/server.py
Normal file
43
croaker/server.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
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)
|
||||||
|
|
@ -1,42 +1,38 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "croaker"
|
name = "croaker"
|
||||||
version = "0.9.2"
|
version = "0.1.1"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["evilchili <evilchili@gmail.com>"]
|
authors = ["evilchili <evilchili@gmail.com>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
packages = [
|
packages = [
|
||||||
{ include = "*", from = "src" }
|
{ include = "croaker" }
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = ">=3.11,<4.0"
|
python = "^3.8"
|
||||||
prompt-toolkit = "^3.0.38"
|
prompt-toolkit = "^3.0.38"
|
||||||
python-dotenv = "^1.1.1"
|
typer = "^0.9.0"
|
||||||
|
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"
|
||||||
python-vlc = "^3.0.21203"
|
psutil = "^5.9.8"
|
||||||
pygobject = "3.50.0"
|
exscript = "^2.6.28"
|
||||||
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.group.dev.dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^8.1.1"
|
black = "^23.3.0"
|
||||||
|
isort = "^5.12.0"
|
||||||
[build-system]
|
pyproject-autoflake = "^1.0.2"
|
||||||
requires = ["poetry-core"]
|
|
||||||
build-backend = "poetry.core.masonry.api"
|
|
||||||
|
|
||||||
|
|
||||||
### SLAM
|
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 120
|
line-length = 120
|
||||||
target-version = ['py311']
|
target-version = ['py310']
|
||||||
|
|
||||||
[tool.isort]
|
[tool.isort]
|
||||||
multi_line_output = 3
|
multi_line_output = 3
|
||||||
|
|
@ -52,8 +48,7 @@ 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"
|
|
||||||
|
|
||||||
### ENDSLAM
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
|
|
@ -1,16 +0,0 @@
|
||||||
window {
|
|
||||||
background: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.artwork {
|
|
||||||
background: #FFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.now_playing {
|
|
||||||
color: #FFF;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +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
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
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()
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
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()])
|
|
||||||
Binary file not shown.
|
|
@ -1,21 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
_theme.mp3
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
one.mp3
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
two.mp3
|
|
||||||
0
test/fixtures/sources/one.mp3
vendored
0
test/fixtures/sources/one.mp3
vendored
0
test/fixtures/sources/two.mp3
vendored
0
test/fixtures/sources/two.mp3
vendored
BIN
test/fixtures/transcoded_silence.mp3
vendored
BIN
test/fixtures/transcoded_silence.mp3
vendored
Binary file not shown.
|
|
@ -1,44 +0,0 @@
|
||||||
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