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' && } ); }; -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,