From 7a4cd1ba50bae0f1930faad7c2b7939c73daaf9e Mon Sep 17 00:00:00 2001 From: evilchili Date: Sat, 13 Sep 2025 17:13:37 -0700 Subject: [PATCH] Replace shoutcast implementation with vlc+gtk app --- pyproject.toml | 2 + src/croaker/assets/froghat.png | Bin 0 -> 53422 bytes src/croaker/assets/style.css | 16 +++ src/croaker/cli.py | 21 +--- src/croaker/gui.py | 189 +++++++++++++++++++++++++++++++++ src/croaker/path.py | 4 + src/croaker/player.py | 31 ++++++ src/croaker/server.py | 144 +++++++------------------ src/croaker/streamer.py | 168 ----------------------------- src/croaker/transcoder.py | 151 -------------------------- test/test_streamer.py | 92 ---------------- test/test_transcoder.py | 43 -------- 12 files changed, 282 insertions(+), 579 deletions(-) create mode 100644 src/croaker/assets/froghat.png create mode 100644 src/croaker/assets/style.css create mode 100644 src/croaker/gui.py create mode 100644 src/croaker/player.py delete mode 100644 src/croaker/streamer.py delete mode 100644 src/croaker/transcoder.py delete mode 100644 test/test_streamer.py delete mode 100644 test/test_transcoder.py diff --git a/pyproject.toml b/pyproject.toml index 5bae30d..7be5da5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ psutil = "^5.9.8" exscript = "^2.6.28" python-shout = "^0.2.8" ffmpeg-python = "^0.2.0" +python-vlc = "^3.0.21203" +pygobject = "3.50.0" [tool.poetry.scripts] croaker = "croaker.cli:app" diff --git a/src/croaker/assets/froghat.png b/src/croaker/assets/froghat.png new file mode 100644 index 0000000000000000000000000000000000000000..09c6be6cfca7a6235037c47604700dcdf4a1cdcc GIT binary patch literal 53422 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 literal 0 HcmV?d00001 diff --git a/src/croaker/assets/style.css b/src/croaker/assets/style.css new file mode 100644 index 0000000..9c01433 --- /dev/null +++ b/src/croaker/assets/style.css @@ -0,0 +1,16 @@ +window { + background: #000; +} + +.artwork { + background: #FFF; +} + +.label { + color: #888; +} + +.now_playing { + color: #FFF; + font-weight: bold; +} diff --git a/src/croaker/cli.py b/src/croaker/cli.py index 820deed..75bdb06 100644 --- a/src/croaker/cli.py +++ b/src/croaker/cli.py @@ -11,8 +11,8 @@ from dotenv import load_dotenv from typing_extensions import Annotated from croaker import path +from croaker.player import Player from croaker.playlist import Playlist -from croaker.server import server SETUP_HELP = f""" # Root directory for croaker configuration and logs. See also croaker --root. @@ -25,18 +25,12 @@ CROAKER_ROOT={path.root()} #PIDFILE={path.root()}/croaker.pid # Command and Control TCP Server bind address -HOST=0.0.0.0 +HOST=127.0.0.1 PORT=8003 # the kinds of files to add to playlists MEDIA_GLOB=*.mp3,*.flac,*.m4a -# Icecast2 configuration for Liquidsoap -ICECAST_PASSWORD= -ICECAST_MOUNT= -ICECAST_HOST= -ICECAST_PORT= -ICECAST_URL= """ app = typer.Typer() @@ -90,15 +84,8 @@ def start( """ Start the Croaker command and control server. """ - server.start(daemonize=daemonize, shoutcast_enabled=shoutcast) - - -@app.command() -def stop(): - """ - Terminate the server. - """ - server.stop() + player = Player() + player.run() @app.command() diff --git a/src/croaker/gui.py b/src/croaker/gui.py new file mode 100644 index 0000000..292972a --- /dev/null +++ b/src/croaker/gui.py @@ -0,0 +1,189 @@ +import threading +import time + +import gi +import vlc + +from croaker import path +from croaker.playlist import Playlist, load_playlist + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +from gi.repository import GLib, GObject, Gdk, Gtk, Pango # noqa E402 + + +class PlayerWindow(Gtk.ApplicationWindow): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._max_width = 300 + self._max_height = 330 + self._artwork_width = self._max_width + self._artwork_height = 248 + + css_provider = Gtk.CssProvider() + css_provider.load_from_path(str(path.assets() / 'style.css')) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + self.set_title("Croaker Radio") + self._root = Gtk.Fixed() + self._root.set_size_request(self._max_width, self._max_height) + self.set_child(self._root) + + self._artwork = Gtk.Fixed() + self._track = None + self._artist = None + self._album = None + + self._draw_window() + + + def _draw_window(self): + margin_size = 8 + label_width = self._max_width - (2 * margin_size) + label_height = 16 + label_spacing = 8 + + self._artwork.set_size_request(self._artwork_width, self._artwork_height) + self._root.put(self._artwork, 0, 0) + self.draw_artwork() + + def label(text: str): + l = Gtk.Label() + l.set_ellipsize(Pango.EllipsizeMode.END) + l.add_css_class("label") + l.set_text(text) + l.set_size_request(label_width, label_height) + l.set_justify(Gtk.Justification.LEFT) + l.set_hexpand(True) + l.set_xalign(0) + return l + + self._track = label("CROAKER RADIO") + self._track.add_css_class("now_playing") + self._root.put(self._track, margin_size, self._artwork_height + label_spacing) + + self._artist = label("Artist") + self._root.put(self._artist, margin_size, self._artwork_height + (2 * label_spacing) + label_height) + + self._album = label("Album") + self._root.put(self._album, margin_size, self._artwork_height + (3 * label_spacing) + (2 * label_height)) + + def now_playing(self, track: str, artist: str, album: str): + self._track.set_text(f"🎵 {track}") + self._artist.set_text(f"🐸 {artist}") + self._album.set_text(f"💿 {album}") + + def draw_artwork(self): + image1 = Gtk.Image() + image1.set_from_file(str(path.assets() / 'froghat.png')) + image1.set_size_request(self._artwork_width, self._artwork_height) + image1.add_css_class("artwork") + self._artwork.put(image1, 0, 0) + + +class GUI(Gtk.Application): + """ + A simple GTK application that instaniates a VLC player and listens for commands. + """ + + def __init__(self): + super().__init__() + + self._playlist: Playlist | None = None + + self._vlc_instance = vlc.Instance("--loop") + self._media_list_player = vlc.MediaListPlayer() + self._player.audio_set_volume(30) + + self._signal_handler = threading.Thread(target=self._wait_for_signals) + self._signal_handler.daemon = True + + self.play_requested = threading.Event() + self.back_requested = threading.Event() + self.ffwd_requested = threading.Event() + self.stop_requested = threading.Event() + self.load_requested = threading.Event() + self.clear_requested = threading.Event() + self.shutdown_requested = threading.Event() + + GLib.set_application_name("Croaker Radio") + + @property + def _player(self): + return self._media_list_player.get_media_player() + + def do_activate(self): + self._signal_handler.start() + self._window = PlayerWindow(application=self) + self._window.present() + + def load(self, playlist_name: str): + self.clear() + self._playlist = load_playlist(playlist_name) + + media = self._vlc_instance.media_list_new() + for track in self._playlist.tracks: + media.add_media(self._vlc_instance.media_new(track)) + + self._media_list_player.set_media_list(media) + self._media_list_player.play() + self._update_now_playing() + events = self._player.event_manager() + events.event_attach(vlc.EventType.MediaPlayerMediaChanged, self._update_now_playing) + + def _update_now_playing(self, event=None): + track = "[NOTHING PLAYING]" + artist = "artist" + album = "album" + media = self._player.get_media() + if media: + media.parse() + track = media.get_meta(vlc.Meta.Title) + artist = media.get_meta(vlc.Meta.Artist) + album = media.get_meta(vlc.Meta.Album) + self._window.now_playing(track, artist, album) + + def _wait_for_signals(self): + while not self.shutdown_requested.is_set(): + if self.play_requested.is_set(): + self.play_requested.clear() + GLib.idle_add(self._media_list_player.play) + + if self.back_requested.is_set(): + self.back_requested.clear() + GLib.idle_add(self._media_list_player.previous) + + if self.ffwd_requested.is_set(): + self.ffwd_requested.clear() + GLib.idle_add(self._media_list_player.next) + + if self.stop_requested.is_set(): + self.stop_requested.clear() + GLib.idle_add(self._media_list_player.stop) + + if self.load_requested.is_set(): + self.load_requested.clear() + GLib.idle_add(self._media_list_player.load) + + if self.clear_requested.is_set(): + self.clear_requested.clear() + GLib.idle_add(self.clear) + + time.sleep(0.25) + GLib.idle_add(self.quit) + exit() + + def clear(self): + if self._media_list_player: + self._media_list_player.stop() + self._playlist = None + + def quit(self): + self.clear() + self._vlc_instance.release() + exit() diff --git a/src/croaker/path.py b/src/croaker/path.py index 515867e..601d0da 100644 --- a/src/croaker/path.py +++ b/src/croaker/path.py @@ -9,6 +9,10 @@ def root(): return Path(os.environ.get("CROAKER_ROOT", "~/.dnd/croaker")).expanduser() +def assets(): + return Path(__file__).parent / 'assets' + + def playlist_root(): path = Path(os.environ.get("PLAYLIST_ROOT", root() / "playlists")).expanduser() return path diff --git a/src/croaker/player.py b/src/croaker/player.py new file mode 100644 index 0000000..1e465a3 --- /dev/null +++ b/src/croaker/player.py @@ -0,0 +1,31 @@ +import logging +import threading + +import gi + +from croaker.gui import GUI +from croaker.server import Controller + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, GObject, Gtk # noqa E402 + +logger = logging.getLogger("player") + + +class Player(GUI): + """ + A GTK GUI application with a TCP command and control server. + """ + + def __init__(self): + super().__init__() + self._controller = threading.Thread(target=self._start_controller) + self._controller.daemon = True + + def do_activate(self): + self._controller.start() + super().do_activate() + self.load("session_start") + + def _start_controller(self): + Controller(self).serve_forever(poll_interval=0.25) diff --git a/src/croaker/server.py b/src/croaker/server.py index 021b87c..d3b4c55 100644 --- a/src/croaker/server.py +++ b/src/croaker/server.py @@ -1,25 +1,20 @@ import logging import os -import queue +import socket import socketserver -from pathlib import Path -from time import sleep +import time -import daemon - -from croaker import path -from croaker.pidfile import pidfile +from croaker.gui import GUI +from croaker.path import playlist_root from croaker.playlist import load_playlist -from croaker.streamer import AudioStreamer -logger = logging.getLogger("server") +logger = logging.getLogger(__name__) class RequestHandler(socketserver.StreamRequestHandler): """ Instantiated by the TCPServer when a request is received. Implements the - command and control protocol and sends commands to the shoutcast source - client on behalf of the user. + command and control protocol and issues commands to the GUI application. """ supported_commands = { @@ -46,43 +41,44 @@ class RequestHandler(socketserver.StreamRequestHandler): 4 Ignored 5+ Arguments """ - while True: + while self.should_listen: + time.sleep(0.01) self.data = self.rfile.readline().strip().decode() logger.debug(f"Received: {self.data}") try: cmd = self.data[0:4].strip().upper() - args = self.data[5:] + if not cmd: + continue + elif cmd not in self.supported_commands: + self.send(f"ERR Unknown Command '{cmd}'") except IndexError: self.send(f"ERR Command not understood '{cmd}'") - sleep(0.001) continue - if not cmd: - sleep(0.001) - continue - elif cmd not in self.supported_commands: - self.send(f"ERR Unknown Command '{cmd}'") - sleep(0.001) - continue - elif cmd == "KTHX": + args = self.data[5:] + if cmd == "KTHX": return self.send("KBAI") handler = getattr(self, f"handle_{cmd}", None) if not handler: self.send(f"ERR No handler for {cmd}.") + continue + handler(args) - if not self.should_listen: - break def send(self, msg): return self.wfile.write(msg.encode() + b"\n") def handle_PLAY(self, args): - self.server.load(args) + self.server.player.load(args) + return self.send("OK") + + def handle_BACK(self, args): + self.server.player.back_requested.set() return self.send("OK") def handle_FFWD(self, args): - self.server.ffwd() + self.server.player.ffwd_requested.set() return self.send("OK") def handle_LIST(self, args): @@ -92,99 +88,31 @@ 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.streamer.stop_requested.set() + return self.server.player.stop_requested.set() def handle_STFU(self, args): self.send("Shutting down.") - self.server.stop() + self.server.shutdown() -class CroakerServer(socketserver.TCPServer): +class Controller(socketserver.TCPServer): """ - A Daemonized TCP Server that also starts a Shoutcast source client. + A TCP Server that listens for commands and proxies the GUI audio player. """ - allow_reuse_address = True + def __init__(self, player: GUI): + self.player = player + super().__init__((os.environ["HOST"], int(os.environ["PORT"])), RequestHandler) - def __init__(self): - self._context = daemon.DaemonContext() - self._queue = queue.Queue() - self._streamer = None - self.playlist = None + def server_bind(self): + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socket.bind(self.server_address) - def _pidfile(self): - return pidfile(path.root() / "croaker.pid") - - @property - def streamer(self): - return self._streamer - - def bind_address(self): - return (os.environ["HOST"], int(os.environ["PORT"])) - - def _daemonize(self) -> None: - """ - Daemonize the current process. - """ - logger.info(f"Daemonizing controller; pidfile and output in {path.root()}") - self._context.pidfile = self._pidfile() - self._context.stdout = open(path.root() / Path("croaker.out"), "wb", buffering=0) - self._context.stderr = open(path.root() / Path("croaker.err"), "wb", buffering=0) - - # when open() is called, all open file descriptors will be closed, as - # befits a good daemon. However this will also close the socket on - # which the TCPServer is listening! So let's keep that one open. - self._context.files_preserve = [self.fileno()] - self._context.open() - - def start(self, daemonize: bool = True, shoutcast_enabled: bool = True) -> None: - """ - Start the shoutcast controller background thread, then begin listening for connections. - """ - logger.info(f"Starting controller on {self.bind_address()}.") - super().__init__(self.bind_address(), RequestHandler) - if daemonize: - self._daemonize() - try: - logger.debug("Starting AudioStreamer...") - self._streamer = AudioStreamer(self._queue, shoutcast_enabled=shoutcast_enabled) - self.streamer.start() - self.load("session_start") - self.serve_forever() - except KeyboardInterrupt: - logger.info("Keyboard interrupt detected.") - self.streamer.shutdown_requested.set() - self.stop() - - def stop(self): - self._pidfile() - - def ffwd(self): - logger.debug("Sending SKIP signal to streamer...") - self.streamer.skip_requested.set() - - def clear_queue(self): - logger.debug("Requesting a clear...") - self.streamer.clear_requested.set() - while self.streamer.clear_requested.is_set(): - sleep(0.001) - logger.debug("Cleared") + def shutdown(self): + self.player.shutdown_requested.set() + exit() 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()]) - - def load(self, playlist_name: str): - logger.debug(f"Switching to {playlist_name = }") - self.streamer.stop_requested.set() - if self.playlist: - self.clear_queue() - self.playlist = load_playlist(playlist_name) - logger.debug(f"Loaded new playlist {self.playlist = }") - for track in self.playlist.tracks: - self._queue.put(str(track).encode()) - self.streamer.start_requested.set() - - -server = CroakerServer() + return "\n".join([str(p.name) for p in playlist_root().iterdir()]) diff --git a/src/croaker/streamer.py b/src/croaker/streamer.py deleted file mode 100644 index 18f6489..0000000 --- a/src/croaker/streamer.py +++ /dev/null @@ -1,168 +0,0 @@ -import logging -import os -import queue -import threading -from dataclasses import dataclass -from functools import cached_property -from pathlib import Path -from time import sleep - -import shout - -from croaker.transcoder import FrameAlignedStream - -logger = logging.getLogger("streamer") - - -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: queue.Queue = queue.Queue(), chunk_size: int = 8092, shoutcast_enabled: bool = True): - super().__init__() - self.queue = queue - self.chunk_size = chunk_size - self._shoutcast_enabled = shoutcast_enabled - self.skip_requested = threading.Event() - self.stop_requested = threading.Event() - self.start_requested = threading.Event() - self.clear_requested = threading.Event() - self.shutdown_requested = threading.Event() - - @cached_property - def silence(self): - return FrameAlignedStream(Path(__file__).parent / "silence.mp3", chunk_size=self.chunk_size) - - @cached_property - def _out(self): - if self._shoutcast_enabled: - s = shout.Shout() - else: - s = debugServer() - s.name = "Croaker Radio" - s.url = os.environ["ICECAST_URL"] - s.mount = os.environ["ICECAST_MOUNT"] - s.host = os.environ["ICECAST_HOST"] - s.port = int(os.environ["ICECAST_PORT"]) - s.password = os.environ["ICECAST_PASSWORD"] - s.protocol = os.environ.get("ICECAST_PROTOCOL", "http") - s.format = os.environ.get("ICECAST_FORMAT", "mp3") - return s - - def run(self): # pragma: no cover - while not self.shutdown_requested.is_set(): - try: - self.connect() - self.stream_forever() - break - except shout.ShoutException as e: - logger.error("Error connecting to shoutcast server. Will sleep and try again.", exc_info=e) - sleep(3) - self.shutdown() - self.shutdown_requested.clear() - - def connect(self): - logger.info(f"Connecting to downstream server at {self._out}") - self._out.close() - self._out.open() - - def shutdown(self): - if hasattr(self, "_out"): - self._out.close() - del self._out - self.clear_queue() - logger.info("Shutting down.") - - def clear_queue(self): - logger.info("Clearing queue...") - while not self.queue.empty(): - self.queue.get() - - def queued_audio_source(self): - """ - Return a filehandle to the next queued audio source, or silence if the queue is empty. - """ - try: - track = Path(self.queue.get(block=False).decode()) - logger.debug(f"Streaming {track.stem = }") - return FrameAlignedStream(track, chunk_size=self.chunk_size), track.stem - except queue.Empty: - logger.debug("Nothing queued; enqueing silence.") - except Exception as exc: - logger.error("Caught exception; falling back to silence.", exc_info=exc) - return self.silence, "[NOTHING PLAYING]" - - def pause_if_necessary(self): - while self.stop_requested.is_set(): - if self.start_requested.is_set(): - self.stop_requested.clear() - self.start_requested.clear() - return - sleep(0.001) - - def stream_forever(self): - while not self.shutdown_requested.is_set(): - self.pause_if_necessary() - stream, title = self.queued_audio_source() - logging.debug(f"Starting stream of {title = }") - self._out.set_metadata({"song": title}) - for chunk in stream: - if self.skip_requested.is_set(): - logger.info("EVENT: Skip") - self.skip_requested.clear() - break - - if self.clear_requested.is_set(): - logger.info("EVENT: Clear") - self.clear_queue() - self.clear_requested.clear() - break - - if self.stop_requested.is_set(): - logger.info("EVENT: Stop") - break - - if self.start_requested.is_set(): - self.start_requested.clear() - break - - if self.shutdown_requested.is_set(): - logger.info("EVENT: Shutdown") - break - - logger.debug(f"{title}: {len(chunk)} bytes") - self._out.send(chunk) - self._out.sync() - - -@dataclass -class debugServer: - name: str = "Croaker Debugger" - url: str = None - mount: str = None - host: str = None - port: str = None - password: str = None - format: str = None - - _output_file: Path = Path("/dev/null") # Path("./croaker.stream.output.mp3") - _filehandle = None - - def open(self): - self._filehandle = self._output_file.open("wb") - - def close(self): - if self._filehandle: - self._filehandle.close() - self._filehandle = None - - def set_metadata(self, metadata: dict): - logger.info(f"debugServer: {metadata = }") - - def send(self, chunk: bytes): - self._filehandle.write(chunk) - - def sync(self): - self._filehandle.flush() diff --git a/src/croaker/transcoder.py b/src/croaker/transcoder.py deleted file mode 100644 index 2742f96..0000000 --- a/src/croaker/transcoder.py +++ /dev/null @@ -1,151 +0,0 @@ -import io -import logging -import os -import subprocess -from dataclasses import dataclass -from pathlib import Path - -import ffmpeg - -logger = logging.getLogger("transcoder") - - -@dataclass -class FrameAlignedStream: - """ - Use ffmpeg to transcode a source audio file to mp3 and iterate over the result - in frame-aligned chunks. This will ensure that readers will always have a full - frame of audio data to parse or emit. - - I learned a lot from https://github.com/pylon/streamp3 figuring this stuff out! - - Usage: - - >>> stream = FrameAlignedStream.from_source(Path('test.flac').open('rb')) - >>> for segment in stream: - ... - """ - - source_file: Path - chunk_size: int = 1024 - bit_rate: int = 192000 - sample_rate: int = 44100 - - _transcoder: subprocess.Popen = None - _buffer: io.BufferedReader = None - - @property - def source(self): - if self._buffer: - return self._buffer - if self._transcoder: - return self._transcoder.stdout - logger.info("Source is empty") - return None - - @property - def frames(self): - while True: - frame = self._read_one_frame() - if not frame: - return - yield frame - - def _read_one_frame(self): - """ - Read the next full audio frame from the input source and return it - """ - - # step through the source a byte at a time and look for the frame sync. - header = None - buffer = b"" - while not header: - buffer += self.source.read(4 - len(buffer)) - if len(buffer) != 4: - logging.debug("Reached the end of the source stream without finding another framesync.") - return False - header = buffer[:4] - if header[0] != 0b11111111 or header[1] >> 5 != 0b111: - logging.debug(f"Expected a framesync but got {buffer} instead; moving fwd 1 byte.") - header = None - buffer = buffer[1:] - - # Decode the mp3 header. We could derive the bit_rate and sample_rate - # here if we had the lookup tables etc. from the MPEG spec, but since - # we control the input, we can rely on them being predefined. - version_code = (header[1] & 0b00011000) >> 3 - padding_code = (header[2] & 0b00000010) >> 1 - version = version_code & 1 if version_code >> 1 else 2 - is_padded = bool(padding_code) - - # calculate the size of the whole frame - frame_size = 1152 if version == 1 else 576 - frame_size = self.bit_rate // 8 * frame_size // self.sample_rate - if is_padded: - frame_size += 1 - - # read the rest of the frame from the source - frame_data = self.source.read(frame_size - len(header)) - if len(frame_data) != frame_size - len(header): - logging.debug("Reached the end of the source stream without finding a full frame.") - return None - - # return the entire frame - return header + frame_data - - def __iter__(self): - """ - Generate approximately chunk_size segments of audio data by iterating over the - frames, buffering them, and then yielding several as a single bytes object. - """ - try: - self._start_transcoder() - buf = b"" - for frame in self.frames: - if len(buf) >= self.chunk_size: - yield buf - buf = b"" - if not frame: - break - buf += frame - if buf: - yield buf - finally: - self._stop_transcoder() - - def _stop_transcoder(self): - if self._transcoder: - logger.debug(f"Killing {self._transcoder = }") - self._transcoder.kill() - self._transcoder = None - self._buffer = None - - def _start_transcoder(self): - args = [] if os.environ.get("DEBUG") else ["-hide_banner", "-loglevel", "quiet"] - self._transcoder = subprocess.Popen( - ( - ffmpeg.input(str(self.source_file)) - .output( - "pipe:", - map="a", - format="mp3", - # no ID3 headers -- saves having to decode them later - write_xing=0, - id3v2_version=0, - # force sample and bit rates - **{ - "b:a": self.bit_rate, - "ar": self.sample_rate, - }, - ) - .global_args("-vn", *args) - .compile() - ), - bufsize=self.chunk_size, - stdout=subprocess.PIPE, - stdin=subprocess.PIPE, - ) - - # Force close STDIN to prevent ffmpeg from trying to read from it. silly ffmpeg. - self._transcoder.stdin.close() - logger.debug(f"Spawned ffmpeg (PID {self._transcoder.pid}): {' '.join(self._transcoder.args)}") diff --git a/test/test_streamer.py b/test/test_streamer.py deleted file mode 100644 index dbdbf38..0000000 --- a/test/test_streamer.py +++ /dev/null @@ -1,92 +0,0 @@ -import io -import threading -from pathlib import Path -from time import sleep -from unittest.mock import MagicMock - -import pytest -import shout - -from croaker import playlist, streamer - - -@pytest.fixture(scope="session") -def silence_bytes(): - # return (Path(streamer.__file__).parent / "silence.mp3").read_bytes() - return (Path(__file__).parent / "fixtures" / "transcoded_silence.mp3").read_bytes() - - -@pytest.fixture -def output_stream(): - return io.BytesIO() - - -@pytest.fixture -def mock_shout(output_stream, monkeypatch): - def handle_send(buf): - print(f"buffering {len(buf)} bytes to output_stream.") - output_stream.write(buf) - - mm = MagicMock(spec=shout.Shout, **{"return_value.send.side_effect": handle_send}) - monkeypatch.setattr("shout.Shout", mm) - return mm - - -@pytest.fixture -def audio_streamer(monkeypatch, mock_shout): - return streamer.AudioStreamer() - - -@pytest.fixture -def thread(audio_streamer): - thread = threading.Thread(target=audio_streamer.run) - thread.daemon = True - yield thread - audio_streamer.shutdown_requested.set() - thread.join() - - -def wait_for(condition, timeout=2.0): - elapsed = 0.0 - while not condition() and elapsed < 2.0: - elapsed += 0.01 - sleep(0.01) - return elapsed <= timeout - - -def wait_for_not(condition, timeout=2.0): - return wait_for(lambda: not condition(), timeout=timeout) - - -def test_streamer_clear(audio_streamer, thread): - # enqueue some tracks - pl = playlist.Playlist(name="test_playlist") - for track in pl.tracks: - audio_streamer.queue.put(bytes(track)) - assert not audio_streamer.queue.empty() - - # start the server and send it a clear request - thread.start() - audio_streamer.clear_requested.set() - assert wait_for(audio_streamer.queue.empty) - assert wait_for_not(audio_streamer.clear_requested.is_set) - - -def test_streamer_shutdown(audio_streamer, thread): - thread.start() - audio_streamer.shutdown_requested.set() - assert wait_for_not(audio_streamer.shutdown_requested.is_set) - - -def test_streamer_skip(audio_streamer, thread): - thread.start() - audio_streamer.skip_requested.set() - assert wait_for_not(audio_streamer.skip_requested.is_set) - - -def test_streamer_defaults_to_silence(audio_streamer, thread, output_stream, silence_bytes): - thread.start() - thread.join(timeout=1) - output_stream.seek(0, 0) - out = output_stream.read() - assert silence_bytes in out diff --git a/test/test_transcoder.py b/test/test_transcoder.py deleted file mode 100644 index 3e8bc2c..0000000 --- a/test/test_transcoder.py +++ /dev/null @@ -1,43 +0,0 @@ -from unittest.mock import MagicMock - -import ffmpeg -import pytest - -from croaker import playlist, transcoder - - -@pytest.fixture -def mock_mp3decoder(monkeypatch): - def read(stream): - return stream.read() - - monkeypatch.setattr(transcoder, "MP3Decoder", MagicMock(**{"__enter__.return_value.read": read})) - - -@pytest.mark.xfail -@pytest.mark.parametrize( - "suffix, expected", - [ - (".mp3", b"_theme.mp3\n"), - (".foo", b"transcoding!\n"), - ], -) -def test_transcoder_open(monkeypatch, mock_mp3decoder, suffix, expected): - 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") - track = [t for t in pl.tracks if t.suffix == suffix][0] - with transcoder.open(track) as handle: - assert handle.read() == expected