restructure project for poetry-slam

This commit is contained in:
evilchili 2024-03-26 00:51:16 -07:00
parent c94fb127ed
commit 7ded43476e
16 changed files with 128 additions and 103 deletions

View File

@ -5,7 +5,7 @@ description = ""
authors = ["evilchili <evilchili@gmail.com>"] authors = ["evilchili <evilchili@gmail.com>"]
readme = "README.md" readme = "README.md"
packages = [ packages = [
{ include = "croaker" } { include = "*", from = "src" }
] ]
[tool.poetry.dependencies] [tool.poetry.dependencies]
@ -26,12 +26,16 @@ ffmpeg-python = "^0.2.0"
[tool.poetry.scripts] [tool.poetry.scripts]
croaker = "croaker.cli:app" croaker = "croaker.cli:app"
[tool.poetry.dev-dependencies] [tool.poetry.group.dev.dependencies]
black = "^23.3.0" pytest = "^8.1.1"
isort = "^5.12.0" pytest-cov = "^5.0.0"
pyproject-autoflake = "^1.0.2"
pytest = "^7.2.0" [build-system]
pytest-cov = "^4.0.0" requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
### SLAM
[tool.black] [tool.black]
line-length = 120 line-length = 120
@ -51,7 +55,8 @@ ignore-init-module-imports = true # exclude __init__.py when removing unused
remove-duplicate-keys = true # remove all duplicate keys in objects remove-duplicate-keys = true # remove all duplicate keys in objects
remove-unused-variables = true # remove unused variables remove-unused-variables = true # remove unused variables
[tool.pytest.ini_options]
log_cli_level = "DEBUG"
addopts = "--cov=src --cov-report=term-missing"
[build-system] ### ENDSLAM
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View File

@ -1,3 +0,0 @@
[pytest]
log_cli_level = DEBUG
addopts = --cov=croaker/ --cov-report=term-missing

View File

@ -42,7 +42,7 @@ ICECAST_URL=
app = typer.Typer() app = typer.Typer()
app_state = {} app_state = {}
logger = logging.getLogger('cli') logger = logging.getLogger("cli")
@app.callback() @app.callback()

View File

@ -5,7 +5,7 @@ from pathlib import Path
from daemon import pidfile as _pidfile from daemon import pidfile as _pidfile
logger = logging.getLogger('daemon') logger = logging.getLogger("daemon")
def pidfile(pidfile_path: Path, sig=signal.SIGQUIT, terminate_if_running: bool = True): def pidfile(pidfile_path: Path, sig=signal.SIGQUIT, terminate_if_running: bool = True):

View File

@ -9,7 +9,7 @@ from typing import List
import croaker.path import croaker.path
logger = logging.getLogger('playlist') logger = logging.getLogger("playlist")
playlists = {} playlists = {}

View File

@ -13,7 +13,7 @@ from croaker.pidfile import pidfile
from croaker.playlist import load_playlist from croaker.playlist import load_playlist
from croaker.streamer import AudioStreamer from croaker.streamer import AudioStreamer
logger = logging.getLogger('server') logger = logging.getLogger("server")
class RequestHandler(socketserver.StreamRequestHandler): class RequestHandler(socketserver.StreamRequestHandler):
@ -22,6 +22,7 @@ class RequestHandler(socketserver.StreamRequestHandler):
command and control protocol and sends commands to the shoutcast source command and control protocol and sends commands to the shoutcast source
client on behalf of the user. client on behalf of the user.
""" """
supported_commands = { supported_commands = {
# command # help text # command # help text
"PLAY": "PLAYLIST - Switch to the specified playlist.", "PLAY": "PLAYLIST - Switch to the specified playlist.",
@ -30,7 +31,7 @@ class RequestHandler(socketserver.StreamRequestHandler):
"HELP": " - Display command help.", "HELP": " - Display command help.",
"KTHX": " - Close the current connection.", "KTHX": " - Close the current connection.",
"STOP": " - Stop the current track and stream silence.", "STOP": " - Stop the current track and stream silence.",
"STFU": " - Terminate the Croaker server." "STFU": " - Terminate the Croaker server.",
} }
should_listen = True should_listen = True
@ -92,7 +93,7 @@ class RequestHandler(socketserver.StreamRequestHandler):
return self.send("\n".join(f"{cmd} {txt}" for cmd, txt in self.supported_commands.items())) return self.send("\n".join(f"{cmd} {txt}" for cmd, txt in self.supported_commands.items()))
def handle_STOP(self, args): def handle_STOP(self, args):
return(self.server.stop_event.set()) return self.server.stop_event.set()
def handle_STFU(self, args): def handle_STFU(self, args):
self.send("Shutting down.") self.send("Shutting down.")
@ -103,6 +104,7 @@ class CroakerServer(socketserver.TCPServer):
""" """
A Daemonized TCP Server that also starts a Shoutcast source client. A Daemonized TCP Server that also starts a Shoutcast source client.
""" """
allow_reuse_address = True allow_reuse_address = True
def __init__(self): def __init__(self):
@ -173,7 +175,7 @@ class CroakerServer(socketserver.TCPServer):
def list(self, playlist_name: str = None): def list(self, playlist_name: str = None):
if playlist_name: if playlist_name:
return str(load_playlist(playlist_name)) return str(load_playlist(playlist_name))
return '\n'.join([str(p.name) for p in path.playlist_root().iterdir()]) return "\n".join([str(p.name) for p in path.playlist_root().iterdir()])
def load(self, playlist_name: str): def load(self, playlist_name: str):
logger.debug(f"Switching to {playlist_name = }") logger.debug(f"Switching to {playlist_name = }")

