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>"]
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

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_state = {}
logger = logging.getLogger('cli')
logger = logging.getLogger("cli")
@app.callback()

View File

@ -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):

View File

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

View File

@ -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 = }")

View File

@ -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():

View File

@ -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()
)

View File

@ -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")

View File

@ -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
@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

View File

@ -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

View File

@ -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

View File

@ -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