initial commit

This commit is contained in:
evilchili 2024-03-01 01:00:17 -08:00
parent a0bd670b9e
commit 0dd3f40a6c
11 changed files with 673 additions and 0 deletions

0
croaker/__init__.py Normal file
View File

207
croaker/cli.py Normal file
View 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
View 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
View 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
View 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
View 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

19
croaker/pidfile.py Normal file
View File

@ -0,0 +1,19 @@
import logging
import os
import signal
from pathlib import Path
from daemon import pidfile as _pidfile
def pidfile(pidfile_path: Path, terminate_if_running: bool = True):
pf = _pidfile.TimeoutPIDLockFile(pidfile_path, 30)
pid = pf.read_pid()
if pid and terminate_if_running:
try:
logging.debug(f"Stopping PID {pid}")
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
logging.debug(f"PID {pid} not running; breaking lock.")
pf.break_lock()
return pf

86
croaker/playlist.py Normal file
View File

@ -0,0 +1,86 @@
import logging
import os
from dataclasses import dataclass
from functools import cached_property
from itertools import chain
from pathlib import Path
from random import shuffle
from typing import List
import croaker.path
playlists = {}
def _stripped(name):
name.replace('"', "")
name.replace("'", "")
return name
@dataclass
class Playlist:
name: str
theme: Path = Path("_theme.mp3")
current_track: int = 0
@cached_property
def path(self):
return croaker.path.playlist_root() / Path(self.name)
@cached_property
def tracks(self):
if not self.path.exists():
raise RuntimeError(f"Playlist {self.name} not found at {self.path}.")
entries = []
theme = self.path / self.theme
if theme.exists():
entries[0] = theme
files = [e for e in self.get_audio_files() if e.name != "_theme.mp3"]
if files:
shuffle(files)
entries += files
return entries
def get_audio_files(self, path: Path = None):
if not path:
path = self.path
logging.debug(f"Getting files matching {os.environ['MEDIA_GLOB']} from {path}")
pats = os.environ["MEDIA_GLOB"].split(",")
return chain(*[list(path.glob(pat)) for pat in pats])
def _add_track(self, target: Path, source: Path, make_theme: bool = False):
if source.is_dir():
for file in self.get_audio_files(source):
self._add_track(self.path / _stripped(file.name), file)
return
if target.exists():
if not target.is_symlink():
logging.warning(f"{target}: target already exists and is not a symlink; skipping.")
return
target.unlink()
target.symlink_to(source)
def add(self, tracks: List[Path], make_theme: bool = False):
self.path.mkdir(parents=True, exist_ok=True)
if make_theme:
if source.is_dir():
raise RuntimeError(f"Cannot create a playlist theme from a directory: {source}")
target = self.path / "_theme.mp3"
source = tracks.pop(0)
self._add_track(target, source, make_theme=True)
for track in tracks:
self._add_track(target=self.path / _stripped(track.name), source=track)
return sorted(self.get_audio_files())
def __repr__(self):
lines = [f"Playlist {self.name}"]
lines += [f" * {track}" for track in self.tracks]
return "\n".join(lines)
def load_playlist(name: str):
if name not in playlists:
playlists[name] = Playlist(name=name)
return playlists[name]

17
croaker/routes.py Normal file
View 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
View 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)

54
pyproject.toml Normal file
View File

@ -0,0 +1,54 @@
[tool.poetry]
name = "croaker"
version = "0.1.0"
description = ""
authors = ["evilchili <evilchili@gmail.com>"]
readme = "README.md"
packages = [
{ include = "croaker" }
]
[tool.poetry.dependencies]
python = "^3.10"
prompt-toolkit = "^3.0.38"
typer = "^0.9.0"
python-dotenv = "^0.21.0"
rich = "^13.7.0"
pyyaml = "^6.0.1"
bottle = "^0.12.25"
paste = "^3.7.1"
python-daemon = "^3.0.1"
requests = "^2.31.0"
psutil = "^5.9.8"
exscript = "^2.6.28"
[tool.poetry.scripts]
croaker = "croaker.cli:app"
[tool.poetry.dev-dependencies]
black = "^23.3.0"
isort = "^5.12.0"
pyproject-autoflake = "^1.0.2"
[tool.black]
line-length = 120
target-version = ['py310']
[tool.isort]
multi_line_output = 3
line_length = 120
include_trailing_comma = true
[tool.autoflake]
check = false # return error code if changes are needed
in-place = true # make changes to files instead of printing diffs
recursive = true # drill down directories recursively
remove-all-unused-imports = true # remove all unused imports (not just those from the standard library)
ignore-init-module-imports = true # exclude __init__.py when removing unused imports
remove-duplicate-keys = true # remove all duplicate keys in objects
remove-unused-variables = true # remove unused variables
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"