diff --git a/documentation/builders/cli-client.md b/documentation/builders/cli-client.md
index b75e942ee..ff1c036cd 100644
--- a/documentation/builders/cli-client.md
+++ b/documentation/builders/cli-client.md
@@ -1,21 +1,102 @@
-# CLI Client
+# RPC CLI Client
-The CLI (command line interface) client can be used to send [RPC commands](./rpc-commands.md) from command line to Phoniebox.
+The Python CLI (Command Line Interface) client can be used to send [RPC commands](./rpc-commands.md) to Phoniebox. It provides an interactive shell with autocompletion and command history, as well as direct command execution.
-## Installation
+The RPC tool can be found here:
-* Install prerequisites: `sudo apt-get install libczmq-dev`
-* Change to directory: `cd ~/RPi-Jukebox-RFID/src/cli_client`
-* Compile CLI client: `gcc pbc.c -o pbc -lzmq -Wall`
+```bash
+~/RPi-Jukebox-RFID/src/jukebox/run_rpc_tool.py
+```
## Usage
-* Get help info: `./pbc -h`
-* Example shutdown: `./pbc -p host -o shutdown`
+The CLI tool can be used in two modes:
-See also [RPC Commands](./rpc-commands.md) reference.
+### Interactive Mode
-## Reference
+```bash
+# Start interactive shell
+./run_rpc_tool.py
-*
-*
+# Start with specific connection type
+./run_rpc_tool.py --tcp 5555 # TCP connection on port 5555
+./run_rpc_tool.py --websocket # WebSocket connection on default port
+```
+
+In interactive mode:
+
+- Use TAB for command autocompletion
+- Use UP/DOWN arrows for command history
+- Type `help` to see available commands
+- Type `usage` for detailed usage information
+- Press Ctrl-D or type `exit` to quit
+
+### Direct Command Mode
+
+```bash
+# Execute single command
+./run_rpc_tool.py -c 'command [args...] [key=value...]'
+
+# Examples with positional args:
+./run_rpc_tool.py -c 'volume.ctrl.set_volume 50'
+./run_rpc_tool.py -c 'player.ctrl.play_content "/music/test.mp3" single'
+
+# Examples with kwargs:
+./run_rpc_tool.py -c 'volume.ctrl.set_volume level=50'
+./run_rpc_tool.py -c 'player.ctrl.play_content content="/music/test.mp3" content_type=single'
+```
+
+## Command Format
+
+Commands support both positional arguments and keyword arguments:
+
+```python
+package.plugin.method [arg1] [arg2] [arg3] # Positional args
+package.plugin.method [key1=value1] [key2=value2] # Keyword args
+```
+
+Arguments can be:
+
+- Numbers (50 or level=50)
+- Strings (use quotes for spaces: "my string" or path="my string")
+- JSON objects (use single quotes: '{"key":"value"}')
+- Hexadecimal numbers (prefix with 0x: 0xFF or value=0xFF)
+
+### Examples
+
+```bash
+# Simple commands - both styles work
+volume.ctrl.set_volume 50
+volume.ctrl.set_volume level=50
+
+system.ctrl.shutdown
+
+# Playing content - positional args
+player.ctrl.play_content '{"artist":"Pink Floyd","album":"The Wall"}' album
+player.ctrl.play_content "/music/classical" folder true
+player.ctrl.play_content "/music/track.mp3" single
+
+# Playing content - keyword args
+player.ctrl.play_content content='{"artist":"Pink Floyd","album":"The Wall"}' content_type=album
+player.ctrl.play_content content="/music/classical" content_type=folder recursive=true
+player.ctrl.play_content content="/music/track.mp3" content_type=single
+
+# Reader-based playback - positional args
+player.ctrl.play_from_reader '{"artist":"Pink Floyd","album":"The Wall"}' album false toggle
+player.ctrl.play_from_reader "/music/classical" folder true replay
+
+# Reader-based playback - keyword args
+player.ctrl.play_from_reader content='{"artist":"Pink Floyd","album":"The Wall"}' content_type=album second_swipe=toggle
+player.ctrl.play_from_reader content="/music/classical" content_type=folder recursive=true second_swipe=replay
+```
+
+## Features
+
+- Command autocompletion
+- Command history
+- Support for both positional and keyword arguments
+- JSON argument support
+- Interactive and direct command modes
+- Automatic type conversion (strings, numbers, JSON)
+- Connection error handling
+- Dynamic command help from server
diff --git a/documentation/developers/docstring/README.md b/documentation/developers/docstring/README.md
index c48c34a41..b1aea3623 100644
--- a/documentation/developers/docstring/README.md
+++ b/documentation/developers/docstring/README.md
@@ -27,10 +27,10 @@
* [resolve](#misc.simplecolors.resolve)
* [print](#misc.simplecolors.print)
* [components](#components)
-* [components.playermpd.playcontentcallback](#components.playermpd.playcontentcallback)
- * [PlayContentCallbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks)
- * [register](#components.playermpd.playcontentcallback.PlayContentCallbacks.register)
- * [run\_callbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks.run_callbacks)
+* [components.playermpd.play_content_callback](#components.playermpd.play_content_callback)
+ * [PlayContentCallbacks](#components.playermpd.play_content_callback.PlayContentCallbacks)
+ * [register](#components.playermpd.play_content_callback.PlayContentCallbacks.register)
+ * [run\_callbacks](#components.playermpd.play_content_callback.PlayContentCallbacks.run_callbacks)
* [components.playermpd](#components.playermpd)
* [PlayerMPD](#components.playermpd.PlayerMPD)
* [mpd\_retry\_with\_mutex](#components.playermpd.PlayerMPD.mpd_retry_with_mutex)
@@ -761,11 +761,11 @@ Use just as a regular print function, but with first parameter as color
# components
-
+
-# components.playermpd.playcontentcallback
+# components.playermpd.play_content_callback
-
+
## PlayContentCallbacks Objects
@@ -776,7 +776,7 @@ class PlayContentCallbacks(Generic[STATE], CallbackHandler)
Callbacks are executed in various play functions
-
+
#### register
@@ -796,7 +796,7 @@ Callback signature is
- `folder`: relativ path to folder to play
- `state`: indicator of the state inside the calling
-
+
#### run\_callbacks
diff --git a/installation/includes/02_helpers.sh b/installation/includes/02_helpers.sh
index ac6d434a0..07c0e6851 100644
--- a/installation/includes/02_helpers.sh
+++ b/installation/includes/02_helpers.sh
@@ -84,7 +84,7 @@ is_debian_based() {
fi
}
-_get_debian_version_number() {
+get_debian_version_number() {
if [ "$(is_debian_based)" = true ]; then
local debian_version_number=$( . /etc/os-release; printf '%s\n' "$VERSION_ID"; )
echo "$debian_version_number"
@@ -106,8 +106,7 @@ is_debian_version_at_least() {
_get_boot_file_path() {
local filename="$1"
- local is_debian_version_number_at_least_12=$(is_debian_version_at_least 12)
- if [ "$(is_debian_version_number_at_least_12)" = true ]; then
+ if [ "$(is_debian_version_at_least 12)" = true ]; then
echo "/boot/firmware/${filename}"
else
echo "/boot/${filename}"
diff --git a/src/cli_client/pbc.c b/src/cli_client/pbc.c
deleted file mode 100644
index 7f39c493e..000000000
--- a/src/cli_client/pbc.c
+++ /dev/null
@@ -1,261 +0,0 @@
-/**
- \file pbc.c
-
- MIT License
-
- Copyright (C) 2021 Arne Pagel
-
- Permission is hereby granted, free of charge, to any person obtaining a copy
- of this software and associated documentation files (the "Software"), to deal
- in the Software without restriction, including without limitation the rights
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- copies of the Software, and to permit persons to whom the Software is
- furnished to do so, subject to the following conditions:
-
- The above copyright notice and this permission notice shall be included in all
- copies or substantial portions of the Software.
-
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- SOFTWARE.
-*/
-
-/*
-
- pbc -> PhonieBox Command line interface
-
- depends on libczmq:
- apt-get install libczmq-dev
-
- how to compile:
- gcc pbc.c -o pbc -lzmq -Wall
-
-*/
-
-#include
-#include
-
-#define MAX_STRLEN 256
-#define MAX_REQEST_STRLEN (MAX_STRLEN * 16)
-#define MAX_PARAMS 16
-int g_verbose = 0;
-
-typedef struct
-{
- char object [MAX_STRLEN];
- char package [MAX_STRLEN];
- char method [MAX_STRLEN];
- char params [MAX_PARAMS][MAX_STRLEN];
- int num_params;
- char address [MAX_STRLEN];
-} t_request;
-
-
-int send_zmq_request_and_wait_response(char * request, int request_len, char * response, int max_response_len, char * address)
-{
- int zmq_ret,ret = -1;
- void *context = zmq_ctx_new ();
- void *requester = zmq_socket (context, ZMQ_REQ);
- int linger = 200;
-
- if (g_verbose)
- {
- int major, minor, patch;
- zmq_version (&major, &minor, &patch);
- printf ("Current ØMQ version is %d.%d.%d\n", major, minor, patch);
- }
-
- zmq_setsockopt(requester,ZMQ_LINGER,&linger,sizeof(linger));
- zmq_setsockopt(requester,ZMQ_RCVTIMEO,&linger,sizeof(linger));
- zmq_connect (requester, address);
-
- if (g_verbose) printf("connected to: %s",address);
-
-
- zmq_ret = zmq_send (requester, request, request_len, 0);
-
- if (zmq_ret > 0)
- {
- zmq_ret = zmq_recv (requester, response, max_response_len, 0);
-
- if (zmq_ret > 0)
- {
- printf ("Received %s (%d Bytes)\n", response,zmq_ret);
- ret = 0;
- }
- else
- {
- printf ("zmq_recv rturned %d \n", zmq_ret);
- }
- }
- else
- {
- if (g_verbose) printf ("zmq_send returned %d\n", zmq_ret);
- }
-
- zmq_close (requester);
- zmq_ctx_destroy (context);
- return (ret);
-}
-
-
-void * connect_and_send_request(t_request * tr)
-{
- char json_request[MAX_REQEST_STRLEN];
- char json_response[MAX_REQEST_STRLEN];
- char kwargs[MAX_STRLEN * 8];
- size_t json_len;
- int n;
-
- if (tr->num_params > 0)
- {
- sprintf(kwargs, "\"kwargs\":{");
-
- for (n = 0;n < tr->num_params;)
- {
- strcat(kwargs,tr->params[n]);
- n++;
- if (n < tr->num_params) strcat(kwargs,",");
- }
-
- strcat(kwargs,"},");
-
- }
- else sprintf(kwargs, "\"kwargs\":{},");
-
- snprintf(json_request,MAX_REQEST_STRLEN,"{\"package\": \"%s\", \"plugin\": \"%s\", \"method\": \"%s\", %s\"id\":%d}",tr->package,tr->object,tr->method,kwargs,123);
- json_len = strlen(json_request);
-
- if (g_verbose) printf("Sending Request (%ld Bytes):\n%s\n",json_len,json_request);
-
- send_zmq_request_and_wait_response(json_request,json_len,json_response,MAX_REQEST_STRLEN,tr->address);
-
- return 0;
-}
-
-int check_and_map_parameters_to_json(char * arg, t_request * tr)
-{
- char * name;
- char * value;
- char * fmt;
- int ret = 0;
- if (strchr(arg, ':') != NULL)
- {
- name = strtok(arg, ":");
- value = strtok(NULL, ":");
- fmt = (isdigit(*value)||*value=='-') ? "\"%s\":%s" : "\"%s\":\"%s\"";
- snprintf (tr->params[tr->num_params++],MAX_STRLEN, fmt,name,value);
- ret = 1;
- }
- return (ret);
-}
-
-
-void usage(void)
-{
- fprintf(stderr,"\npbc -> PhonieBox Command line interface\nusage: pbc -p package -o plugin -m method param_name:value\n\n");
- fprintf(stderr," -h this screen\n");
- fprintf(stderr," -p, --package package\n");
- fprintf(stderr," -o, --object plugin\n");
- fprintf(stderr," -m, --method method\n");
- fprintf(stderr," -a, --address default=tcp://localhost:5555\n");
- fprintf(stderr," -v verbose\n");
-
- fprintf(stderr,"last change %s\n\n",__DATE__);
- exit (1);
-}
-
-/**
- returns the index of the first argument that is not an option; i.e.
- does not start with a dash or a slash
-*/
-int HandleOptions(int argc,char *argv[], t_request * tr)
-{
- int c;
- sprintf(tr->address,"tcp://localhost:5555");
-
- const struct option long_options[] =
- {
- /* These options set a flag. */
- //{"verbose", no_argument, &verbose_flag, 1},
- //{"brief", no_argument, &verbose_flag, 0},
- /* These options don't set a flag.
- We distinguish them by their indices. */
- {"help", no_argument, 0, 'h'},
- {"package", required_argument, 0, 'p'},
- {"object", required_argument, 0, 'o'},
- {"method", required_argument, 0, 'm'},
- {"address", required_argument, 0, 'a'},
- {0, 0, 0, 0}
- };
-
- const char short_options[] = {"o:m:p:a:?hv"};
-
- while (1)
- {
- int option_index = 0; // getopt_long stores the option index here.
-
- c = getopt_long (argc, argv,short_options,long_options, &option_index);
-
- // Detect the end of the options.
- if (c == -1) break;
-
- switch (c)
- {
- case '?':
- case 'h':
- usage();
- puts ("option -a\n");
- break;
- case 'p':
- strncpy (tr->package,optarg,MAX_STRLEN);
- break;
- case 'o':
- strncpy (tr->object,optarg,MAX_STRLEN);
- break;
-
- case 'm':
- strncpy (tr->method,optarg,MAX_STRLEN);
- break;
-
- case 'v':
- g_verbose = '1';
- break;
-
- case 'a':
- strncpy (tr->address,optarg,MAX_STRLEN);
- break;
-
- default:
- usage();
- abort ();
- }
- }
-
- /* treat remaining command line arguments (not options). */
- if (optind < argc)
- {
- while (optind < argc)
- {
- check_and_map_parameters_to_json(argv[optind++], tr);
- }
- }
-
- return (1);
-}
-
-int main(int argc,char *argv[])
-{
- t_request tr;
-
- bzero(&tr, sizeof(t_request));
-
- HandleOptions(argc,argv,&tr);
- connect_and_send_request(&tr);
-
- return 0;
-}
diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py
index 86dbc60ab..161b81679 100644
--- a/src/jukebox/components/playermpd/__init__.py
+++ b/src/jukebox/components/playermpd/__init__.py
@@ -88,6 +88,8 @@
import time
import functools
from pathlib import Path
+from typing import Union, Dict, Any, Optional
+
import components.player
import jukebox.cfghandler
import jukebox.utils as utils
@@ -98,8 +100,9 @@
import misc
from jukebox.NvManager import nv_manager
-from .playcontentcallback import PlayContentCallbacks, PlayCardState
+from .play_content_callback import PlayContentCallbacks, PlayCardState
from .coverart_cache_manager import CoverartCacheManager
+from .play_content_handler import PlayContentHandler, PlayContent, PlayContentType
logger = logging.getLogger('jb.PlayerMPD')
cfg = jukebox.cfghandler.get_handler('jukebox')
@@ -148,38 +151,38 @@ def __init__(self):
self.music_player_status = self.nvm.load(cfg.getn('playermpd', 'status_file'))
self.second_swipe_action_dict = {'toggle': self.toggle,
- 'play': self.play,
- 'skip': self.next,
- 'rewind': self.rewind,
- 'replay': self.replay,
- 'replay_if_stopped': self.replay_if_stopped}
+ 'play': self.play,
+ 'skip': self.next,
+ 'rewind': self.rewind,
+ 'replay': self.replay,
+ 'replay_if_stopped': self.replay_if_stopped}
self.second_swipe_action = None
self.decode_2nd_swipe_option()
self.end_of_playlist_next_action = utils.get_config_action(cfg,
- 'playermpd',
- 'end_of_playlist_next_action',
- 'none',
- {'rewind': self.rewind,
+ 'playermpd',
+ 'end_of_playlist_next_action',
+ 'none',
+ {'rewind': self.rewind,
'stop': self.stop,
'none': lambda: None},
- logger)
+ logger)
self.stopped_prev_action = utils.get_config_action(cfg,
- 'playermpd',
- 'stopped_prev_action',
- 'prev',
- {'rewind': self.rewind,
+ 'playermpd',
+ 'stopped_prev_action',
+ 'prev',
+ {'rewind': self.rewind,
'prev': self._prev_in_stopped_state,
'none': lambda: None},
- logger)
+ logger)
self.stopped_next_action = utils.get_config_action(cfg,
- 'playermpd',
- 'stopped_next_action',
- 'next',
- {'rewind': self.rewind,
- 'next': self._next_in_stopped_state,
- 'none': lambda: None},
- logger)
+ 'playermpd',
+ 'stopped_next_action',
+ 'next',
+ {'rewind': self.rewind,
+ 'next': self._next_in_stopped_state,
+ 'none': lambda: None},
+ logger)
self.mpd_client = mpd.MPDClient()
self.coverart_cache_manager = CoverartCacheManager()
@@ -222,11 +225,18 @@ def __init__(self):
self.mpd_status = {}
self.mpd_status_poll_interval = 0.25
self.mpd_lock = MpdLock(self.mpd_client, self.mpd_host, 6600)
+
+ global play_card_callbacks
+ play_card_callbacks = PlayContentCallbacks[PlayCardState]('play_card_callbacks', logger, context=self.mpd_lock)
+ self.play_card_callbacks = play_card_callbacks
+ self.play_content_handler = PlayContentHandler(self)
+ self.play_content_handler.set_second_swipe_action(self.second_swipe_action)
+
self.status_is_closing = False
# self.status_thread = threading.Timer(self.mpd_status_poll_interval, self._mpd_status_poll).start()
self.status_thread = multitimer.GenericEndlessTimerClass('mpd.timer_status',
- self.mpd_status_poll_interval, self._mpd_status_poll)
+ self.mpd_status_poll_interval, self._mpd_status_poll)
self.status_thread.start()
def exit(self):
@@ -522,60 +532,163 @@ def move(self):
# MPDClient.swapid(song1, song2)
raise NotImplementedError
- @plugs.tag
- def play_single(self, song_url):
+ def _play_single_internal(self, song_url: str) -> None:
with self.mpd_lock:
self.mpd_client.clear()
self.mpd_client.addid(song_url)
self.mpd_client.play()
- @plugs.tag
- def resume(self):
+ def _play_album_internal(self, artist: str, album: str) -> None:
with self.mpd_lock:
- songpos = self.current_folder_status["CURRENTSONGPOS"]
- elapsed = self.current_folder_status["ELAPSED"]
- self.mpd_client.seek(songpos, elapsed)
+ logger.info(f"Play album: '{album}' by '{artist}")
+ self.mpd_client.clear()
+ self.mpd_retry_with_mutex(self.mpd_client.findadd, 'albumartist', artist, 'album', album)
+ self.mpd_client.play()
+
+ def _play_folder_internal(self, folder: str, recursive: bool) -> None:
+ with self.mpd_lock:
+ logger.info(f"Play folder: '{folder}'")
+ self.mpd_client.clear()
+
+ plc = playlistgenerator.PlaylistCollector(components.player.get_music_library_path())
+ plc.parse(folder, recursive)
+ uri = '--unset--'
+ try:
+ for uri in plc:
+ self.mpd_client.addid(uri)
+ except mpd.base.CommandError as e:
+ logger.error(f"{e.__class__.__qualname__}: {e} at uri {uri}")
+ except Exception as e:
+ logger.error(f"{e.__class__.__qualname__}: {e} at uri {uri}")
+
+ self.music_player_status['player_status']['last_played_folder'] = folder
+
+ self.current_folder_status = self.music_player_status['audio_folder_status'].get(folder)
+ if self.current_folder_status is None:
+ self.current_folder_status = self.music_player_status['audio_folder_status'][folder] = {}
+
self.mpd_client.play()
@plugs.tag
- def play_card(self, folder: str, recursive: bool = False):
+ def play_content(self, content: Union[str, Dict[str, Any]], content_type: str = 'folder', recursive: bool = False):
+ """
+ Main entry point for trigger music playing from any source (RFID reader, web UI, etc.).
+ Does NOT support second swipe - use play_from_reader() for that.
+
+ :param content: Content identifier:
+ - For singles/folders: file/folder path as string
+ - For albums: dict with 'albumartist' and 'album' keys
+ :param content_type: Type of content ('single', 'album', 'folder')
+ :param recursive: Add folder recursively (only used for folder type)
"""
- Main entry point for trigger music playing from RFID reader. Decodes second swipe options before playing folder content
+ try:
+ content_type = content_type.lower()
+ if content_type == 'album':
+ if isinstance(content, dict):
+ albumartist = content.get('albumartist')
+ album = content.get('album')
+ if not albumartist or not album:
+ raise ValueError("Album content must contain both 'albumartist' and 'album' keys")
+ else:
+ raise ValueError("Album content must be a dictionary with 'albumartist' and 'album' keys")
+
+ play_content = PlayContent(
+ type=PlayContentType.ALBUM,
+ content=(albumartist, album)
+ )
+ elif content_type == 'single':
+ if isinstance(content, dict):
+ raise ValueError("Single track content should be a direct file path, not a dictionary")
+ play_content = PlayContent(
+ type=PlayContentType.SINGLE,
+ content=content
+ )
+ else: # folder is default
+ if isinstance(content, dict):
+ raise ValueError("Folder content should be a direct folder path, not a dictionary")
+ play_content = PlayContent(
+ type=PlayContentType.FOLDER,
+ content=content,
+ recursive=recursive
+ )
+
+ # Ensure no second swipe for regular content playback
+ old_action = self.play_content_handler._second_swipe_action
+ self.play_content_handler._second_swipe_action = None
- Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action
- accordingly.
+ try:
+ self.play_content_handler.play_content(play_content)
+ finally:
+ # Restore previous second swipe action
+ self.play_content_handler._second_swipe_action = old_action
- :param folder: Folder path relative to music library path
- :param recursive: Add folder recursively
+ except Exception as e:
+ logger.error(f"Error playing content: {e}")
+ raise
+
+ @plugs.tag
+ def play_from_reader(self, content: Union[str, Dict[str, str]], content_type: str = 'folder',
+ recursive: bool = False, second_swipe: Optional[str] = None):
"""
- # Developers notes:
- #
- # * 2nd swipe trigger may also happen, if playlist has already stopped playing
- # --> Generally, treat as first swipe
- # * 2nd swipe of same Card ID may also happen if a different song has been played in between from WebUI
- # --> Treat as first swipe
- # * With place-not-swipe: Card is placed on reader until playlist expieres. Music stop. Card is removed and
- # placed again on the reader: Should be like first swipe
- # * TODO: last_played_folder is restored after box start, so first swipe of last played card may look like
- # second swipe
- #
- logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}")
- with self.mpd_lock:
- is_second_swipe = self.music_player_status['player_status']['last_played_folder'] == folder
- if self.second_swipe_action is not None and is_second_swipe:
- logger.debug('Calling second swipe action')
+ Special entry point for reader-triggered playback with second swipe support.
+ Used when content is identified via RFID, barcode, or other physical readers.
+
+ :param content: Content identifier, either:
+ - string path for single/folder types
+ - dict with 'albumartist' and 'album' keys for album type
+ :param content_type: Type of content ('single', 'album', 'folder')
+ :param recursive: Add folder recursively (only used for folder type)
+ :param second_swipe: Override default second swipe action for this reader:
+ - None/not specified: use default from config
+ - 'none': disable second swipe
+ - One of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'replay_if_stopped'
+ """
+ # Determine second swipe action
+ if second_swipe is None:
+ action = self.second_swipe_action
+ elif second_swipe.lower() == 'none':
+ action = None
+ else:
+ action = self.second_swipe_action_dict.get(second_swipe.lower())
+ if action is None:
+ logger.error(f"Unknown second swipe action '{second_swipe}', using default")
+ action = self.second_swipe_action
- # run callbacks before second_swipe_action is invoked
- play_card_callbacks.run_callbacks(folder, PlayCardState.secondSwipe)
+ # Temporarily set the chosen second swipe action
+ old_action = self.play_content_handler._second_swipe_action
+ self.play_content_handler.set_second_swipe_action(action)
- self.second_swipe_action()
- else:
- logger.debug('Calling first swipe action')
+ try:
+ self.play_content(content, content_type, recursive)
+ finally:
+ # Restore previous second swipe action
+ self.play_content_handler.set_second_swipe_action(old_action)
+
+ # The following methods are kept for backward compatibility but now use play_content internally
+
+ @plugs.tag
+ def play_single(self, song_url):
+ """Deprecated: Use play_content with content_type='single' instead"""
+ self.play_content(song_url, content_type='single')
+
+ @plugs.tag
+ def play_album(self, albumartist: str, album: str):
+ """Deprecated: Use play_content with content_type='album' instead"""
+ content = {'artist': albumartist, 'album': album}
+ self.play_content(content, content_type='album')
- # run callbacks before play_folder is invoked
- play_card_callbacks.run_callbacks(folder, PlayCardState.firstSwipe)
+ @plugs.tag
+ def play_folder(self, folder: str, recursive: bool = False):
+ """Deprecated: Use play_content with content_type='folder' instead"""
+ self.play_content(folder, content_type='folder', recursive=recursive)
- self.play_folder(folder, recursive)
+ @plugs.tag
+ def resume(self):
+ with self.mpd_lock:
+ songpos = self.current_folder_status["CURRENTSONGPOS"]
+ elapsed = self.current_folder_status["ELAPSED"]
+ self.mpd_client.seek(songpos, elapsed)
+ self.mpd_client.play()
@plugs.tag
def get_single_coverart(self, song_url):
@@ -611,58 +724,6 @@ def get_folder_content(self, folder: str):
plc.get_directory_content(folder)
return plc.playlist
- @plugs.tag
- def play_folder(self, folder: str, recursive: bool = False) -> None:
- """
- Playback a music folder.
-
- Folder content is added to the playlist as described by :mod:`jukebox.playlistgenerator`.
- The playlist is cleared first.
-
- :param folder: Folder path relative to music library path
- :param recursive: Add folder recursively
- """
- # TODO: This changes the current state -> Need to save last state
- with self.mpd_lock:
- logger.info(f"Play folder: '{folder}'")
- self.mpd_client.clear()
-
- plc = playlistgenerator.PlaylistCollector(components.player.get_music_library_path())
- plc.parse(folder, recursive)
- uri = '--unset--'
- try:
- for uri in plc:
- self.mpd_client.addid(uri)
- except mpd.base.CommandError as e:
- logger.error(f"{e.__class__.__qualname__}: {e} at uri {uri}")
- except Exception as e:
- logger.error(f"{e.__class__.__qualname__}: {e} at uri {uri}")
-
- self.music_player_status['player_status']['last_played_folder'] = folder
-
- self.current_folder_status = self.music_player_status['audio_folder_status'].get(folder)
- if self.current_folder_status is None:
- self.current_folder_status = self.music_player_status['audio_folder_status'][folder] = {}
-
- self.mpd_client.play()
-
- @plugs.tag
- def play_album(self, albumartist: str, album: str):
- """
- Playback a album found in MPD database.
-
- All album songs are added to the playlist
- The playlist is cleared first.
-
- :param albumartist: Artist of the Album provided by MPD database
- :param album: Album name provided by MPD database
- """
- with self.mpd_lock:
- logger.info(f"Play album: '{album}' by '{albumartist}")
- self.mpd_client.clear()
- self.mpd_retry_with_mutex(self.mpd_client.findadd, 'albumartist', albumartist, 'album', album)
- self.mpd_client.play()
-
@plugs.tag
def queue_load(self, folder):
# There was something playing before -> stop and save state
@@ -762,6 +823,7 @@ def _db_is_updating(self, update_id: int):
#: States:
#: - See :class:`PlayCardState`
#: See :class:`PlayContentCallbacks`
+player_ctrl: PlayerMPD
play_card_callbacks: PlayContentCallbacks[PlayCardState]
diff --git a/src/jukebox/components/playermpd/playcontentcallback.py b/src/jukebox/components/playermpd/play_content_callback.py
similarity index 100%
rename from src/jukebox/components/playermpd/playcontentcallback.py
rename to src/jukebox/components/playermpd/play_content_callback.py
diff --git a/src/jukebox/components/playermpd/play_content_handler.py b/src/jukebox/components/playermpd/play_content_handler.py
new file mode 100644
index 000000000..660b82c28
--- /dev/null
+++ b/src/jukebox/components/playermpd/play_content_handler.py
@@ -0,0 +1,97 @@
+from enum import Enum, auto
+from dataclasses import dataclass
+from typing import Union, Optional, Callable, Protocol
+import logging
+
+from .play_content_callback import PlayCardState # Add this import
+
+logger = logging.getLogger('jb.PlayerMPD')
+
+
+class PlayContentType(Enum):
+ SINGLE = auto()
+ ALBUM = auto()
+ FOLDER = auto()
+
+
+@dataclass
+class PlayContent:
+ """Represents playable content with its type and metadata"""
+ type: PlayContentType
+ content: Union[str, tuple[str, str]] # str for SINGLE/FOLDER, tuple(artist, album) for ALBUM
+ recursive: bool = False
+
+
+class PlayerProtocol(Protocol):
+ """Protocol defining required player methods"""
+ def _play_single_internal(self, song_url: str) -> None:
+ """Play a single track"""
+
+ def _play_album_internal(self, artist: str, album: str) -> None:
+ """Play an album"""
+
+ def _play_folder_internal(self, folder: str, recursive: bool) -> None:
+ """Play a folder"""
+
+ @property
+ def play_card_callbacks(self) -> any:
+ """Access to callbacks"""
+
+
+class PlayContentHandler:
+ """Handles different types of playback content with second swipe support"""
+
+ def __init__(self, player: PlayerProtocol):
+ self.player = player
+ self.last_played_content: Optional[PlayContent] = None
+ self._second_swipe_action = None
+
+ def set_second_swipe_action(self, action: Optional[Callable]) -> None:
+ """Set the action to be performed on second swipe"""
+ self._second_swipe_action = action
+
+ def _play_content(self, content: PlayContent) -> None:
+ """Internal method to play content based on its type"""
+ if content.type == PlayContentType.SINGLE:
+ logger.debug(f"Playing single track: {content.content}")
+ self.player._play_single_internal(content.content)
+ elif content.type == PlayContentType.ALBUM:
+ artist, album = content.content
+ logger.debug(f"Playing album: {album} by {artist}")
+ self.player._play_album_internal(artist, album)
+ elif content.type == PlayContentType.FOLDER:
+ logger.debug(f"Playing folder: {content.content} (recursive={content.recursive})")
+ self.player._play_folder_internal(content.content, content.recursive)
+
+ def play_content(self, content: PlayContent) -> None:
+ """
+ Main entry point for playing content with second swipe support
+
+ Checks for second trigger of the same content and calls first/second swipe
+ action accordingly.
+ """
+ is_second_swipe = False
+
+ if self.last_played_content is not None:
+ if (content.type == self.last_played_content.type
+ and content.content == self.last_played_content.content):
+ is_second_swipe = True
+
+ if self._second_swipe_action is not None and is_second_swipe:
+ logger.debug('Calling second swipe action')
+ # run callbacks before second_swipe_action is invoked
+ self.player.play_card_callbacks.run_callbacks(
+ str(content.content),
+ PlayCardState.secondSwipe # Use imported PlayCardState directly
+ )
+ self._second_swipe_action()
+ else:
+ logger.debug('Calling first swipe action')
+ # run callbacks before play_content is invoked
+ self.player.play_card_callbacks.run_callbacks(
+ str(content.content),
+ PlayCardState.firstSwipe # Use imported PlayCardState directly
+ )
+ self._play_content(content)
+
+ self.last_played_content = content
diff --git a/src/jukebox/components/rfid/cards/__init__.py b/src/jukebox/components/rfid/cards/__init__.py
index 65e3ff8b9..1413f791e 100644
--- a/src/jukebox/components/rfid/cards/__init__.py
+++ b/src/jukebox/components/rfid/cards/__init__.py
@@ -17,7 +17,7 @@
import logging
import time
-from typing import (List, Dict, Optional)
+from typing import List, Dict, Optional, Union
import jukebox.utils as utils
import jukebox.cfghandler
import jukebox.plugs as plugs
@@ -89,42 +89,48 @@ def delete_card(card_id: str, auto_save: bool = True):
@plugs.register
def register_card(card_id: str, cmd_alias: str,
- args: Optional[List] = None, kwargs: Optional[Dict] = None,
- ignore_card_removal_action: Optional[bool] = None, ignore_same_id_delay: Optional[bool] = None,
- overwrite: bool = False,
- auto_save: bool = True):
- """Register a new card based on quick-selection
-
- If you are going to call this through the RPC it will get a little verbose
-
- **Example:** Registering a new card with ID *0009* for increment volume with a custom argument to inc_volume
- (*here: 15*) and custom *ignore_same_id_delay value*::
-
- plugin.call_ignore_errors('cards', 'register_card',
- args=['0009', 'inc_volume'],
- kwargs={'args': [15], 'ignore_same_id_delay': True, 'overwrite': True})
-
- """
+ args: Optional[Union[List, Dict]] = None,
+ kwargs: Optional[Dict] = None,
+ ignore_card_removal_action: Optional[bool] = None,
+ ignore_same_id_delay: Optional[bool] = None,
+ overwrite: bool = False,
+ auto_save: bool = True):
+ """Register a new card based on quick-selection"""
if cmd_alias not in cmd_alias_definitions.keys():
msg = f"Unknown RPC command alias: '{cmd_alias}'"
log.error(msg)
raise KeyError(msg)
+
with cfg_cards:
if not overwrite and card_id in cfg_cards.keys():
msg = f"Card already registered: '{card_id}'. Abort. (use overwrite=True to overrule)"
log.error(msg)
raise KeyError(msg)
+
cfg_cards[card_id] = {'alias': cmd_alias}
- if args is not None:
+
+ # For play_from_reader, expect a single dict of args
+ if cmd_alias == 'play_from_reader':
+ # Use either kwargs or args if it's a dict
+ if kwargs is not None:
+ cfg_cards[card_id]['args'] = kwargs
+ elif isinstance(args, dict):
+ cfg_cards[card_id]['args'] = args
+ else:
+ log.error(f"play_from_reader requires dict arguments, got: {type(args)}")
+ raise ValueError("play_from_reader requires dict arguments")
+ # For other commands, maintain list args support
+ elif args is not None:
cfg_cards[card_id]['args'] = args
- if kwargs is not None:
- cfg_cards[card_id]['kwargs'] = args
+
if ignore_same_id_delay is not None:
cfg_cards[card_id]['ignore_same_id_delay'] = ignore_same_id_delay
if ignore_card_removal_action is not None:
cfg_cards[card_id]['ignore_card_removal_action'] = ignore_card_removal_action
+
if auto_save:
cfg_cards.save()
+
publishing.get_publisher().send(f'{plugs.loaded_as(__name__)}.database.has_changed', time.ctime())
diff --git a/src/jukebox/components/rpc_command_alias.py b/src/jukebox/components/rpc_command_alias.py
index 5a7820733..48b76ea7c 100644
--- a/src/jukebox/components/rpc_command_alias.py
+++ b/src/jukebox/components/rpc_command_alias.py
@@ -12,12 +12,12 @@
# --------------------------------------------------------------
cmd_alias_definitions = {
# Player
- 'play_card': {
- 'title': 'Play music folder triggered by card swipe',
+ 'play_from_reader': {
+ 'title': 'Play content triggered by card swipe, supports second swipe',
'note': "This function you'll want to use most often",
'package': 'player',
'plugin': 'ctrl',
- 'method': 'play_card'},
+ 'method': 'play_from_reader'},
'play_album': {
'title': 'Play Album triggered by card swipe',
'note': "This function plays the content of a given album",
diff --git a/src/jukebox/components/synchronisation/rfidcards/__init__.py b/src/jukebox/components/synchronisation/rfidcards/__init__.py
index 0fa0969a9..10f58541c 100644
--- a/src/jukebox/components/synchronisation/rfidcards/__init__.py
+++ b/src/jukebox/components/synchronisation/rfidcards/__init__.py
@@ -29,7 +29,7 @@
import shutil
from components.rfid.reader import RfidCardDetectState
-from components.playermpd.playcontentcallback import PlayCardState
+from components.playermpd.play_content_callback import PlayCardState
logger = logging.getLogger('jb.sync_rfidcards')
diff --git a/src/jukebox/run_rpc_tool.py b/src/jukebox/run_rpc_tool.py
index 4bd834e12..40a3391b1 100644
--- a/src/jukebox/run_rpc_tool.py
+++ b/src/jukebox/run_rpc_tool.py
@@ -3,368 +3,487 @@
Command Line Interface to the Jukebox RPC Server
A command line tool for sending RPC commands to the running jukebox app.
-This uses the same interface as the WebUI. Can be used for additional control
-or for debugging.
-
-The tool features auto-completion and command history.
-
-The list of available commands is fetched from the running Jukebox service.
-
-.. todo:
- - kwargs support
-
+Features auto-completion, command history, and RPC command execution.
+Supports JSON arguments for complex data structures.
"""
import argparse
-import zmq
+import json
+from dataclasses import dataclass
+from typing import List, Dict, Any, Tuple
import curses
import curses.ascii
+import zmq
import jukebox.rpc.client as rpc
-# Developers note: Scripting at it's dirty end :-)
+@dataclass
+class CliState:
+ """Encapsulates CLI state and configuration"""
+ url: str
+ client: rpc.RpcClient
+ rpc_help: Dict[str, Dict[str, str]] = None
+ candidates: List[str] = None
+ history: List[str] = None
+ prompt: str = '> '
+
+ def __post_init__(self):
+ self.rpc_help = {}
+ self.candidates = []
+ self.history = ['']
+
+
+class CommandParser:
+ """Handles parsing and execution of RPC commands with JSON and quoted string support"""
+
+ @staticmethod
+ def parse_command(cmd: str) -> Tuple[List[str], List[Any], Dict[str, Any]]:
+ """
+ Parse command string into command parts, positional args, and keyword args
+ Returns: (command_parts, args, kwargs)
+ """
+ # Split while preserving quotes and JSON structures
+ parts = CommandParser._split_preserving_json(cmd.strip())
+ if not parts:
+ return [], [], {}
+
+ # Split cmd on '.' into package.plugin.method
+ command_parts = [v for v in parts[0].split('.') if len(v) > 0]
+
+ # Process remaining parts into args and kwargs
+ args = []
+ kwargs = {}
+ seen_keys = set() # Track seen keyword argument names
+
+ for part in parts[1:]:
+ # Check if part is a kwarg (contains '=')
+ if '=' in part:
+ key, value = part.split('=', 1)
+ key = key.strip()
+ value = value.strip()
+
+ # Check for duplicate keyword arguments
+ if key in seen_keys:
+ raise ValueError(f"Duplicate keyword argument: {key}")
+ seen_keys.add(key)
+
+ # Handle the value based on its format
+ kwargs[key] = CommandParser._parse_value(value)
+ else:
+ # Handle as positional argument
+ args.append(CommandParser._parse_value(part))
+
+ return command_parts, args, kwargs
+
+ @staticmethod
+ def _parse_value(value: str) -> Any:
+ """Parse a value string into appropriate type"""
+ # Strip quotes if present (only if matching quotes at start and end)
+ if (value.startswith('"') and value.endswith('"')) or \
+ (value.startswith("'") and value.endswith("'")):
+ value = value[1:-1]
+
+ # Try to parse as JSON if it looks like JSON
+ if value.startswith('{') or value.startswith('['):
+ try:
+ return json.loads(value)
+ except json.JSONDecodeError:
+ # If JSON parsing fails, continue with other parsing attempts
+ pass
+
+ # Try to convert to number if appropriate
+ return CommandParser.convert_to_number(value)
+
+ @staticmethod
+ def _split_preserving_json(cmd: str) -> List[str]: # noqa: C901
+ """Split command string while preserving quoted strings and JSON structures"""
+ parts = []
+ current = []
+ brace_count = 0
+ bracket_count = 0
+ in_single_quotes = False
+ in_double_quotes = False
+ escape = False
+
+ for char in cmd:
+ if escape:
+ current.append(char)
+ escape = False
+ continue
+
+ if char == '\\':
+ escape = True
+ current.append(char)
+ continue
+
+ if char == '"' and not in_single_quotes:
+ in_double_quotes = not in_double_quotes
+ current.append(char)
+ continue
+
+ if char == "'" and not in_double_quotes:
+ in_single_quotes = not in_single_quotes
+ current.append(char)
+ continue
+
+ if not in_single_quotes and not in_double_quotes:
+ if char == '{':
+ brace_count += 1
+ elif char == '}':
+ brace_count -= 1
+ elif char == '[':
+ bracket_count += 1
+ elif char == ']':
+ bracket_count -= 1
+ elif char.isspace() and brace_count == 0 and bracket_count == 0:
+ if current:
+ parts.append(''.join(current))
+ current = []
+ continue
+
+ current.append(char)
+
+ if current:
+ parts.append(''.join(current))
+
+ # Validate quote matching
+ if in_single_quotes:
+ raise ValueError("Unmatched single quote")
+ if in_double_quotes:
+ raise ValueError("Unmatched double quote")
+
+ return parts
+
+ @staticmethod
+ def convert_to_number(value: str) -> Any:
+ """Convert string to number if possible"""
+ # Try integer
+ try:
+ return int(value)
+ except ValueError:
+ pass
+
+ # Try float
+ try:
+ return float(value)
+ except ValueError:
+ pass
+
+ # Try hex
+ if value.isalnum() and value.startswith('0x'):
+ try:
+ return int(value, base=16)
+ except ValueError:
+ pass
+
+ return value
-# Careful: curses and default outputs don't mix!
-# In case you'll get an error, most likely your terminal may become funny
-# Best bet: Just don't configure any logger at all!
-# import logging
-# import misc.loggingext
-# logger = misc.loggingext.configure_default(logging.ERROR)
+class JukeboxCli:
+ """Main CLI class handling user interaction and command execution"""
-url: str
-client: rpc.RpcClient
-rpc_help = {}
-candidates = []
-history = ['']
-prompt = '> '
+ def __init__(self, url: str):
+ self.state = CliState(url=url, client=rpc.RpcClient(url))
+ self.command_parser = CommandParser()
+ def update_help(self, scr) -> None:
+ """Update available RPC commands from server"""
+ try:
+ rpc_help_tmp = self.state.client.enque('misc', 'rpc_cmd_help')
+ self.state.rpc_help = {k: rpc_help_tmp[k] for k in sorted(rpc_help_tmp.keys())}
+ except Exception:
+ self._show_connection_error(scr)
+ return
+
+ # Add CLI specific commands
+ self._add_cli_commands()
+ self.state.candidates = list(self.state.rpc_help.keys())
+
+ def _add_cli_commands(self) -> None:
+ """Add CLI-specific commands to help"""
+ cli_commands = {
+ "help": {'description': "Print RPC Server command list (all commands that start with ...)",
+ 'signature': "(cmd_starts_with='')"},
+ 'usage': {'description': "Usage help and key bindings", 'signature': "()"},
+ 'exit': {'description': "Exit RPC Client", 'signature': "()"}
+ }
+ self.state.rpc_help.update(cli_commands)
+
+ def execute_command(self, scr, cmd: str) -> None:
+ """Execute a command and display results"""
+ if not cmd.strip():
+ return
+
+ if cmd == 'help':
+ self._show_help(scr)
+ return
+ elif cmd == 'usage':
+ self._show_usage(scr)
+ return
+ elif cmd == 'exit':
+ return
+
+ command_parts, args, kwargs = self.command_parser.parse_command(cmd)
+
+ if not (2 <= len(command_parts) <= 3):
+ scr.addstr(":: Error = Ill-formatted command\n")
+ return
-def add_cli():
- global rpc_help
- rpc_help["help"] = {'description': "Print RPC Server command list (all commands that start with ...)",
- 'signature': "(cmd_starts_with='')"}
- rpc_help['usage'] = {'description': "Usage help and key bindings", 'signature': "()"}
- rpc_help['exit'] = {'description': "Exit RPC Client", 'signature': "()"}
+ method = command_parts[2] if len(command_parts) == 3 else None
+ try:
+ response = self.state.client.enque(
+ command_parts[0],
+ command_parts[1],
+ method,
+ args=args,
+ kwargs=kwargs
+ )
+ scr.addstr(f"\n:: Response =\n{response}\n\n")
+ except zmq.error.Again:
+ self._show_connection_error(scr)
+ except Exception as e:
+ scr.addstr(f":: Exception response =\n{e}\n")
-def get_help(scr):
- global rpc_help
- global candidates
- rpc_help = {}
- try:
- rpc_help_tmp = client.enque('misc', 'rpc_cmd_help')
- except Exception:
+ def run(self, scr) -> None:
+ """Main CLI loop"""
+ self._setup_screen(scr)
+ self._show_welcome(scr)
+ self.update_help(scr)
+ self._show_usage(scr)
+
+ cmd = ''
+ while cmd != 'exit':
+ cmd = self._get_input(scr)
+ scr.addstr("\n")
+ self.execute_command(scr, cmd)
+
+ def _setup_screen(self, scr) -> None:
+ """Configure screen settings"""
+ scr.idlok(True)
+ scr.scrollok(True)
+ curses.noecho()
+
+ def _show_connection_error(self, scr) -> None:
+ """Display connection error message"""
scr.addstr("\n\n" + '-' * 70 + "\n")
scr.addstr("Could not reach RPC Server. Jukebox running? Correct Port?\n")
scr.addstr('-' * 70 + "\n\n")
scr.refresh()
- else:
- # Sort the commands (Python 3.7 has ordered entries in dicts!)
- rpc_help = {k: rpc_help_tmp[k] for k in sorted(rpc_help_tmp.keys())}
- add_cli()
- candidates = rpc_help.keys()
-
-
-def format_help(scr, topic):
- global rpc_help
- # Always update help, in case Jukebox App has been restarted in between
- scr.erase()
- get_help(scr)
- max_y, max_x = scr.getmaxyx()
- scr.addstr("Available commands:\n\n")
- for key, value in rpc_help.items():
- sign: str = value['signature']
- sign = sign[sign.find('('):]
- func = f"{key}{sign}"
- # print(f"{func:50}: {value['description']}")
- if key.startswith(topic):
- scr.addstr(f"{func:50}: {value['description']}\n")
- [y, x] = scr.getyx()
- if y == max_y - 1:
- scr.addstr("--HIT A KEY TO CONTINUE--")
- scr.getch()
- scr.erase()
- scr.addstr("\n")
- scr.refresh()
-
-
-def format_welcome(scr):
- scr.addstr("\n\n" + '-' * 70 + "\n")
- scr.addstr("RPC Tool\n")
- scr.addstr('-' * 70 + "\n")
- scr.addstr(f"Connection url: '{client.address}'\n")
- try:
- jukebox_version = client.enque('misc', 'get_version')
- except Exception:
- jukebox_version = "unknown"
- scr.addstr(f"Jukebox version: {jukebox_version}\n")
- scr.addstr(f"Pyzmq version: {zmq.pyzmq_version()}; ZMQ version: {zmq.zmq_version()}; has draft API: {zmq.DRAFT_API}\n")
- scr.addstr('-' * 70 + "\n")
-
-
-def format_usage(scr):
- scr.addstr("\n\nUsage:\n")
- scr.addstr(" > cmd [arg1] [arg2] [arg3]\n")
- scr.addstr("e.g.\n")
- scr.addstr(" > volume.ctrl.set_volume 50\n")
- scr.addstr("Note: NOT yet supported: kwargs, quoting!\n")
- scr.addstr("\n")
- scr.addstr("Numbers are supported in decimal and hexadecimal format when prefixed with '0x'")
- scr.addstr("\n")
- scr.addstr("Use for auto-completion of commands!\n")
- scr.addstr("Use / for command history!\n")
- scr.addstr("\n")
- scr.addstr("Type help , to get a list of all commands'\n")
- scr.addstr("Type usage , to get this usage help'\n")
- scr.addstr("\n")
- scr.addstr("After Jukebox app restart, call help once to update command list from jukebox app\n")
- scr.addstr("\n")
- scr.addstr("To exit, press Ctrl-D or type 'exit'\n")
- scr.addstr("\n")
- scr.refresh()
-
-
-def get_common_beginning(strings):
- """
- Return the strings that are common to the beginning of each string in the strings list.
- """
- result = []
- limit = min([len(s) for s in strings])
- for i in range(limit):
- chs = set([s[i] for s in strings])
- if len(chs) == 1:
- result.append(chs.pop())
- else:
- break
- return ''.join(result)
-
-
-def autocomplete(msg):
- # logger.debug(f"Autocomplete {msg}")
- # Get all stings that match the beginning
- # candidates = ["ap1", 'ap2', 'appbbb3', 'appbbb4', 'appbbb5', 'appbbb6', 'exit']
- matches = [s for s in candidates if s.startswith(msg)]
- if len(matches) == 0:
- # Matches is empty: nothing found
- return msg, matches
- common = get_common_beginning(matches)
- return common, matches
-
-
-def is_printable(ch: int):
- return 32 <= ch <= 127
-
-
-def reprompt(scr, msg, y, x):
- scr.move(y, 0)
- scr.clrtoeol()
- scr.addstr(prompt)
- scr.addstr(msg)
- scr.move(y, x)
-
-
-def get_input(scr): # noqa: C901
- curses.noecho()
- ch = 0
- msg = ''
- ihist = ''
- hidx = len(history)
- [y, x] = scr.getyx()
- reprompt(scr, msg, y, len(prompt) + len(msg))
- scr.refresh()
- while ch != ord(b'\n'):
+
+ def _show_welcome(self, scr) -> None:
+ """Display welcome message and connection information"""
+ scr.addstr("\n\n" + '-' * 70 + "\n")
+ scr.addstr("RPC Tool\n")
+ scr.addstr('-' * 70 + "\n")
+ scr.addstr(f"Connection url: '{self.state.client.address}'\n")
+
try:
- ch = scr.getch()
- except KeyboardInterrupt:
- msg = 'exit'
- break
- [y, x] = scr.getyx()
- pos = x - len(prompt)
- if ch == ord(b'\t'):
- msg, matches = autocomplete(msg)
- if len(matches) > 1:
- scr.addstr('\n')
- scr.addstr(', '.join(matches))
- scr.addstr('\n')
- scr.clrtobot()
- reprompt(scr, msg, y, len(prompt) + len(msg))
- if ch == ord(b'\n'):
- break
- if ch == 4:
- msg = 'exit'
- break
- elif ch == curses.KEY_BACKSPACE or ch == 127:
- if pos > 0:
- scr.delch(y, x - 1)
- msg = msg[0:pos - 1] + msg[pos:]
- elif ch == curses.KEY_DC:
- scr.delch(y, x)
- msg = msg[0:pos] + msg[pos + 1:]
- elif ch == curses.KEY_LEFT:
- if pos > 0:
- scr.move(y, x - 1)
- elif ch == curses.KEY_RIGHT:
- if pos < len(msg):
- scr.move(y, x + 1)
- elif ch == curses.KEY_HOME:
- scr.move(y, len(prompt))
- elif ch == curses.KEY_END:
- scr.move(y, len(prompt) + len(msg))
- elif ch == curses.KEY_UP:
- if hidx == len(history):
- ihist = msg
- hidx = max(hidx - 1, 0)
- msg = history[hidx]
- reprompt(scr, msg, y, len(prompt) + len(msg))
- elif ch == curses.KEY_DOWN:
- hidx = min(hidx + 1, len(history))
- if hidx == len(history):
- msg = ihist
- else:
- msg = history[hidx]
- reprompt(scr, msg, y, len(prompt) + len(msg))
- elif is_printable(ch):
- msg = msg[0:pos] + curses.ascii.unctrl(ch) + msg[pos:]
- reprompt(scr, msg, y, x + 1)
- # else:
- # print(f" {ch} -- {type(ch)}")
+ jukebox_version = self.state.client.enque('misc', 'get_version')
+ except Exception:
+ jukebox_version = "unknown"
+
+ scr.addstr(f"Jukebox version: {jukebox_version}\n")
+ scr.addstr(f"Pyzmq version: {zmq.pyzmq_version()}; ZMQ version: {zmq.zmq_version()}; "
+ f"has draft API: {zmq.DRAFT_API}\n")
+ scr.addstr('-' * 70 + "\n")
scr.refresh()
- scr.refresh()
- history.append(msg)
- return msg
+ def _show_help(self, scr, topic: str = '') -> None:
+ """Display help information for commands"""
+ scr.erase()
+ self.update_help(scr)
+ max_y, max_x = scr.getmaxyx()
+ scr.addstr("Available commands:\n\n")
+
+ for key, value in self.state.rpc_help.items():
+ if not key.startswith(topic):
+ continue
+
+ sign: str = value['signature']
+ sign = sign[sign.find('('):]
+ func = f"{key}{sign}"
+ scr.addstr(f"{func:50}: {value['description']}\n")
+
+ # Handle pagination
+ y, x = scr.getyx()
+ if y == max_y - 1:
+ scr.addstr("--HIT A KEY TO CONTINUE--")
+ scr.getch()
+ scr.erase()
-def tonum(string_value):
- ret = string_value
- try:
- ret = int(string_value)
- except ValueError:
- pass
- else:
- return ret
- try:
- ret = float(string_value)
- except ValueError:
- pass
- else:
- return ret
- if string_value.isalnum() and string_value.startswith('0x'):
- try:
- ret = int(string_value, base=16)
- except ValueError:
- pass
- else:
- return ret
- return ret
-
-
-def main(scr):
- global candidates
- scr.idlok(True)
- scr.scrollok(True)
- format_welcome(scr)
- get_help(scr)
- format_usage(scr)
- cmd = ''
- while cmd != 'exit':
- cmd = get_input(scr)
scr.addstr("\n")
- # Split on whitespaces to separate cmd and arg list
- dec = [v for v in cmd.strip().split(' ') if len(v) > 0]
- if len(dec) == 0:
- continue
- elif dec[0] == 'help':
- topic = ''
- if len(dec) > 1:
- topic = dec[1]
- format_help(scr, topic)
- continue
- elif dec[0] == 'usage':
- format_usage(scr)
- continue
- # scr.addstr(f"\n{cmd}\n")
- # Split cmd on '.' into package.plugin.method
- # Remove duplicate '.' along the way
- sl = [v for v in dec[0].split('.') if len(v) > 0]
- fargs = [tonum(a) for a in dec[1:]]
- scr.addstr(f"\n:: Command = {sl}, args = {fargs}\n")
- response = None
- method = None
- if not (2 <= len(sl) <= 3):
- scr.addstr(":: Error = Ill-formatted command\n")
- continue
- if len(sl) == 3:
- method = sl[2]
- try:
- response = client.enque(sl[0], sl[1], method, args=fargs)
- except zmq.error.Again:
- scr.addstr("\n\n" + '-' * 70 + "\n")
- scr.addstr("Could not reach RPC Server. Jukebox running? Correct Port?\n")
- scr.addstr('-' * 70 + "\n\n")
- scr.refresh()
- except Exception as e:
- scr.addstr(f":: Exception response =\n{e}\n")
- else:
- scr.addstr(f"\n:: Response =\n{response}\n\n")
+ scr.refresh()
+
+ def _show_usage(self, scr) -> None:
+ """Display usage information and key bindings"""
+ scr.addstr("\n\nUsage:\n")
+ scr.addstr(" > cmd [arg1] [arg2] [kwarg1=value1]\n")
+ scr.addstr("Examples:\n")
+ scr.addstr(" > volume.ctrl.set_volume 50\n")
+ example = (
+ ' > player.ctrl.play_from_reader '
+ 'content={"albumartist": "Taylor Swift", "album": "Fearless"} '
+ 'content_type=album\n'
+ )
+ scr.addstr(example)
+ scr.addstr("\nSupported argument formats:\n")
+ scr.addstr(" - Simple values (strings, numbers)\n")
+ scr.addstr(" - Hexadecimal numbers (0x...)\n")
+ scr.addstr(" - JSON objects and arrays for keyword arguments\n")
+ scr.addstr("Note: JSON must be valid and properly quoted\n")
+ scr.addstr("\n")
+ scr.addstr("Use for auto-completion of commands!\n")
+ scr.addstr("Use / for command history!\n")
+ scr.addstr("\n")
+ scr.addstr("Type help , to get a list of all commands\n")
+ scr.addstr("Type usage , to get this usage help\n")
+ scr.addstr("\n")
+ scr.addstr("To exit, press Ctrl-D or type 'exit'\n")
+ scr.addstr("\n")
+ scr.refresh()
+ def _get_common_beginning(self, strings: List[str]) -> str:
+ """Find common prefix among a list of strings"""
+ if not strings:
+ return ""
-def runcmd(cmd):
- """
- Just run a command.
- Right now duplicates more or less main()
- :todo remove duplication of code
- """
-
- # Split on whitespaces to separate cmd and arg list
- dec = [v for v in cmd.strip().split(' ') if len(v) > 0]
- if len(dec) == 0:
- return
- # Split cmd on '.' into package.plugin.method
- # Remove duplicate '.' along the way
- sl = [v for v in dec[0].split('.') if len(v) > 0]
- fargs = [tonum(a) for a in dec[1:]]
- response = None
- method = None
- if not (2 <= len(sl) <= 3):
- print(":: Error = Ill-formatted command\n")
- return
- if len(sl) == 3:
- method = sl[2]
- try:
- response = client.enque(sl[0], sl[1], method, args=fargs)
- except zmq.error.Again:
- print("\n\n" + '-' * 70 + "\n")
- print("Could not reach RPC Server. Jukebox running? Correct Port?\n")
- print('-' * 70 + "\n\n")
- return
- except Exception as e:
- print(f":: Exception response =\n{e}\n")
- return
- else:
- print(f"\n:: Response =\n{response}\n\n")
+ result = []
+ limit = min(len(s) for s in strings)
+ for i in range(limit):
+ chars = set(s[i] for s in strings)
+ if len(chars) == 1:
+ result.append(chars.pop())
+ else:
+ break
+
+ return ''.join(result)
+
+ def _autocomplete(self, msg: str) -> Tuple[str, List[str]]:
+ """Handle command autocompletion"""
+ matches = [s for s in self.state.candidates if s.startswith(msg)]
+ if not matches:
+ return msg, matches
+
+ common = self._get_common_beginning(matches)
+ return common, matches
+
+ def _is_printable(self, ch: int) -> bool:
+ """Check if character is printable"""
+ return 32 <= ch <= 127
+
+ def _reprompt(self, scr, msg: str, y: int, x: int) -> None:
+ """Redraw prompt and message"""
+ scr.move(y, 0)
+ scr.clrtoeol()
+ scr.addstr(self.state.prompt)
+ scr.addstr(msg)
+ scr.move(y, x)
+
+ def _get_input(self, scr) -> str: # noqa: C901
+ """Handle user input with history and autocompletion"""
+ ch = 0
+ msg = ''
+ ihist = ''
+ hidx = len(self.state.history)
+
+ y, x = scr.getyx()
+ self._reprompt(scr, msg, y, len(self.state.prompt) + len(msg))
+ scr.refresh()
-if __name__ == '__main__':
+ while ch != ord(b'\n'):
+ try:
+ ch = scr.getch()
+ except KeyboardInterrupt:
+ return 'exit'
+
+ y, x = scr.getyx()
+ pos = x - len(self.state.prompt)
+
+ if ch == ord(b'\t'):
+ msg, matches = self._autocomplete(msg)
+ if len(matches) > 1:
+ scr.addstr('\n')
+ scr.addstr(', '.join(matches))
+ scr.addstr('\n')
+ scr.clrtobot()
+ self._reprompt(scr, msg, y, len(self.state.prompt) + len(msg))
+
+ elif ch == ord(b'\n'):
+ break
+ elif ch == 4: # Ctrl-D
+ return 'exit'
+ elif ch in (curses.KEY_BACKSPACE, 127):
+ if pos > 0:
+ scr.delch(y, x - 1)
+ msg = msg[0:pos - 1] + msg[pos:]
+ elif ch == curses.KEY_DC:
+ scr.delch(y, x)
+ msg = msg[0:pos] + msg[pos + 1:]
+ elif ch == curses.KEY_LEFT:
+ if pos > 0:
+ scr.move(y, x - 1)
+ elif ch == curses.KEY_RIGHT:
+ if pos < len(msg):
+ scr.move(y, x + 1)
+ elif ch == curses.KEY_HOME:
+ scr.move(y, len(self.state.prompt))
+ elif ch == curses.KEY_END:
+ scr.move(y, len(self.state.prompt) + len(msg))
+ elif ch == curses.KEY_UP:
+ if hidx == len(self.state.history):
+ ihist = msg
+ hidx = max(hidx - 1, 0)
+ msg = self.state.history[hidx]
+ self._reprompt(scr, msg, y, len(self.state.prompt) + len(msg))
+ elif ch == curses.KEY_DOWN:
+ hidx = min(hidx + 1, len(self.state.history))
+ msg = ihist if hidx == len(self.state.history) else self.state.history[hidx]
+ self._reprompt(scr, msg, y, len(self.state.prompt) + len(msg))
+ elif self._is_printable(ch):
+ msg = msg[0:pos] + curses.ascii.unctrl(ch) + msg[pos:]
+ self._reprompt(scr, msg, y, x + 1)
+
+ scr.refresh()
+
+ self.state.history.append(msg)
+ return msg
+
+
+def main():
+ """CLI entry point with argument parsing"""
default_tcp = 5555
default_ws = 5556
- url = f"tcp://localhost:{default_tcp}"
- argparser = argparse.ArgumentParser(description='The Jukebox RPC command line tool',
- epilog=f'Default connection: {url}')
- port_group = argparser.add_mutually_exclusive_group()
- port_group.add_argument("-w", "--websocket",
- help=f"Use websocket protocol on PORT [default: {default_ws}]",
- nargs='?', const=default_ws,
- metavar="PORT", default=None)
- port_group.add_argument("-t", "--tcp",
- help=f"Use tcp protocol on PORT [default: {default_tcp}]",
- nargs='?', const=default_tcp,
- metavar="PORT", default=None)
- port_group.add_argument("-c", "--command",
- help="Send command to Jukebox server",
- default=None)
- args = argparser.parse_args()
-
+ default_url = f"tcp://localhost:{default_tcp}"
+
+ parser = argparse.ArgumentParser(
+ description='The Jukebox RPC command line tool',
+ epilog=f'Default connection: {default_url}'
+ )
+
+ port_group = parser.add_mutually_exclusive_group()
+ port_group.add_argument(
+ "-w", "--websocket",
+ help=f"Use websocket protocol on PORT [default: {default_ws}]",
+ nargs='?', const=default_ws,
+ metavar="PORT", default=None
+ )
+ port_group.add_argument(
+ "-t", "--tcp",
+ help=f"Use tcp protocol on PORT [default: {default_tcp}]",
+ nargs='?', const=default_tcp,
+ metavar="PORT", default=None
+ )
+ port_group.add_argument(
+ "-c", "--command",
+ help="Send command to Jukebox server",
+ default=None
+ )
+
+ args = parser.parse_args()
+
+ url = default_url
if args.websocket is not None:
url = f"ws://localhost:{args.websocket}"
elif args.tcp is not None:
@@ -372,12 +491,17 @@ def runcmd(cmd):
print(f">>> RPC Client connect on {url}")
- client = rpc.RpcClient(url)
+ cli = JukeboxCli(url)
if args.command is not None:
- runcmd(args.command)
- exit(0)
+ # Handle single command execution
+ cli.execute_command(None, args.command)
else:
- curses.wrapper(main)
+ # Run interactive CLI
+ curses.wrapper(cli.run)
print(">>> RPC Client exited!")
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/webapp/public/locales/de/translation.json b/src/webapp/public/locales/de/translation.json
index 1c6729415..42046e7b6 100644
--- a/src/webapp/public/locales/de/translation.json
+++ b/src/webapp/public/locales/de/translation.json
@@ -27,6 +27,7 @@
"next_song": "Nächster Song",
"pause": "Pause",
"play": "Abspielen",
+ "play_content": "Inhalte abspielen",
"prev_song": "Vorheriger Song",
"shuffle": "Zufallswiedergabe",
"repeat": "Wiedergabe wiederholen",
@@ -40,7 +41,7 @@
"label": "Aktionen",
"placeholder": "Wähle eine Aktion aus",
"actions": {
- "play_music": "Musik abspielen",
+ "play_content": "Inhalte abspielen",
"audio": "Audio & Lautstärke",
"host": "System",
"timers": "Timer",
@@ -53,15 +54,15 @@
"label-full": "Gesamte Addresse (z.B. 192.168.1.53)",
"label-short": "Letzter Quadrant (z.B. 53)"
},
- "play-music": {
+ "play-content": {
"commands": {
- "play_album": "Ausgewähltes Album",
- "play_folder": "Ausgewählter Ordner",
- "play_single": "Ausgewählter Song"
+ "album": "Ausgewähltes Album",
+ "folder": "Ausgewählter Ordner",
+ "single": "Ausgewählter Song"
},
- "button-label": "Musik auswählen",
- "no-music-selected": "Es ist keine Musik ausgewählt.",
- "loading-song-error": "Während des Ladens des Songs ist ein Fehler aufgetreten."
+ "button-label": "Inhalt auswählen",
+ "no-music-selected": "Es sind keine Inhalte ausgewählt.",
+ "loading-song-error": "Während des Ladens des Inhalts ist ein Fehler aufgetreten."
},
"audio": {
"repeat": {
diff --git a/src/webapp/public/locales/en/translation.json b/src/webapp/public/locales/en/translation.json
index 20a28bdac..358b6b1ec 100644
--- a/src/webapp/public/locales/en/translation.json
+++ b/src/webapp/public/locales/en/translation.json
@@ -27,6 +27,7 @@
"next_song": "Next song",
"pause": "Pause",
"play": "Play",
+ "play_content": "Play content",
"prev_song": "Previous song",
"shuffle": "Shuffle",
"repeat": "Repeat",
@@ -40,7 +41,7 @@
"label": "Actions",
"placeholder": "Select an action",
"actions": {
- "play_music": "Play music",
+ "play_content": "Play content",
"audio": "Audio & Volume",
"host": "System",
"timers": "Timers",
@@ -53,15 +54,15 @@
"label-full": "Full address (e.g. 192.168.1.53)",
"label-short": "Last quadrant (e.g. 53)"
},
- "play-music": {
+ "play-content": {
"commands": {
- "play_album": "Selected album",
- "play_folder": "Selected folder",
- "play_single": "Selected song"
+ "album": "Selected album",
+ "folder": "Selected folder",
+ "single": "Selected song"
},
- "button-label": "Select music",
- "no-music-selected": "No music selected",
- "loading-song-error": "An error occurred while loading song."
+ "button-label": "Select content",
+ "no-music-selected": "No content selected",
+ "loading-song-error": "An error occurred while loading the content."
},
"audio": {
"repeat": {
diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js
index 1e984997e..ffbc01f03 100644
--- a/src/webapp/src/commands/index.js
+++ b/src/webapp/src/commands/index.js
@@ -59,23 +59,11 @@ const commands = {
plugin: 'ctrl',
method: 'play',
},
- play_single: {
+ play_content: {
_package: 'player',
plugin: 'ctrl',
- method: 'play_single',
- argKeys: ['song_url']
- },
- play_folder: {
- _package: 'player',
- plugin: 'ctrl',
- method: 'play_folder',
- argKeys: ['folder']
- },
- play_album: {
- _package: 'player',
- plugin: 'ctrl',
- method: 'play_album',
- argKeys: ['albumartist', 'album']
+ method: 'play_content',
+ argKeys: ['content', 'content_type', 'recursive']
},
pause: {
_package: 'player',
diff --git a/src/webapp/src/components/Cards/controls/actions-controls.js b/src/webapp/src/components/Cards/controls/actions-controls.js
index 90afb56ad..b1577f25b 100644
--- a/src/webapp/src/components/Cards/controls/actions-controls.js
+++ b/src/webapp/src/components/Cards/controls/actions-controls.js
@@ -10,8 +10,8 @@ import {
import CardsDeleteDialog from '../dialogs/delete';
import request from '../../../utils/request';
import {
+ cleanObject,
getActionAndCommand,
- getArgsValues
} from '../utils';
const ActionsControls = ({
@@ -24,14 +24,14 @@ const ActionsControls = ({
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const handleRegisterCard = async () => {
- const args = getArgsValues(actionData);
+ const { args } = actionData.command || {};
const { command: cmd_alias } = getActionAndCommand(actionData);
const kwargs = {
card_id: cardId.toString(),
- cmd_alias,
+ cmd_alias: cmd_alias === 'play_content' ? 'play_from_reader' : cmd_alias,
overwrite: true,
- ...(args.length && { args }),
+ args: cleanObject(args),
};
const { error } = await request('registerCard', kwargs);
diff --git a/src/webapp/src/components/Cards/controls/actions/play-music/index.js b/src/webapp/src/components/Cards/controls/actions/play-content/index.js
similarity index 69%
rename from src/webapp/src/components/Cards/controls/actions/play-music/index.js
rename to src/webapp/src/components/Cards/controls/actions/play-content/index.js
index 2e8d7332c..0e8fe8eae 100644
--- a/src/webapp/src/components/Cards/controls/actions/play-music/index.js
+++ b/src/webapp/src/components/Cards/controls/actions/play-content/index.js
@@ -20,17 +20,18 @@ import SelectedAlbum from './selected-album';
import SelectedFolder from './selected-folder';
import SelectedSingle from './selected-single';
-const SelectPlayMusic = ({
+const SelectPlayContent = ({
actionData,
cardId,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
- const { command } = getActionAndCommand(actionData);
+ const { content_type } = actionData.command.args || {};
+
const values = getArgsValues(actionData);
- const selectMusic = () => {
+ const selectContent = () => {
const searchParams = createSearchParams({
isSelecting: true,
cardId
@@ -44,30 +45,30 @@ const SelectPlayMusic = ({
return (
- {command &&
+ {content_type &&
- {t(`cards.controls.actions.play-music.commands.${command}`)}
+ {t(`cards.controls.actions.play-content.commands.${content_type}`)}
}
- {command === 'play_album' && }
- {command === 'play_folder' && }
- {command === 'play_single' && }
+ {content_type === 'album' && }
+ {content_type === 'folder' && }
+ {content_type === 'single' && }
}
>
- {t('cards.controls.actions.play-music.button-label')}
+ {t('cards.controls.actions.play-content.button-label')}
);
};
-export default SelectPlayMusic;
+export default SelectPlayContent;
diff --git a/src/webapp/src/components/Cards/controls/actions/play-music/no-music-selected.js b/src/webapp/src/components/Cards/controls/actions/play-content/no-music-selected.js
similarity index 80%
rename from src/webapp/src/components/Cards/controls/actions/play-music/no-music-selected.js
rename to src/webapp/src/components/Cards/controls/actions/play-content/no-music-selected.js
index 90821d151..ce9478f14 100644
--- a/src/webapp/src/components/Cards/controls/actions/play-music/no-music-selected.js
+++ b/src/webapp/src/components/Cards/controls/actions/play-content/no-music-selected.js
@@ -8,7 +8,7 @@ const NoMusicSelected = () => {
return (
- {t('cards.controls.actions.play-music.no-music-selected')}
+ {t('cards.controls.actions.play-content.no-music-selected')}
);
}
diff --git a/src/webapp/src/components/Cards/controls/actions/play-music/selected-album.js b/src/webapp/src/components/Cards/controls/actions/play-content/selected-album.js
similarity index 88%
rename from src/webapp/src/components/Cards/controls/actions/play-music/selected-album.js
rename to src/webapp/src/components/Cards/controls/actions/play-content/selected-album.js
index b0f5d2bc9..f7f323245 100644
--- a/src/webapp/src/components/Cards/controls/actions/play-music/selected-album.js
+++ b/src/webapp/src/components/Cards/controls/actions/play-content/selected-album.js
@@ -4,7 +4,7 @@ import { List } from '@mui/material';
import AlbumListItem from '../../../../Library/lists/albums/album-list/album-list-item'
import NoMusicSelected from './no-music-selected';
-const SelectedAlbum = ({ values: [albumartist, album] }) => {
+const SelectedAlbum = ({ values: [{ albumartist, album }] }) => {
if (albumartist && album) {
return (
diff --git a/src/webapp/src/components/Cards/controls/actions/play-music/selected-folder.js b/src/webapp/src/components/Cards/controls/actions/play-content/selected-folder.js
similarity index 100%
rename from src/webapp/src/components/Cards/controls/actions/play-music/selected-folder.js
rename to src/webapp/src/components/Cards/controls/actions/play-content/selected-folder.js
diff --git a/src/webapp/src/components/Cards/controls/actions/play-music/selected-single.js b/src/webapp/src/components/Cards/controls/actions/play-content/selected-single.js
similarity index 95%
rename from src/webapp/src/components/Cards/controls/actions/play-music/selected-single.js
rename to src/webapp/src/components/Cards/controls/actions/play-content/selected-single.js
index a7280c11e..a369e93b2 100644
--- a/src/webapp/src/components/Cards/controls/actions/play-music/selected-single.js
+++ b/src/webapp/src/components/Cards/controls/actions/play-content/selected-single.js
@@ -32,7 +32,7 @@ const SelectecSingle = ({ values: [song_url] }) => {
if (error) {
return (
- {t('cards.controls.actions.play-music.loading-song-error')}
+ {t('cards.controls.actions.play-content.loading-song-error')}
);
}
diff --git a/src/webapp/src/components/Cards/controls/controls-selector.js b/src/webapp/src/components/Cards/controls/controls-selector.js
index eea2d5c67..1295bcfe7 100644
--- a/src/webapp/src/components/Cards/controls/controls-selector.js
+++ b/src/webapp/src/components/Cards/controls/controls-selector.js
@@ -7,7 +7,7 @@ import {
} from '@mui/material';
import SelectCommandAliases from './select-command-aliases';
-import SelectPlayMusic from './actions/play-music';
+import SelectPlayContent from './actions/play-content';
import SelectTimers from './actions/timers';
import SelectAudio from './actions/audio';
import { buildActionData } from '../utils';
@@ -61,8 +61,8 @@ const ControlsSelector = ({
/>
}
- {actionData.action === 'play_music' &&
-
diff --git a/src/webapp/src/components/Cards/edit.js b/src/webapp/src/components/Cards/edit.js
index 6bcf11012..7ca5f803c 100644
--- a/src/webapp/src/components/Cards/edit.js
+++ b/src/webapp/src/components/Cards/edit.js
@@ -22,9 +22,11 @@ const CardsEdit = () => {
if (result && result[cardId]) {
const {
action: { args },
- from_alias: command
+ from_alias,
} = result[cardId];
+ const command = from_alias === 'play_from_reader' ? 'play_content' : from_alias;
+
const action = findActionByCommand(command);
const actionData = buildActionData(action, command, args);
diff --git a/src/webapp/src/components/Cards/list.js b/src/webapp/src/components/Cards/list.js
index 4e8211a27..020389b7f 100644
--- a/src/webapp/src/components/Cards/list.js
+++ b/src/webapp/src/components/Cards/list.js
@@ -13,6 +13,7 @@ import {
} from '@mui/material';
import BookmarkIcon from '@mui/icons-material/Bookmark';
+import { printObject } from '../../utils/utils';
const CardsList = ({ cardsList }) => {
const { t } = useTranslation();
@@ -28,10 +29,15 @@ const CardsList = ({ cardsList }) => {
return
});
- const description = cardsList[cardId].from_alias
+ const command = cardsList[cardId].from_alias === 'play_from_reader' ? 'play_content' : cardsList[cardId].from_alias;
+
+ const description = command
? reject(
isNil,
- [cardsList[cardId].from_alias, cardsList[cardId].action.args]
+ [
+ t(`cards.controls.command-selector.commands.${command}`),
+ printObject(cardsList[cardId].action.args)
+ ]
).join(', ')
: cardsList[cardId].func
diff --git a/src/webapp/src/components/Cards/register.js b/src/webapp/src/components/Cards/register.js
index c4d3d32f0..848acb9db 100644
--- a/src/webapp/src/components/Cards/register.js
+++ b/src/webapp/src/components/Cards/register.js
@@ -17,6 +17,7 @@ const CardsRegister = () => {
const [cardId, setCardId] = useState(undefined);
const [actionData, setActionData] = useState(registerCard?.actionData || {});
+ const [args, setArgs] = useState(registerCard?.args || {});
useEffect(() => {
setState(state => (omit(['rfid.card_id'], state)));
diff --git a/src/webapp/src/components/Cards/utils.js b/src/webapp/src/components/Cards/utils.js
index 17eaede6f..0202b783e 100644
--- a/src/webapp/src/components/Cards/utils.js
+++ b/src/webapp/src/components/Cards/utils.js
@@ -1,6 +1,8 @@
import {
isEmpty,
+ isNil,
has,
+ reject,
} from 'ramda';
import commands from '../../commands';
@@ -67,8 +69,11 @@ const getArgsValues = (actionData) => {
);
};
+const cleanObject = reject(isNil);
+
export {
buildActionData,
+ cleanObject,
findActionByCommand,
getActionAndCommand,
getArgsValues,
diff --git a/src/webapp/src/components/Library/lists/albums/song-list/index.js b/src/webapp/src/components/Library/lists/albums/song-list/index.js
index 006ab791b..9fb87a841 100644
--- a/src/webapp/src/components/Library/lists/albums/song-list/index.js
+++ b/src/webapp/src/components/Library/lists/albums/song-list/index.js
@@ -18,7 +18,7 @@ import SongListItem from './song-list-item';
const SongList = ({
isSelecting,
- registerMusicToCard,
+ registerContentToCard,
}) => {
const { t } = useTranslation();
const { artist, album } = useParams();
@@ -59,7 +59,7 @@ const SongList = ({
albumartist={decodeURIComponent(artist)}
disabled={songs.length === 0}
isSelecting={isSelecting}
- registerMusicToCard={registerMusicToCard}
+ registerContentToCard={registerContentToCard}
/>
)}
diff --git a/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js b/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js
index b2391819e..a3db788e0 100644
--- a/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js
+++ b/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js
@@ -14,18 +14,17 @@ const SongListControls = ({
albumartist,
album,
disabled,
- registerMusicToCard,
+ registerContentToCard,
isSelecting
}) => {
const { t } = useTranslation();
- const command = 'play_album';
const playAlbum = () => (
- request(command, { albumartist, album })
+ request('play_content', { content: { albumartist, album }, content_type: 'album' })
);
const registerAlbumToCard = () => (
- registerMusicToCard(command, { albumartist, album })
+ registerContentToCard('play_content', { content: { albumartist, album }, content_type: 'album' })
);
return (
diff --git a/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js b/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js
index 0f22d2df3..e9edea8eb 100644
--- a/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js
+++ b/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js
@@ -12,12 +12,11 @@ import request from '../../../../../utils/request'
const SongListItem = ({
isSelecting,
- registerMusicToCard,
+ registerContentToCard,
song,
}) => {
const { t } = useTranslation();
- const command = 'play_single';
const {
artist,
duration,
@@ -26,18 +25,18 @@ const SongListItem = ({
} = song;
const playSingle = () => {
- request(command, { song_url: file })
+ request('play_content', { content: file, content_type: 'single' })
}
- const registerSongToCard = () => (
- registerMusicToCard(command, { song_url: file })
+ const registerSingleToCard = () => (
+ registerContentToCard('play_content', { content: file, content_type: 'single' })
);
return (
(isSelecting ? registerSongToCard() : playSingle())}
+ onClick={() => (isSelecting ? registerSingleToCard() : playSingle())}
>
{
const { t } = useTranslation();
const { type, name, relpath } = folder;
const playItem = () => {
switch(type) {
- case 'directory': return request('play_folder', { folder: relpath, recursive: true });
- case 'file': return request('play_single', { song_url: relpath });
+ case 'directory': return request('play_content', { content: relpath, content_type: 'folder', recursive: true });
+ case 'file': request('play_content', { content: relpath, content_type: 'single' });
// TODO: Add missing Podcast
// TODO: Add missing Stream
default: return;
@@ -34,8 +34,8 @@ const FolderListItem = ({
const registerItemToCard = () => {
switch(type) {
- case 'directory': return registerMusicToCard('play_folder', { folder: relpath, recursive: true });
- case 'file': return registerMusicToCard('play_single', { song_url: relpath });
+ case 'directory': return registerContentToCard('play_content', { content: relpath, content_type: 'folder', recursive: true });
+ case 'file': return registerContentToCard('play_content', { content: relpath, content_type: 'single' });
// TODO: Add missing Podcast
// TODO: Add missing Stream
default: return;
diff --git a/src/webapp/src/components/Library/lists/folders/folder-list.js b/src/webapp/src/components/Library/lists/folders/folder-list.js
index 3222e4234..2c2f90086 100644
--- a/src/webapp/src/components/Library/lists/folders/folder-list.js
+++ b/src/webapp/src/components/Library/lists/folders/folder-list.js
@@ -17,7 +17,7 @@ const FolderList = ({
dir,
folders,
isSelecting,
- registerMusicToCard,
+ registerContentToCard,
}) => {
const { t } = useTranslation();
@@ -47,7 +47,7 @@ const FolderList = ({
key={key}
folder={folder}
isSelecting={isSelecting}
- registerMusicToCard={registerMusicToCard}
+ registerContentToCard={registerContentToCard}
/>
)}
diff --git a/src/webapp/src/components/Library/lists/folders/index.js b/src/webapp/src/components/Library/lists/folders/index.js
index fa0532589..ce279cebd 100644
--- a/src/webapp/src/components/Library/lists/folders/index.js
+++ b/src/webapp/src/components/Library/lists/folders/index.js
@@ -15,7 +15,7 @@ import { ROOT_DIR } from '../../../../config';
const Folders = ({
musicFilter,
isSelecting,
- registerMusicToCard,
+ registerContentToCard,
}) => {
const { t } = useTranslation();
const { dir = ROOT_DIR } = useParams();
@@ -60,7 +60,7 @@ const Folders = ({
dir={dir}
folders={filteredFolders}
isSelecting={isSelecting}
- registerMusicToCard={registerMusicToCard}
+ registerContentToCard={registerContentToCard}
/>
);
};
diff --git a/src/webapp/src/components/Library/lists/index.js b/src/webapp/src/components/Library/lists/index.js
index e2b7a2d46..7390eb451 100644
--- a/src/webapp/src/components/Library/lists/index.js
+++ b/src/webapp/src/components/Library/lists/index.js
@@ -32,8 +32,8 @@ const LibraryLists = () => {
setMusicFilter(event.target.value);
};
- const registerMusicToCard = (command, args) => {
- const actionData = buildActionData('play_music', command, args);
+ const registerContentToCard = (command, args) => {
+ const actionData = buildActionData('play_content', command, args);
const state = {
registerCard: {
actionData,
@@ -71,7 +71,7 @@ const LibraryLists = () => {
element={
}
exact
@@ -86,7 +86,7 @@ const LibraryLists = () => {
}
/>
diff --git a/src/webapp/src/config.js b/src/webapp/src/config.js
index 46a6ec1df..9d5f5d372 100644
--- a/src/webapp/src/config.js
+++ b/src/webapp/src/config.js
@@ -28,11 +28,9 @@ const ROOT_DIR = './';
const JUKEBOX_ACTIONS_MAP = {
// Command Aliases
// Player
- play_music: {
+ play_content: {
commands: {
- play_album: {},
- play_folder: {},
- play_single: {},
+ play_content: {},
}
},
diff --git a/src/webapp/src/utils/utils.js b/src/webapp/src/utils/utils.js
index 170a8994f..5b217e9fb 100644
--- a/src/webapp/src/utils/utils.js
+++ b/src/webapp/src/utils/utils.js
@@ -24,10 +24,21 @@ const flatByAlbum = (albumList, { albumartist, album }) => {
return [...albumList, ...list];
};
+const printObject = (obj) => {
+ return Object.entries(obj)
+ .map(([key, value]) => {
+ if (value && typeof value === 'object') {
+ return `${key}: ${printObject(value)}`;
+ }
+ return `${key}: ${value}`;
+ })
+ .join(', ');
+};
export {
flatByAlbum,
pluginIsLoaded,
+ printObject,
progressToTime,
timeToProgress,
toHHMMSS,