diff --git a/pyproject.toml b/pyproject.toml index 15141dd..5bae30d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "" authors = ["evilchili "] readme = "README.md" packages = [ - { include = "croaker" } + { include = "*", from = "src" } ] [tool.poetry.dependencies] @@ -26,12 +26,16 @@ ffmpeg-python = "^0.2.0" [tool.poetry.scripts] croaker = "croaker.cli:app" -[tool.poetry.dev-dependencies] -black = "^23.3.0" -isort = "^5.12.0" -pyproject-autoflake = "^1.0.2" -pytest = "^7.2.0" -pytest-cov = "^4.0.0" +[tool.poetry.group.dev.dependencies] +pytest = "^8.1.1" +pytest-cov = "^5.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + + +### SLAM [tool.black] 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-unused-variables = true # remove unused variables +[tool.pytest.ini_options] +log_cli_level = "DEBUG" +addopts = "--cov=src --cov-report=term-missing" -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +### ENDSLAM diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 1dbd516..0000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -log_cli_level = DEBUG -addopts = --cov=croaker/ --cov-report=term-missing diff --git a/croaker/__init__.py b/src/croaker/__init__.py similarity index 100% rename from croaker/__init__.py rename to src/croaker/__init__.py diff --git a/croaker/cli.py b/src/croaker/cli.py similarity index 98% rename from croaker/cli.py rename to src/croaker/cli.py index 78fd5ec..0b78bdf 100644 --- a/croaker/cli.py +++ b/src/croaker/cli.py @@ -42,7 +42,7 @@ ICECAST_URL= app = typer.Typer() app_state = {} -logger = logging.getLogger('cli') +logger = logging.getLogger("cli") @app.callback() diff --git a/croaker/path.py b/src/croaker/path.py similarity index 100% rename from croaker/path.py rename to src/croaker/path.py diff --git a/croaker/pidfile.py b/src/croaker/pidfile.py similarity index 93% rename from croaker/pidfile.py rename to src/croaker/pidfile.py index e609740..d8edd61 100644 --- a/croaker/pidfile.py +++ b/src/croaker/pidfile.py @@ -5,7 +5,7 @@ from pathlib import Path 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): diff --git a/croaker/playlist.py b/src/croaker/playlist.py similarity index 98% rename from croaker/playlist.py rename to src/croaker/playlist.py index 4ac996f..bbfd938 100644 --- a/croaker/playlist.py +++ b/src/croaker/playlist.py @@ -9,7 +9,7 @@ from typing import List import croaker.path -logger = logging.getLogger('playlist') +logger = logging.getLogger("playlist") playlists = {} diff --git a/croaker/server.py b/src/croaker/server.py similarity index 96% rename from croaker/server.py rename to src/croaker/server.py index 7c53912..a12ed97 100644 --- a/croaker/server.py +++ b/src/croaker/server.py @@ -13,7 +13,7 @@ from croaker.pidfile import pidfile from croaker.playlist import load_playlist from croaker.streamer import AudioStreamer -logger = logging.getLogger('server') +logger = logging.getLogger("server") class RequestHandler(socketserver.StreamRequestHandler): @@ -22,6 +22,7 @@ class RequestHandler(socketserver.StreamRequestHandler): command and control protocol and sends commands to the shoutcast source client on behalf of the user. """ + supported_commands = { # command # help text "PLAY": "PLAYLIST - Switch to the specified playlist.", @@ -30,7 +31,7 @@ class RequestHandler(socketserver.StreamRequestHandler): "HELP": " - Display command help.", "KTHX": " - Close the current connection.", "STOP": " - Stop the current track and stream silence.", - "STFU": " - Terminate the Croaker server." + "STFU": " - Terminate the Croaker server.", } 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())) def handle_STOP(self, args): - return(self.server.stop_event.set()) + return self.server.stop_event.set() def handle_STFU(self, args): self.send("Shutting down.") @@ -103,6 +104,7 @@ class CroakerServer(socketserver.TCPServer): """ A Daemonized TCP Server that also starts a Shoutcast source client. """ + allow_reuse_address = True def __init__(self): @@ -173,7 +175,7 @@ class CroakerServer(socketserver.TCPServer): 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 path.playlist_root().iterdir()]) + return "\n".join([str(p.name) for p in path.playlist_root().iterdir()]) def load(self, playlist_name: str): logger.debug(f"Switching to {playlist_name = }") diff --git a/croaker/silence.mp3 b/src/croaker/silence.mp3 similarity index 100% rename from croaker/silence.mp3 rename to src/croaker/silence.mp3 diff --git a/croaker/streamer.py b/src/croaker/streamer.py similarity index 93% rename from croaker/streamer.py rename to src/croaker/streamer.py index 327a0b0..88aa424 100644 --- a/croaker/streamer.py +++ b/src/croaker/streamer.py @@ -1,6 +1,6 @@ -import queue import logging import os +import queue import threading from functools import cached_property from pathlib import Path @@ -9,7 +9,7 @@ import shout from croaker import transcoder -logger = logging.getLogger('streamer') +logger = logging.getLogger("streamer") class AudioStreamer(threading.Thread): @@ -17,6 +17,7 @@ class AudioStreamer(threading.Thread): Receive filenames from the controller thread and stream the contents of those files to the icecast server. """ + def __init__(self, queue, skip_event, stop_event, load_event, chunk_size=4096): super().__init__() self.queue = queue @@ -27,7 +28,7 @@ class AudioStreamer(threading.Thread): @cached_property 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 def _shout(self): @@ -51,7 +52,6 @@ class AudioStreamer(threading.Thread): self._shout.close() def do_one_loop(self): - # If the user said STOP, clear the queue. if self.stop_requested.is_set(): logger.debug("Stop requested; clearing queue.") @@ -76,7 +76,7 @@ class AudioStreamer(threading.Thread): if not_playing: try: 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) except Exception as exc: # pragma: no cover logger.error("Caught exception trying to loop silence!", exc_info=exc) @@ -95,14 +95,13 @@ class AudioStreamer(threading.Thread): def play_file(self, track: Path): logger.debug(f"Streaming {track.stem = }") self._shout.set_metadata({"song": track.stem}) - with transcoder.open(track, bufsize=2*self.chunk_size) as fh: + with transcoder.open(track, bufsize=2 * self.chunk_size) as fh: return self.play_from_stream(fh) def play_from_stream(self, stream): self._shout.get_connected() input_buffer = self._read_chunk(stream) while True: - # To load a playlist, stop streaming the current track and clear the queue # but do not clear the event. run() will detect it and if self.load_requested.is_set(): diff --git a/croaker/transcoder.py b/src/croaker/transcoder.py similarity index 76% rename from croaker/transcoder.py rename to src/croaker/transcoder.py index c14e988..8a9302a 100644 --- a/croaker/transcoder.py +++ b/src/croaker/transcoder.py @@ -1,10 +1,10 @@ -from pathlib import Path -import subprocess import logging +import subprocess +from pathlib import Path import ffmpeg -logger = logging.getLogger('transcoder') +logger = logging.getLogger("transcoder") def open(infile: Path, bufsize: int = 4096): @@ -16,15 +16,14 @@ def open(infile: Path, bufsize: int = 4096): a pipe to ffmpeg's STDOUT. """ suffix = infile.suffix.lower() - if suffix == '.mp3': + if suffix == ".mp3": logger.debug(f"Not transcoding mp3 {infile = }") - return infile.open('rb', buffering=bufsize) + return infile.open("rb", buffering=bufsize) ffmpeg_args = ( - ffmpeg - .input(str(infile)) - .output('-', format='mp3', q=2) - .global_args('-hide_banner', '-loglevel', 'quiet') + ffmpeg.input(str(infile)) + .output("-", format="mp3", q=2) + .global_args("-hide_banner", "-loglevel", "quiet") .compile() ) diff --git a/test/conftest.py b/test/conftest.py index 67df0b0..78470aa 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -5,12 +5,12 @@ 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') + 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") diff --git a/test/test_pidfile.py b/test/test_pidfile.py index 02cbcba..fe27c2b 100644 --- a/test/test_pidfile.py +++ b/test/test_pidfile.py @@ -6,19 +6,30 @@ import pytest from croaker import pidfile -@pytest.mark.parametrize('pid,terminate,kill_result,broken', [ - ('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 -]) +@pytest.mark.parametrize( + "pid,terminate,kill_result,broken", + [ + ("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 + ], +) def test_pidfile(monkeypatch, pid, terminate, kill_result, broken): - monkeypatch.setattr(pidfile._pidfile, 'TimeoutPIDLockFile', MagicMock(**{ - '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] - })) + monkeypatch.setattr( + pidfile._pidfile, + "TimeoutPIDLockFile", + MagicMock( + **{ + "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 diff --git a/test/test_playlist.py b/test/test_playlist.py index 83c1c20..18f9015 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -2,17 +2,17 @@ from unittest.mock import MagicMock import pytest -import croaker.playlist import croaker.path +import croaker.playlist def test_playlist_loading(): - pl = croaker.playlist.Playlist(name='test_playlist') + 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 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 @@ -20,22 +20,25 @@ def test_playlist_loading(): 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), -]) +@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 = 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 diff --git a/test/test_streamer.py b/test/test_streamer.py index f6a7cfa..a814443 100644 --- a/test/test_streamer.py +++ b/test/test_streamer.py @@ -1,14 +1,13 @@ -from pathlib import Path -from unittest.mock import MagicMock - import io import queue import threading +from pathlib import Path +from unittest.mock import MagicMock import pytest import shout -from croaker import streamer, playlist +from croaker import playlist, streamer def get_stream_output(stream): @@ -16,9 +15,9 @@ def get_stream_output(stream): return stream.read() -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def silence_bytes(): - return (Path(streamer.__file__).parent / 'silence.mp3').read_bytes() + return (Path(streamer.__file__).parent / "silence.mp3").read_bytes() @pytest.fixture @@ -30,10 +29,9 @@ def output_stream(): def mock_shout(output_stream, monkeypatch): def handle_send(buf): output_stream.write(buf) - mm = MagicMock(spec=shout.Shout, **{ - 'return_value.send.side_effect': handle_send - }) - monkeypatch.setattr('shout.Shout', mm) + + mm = MagicMock(spec=shout.Shout, **{"return_value.send.side_effect": handle_send}) + monkeypatch.setattr("shout.Shout", mm) return mm @@ -41,6 +39,7 @@ def mock_shout(output_stream, monkeypatch): def input_queue(): return queue.Queue() + @pytest.fixture def skip_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): - pl = playlist.Playlist(name='test_playlist') + pl = playlist.Playlist(name="test_playlist") for track in pl.tracks: input_queue.put(bytes(track)) 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): 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)) 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): - monkeypatch.setattr(audio_streamer, 'play_file', MagicMock(side_effect=Exception)) - track = playlist.Playlist(name='test_playlist').tracks[0] + monkeypatch.setattr(audio_streamer, "play_file", MagicMock(side_effect=Exception)) + track = playlist.Playlist(name="test_playlist").tracks[0] input_queue.put(bytes(track)) audio_streamer.do_one_loop() assert get_stream_output(output_stream) == silence_bytes + def test_streamer_plays_from_queue(audio_streamer, input_queue, output_stream): - pl = playlist.Playlist(name='test_playlist') - expected = b'' + pl = playlist.Playlist(name="test_playlist") + expected = b"" for track in pl.tracks: input_queue.put(bytes(track)) expected += track.read_bytes() @@ -119,14 +119,14 @@ def test_streamer_handles_stop_interrupt(audio_streamer, output_stream, stop_eve stop_event.set() audio_streamer.silence.seek(0, 0) 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): - pl = playlist.Playlist(name='test_playlist') + pl = playlist.Playlist(name="test_playlist") input_queue.put(bytes(pl.tracks[0])) load_event.set() audio_streamer.silence.seek(0, 0) 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 diff --git a/test/test_transcoder.py b/test/test_transcoder.py index b71078b..42a1540 100644 --- a/test/test_transcoder.py +++ b/test/test_transcoder.py @@ -3,23 +3,32 @@ from unittest.mock import MagicMock import ffmpeg import pytest -from croaker import playlist -from croaker import transcoder +from croaker import playlist, transcoder -@pytest.mark.parametrize('suffix, expected', [ - ('.mp3', b'_theme.mp3\n'), - ('.foo', b'transcoding!\n'), -]) +@pytest.mark.parametrize( + "suffix, expected", + [ + (".mp3", b"_theme.mp3\n"), + (".foo", b"transcoding!\n"), + ], +) def test_transcoder_open(monkeypatch, suffix, expected): - monkeypatch.setattr(transcoder, 'ffmpeg', MagicMock(spec=ffmpeg, **{ - 'input.return_value.' - 'output.return_value.' - 'global_args.return_value.' - 'compile.return_value': ['echo', 'transcoding!'], - })) + monkeypatch.setattr( + transcoder, + "ffmpeg", + MagicMock( + 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] with transcoder.open(track) as handle: assert handle.read() == expected