View File

@ -1,6 +1,6 @@
import queue
import logging import logging
import os import os
import queue
import threading import threading
from functools import cached_property from functools import cached_property
from pathlib import Path from pathlib import Path
@ -9,7 +9,7 @@ import shout
from croaker import transcoder from croaker import transcoder
logger = logging.getLogger('streamer') logger = logging.getLogger("streamer")
class AudioStreamer(threading.Thread): class AudioStreamer(threading.Thread):
@ -17,6 +17,7 @@ class AudioStreamer(threading.Thread):
Receive filenames from the controller thread and stream the contents of Receive filenames from the controller thread and stream the contents of
those files to the icecast server. those files to the icecast server.
""" """
def __init__(self, queue, skip_event, stop_event, load_event, chunk_size=4096): def __init__(self, queue, skip_event, stop_event, load_event, chunk_size=4096):
super().__init__() super().__init__()
self.queue = queue self.queue = queue
@ -27,7 +28,7 @@ class AudioStreamer(threading.Thread):
@cached_property @cached_property
def silence(self): def silence(self):
return transcoder.open(Path(__file__).parent / 'silence.mp3', bufsize=2*self.chunk_size) return transcoder.open(Path(__file__).parent / "silence.mp3", bufsize=2 * self.chunk_size)
@cached_property @cached_property
def _shout(self): def _shout(self):
@ -51,7 +52,6 @@ class AudioStreamer(threading.Thread):
self._shout.close() self._shout.close()
def do_one_loop(self): def do_one_loop(self):
# If the user said STOP, clear the queue. # If the user said STOP, clear the queue.
if self.stop_requested.is_set(): if self.stop_requested.is_set():
logger.debug("Stop requested; clearing queue.") logger.debug("Stop requested; clearing queue.")
@ -76,7 +76,7 @@ class AudioStreamer(threading.Thread):
if not_playing: if not_playing:
try: try:
self.silence.seek(0, 0) self.silence.seek(0, 0)
self._shout.set_metadata({"song": '[NOTHING PLAYING]'}) self._shout.set_metadata({"song": "[NOTHING PLAYING]"})
self.play_from_stream(self.silence) self.play_from_stream(self.silence)
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
logger.error("Caught exception trying to loop silence!", exc_info=exc) logger.error("Caught exception trying to loop silence!", exc_info=exc)
@ -102,7 +102,6 @@ class AudioStreamer(threading.Thread):
self._shout.get_connected() self._shout.get_connected()
input_buffer = self._read_chunk(stream) input_buffer = self._read_chunk(stream)
while True: while True:
# To load a playlist, stop streaming the current track and clear the queue # To load a playlist, stop streaming the current track and clear the queue
# but do not clear the event. run() will detect it and # but do not clear the event. run() will detect it and
if self.load_requested.is_set(): if self.load_requested.is_set():

View File

