From 7c8ba12778a2b29d28ecccce55248151aaa3e514 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Fri, 23 Jan 2026 22:29:10 +0100 Subject: [PATCH 01/23] added queue log type --- shared/logger.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/shared/logger.py b/shared/logger.py index 34a87dd..1756f89 100644 --- a/shared/logger.py +++ b/shared/logger.py @@ -27,7 +27,8 @@ class Logger(DLogger): 'auth': 'AUTH', 'tls': 'TLS', 'morse': 'MORSE', - 'alsa': 'ALSA' + 'alsa': 'ALSA', + 'queue': 'QUEUE' } STYLES = { @@ -46,7 +47,8 @@ class Logger(DLogger): 'auth': 'blue', 'tls': 'red', 'morse': 'purple', - 'alsa': 'pink' + 'alsa': 'pink', + 'queue': 'orange' } ws_clients = set() From 139ec123fa4e6dbcfc62d77902dd5aa848c92e94 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Fri, 23 Jan 2026 23:50:27 +0100 Subject: [PATCH 02/23] shared,client: protocol 2.0.2 Added END command, the client sends it on broadcast end --- client/client.py | 10 ++++++++++ shared/protocol.py | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/client/client.py b/client/client.py index 040b245..7a88cee 100644 --- a/client/client.py +++ b/client/client.py @@ -505,6 +505,16 @@ async def _delayed_broadcast(self, file_path, filename, frequency, ps, rt, pi, l async def _start_broadcast(self, file_path, filename, frequency, ps, rt, pi, loop): async def finished(): Log.info("Playback finished, stopping broadcast...") + + try: + response = ProtocolParser.build_command( + Commands.END, + filename=filename + ) + await self.ws_client.send(response) + except Exception as e: + Log.error(f"Error notifying server of broadcast end: {e}") + await self._stop_broadcast() async with self.broadcast_lock: diff --git a/shared/protocol.py b/shared/protocol.py index 00bf127..d896b7d 100644 --- a/shared/protocol.py +++ b/shared/protocol.py @@ -1,7 +1,7 @@ import shlex from typing import Dict, Tuple -PROTOCOL_VERSION = "2.0.1" +PROTOCOL_VERSION = "2.0.2" class Commands: @@ -18,6 +18,7 @@ class Commands: # broadcast START = 'START' STOP = 'STOP' + END = 'END' # files UPLOAD_TOKEN = 'UPLOAD_TOKEN' From 18d64514ce92a27c4c64b072926da0ef447ddda3 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Fri, 23 Jan 2026 23:50:55 +0100 Subject: [PATCH 03/23] temp queue implementatio, there are still a lot of things to do --- server/server.py | 20 ++- shared/queue.py | 426 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 438 insertions(+), 8 deletions(-) create mode 100644 shared/queue.py diff --git a/server/server.py b/server/server.py index f4e4074..097da0a 100644 --- a/server/server.py +++ b/server/server.py @@ -32,6 +32,7 @@ from shared.logger import Log, toggle_input from shared.morser import text_to_morse from shared.protocol import ProtocolParser, Commands, PROTOCOL_VERSION +from shared.queue import Queue from shared.socket import BWWebSocketServer from shared.sstv import make_sstv_wav from shared.tls import gen_cert, save_cert @@ -81,9 +82,7 @@ def __init__(self, host: str = '0.0.0.0', ws_port: int = 9938, http_port: int = self.running = False self.pending_responses: Dict[str, asyncio.Future] = {} self.file_list_responses: Dict[str, list] = {} - - # cmd history for interactive mode - self.command_history = [] + self.queue = Queue(self) self.handlers_executor = HandlerExecutor(handlers_dir, self._execute_command) self.loop = None @@ -243,6 +242,12 @@ async def _handle_client_message(self, client_id: Optional[str], message: str, w return + if command == Commands.END: + filename = kwargs.get('filename', 'unknown') + Log.broadcast(f"{self.clients[client_id].get_display_name()}: Finished broadcasting {filename}") + self.queue.on_broadcast_ended(client_id) + return + Log.warning(f"Unexpected command from {client_id}: {command}") except Exception as e: @@ -563,6 +568,10 @@ async def _execute_command_async(self, command_name: str, cmd: list): await self.stop_broadcast(cmd[1]) return + elif command_name == 'queue': + self.queue.parse(' '.join(cmd[1:])) + return + # OTHER MEDIA FORM elif command_name == 'sstv': if len(cmd) < 3: @@ -1654,11 +1663,6 @@ def run_async_server(): if HAS_READLINE: readline.add_history(cmd_input) - - server.command_history.append(cmd_input) - - if len(server.command_history) > 1000: - server.command_history = server.command_history[-1000:] server._execute_command(cmd_input) diff --git a/shared/queue.py b/shared/queue.py new file mode 100644 index 0000000..62d80f5 --- /dev/null +++ b/shared/queue.py @@ -0,0 +1,426 @@ +from .logger import Log +import os +from typing import List, Dict, Set, Optional +import asyncio +import fnmatch + +class Queue: + def __init__(self, server_instance=None, client_instance=None, is_local=False, upload_dir="/opt/BotWave/uploads"): + self.queue = [] + self.server = server_instance + self.client = client_instance + self.is_local = is_local + self.upload_dir = upload_dir + self.paused = True + self.current_index = 0 + self.client_indices = {} # {client_id: current_index} + self.active_targets = "all" # remember which targets we're playing on + + def parse(self, command: str): + if not command: + self.show("") + return + + first = command[0] + + if first == "+": + action = self.add + elif first == "-": + action = self.remove + elif first == "*": + action = self.show + elif first == "?": + action = self.help + elif first == "!": + action = self.toggle + else: + Log.error(f"Invalid Actions: {first}.") + Log.queue(f"Use 'queue ?' for help.") + return + + command = command[1:].strip() + action(command) + + def add(self, command: str): + """Add files to queue. Supports: file, file1,file2, pattern_*, or *""" + force = command.endswith("!") + if force: + command = command[:-1].strip() + + if not command: + Log.error("No file specified") + return + + file_specs = [f.strip() for f in command.split(',')] + + if self.is_local: + self._add_local(file_specs, force) + else: + asyncio.create_task(self._add_server(file_specs, force)) + + def _add_local(self, file_specs: List[str], force: bool): + """Add files locally""" + added = [] + for spec in file_specs: + if '*' in spec: + files = self._match_files_local(spec, self.upload_dir) + added.extend(files) + else: + if os.path.exists(os.path.join(self.upload_dir, spec)): + added.append(spec) + else: + Log.warning(f"File not found: {spec}") + + self.queue.extend(added) + Log.queue(f"Added {len(added)} file(s) to queue") + + self.show("") + + async def _add_server(self, file_specs: List[str], force: bool): + """Add files on server & checks all clients have them (unless forced)""" + if not self.server or not self.server.clients: + Log.error("No clients connected") + return + + # if forcing, just add the specs directly without checking clients + if force: + added = [] + for spec in file_specs: + if '*' in spec: + # Get from first client + client_ids = list(self.server.clients.keys()) + if client_ids: + client_files = await self._get_all_client_files([client_ids[0]]) + if client_files: + all_files = list(client_files.values())[0] + if spec == '*': + added.extend(sorted(all_files)) + else: + pattern_matches = [f for f in all_files if fnmatch.fnmatch(f, spec)] + added.extend(sorted(pattern_matches)) + else: + added.append(spec) + + self.queue.extend(added) + Log.queue(f"Added {len(added)} file(s) to queue (forced)") + self.show("") + return + + client_ids = list(self.server.clients.keys()) + + client_files = await self._get_all_client_files(client_ids) + + if not client_files: + Log.error("Could not retrieve file lists from clients") + return + + candidates, missing_per_client = self._resolve_file_specs(file_specs, client_files) + + if not candidates: + Log.error("No matching files found on all clients.") + Log.queue("Use '!' at the end to force add anyway (e.g., 'queue +file!')") + return + + # check if any files are missing on some clients + if missing_per_client: + Log.error("Some files are not present on all clients:") + for client_id, missing_files in missing_per_client.items(): + if missing_files: + client_name = self.server.clients[client_id].get_display_name() + Log.error(f" {client_name}: missing {', '.join(list(missing_files)[:3])}{'...' if len(missing_files) > 3 else ''}") + Log.queue("Use '!' at the end to force add anyway (e.g., 'queue +file!')") + return + + self.queue.extend(candidates) + Log.queue(f"Added {len(candidates)} file(s) to queue") + self.show("") + + async def _get_all_client_files(self, client_ids: List[str]) -> Dict[str, Set[str]]: + """Get file lists from all clients""" + client_files = {} + + for client_id in client_ids: + try: + files = await self.server._request_file_list(client_id, timeout=10) + if files: + client_files[client_id] = set(f['name'] for f in files) + else: + Log.warning(f"No files from {client_id}") + client_files[client_id] = set() + except Exception as e: + Log.error(f"Error getting files from {client_id}: {e}") + client_files[client_id] = set() + + return client_files + + def _resolve_file_specs(self, file_specs: List[str], client_files: Dict[str, Set[str]]) -> tuple[List[str], Dict[str, Set[str]]]: + """Resolve file specs to actual files that exist on ALL clients + Returns: (common_files, missing_per_client) + """ + if not client_files: + return [], {} + + non_empty_client_files = [files for files in client_files.values() if files] + + if not non_empty_client_files: + return [], {} + + # find intersection of all client files (only non-empty ones) + common_files = set.intersection(*non_empty_client_files) + + matched = set() + requested_files = set() + + for spec in file_specs: + if spec == '*': + # All common files + matched.update(common_files) + # For *, we consider all files from any client as "requested" + for files in client_files.values(): + requested_files.update(files) + elif '*' in spec: + # Wildcard pattern - check against common files + pattern_matches = [f for f in common_files if fnmatch.fnmatch(f, spec)] + matched.update(pattern_matches) + + # Also find all files that match the pattern on any client + for files in client_files.values(): + requested_files.update([f for f in files if fnmatch.fnmatch(f, spec)]) + + if not pattern_matches: + Log.warning(f"No files match pattern on all clients: {spec}") + else: + # Exact file + requested_files.add(spec) + if spec in common_files: + matched.add(spec) + + # Calculate which files are missing on which clients + missing_per_client = {} + if requested_files: + for client_id, files in client_files.items(): + missing = requested_files - files + if missing: + missing_per_client[client_id] = missing + + return sorted(list(matched)), missing_per_client + + def _match_files_local(self, pattern: str, directory: str) -> List[str]: + """Match files using wildcard pattern""" + try: + all_files = [f for f in os.listdir(directory) if f.endswith('.wav')] + if pattern == '*': + return sorted(all_files) + return sorted([f for f in all_files if fnmatch.fnmatch(f, pattern)]) + except Exception as e: + Log.error(f"Error matching files: {e}") + return [] + + def remove(self, command: str): + """Remove files from queue. Same syntax as add""" + if not command: + Log.error("No file specified") + return + + file_specs = [f.strip() for f in command.split(',')] + + removed_count = 0 + for spec in file_specs: + if spec == '*': + # Remove all + removed_count = len(self.queue) + self.queue = [] + break + elif '*' in spec: + # Wildcard removal + original_len = len(self.queue) + self.queue = [f for f in self.queue if not fnmatch.fnmatch(f, spec)] + removed_count += original_len - len(self.queue) + else: + # Exact file + if spec in self.queue: + self.queue.remove(spec) + removed_count += 1 + + Log.queue(f"Removed {removed_count} file(s) from queue") + self.show("") + + def show(self, command: str = ""): + """Display current queue""" + if not self.queue: + Log.queue("Queue is empty") + return + + status = "PAUSED" if self.paused else "PLAYING" + + if self.is_local: + Log.queue(f"Queue ({len(self.queue)} files) - {status}:") + for i, filename in enumerate(self.queue, 1): + marker = "> " if i == self.current_index + 1 else " " + Log.print(f"{marker}{i}. {filename}", 'cyan') + else: + # Show per-client progress + Log.queue(f"Queue ({len(self.queue)} files) - {status}:") + + if self.client_indices: + Log.print("Client positions:", 'yellow') + for client_id, index in self.client_indices.items(): + if client_id in self.server.clients: + client_name = self.server.clients[client_id].get_display_name() + current_file = self.queue[index] if index < len(self.queue) else "finished" + Log.print(f" {client_name}: [{index + 1}/{len(self.queue)}] {current_file}", 'cyan') + + Log.print("\nQueue:", 'yellow') + for i, filename in enumerate(self.queue, 1): + Log.print(f" {i}. {filename}", 'white') + + def help(self, command: str): + """Show help""" + Log.queue("Queue Commands:") + Log.print(" queue +file - Add file to queue", 'white') + Log.print(" queue +file1,file2 - Add multiple files", 'white') + Log.print(" queue +pattern_* - Add files matching pattern", 'white') + Log.print(" queue +* - Add all files", 'white') + Log.print(" queue +file! - Force add (even if not on all clients)", 'white') + Log.print(" queue -file - Remove file from queue", 'white') + Log.print(" queue -* - Clear queue", 'white') + Log.print(" queue * - Show queue", 'white') + Log.print(" queue ! - Toggle play/pause (local or all)", 'white') + Log.print(" queue !targets - Toggle play/pause on specific targets", 'white') + + def toggle(self, command: str): + """Toggle between play/pause - queue !""" + if self.is_local: + self._toggle_local() + else: + targets = command.strip() if command.strip() else "all" + asyncio.create_task(self._toggle_server(targets)) + + def _toggle_local(self): + """Toggle queue playback locally""" + if not self.queue: + Log.error("Queue is empty") + return + + self.paused = not self.paused + status = "paused" if self.paused else "playing" + Log.queue(f"Queue {status}") + + if not self.paused: + # Start playing current file + self._play_current_local() + + def _play_current_local(self): + """Play current file in queue (local client only)""" + if self.current_index >= len(self.queue): + Log.queue("End of queue reached") + self.paused = True + self.current_index = 0 + return + + if not self.client: + Log.error("No client instance available") + return + + filename = self.queue[self.current_index] + file_path = os.path.join(self.upload_dir, filename) + + Log.queue(f"Playing [{self.current_index + 1}/{len(self.queue)}]: {filename}") + + if os.path.exists(file_path): + # Call client's start_broadcast + self.client.start_broadcast(file_path, frequency=90.0, loop=False) + else: + Log.error(f"File not found: {filename}") + self._next_local() + + async def _toggle_server(self, targets: str): + """Toggle queue playback on server""" + if not self.queue: + Log.error("Queue is empty") + return + + if not self.server: + Log.error("No server instance") + return + + self.paused = not self.paused + status = "paused" if self.paused else "playing" + self.active_targets = targets + Log.queue(f"Queue {status} on {targets}") + + if not self.paused: + # Initialize client indices for the targets + target_clients = self.server._parse_client_targets(targets) + for client_id in target_clients: + if client_id not in self.client_indices: + self.client_indices[client_id] = 0 + + # Start playing from each client's current position + await self._play_all_clients(target_clients) + else: + # Stop current broadcast on targets + await self.server.stop_broadcast(targets) + + async def _play_all_clients(self, target_clients: List[str]): + """Start playback for all target clients at their individual positions""" + for client_id in target_clients: + index = self.client_indices.get(client_id, 0) + + if index >= len(self.queue): + Log.queue(f"{self.server.clients[client_id].get_display_name()}: Queue finished") + continue + + filename = self.queue[index] + client_name = self.server.clients[client_id].get_display_name() + + Log.queue(f"{client_name}: Playing [{index + 1}/{len(self.queue)}] {filename}") + + # Start broadcast on this specific client + await self.server.start_broadcast(client_id, filename, frequency=90.0, loop=False) + + def on_broadcast_ended(self, client_id: str = None): + """Called when a broadcast ends - advance to next in queue""" + if self.paused: + return + + if self.is_local: + self._next_local() + else: + asyncio.create_task(self._next_server(client_id)) + + def _next_local(self): + """Move to next file in queue (local)""" + self.current_index += 1 + + if self.current_index >= len(self.queue): + Log.queue("Queue finished") + self.current_index = 0 + self.paused = True + return + + self._play_current_local() + + async def _next_server(self, client_id: str): + """Move to next file in queue for specific client (server)""" + if not client_id or client_id not in self.client_indices: + Log.warning(f"Client {client_id} not in queue tracking") + return + + # Increment this client's index + self.client_indices[client_id] += 1 + client_index = self.client_indices[client_id] + + if client_index >= len(self.queue): + client_name = self.server.clients[client_id].get_display_name() + Log.queue(f"{client_name}: Queue finished") + return + + # Play next file for this specific client + filename = self.queue[client_index] + client_name = self.server.clients[client_id].get_display_name() + + Log.queue(f"{client_name}: Next [{client_index + 1}/{len(self.queue)}] {filename}") + + # Start broadcast on this specific client only + await self.server.start_broadcast(client_id, filename, frequency=90.0, loop=False) \ No newline at end of file From d5ea249f78274978953a5f12f1c079948194443b Mon Sep 17 00:00:00 2001 From: douxxtech Date: Sat, 24 Jan 2026 21:36:13 +0100 Subject: [PATCH 04/23] queue: cleaned up, and made some maintenance (claude) --- shared/queue.py | 406 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 300 insertions(+), 106 deletions(-) diff --git a/shared/queue.py b/shared/queue.py index 62d80f5..f8249ba 100644 --- a/shared/queue.py +++ b/shared/queue.py @@ -1,26 +1,63 @@ from .logger import Log import os -from typing import List, Dict, Set, Optional +from typing import List, Dict, Set import asyncio import fnmatch + class Queue: + """Queue system for managing and playing broadcast files in sequence. + + Supports both local (single client) and server (multi-client) modes. + """ + def __init__(self, server_instance=None, client_instance=None, is_local=False, upload_dir="/opt/BotWave/uploads"): + """Initialize the queue system. + + Args: + server_instance: BotWaveServer instance (for server mode) + client_instance: BotWaveCLI instance (for local mode) + is_local: True for local client mode, False for server mode + upload_dir: Directory containing broadcast files + """ + # Queue data self.queue = [] + self.paused = True + self.current_index = 0 # For local mode + self.client_indices = {} # {client_id: current_index} for server mode + + # Instances self.server = server_instance self.client = client_instance self.is_local = is_local self.upload_dir = upload_dir - self.paused = True - self.current_index = 0 - self.client_indices = {} # {client_id: current_index} - self.active_targets = "all" # remember which targets we're playing on - + + # Playback settings + self.active_targets = "all" + self.broadcast_settings = { + 'frequency': 90.0, + 'ps': 'BotWave', + 'rt': 'Broadcasting', + 'pi': 'FFFF' + } + + # COMMAND PARSER + def parse(self, command: str): + """Parse and execute queue commands. + + Commands: + + : Add files to queue + - : Remove files from queue + * : Show queue + ? : Show help + ! : Toggle play/pause + """ if not command: self.show("") + Log.queue("Use 'queue ?' for help.") return - + first = command[0] if first == "+": @@ -34,15 +71,24 @@ def parse(self, command: str): elif first == "!": action = self.toggle else: - Log.error(f"Invalid Actions: {first}.") - Log.queue(f"Use 'queue ?' for help.") + Log.error(f"Invalid action: {first}") + Log.queue("Use 'queue ?' for help") return command = command[1:].strip() action(command) - + + # ADD FILES TO QUEUE + def add(self, command: str): - """Add files to queue. Supports: file, file1,file2, pattern_*, or *""" + """Add files to queue. + + Supports: + - Single file: file.wav + - Multiple files: file1.wav,file2.wav + - Wildcard patterns: pattern_*.wav or * + - Force add: file.wav! (skip availability checks in server mode) + """ force = command.endswith("!") if force: command = command[:-1].strip() @@ -57,10 +103,11 @@ def add(self, command: str): self._add_local(file_specs, force) else: asyncio.create_task(self._add_server(file_specs, force)) - + def _add_local(self, file_specs: List[str], force: bool): - """Add files locally""" + """Add files in local mode.""" added = [] + for spec in file_specs: if '*' in spec: files = self._match_files_local(spec, self.upload_dir) @@ -73,21 +120,20 @@ def _add_local(self, file_specs: List[str], force: bool): self.queue.extend(added) Log.queue(f"Added {len(added)} file(s) to queue") - self.show("") - + async def _add_server(self, file_specs: List[str], force: bool): - """Add files on server & checks all clients have them (unless forced)""" + """Add files in server mode with client availability checks.""" if not self.server or not self.server.clients: Log.error("No clients connected") return - # if forcing, just add the specs directly without checking clients + # Force mode: add without checking all clients if force: added = [] for spec in file_specs: if '*' in spec: - # Get from first client + # Get files from first available client client_ids = list(self.server.clients.keys()) if client_ids: client_files = await self._get_all_client_files([client_ids[0]]) @@ -106,8 +152,8 @@ async def _add_server(self, file_specs: List[str], force: bool): self.show("") return + # Normal mode: check all clients have the files client_ids = list(self.server.clients.keys()) - client_files = await self._get_all_client_files(client_ids) if not client_files: @@ -117,26 +163,28 @@ async def _add_server(self, file_specs: List[str], force: bool): candidates, missing_per_client = self._resolve_file_specs(file_specs, client_files) if not candidates: - Log.error("No matching files found on all clients.") + Log.error("No matching files found on all clients") Log.queue("Use '!' at the end to force add anyway (e.g., 'queue +file!')") return - # check if any files are missing on some clients + # Check for missing files if missing_per_client: Log.error("Some files are not present on all clients:") for client_id, missing_files in missing_per_client.items(): if missing_files: client_name = self.server.clients[client_id].get_display_name() - Log.error(f" {client_name}: missing {', '.join(list(missing_files)[:3])}{'...' if len(missing_files) > 3 else ''}") + missing_list = ', '.join(list(missing_files)[:3]) + suffix = '...' if len(missing_files) > 3 else '' + Log.error(f" {client_name}: missing {missing_list}{suffix}") Log.queue("Use '!' at the end to force add anyway (e.g., 'queue +file!')") return self.queue.extend(candidates) Log.queue(f"Added {len(candidates)} file(s) to queue") self.show("") - + async def _get_all_client_files(self, client_ids: List[str]) -> Dict[str, Set[str]]: - """Get file lists from all clients""" + """Retrieve file lists from all specified clients.""" client_files = {} for client_id in client_ids: @@ -152,10 +200,12 @@ async def _get_all_client_files(self, client_ids: List[str]) -> Dict[str, Set[st client_files[client_id] = set() return client_files - + def _resolve_file_specs(self, file_specs: List[str], client_files: Dict[str, Set[str]]) -> tuple[List[str], Dict[str, Set[str]]]: - """Resolve file specs to actual files that exist on ALL clients - Returns: (common_files, missing_per_client) + """Resolve file specs to actual files that exist on ALL clients. + + Returns: + (common_files, missing_per_client) """ if not client_files: return [], {} @@ -165,7 +215,7 @@ def _resolve_file_specs(self, file_specs: List[str], client_files: Dict[str, Set if not non_empty_client_files: return [], {} - # find intersection of all client files (only non-empty ones) + # Find intersection of all client files common_files = set.intersection(*non_empty_client_files) matched = set() @@ -175,15 +225,15 @@ def _resolve_file_specs(self, file_specs: List[str], client_files: Dict[str, Set if spec == '*': # All common files matched.update(common_files) - # For *, we consider all files from any client as "requested" + # For *, consider all files from any client as "requested" for files in client_files.values(): requested_files.update(files) elif '*' in spec: - # Wildcard pattern - check against common files + # Wildcard pattern pattern_matches = [f for f in common_files if fnmatch.fnmatch(f, spec)] matched.update(pattern_matches) - # Also find all files that match the pattern on any client + # Find all files matching pattern on any client for files in client_files.values(): requested_files.update([f for f in files if fnmatch.fnmatch(f, spec)]) @@ -195,7 +245,7 @@ def _resolve_file_specs(self, file_specs: List[str], client_files: Dict[str, Set if spec in common_files: matched.add(spec) - # Calculate which files are missing on which clients + # Calculate missing files per client missing_per_client = {} if requested_files: for client_id, files in client_files.items(): @@ -204,9 +254,9 @@ def _resolve_file_specs(self, file_specs: List[str], client_files: Dict[str, Set missing_per_client[client_id] = missing return sorted(list(matched)), missing_per_client - + def _match_files_local(self, pattern: str, directory: str) -> List[str]: - """Match files using wildcard pattern""" + """Match files using wildcard pattern in local directory.""" try: all_files = [f for f in os.listdir(directory) if f.endswith('.wav')] if pattern == '*': @@ -215,16 +265,25 @@ def _match_files_local(self, pattern: str, directory: str) -> List[str]: except Exception as e: Log.error(f"Error matching files: {e}") return [] - + + # REMOVE FILES FROM QUEUE + def remove(self, command: str): - """Remove files from queue. Same syntax as add""" + """Remove files from queue. + + Supports same syntax as add: + - Single file: file.wav + - Multiple files: file1.wav,file2.wav + - Wildcard patterns: pattern_*.wav + - Clear all: * + """ if not command: Log.error("No file specified") return file_specs = [f.strip() for f in command.split(',')] - removed_count = 0 + for spec in file_specs: if spec == '*': # Remove all @@ -244,9 +303,11 @@ def remove(self, command: str): Log.queue(f"Removed {removed_count} file(s) from queue") self.show("") - + + # SHOW QUEUE + def show(self, command: str = ""): - """Display current queue""" + """Display current queue status.""" if not self.queue: Log.queue("Queue is empty") return @@ -254,12 +315,13 @@ def show(self, command: str = ""): status = "PAUSED" if self.paused else "PLAYING" if self.is_local: + # Local mode: show simple list with current position Log.queue(f"Queue ({len(self.queue)} files) - {status}:") for i, filename in enumerate(self.queue, 1): marker = "> " if i == self.current_index + 1 else " " Log.print(f"{marker}{i}. {filename}", 'cyan') else: - # Show per-client progress + # Server mode: show per-client progress Log.queue(f"Queue ({len(self.queue)} files) - {status}:") if self.client_indices: @@ -273,31 +335,130 @@ def show(self, command: str = ""): Log.print("\nQueue:", 'yellow') for i, filename in enumerate(self.queue, 1): Log.print(f" {i}. {filename}", 'white') - + + # HELP + def help(self, command: str): - """Show help""" + """Display queue command help.""" Log.queue("Queue Commands:") - Log.print(" queue +file - Add file to queue", 'white') - Log.print(" queue +file1,file2 - Add multiple files", 'white') - Log.print(" queue +pattern_* - Add files matching pattern", 'white') - Log.print(" queue +* - Add all files", 'white') - Log.print(" queue +file! - Force add (even if not on all clients)", 'white') - Log.print(" queue -file - Remove file from queue", 'white') - Log.print(" queue -* - Clear queue", 'white') - Log.print(" queue * - Show queue", 'white') - Log.print(" queue ! - Toggle play/pause (local or all)", 'white') - Log.print(" queue !targets - Toggle play/pause on specific targets", 'white') + Log.print(" queue +file - Add file to queue", 'white') + Log.print(" queue +file1,file2 - Add multiple files", 'white') + Log.print(" queue +pattern_* - Add files matching pattern", 'white') + Log.print(" queue +* - Add all files", 'white') + Log.print(" queue +file! - Force add (skip availability checks)", 'white') + Log.print(" queue -file - Remove file from queue", 'white') + Log.print(" queue -* - Clear queue", 'white') + Log.print(" queue * - Show queue", 'white') + Log.print(" queue ! - Toggle play/pause with defaults", 'white') + if not self.is_local: + Log.print(" queue !targets - Toggle on specific targets", 'white') + Log.print(" queue !targets,freq,ps,rt,pi - Toggle with custom settings", 'white') + Log.print(' Example: queue !all,100.5,"My Radio","Live",ABCD', 'white') + else: + Log.print(" queue !freq,ps,rt,pi - Toggle with custom settings", 'white') + Log.print(' Example: queue !100.5,"My Radio","Live",ABCD', 'white') + + # TOGGLE PLAY/PAUSE + def toggle(self, command: str): - """Toggle between play/pause - queue !""" + """Toggle between play and pause states. + + Supports custom broadcast parameters: + Server: queue !targets,freq,ps,rt,pi + Local: queue !freq,ps,rt,pi + + Examples: + queue ! # Defaults + queue !all,100.5 # Custom frequency + queue !all,90.0,"My Radio","Live",ABCD # Full custom settings + """ + args = self._parse_toggle_args(command) + if self.is_local: - self._toggle_local() + self._toggle_local(args) else: - targets = command.strip() if command.strip() else "all" - asyncio.create_task(self._toggle_server(targets)) - - def _toggle_local(self): - """Toggle queue playback locally""" + asyncio.create_task(self._toggle_server(args)) + + def _parse_toggle_args(self, command: str) -> dict: + """Parse toggle command arguments with support for quoted strings. + + Server format: targets,freq,ps,rt,pi + Local format: freq,ps,rt,pi + + Returns dict with: targets, frequency, ps, rt, pi + """ + defaults = { + 'targets': 'all', + 'frequency': 90.0, + 'ps': 'BotWave', + 'rt': 'Broadcasting', + 'pi': 'FFFF' + } + + if not command.strip(): + return defaults + + try: + # Parse comma-separated values respecting quoted strings + parts = [] + current = [] + in_quotes = False + quote_char = None + + for char in command: + if char in ('"', "'") and not in_quotes: + in_quotes = True + quote_char = char + elif char == quote_char and in_quotes: + in_quotes = False + quote_char = None + elif char == ',' and not in_quotes: + parts.append(''.join(current).strip()) + current = [] + continue + + current.append(char) + + # Add the last part + if current: + parts.append(''.join(current).strip()) + + # Remove quotes from parts + parts = [p.strip('"').strip("'") for p in parts] + + # Parse based on mode + if not self.is_local: + # Server mode: targets,freq,ps,rt,pi + if len(parts) > 0 and parts[0]: + defaults['targets'] = parts[0] + if len(parts) > 1 and parts[1]: + defaults['frequency'] = float(parts[1]) + if len(parts) > 2 and parts[2]: + defaults['ps'] = parts[2] + if len(parts) > 3 and parts[3]: + defaults['rt'] = parts[3] + if len(parts) > 4 and parts[4]: + defaults['pi'] = parts[4] + else: + # Local mode: freq,ps,rt,pi + if len(parts) > 0 and parts[0]: + defaults['frequency'] = float(parts[0]) + if len(parts) > 1 and parts[1]: + defaults['ps'] = parts[1] + if len(parts) > 2 and parts[2]: + defaults['rt'] = parts[2] + if len(parts) > 3 and parts[3]: + defaults['pi'] = parts[3] + + return defaults + + except Exception as e: + Log.error(f"Error parsing toggle args: {e}") + return defaults + + def _toggle_local(self, args: dict): + """Toggle queue playback in local mode.""" if not self.queue: Log.error("Queue is empty") return @@ -307,11 +468,42 @@ def _toggle_local(self): Log.queue(f"Queue {status}") if not self.paused: - # Start playing current file + self.broadcast_settings = args self._play_current_local() - + + async def _toggle_server(self, args: dict): + """Toggle queue playback in server mode.""" + if not self.queue: + Log.error("Queue is empty") + return + + if not self.server: + Log.error("No server instance") + return + + self.paused = not self.paused + status = "paused" if self.paused else "playing" + self.active_targets = args['targets'] + self.broadcast_settings = args + + Log.queue(f"Queue {status} on {args['targets']}") + + if not self.paused: + # Initialize client indices for targets + target_clients = self.server._parse_client_targets(args['targets']) + for client_id in target_clients: + if client_id not in self.client_indices: + self.client_indices[client_id] = 0 + + await self._play_all_clients(target_clients) + else: + # Stop broadcast on targets + await self.server.stop_broadcast(args['targets']) + + # PLAYBACK CONTROL + def _play_current_local(self): - """Play current file in queue (local client only)""" + """Play current file in local mode.""" if self.current_index >= len(self.queue): Log.queue("End of queue reached") self.paused = True @@ -325,45 +517,25 @@ def _play_current_local(self): filename = self.queue[self.current_index] file_path = os.path.join(self.upload_dir, filename) - Log.queue(f"Playing [{self.current_index + 1}/{len(self.queue)}]: {filename}") - - if os.path.exists(file_path): - # Call client's start_broadcast - self.client.start_broadcast(file_path, frequency=90.0, loop=False) - else: + if not os.path.exists(file_path): Log.error(f"File not found: {filename}") self._next_local() - - async def _toggle_server(self, targets: str): - """Toggle queue playback on server""" - if not self.queue: - Log.error("Queue is empty") - return - - if not self.server: - Log.error("No server instance") return - self.paused = not self.paused - status = "paused" if self.paused else "playing" - self.active_targets = targets - Log.queue(f"Queue {status} on {targets}") + Log.queue(f"Playing [{self.current_index + 1}/{len(self.queue)}]: {filename}") - if not self.paused: - # Initialize client indices for the targets - target_clients = self.server._parse_client_targets(targets) - for client_id in target_clients: - if client_id not in self.client_indices: - self.client_indices[client_id] = 0 - - # Start playing from each client's current position - await self._play_all_clients(target_clients) - else: - # Stop current broadcast on targets - await self.server.stop_broadcast(targets) - + # Use stored broadcast settings + self.client.start_broadcast( + file_path, + frequency=self.broadcast_settings['frequency'], + ps=self.broadcast_settings['ps'], + rt=self.broadcast_settings['rt'], + pi=self.broadcast_settings['pi'], + loop=False + ) + async def _play_all_clients(self, target_clients: List[str]): - """Start playback for all target clients at their individual positions""" + """Start playback for all target clients at their individual positions.""" for client_id in target_clients: index = self.client_indices.get(client_id, 0) @@ -376,11 +548,25 @@ async def _play_all_clients(self, target_clients: List[str]): Log.queue(f"{client_name}: Playing [{index + 1}/{len(self.queue)}] {filename}") - # Start broadcast on this specific client - await self.server.start_broadcast(client_id, filename, frequency=90.0, loop=False) - + # Use stored broadcast settings + await self.server.start_broadcast( + client_id, + filename, + frequency=self.broadcast_settings['frequency'], + ps=self.broadcast_settings['ps'], + rt=self.broadcast_settings['rt'], + pi=self.broadcast_settings['pi'], + loop=False + ) + + # AUTO-ADVANCE (NEXT TRACK) + def on_broadcast_ended(self, client_id: str = None): - """Called when a broadcast ends - advance to next in queue""" + """Called when a broadcast ends - advance to next in queue. + + Args: + client_id: Client that finished (server mode only) + """ if self.paused: return @@ -388,9 +574,9 @@ def on_broadcast_ended(self, client_id: str = None): self._next_local() else: asyncio.create_task(self._next_server(client_id)) - + def _next_local(self): - """Move to next file in queue (local)""" + """Advance to next file in local mode.""" self.current_index += 1 if self.current_index >= len(self.queue): @@ -400,9 +586,9 @@ def _next_local(self): return self._play_current_local() - + async def _next_server(self, client_id: str): - """Move to next file in queue for specific client (server)""" + """Advance to next file for specific client in server mode.""" if not client_id or client_id not in self.client_indices: Log.warning(f"Client {client_id} not in queue tracking") return @@ -416,11 +602,19 @@ async def _next_server(self, client_id: str): Log.queue(f"{client_name}: Queue finished") return - # Play next file for this specific client + # Play next file for this client filename = self.queue[client_index] client_name = self.server.clients[client_id].get_display_name() Log.queue(f"{client_name}: Next [{client_index + 1}/{len(self.queue)}] {filename}") - # Start broadcast on this specific client only - await self.server.start_broadcast(client_id, filename, frequency=90.0, loop=False) \ No newline at end of file + # Use stored broadcast settings + await self.server.start_broadcast( + client_id, + filename, + frequency=self.broadcast_settings['frequency'], + ps=self.broadcast_settings['ps'], + rt=self.broadcast_settings['rt'], + pi=self.broadcast_settings['pi'], + loop=False + ) \ No newline at end of file From 6cf258e9a08e389f811e2a3f74d8cfadf1ee63ae Mon Sep 17 00:00:00 2001 From: douxxtech Date: Sat, 24 Jan 2026 21:36:23 +0100 Subject: [PATCH 05/23] local: implemented the queue system --- local/local.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/local/local.py b/local/local.py index 858ed2b..cd7acde 100644 --- a/local/local.py +++ b/local/local.py @@ -29,6 +29,7 @@ from shared.logger import Log, toggle_input from shared.morser import text_to_morse from shared.pw_monitor import PWM +from shared.queue import Queue from shared.sstv import make_sstv_wav from shared.syscheck import check_requirements from shared.ws_cmd import WSCMDH @@ -59,6 +60,7 @@ def __init__(self, upload_dir: str = "/opt/BotWave/uploads", handlers_dir: str = self.handlers_executor = HandlerExecutor(handlers_dir, self._execute_command) self.piwave_monitor = PWM() self.alsa = Alsa() + self.queue = Queue(client_instance=self, is_local=True, upload_dir=upload_dir) self.ws_port = ws_port self.ws_server = None self.ws_clients = set() @@ -135,6 +137,10 @@ def _execute_command(self, command: str): self.onstop_handlers() return True + elif cmd == 'queue': + self.queue.parse(' '.join(cmd_parts[1:])) + return True + elif cmd == 'sstv': if len(cmd_parts) < 2: Log.error("Usage: sstv [mode] [output_wav] [frequency] [loop] [ps] [rt] [pi]") @@ -400,6 +406,7 @@ def finished(): Log.info("Playback finished, stopping broadcast...") self.stop_broadcast() self.onstop_handlers() + self.queue.on_broadcast_ended() if not os.path.exists(file_path): Log.error(f"File {file_path} not found") From 3498eabfe4b4d5db679c6c5a80a7bd030db80510 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Sat, 24 Jan 2026 21:56:52 +0100 Subject: [PATCH 06/23] server,local: update doc to include queue --- local/local.md | 3 +++ server/server.md | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/local/local.md b/local/local.md index babb904..f8dde5c 100644 --- a/local/local.md +++ b/local/local.md @@ -64,6 +64,9 @@ Once the client is running, you can use the following commands: - `live`: Start a live broadcast. - Usage: `botwave> live [frequency] [loop] [ps] [rt] [pi]` + +- `queue`: Manages the queue. See the [`Main/Queue system`](https://github.com/dpipstudio/botwave/wiki/Queue-system) wiki page for more details. + - Usage: `botwave> queue ?` - `sstv`: Start broadcasting an image converted to SSTV. For modes see [dnet/pySSTV](https://github.com/dnet/pySSTV/). - Usage: `botwave> sstv [mode] [output wav name] [freq] [loop] [ps] [rt] [pi]` diff --git a/server/server.md b/server/server.md index 7faa279..ae43519 100644 --- a/server/server.md +++ b/server/server.md @@ -54,9 +54,12 @@ targets: Specifies the target clients. Can be 'all', a client ID, a hostname, or `stop`: Stops broadcasting on specified client(s). - Usage: `botwave> stop ` -- `live`: Start a live broadcast to client(s). +`live`: Start a live broadcast to client(s). - Usage: `botwave> live [frequency] [loop] [ps] [rt] [pi]` +`queue`: Manages the queue. See the [`Main/Queue system`](https://github.com/dpipstudio/botwave/wiki/Queue-system) wiki page for more details. + - Usage: `botwave> queue ?` + `sstv`: Start broadcasting an image converted to SSTV. For modes see [dnet/pySSTV](https://github.com/dnet/pySSTV/). - Usage: `botwave> sstv [mode] [output wav name] [freq] [loop] [ps] [rt] [pi]` From 3485f6c594ef58784d4a62ed6a38b8c8019394b6 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Sat, 24 Jan 2026 21:59:21 +0100 Subject: [PATCH 07/23] scripts: temporarly changed install script to target queue branch --- scripts/install.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 4a0bab8..0408e42 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -347,7 +347,7 @@ prompt_alsa_setup() { resolve_target_commit() { if [[ "$USE_LATEST" == true ]]; then log INFO "Fetching latest commit..." - local latest_commit=$(curl -sSL https://api.github.com/repos/dpipstudio/botwave/commits | \ + local latest_commit=$(curl -sSL https://api.github.com/repos/dpipstudio/botwave/commits?sha=queue | \ grep '"sha":' | \ head -n 1 | \ cut -d '"' -f 4) @@ -364,7 +364,7 @@ resolve_target_commit() { if [[ -n "$TARGET_VERSION" ]]; then log INFO "Looking up release: $TARGET_VERSION" - local install_json=$(curl -sSL "${GITHUB_RAW_URL}/main/assets/installation.json?t=$(date +%s)") + local install_json=$(curl -sSL "${GITHUB_RAW_URL}/queue/assets/installation.json?t=$(date +%s)") local commit=$(echo "$install_json" | jq -r ".releases[] | select(.codename==\"$TARGET_VERSION\") | .commit") if [[ -z "$commit" ]]; then @@ -383,7 +383,7 @@ resolve_target_commit() { # Default: latest release log INFO "Fetching latest release..." - local install_json=$(curl -sSL "${GITHUB_RAW_URL}/main/assets/installation.json?t=$(date +%s)") + local install_json=$(curl -sSL "${GITHUB_RAW_URL}/queue/assets/installation.json?t=$(date +%s)") local latest_release_commit=$(echo "$install_json" | jq -r '.releases[0].commit') if [[ -z "$latest_release_commit" ]]; then @@ -679,7 +679,7 @@ save_version_info() { if [[ -n "$TARGET_VERSION" ]]; then echo "$TARGET_VERSION" > "$INSTALL_DIR/last_release" elif [[ "$USE_LATEST" != true ]]; then - local install_json=$(curl -sSL "${GITHUB_RAW_URL}/main/assets/installation.json?t=$(date +%s)") + local install_json=$(curl -sSL "${GITHUB_RAW_URL}/queue/assets/installation.json?t=$(date +%s)") local codename=$(echo "$install_json" | jq -r ".releases[] | select(.commit==\"$commit\") | .codename") if [[ -n "$codename" ]]; then echo "$codename" > "$INSTALL_DIR/last_release" From f22b6d00522dd50a79ee7f125183a635c1073902 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Sat, 24 Jan 2026 22:14:28 +0100 Subject: [PATCH 08/23] queue,local,server: pausing queue on manual actions, like start, stop, etc. --- local/local.py | 4 ++++ server/server.py | 9 +++++++-- shared/queue.py | 13 +++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/local/local.py b/local/local.py index cd7acde..9339d2c 100644 --- a/local/local.py +++ b/local/local.py @@ -412,6 +412,8 @@ def finished(): Log.error(f"File {file_path} not found") return False + self.queue.manual_pause() + if self.broadcasting: self.stop_broadcast() @@ -454,6 +456,8 @@ def finished(): Log.alsa("Did you setup the ALSA loopback card correctly ?") return False + self.queue.manual_pause() + if self.broadcasting: self.stop_broadcast() diff --git a/server/server.py b/server/server.py index 097da0a..add40c1 100644 --- a/server/server.py +++ b/server/server.py @@ -538,7 +538,7 @@ async def _execute_command_async(self, command_name: str, cmd: list): if len(cmd) < 3: Log.error("Usage: start [freq] [loop] [ps] [rt] [pi]") return - + frequency = float(cmd[3]) if len(cmd) > 3 else 90.0 loop = cmd[4].lower() == 'true' if len(cmd) > 4 else False ps = cmd[5] if len(cmd) > 5 else "BotWave" @@ -552,7 +552,7 @@ async def _execute_command_async(self, command_name: str, cmd: list): if len(cmd) < 2: Log.error("Usage: live [freq] [ps] [rt] [pi]") return - + frequency = float(cmd[2]) if len(cmd) > 2 else 90.0 ps = cmd[3] if len(cmd) > 3 else "BotWave" rt = cmd[4] if len(cmd) > 4 else "Broadcasting" @@ -565,6 +565,7 @@ async def _execute_command_async(self, command_name: str, cmd: list): if len(cmd) < 2: Log.error("Usage: stop ") return + await self.stop_broadcast(cmd[1]) return @@ -839,6 +840,8 @@ async def start_live(self, client_targets: str, frequency: float = 90.0, ps: str Log.alsa("Did you setup the ALSA loopback card correctly ?") return False + self.queue.manual_pause() + self.alsa.start() Log.broadcast(f"Sending stream tokens to {len(target_clients)} client(s)...") @@ -1256,6 +1259,8 @@ async def start_broadcast(self, client_targets: str, filename: str, frequency: f Log.warning("No client(s) found matching the query") return False + self.queue.manual_pause() + # calculate start_at timestamp if wait_start is enabled if self.wait_start and len(target_clients) > 1: start_at = datetime.now(timezone.utc).timestamp() + 20 * (len(target_clients) - 1) diff --git a/shared/queue.py b/shared/queue.py index f8249ba..19a5225 100644 --- a/shared/queue.py +++ b/shared/queue.py @@ -77,6 +77,19 @@ def parse(self, command: str): command = command[1:].strip() action(command) + + + # MANUAL QUEUE PAUSE + + def manual_pause(self): + """ + Pauses the queue if it is playing. + To be used on manual 'start', 'live', etc. commands. + """ + + if not self.paused: + Log.queue("Auto-pausing queue due to manual action") + self.paused = True # ADD FILES TO QUEUE From 8a0d8ffbd804a7c706a422b37629c030d6565af4 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Sat, 24 Jan 2026 23:08:39 +0100 Subject: [PATCH 09/23] installation: added queue.py to the always component --- assets/installation.json | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/installation.json b/assets/installation.json index 5cca512..1286497 100644 --- a/assets/installation.json +++ b/assets/installation.json @@ -67,6 +67,7 @@ "shared/logger.py", "shared/morser.py", "shared/protocol.py", + "shared/queue.py", "shared/socket.py", "shared/sstv.py", "shared/syscheck.py", From cc975a1190ee49b27fe9392ecc6b322238ead3be Mon Sep 17 00:00:00 2001 From: douxxtech Date: Sat, 24 Jan 2026 23:59:17 +0100 Subject: [PATCH 10/23] queue,local,server: fixed issue where the queue system self-triggers the manual pause --- local/local.py | 10 ++++++---- server/server.py | 10 ++++++---- shared/queue.py | 9 ++++++--- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/local/local.py b/local/local.py index 9339d2c..b355259 100644 --- a/local/local.py +++ b/local/local.py @@ -401,7 +401,7 @@ def _download_reporthook(block_num, block_size, total_size): Log.error(f"Download error: {str(e)}") return False - def start_broadcast(self, file_path: str, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF", loop: bool = False): + def start_broadcast(self, file_path: str, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF", loop: bool = False, trigger_manual: bool = True): def finished(): Log.info("Playback finished, stopping broadcast...") self.stop_broadcast() @@ -412,7 +412,8 @@ def finished(): Log.error(f"File {file_path} not found") return False - self.queue.manual_pause() + if trigger_manual: + self.queue.manual_pause() if self.broadcasting: self.stop_broadcast() @@ -445,7 +446,7 @@ def finished(): self.piwave = None return False - def start_live(self, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF"): + def start_live(self, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF", trigger_manual: bool = False): def finished(): Log.info("Playback finished, stopping broadcast...") self.stop_broadcast() @@ -456,7 +457,8 @@ def finished(): Log.alsa("Did you setup the ALSA loopback card correctly ?") return False - self.queue.manual_pause() + if trigger_manual: + self.queue.manual_pause() if self.broadcasting: self.stop_broadcast() diff --git a/server/server.py b/server/server.py index add40c1..f587427 100644 --- a/server/server.py +++ b/server/server.py @@ -828,7 +828,7 @@ async def _upload_folder_contents(self, client_targets: str, folder_path: str): return overall_success > 0 - async def start_live(self, client_targets: str, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF"): + async def start_live(self, client_targets: str, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF", trigger_manual: bool = True): target_clients = self._parse_client_targets(client_targets) if not target_clients: @@ -840,7 +840,8 @@ async def start_live(self, client_targets: str, frequency: float = 90.0, ps: str Log.alsa("Did you setup the ALSA loopback card correctly ?") return False - self.queue.manual_pause() + if trigger_manual: + self.queue.manual_pause() self.alsa.start() @@ -1252,14 +1253,15 @@ def _remove_temp_dir(self, directory: str): except Exception as e: Log.warning(f"Failed to remove temp directory {directory}: {e}") - async def start_broadcast(self, client_targets: str, filename: str, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF", loop: bool = False): + async def start_broadcast(self, client_targets: str, filename: str, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF", loop: bool = False, trigger_manual:bool = True): target_clients = self._parse_client_targets(client_targets) if not target_clients: Log.warning("No client(s) found matching the query") return False - self.queue.manual_pause() + if trigger_manual: + self.queue.manual_pause() # calculate start_at timestamp if wait_start is enabled if self.wait_start and len(target_clients) > 1: diff --git a/shared/queue.py b/shared/queue.py index 19a5225..aa09d2c 100644 --- a/shared/queue.py +++ b/shared/queue.py @@ -544,7 +544,8 @@ def _play_current_local(self): ps=self.broadcast_settings['ps'], rt=self.broadcast_settings['rt'], pi=self.broadcast_settings['pi'], - loop=False + loop=False, + trigger_manual=False ) async def _play_all_clients(self, target_clients: List[str]): @@ -569,7 +570,8 @@ async def _play_all_clients(self, target_clients: List[str]): ps=self.broadcast_settings['ps'], rt=self.broadcast_settings['rt'], pi=self.broadcast_settings['pi'], - loop=False + loop=False, + trigger_manual=False ) # AUTO-ADVANCE (NEXT TRACK) @@ -629,5 +631,6 @@ async def _next_server(self, client_id: str): ps=self.broadcast_settings['ps'], rt=self.broadcast_settings['rt'], pi=self.broadcast_settings['pi'], - loop=False + loop=False, + trigger_manual=False ) \ No newline at end of file From 1310ba420959f39e34064ad61b887a120a26d1d7 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Sun, 25 Jan 2026 00:14:53 +0100 Subject: [PATCH 11/23] local,server: more manual toggles --- local/local.py | 7 ++++--- server/server.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/local/local.py b/local/local.py index b355259..80c6de1 100644 --- a/local/local.py +++ b/local/local.py @@ -135,6 +135,8 @@ def _execute_command(self, command: str): elif cmd == 'stop': self.stop_broadcast() self.onstop_handlers() + + self.queue.manual_pause() return True elif cmd == 'queue': @@ -446,7 +448,7 @@ def finished(): self.piwave = None return False - def start_live(self, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF", trigger_manual: bool = False): + def start_live(self, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF"): def finished(): Log.info("Playback finished, stopping broadcast...") self.stop_broadcast() @@ -457,8 +459,7 @@ def finished(): Log.alsa("Did you setup the ALSA loopback card correctly ?") return False - if trigger_manual: - self.queue.manual_pause() + self.queue.manual_pause() if self.broadcasting: self.stop_broadcast() diff --git a/server/server.py b/server/server.py index f587427..f97ac32 100644 --- a/server/server.py +++ b/server/server.py @@ -566,6 +566,8 @@ async def _execute_command_async(self, command_name: str, cmd: list): Log.error("Usage: stop ") return + self.queue.toggle() + await self.stop_broadcast(cmd[1]) return @@ -828,7 +830,7 @@ async def _upload_folder_contents(self, client_targets: str, folder_path: str): return overall_success > 0 - async def start_live(self, client_targets: str, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF", trigger_manual: bool = True): + async def start_live(self, client_targets: str, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF"): target_clients = self._parse_client_targets(client_targets) if not target_clients: @@ -840,8 +842,7 @@ async def start_live(self, client_targets: str, frequency: float = 90.0, ps: str Log.alsa("Did you setup the ALSA loopback card correctly ?") return False - if trigger_manual: - self.queue.manual_pause() + self.queue.manual_pause() self.alsa.start() From 9cae0bc68179420855625f7d76fc5ef23229dd25 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Mon, 26 Jan 2026 18:37:02 +0100 Subject: [PATCH 12/23] queue: added loop to the toggle ocmmand --- shared/queue.py | 87 ++++++++++++++++++++++++++++--------------------- 1 file changed, 49 insertions(+), 38 deletions(-) diff --git a/shared/queue.py b/shared/queue.py index aa09d2c..02752b5 100644 --- a/shared/queue.py +++ b/shared/queue.py @@ -36,6 +36,7 @@ def __init__(self, server_instance=None, client_instance=None, is_local=False, u self.active_targets = "all" self.broadcast_settings = { 'frequency': 90.0, + 'loop': False, 'ps': 'BotWave', 'rt': 'Broadcasting', 'pi': 'FFFF' @@ -366,11 +367,11 @@ def help(self, command: str): if not self.is_local: Log.print(" queue !targets - Toggle on specific targets", 'white') - Log.print(" queue !targets,freq,ps,rt,pi - Toggle with custom settings", 'white') - Log.print(' Example: queue !all,100.5,"My Radio","Live",ABCD', 'white') + Log.print(" queue !targets,freq,loop,ps,rt,pi - Toggle with custom settings", 'white') + Log.print(' Example: queue !all,100.5,false,"My Radio","Live",ABCD', 'white') else: - Log.print(" queue !freq,ps,rt,pi - Toggle with custom settings", 'white') - Log.print(' Example: queue !100.5,"My Radio","Live",ABCD', 'white') + Log.print(" queue !freq,loop,ps,rt,pi - Toggle with custom settings", 'white') + Log.print(' Example: queue !100.5,false"My Radio","Live",ABCD', 'white') # TOGGLE PLAY/PAUSE @@ -382,9 +383,9 @@ def toggle(self, command: str): Local: queue !freq,ps,rt,pi Examples: - queue ! # Defaults - queue !all,100.5 # Custom frequency - queue !all,90.0,"My Radio","Live",ABCD # Full custom settings + queue ! # Defaults + queue !all,100.5 # Custom frequency + queue !all,90.0,false,"My Radio","Live",ABCD # Full custom settings """ args = self._parse_toggle_args(command) @@ -396,29 +397,30 @@ def toggle(self, command: str): def _parse_toggle_args(self, command: str) -> dict: """Parse toggle command arguments with support for quoted strings. - Server format: targets,freq,ps,rt,pi - Local format: freq,ps,rt,pi - - Returns dict with: targets, frequency, ps, rt, pi + Server format: targets,freq,loop,ps,rt,pi + Local format: freq,loop,ps,rt,pi """ defaults = { 'targets': 'all', 'frequency': 90.0, + 'loop': False, 'ps': 'BotWave', 'rt': 'Broadcasting', 'pi': 'FFFF' } - + if not command.strip(): return defaults - + + def parse_bool(value: str) -> bool: + return value.lower() == 'true' + try: - # Parse comma-separated values respecting quoted strings parts = [] current = [] in_quotes = False quote_char = None - + for char in command: if char in ('"', "'") and not in_quotes: in_quotes = True @@ -430,45 +432,47 @@ def _parse_toggle_args(self, command: str) -> dict: parts.append(''.join(current).strip()) current = [] continue - + current.append(char) - - # Add the last part + if current: parts.append(''.join(current).strip()) - - # Remove quotes from parts + parts = [p.strip('"').strip("'") for p in parts] - - # Parse based on mode + if not self.is_local: - # Server mode: targets,freq,ps,rt,pi + # Server: targets,freq,loop,ps,rt,pi if len(parts) > 0 and parts[0]: defaults['targets'] = parts[0] if len(parts) > 1 and parts[1]: defaults['frequency'] = float(parts[1]) if len(parts) > 2 and parts[2]: - defaults['ps'] = parts[2] + defaults['loop'] = parse_bool(parts[2]) if len(parts) > 3 and parts[3]: - defaults['rt'] = parts[3] + defaults['ps'] = parts[3] if len(parts) > 4 and parts[4]: - defaults['pi'] = parts[4] + defaults['rt'] = parts[4] + if len(parts) > 5 and parts[5]: + defaults['pi'] = parts[5] else: - # Local mode: freq,ps,rt,pi + # Local: freq,loop,ps,rt,pi if len(parts) > 0 and parts[0]: defaults['frequency'] = float(parts[0]) if len(parts) > 1 and parts[1]: - defaults['ps'] = parts[1] + defaults['loop'] = parse_bool(parts[1]) if len(parts) > 2 and parts[2]: - defaults['rt'] = parts[2] + defaults['ps'] = parts[2] if len(parts) > 3 and parts[3]: - defaults['pi'] = parts[3] - + defaults['rt'] = parts[3] + if len(parts) > 4 and parts[4]: + defaults['pi'] = parts[4] + return defaults - + except Exception as e: Log.error(f"Error parsing toggle args: {e}") return defaults + def _toggle_local(self, args: dict): """Toggle queue playback in local mode.""" @@ -518,7 +522,7 @@ async def _toggle_server(self, args: dict): def _play_current_local(self): """Play current file in local mode.""" if self.current_index >= len(self.queue): - Log.queue("End of queue reached") + Log.queue(f"End of queue reached") self.paused = True self.current_index = 0 return @@ -595,10 +599,12 @@ def _next_local(self): self.current_index += 1 if self.current_index >= len(self.queue): - Log.queue("Queue finished") + startagain = ", starting over" if self.broadcast_settings['loop'] else "" + Log.queue(f"Queue finished{startagain}") self.current_index = 0 - self.paused = True - return + if not startagain: + self.paused = True + return self._play_current_local() @@ -614,8 +620,13 @@ async def _next_server(self, client_id: str): if client_index >= len(self.queue): client_name = self.server.clients[client_id].get_display_name() - Log.queue(f"{client_name}: Queue finished") - return + startagain = ", starting over" if self.broadcast_settings['loop'] else "" + Log.queue(f"{client_name}: Queue finished{startagain}") + self.client_indices[client_id] = 0 + client_index = 0 + + if not startagain: + return # Play next file for this client filename = self.queue[client_index] From a2005c120b0693889a2a4f415b20c84205e36f13 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Mon, 26 Jan 2026 18:41:12 +0100 Subject: [PATCH 13/23] queue: using shlex to parse the toggle args --- shared/queue.py | 27 +++------------------------ 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/shared/queue.py b/shared/queue.py index 02752b5..b18dcf3 100644 --- a/shared/queue.py +++ b/shared/queue.py @@ -3,6 +3,7 @@ from typing import List, Dict, Set import asyncio import fnmatch +import shlex class Queue: @@ -416,30 +417,8 @@ def parse_bool(value: str) -> bool: return value.lower() == 'true' try: - parts = [] - current = [] - in_quotes = False - quote_char = None - - for char in command: - if char in ('"', "'") and not in_quotes: - in_quotes = True - quote_char = char - elif char == quote_char and in_quotes: - in_quotes = False - quote_char = None - elif char == ',' and not in_quotes: - parts.append(''.join(current).strip()) - current = [] - continue - - current.append(char) - - if current: - parts.append(''.join(current).strip()) - - parts = [p.strip('"').strip("'") for p in parts] - + parts = shlex.split(command.replace(',', ' ')) + if not self.is_local: # Server: targets,freq,loop,ps,rt,pi if len(parts) > 0 and parts[0]: From 09f0c4f69ac9b24d4d4b91091af846ce0a235355 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Mon, 26 Jan 2026 18:52:06 +0100 Subject: [PATCH 14/23] queue: updated queue show logs to show if it is looping --- shared/queue.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shared/queue.py b/shared/queue.py index b18dcf3..ccb6efa 100644 --- a/shared/queue.py +++ b/shared/queue.py @@ -327,7 +327,8 @@ def show(self, command: str = ""): Log.queue("Queue is empty") return - status = "PAUSED" if self.paused else "PLAYING" + looping = " (LOOPING)" if self.broadcast_settings['loop'] else "" + status = "PAUSED" if self.paused else f"PLAYING{looping}" if self.is_local: # Local mode: show simple list with current position From 1076dfbfcda43de0cb7d34f8594338481daaae49 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Mon, 26 Jan 2026 22:26:44 +0100 Subject: [PATCH 15/23] queue: fixed help formatting --- shared/queue.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/shared/queue.py b/shared/queue.py index ccb6efa..3ef4f8f 100644 --- a/shared/queue.py +++ b/shared/queue.py @@ -357,22 +357,22 @@ def show(self, command: str = ""): def help(self, command: str): """Display queue command help.""" Log.queue("Queue Commands:") - Log.print(" queue +file - Add file to queue", 'white') - Log.print(" queue +file1,file2 - Add multiple files", 'white') - Log.print(" queue +pattern_* - Add files matching pattern", 'white') - Log.print(" queue +* - Add all files", 'white') - Log.print(" queue +file! - Force add (skip availability checks)", 'white') - Log.print(" queue -file - Remove file from queue", 'white') - Log.print(" queue -* - Clear queue", 'white') - Log.print(" queue * - Show queue", 'white') - Log.print(" queue ! - Toggle play/pause with defaults", 'white') + Log.print(" queue +file - Add file to queue", 'white') + Log.print(" queue +file1,file2 - Add multiple files", 'white') + Log.print(" queue +pattern_* - Add files matching pattern", 'white') + Log.print(" queue +* - Add all files", 'white') + Log.print(" queue +file! - Force add (skip availability checks)", 'white') + Log.print(" queue -file - Remove file from queue", 'white') + Log.print(" queue -* - Clear queue", 'white') + Log.print(" queue * - Show queue", 'white') + Log.print(" queue ! - Toggle play/pause with defaults", 'white') if not self.is_local: - Log.print(" queue !targets - Toggle on specific targets", 'white') + Log.print(" queue !targets - Toggle on specific targets", 'white') Log.print(" queue !targets,freq,loop,ps,rt,pi - Toggle with custom settings", 'white') Log.print(' Example: queue !all,100.5,false,"My Radio","Live",ABCD', 'white') else: - Log.print(" queue !freq,loop,ps,rt,pi - Toggle with custom settings", 'white') + Log.print(" queue !freq,loop,ps,rt,pi - Toggle with custom settings", 'white') Log.print(' Example: queue !100.5,false"My Radio","Live",ABCD', 'white') # TOGGLE PLAY/PAUSE From a98a398a0985e695a95c855db36a1217c30e121d Mon Sep 17 00:00:00 2001 From: douxxtech Date: Tue, 27 Jan 2026 15:03:37 +0100 Subject: [PATCH 16/23] scripts: Revert "scripts: temporarly changed install script to target queue branch" This reverts commit 3485f6c594ef58784d4a62ed6a38b8c8019394b6. --- scripts/install.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 0408e42..4a0bab8 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -347,7 +347,7 @@ prompt_alsa_setup() { resolve_target_commit() { if [[ "$USE_LATEST" == true ]]; then log INFO "Fetching latest commit..." - local latest_commit=$(curl -sSL https://api.github.com/repos/dpipstudio/botwave/commits?sha=queue | \ + local latest_commit=$(curl -sSL https://api.github.com/repos/dpipstudio/botwave/commits | \ grep '"sha":' | \ head -n 1 | \ cut -d '"' -f 4) @@ -364,7 +364,7 @@ resolve_target_commit() { if [[ -n "$TARGET_VERSION" ]]; then log INFO "Looking up release: $TARGET_VERSION" - local install_json=$(curl -sSL "${GITHUB_RAW_URL}/queue/assets/installation.json?t=$(date +%s)") + local install_json=$(curl -sSL "${GITHUB_RAW_URL}/main/assets/installation.json?t=$(date +%s)") local commit=$(echo "$install_json" | jq -r ".releases[] | select(.codename==\"$TARGET_VERSION\") | .commit") if [[ -z "$commit" ]]; then @@ -383,7 +383,7 @@ resolve_target_commit() { # Default: latest release log INFO "Fetching latest release..." - local install_json=$(curl -sSL "${GITHUB_RAW_URL}/queue/assets/installation.json?t=$(date +%s)") + local install_json=$(curl -sSL "${GITHUB_RAW_URL}/main/assets/installation.json?t=$(date +%s)") local latest_release_commit=$(echo "$install_json" | jq -r '.releases[0].commit') if [[ -z "$latest_release_commit" ]]; then @@ -679,7 +679,7 @@ save_version_info() { if [[ -n "$TARGET_VERSION" ]]; then echo "$TARGET_VERSION" > "$INSTALL_DIR/last_release" elif [[ "$USE_LATEST" != true ]]; then - local install_json=$(curl -sSL "${GITHUB_RAW_URL}/queue/assets/installation.json?t=$(date +%s)") + local install_json=$(curl -sSL "${GITHUB_RAW_URL}/main/assets/installation.json?t=$(date +%s)") local codename=$(echo "$install_json" | jq -r ".releases[] | select(.commit==\"$commit\") | .codename") if [[ -n "$codename" ]]; then echo "$codename" > "$INSTALL_DIR/last_release" From 2e4df90fb500d7e12973ec96a23700363ac3d7e2 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Tue, 27 Jan 2026 15:08:55 +0100 Subject: [PATCH 17/23] added queue to supported commands --- bin/bw-nandl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/bw-nandl b/bin/bw-nandl index 6c4a6ec..a6cbde1 100644 --- a/bin/bw-nandl +++ b/bin/bw-nandl @@ -50,7 +50,7 @@ EDITOR="${EDITOR:-nano}" # supported handlers and commands VALID_PREFIXES=("l_onready" "l_onstart" "l_onstop" "s_onready" "s_onstart" "s_onstop" "s_onconnect" "s_ondisconnect" "s_onwsjoin" "s_onwsleave") -VALID_COMMANDS=("start" "stop" "list" "upload" "dl" "handlers" "<" "help" "exit" "kick" "rm" "sync" "lf" "sstv" "morse" "live") +VALID_COMMANDS=("start" "stop" "list" "upload" "dl" "handlers" "<" "help" "exit" "kick" "rm" "sync" "lf" "sstv" "morse" "live" "queue") list_handlers() { echo "" From 11b9ec6b1a017fa92fc137cc3bc3ee0bdb03aa92 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Tue, 27 Jan 2026 16:13:29 +0100 Subject: [PATCH 18/23] client: sending file not found as Commands.END --- client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/client.py b/client/client.py index 7a88cee..0df8b94 100644 --- a/client/client.py +++ b/client/client.py @@ -352,7 +352,7 @@ async def _handle_start_broadcast(self, kwargs: dict): file_path = os.path.join(self.upload_dir, filename) if not os.path.exists(file_path): - response = ProtocolParser.build_response(Commands.ERROR, f"File not found: {filename}") + response = ProtocolParser.build_response(Commands.END, f"File not found: {filename}") await self.ws_client.send(response) return From a31a5efee3762ff37a736ba078304685b7c601a5 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Tue, 27 Jan 2026 16:14:06 +0100 Subject: [PATCH 19/23] server: if a message is provided in the Commands.END, it will be shown instead of the default message --- server/server.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/server.py b/server/server.py index f97ac32..5b87b47 100644 --- a/server/server.py +++ b/server/server.py @@ -244,7 +244,11 @@ async def _handle_client_message(self, client_id: Optional[str], message: str, w if command == Commands.END: filename = kwargs.get('filename', 'unknown') - Log.broadcast(f"{self.clients[client_id].get_display_name()}: Finished broadcasting {filename}") + msg = kwargs.get('message') + if msg: + Log.error(f"{self.clients[client_id].get_display_name()}: {msg}") + else: + Log.broadcast(f"{self.clients[client_id].get_display_name()}: Finished broadcasting {filename}") self.queue.on_broadcast_ended(client_id) return From 5f53b31b5cbc32e0c3abd9fda1602b4e808a6e16 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Tue, 27 Jan 2026 16:44:52 +0100 Subject: [PATCH 20/23] readme: improved features list --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 9fcaec4..53749a4 100644 --- a/README.md +++ b/README.md @@ -73,12 +73,15 @@ BotWave lets you broadcast audio over FM radio using Raspberry Pi devices. It su ## Features - **Server-Client Architecture**: Manage multiple Raspberry Pi clients from a central server. +- **Standalone Client**: Run a client without a central server for single-device broadcasting. - **Audio Broadcasting**: Broadcast audio files over FM radio. - **File Upload**: Upload audio files to clients for broadcasting. - **Remote Management**: Start, stop, and manage broadcasts remotely. - **Authentication**: Client-server authentication with passkeys. - **Protocol Versioning**: Ensure compatibility between server and clients. - **Live Broadcasting**: Stream live output from any application in real time. +- **Queue System**: Manage playlists and multiple audio files at once. +- **Task Automation**: Run commands automatically on events and start on system boot. ## Requirements > All requirements can be installed automatically via the installer, see below. From 90d74e729675801a058bc8558d934d05030ac1b2 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Tue, 27 Jan 2026 16:49:03 +0100 Subject: [PATCH 21/23] server,local: updated help command to include the queue command --- local/local.py | 5 +++++ server/server.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/local/local.py b/local/local.py index 80c6de1..ab5fc40 100644 --- a/local/local.py +++ b/local/local.py @@ -590,6 +590,11 @@ def display_help(self): Log.print(" live", "cyan") Log.print("") + Log.print("queue [+|-|*|!|?]", "bright_green") + Log.print(" Manage broadcast queue", "white") + Log.print(" Use 'queue ?' for detailed help", "white") + Log.print("") + Log.print("sstv [mode] [output_wav] [frequency] [loop] [ps] [rt] [pi]", "bright_green") Log.print(" Convert an image into a SSTV WAV file, and then broadcast it", "white") Log.print(" Example:", "white") diff --git a/server/server.py b/server/server.py index 5b87b47..7538091 100644 --- a/server/server.py +++ b/server/server.py @@ -1504,6 +1504,11 @@ def display_help(self): Log.print(" stop all", "cyan") Log.print("") + Log.print("queue [+|-|*|!|?]", "bright_green") + Log.print(" Manage broadcast queue", "white") + Log.print(" Use 'queue ?' for detailed help", "white") + Log.print("") + Log.print("live [freq] [ps] [rt] [pi]", "bright_green") Log.print(" Start a live audio broadcast to client(s)", "white") Log.print(" Example:", "white") From fb119eea271a0524e216bac9f34947544ff1af75 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Tue, 27 Jan 2026 16:51:08 +0100 Subject: [PATCH 22/23] latestver: updated to 2.0.2 --- assets/latest.ver.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/latest.ver.txt b/assets/latest.ver.txt index 10bf840..f93ea0c 100644 --- a/assets/latest.ver.txt +++ b/assets/latest.ver.txt @@ -1 +1 @@ -2.0.1 \ No newline at end of file +2.0.2 \ No newline at end of file From 7b22143e01fbadf8f54694fd1d9990c7bf3cb659 Mon Sep 17 00:00:00 2001 From: douxxtech Date: Tue, 27 Jan 2026 17:05:40 +0100 Subject: [PATCH 23/23] =--- v1.0.7-annea ---= --- assets/releases.txt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/assets/releases.txt b/assets/releases.txt index 2139384..f47a1b3 100644 --- a/assets/releases.txt +++ b/assets/releases.txt @@ -14,6 +14,25 @@ # - Release description: detailed information about the release # - =--- END ---=: marks the end of the release +=--- v1.0.7-annea ---= +**What changed:** +- **Protocol**: + - New `Commands.END` for the client to report a broadcast end or failure + - Version `2.0.2` + +- **New features**: + - `queue` command to manage your playlist. See [the wiki](https://github.com/dpipstudio/botwave/wiki/Queue-system) + +- **Usage changes**: + - Now local client and client can take the `--talk` argument to show [`PiWave`](https://git.douxx.tech/piwave) debug logs. Disabled by default. + +--- + +Related: +- #33 +- #37 +=--- END ---= + =--- v1.0.6-citrus ---= **What changed:** - Updated default rt on server to reflect the filename, not the targets