initial commit
This commit is contained in:
parent
a0bd670b9e
commit
0dd3f40a6c
0
croaker/__init__.py
Normal file
0
croaker/__init__.py
Normal file
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
|
19
croaker/pidfile.py
Normal file
19
croaker/pidfile.py
Normal 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
86
croaker/playlist.py
Normal 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
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)
|
54
pyproject.toml
Normal file
54
pyproject.toml
Normal 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"
|
Loading…
Reference in New Issue
Block a user