@ -1,10 +1,10 @@
from pathlib import Path
import subprocess
import logging import logging
import subprocess
from pathlib import Path
import ffmpeg import ffmpeg
logger = logging.getLogger('transcoder') logger = logging.getLogger("transcoder")
def open(infile: Path, bufsize: int = 4096): def open(infile: Path, bufsize: int = 4096):
@ -16,15 +16,14 @@ def open(infile: Path, bufsize: int = 4096):
a pipe to ffmpeg's STDOUT. a pipe to ffmpeg's STDOUT.
""" """
suffix = infile.suffix.lower() suffix = infile.suffix.lower()
if suffix == '.mp3': if suffix == ".mp3":
logger.debug(f"Not transcoding mp3 {infile = }") logger.debug(f"Not transcoding mp3 {infile = }")
return infile.open('rb', buffering=bufsize) return infile.open("rb", buffering=bufsize)
ffmpeg_args = ( ffmpeg_args = (
ffmpeg ffmpeg.input(str(infile))
.input(str(infile)) .output("-", format="mp3", q=2)
.output('-', format='mp3', q=2) .global_args("-hide_banner", "-loglevel", "quiet")
.global_args('-hide_banner', '-loglevel', 'quiet')
.compile() .compile()
) )

View File

@ -5,12 +5,12 @@ import pytest
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def mock_env(monkeypatch): def mock_env(monkeypatch):
fixtures = Path(__file__).parent / 'fixtures' fixtures = Path(__file__).parent / "fixtures"
monkeypatch.setenv('CROAKER_ROOT', str(fixtures)) monkeypatch.setenv("CROAKER_ROOT", str(fixtures))
monkeypatch.setenv('MEDIA_GLOB', '*.mp3,*.foo,*.bar') monkeypatch.setenv("MEDIA_GLOB", "*.mp3,*.foo,*.bar")
monkeypatch.setenv('ICECAST_URL', 'http://127.0.0.1') monkeypatch.setenv("ICECAST_URL", "http://127.0.0.1")
monkeypatch.setenv('ICECAST_HOST', 'localhost') monkeypatch.setenv("ICECAST_HOST", "localhost")
monkeypatch.setenv('ICECAST_MOUNT', 'mount') monkeypatch.setenv("ICECAST_MOUNT", "mount")
monkeypatch.setenv('ICECAST_PORT', '6523') monkeypatch.setenv("ICECAST_PORT", "6523")
monkeypatch.setenv('ICECAST_PASSWORD', 'password') monkeypatch.setenv("ICECAST_PASSWORD", "password")
monkeypatch.setenv('DEBUG', '1') monkeypatch.setenv("DEBUG", "1")

View File

@ -6,19 +6,30 @@ import pytest
from croaker import pidfile from croaker import pidfile
@pytest.mark.parametrize('pid,terminate,kill_result,broken', [ @pytest.mark.parametrize(
('pid', False, None, False), # running proc, no terminate "pid,terminate,kill_result,broken",
('pid', True, True, False), # running proc, terminate [
('pid', True, ProcessLookupError, True), # stale pid ("pid", False, None, False), # running proc, no terminate
("pid", True, True, False), # running proc, terminate
("pid", True, ProcessLookupError, True), # stale pid
(None, None, None, False), # no running proc (None, None, None, False), # no running proc
]) ],
)
def test_pidfile(monkeypatch, pid, terminate, kill_result, broken): def test_pidfile(monkeypatch, pid, terminate, kill_result, broken):
monkeypatch.setattr(pidfile._pidfile, 'TimeoutPIDLockFile', MagicMock(**{ monkeypatch.setattr(
'return_value.read_pid.return_value': pid, pidfile._pidfile,
})) "TimeoutPIDLockFile",
monkeypatch.setattr(pidfile.os, 'kill', MagicMock(**{ MagicMock(
'side_effect': kill_result if type(kill_result) is Exception else [kill_result] **{
})) "return_value.read_pid.return_value": pid,
}
),
)
monkeypatch.setattr(
pidfile.os,
"kill",
MagicMock(**{"side_effect": kill_result if type(kill_result) is Exception else [kill_result]}),
)
ret = pidfile.pidfile(pidfile_path=Path('/dev/null'), terminate_if_running=terminate) ret = pidfile.pidfile(pidfile_path=Path("/dev/null"), terminate_if_running=terminate)
assert ret.break_lock.called == broken assert ret.break_lock.called == broken

View File

