restructure project for poetry-slam
This commit is contained in:
parent
c94fb127ed
commit
7ded43476e
|
@ -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
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
[pytest]
|
||||
log_cli_level = DEBUG
|
||||
addopts = --cov=croaker/ --cov-report=term-missing
|
|
@ -42,7 +42,7 @@ ICECAST_URL=
|
|||
app = typer.Typer()
|
||||
app_state = {}
|
||||
|
||||
logger = logging.getLogger('cli')
|
||||
logger = logging.getLogger("cli")
|
||||
|
||||
|
||||
@app.callback()
|
|
@ -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):
|
|
@ -9,7 +9,7 @@ from typing import List
|
|||
|
||||
import croaker.path
|
||||
|
||||
logger = logging.getLogger('playlist')
|
||||
logger = logging.getLogger("playlist")
|
||||
|
||||
playlists = {}
|
||||
|
|
@ -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 = }")
|
|
@ -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():
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue
Block a user