@ -2,17 +2,17 @@ from unittest.mock import MagicMock
import pytest import pytest
import croaker.playlist
import croaker.path import croaker.path
import croaker.playlist
def test_playlist_loading(): def test_playlist_loading():
pl = croaker.playlist.Playlist(name='test_playlist') pl = croaker.playlist.Playlist(name="test_playlist")
path = str(pl.path) path = str(pl.path)
tracks = [str(t) for t in pl.tracks] tracks = [str(t) for t in pl.tracks]
assert path == str(croaker.path.playlist_root() / pl.name) assert path == str(croaker.path.playlist_root() / pl.name)
assert pl.name == 'test_playlist' assert pl.name == "test_playlist"
assert tracks[0] == f"{path}/_theme.mp3" assert tracks[0] == f"{path}/_theme.mp3"
assert f"{path}/one.mp3" in tracks assert f"{path}/one.mp3" in tracks
assert f"{path}/two.mp3" in tracks assert f"{path}/two.mp3" in tracks
@ -20,22 +20,25 @@ def test_playlist_loading():
assert f"{path}/one.baz" not in tracks assert f"{path}/one.baz" not in tracks
@pytest.mark.parametrize('paths, make_theme, expected_count', [ @pytest.mark.parametrize(
(['test_playlist'], True, 4), "paths, make_theme, expected_count",
(['test_playlist'], False, 4), [
(['test_playlist', 'sources/one.mp3'], True, 5), (["test_playlist"], True, 4),
(['test_playlist', 'sources/one.mp3'], False, 5), (["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): def test_playlist_creation(monkeypatch, paths, make_theme, expected_count):
new_symlinks = [] new_symlinks = []
def symlink(target): def symlink(target):
new_symlinks.append(target) new_symlinks.append(target)
pl = croaker.playlist.Playlist(name='foo') pl = croaker.playlist.Playlist(name="foo")
monkeypatch.setattr(croaker.playlist.Path, 'unlink', MagicMock()) monkeypatch.setattr(croaker.playlist.Path, "unlink", MagicMock())
monkeypatch.setattr(croaker.playlist.Path, 'symlink_to', MagicMock(side_effect=symlink)) monkeypatch.setattr(croaker.playlist.Path, "symlink_to", MagicMock(side_effect=symlink))
monkeypatch.setattr(croaker.playlist.Path, 'mkdir', MagicMock()) monkeypatch.setattr(croaker.playlist.Path, "mkdir", MagicMock())
pl.add([croaker.path.playlist_root() / p for p in paths], make_theme) pl.add([croaker.path.playlist_root() / p for p in paths], make_theme)
assert len(new_symlinks) == expected_count assert len(new_symlinks) == expected_count

View File

@ -1,14 +1,13 @@
from pathlib import Path
from unittest.mock import MagicMock
import io import io
import queue import queue
import threading import threading
from pathlib import Path
from unittest.mock import MagicMock
import pytest import pytest
import shout import shout
from croaker import streamer, playlist from croaker import playlist, streamer
def get_stream_output(stream): def get_stream_output(stream):
@ -16,9 +15,9 @@ def get_stream_output(stream):
return stream.read() return stream.read()
@pytest.fixture(scope='session') @pytest.fixture(scope="session")
def silence_bytes(): def silence_bytes():
return (Path(streamer.__file__).parent / 'silence.mp3').read_bytes() return (Path(streamer.__file__).parent / "silence.mp3").read_bytes()
@pytest.fixture @pytest.fixture
@ -30,10 +29,9 @@ def output_stream():
def mock_shout(output_stream, monkeypatch): def mock_shout(output_stream, monkeypatch):
def handle_send(buf): def handle_send(buf):
output_stream.write(buf) output_stream.write(buf)
mm = MagicMock(spec=shout.Shout, **{
'return_value.send.side_effect': handle_send mm = MagicMock(spec=shout.Shout, **{"return_value.send.side_effect": handle_send})
}) monkeypatch.setattr("shout.Shout", mm)
monkeypatch.setattr('shout.Shout', mm)
return mm return mm
@ -41,6 +39,7 @@ def mock_shout(output_stream, monkeypatch):
def input_queue(): def input_queue():
return queue.Queue() return queue.Queue()
@pytest.fixture @pytest.fixture
def skip_event(): def skip_event():
return threading.Event() return threading.Event()
@ -80,7 +79,7 @@ def test_streamer_load(audio_streamer, load_event, output_stream):
def test_clear_queue(audio_streamer, input_queue): def test_clear_queue(audio_streamer, input_queue):
pl = playlist.Playlist(name='test_playlist') pl = playlist.Playlist(name="test_playlist")
for track in pl.tracks: for track in pl.tracks:
input_queue.put(bytes(track)) input_queue.put(bytes(track))
assert input_queue.not_empty assert input_queue.not_empty
@ -90,7 +89,7 @@ def test_clear_queue(audio_streamer, input_queue):
def test_streamer_defaults_to_silence(audio_streamer, input_queue, output_stream, silence_bytes): def test_streamer_defaults_to_silence(audio_streamer, input_queue, output_stream, silence_bytes):
audio_streamer.do_one_loop() audio_streamer.do_one_loop()
track = playlist.Playlist(name='test_playlist').tracks[0] track = playlist.Playlist(name="test_playlist").tracks[0]
input_queue.put(bytes(track)) input_queue.put(bytes(track))
audio_streamer.do_one_loop() audio_streamer.do_one_loop()
audio_streamer.do_one_loop() audio_streamer.do_one_loop()
@ -98,15 +97,16 @@ def test_streamer_defaults_to_silence(audio_streamer, input_queue, output_stream
def test_streamer_plays_silence_on_error(monkeypatch, audio_streamer, input_queue, output_stream, silence_bytes): def test_streamer_plays_silence_on_error(monkeypatch, audio_streamer, input_queue, output_stream, silence_bytes):
monkeypatch.setattr(audio_streamer, 'play_file', MagicMock(side_effect=Exception)) monkeypatch.setattr(audio_streamer, "play_file", MagicMock(side_effect=Exception))
track = playlist.Playlist(name='test_playlist').tracks[0] track = playlist.Playlist(name="test_playlist").tracks[0]
input_queue.put(bytes(track)) input_queue.put(bytes(track))
audio_streamer.do_one_loop() audio_streamer.do_one_loop()
assert get_stream_output(output_stream) == silence_bytes assert get_stream_output(output_stream) == silence_bytes
def test_streamer_plays_from_queue(audio_streamer, input_queue, output_stream): def test_streamer_plays_from_queue(audio_streamer, input_queue, output_stream):
pl = playlist.Playlist(name='test_playlist') pl = playlist.Playlist(name="test_playlist")
expected = b'' expected = b""
for track in pl.tracks: for track in pl.tracks:
input_queue.put(bytes(track)) input_queue.put(bytes(track))
expected += track.read_bytes() expected += track.read_bytes()
@ -119,14 +119,14 @@ def test_streamer_handles_stop_interrupt(audio_streamer, output_stream, stop_eve
stop_event.set() stop_event.set()
audio_streamer.silence.seek(0, 0) audio_streamer.silence.seek(0, 0)
audio_streamer.play_from_stream(audio_streamer.silence) audio_streamer.play_from_stream(audio_streamer.silence)
assert get_stream_output(output_stream) == b'' assert get_stream_output(output_stream) == b""
def test_streamer_handles_load_interrupt(audio_streamer, input_queue, output_stream, load_event): def test_streamer_handles_load_interrupt(audio_streamer, input_queue, output_stream, load_event):
pl = playlist.Playlist(name='test_playlist') pl = playlist.Playlist(name="test_playlist")
input_queue.put(bytes(pl.tracks[0])) input_queue.put(bytes(pl.tracks[0]))
load_event.set() load_event.set()
audio_streamer.silence.seek(0, 0) audio_streamer.silence.seek(0, 0)
audio_streamer.play_from_stream(audio_streamer.silence) audio_streamer.play_from_stream(audio_streamer.silence)
assert get_stream_output(output_stream) == b'' assert get_stream_output(output_stream) == b""
assert input_queue.empty assert input_queue.empty

View File

@ -3,23 +3,32 @@ from unittest.mock import MagicMock
import ffmpeg import ffmpeg
import pytest import pytest
from croaker import playlist from croaker import playlist, transcoder
from croaker import transcoder
@pytest.mark.parametrize('suffix, expected', [ @pytest.mark.parametrize(
('.mp3', b'_theme.mp3\n'), "suffix, expected",
('.foo', b'transcoding!\n'), [
]) (".mp3", b"_theme.mp3\n"),
(".foo", b"transcoding!\n"),
],
)
def test_transcoder_open(monkeypatch, suffix, expected): def test_transcoder_open(monkeypatch, suffix, expected):
monkeypatch.setattr(transcoder, 'ffmpeg', MagicMock(spec=ffmpeg, **{ monkeypatch.setattr(
'input.return_value.' transcoder,
'output.return_value.' "ffmpeg",
'global_args.return_value.' MagicMock(
'compile.return_value': ['echo', 'transcoding!'], spec=ffmpeg,
})) **{
"input.return_value."
"output.return_value."
"global_args.return_value."
"compile.return_value": ["echo", "transcoding!"],
},
),
)
pl = playlist.Playlist(name='test_playlist') pl = playlist.Playlist(name="test_playlist")
track = [t for t in pl.tracks if t.suffix == suffix][0] track = [t for t in pl.tracks if t.suffix == suffix][0]
with transcoder.open(track) as handle: with transcoder.open(track) as handle:
assert handle.read() == expected assert handle.read() == expected