[
  {
    "path": ".gitignore",
    "content": "/venv/\n.idea\n/deploy.sh\n__pycache__/\n/build/\n/dist/\n/soco_cli.egg-info/\n*.pyc\n/README.md.orig.*\n/README.md.toc.*\n/exploration\n/audio_files\n/examples\n/.mypy_cache\n/.pytest_cache\n"
  },
  {
    "path": "CHANGELOG.txt",
    "content": "v0.4.86   - Add 'async_' prefix support for HTTP API Server macros\n          - Allow multiple sharelinks in a single 'add_sharelink_to_queue' action\n          - Allow multiple sharelinks in a single 'play_sharelink' action;\n            add optional 'position' argument\nv0.4.85   - Add 'alarms_spec' and 'alarms_spec_zone' actions to list alarms in\n            alarm spec format for easy copy/paste into 'modify_alarm'/'add_alarm'\nv0.4.84   - Interrupt command sequence on break (fixes #35)\n          - Add support for adding & removing surround sound satellite speakers\n          - Add requirement for SoCo v0.31.0\nv0.4.83   - Fix tab completion in interactive mode for Python 3.10+\n          - Fix bug when checking for .soco-cli directory\nv0.4.82   - Add 'redirect_io' boolean option to API 'run_command()' to\n            prevent capture of stdout and stderr (fixes #85)\nv0.4.81   - Eliminate/alias inconsistent use of underscore in command line\n            option names (#84)\nv0.4.80   - Minor fixes & bump SoCo version requirement\nv0.4.79   - Add 'list_audio_files' path to HTTP API Server\nv0.4.78   - Allow pass-through of '_end_on_pause' via HTTP API\n          - HTTP API server: new 'async' operation will cancel an existing,\n            running one\nv0.4.77   - Changes to 'play_file' speaker reachability fallback logic\n          - Add support for HTTP API Server 'async_' actions\nv0.4.76   - Add 'play_sharelink' action\nv0.4.75   - Use dual-stage approach for determining 'play_file' server IP address\nv0.4.74   - Determine HTTP server IP to use for 'play_file' using target speaker\n            reachability\nv0.4.73   - Remove dependency on distutils.version.StrictVersion for Python 3.12\n            compatibility\nv0.4.72   - Workaround for https://github.com/SoCo/SoCo/issues/950\nv0.4.71   - Fix for https://github.com/avantrec/soco-cli/issues/64\nv0.4.70   - Add 'is_not_coordinator' conditional action modifier\nv0.4.69   - Add 'strict' option for all music library searches\n          - Consolidate queueing search results under action\n            'queue_search_results'. Earlier, equivalent actions remain but are\n            now undocumented\nv0.4.68   - Add 'strict' option to 'search_artists'\n          - Add 'queue_multiple_search_results' action\nv0.4.67   - Multiple artist search results from 'search_artists' are now saved\nv0.4.66   - '_all_' now targets all visible speakers (including non-coordinators)\nv0.4.65   - Add 'if_queue/if_no_queue' conditional action modifiers\nv0.4.64   - Add 'multi_group/mg' action\nv0.4.63   - Add 'if_coordinator' conditional action modifier\n          - Add 'relative_sub_gain/rel_sub_gain/rsb' action\n          - Add 'rel_bass' and 'rel_treble' synonyms\nv0.4.62   - Use Sonos Favourites instead of URIs when creating alarms\nv0.4.61   - Add 'set_queue_position' action\nv0.4.60   - Add 'generic' HTTP macro and increase number of args to 12\n          - Fix remove_playlist / delete_playlist actions\nv0.4.59   - Add queue position option for 'add_sharelink_to_queue'\nv0.4.58   - Add 'stop_all' action\nv0.4.57   - Defect fix for 'if_stopped/if_playing'\nv0.4.56   - Suppress switch-to-coordinator for 'if_stopped/if_playing'\nv0.4.55   - Display \"Sonos Chime\" for default alarm\n          - Add 'playing_tv' action\nv0.4.54   - Display URIs for alarms that don't have Title metadata\nv0.4.53   - Update for SoCo v0.29.0\nv0.4.52   - Add 'last' option to 'play_from_queue' action\n          - Add 'random' option to 'play_from_queue' action\n          - Add 'last_added' option to 'play_from_queue' action\nv0.4.51   - Constrain ifaddr package to 0.1.7 for Python < 3.7\n          - Allow URL/Path parameters in HTTP API Server (fixes #38)\nv0.4.50   - Update to SoCo v0.28.0; pin SoCo to v0.27.1 for Python < 3.6\nv0.4.49   - Maintenance update\nv0.4.48   - Add 'sub_gain' action\n          - Add 'surround_volume_tv' action\n          - Add 'surround_volume_music' action\n          - Add 'surround_full_volume_enabled' action\nv0.4.47   - Improvements to 'cue_line_in' + maintenance updates\n          - Update to SoCo v0.26.4 fixes regression in obtaining track titles\n            when playing from local libraries\nv0.4.46   - Add 'cue_line_in' action\n          - Enable use of 'wait' and 'wait_until' with speaker names\n            and 'if_stopped/if_playing' tests\nv0.4.45   - Allow use of local speaker cache with HTTP API server\nv0.4.44   - Maintenance update\nv0.4.43   - HTTP API server: Expand number of macro arguments to nine\n          - HTTP API server: populate OpenAPI doc metadata\nv0.4.42   - Allow use of '_' to supply arguments to be ignored during\n            HTTP API server macro processing\nv0.4.41   - Add 'ugaig' synonym for 'ungroup_all_in_group'\n          - Fix typing issue with Python < 3.8 in HTTP API server\nv0.4.40   - Add '/macros/reload' operation to HTTP API server\n          - Add 'ungroup_all_in_group' action\nv0.4.39   - Maintenance release\nv0.4.38   - Allow parameterisation of HTTP API server macros\nv0.4.37   - Add `group_volume_equalise` action\nv0.4.36   - Document support for Apple Music share links\n          - Add 'macros' URL path to the HTTP API server\nv0.4.35   - Add 'mic_enabled' action\nv0.4.34   - Add macros capability to the HTTP API server\nv0.4.33   - Add 'tv_audio_delay' action\n          - Add 'alarms_zone' action to list alarms for target speaker only\nv0.4.32   - Allow 'copy_modify_alarm' to copy a modified alarm to a\n            different target speaker\nv0.4.31   - Add 'copy_modify_alarm' action\nv0.4.30   - Allow the use of 'loop' actions in interactive shell aliases\nv0.4.29   - Maintenance release\nv0.4.28   - Add 'audio_format' action for soundbars\nv0.4.27   - Maintenance release\nv0.4.26   - Add subwoofer/surround speaker status and control actions\nv0.4.25   - Add 'switch_to_tv' action\nv0.4.24   - Allow use of the 'loop' actions in interactive mode\nv0.4.23   - Add '/speakers' path to the HTTP API server\nv0.4.22   - Improve output of 'sleep_timer' action\nv0.4.21   - Add 'play_directory' action & notes about CD playback on macOS\nv0.4.20   - Add 'AIFF' to supported local file types, for direct CD playback\n            on macOS\nv0.4.19   - Add 'reboot_count' action\n          - Add type annotations to the API calls\nv0.4.18   - Add support for Deezer share links (require SoCo v0.24)\nv0.4.17   - Add '_end_on_pause_' option for 'play_file' action\nv0.4.16   - Improve behaviour of CTRL-C in single keystroke, interactive mode\n          - Find 'album_art' URIs in broader range of cases\nv0.4.15   - Actions 'play_file' and 'play_m3u' can now be cancelled in the\n            interactive shell without exiting the shell\n          - Change behaviour of CTRL-C in the shell: now requires 'exit'\nv0.4.14   - Improve signal handling (behaviour and outputs for CTRL-C, etc.)\nv0.4.13   - All 'wait' actions can now be cancelled in the interactive shell\n            using CTRL-C, without exiting interactive mode\n          - Interactive shell commands which run in subprocesses now do so in\n            OS shell environments. Hence, commands like 'tf > tracks.txt' now\n            work in interactive mode\nv0.4.12   - Display Plex track data in the output of 'list_queue'\n          - Add 'is_indexing' action\nv0.4.11   - Internal improvements\n          - Action 'list_queue' now returns silently if the queue is empty\nv0.4.10   - Fix regression: removal of 'Title' in 'track' output in some cases\nv0.4.9    - Add 'Radio Show' details to output of 'track', etc.\n          - 'wait_end_track' now detects a change of radio show\nv0.4.8    - Bugfixes and minor cosmetic changes\nv0.4.7    - Improve output of 'track' and related actions, including support\n            for Audible audio book content.\nv0.4.6    - Add 'track_follow_compact' action, and 'tf' and 'tfc' synonyms.\nv0.4.5    - Add podcast information to 'track' output\n          - Improve 'album_art': now returns a URL in more cases\n            (e.g., Spotify).\n          - Minor tidy up to the output of 'track'\nv0.4.4    - Add 'add_sharelink_to_queue/sharelink' action for Spotify/Tidal\n          - Add 'end_session' action\n          - Add 'get_channel' action\nv0.4.3    - Add 'get_uri' action\n          - Add support for 'USE_LOCAL_SPKR_CACHE' env. variable\nv0.4.2    - Rename 'sonos-http-server' to 'sonos-http-api-server'\n          - Additional HTTP API server logging\nv0.4.1    - Minor changes to HTTP API server\nv0.4.0    - Add HTTP API server functionality\nv0.3.50   - Minor cosmetic improvements & bugfixes\nv0.3.49   - Minor improvements to 'track_follow'\nv0.3.48   - Bugfixes\nv0.3.47   - Interactive mode 'track_follow' now runs in a subprocess\nv0.3.46   - Improve output of 'track_follow'\nv0.3.45   - Add 'track_follow' action outside interactive mode\nv0.3.44   - Add 'track_follow' command to the interactive shell\nv0.3.43   - Add support for imported local library playlists\nv0.3.42   - Minor bugfixes only\nv0.3.41   - Add 'relative_bass' and 'relative_treble' actions\nv0.3.40   - Simplify 'snooze_alarm' action (backward-compatible)\nv0.3.39   - Add 'snooze_alarm' action\nv0.3.38   - Add '--check_for_update' option\nv0.3.37   - Actions 'info' and 'sysinfo' now report correct playback state\n            for slave speakers\n          - Upgrade to SoCo v0.22.0, allowing some code simplification\nv0.3.36   - Fix regression in exit code; send error messages to stderr\n          - Action 'copy_alarm' now returns the ID of the copy\nv0.3.35   - Add 'move_alarm' action\n          - Remove 'alarm(s)_enabled' action ...\n          - Replace with 'enable_alarm(s)' and 'disable_alarm(s)' actions\nv0.3.34   - Add 'copy_alarm' action\n          - Reorder columns in output of 'alarms' to match the sequencing\n            used in 'create_alarm' and 'modify_alarm'\nv0.3.33   - Rename 'enable_alarm(s)' actions to 'alarm(s)_enabled'\n            (Original action names will still work, at least for now)\n          - Add 'list_alarms' synonym for 'alarms' action\n          - Add 'modify_alarm(s)' action\nv0.3.32   - Add 'exec' and 'cd' commands to the interactive shell\n          - Add '--subnets' option to 'sonos-discover', to specify\n            which IP addresses / subnets to search\nv0.3.31   - Add 'create_alarm/add_alarm' actions\n          - Add 'remove_alarm(s)' action to remove alarms by ID\n          - Add 'enable_alarm(s)' action to enable/disable alarms\n          - Include alarm ID in output from 'alarms' action\nv0.3.30   - Fixes #18 (exception when no speakers are discovered in cached mode)\nv0.3.29   - Cosmetic change to multi-line output (fixes a regression)\nv0.3.28   - Minor bugfixes\nv0.3.27   - Minor bugfixes\nv0.3.26   - Alias processing bugfixes\nv0.3.25   - Add argument substitution (%1, %2, etc.) to aliases\n            Note that the use of '_' to suppress arguments is now deprecated\n          - Add 'docs' command to shell\nv0.3.24   - Add 'available_actions' action\n          - Add 'wait_end_track' action\nv0.3.23   - Bugfix: Remove spurious newline when action returns no value (#17)\nv0.3.22   - Multiple sequential actions will now attempt to proceed in the\n            event of an action in the sequence failing\nv0.3.21   - Bugfix: correct issue where shell aliases can't be deleted\nv0.3.20   - Add the ability to save, load, and overwrite shell aliases from\n            text files\nv0.3.19   - Add \"_\" option to suppress pass-thru parameters in shell aliases\n          - Add single keystroke shell support for Windows\n          - Add 'playpause' as synonym for 'pauseplay'\nv0.3.18   - Add 'pauseplay' action\n          - Improve the prompt in Shell single keystroke mode\nv0.3.17   - Add 'version' command to the Shell\n          - Fix regression in 'remove_from_queue'\n          - Add 'groupstatus' action\nv0.3.16   - Selected actions targeted at a non-coordinator speaker in a\n            group are now diverted to the coordinator instead of returning\n            an error\nv0.3.15   - Add 'album_art' action\nv0.3.14   - Bugfixes to API and alias loop detection\nv0.3.13   - Shell aliases now accept parameters\n          - Network scan options now respected in normal discovery mode\nv0.3.12   - Bugfix for 'play_fav_radio_station_no' picking the wrong\n            station (#12).\nv0.3.11   - Allow aliases to include other aliases, with loop detection\n          - Add 'single keystroke' mode to the shell. (Not supported on\n            Windows.)\nv0.3.10   - Shell aliases can now be used for shell commands (except\n            alias!)\n          - Add 'push' and 'pop' shell commands to save / restore the\n            active speaker\n          - Shell and API bugfixes\nv0.3.9    - Add alias capability to the shell\nv0.3.8    - Shell history is now saved across shell sessions in\n            ~/.soco-cli/shell-history.txt\n          - Add 'play_fav_radio_station_no' action\n          - Shell now supports ' : ' for multiple actions, and 'wait' actions\nv0.3.7    - Fix 'readline' import error on Windows and add shell warning\nv0.3.6    - Add auto-completion for Interactive Shell commands\n          - Various shell improvements\nv0.3.5    - Interactive mode: quickly select speaker by number\nv0.3.4    - Fix refactoring bug\nv0.3.3    - Significantly improved Interactive Mode\n          - Bugfix for numbering issue in 'play_favourite_number'\n          - API change / bugfix for 'get_soco_object()'\nv0.3.2    - Interactive mode: add ability to set/unset active speaker\n          - Add 'play_favourite_number' action to play a favourite by its\n            number\n          - Remove API type hints for backward compatibility; bugfixes\nv0.3.1    - API interface change/expansion, and bugfix for local cache\nv0.3.0    - Add an API allowing the use of SoCo-CLI as a Python library\n          - Add early version of interactive mode\n          - Add the ability to get the speaker name from the $SPKR environment\n            variable\n          - Add 'wait_stopped_for_not_pause' action\nv0.2.1    - Performance improvement for speaker discovery with partial name\n            match\n          - Action 'play_m3u' now accepts files that contain any list of audio\n            filenames, without requiring M3U/M3U8 conventions\nv0.2.0    - Requires SoCo v0.21, and benefits from its big improvements\n          - If a speaker name is not found, discovery will now fall back to\n            scanning the network for a matching speaker\n          - Partial, case insensitive matches can now be used for speaker\n            naming when using normal discovery\n          - Supplying, ambiguous partial speaker names now results in an error\n          - 'Alternative Discovery' is now referred to as 'Cached Discovery'\n          - Add 'buttons' action to inspect/change whether speaker buttons\n            are enabled\n          - Add 'fixed_volume' action to inspect/change whether the Fixed\n            Volume feature is enabled (applies to Connect and Port)\n          - Add 'trueplay' action to inspect/change whether a Trueplay\n            tuning profile is enabled\nv0.1.54   - Add 'wait_stop_not_pause\" action\n          - Action 'play_file' now accepts multiple files as parameters\nv0.1.53   - Action 'play_file' now supported back to Python 3.5\n          - Add simple 'interactive mode' option to 'play_m3u' action, allowing\n            'next track', 'pause', and 'resume' while playing a playlist\nv0.1.52   - Bugfix only\nv0.1.51   - Add queue position options to 'add_uri_to_queue'\n          - Restore Python 3.5+ compatibility\nv0.1.50   - Action 'play_file' can now be paused without terminating the server\nv0.1.49   - Minor fixes & update docs re: AAC playback issues\nv0.1.48   - Add 'r' option to 'play_m3u' to play a single, random track\n          - Add support for 'm3u8' playlist files\n          - Album art now displayed when using 'play_file'\n          - Add WMA file support for 'play_file'\n          - Add AAC file support for 'play_file' (with issues)\nv0.1.47   - Add 'play_m3u' action to play local M3U playlists\nv0.1.46   - Fix behaviour of 'play_uri' when playing file URLs\n          - Add support for M4A and MP4 playback using 'play_file'\n          - Add support for seeking within a track when using 'play_file'\nv0.1.45   - Add 'play_file' action for playback of local audio files\n            (Experimental: currently works for MP3, FLAC, OGG and WAV files)\nv0.1.44   - Simplify output format of 'zones' (etc.)\n          - Add 'first/start' option for various queue actions\n          - Add 'rename' action to rename speakers\n          - Add '--actions' option (same as '--commands')\nv0.1.43   - Add '_all_' option instead of targeting a named speaker\n          - Simplify the output of the 'groups' action\n          - Add 'commands' option to sonos, to print the list of available\n            commands\nv0.1.42   - Patch SoCo to provide full Python 3.9 support (until SoCo 0.21)\n          - Add 'battery' action to print battery status for Sonos Move speakers\nv0.1.41   - Improve time accuracy in 'wait_stopped_for'\n          - Improve playback state detection in 'wait_stopped_for'\n          - Further evolution of 'track' output for streams\n          - Add 'first/start' option to 'queue_search_number'\n          - Save search results when using 'list_playlist_tracks'\nv0.1.40   - Add 'seek_forward' action to jump ahead within a track\n          - Add 'seek_back' action to jump back within a track\n          - Action 'seek' now supports more flexible time formats\n          - Add 'seek_to' synonym for 'seek'\n          - Improve 'track' output when reporting a stopped stream\n          - Add 'min_netmask' option for alternative discovery\n          - Improve network selection logic when using alternative discovery\n          - Improve network timeout logic when using alternative discovery\nv0.1.39   - Added 'search_album', 'search_artist', search_track' synonyms\n          - Fix WARN(ING) setting for --log option\n          - Require SoCo >= 0.20\nv0.1.38   - Add search caching and indexed playback for 'tracks_in_album'\n          - Add search caching and indexed playback for 'list_albums'\n          - Add search caching and indexed playback for 'search_artists'\n          - Add '--docs' option to print URL to online documentation\n          - Add 'soco-discover' synonym for 'sonos-discover'\n          - Add line_in 'right_input' parameter for stereo paired P:5/Fives\n          - Behaviour change: Line-In starts playback after being selected\nv0.1.37   - Fix 'play_favourite_radio_station'\n          - Improve output from 'track' action for non-queue items\n          - Add 'cue_favourite_radio_station' action\nv0.1.36   - Add 'last_search' action to cache track and album searches\n          - Add 'queue_last_search_number' action\nv0.1.35   - Add 'queue_position' action\n          - Add 'play_next' option to 'queue_track' and 'queue_album'\n          - Add 'play_next' option for 'add_playlist_to_queue'\n          - Add 'play_next' option for 'add_favourite_to_queue'\nv0.1.34   - Add 'fade' synonym for 'cross_fade'\n          - Add 'remove_current_track_from_queue' action\n          - Add 'remove_last_track_from_queue' action\nv0.1.33   - Add 'none' as a synonym for 'off', in the 'repeat' action\n          - Add the ability to use sequences and ranges with 'remove_from_queue'\nv0.1.32   - Add 'shuffle' action for direct inspection and control of shuffle\n            mode\n          - Add 'repeat' action for direct inspection and control of repeat mode\nv0.1.31   - Add 'transfer_to' synonym for 'transfer_playback'\n          - Add 'create_playlist_from_queue' synonym for 'save_queue'\n          - Remove erroneous printout in 'tracks_in_albums'\nv0.1.30   - Added 'SHUFFLE_REPEAT_ONE' playback mode\n          - Add 'transfer_playback' action\nv0.1.29   - Updated logic fix for 'wait_stopped_for'\n          - Add 'status' synomym for 'playback'\nv0.1.28   - Add 'cue_favourite' action\nv0.1.27   - Add 'wait_for' synonym for 'wait'\n          - Fix minor timer expiry logic issue in 'wait_stopped_for'\n          - Improve SoCo version check\n          - Improve some error messages regarding use of ':'\nv0.1.26   - Add 'queue_track' action\n          - Add 'list_queue <track_number>' action\nv0.1.25   - Add music library functions: 'list_artists', 'list_albums',\n            'search_library', 'search_artists', 'search_albums'\n            'search_tracks', 'tracks_in_albums', 'queue_album'\n          - sonos-discover behaviour change: '-p' now prints the current speaker\n            data then exits, and '-s' has been removed.\nv0.1.24   - Add 'loop_to_start' action\n          - Allow CTRL-C to break out of 'wait_stopped_for' state on\n            Windows (SIGTERM)\n          - Add 'soco' synonym for 'sonos' command\n          - Fix loop counting defect\nv0.1.23   - Add conditional modifiers 'if_playing' and 'if_stopped'\n          _ Add actions 'loop_for' and 'loop_until'\n          - Reintroduce SIGKILL workaround for non-Windows platforms\nv0.1.22_1 - Revert use of SIGKILL (was preventing running on Windows)\nv0.1.22   - Add 'wait_stopped_for' action (experimental)\n          - Add 'loop' and 'loop <iterations>' actions (experimental)\n          - Fix 100 track display limit on 'list_playlist_tracks'\nv0.1.21   - Add 'rfq' synonym for 'remove_from_queue'\n          - Added 'wait_start' and 'wait_stop' actions\nv0.1.20   - Add 'list_all_playlist_tracks' action\nv0.1.19   - Exact speaker name matching is now case sensitive\n          - Additional logging\n          - Add 'list_playlist_tracks' action\nv0.1.18   - Add README notes on what sources can be played back\n          - 'add_playlist_to_queue' now returns the first track queue position\n          - Experimental support for 'add_fav_to_queue', with some issues\n          - Fix issue with WARN-level logging enabled by default\nv0.1.17_1 - Remove backport requirement (was breaking Windows installs)\nv0.1.17   - Added 'wait_until' action\n          - Fix for Python 3.7 requirement\nv0.1.16   - Add ability to cancel sleep timers\n          - Add the 'sleep_at' action to schedule a sleep timer\n          - Allow 'wait', 'sleep', to use HH:MM:SS format for durations\n          - Miscellaneous minor fixes\nv0.1.15   - Improve sleep timer action to allow durations in h/m/s\n          - Initial logging capability\nv0.1.14   - Improved, faster discovery for local speaker list\n          - Add 'libraries' action\n          - Add 'sysinfo' action\nv0.1.13   - Change to local speaker list file contents. Old speaker data files\n            will be removed and rediscovery will be required.\n          - Simple SIGINT handling added.\n          - Added '-v' option to sonos-discover\n          - 'sonos-discover -s' prints Sonos software version of each speaker\n          - Add 'alarms' action to list Sonos alarms\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "include requirements.txt\n"
  },
  {
    "path": "Makefile",
    "content": ".DEFAULT_GOAL := no_op\n\nSRC = setup.py soco_cli/*.py\nTESTS = tests/*.py\nMANIFEST = LICENSE README.md PYPI_README.md MANIFEST.in requirements.txt\nBUILD_DIST = build dist soco_cli.egg-info\nPYCACHE = soco_cli/__pycache__ tests/__pycache__ __pycache__\nTOC = README.md.*\n\nbuild: $(SRC) $(MANIFEST)\n\tpython -m build\n\nclean:\n\trm -rf $(BUILD_DIST) $(PYCACHE) $(TOC)\n\ninstall: build\n\tpip install -U -e .\n\nuninstall:\n\tpip uninstall -y soco_cli\n\nblack: $(SRC)\n\tblack --preview --target-version=py37 $(SRC) $(TESTS)\n\nisort: $(SRC)\n\tisort --profile black $(SRC) $(TESTS)\n\nformat: isort black\n\nmypy: $(SRC) $(TESTS)\n\tmypy $(SRC) $(TESTS)\n\npypi_upload: clean build\n\tpython -m twine upload --repository pypi dist/*\n\npypi_test: clean build\n\tpython -m twine upload --repository testpypi dist/*\n\npypi_check: build\n\ttwine check dist/*\n\ntoc:\n\t./gh-md-toc --insert README.md\n\nupdate:\n\tpip install -U -r requirements.txt -r requirements-dev.txt\n\nno_op:\n\t# Available targets are: build, clean, install, uninstall, black, pypi_upload, pypi_check\n"
  },
  {
    "path": "PYPI_README.md",
    "content": "\n# SoCo-CLI: Control Sonos from the Command Line\n\n## Overview\n\nSoCo-CLI is a powerful command line wrapper for the popular Python SoCo library [1], for controlling Sonos systems. SoCo-CLI is written entirely in Python and is portable across platforms.\n\nA simple `sonos` command provides easy control over a huge range of speaker functions, including playback, volume, groups, EQ settings, sleep timers, alarms, speaker settings, the playback queue, etc. Multiple commands can be run in sequence, including the ability to insert delays between commands, to wait for speakers to stop or start playing, and to create repeated action sequences using loops. Audio files from the local filesystem can be played directly on Sonos.\n\nSoCo-CLI has an orderly command structure and consistent return values, making it suitable for use in automated scripts, `cron` jobs, etc.\n\nFor interactive command line use, SoCo-CLI provides a powerful **Interactive Shell Mode** that improves speed of operation and reduces typing.\n\nSoCo-CLI can be imported as a streamlined, high-level **API** library by other Python programs, and acts as an intermediate abstraction layer between the client program and the underlying SoCo library, simplifying the use of SoCo.\n\nSoCo-CLI can also run as a simple **HTTP API server**, providing access to a huge range of actions via simple HTTP requests. (Requires Python 3.6 or above.)\n\n## Supported Environments\n\n- Requires Python 3.5+. (The HTTP API Server functionality requires Python 3.6 or above.)\n- Runs on all platforms supported by Python. Tested on various versions of Linux, macOS and Windows.\n- Works with Sonos 'S1' and 'S2' systems, as well as split S1/S2 systems.\n\n## Installation\n\nInstall from PyPI using **`pip install soco-cli`**.\n\n## User Guide\n\nThe installer adds the `sonos` command to the PATH. All commands have the form:\n\n```\nsonos SPEAKER ACTION <parameters>\n```\n\n- `SPEAKER` identifies the speaker, and can be the speaker's Sonos Room name or its IPv4 address in dotted decimal format. Note that the speaker name is case sensitive (unless using alternative discovery, discussed in the full documentation).\n- `ACTION` is the operation to perform on the speaker. It can take zero or more parameters depending on the operation.\n\nActions that make changes to speakers do not generally provide return values. Instead, the program exit code can be inspected to test for successful operation (exit code 0). If an error is encountered, an error message will be printed to `stderr`, and the program will return a non-zero exit code.\n\n### Simple Usage Examples:\n\n- **`sonos \"Living Room\" volume`** Returns the current volume setting of the *Living Room* speaker.\n- **`sonos Study volume 25`** Sets the volume of the *Study* speaker to 25.\n- **`sonos Study group Kitchen`** Groups the *Study* speaker with the *Kitchen* speaker.\n- **`sonos 192.168.0.10 mute`** Returns the mute state ('on' or 'off') of the speaker at the given IP address.\n- **`sonos 192.168.0.10 mute on`** Mutes the speaker at the given IP address.\n- **`sonos Kitchen play_favourite Jazz24 : wait 30m : Kitchen stop`** Plays 'Jazz24' for 30 minutes, then stops playback.\n\nPlease see [https://github.com/avantrec/soco-cli](https://github.com/avantrec/soco-cli) for full documentation.\n\n## Links\n\n[1] https://github.com/SoCo/SoCo\n\n## Acknowledgments\n\nAll trademarks acknowledged. Avantrec Ltd has no connection with Sonos Inc."
  },
  {
    "path": "README.md",
    "content": "# SoCo-CLI: Control Sonos from the Command Line\n\n<!--ts-->\n* [SoCo-CLI: Control Sonos from the Command Line](#soco-cli-control-sonos-from-the-command-line)\n   * [Overview](#overview)\n   * [Supported Environments](#supported-environments)\n   * [Installation](#installation)\n   * [User Guide](#user-guide)\n      * [The sonos Command](#the-sonos-command)\n      * [Speaker Discovery by Name](#speaker-discovery-by-name)\n      * [Simple Usage Examples](#simple-usage-examples)\n      * [The SPKR Environment Variable](#the-spkr-environment-variable)\n      * [Using Shell Aliases](#using-shell-aliases)\n      * [Options for the sonos Command](#options-for-the-sonos-command)\n      * [Firewall Rules](#firewall-rules)\n      * [Operating on All Speakers: Using _all_](#operating-on-all-speakers-using-_all_)\n      * [Redirection of Actions to Coordinator Devices](#redirection-of-actions-to-coordinator-devices)\n   * [Guidelines on Playing Content](#guidelines-on-playing-content)\n      * [Radio Stations](#radio-stations)\n      * [Single Tracks](#single-tracks)\n      * [Albums and Playlists](#albums-and-playlists)\n      * [Audio Files on the Local Filesystem](#audio-files-on-the-local-filesystem)\n      * [Local Playlists (M3U Files)](#local-playlists-m3u-files)\n      * [Directories of Audio Files](#directories-of-audio-files)\n      * [Spotify, Tidal, Deezer, and Apple Music Share Links](#spotify-tidal-deezer-and-apple-music-share-links)\n   * [Complete List of Available Actions](#complete-list-of-available-actions)\n      * [Volume and EQ Control](#volume-and-eq-control)\n      * [Playback Control](#playback-control)\n      * [Queue Actions](#queue-actions)\n      * [Favourites and Playlists](#favourites-and-playlists)\n      * [TuneIn Radio Station Favourites](#tunein-radio-station-favourites)\n      * [Grouping, Stereo Pairing, and Surround (Satellite) Speakers](#grouping-stereo-pairing-and-surround-satellite-speakers)\n      * [Alarms](#alarms)\n      * [Music Library Search Functions](#music-library-search-functions)\n      * [Speaker and Sonos System Information](#speaker-and-sonos-system-information)\n   * [Multiple Sequential Commands](#multiple-sequential-commands)\n      * [Chaining Commands Using the : Separator](#chaining-commands-using-the--separator)\n      * [Inserting Delays: wait and wait_until](#inserting-delays-wait-and-wait_until)\n      * [Waiting Until Playback has Started/Stopped: wait_start, wait_stop and wait_end_track](#waiting-until-playback-has-startedstopped-wait_start-wait_stop-and-wait_end_track)\n      * [The wait_stopped_for &lt;duration&gt; Action](#the-wait_stopped_for-duration-action)\n      * [Repeating Commands: The loop Actions](#repeating-commands-the-loop-actions)\n   * [Conditional Command Execution](#conditional-command-execution)\n   * [Interactive Shell Mode](#interactive-shell-mode)\n      * [Description](#description)\n      * [Usage](#usage)\n      * [Shell History and Auto-Completion](#shell-history-and-auto-completion)\n      * [Shell Aliases](#shell-aliases)\n         * [Push and Pop](#push-and-pop)\n         * [Alias Subroutines](#alias-subroutines)\n         * [Alias Arguments](#alias-arguments)\n         * [Saving and Loading Aliases](#saving-and-loading-aliases)\n      * [Single Keystroke Mode](#single-keystroke-mode)\n   * [Cached Discovery](#cached-discovery)\n      * [Usage](#usage-1)\n      * [Speaker Naming](#speaker-naming)\n      * [Refreshing the Local Speaker List](#refreshing-the-local-speaker-list)\n      * [Discovery Options](#discovery-options)\n      * [The sonos-discover Command](#the-sonos-discover-command)\n      * [Options for the sonos-discover Command](#options-for-the-sonos-discover-command)\n   * [The SoCo-CLI HTTP API Server](#the-soco-cli-http-api-server)\n      * [Server Usage](#server-usage)\n      * [Using the Local Speaker Cache](#using-the-local-speaker-cache)\n      * [HTTP Request Structure](#http-request-structure)\n      * [Return Values](#return-values)\n      * [Asynchronous Actions (Experimental)](#asynchronous-actions-experimental)\n      * [Macros: Defining Custom HTTP API Server Actions](#macros-defining-custom-http-api-server-actions)\n         * [Macro Definition and Usage](#macro-definition-and-usage)\n         * [Macro Arguments](#macro-arguments)\n         * [Using the Generic Macro](#using-the-generic-macro)\n         * [Troubleshooting](#troubleshooting)\n         * [Specifying the Macro Definition File](#specifying-the-macro-definition-file)\n         * [Reloading the Macro Definition File](#reloading-the-macro-definition-file)\n         * [Return Values](#return-values-1)\n         * [Listing Macros](#listing-macros)\n         * [Asynchronous Macros](#asynchronous-macros)\n      * [Listing Speakers](#listing-speakers)\n      * [Rediscovering Speakers](#rediscovering-speakers)\n      * [Inspecting the HTTP API](#inspecting-the-http-api)\n   * [Using SoCo-CLI as a Python Library](#using-soco-cli-as-a-python-library)\n      * [Importing the API](#importing-the-api)\n      * [Using the API](#using-the-api)\n      * [Convenience Functions](#convenience-functions)\n   * [Known Issues](#known-issues)\n   * [Uninstalling](#uninstalling)\n   * [Acknowledgments](#acknowledgments)\n   * [Resources](#resources)\n\n<!-- Created by https://github.com/ekalinin/github-markdown-toc -->\n<!-- Added by: pwt, at: Tue Apr  7 08:43:38 BST 2026 -->\n\n<!--te-->\n\n## Overview\n\nSoCo-CLI is a powerful command line wrapper for the popular Python SoCo library [1], for controlling Sonos systems. SoCo-CLI is written entirely in Python and is portable across platforms.\n\nA simple `sonos` command provides easy control over a huge range of speaker functions, including playback, volume, groups, EQ settings, sleep timers, alarms, speaker settings, the playback queue, etc. Multiple commands can be run in sequence, including the ability to insert delays between commands, to wait for speakers to stop or start playing, and to create repeated action sequences using loops. Audio files from the local filesystem can be played directly on Sonos.\n\nSoCo-CLI has an orderly command structure and consistent return values, making it suitable for use in automated scripts, `cron` jobs, etc.\n\nFor interactive command line use, SoCo-CLI provides a powerful [Interactive Shell Mode](#interactive-shell-mode) that improves speed of operation and reduces typing.\n\nSoCo-CLI can be imported as a streamlined, high-level [API](#using-soco-cli-as-a-python-library) library by other Python programs, and acts as an intermediate abstraction layer between the client program and the underlying SoCo library, simplifying the use of SoCo.\n\nSoCo-CLI can also run as a simple [HTTP API server](#the-soco-cli-http-api-server), providing access to a huge range of actions via simple HTTP requests. (Requires Python 3.7 or above.)\n\nSoCo-CLI only uses **local network** (UPnP) interaction with Sonos devices. There is no support for the Sonos cloud API and there is no intention to change this. This means that support for music service content is limited to Sonos Playlists and music service shared links.\n\n## Supported Environments\n\n- Requires Python 3.5+\n  - For Python 3.5, the latest compatible version of the underlying SoCo library is v0.27.1. Python 3.6 and above supports the latest version of the SoCo library.\n  - The HTTP API Server functionality requires Python 3.7 or above.\n- Should run on all platforms supported by Python. Tested on various versions of Linux, macOS and Windows.\n- Works with Sonos 'S1' and 'S2' systems, as well as split S1/S2 systems.\n\n## Installation\n\nInstall the latest version from PyPI [2] using **`pip install -U soco-cli`**.\n\nSoco-CLI can also be easily installed as a self-contained application with [**pipx**](https://pypa.github.io/pipx/) using **`pipx install soco-cli`**.\n\nPlease see the CHANGELOG.txt file for a list of the user-facing changes in each release.\n\n## User Guide\n\n### The `sonos` Command\n\nThe installer adds the `sonos` command to the PATH. If the `sonos` command is not found, make sure your `PATH` is set up correctly for the Python installation you want to use.\n\nAll commands have the form:\n\n```\nsonos SPEAKER ACTION <parameters>\n```\n\n- `SPEAKER` identifies the speaker to operate on, and can be the speaker's Sonos Room (Zone) name or its IPv4 address in dotted decimal format. Partial, case-insensitive speaker names will be matched, e.g., `kit` will match `Kitchen`. Partial matches must be unambiguous, or an error is returned.\n- `ACTION` is the operation to perform on the speaker. It can take zero or more parameters depending on the action.\n\nAs usual, command line arguments containing spaces must be surrounded by quotes: double quotes work on all supported OS platforms, while Linux and macOS also support single quotes.\n\nThe `soco` command is also added to the PATH, and can be used as an alias for the `sonos` command if preferred.\n\nActions that make changes to speakers do not generally provide return values. Instead, the program exit code can be inspected to test for successful operation (exit code 0). If an error is encountered, an error message will be printed to `stderr`, and the program will return a non-zero exit code. Note that `sonos` actions are executed without seeking user confirmation; please bear this in mind when manipulating the queue, playlists, etc.\n\n### Speaker Discovery by Name\n\nSoCo-CLI will try a number of approaches to find a speaker's IP address by speaker name, which escalate in cost until the speaker is discovered or discovery fails. If SoCo-CLI seems slow to find speakers (especially if you have a multi-household Sonos system), or if you occasionally experience problems with speakers not being found, please take a look at the generally faster [Cached Discovery](#cached-discovery) method.\n\n### Simple Usage Examples\n\n- **`sonos \"Living Room\" volume`** Returns the current volume setting of the *Living Room* speaker.\n- **`sonos Study volume 25`** Sets the volume of the *Study* speaker to 25.\n- **`sonos Study group Kitchen`** Groups the *Study* speaker with the *Kitchen* speaker.\n- **`sonos 192.168.0.10 mute`** Returns the mute state ('on' or 'off') of the speaker at the given IP address.\n- **`sonos 192.168.0.10 mute on`** Mutes the speaker at the given IP address.\n- **`sonos Kitchen play_favourite Jazz24 : wait 30m : Kitchen stop`** Plays 'Jazz24' for 30 minutes, then stops playback.\n- **`sonos Study play_file \"Zoo Station.mp3\"`** Plays a local audio file.\n\n### The `SPKR` Environment Variable\n\nTo avoid typing the speaker name, or to parameterise the use of SoCo-CLI commands, it's possible to use the `SPKR` environment variable instead of supplying the speaker name (or IP address) on the command line.\n\n**Example:** The following will set up all sonos commands to operate on the \"Front Reception\" speaker:\n\nLinux and macOS:\n\n```\n$ export SPKR=\"Front Reception\"\n$ sonos play\n$ sonos wait_stop : volume 10\n```\n\nWindows:\n\n```\nC:\\ set SPKR=\"Front Reception\"\nC:\\ sonos play\nC:\\ sonos wait_stop : volume 10\n```\n\nIP addresses also work, e.g.: `$ export SPKR=192.168.0.50`.\n\nIf you want to ignore the `SPKR` environment variable for a specific `sonos` invocation, use the `--no-env` command line option.\n\n### Using Shell Aliases\n\nIf your shell supports it, shell aliasing can be very convenient in creating shortcuts to SoCo-CLI commands. For example, I have the following in my `.zshrc` file:\n\n```\n# Sonos Aliases\nalias s=\"sonos\"\nalias sk=\"sonos Kitchen\"\nalias sr=\"sonos 'Rear Reception'\"\nalias sf=\"sonos 'Front Reception'\"\nalias sm=\"sonos Move\"\nalias sb=\"sonos Bedroom\"\nalias sb2=\"sonos 'Bedroom 2'\"\nalias ss=\"sonos Study\"\nalias sd=\"sonos-discover\"\n```\n\nThis allows the use of shorthand like `sk stop`, to stop playback on the Kitchen speaker. Note, however, that this won't work with sequences of commands using a single `sonos` invocation, separated with ` : ` (see [Multiple Sequential Commands](#multiple-sequential-commands)), only for the first command in such a sequence. (Normal, multiple `sonos` invocation, shell sequences using `;` or `&&` as separators will work, of course.)\n\n### Options for the `sonos` Command\n\n- **`--version, -v`**: Print the versions of SoCo-CLI, SoCo, and Python.\n- **`--check-for-update`**: Check for a more recent version of SoCo-CLI.\n- **`--actions`**: Print the list of available actions.\n- **`--docs`**: Print the URL of this README documentation, for the version of SoCo-CLI being used.\n- **`--log <level>`**: Turn on logging. Available levels are `NONE` (default), `CRITICAL`, `ERROR`, `WARN`, `INFO`, `DEBUG`, in order of increasing verbosity. `INFO` level logging tends to be the most useful when troubleshooting SoCo-CLI issues.\n\nThe following options are for use with the cached discovery mechanism:\n\n- **`--use-local-speaker-list, -l`**: Use the local speaker list instead of SoCo discovery. The speaker list will first be created and saved if it doesn't already exist.\n- **`--refresh-local-speaker-list, -r`**: In conjunction with the `-l` option, the speaker list will be regenerated and saved.\n- **`--network-discovery-threads, -t`**: The maximum number of parallel threads used to scan the local network.\n- **`--network-discovery-timeout, -n`**: The timeout used when scanning each host on the local network (how long to wait for a socket connection on port 1400 before giving up).\n- **`--min-netmask, -m`**: The minimum netmask to use when scanning networks. Used to constrain the IP search space.\n\nNote that the `sonos-discover` utility (discussed below) can also be used to manage the local speaker list. This is the recommended way of using cached discovery: first run `sonos-discover` to create the local speaker database, then use `sonos` with the `-l` option to use the local database when invoking `sonos` actions.\n\nIf you set the environment variable **`USE_LOCAL_CACHE=TRUE`**, the `--use-local-speaker-list` option will always be used.\n\n### Firewall Rules\n\nIf you're running on a host with its firewall enabled, note that some SoCo-CLI actions require the following incoming ports to be open: **TCP 1400-1499**, **TCP 54000-54099**.\n\nThe TCP/1400 range is used to receive notification events from Sonos players (used in the `wait_stop` action, etc.), the TCP/54000 range is used for the built-in Python HTTP server when playing files from the local filesystem (used in the `play_file` action).\n\nWhen opening ports, SoCo-CLI will try port numbers starting at the beginning of the range and incrementing by one until a free port is found, up to the limit of the range. This allows multiple invocations of SoCo-CLI to run in parallel on the same host.\n\nThe standard speaker discovery mechanism uses SSDP multicast (multicast address 239.255.255.250 on UDP port 1900). The outgoing port chosen for the multicast request is variable and OS-dependent, e.g., for most Linux distributions it's in the ephemeral port range 32768–60999. Multicast responses are returned to the outgoing port, so if the firewall blocks incoming UDP traffic on the relevant port range, then standard discovery will fail. SoCo-CLI will automatically fall back to using network scan discovery if standard discovery fails, but this is slower, so either adjust your firewall to open the relevant ports or consider using SoCo-CLI [Cached Discovery](#cached-discovery).\n\nIf using the HTTP API Server functionality, its listen port must be open to incoming TCP requests. The default port is 8000.\n\n### Operating on All Speakers: Using `_all_`\n\nThere is a limited set of operations where it can be desirable to operate on all speakers, e.g., muting every speaker in the house. This is done by using **`_all_`** as the speaker name. Operations will be performed on all visible devices.\n\n**Examples**: `sonos _all_ mute on` and `sonos _all_ relative_volume -10`.\n\nNote that `_all_` can be used with every `sonos` operation: no checking is performed to ensure that the use of `all` is appropriate, so use with caution.\n\n### Redirection of Actions to Coordinator Devices\n\nIf an action is applied to a non-coordinator device, there are some cases where the action is automatically redirected to the coordinator. For example, if `lounge` is the coordinator speaker and `kitchen` is a grouped speaker:\n\n- `sonos kitchen queue` will be redirected to the `lounge` speaker, because that's the queue in use\n- `sonos kitchen volume 40` will remain directed to the `kitchen` speaker\n\n## Guidelines on Playing Content\n\nSoCo-CLI enables playback of content from the **Sonos Favourites** and **Sonos Playlists** collections, from **local libraries**, and from the **TuneIn 'My Radio Stations'** list. It also allows playback of **audio files from the local filesystem**, and can add **sharelinks** from the Spotify, Tidal, Apple Music, and Deezer music services to the Sonos queue. On macOS, the contents of physical CDs can be played direct to Sonos.\n\n### Radio Stations\n\nRadio stations can be played by adding them to your Sonos Favourites, and then starting playback using `play_fav`. Alternatively, stations can be added to the TuneIn 'My Radio Stations' list, and played using `play_favourite_radio_station`.\n\n### Single Tracks\n\nAs with radio stations, add single tracks from local libraries and music services to your Sonos Favourites, and play them using `play_fav`.\n\n`sonos <speaker_name> play_fav <favourite_name>`\n\nTracks from local music libraries can also be added to the queue using `sonos <speaker> queue_track <track_name>`, which returns the queue position of the track. It can then be played using `sonos <speaker> play_from_queue <track_number>`.\n\n### Albums and Playlists\n\nAlbums and playlists from local libraries or music services can be added to your Sonos Playlists, and then played by adding them to the queue, followed by playing from the queue. For example:\n\n`sonos <speaker_name> clear_queue : <speaker_name> add_playlist_to_queue <playlist> : <speaker_name> play_from_queue`\n\nOr, to add to the current queue, then play the first playlist track:\n\n```\nsonos <speaker_name> add_playlist_to_queue <playlist>\n24 <-- Returns queue position of the first playlist track\nsonos <speaker_name> play_from_queue last_added\n```\n\nTo add imported playlists from local libraries to the queue, use the `add_library_playlist_to_queue` action.\n\nAlbums from local music libraries can also be added to the queue using `sonos <speaker> queue_album <album_name>`. The action returns the queue position of the first track in the album, which can then be played as in the example above:\n\n### Audio Files on the Local Filesystem\n\nIt's possible to play local audio files in **MP3, M4A, MP4, FLAC, OGG, WMA, WAV, and AIFF** formats directly on your Sonos speakers using the `play_file` (or `play_local_file`) action.\n\n**Example**: `sonos Lounge play_file mozart.mp3`\n\nSoCo-CLI establishes a temporary internal HTTP server from which the specified audio file can be streamed, and then instructs the speaker to play it. The `play_file` action will terminate once playback stops. Note that playback can be paused using a Sonos app (or SoCo-CLI), and the HTTP server will remain active so that playback can be resumed.\n\nUnfortunately, one can pause but not fully stop playback when using the Sonos apps. Hence, stop playback by playing something else on Sonos, by issuing a 'CTRL-C' to the active `play_file` action, or by issuing `sonos <SPEAKER> stop` from another command line. Alternatively, use the `_end_on_pause_` option to terminate the `play_file` action if playback is paused.\n\nThe host running SoCo-CLI must remain on and connected to the network during playback, in order to serve the file to the speaker. The internal HTTP server is active only for the duration of the `play_file` action. For security reasons, it will only serve the specified audio file, and only to the IP addresses of the Sonos speakers in the system.\n\nMultiple files can be played in sequence by providing multiple audio file names as parameters.\n\n**Example**: `sonos Lounge play_file one.mp3 two.mp3 three.mp3`\n\n### Local Playlists (M3U Files)\n\nThe `play_m3u` (or `play_local_m3u`) action will play a local filesystem playlist in M3U (or M3U8) format. Files in the playlist should be available on the local filesystem; any that are not will be skipped. Simple lists of audio files in non-M3U format can also be supplied. Comments can be inserted in the file by prefixing each comment line with `#`.\n\nThere are options to print the track filenames as they are played, to shuffle the playlist, and to select a single random track from the playlist. There is also an **interactive mode** option, which allows (N)ext track, (P)ause playback, and (R)esume playback, while the playlist is being played.\n\n**Example**: `sonos Lounge play_m3u my_playlist.m3u`, or, to print filenames and invoke interactive mode: `sonos Lounge play_m3u my_playlist.m3u pi`.\n\nThis feature works by invoking the `play_file` action for each file in the playlist in sequence, so the same rules apply as for `play_file`. Note that `play_m3u` does not create a Sonos queue on the speaker -- the 'queue' is managed locally by SoCo-CLI -- so it's not possible to skip forward or back using a Sonos app.\n\n### Directories of Audio Files\n\nTo play every audio file in a local directory, use the `play_directory` (or `play_dir`) action. As with the `play_m3u` action this invokes `play_file` for each valid audio file in the directory. It does not traverse into subdirectories. \n\n**Example**: `sonos Lounge play_directory \"Music/Mozart/The Magic Flute/CD 1\"`\n\nOn macOS (but not on Windows or Linux), if you have an attached CD drive, this action can be used to play a CD directly to your Sonos speakers, e.g.: \n\n`sonos Lounge play_dir \"/Volumes/Audio CD\"`.\n\nThe `play_file` action can be used to play individual tracks on the CD, e.g.:\n\n`sonos Lounge play_file \"/Volumes/Audio CD/1 Audio Track.aiff\"`.\n\n### Spotify, Tidal, Deezer, and Apple Music Share Links\n\nThe `add_sharelink_to_queue` (or `sharelink`) action can be used to add one or more share links from Spotify, Tidal, Deezer, or Apple Music to the queue, provided the Sonos system has a subscription to the required service.\n\nLinks can refer to tracks, albums, or playlists. The queue position of the first track added is returned, which can then be played using `play_from_queue`. Share links can be of the form:\n\n- `https://open.spotify.com/track/6cpcorzV5cmVjBsuAXq4wD`\n- `spotify:album:6wiUBliPe76YAVpNEdidpY`\n- `https://tidal.com/browse/album/157273956`\n- `https://www.deezer.com/en/playlist/5390258182`\n- `https://music.apple.com/dk/album/black-velvet/217502930?i=217503142`\n\nMultiple sharelinks can be supplied in a single action; they are added to the queue in order. An optional queue position can be supplied as the final argument; it applies to the first sharelink, and subsequent sharelinks are appended after it.\n\n**Examples**:\n```\nsonos Kitchen sharelink \"https://open.spotify.com/track/6cpcorzV5cmVjBsuAXq4wD\"\n5 <-- Returns queue position of first track\nsonos Kitchen play_from_queue 5\n\nsonos Kitchen sharelink \"https://open.spotify.com/track/AAA\" \"https://open.spotify.com/album/BBB\"\n5 <-- Both added; returns queue position of first track\n```\n\n## Complete List of Available Actions\n\n### Volume and EQ Control\n\n- **`balance`**: Returns the balance setting of the speaker as a value between -100 and +100, where -100 is left channel only, 0 is left and right set to the same volume, and +100 is right channel only.\n- **`balance <balance_setting>`**: Sets the balance of the speaker to a value between -100 and +100, where -100 is left channel only, 0 is left and right set to the same volume, and +100 is right channel only. Intermediate values produce a mix of right/left channels.\n- **`bass`**: Returns the bass setting of the speaker, from -10 to 10.\n- **`bass <number>`**: Sets the bass setting of the speaker to `<number>`. Values must be between -10 and 10.\n- **`dialog_mode`** (or **`dialog`**, **`dialogue_mode`**, **`dialogue`**): Returns the dialog mode setting of the speaker, 'on' or 'off' (if applicable).\n- **`dialog_mode <on|off>`** (or **`dialog`**, **`dialogue_mode`**, **`dialogue`**): Sets the dialog mode setting of the speaker to 'on' or 'off' (if applicable).\n- **`fixed_volume`**: Returns whether the speaker's Fixed Volume feature is enabled, 'on' or 'off'. (Applies to Sonos Connect and Port devices only.)\n- **`fixed_volume <on|off>`**: Sets whether the speaker's Fixed Volume feature is enabled.   \n- **`group_mute`**: Returns the group mute state of a group of speakers, 'on' or 'off'.\n- **`group_mute <on|off>`**: Sets the group mute state of a group of speakers to 'on' or 'off'.\n- **`group_relative_volume <adjustment>` (or `group_rel_vol`, `grv`)**: Raises or lowers the group volume by `<adjustment>` which must be a number from -100 to 100.\n- **`group_volume` (or `group_vol`)**: Returns the current group volume setting of the speaker's group (0 to 100).\n- **`group_volume <volume>` (or `group_vol`)**: Sets the group volume of the speaker's group to `<volume>` (0 to 100). This action operates in the same way as the group volume slider in the Sonos apps. The volume applied to each speaker in the group is scaled by the speaker's current volume.\n- **`group_volume_equalize <volume>` (or `group_volume_equalise`, `gve`)**: Sets the volume of all speakers in a group to the same absolute `<volume>` (0-100).\n- **`loudness`**: Returns the loudness setting of the speaker, 'on' or 'off'.\n- **`loudness <on|off>`**: Sets the loudness setting of the speaker to 'on' or 'off'.\n- **`mute`**: Returns the mute setting of the speaker, 'on' or 'off'.\n- **`mute <on|off>`**: Sets the mute setting of the speaker to 'on' or 'off'.\n- **`night_mode`** (or **`night`**): Returns the night mode setting of the speaker, 'on' or 'off' (if applicable).\n- **`night_mode <on|off>`** (or **`night`**): Sets the night mode setting of the speaker to 'on' or 'off' (if applicable).\n- **`playing_tv`** (or **`is_playing_tv`**): Returns whether the speaker is currently playing from its TV input source, 'yes' or 'no'.\n- **`ramp_to_volume <volume>` (or `ramp`)**: Gently raise or reduce the volume to `<volume>`, which is between 0 and 100. Returns the number of seconds to complete the ramp.\n- **`relative_bass <adjustment>` (or `rel_bass`, `rb`)**: Increase or reduce the bass setting by `<adjustment>`, a value between -10 and 10.\n- **`relative_sub_gain <adjustment>` (or `rel_sub_gain`, `rsg`)**: Increase or reduce a Sub's gain setting by `<adjustment>`, a value between -15 and 15.\n- **`relative_treble <adjustment>` (or `rel_treble`, `rt`)**: Increase or reduce the treble setting by `<adjustment>`, a value between -10 and 10.\n- **`relative_volume <adjustment>` (or `rel_vol`, `rv`)**: Raises or lowers the volume by `<adjustment>`, which must be a number from -100 to 100.\n- **`sub_enabled`**: Returns `on` if the zone's subwoofer is enabled, otherwise `off`.\n- **`sub_enabled <on|off>`**: Enables or disables a zone's subwoofer.\n- **`sub_gain`**: Reports the value of a Sub's gain (for speaker groups with a bonded Sub), from `-15` to `+15`.\n- **`sub_gain <gain>`**: Sets the value of a Sub's gain, from `-15` to `+15`.\n- **`surround_enabled`**: Returns `on` if the zone's surround speakers are enabled, otherwise `off`.\n- **`surround_enabled <on|off>`**: Enables or disables a zone's surround speakers.\n- **`surround_full_volume_enabled`**: Reports whether surround speakers are in full volume mode (on) or ambient mode (off).\n- **`surround_full_volume_enabled <on|off>`**: Sets surround speakers to full volume mode (on) or ambient mode (off).\n- **`surround_volume_music`**: Reports the value of the volume level for surround speakers, when playing music sources, from `-15` to `+15`.\n- **`surround_volume_music <level>`**: Sets the value of the volume level for surround speakers, when playing music sources, from `-15` to `+15`.\n- **`surround_volume_tv`**: Reports the value of the volume level for surround speakers, when playing TV sources, from `-15` to `+15`.\n- **`surround_volume_tv <level>`**: Sets the value of the volume level for surround speakers, when playing TV sources, from `-15` to `+15`.\n- **`treble`**: Returns the treble setting of the speaker, from -10 to 10.\n- **`treble <number>`**: Sets the treble setting of the speaker to `<number>`. Values must be between -10 and 10.\n- **`trueplay`**: Returns whether a speaker's Trueplay profile is enabled, 'on' or 'off'.\n- **`trueplay <on|off>`**: Sets whether a speaker's Trueplay profile is enabled. Can only be set to 'on' for speakers that have a current Trueplay tuning profile available.\n- **`volume` (or `vol`)**: Returns the current volume setting of the speaker (0 to 100).\n- **`volume <volume>` (or `vol`)**: Sets the volume of the speaker to `<volume>` (0 to 100).\n\n### Playback Control\n\n- **`album_art`**: Return a URL to the album art for the current stream, if there's one available.\n- **`cross_fade`** (or **`crossfade`, `fade`**): Returns the cross fade setting of the speaker, 'on' or 'off'.\n- **`cross_fade <on|off>`** (or **`crossfade`, `fade`**): Sets the cross fade setting of the speaker to 'on' or 'off'.\n- **`cue_line_in <on | line_in_speaker | left_input, right_input | line_in_speaker right_input>`**: This functions in the same way as the `line_in` action below, but does not automatically start playback (and will stop playback if the speaker is currently playing from the selected Line In input). Can be used without supplying the `on` parameter.\n- **`end_session`**: Ends a third-party controlled session, e.g. Spotify Connect.\n- **`get_channel`** (or **`channel`**): Get the channel name of the current radio stream, if available.\n- **`get_uri`**: Get the URI of the current track or stream. (Note: the output format is subject to change.)  \n- **`line_in`**: Returns a speaker's Line-In state, 'on' if its input is set to a Line-In source, 'off' otherwise, plus the current playback state (e.g., `on (PAUSED_PLAYBACK)`).\n- **`line_in <on | line_in_speaker | left_input, right_input | line_in_speaker right_input>`**: Switch a speaker to a Line-In input. Playback is started automatically. A speaker can be switched to its own Line-In input (`<on>`), **or** the Line-In input of another `<line_in_speaker>` (if applicable). For the case where there is a stereo pair of Play:5 or Five speakers, the left hand speaker's Line-In source is selected using `left_input` (default), and the right-hand speaker's Line-In input is selected using `right_input`. (Complicated example: `sonos Bedroom line_in Lounge right_input`, switches the Bedroom to the right-hand input of the stereo pair in the Lounge, and starts playback.)\n- **`next`**: Move to the next track (if applicable for the current audio source).\n- **`pause`**: Pause playback (if applicable for the audio source).\n- **`pause_all`**: Pause playback on all speakers in the system. (Note: only pauses speakers that are in the same Sonos Household.)\n- **`pauseplay`** (or **`playpause`**): Inverts a playing/paused state: if a speaker is currently playing, it will be paused or stopped; if a speaker is paused or stopped, playback will be started.\n- **`play`** (or **`start`**): Start playback.\n- **`play_directory <directory_name> <options>`** (or **`play_dir`**): Play all of the audio files in the specified local directory. Takes the same `<options>` as the `play_m3u` action. Does not traverse into subdirectories.\n- **`play_file <filename> ...`** (or **`play_local_file`**): Play MP3, M4A, MP4, FLAC, OGG, WMA, WAV, or AIFF audio files from your computer. Multiple filenames can be provided and will be played in sequence. To configure termination of the command if playback is paused (as well as stopped), add `_end_on_pause_` to the command, i.e.: `play_file filename _end_on_pause_`.\n- **`play_from_queue <track>`** (or **`play_queue`, `pfq`, `pq`**): Play track number `<track>` from the queue. Tracks begin at 1. If `<track>` is omitted, the first item in the queue is played.\n- **`play_m3u <m3u_file> <options>`** (or **`play_local_m3u`**): Plays a local M3U/M3U8 playlist consisting of local audio files (in supported audio formats). Can be followed by options `p` to print each filename before it plays, and/or `s` to shuffle the playlist, or `r` to play a single, random track from the playlist. (If using multiple options, concatenate them: e.g., `ps`.) Example: `sonos Study play_m3u my_playlist.m3u ps`. Add the `i` option to invoke **interactive** mode, which allows use of the keyboard to go to the (N)ext track, to (P)ause, or to (R)esume playback.\n- **`play_mode` (or `mode`)**: Returns the play mode of the speaker, one of `NORMAL`, `REPEAT_ONE`, `REPEAT_ALL`, `SHUFFLE`, `SHUFFLE_REPEAT_ONE`, or `SHUFFLE_NOREPEAT`.\n- **`play_mode <mode>` (or `mode`)**: Sets the play mode of the speaker to `<mode>`, which is one of the values above.\n- **`play_sharelink <sharelink> [<sharelink2> ...] [<position>]`**: Adds one or more sharelinks to the queue and starts playback from the first one added. An optional position applies to the first sharelink; the rest are appended after it.\n- **`play_uri <uri> <title>` (or `uri`, `pu`)**: Plays the audio object given by the `<uri>` parameter (e.g., a radio stream URL). `<title>` is optional, and if present will be used for the title of the audio stream.\n- **`previous` (or `prev`)**: Move to the previous track (if applicable for the audio source).\n- **`repeat` (or `rpt`)**: Returns the repeat mode state: 'off', 'one', or 'all'.\n- **`repeat <off,none|one|all>` (or `rpt`)**: Sets the repeat mode state to one of: 'off' (or 'none'), 'one', or 'all'.\n- **`seek <time>` (or `seek_to`)**: Seek to a point within a track (if applicable for the audio source). `<time>` can be expressed using the same formats as used for `sleep_timer` below. \n- **`seek_forward <time>` (or `sf`)**: Seek forward within a track (if applicable for the audio source). `<time>` can be expressed using the same formats as used for `sleep_timer` below.\n- **`seek_back <time>` (or `sb`)**: Seek backward within a track (if applicable for the audio source). `<time>` can be expressed using the same formats as used for `sleep_timer` below.\n- **`shuffle` (or `sh`)**: Returns 'on' if shuffle is enabled, 'off' if not.\n- **`shuffle <on|off>` (or `sh`)**: Enables or disables shuffle mode.\n- **`sleep_timer` (or `sleep`)**: Returns the current sleep timer remaining time in seconds; 0 if no sleep timer is active.\n- **`sleep_timer <duration|off|cancel>` (or `sleep`)**: Set the sleep timer to `<duration>`, which can be **one** of seconds, minutes or hours. Floating point values for the duration are acceptable. Examples: **`10s`, `30m`, `1.5h`**. If the s/m/h is omitted, `s` (seconds) is assumed. The time duration formats HH:MM and HH:MM:SS can also be used. To **cancel** a sleep timer, use `off` or `cancel` instead of a duration.\n- **`sleep_at <HH:MM:SS>`**: Sets the sleep timer to sleep at a time up to 24 hours in the future. For example, to set the speaker to sleep at 4pm, use `sleep_at 16:00`.\n- **`stop`**: Stop playback.\n- **`stop_all`**: Stop playback on all speakers in the system. (Note: only stops speakers that are in the same Sonos Household.)\n- **`switch_to_tv`**: Switches to the TV input. Only applicable to soundbars and the Sonos Amp.\n- **`track`**: Return information about the currently playing track.\n- **`track_follow`** (or **`tf`**): Returns information about the currently playing track, and each subsequent track when the track changes. This action keeps running until cancelled using CTRL-C.\n- **`track_follow_compact`** (or **`tfc`**): As `track_follow`, but with a more compact single line representation for each track.\n- **`tv_audio_delay`**: Returns the current setting for the audio delay for TV sources, an integer from 0 to 5. (Sonos does not specify the units used for adjustment.) Only applicable to devices with TV inputs.\n- **`tv_audio_delay <delay>`**: Sets the audio delay for TV sources; `delay` must be an integer from 0 to 5.\n\n### Queue Actions\n\nWhen adding items to the queue, the default is to add the items at the end of the queue unless an optional **`<position>`** parameter is supplied, which can take one of the following values:\n\n- An **integer** queue position. If this is <= 0, the position will be set to 1; if greater than the end of the current queue, this will be set to the end of the queue.\n- **`start`** or **`first`**: insert at the start of the queue (equivalent to `1`).\n- **`play_next`** or **`next`**: Insert at the current queue playback position unless the queue is being used for playback, in which case the item is added directly after the currently playing track, and will play next.\n- **`end`** or **`last`**: Insert at the end of the queue -- which is the default if the `position` parameter is not supplied.\n\nExamples:\n\n```shell\nsonos lounge queue_search_results all start  <-- 'start' is equivalent to '1'\nsonos lounge queue_search_results 1-5,9 next\nsonos lounge queue_search_results 1,3,4 end  <-- Note: 'end' can be omitted\nsonos kitchen queue_album zooropa next\n```\n\nWhen items are added to the queue successfully, the queue position of the first added track is returned. Use `play_from_queue` with this track number to commence playback of the added items.\n\nThe available actions are:\n\n- **`add_playlist_to_queue <playlist_name> [<position>]`** (or **`queue_playlist`, `add_pl_to_queue`, `apq`**): Add `<playlist_name>` to the queue. Name matching is case-insensitive, and will work on partial matches.\n- **`add_library_playlist_to_queue <playlist_name> [<position>]`** (or **`alpq`**): As above, but targets local library imported playlists instead of Sonos playlists.\n- **`add_sharelink_to_queue <sharelink> [<sharelink2> ...] [<position>]`** (or **`sharelink`**): Add one or more **Spotify**, **Tidal**, **Deezer**, or **Apple Music** links (for tracks, albums, playlists, etc.) to the queue. Returns the queue position of the first track added. Multiple sharelinks are added in order; an optional position applies to the first, and the rest are appended.\n- **`add_uri_to_queue <uri> [<position>]`** Adds a URI to the queue.\n- **`clear_queue`** (or **`cq`**): Clears the current queue\n- **`list_queue`** (or **`lq`, `q`**): List the tracks in the queue\n- **`list_queue <track_number>`** (or **`lq`, `q`**): List the track in the queue at position `<track_number>`\n- **`play_from_queue <track_number, or 'current', or 'last_added', or 'last', or 'random'>`** (or **`pfq`, `pq`**): Play `<track_number>` from the queue. Track numbers start from 1. If no `<track_number>` is provided, play starts from the beginning of the queue. If `current` is provided, play starts at the current queue position. If `last_added` is provided, play starts from the queue position of the last added track or set of tracks. If `last` is provided, the last track in the queue is played. If `random` is provided, playback will start at a random queue position.\n- **`queue_album <album_name> [<position>]`** (or **`qa`**): Add `<album_name>` from the local library to the queue. If multiple (fuzzy) matches are found for the album name, a random match will be chosen.\n- **`queue_length`** (or **`ql`**): Return the length of the current queue.\n- **`queue_position`** (or **`qp`**): Return the current queue position.\n- **`queue_search_results <search_result_numbers> [<position>]`** (or **`qsr`**): Queue one or more items from the list of items returned by the last search performed, at the end of the queue by default. Search result numbers start from 1, and can be supplied as single integers, sequences (e.g., '4,7,3'), ranges (e.g., '5-10'), or 'all' (for all tracks). Note: do not use spaces either side of the commas and dashes. Sequences and ranges can be mixed, e.g., '1,3-6,10'.\n- **`queue_track <track_name> [<position>]`** (or **`qt`**): Add `<track_name>` from the local library to the queue. If multiple (fuzzy) matches are found for the track name, a random match will be chosen. Optionally, `next` or `play_next` can be added to insert the track at the next_play position in the queue. The queue position of the track will be returned.\n- **`remove_current_track_from_queue` (or `rctfq`)**: Remove from the queue the track at the current queue position. If the track is playing, this will have the effect of stopping playback and starting to play the next track. (If the last track in the queue is playing, playback will stop and the previous track will start to play.)\n- **`remove_last_track_from_queue <count>` (or `rltfq`)**: Removes the last `<count>` tracks from the queue. If `<count>` is omitted, the last track is removed.\n- **`remove_from_queue <track_number|sequence|range>`** (or **`rfq`, `rq`**): Remove tracks from the queue. Track numbers start from 1, and can be supplied as single integers, sequences (e.g., '4,7,3'), or ranges (e.g., '5-10'). Note: do not use spaces either side of the commas and dashes. Sequences and ranges can be mixed, e.g., '1,3-6,10'.\n- **`save_queue <title>`** (or **`sq`, `create_playlist_from_queue`**): Save the current queue as a Sonos playlist called `<title>`.\n- **`set_queue_position <track_number>`** (or **`sqp`**): Set the queue position to `track_number`, without commencing playback. Note that this will stop any current audio being played.\n\nThe following has issues and requires further development. For example, it's currently possible to add radio stations to the queue!\n\n- **`add_favourite_to_queue <favourite> [<position>]` (or `add_favorite_to_queue`, `add_fav_to_queue`, `afq`)**: Add a Sonos Favourite to the queue. Optionally, `play_next` or `next` can be added to add the favourite as the next track or playlist to be played. Returns the queue position of the favourite.\n\n### Favourites and Playlists\n\n- **`clear_playlist <playlist>`**: Clear the Sonos playlist named `<playlist>`.\n- **`create_playlist <playlist>`**: Create a Sonos playlist named `<playlist>`. (See also `save_queue` above).\n- **`cue_favourite <favourite_name>`** (or **`cue_favorite`, `cue_fav`, `cf`**): Cues up a Sonos favourite for playback. This is a convenience action that issues the sequence: `mute, play_favourite, stop, unmute`. It's useful for silently setting a speaker to a state where it's ready to play the nominated favourite. Mute and group mute states are preserved. \n- **`delete_playlist <playlist>`** (or **`remove_playlist`**): Delete the Sonos playlist named `<playlist>`.\n- **`list_all_playlist_tracks`** (or **`lapt`**): Lists all tracks in all Sonos Playlists.\n- **`list_favs`** (or **`list_favorites`, `list_favourites`, `lf`**): Lists all Sonos favourites.\n- **`list_library_playlists`** (or **`llp`**): List all local library imported playlists.\n- **`list_playlists`** (or **`playlists`, `lp`**): Lists the Sonos playlists.\n- **`list_library_playlist_tracks <playlist_name>`** (or **`llpt`**): List the tracks in a local library imported playlist.\n- **`list_playlist_tracks <playlist_name>`** (or **`lpt`**): List the tracks in a given Sonos Playlist.\n- **`play_favourite <favourite_name>` (or `play_favorite`, `favourite`, `favorite`, `fav`, `pf`, `play_fav`)**: Plays the Sonos favourite identified by `<favourite_name>`. The name is loosely matched; if `<favourite_name>` is a (case insensitive) substring of a Sonos favourite, it will match. In the case of duplicates, the first match encountered will be used. If a queueable item, the favourite will be added to the end of the current queue and played. **Note: this currently works only for certain types of favourite: local library tracks and playlists, radio stations, single Spotify tracks, etc.**\n- **`play_favourite_number <number>`** (or **`play_favorite_number`**, **`pfn`**): Play a Sonos favourite by its index number in the list of favourites.  \n- **`play_favourite_radio_station <station_name>`** (or **`play_favorite_radio_station`, `pfrs`**): Play a favourite radio station in TuneIn's 'My Stations' list.\n- **`remove_from_playlist <playlist_name> <track_number>`** (or **`rfp`**): Remove a track from a Sonos playlist.\n\n### TuneIn Radio Station Favourites\n\nThe following operate on the stations in TuneIn's 'My Radio Stations' list.\n\n- **`cue_favourite_radio_station`** (or **`cue_favorite_radio_station`**, **`cfrs`**): Cue a favourite radio station for later playback. This is a convenience action that issues the sequence: `mute, play_favourite_radio_station, stop, unmute`. It's useful for silently setting a speaker to a state where it's ready to play the nominated favourite. Mute and group mute states are preserved.\n- **`favourite_radio_stations`** (or **`favorite_radio_stations`**, **`lfrs`**, **`frs`**): List the favourite radio stations.\n- **`play_favourite_radio_station <station_name>`** (or **`play_favorite_radio_station`, `pfrs`**): Play a favourite radio station.\n- **`play_fav_radio_station_no <station_number>`** (or **`pfrsn`**): Play a favourite radio station by its number.\n\n### Grouping, Stereo Pairing, and Surround (Satellite) Speakers\n\n- **`add_satellite_speakers <left_rear_speaker> <right_rear_speaker>`** (or **`add_satellites`**): Bonds `<left_rear_speaker>` and `<right_rear_speaker>` as rear satellite speakers to the target soundbar. The target speaker must be a soundbar. Example: `sonos \"Arc\" add_satellites \"Era 100 L\" \"Era 100 R\"`.\n- **`group <master_speaker>`(or `g`)**: Groups the speaker with `<master_speaker>`, which acts as the coordinator.\n- **`multi_group <slave_speaker> [<slave_speaker> ...]`**: Groups one or more speakers with the target speaker, which acts as the coordinator.\n- **`pair <right_hand_speaker>`**: Creates a stereo pair, where the target speaker becomes the left-hand speaker of the pair and `<right_hand_speaker>` becomes the right-hand of the pair. Can be used to pair dissimilar Sonos devices (e.g., to stereo-pair a Play:1 with a One). The left-hand speaker becomes the coordinator speaker, and the stereo pair will adopt its name.\n- **`party_mode` (or `party`)**: Adds all speakers in the system into a single group. The target speaker becomes the group coordinator. Remove speakers individually using `ungroup`, or use `ungroup_all`.\n- **`separate_satellite_speakers`** (or **`separate_satellites`**): Removes all bonded satellite speakers from the target soundbar. The target speaker must be a soundbar. Note: this will reset the Trueplay tuning for the device.\n- **`transfer_playback <target_speaker>` (or `transfer_to`, `transfer`)**: Transfers playback to <target_speaker>. This is achieved by grouping and ungrouping the speakers, and swapping the group coordinator. It's a convenience shortcut for `speaker1 group speaker2 : speaker1 ungroup`.\n- **`ungroup` (or `ug`, `u`)**: Removes the speaker from a group.\n- **`ungroup_all`**: Removes all speakers in the target speaker's household from all groups.\n- **`ungroup_all_in_group` (or `ugaig`)**: Ungroups all speakers in a group.\n- **`unpair`**: Separate a stereo pair. Can be applied to either speaker in the pair.\n\n### Alarms\n\nSome SoCo-CLI alarm actions below require an **alarm specification** (`alarm_spec`), a comma-separated list of exactly **eight** parameters (without spaces next to the commas), which defines all the properties of an alarm. The parameters are as follows:\n\n  1. Alarm start time, in hours and minutes using the 24hr clock: HH:MM\n  2. Alarm duration in hours and minutes: HH:MM\n  3. Recurrence: A valid recurrence string is  `DAILY`, `ONCE`, `WEEKDAYS`,\n     `WEEKENDS` or of the form `ON_DDDDDD` where `D` is a number from 0-6\n     representing a day of the week (Sunday is 0, Monday is 1, etc.), e.g., `ON_034` means\n     Sunday, Wednesday and Thursday\n  4. Whether the alarm is enabled: `ON` or `OFF` (or `YES`, `NO`)\n  5. What to play: `CHIME` (or `chime`) for the standard Sonos alarm sound, or a choice from your Sonos Favourites. Sonos Favourite matching will use case-insensitive, partial matches. \n  6. Play mode: one of `NORMAL`, `SHUFFLE_NOREPEAT`, `SHUFFLE`, `REPEAT_ALL`, `REPEAT_ONE`, `SHUFFLE_REPEAT_ONE` (note that `SHUFFLE` means SHUFFLE *and* REPEAT)\n  7. The volume to play at: `0`-`100`\n  8. Whether to include grouped speakers: `ON` or `OFF` (or `YES`, `NO`)\n \n  Examples of alarm specifications:\n  - `07:00,01:30,WEEKDAYS,ON,\"Radio 4\",NORMAL,50,OFF`\n  - `06:30,00:01,WEEKDAYS,ON,CHIME,NORMAL,50,OFF`\n\nIn actions which **modify** (or copy and modify) an existing alarm, values that are to be left unchanged are denoted by an underscore in the `alarm_spec`. E.g., to change only the duration and volume of an alarm, use an alarm spec such as: `_,01:00,_,_,_,_,60,_`.\n\nThe **alarm actions** are as follows:\n\n  - **`alarms`** (or **`list_alarms`**): List all of the alarms in the Sonos system. Each alarm has a unique integer `alarm_id` that can be used in the other alarm actions.\n  - **`alarms_spec`**: List all alarms in `alarm_spec` format, making it easy to copy alarm specs directly into `modify_alarm` or `add_alarm` commands. Specs containing spaces (e.g., in favourite names) are shown pre-quoted for shell use.\n  - **`alarms_spec_zone`**: As `alarms_spec`, but lists only the alarms for the target zone (speaker).\n  - **`alarms_zone`**: List the alarms for the target zone (speaker) only.\n  - **`copy_alarm <alarm_id>`**: Copies the alarm with ID `alarm_id` to the target speaker. Note that alarms cannot be copied back to the same speaker (instead, use `copy_modify_alarm` to do this).\n  - **`copy_modify_alarm <alarm_id> <alarm_spec>`**: Copies an existing alarm to the target speaker and modifies it according to an alarm specification. Can be used to copy an alarm on the same speaker, but in that case note that the copied alarm **must** have its start time modified.\n  - **`create_alarm <alarm_spec>`** (or **`add_alarm`**): Creates a new alarm for the target speaker, according to the `alarm_spec`. \n  - **`disable_alarm <alarm_id,[alarm_id]|all>`** (or **`disable_alarms`**): Disables an existing alarm or alarms. If multiple IDs are supplied, separate them by commas without spaces, e.g., `1,5,6`. To disable all alarms, use `all` as the `alarm_id`.\n  - **`enable_alarm <alarm_id,[alarm_id]|all>`** (or **`enable_alarms`**): Enables an existing alarm or alarms. If multiple IDs are supplied, separate them by commas without spaces, e.g., `1,5,6`. To enable all alarms, use `all` as the `alarm_id`.\n  - **`modify_alarm <alarm_id,[alarm_id]|all> <alarm_spec>`** (or **`modify_alarms`**): Modifies an existing alarm or alarms, according to the `alarm_spec` format described above (with underscores for values to be left unchanged). To modify all alarms, use `all` as the `alarm_id`.\n  - **`move_alarm <alarm_id>`**: Move the alarm with ID `alarm_id` to the target speaker.\n  - **`remove_alarm <alarm_id[,alarm_id]|all>`** (or **`remove_alarms`**): Removes one or more alarms by their alarm IDs. If multiple IDs are supplied, separate them by commas without spaces, e.g., `1,5,6`. To remove all alarms, use `all` as the `alarm_id`.\n  - **`snooze_alarm <snooze_duration>`**: Snooze an alarm that's already playing on the target speaker. Snooze duration can be `Nm` to snooze for `N` minutes (or just `N`). Alternatively, the duration can be expressed as `HH:MM:SS`. E.g., `sonos bedroom snooze_alarm 10m`, `sonos bedroom snooze_alarm 90`, or `sonos bedroom snooze_alarm 01:20:00`.\n\n### Music Library Search Functions\n\nThe actions below search the Sonos Music library.\n\n- **`list_albums`** (or **`albums`**): Lists all the albums in the music library.\n- **`list_artists`** (or **`artists`**): Lists all the artists in the music library.\n- **`last_search`** (or **`ls`**): Prints the results of the last album, track or artist search performed, or the last use of `tracks_in_album`, `list_albums`, or `list_playlist_tracks`. Use with `queue_search_results` to add specific items to the queue.\n- **`search_albums <album_name> <strict>`** (or **`search_album`**, **`salb`**): Searches the albums in your music library for a fuzzy match with `<album_name>`. Prints out the list of matching albums. Use `strict` to require an exact (case-insensitive) match.\n-  **`search_artists <artist_name> <strict>`** (or **`search_artist`**, **`sart`**): Searches the artists in your music library for a fuzzy match with `<artist_name>`. Prints out the list of albums featuring any artists that match the search. Use `strict` to require an exact (case-insensitive) match.\n- **`search_library <name> <strict>`** (or **`sl`**): Searches the titles in your music library for a fuzzy match with `<name>` against artists, albums and tracks. Prints out the lists of matches. This action is a superset of `search_artists`, `search_albums`, and `search_tracks`, i.e., it searches across all categories. Note: only the last populated search is saved.\n- **`search_tracks <track_name> <strict>`** (or **`search_track`**, **`st`**): Searches the tracks in your music library for a fuzzy match with `<track_name>`. Prints out the list of matching tracks. Use `strict` to require an exact (case-insensitive) match.\n- **`tracks_in_album <album_name> <strict>`** (or **`tia`**, **`lta`**): Searches the albums in your music library for a fuzzy match with `<album_name>`. Prints out the list of tracks in each matching album. Use `strict` to require an exact (case-insensitive) match.\n\n### Speaker and Sonos System Information\n\n- **`audio_format`**: Soundbars only: report the audio format currently being played.\n- **`available_actions`**: List the currently available speaker actions (play, pause, seek, next, etc.).  \n- **`battery`**: Shows the battery status for Sonos speakers that contain batteries.\n- **`buttons`**: Returns whether the speaker's control buttons are enabled, 'on' or 'off'.\n- **`buttons <on|off>`**: Sets whether the speaker's control buttons are on or off.\n- **`groups`**: Lists all groups in the Sonos system. Also includes single speakers as groups of one, and paired/bonded sets as groups.\n- **`groupstatus`**: Indicates whether the speaker is part of a group, and whether it's part of a stereo pair or bonded home theatre configuration. Note that first can override the second: if a paired/bonded coordinator speaker is also part of a group, the group will be reported but not the paired/bonded status.\n- **`has_satellites`**: Returns `yes` if the zone/room has satellite (surround) speakers bonded, otherwise `no`.\n- **`has_subwoofer`**: Returns `yes` if the zone/room has a subwoofer bonded, otherwise `no`.\n- **`info`**: Provides detailed information on the speaker's settings, current state, software version, IP address, etc.\n- **`is_indexing`**: Reports on whether the system is currently in the process of reindexing its local libraries: possible responses are `yes` or `no`.\n- **`is_satellite`**: Returns `yes` if the target device is a satellite (surround) speaker, otherwise `no`.\n- **`is_subwoofer`**: Returns `yes` if the target device is a subwoofer, otherwise `no`.\n- **`libraries`** (or **`shares`**): List the local music library shares.\n- **`mic_enabled`**: Reports whether a voice-enabled speaker's microphone is active. Returns `True` or `False`. If the speaker does not have a microphone, or if voice services are not enabled, an error will be reported. (Note that the microphone state is not under software control, so this action can only be used to inspect its state, not to change it.)\n- **`reindex`**: Start a reindex of the local music libraries. Will not proceed if a reindex is already underway.\n- **`reboot_count`**: Returns the number of times a speaker has been rebooted.\n- **`rename <new_name>`**: Rename the speaker.\n- **`state`** (or **`status`, `playback`**): Returns the current playback state for the speaker, one of: `PAUSED_PLAYBACK`, `PLAYING`, `STOPPED`, or `TRANSITIONING`.\n- **`status_light` (or `light`)**: Returns the state of the speaker's status light, 'on' or 'off'.\n- **`status_light <on|off>` (or `light`)**: Switch the speaker's status light on or off.\n- **`sysinfo`**: Prints a table of information about all speakers in the system.\n- **`zones` (or `visible_zones`, `rooms`, `visible_rooms`)**: Prints a simple list of comma separated visible zone/room names, each in double quotes. Use **`all_zones` (or `all_rooms`)** to return all devices including ones not visible in the Sonos controller apps.\n\n## Multiple Sequential Commands\n\n### Chaining Commands Using the `:` Separator\n\nMultiple commands can be run as part of the same `sonos` invocation by using the `:` separator to add multiple `SPEAKER ACTION <parameters>` sequences to the command line. **The `:` separator must be surrounded by spaces** to disambiguate from other uses of `:` in sonos actions.\n\nThe benefit of using this approach instead of multiple separate `sonos` commands is that the cost of starting the program is only incurred once. In addition, it allows for the introduction of wait states and loops.\n\nAn arbitrary number of commands can be supplied as part of a single `sonos` invocation. If a failure is encountered with any command, `sonos` will report the error, but will generally attempt to execute subsequent commands.\n\n**Example:** `sonos Kitchen volume 25 : Kitchen play`\n\n### Inserting Delays: `wait` and `wait_until`\n\n```\nsonos wait <duration>\nsonos wait_until <time>\n```\n\nThe **`wait <duration>`** (or **`wait_for`**) action waits for the specified duration before moving on to the next command. Do not supply a speaker name. This action is useful when, for example, one wants to play audio for a specific period of time, or maintain a speaker grouping for a specific period then ungroup, etc.\n\n`<duration>` can be **one** of seconds, minutes or hours. Floating point values for the duration are acceptable. Examples: `wait 10s`, `wait 30m`, `wait 1.5h`. (If the s/m/h is omitted, `s` (seconds) is assumed.) The time duration formats HH:MM and HH:MM:SS can also be used. Examples are `wait 2:30` (waits 2hrs and 30mins), `wait 0:1:25` (waits 1min 25secs).\n\nThe **`wait_until <time>`** action pauses sonos command line execution until the specified time, in 24hr HH:MM or HH:MM:SS format, for example `wait_until 16:30`.\n\nExamples:\n\n- `sonos Bedroom group Study : Study group_volume 50 : Study play : wait 10m : Study stop : Study ungroup`\n- `sonos Kitchen play_favourite Jazz24 : wait 30m : Kitchen stop`\n- `sonos Bedroom volume 0 : Bedroom play_favourite \"Radio 4\" : Bedroom ramp 40 : wait 1h : Bedroom ramp 0 : Bedroom stop`\n\n### Waiting Until Playback has Started/Stopped: `wait_start`, `wait_stop` and `wait_end_track`\n\n```\nsonos <speaker> wait_start\nsonos <speaker> wait_stop\nsonos <speaker> wait_stop_not_pause\nsonos <speaker> wait_end_track\n```\n\nThe **`<speaker> wait_start`** and **`<speaker> wait_stop`** actions are used to pause execution of the sequence of `sonos` commands until a speaker has either started or stopped/paused playback. The **`wait_stop_not_pause`** (or **`wsnp`**) action is the same as `wait_stop` but ignores the 'paused' state, i.e., it only resumes when a speaker enters the stopped state.\n\nFor example, to reset the volume back to `25` only after the `Bedroom` speaker has stopped playing, use the following command sequence:\n\n`sonos Bedroom wait_stop : Bedroom volume 25`\n\nNote that if a speaker is already playing, `wait_start` will proceed immediately, and if a speaker is already stopped, `wait_stop` will proceed immediately. If the behaviour you want is to continue **after** the **next** piece of audio ends, then you can chain commands as shown in the following example:\n\n`sonos <speaker> wait_start : <speaker> wait_stop : <speaker> vol 50`\n\nThe **`wait_end_track`** action will pause execution of `sonos` commands until the current track has ended, or until playback has otherwise paused or stopped. This is useful, for example, when one want to stop playback after the current track has ended:\n\n`sonos <speaker> wait_end_track : <speaker> stop`\n\n### The `wait_stopped_for <duration>` Action\n\n```\nsonos <speaker> wait_stopped_for <duration>\nsonos <speaker> wait_stopped_for_not_pause <duration>\n```\n\nThe **`<speaker> wait_stopped_for <duration>`** (or **`wsf`**) action will wait until a speaker has stopped playback for `<duration>` (which uses the same time parameter formats as the `wait` action). If the speaker stops playback, but then restarts (any number of times) during `<duration>`, the timer will be reset to zero each time. Processing continues once the speaker has been stopped for a continuous period equalling the `<duration>`.\n\nThe **`<speaker> wait_stopped_for_not_pause <duration>`** (or **`wsfnp`**) action is the same, but ignores the 'paused' state.\n\nThis function is useful if one wants to perform an action on a speaker (such as ungrouping it) only once its use has definitely stopped, as opposed to it just being temporarily paused, or stopped while switched to a different audio source. For example:\n\n```\nsonos Study wait_stopped_for 5m : Study line_in on : Study play\n```\n\n### Repeating Commands: The `loop` Actions\n\n```\nloop\nloop <iterations>\nloop_for <duration>\nloop_until <time>\nloop_to_start\n```\n\nThe **`loop`** action loops back to the beginning of a sequence of commands and executes the sequence again. Do not supply a speaker name. In the absence of errors, `loop` will continue indefinitely until manually stopped.\n\nTo loop a specific number of times, use **`loop <iterations>`**, giving an integer number of iterations to perform before command processing continues. The number of iterations includes the one just performed, i.e., in the sequence `sonos <speaker> vol 25: wait 1h : loop 2`, the commands preceding the `loop 2` action will be performed twice in total.\n\nTo loop for a specific period of time, use **`loop_for <duration>`**, where the format for `<duration>` follows the same rules as `wait`. Note that timer starts from the point when the `loop` statement is reached, not from the overall start of command execution.\n\nTo loop until a specific time, use **`loop_until <time>`**, where the format for `<time>` follows the same rules as `wait_until`.\n\nMultiple `loop` statements can be used in `sonos` command sequence. For any given `loop` statement, command execution returns to the command immediately after the most recent `loop`, i.e., the loop executes the commands between the current `loop` action and the previous one.  Note that `loop 1` can be considered a null loop action, and can be useful in restricting the scope of a subsequent `loop` action.\n\nThe **`loop_to_start`** action will loop back to the very start of a command sequence. It takes no parameters.\n\nExamples:\n\n```\nsonos Study wait_start : Study wait_stopped_for 10m : Study volume 25 : loop\nsonos wait_until 22:00 : Bedroom play_fav \"Radio 4\" : Bedroom sleep 30m : loop 3\nsonos Bedroom play_fav Jazz24 : Bedroom sleep 30m : wait 1h : loop_for 3h\nsonos wait_until 08:00 : Kitchen play_fav \"World Service\" : Kitchen sleep 10m : wait 1h : loop_until 12:01\n```\n\n## Conditional Command Execution\n\nThe following modifiers are available that will invoke or suppress an action depending on the state of the target speaker:\n\n```\nsonos <speaker> if_stopped <action> <parameters>\nsonos <speaker> if_playing <action> <parameters>\nsonos <speaker> if_coordinator <action> <parameters>\nsonos <speaker> if_not_coordinator <action> <parameters>\nsonos <speaker> if_queue <action> <parameters>\nsonos <speaker> if_no_queue <action> <parameters>\n```\n\nThe `if_stopped` modifier will execute the action that follows it only if the speaker is not currently playing. If the speaker is playing, the action will be skipped, and the next command in the sequence (if applicable) will be executed immediately. For example, to set the volume of a speaker back to a default value only if the speaker is not playing, use:\n\n`sonos <speaker> if_stopped volume 25`\n\nNo action will be taken if the speaker is playing, and the command will terminate immediately.\n\nSimilarly, the `if_playing` modifier will execute the action that follows it only if the speaker is currently playing.\n\nThe `if_coordinator` modifier will execute the action that follows only if the target speaker is a coordinator. The `if_not_coordinator` modifier will execute the action that follows only if the target speaker is not a coordinator. Note that many actions are automatically redirected to the coordinator speaker, so this modifier may not be required, depending on your use case.\n\nThe `if_queue` modifier will execute the action that follows it only if the speaker's queue has one or more items in it. Similarly, the `if_no_queue` modifier will execute the following action only if the speaker's queue is empty.\n\nModifiers can be combined and will be evaluated in left to right sequence. All modifiers must be true for the action to be invoked. E.g.:\n\n`sonos <speaker> if_no_queue if_stopped <action> <parameters>`\n\n## Interactive Shell Mode\n\n```\nsonos -i <speaker_name>\nsonos --interactive <speaker_name>\nsonos -i\n```\n\n### Description\n\nInteractive shell mode creates a SoCo-CLI command line session for entering `sonos` commands. Compared to using individual `sonos` invocations, using the shell is faster to perform operations, and requires less typing.\n\nMost `sonos` actions are accepted. Multiple actions can be submitted on a single command line using the ` : ` separator. Command **aliases** can be created for commonly used actions and sequences of actions. A **single-keystroke** mode allows action invocations using one touch of the keyboard. \n\n### Usage\n\nInteractive mode is started with the `-i` or `--interactive` command line option. Optionally, a speaker name can be given, in which case all commands will be directed to that speaker (until changed in the shell).\n\nType `help` or `?` at the sonos command line for more information on using interactive shell mode:\n\n```\n$ sonos -i\n\nEntering SoCo-CLI interactive shell.\nType 'help' for available shell commands.\n\nSonos [] > help\n\nThis is SoCo-CLI interactive mode. Interactive commands are as follows:\n\n    '1', ...     :  Set the active speaker. Use the numbers shown by the\n                    'speakers' command. E.g., to set to speaker number 4\n                    in the list, just type '4'.\n                    '0' will unset the active speaker.\n    'actions'    :  Show the complete list of SoCo-CLI actions.\n    'alias'      :  Add an alias: alias <alias_name> <actions>\n                    Remove an alias: alias <alias_name>\n                    Update an alias by creating a new alias with the same name.\n                    Using 'alias' without parameters shows the current list of\n                    aliases.\n                    Aliases override existing actions and can contain\n                    sequences of actions.\n    'cd'         :  Change the working directory of the shell, e.g. 'cd ..'.\n                    Note that on Windows, backslashes must be doubled, e.g.:\n                    'cd C:\\\\'\n    'check-for-update' \n                 : Check whether an update is available\n    'docs'       :  Print a link to the online documentation.\n    'exec'       :  Run a shell command, e.g.: 'exec ls -l'.\n    'exit'       :  Exit the shell.\n    'help'       :  Show this help message (available shell commands).\n    'pop'        :  Restore saved active speaker state.\n    'push'       :  Save the current active speaker, and unset the active\n                    speaker.\n    'rescan'     :  If your speaker doesn't appear in the 'speakers' list,\n                    use this to perform a more comprehensive scan.\n    'rescan_max' :  Try this if you're still having trouble finding all your\n                    speakers.\n    'set <spkr>' :  Set the active speaker using its name.\n                    Use quotes when needed for the speaker name, e.g.,\n                    'set \"Front Reception\"'. Unambiguous, partial,\n                    case-insensitive matches are supported, e.g., 'set front'.\n                    To unset the active speaker, omit the speaker name,\n                    or just enter '0'.\n    'sk'         :  Enters 'single keystroke' mode. (Also 'single-keystroke'.)\n    'speakers'   :  List the names of all available speakers.\n    'version'    :  Print the versions of SoCo-CLI, SoCo, and Python in use.\n    \n    The action syntax is the same as when using 'sonos' from the command line.\n    If a speaker has been set in the shell, omit the speaker name from the\n    action.\n\n    Use the arrow keys for command history and command editing.\n    \n    [Not Available on Windows] Use the TAB key for autocompletion of shell\n    commands, SoCo-CLI actions, aliases, and speaker names.\n\nSonos [] > \n\n```\n\n### Shell History and Auto-Completion\n\nCommands in the shell history can be scrolled through by using the up/down arrows, and commands can be edited using the left/right arrows to position the cursor.\n\n(*Not available on Windows*) Shell commands can be auto-completed using the TAB key. The shell history is saved between shell sessions in `~/.soco-cli/shell-history.txt`.\n\n### Shell Aliases\n\nShell aliases allow the creation of shortcuts for individual actions or sequences of actions. Aliases are created using:\n\n`> alias <alias_name> <alias_action> [ : <alias_action>]`\n\nFor example, to **create** an alias action sequence `go` that sets the volume of the active speaker to '50', starts playback, and then shows the track name, use:\n\n`> alias go volume 50 : play : track`\n\nAliases are **run** by using the alias name, e.g.: `> go`.\n\nAliases are included in **autocompletion** results, and they **override** built-in commands and actions of the same name (so they can be used to remap commands and actions).\n\n- **List** your aliases using the command `alias` without parameters.\n- **Remove** an alias by using `alias <alias_name>` without additional parameters.\n- **Update** an alias by creating a new action sequence with the same alias name (the previous alias is overwritten).\n\nAliases are **saved** between sessions, using a file in the `~/.soco-cli` directory.\n\n#### Push and Pop\n\nThe **`push`** and **`pop`** commands are useful in alias actions when one wants to target actions at other speakers, but keep the current active speaker when the action sequence ends. `push` saves the current speaker and unsets it, and `pop` reselects the saved speaker, e.g.:\n\n`> alias fv push : set \"Front Reception\" : volume 50 : pfrs \"Jazz 24\" : pop`\n\nThis command sequence targets the 'Front Reception' speaker, but first saves the current active speaker, restoring it at the end. (This will, of course, still work if the current active speaker is 'Front Reception'.)\n\n#### Alias Subroutines\n\nAliases can include other aliases in their sequences of actions, e.g.:\n\n```\n> alias alias_1 vol 30 : play\n> alias alias_2 push : set Kitchen : alias_1 : pop \n> alias_2\n```\n\nAlias subroutines can be nested to an arbitrary depth. **Loops** are detected and prevented when an alias with a loop is invoked. \n\n#### Alias Arguments\n\nAliases accept arguments when invoked, which is helpful in remapping existing actions to new alias names. Arguments are specified positionally using `%1`, `%2`, etc. For example:\n\n```\n> alias a1 pfq %1\n> a1 5          <- Invokes 'pfq 5'\n>\n> alias a2 push : Kitchen volume %1 : Bathroom volume %1 : pop\n>\n> a2 30         <- Invokes 'Kitchen volume 30 : Bathroom volume 30'\n                   (surrounded by a push/pop to save the current target\n                   speaker).\n```\n\nIf positional arguments are not specified, values will not be passed through. Unsatisfied positional arguments are ignored. For example:\n\n```\n> alias a1 vol %1 %2\n>\n> a1            <- Invokes 'vol'\n> a1 50         <- Invokes 'vol 50'\n> a1 50 50      <- Invokes 'vol 50 50' (and generates an error).\n```\n\nPositional arguments can be used multiple times within an action (unlikely to be useful) or within a sequence of actions.\n\n#### Saving and Loading Aliases\n\n```\nsonos --save-aliases <filename>\nsonos --load-aliases <filename>\nsonos --overwrite-aliases <filename>\n```\n\nAliases can be exported to, and loaded from, plain text files using the command line options above. The command will terminate once the file operation is complete. Option `save-aliases` will export the current aliases to the supplied filename; `load-aliases` will load a list of aliases and merge them with the current list (overwriting any duplicate alias names); `overwrite-aliases` will overwrite all current aliases with the list from the file.\n\nThe alias file format consists of lines containing `<alias_name> = <alias actions>`, e.g:\n\n```\n# This is a comment line\nmy_alias = vol 30 : play_fav \"Radio 4\"\np = pauseplay\n\n# Blank lines are OK\nm = mute on\n```\n\n### Single Keystroke Mode\n\nSingle keystroke mode allows the shell to be controlled by single character presses on the keyboard, without requiring the return key to be pressed. This is useful for some headless automation use cases, as well as sometimes being convenient for interactive use. All single character actions are available, including aliases. \n\nEnable by using the action `sk` or `single-keystroke` at the shell prompt. Type `x` to exit back to the normal shell.\n\nTo start SoCo-CLI in single keystroke mode, use the command line option `--sk`, along with the interactive (`-i` or `--interactive`) option.\n\n## Cached Discovery\n\nSoCo-CLI uses the full range of speaker discovery mechanisms in SoCo to look up speakers by their names to determine their IP addresses.\n\nFirst, the native Sonos SSDP multicast discovery process is tried.\n\nIf this fails, SoCo-CLI will try scanning every IP address on your local network(s) to find the speaker; it's likely to be doing this some of the time if your network contains multiple Sonos systems (multiple 'households'), or if the network has problems with multicast forwarding. This can be slower than is desirable, so SoCo-CLI also provides an alternative process that scans the complete local network for Sonos devices as a one-off process, and then caches the results in a local file for use in future operations.\n\nIt's often faster and more convenient to use the local cached speaker list. The disadvantage of using the cached discovery mechanism is that the speaker list can become stale due to speakers being added/removed/renamed, or IP addresses having changed, meaning the cached list must be refreshed. The `sonos-discover` command, discussed below, is a convenient way of doing this.\n\n### Usage\n\nTo use the cached discovery mechanism with `sonos`, use the `--use-local-speaker-list` or `-l` flag. The first time this flag is used, the discovery process will be initiated. This will take a few seconds to complete, after which the `sonos` command will execute. A local speaker list is stored in `<your_home_directory>/.soco-cli/` for use with future invocations of the `sonos` command.\n\n**Example:** `sonos -l \"living room\" volume 50` uses the local speaker database to look up the \"living room\" speaker.\n\nWhen executing a sequence of commands, supply the `-l` option only for the first speaker and it will be used for all speaker lookups, e.g.:\n \n `sonos -l kitchen wait_stop : kitchen vol 25 : study play_favourite \"Radio 4\"`\n\n### Speaker Naming\n\nSpeaker naming does not need to be exact. Matching is case-insensitive, and works on substrings. For example, if you have a speaker named `Front Reception`, then `\"front reception\"` or just `front` will match, as will any unambiguous substring. If an ambiguously matchable name is supplied then an error will be returned.\n\nNote that if you have speakers with the same names in multiple Sonos systems (Households), SoCo-CLI will fail because it cannot disambiguate the speakers. Speakers should have unique names within a network (or fall back on using IP addresses instead of speaker names).\n\n### Refreshing the Local Speaker List\n\nIf your speakers change in some way (e.g., they are renamed, are assigned different IP addresses, or you add/remove speakers), you can refresh the discovery cache using the `--refresh-local-speaker-list` or `-r` option. Note that this option only has an effect when combined with the `-l` option. You can also use the `sonos-discover` command (below).\n\n**Example:** `sonos -lr \"living room\" volume 50` will refresh the discovery cache before executing the `sonos` command.\n\n### Discovery Options\n\nThe following flags can be used to adjust network discovery behaviour if the discovery process is failing:\n\n- **`--network-discovery-threads, -t`**: The number of parallel threads used to scan the local network.\n- **`--network-discovery-timeout, -n`**: The timeout used when scanning each host on the local network (how long to wait for a socket connection on port 1400 before giving up).\n\nThese options only have an effect when combined with the `-l` **and** `-r` options.\n\n**Example:** `sonos -lr -t 256 -n 1.0 \"living room\" volume 50`\n\n### The `sonos-discover` Command\n\n**`sonos-discover`** is a separate command for creating/updating the local speaker cache, and for seeing the results of the discovery process. It's an alternative to using the `sonos -r` command. It accepts the same `-t`, `-n` and `-m` options as the `sonos` command. \n\n**Example:** `sonos-discover -t 256 -n 1.0 -m 24` will run `sonos-discover` with a maximum of 256 threads, a network timeout of 1.0s, a minimum netmask of 24 bits, and will print the result.\n\n### Options for the `sonos-discover` Command\n\nWithout options, `sonos-discover` will execute the discovery process and print out its results. It will create a speaker cache file, or replace it if already present.\n\nDiscovery works by interrogating all network adapters on the device running SoCo-CLI, to build a list of the IP addresses to search for Sonos speakers. If your speakers reside on a subnet that is not directly attached (e.g., they're on a separate VLAN), then use the `--subnets` option to specify manually which networks to search.\n\n**Options**:\n\n- **`--print, -p`**: Print the the current contents of the speaker cache file\n- **`--delete-local-speaker-cache, -d`**: Delete the local speaker cache file.\n- **`--network-discovery-threads, -t`**: The maximum number of parallel threads used to scan the local network.\n- **`--network-discovery-timeout, -n`**: The timeout used when scanning each host on the local network (how long to wait for a socket connection on port 1400 before giving up). Use this if `sonos-discover` is not finding all of your Sonos devices.\n- **`--min-netmask, -m`**: The minimum netmask to use when scanning networks. Used to constrain the IP search space. (Note that this option will never **increase** the search space, e.g., if one of the attached networks is 192.168.0.0/24, supplying a `--min-netmask` value of 16 will not increase the search space to 192.168.0.0/16.)\n- **`--version, -v`**: Print the versions of SoCo-CLI, SoCo, Python, and exit.\n- **`--check-for-update`**: Check for a more recent version of SoCo-CLI.  \n- **`--docs`**: Print the URL of this README documentation, for the version of SoCo-CLI being used.\n- **`--log <level>`**: Turn on logging. Available levels are NONE (default), CRITICAL, ERROR, WARN, INFO, DEBUG, in order of increasing verbosity.\n- **`--subnets <subnets_list>`**: Specify which subnet(s) to search, as a comma separated list (without spaces). E.g.: `--subnets 192.168.0.0/24,192.168.1.0/24` or `--subnets 192.168.0.30`. When this option is used, only the specified subnet(s) will be searched, and the `--min-netmask` option (if supplied) is ignored.\n\n## The SoCo-CLI HTTP API Server\n\n(Note that this functionality requires Python 3.7 or above.)\n\n```\nsonos-http-api-server\nsoco-http-api-server\n```\n\nSoCo-CLI can be run as a simple HTTP API server, allowing most of its features to be accessed via HTTP requests. It's very simple to run the server and to construct HTTP requests that invoke it.\n\n### Server Usage\n\nThe server is started using the `sonos-http-api-server` or `soco-http-api-server` commands:\n\n```\n% sonos-http-api-server\nSoCo-CLI: Starting SoCo-CLI HTTP API Server v0.4.41\nSoCo-CLI: Finding speakers ... ['Bedroom', 'Front Reception', 'Kitchen', 'Study']\nSoCo-CLI: Macro: Attempting to (re)load macros from '/Users/pwt/macros.txt'\nSoCo-CLI: Macro: Loaded macros:\n          ...\nINFO:     Started server process [52137]\nINFO:     Waiting for application startup.\nINFO:     Application startup complete.\nINFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)\n```\n\nBy default, the server listens for incoming requests on **port 8000**. This can be changed using the **`--port (or -p)`** command line option (e.g., `sonos-http-api-server -p 51000`). The listen port must be available, otherwise the server will terminate with an error.\n\nIf accessing the HTTP server from another machine on the network, make sure that your firewall allows incoming TCP requests on your chosen port.\n\nFor each request, the server will report the equivalent `sonos` command line (if applicable), execute the sonos command, and report the exit code/error, followed by the URL requested and the HTTP response code, e.g.:\n\n```\nSoCo-CLI: Command = 'sonos Study volume', exit code = 0\nINFO:     127.0.0.1:51017 - \"GET /study/volume HTTP/1.1\" 200 OK\nSoCo-CLI: Command = 'sonos Study volume 30', exit code = 0\nINFO:     127.0.0.1:64948 - \"GET /Study/volume/30 HTTP/1.1\" 200 OK\n```\n\nThe server will continue running until stopped using CTRL-C (etc.).\n\n### Using the Local Speaker Cache\n\nTo use the local speaker cache file instead of speaker discovery, start the HTTP API server with the `--use-local-speaker-list` or `-l` command line option.\n\nWhen using this option, it's also possible to supply a `--subnets` command line specification, which will be used in the event of a `/rediscover` operation. The use of this option is described in the usage guide for `sonos-discover`.\n\nThe `--subnets` option **only** has an effect when using the local speaker cache, and the results of any `/rediscover` operations will overwrite the existing local speaker cache file.\n\nExample of use with both options:\n\n```\nsonos-http-api-server -l --subnets=192.168.0.1/24\n```\n\n### HTTP Request Structure\n\nAll requests are simple HTTP `GET` requests. Request URLs have the form:\n\n```\nhttp://<server>:<port>/<speaker_name>/<action>[/parameter_1/parameter_2/parameter_3]\n```\n\n- **`<server>`** is the IP address or hostname of the server on which the HTTP API server is running.\n- **`<port>`** is the TCP port on which the server is listening.\n- **`<speaker_name>`** is the target speaker for the action. Use the same naming conventions as for other forms of SoCo-CLI usage; unambiguous partial names can be used, as can IP addresses.\n- **`<action>`** is the SoCo-CLI action to perform. Almost all of the main SoCo-CLI actions are available for use.\n- **`<parameter_1>`** (etc.) are the parameter(s) required by the action, if any.\n\nStrings with characters that are invalid within URLs should be substituted by their URL-friendly replacements  (e.g., space should be replaced by `%20`, comma by `%2C`, apostrophe by `%27`, and colon by `%3A`).\n\nUsage examples:\n\n```\nhttp://192.168.0.100:8000/Study/volume\nhttp://192.168.0.100:8000/Study/volume/50\nhttp://192.168.0.100:8000/Front%20Reception/pause\nhttp://192.168.0.100:8000/Kitchen/group/Hallway\nhttp://192.168.0.100:8000/Kitchen/line_in/Lounge/right_input\n```\n\n### Return Values\n\nReturn values are supplied in JSON format, and always contain the same fields. A formatted example is shown below:\n\n```\n{\n  \"speaker\": \"Study\",\n  \"action\": \"volume\",\n  \"args\": [],\n  \"exit_code\": 0,\n  \"result\": \"35\",\n  \"error_msg\": \"\"\n}\n```\n\nThe **`speaker`**, **`action`**, and **`args`** fields confirm the data that was sent in the HTTP request, with the speaker's name replaced by its full Sonos name if a shortened version was used in the invocation URL.\n\nThe **`exit_code`** field is an integer. This will be zero if the command completed successfully, and non-zero otherwise. (Note that the HTTP response code will indicate success (200) even if the SoCo-CLI action fails, so inspect the `exit_code` for failure detection.)\n\nIf the command is successful, the **`result`** field contains the result string, which is exactly the string that would have been printed if the action had been performed on the command line.\n\nIf the command is unsuccessful, the **`error_msg`** field contains an error message describing the error.\n\n### Asynchronous Actions (Experimental)\n\nIt's sometimes useful for the HTTP API server to respond immediately while its invoked action continues to run in the background. For example, if one wants to invoke a `play_file` action, and have the server respond immediately while the file is played in separate process.\n\nThis can be achieved by prefixing the action with `async_`. For example:\n\n```commandline\nhttp://192.168.0.100:8000/Kitchen/async_play_file/my_file.mp3\n```\n\nNote that the data returned in this case is probably not useful: it will simply indicate whether the background command was invoked successfully, not whether the command was successful.\n\nAsync actions are mutually exclusive for a given speaker: any running async action will be cancelled if a new async action is invoked.\n\nThe `async_` prefix also works with macros. See [Asynchronous Macros](#asynchronous-macros) below.\n\n### Macros: Defining Custom HTTP API Server Actions\n\nThe **macros** feature allows the creation of custom actions or sequences of actions to be executed by the HTTP API server, and available at the `/macro/<macro_name>` endpoint. Macros are defined in a text file that is loaded when the server starts, and which can subsequently be reloaded using the `/macros/reload` endpoint.\n\n#### Macro Definition and Usage\n\nMacro definitions take the form of the macro name followed by an equals sign (`=`), then the action(s) to be performed. Comments can be included by using `#` as the first character of a line, and blank lines are ignored. For example, the contents of a macro definition file might be:\n\n```\n# SoCo-CLI HTTP API Server Macros file\n# Format is:\n#   macro_name = speaker <action> <parameters> [: speaker <action> <parameters> ...]\n\n# Play the doorbell sound on all speakers\ndoorbell = Hallway party_mode : Hallway play_file doorbell.mp3 : Hallway ungroup_all\n\n# Group speakers in the morning, and start a favourite radio station\nmorning = Bathroom group Bedroom : Kitchen group Bedroom : Bedroom play_favourite \"Radio 4\"\n\n# Set the volume and start playback of a favourite\nfront_R3 = \"Front Reception\" volume 50 : \"Front Reception\" play_favourite \"Radio 3\"\n```\n\nThe macros above would be invoked using URLs of the form:\n```\nhttp://192.168.0.100:8000/macro/doorbell\nhttp://192.168.0.100:8000/macro/morning\nhttp://192.168.0.100:8000/macro/front_R3\n```\n\n**Macro names** are case-sensitive, and should not contain spaces or special characters except for underscores (`_`) and dashes (`-`).\n\n**Speaker names** should ideally use the **exact** speaker name, including capitalisation, and using enclosing quotes where necessary. Shortened names will work, but will be less efficient. (Note: this does not apply when using the local speaker cache option.)\n\n#### Macro Arguments\n\nMacros can be parameterised using up to **twelve** positional arguments, specified in macro definitions by the terms **`%1`** to **`%12`**. The general form for supplying the arguments when the macro is invoked is:\n\n`http://192.168.0.100:8000/macro/<macro_name>/<arg_1>/<arg_2>/<arg_3>/<arg_4>/...` etc.\n\nFor example, a macro definition to set all the speakers on one floor to a specified volume could be defined as:\n\n`lower_floor_volume = Kitchen volume %1 : Hallway volume %1 : \"Living Room\" volume %1`\n\nThe macro is then invoked using:\n\n`http://192.168.0.100:8000/macro/lower_floor_volume/30`\n\nOr to use different volumes for each speaker, the macro definition might be:\n\n`lower_floor_volume = Kitchen volume %1 : Hallway volume %2 : \"Living Room\" volume %3`\n\nand the macro invocation would take the form:\n\n`http://192.168.0.100:8000/macro/lower_floor_volume/30/40/25`\n\nIf a macro argument needs to be supplied, but should be ignored during macro processing, then use an underscore `_` as the argument to be ignored. E.g. to ignore `%2` when processing a macro, use a URL of the form:\n\n`http://192.168.0.100:8000/macro/lower_floor_volume/30/_/25`\n\n#### Using the Generic Macro\n\nThere's a built-in *generic* macro called `__` that simply maps to `%1 %2 %3 %4 %5 %6 %7 %8 %9 %10 %11 %12`. This can be used to create arbitrary command sequences. For example, to run the equivalent of:\n```shell\nsonos diner volume 30 : diner play_favourite \"radio 4\"\n```\nuse the following HTTP request (replacing the `:` command sequence separator with `%3A`):\n```shell\nhttp://192.168.0.100:8000/macro/__/diner/volume/30/%3A/diner/play_favourite/radio%204\n```\n\n#### Troubleshooting\n\nThere is comprehensive server-side logging that reports the macro being invoked, the arguments supplied, the substitutions performed, and the `sonos` command line that is assembled and executed. This is helpful for troubleshooting. E.g., processing the following URL:\n\n`http://192.168.0.100:8000/macro/test_1/Study/volume/_/Peter%27s%20Room/volume`\n\nmight generate the following server-side output:\n\n```\nSoCo-CLI: Macro: Processing macro 'test_1' = '%1 %2 %3 : %4 %5'\nSoCo-CLI: Macro: Parameter variables supplied: ['Study', 'volume', '_', \"Peter's Room\", 'volume']\nSoCo-CLI: Macro: Parameter variables used: ['%1', '%2', '%4', '%5'] -> ['Study', 'volume', '\"Peter\\'s Room\"', 'volume']\nSoCo-CLI: Macro: Parameter variables ignored or not supplied for: ['%3']\nSoCo-CLI: Macro: Parameter variables supplied but ignored or not used: ['%3'] -> ['_']\nSoCo-CLI: Macro: Substituting speaker name 'Study' by IP address '192.168.0.39'\nSoCo-CLI: Macro: Substituting speaker name 'Peter's Room' by IP address '192.168.0.42'\nSoCo-CLI: Macro: Executing: 'sonos 192.168.0.39 volume : 192.168.0.42 volume' in a subprocess\nSoCo-CLI: Macro: Exit code = 0\nINFO:     192.168.0.100:61548 - \"GET /macro/test_1/Study/volume/_/Peter%27s%20Room/volume HTTP/1.1\" 200 OK\n```\n\n#### Specifying the Macro Definition File\n\nBy default, the HTTP API server will look for a file named `macros.txt` in the directory from which it's invoked (the presence of the file is optional).  If instead you wish to use a specific macros file, use the `--macros` or `-m` option when starting the server, followed by the name of the macros file, e.g.:\n\n```\nsonos-http-api-server --macros my_macros.txt\n```\n\n#### Reloading the Macro Definition File\n\nThe macro file can be reloaded using the `/macros/reload` endpoint (e.g.: `http://192.168.0.100:8000/macros/reload`). This will reload the macros from the original macros file, and overwrite any that are already installed. The new list of macros will be returned in JSON format.\n\n#### Return Values\n\nSuccessful invocation of a macro will return the sonos command that was executed, and the result(s) of the actions that were performed (or the error output(s) in the case of a failure), in JSON format, e.g.:\n\n```\n{\"command\": \"sonos Kitchen volume\", \"result\": \"30\"}\n```\n\n#### Listing Macros\n\nThe `macros/list` endpoint (e.g.: `http://192.168.0.100:8000/macros/list`) will return a JSON list of the macros installed in the server.\n\n#### Asynchronous Macros\n\nMacros can be run asynchronously by prefixing the macro name with `async_` in the URL. The server responds immediately while the macro continues to run in the background. For example:\n\n```\nhttp://192.168.0.100:8000/macro/async_doorbell\nhttp://192.168.0.100:8000/macro/async_lower_floor_volume/30\n```\n\nAs with asynchronous actions, the data returned is not the macro's output — it will simply indicate whether the background process was successfully invoked. Asynchronous macros are mutually exclusive per macro name: if an async macro with the same name is already running, it will be cancelled before the new one is started.\n\n### Listing Speakers\n\nTo list the available speakers, use the `/speakers` endpoint, e.g.: `http://192.168.0.100:8000/speakers`. The returned JSON string will list the speakers.\n\n### Rediscovering Speakers\n\nIf the configuration of your speakers changes in some way (e.g., if speakers are renamed or if there are IP address changes), the server can be instructed to reload its speaker data using the `/rediscover` endpoint, e.g.: `http://192.168.0.100:8000/rediscover`. The returned JSON string will list the speakers discovered.\n\nIf using the local speaker cache option, the speaker cache file will be overwritten with the new discovery results.\n\n### Inspecting the HTTP API\n\nThe HTTP API can be inspected and tested using its OpenAPI live documentation, at the `/docs` endpoint, e.g.:`http://192.168.0.100:8000/docs`\n\n## Using SoCo-CLI as a Python Library\n\nIf you'd like to use SoCo-CLI as a high-level library in another Python program, it's simple to do so using its API capability. The goal is to provide the same added value, abstractions, and command structure as when using SoCo-CLI directly from the command line. Essentially, there is a single entry point that accepts exactly the same commands that would be used on the command line.\n\nUsing the SoCo-CLI API means that the expense of loading SoCo-CLI is incurred only once during the operation of your program, and speaker discovery results are cached for efficiency.\n\nNote that the native SoCo library can be used alongside the SoCo-CLI API, as needed.\n\n### Importing the API\n\nImport SoCo-CLI in your Python code as follows:\n\n```\nfrom soco_cli import api\n```\n\n### Using the API\n\nThe API entry point is **`api.run_command(speaker_name, action, *args, use_local_speaker_list)`**, which takes exactly the same parameters as would be provided on the command line:\n\n**Parameters:**\n\n- **`speaker_name (str)`**: The speaker name or speaker IP address supplied as a string. Partial, case-insensitive names will be matched, but the match must be unambiguous. A `SoCo` object may be passed in place of the speaker name.\n- **`action (str)`**: The action to perform, supplied as a string. Almost all of the SoCo-CLI actions are available for use, with the exception of the `loop` actions, the `wait_until` and `wait_for` actions, and the `track_follow` action.\n- **`*args (tuple)`**: The arguments for the action, supplied as strings. There can be zero or more arguments, depending on the action.\n- **`use_local_speaker_list (bool)`**: Whether to use the local speaker cache for speaker discovery. Optional, defaults to `False`.\n\n**Return Values:**\n\nEach `run_command()` invocation returns a three tuple consisting of `exit_code (int)`, `output_string (str)`, and `error_msg (str)`. If the exit code is `0`, the command completed successfully, and the command output (if any) is contained in the `output_string`. If the exit code is non-zero, the command did not complete successfully and `error_msg` will be populated while `output_string` will not.\n\nThe `output_string` return value contains exactly what would have been printed to the console if the command had been run from the command line.\n\nThe public API function definitions include type annotations, to enable type checking with the utility of your choice (e.g., mypy).\n\n**Examples of use:**\n\n```\nexit_code, output, error = api.run_command(\"Kitchen\", \"volume\")\nexit_code, output, error = api.run_command(\"Study\", \"mute\", \"on\")\nexit_code, output, error = api.run_command(\"Study\", \"group\", \"Kitchen\")\nexit_code, output, error = api.run_command(\"Front Reception\", \"play_favourite\", \"Radio 6\")\n```\n\n### Convenience Functions\n\nThere are some simple additional convenience functions provided by SoCo-CLI. The use of these functions is optional.\n\n- **`api.set_log_level(log_level)`**: This function sets up Python logging for the whole program. `log_level` is a string which can take one of the following values: `None, Critical, Error, Warn, Info, Debug`. The default value is `None`.\n- **`api.handle_sigint()`**: This function sets up a signal handler for SIGINT, providing a tidier exit than a stack trace in the event of a CTRL-C interrupt.\n- **`api.get_soco_object(speaker_name, use_local_speaker_list=False)`**: Returns a two-tuple of the SoCo object for a given speaker name (or None), and an error message string. Uses the complete set of SoCo-CLI strategies for speaker discovery.\n\n## Known Issues\n\nPlease report any problems you find using GitHub Issues [3].\n\n## Uninstalling\n\n1. Use the normal Pip approach to uninstall the SoCo-CLI package: `pip uninstall soco-cli`. \n2. As usual, Pip will not remove dependencies. If you'd like to perform an exhaustive removal, inspect the `requirements.txt` files for `soco-cli`, and for `SoCo`. Take care not to remove packages that may also be required by other installed packages.\n3. You may also need to remove the directory `.soco-cli` and its contents from your home directory.\n\n## Acknowledgments\n\nDeveloped with **[PyCharm](https://www.jetbrains.com/pycharm/)**. Earlier versions benefited from a free Professional licence for open source development from JetBrains.\n\nAll trademarks acknowledged. Avantrec Ltd has no connection with Sonos Inc.\n\n## Resources\n\n[1] https://github.com/SoCo/SoCo \\\n[2] https://pypi.org/project/soco-cli \\\n[3] https://github.com/avantrec/soco-cli/issues\n"
  },
  {
    "path": "RELEASING.txt",
    "content": "0. Start on branch 'next_version'; when changes are ready to be released:\n   Update the CHANGELOG (if required)\n   make format  (if required)\n   make toc  (if required)\n   git commit -a -m \"Ready for release\"  (if required)\n   git checkout master\n   git merge next_version\n\n1. Check __version__ in soco_cli/__init__.py\n2. Commit: git commit -a -m \"Version X.Y.Z\"\n3. Push: git push\n4. Tag the commit: git tag -a vX.Y.Z -m \"Version X.Y.Z\"\n5. Push the commit tags: git push --tags\n6. PyPi push: make pypi_upload\n\n7. git checkout next_version\n   git merge master\n   git push\n   Increment __version__ in __init__.py\n   Set up CHANGELOG.txt for next version\n   git commit -a -m \"Set up for vX.Y.Z development\"\n   start next set of changes"
  },
  {
    "path": "gh-md-toc",
    "content": "#!/usr/bin/env bash\n\n#\n# Steps:\n#\n#  1. Download corresponding html file for some README.md:\n#       curl -s $1\n#\n#  2. Discard rows where no substring 'user-content-' (github's markup):\n#       awk '/user-content-/ { ...\n#\n#  3.1 Get last number in each row like ' ... </span></a>sitemap.js</h1'.\n#      It's a level of the current header:\n#       substr($0, length($0), 1)\n#\n#  3.2 Get level from 3.1 and insert corresponding number of spaces before '*':\n#       sprintf(\"%*s\", (level-1)*'\"$nb_spaces\"', \"\")\n#\n#  4. Find head's text and insert it inside \"* [ ... ]\":\n#       substr($0, match($0, /a>.*<\\/h/)+2, RLENGTH-5)\n#\n#  5. Find anchor and insert it inside \"(...)\":\n#       substr($0, match($0, \"href=\\\"[^\\\"]+?\\\" \")+6, RLENGTH-8)\n#\n\ngh_toc_version=\"0.10.0\"\n\ngh_user_agent=\"gh-md-toc v$gh_toc_version\"\n\n#\n# Download rendered into html README.md by its url.\n#\n#\ngh_toc_load() {\n    local gh_url=$1\n\n    if type curl &>/dev/null; then\n        curl --user-agent \"$gh_user_agent\" -s \"$gh_url\"\n    elif type wget &>/dev/null; then\n        wget --user-agent=\"$gh_user_agent\" -qO- \"$gh_url\"\n    else\n        echo \"Please, install 'curl' or 'wget' and try again.\"\n        exit 1\n    fi\n}\n\n#\n# Converts local md file into html by GitHub\n#\n# -> curl -X POST --data '{\"text\": \"Hello world github/linguist#1 **cool**, and #1!\"}' https://api.github.com/markdown\n# <p>Hello world github/linguist#1 <strong>cool</strong>, and #1!</p>'\"\ngh_toc_md2html() {\n    local gh_file_md=$1\n    local skip_header=$2\n\n    URL=https://api.github.com/markdown/raw\n\n    if [ -n \"$GH_TOC_TOKEN\" ]; then\n        TOKEN=$GH_TOC_TOKEN\n    else\n        TOKEN_FILE=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)/token.txt\"\n        if [ -f \"$TOKEN_FILE\" ]; then\n            TOKEN=\"$(cat \"$TOKEN_FILE\")\"\n        fi\n    fi\n    if [ -n \"${TOKEN}\" ]; then\n        AUTHORIZATION=\"Authorization: token ${TOKEN}\"\n    fi\n\n    local gh_tmp_file_md=$gh_file_md\n    if [ \"$skip_header\" = \"yes\" ]; then\n        if grep -Fxq \"<!--te-->\" \"$gh_src\"; then\n          # cut everything before the toc\n          gh_tmp_file_md=$gh_file_md~~\n          sed '1,/<!--te-->/d' \"$gh_file_md\" > \"$gh_tmp_file_md\"\n        fi\n    fi\n\n    # echo $URL 1>&2\n    OUTPUT=$(curl -s \\\n        --user-agent \"$gh_user_agent\" \\\n        --data-binary @\"$gh_tmp_file_md\" \\\n        -H \"Content-Type:text/plain\" \\\n        -H \"$AUTHORIZATION\" \\\n        \"$URL\")\n\n    rm -f \"${gh_file_md}~~\"\n\n    if [ \"$?\" != \"0\" ]; then\n        echo \"XXNetworkErrorXX\"\n    fi\n    if [ \"$(echo \"${OUTPUT}\" | awk '/API rate limit exceeded/')\" != \"\" ]; then\n        echo \"XXRateLimitXX\"\n    else\n        echo \"${OUTPUT}\"\n    fi\n}\n\n\n#\n# Is passed string url\n#\ngh_is_url() {\n    case $1 in\n        https* | http*)\n            echo \"yes\";;\n        *)\n            echo \"no\";;\n    esac\n}\n\n#\n# TOC generator\n#\ngh_toc(){\n    local gh_src=$1\n    local gh_src_copy=$1\n    local gh_ttl_docs=$2\n    local need_replace=$3\n    local no_backup=$4\n    local no_footer=$5\n    local indent=$6\n    local skip_header=$7\n\n    if [ \"$gh_src\" = \"\" ]; then\n        echo \"Please, enter URL or local path for a README.md\"\n        exit 1\n    fi\n\n\n    # Show \"TOC\" string only if working with one document\n    if [ \"$gh_ttl_docs\" = \"1\" ]; then\n\n        echo \"Table of Contents\"\n        echo \"=================\"\n        echo \"\"\n        gh_src_copy=\"\"\n\n    fi\n\n    if [ \"$(gh_is_url \"$gh_src\")\" == \"yes\" ]; then\n        gh_toc_load \"$gh_src\" | gh_toc_grab \"$gh_src_copy\" \"$indent\"\n        if [ \"${PIPESTATUS[0]}\" != \"0\" ]; then\n            echo \"Could not load remote document.\"\n            echo \"Please check your url or network connectivity\"\n            exit 1\n        fi\n        if [ \"$need_replace\" = \"yes\" ]; then\n            echo\n            echo \"!! '$gh_src' is not a local file\"\n            echo \"!! Can't insert the TOC into it.\"\n            echo\n        fi\n    else\n        local rawhtml\n        rawhtml=$(gh_toc_md2html \"$gh_src\" \"$skip_header\")\n        if [ \"$rawhtml\" == \"XXNetworkErrorXX\" ]; then\n             echo \"Parsing local markdown file requires access to github API\"\n             echo \"Please make sure curl is installed and check your network connectivity\"\n             exit 1\n        fi\n        if [ \"$rawhtml\" == \"XXRateLimitXX\" ]; then\n             echo \"Parsing local markdown file requires access to github API\"\n             echo \"Error: You exceeded the hourly limit. See: https://developer.github.com/v3/#rate-limiting\"\n             TOKEN_FILE=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)/token.txt\"\n             echo \"or place GitHub auth token here: ${TOKEN_FILE}\"\n             exit 1\n        fi\n        local toc\n        toc=`echo \"$rawhtml\" | gh_toc_grab \"$gh_src_copy\" \"$indent\"`\n        echo \"$toc\"\n        if [ \"$need_replace\" = \"yes\" ]; then\n            if grep -Fxq \"<!--ts-->\" \"$gh_src\" && grep -Fxq \"<!--te-->\" \"$gh_src\"; then\n                echo \"Found markers\"\n            else\n                echo \"You don't have <!--ts--> or <!--te--> in your file...exiting\"\n                exit 1\n            fi\n            local ts=\"<\\!--ts-->\"\n            local te=\"<\\!--te-->\"\n            local dt\n            dt=$(date +'%F_%H%M%S')\n            local ext=\".orig.${dt}\"\n            local toc_path=\"${gh_src}.toc.${dt}\"\n            local toc_createdby=\"<!-- Created by https://github.com/ekalinin/github-markdown-toc -->\"\n            local toc_footer\n            toc_footer=\"<!-- Added by: `whoami`, at: `date` -->\"\n            # http://fahdshariff.blogspot.ru/2012/12/sed-mutli-line-replacement-between-two.html\n            # clear old TOC\n            sed -i\"${ext}\" \"/${ts}/,/${te}/{//!d;}\" \"$gh_src\"\n            # create toc file\n            echo \"${toc}\" > \"${toc_path}\"\n            if [ \"${no_footer}\" != \"yes\" ]; then\n                echo -e \"\\n${toc_createdby}\\n${toc_footer}\\n\" >> \"$toc_path\"\n            fi\n\n            # insert toc file\n            if ! sed --version > /dev/null 2>&1; then\n                sed -i \"\" \"/${ts}/r ${toc_path}\" \"$gh_src\"\n            else\n                sed -i \"/${ts}/r ${toc_path}\" \"$gh_src\"\n            fi\n            echo\n            if [ \"${no_backup}\" = \"yes\" ]; then\n                rm \"$toc_path\" \"$gh_src$ext\"\n            fi\n            echo \"!! TOC was added into: '$gh_src'\"\n            if [ -z \"${no_backup}\" ]; then\n                echo \"!! Origin version of the file: '${gh_src}${ext}'\"\n                echo \"!! TOC added into a separate file: '${toc_path}'\"\n        fi\n            echo\n        fi\n    fi\n}\n\n#\n# Grabber of the TOC from rendered html\n#\n# $1 - a source url of document.\n#      It's need if TOC is generated for multiple documents.\n# $2 - number of spaces used to indent.\n#\ngh_toc_grab() {\n\n    href_regex=\"/href=\\\"[^\\\"]+?\\\"/\"\n    common_awk_script='\n                     modified_href = \"\"\n                     split(href, chars, \"\")\n                     for (i=1;i <= length(href); i++) {\n                         c = chars[i]\n                         res = \"\"\n                         if (c == \"+\") {\n                             res = \" \"\n                         } else {\n                             if (c == \"%\") {\n                                 res = \"\\\\x\"\n                             } else {\n                                 res = c \"\"\n                             }\n                         }\n                         modified_href = modified_href res\n                    }\n                    print sprintf(\"%*s\", (level-1)*'\"$2\"', \"\") \"* [\" text \"](\" gh_url  modified_href \")\"\n                    '\n    if [ \"`uname -s`\" == \"OS/390\" ]; then\n        grepcmd=\"pcregrep -o\"\n        echoargs=\"\"\n        awkscript='{\n                     level = substr($0, 3, 1)\n                     text = substr($0, match($0, /<\\/span><\\/a>[^<]*<\\/h/)+11, RLENGTH-14)\n                     href = substr($0, match($0, '$href_regex')+6, RLENGTH-7)\n                     '\"$common_awk_script\"'\n                }'\n    else\n        grepcmd=\"grep -Eo\"\n        echoargs=\"-e\"\n        awkscript='{\n                     level = substr($0, 3, 1)\n                     text = substr($0, match($0, /\">.*<\\/h/)+2, RLENGTH-5)\n                     href = substr($0, match($0, '$href_regex')+6, RLENGTH-7)\n                     '\"$common_awk_script\"'\n                }'\n    fi\n\n    # if closed <h[1-6]> is on the new line, then move it on the prev line\n    # for example:\n    #   was: The command <code>foo1</code>\n    #        </h1>\n    #   became: The command <code>foo1</code></h1>\n    sed -e ':a' -e 'N' -e '$!ba' -e 's/\\n<\\/h/<\\/h/g' |\n\n    # Sometimes a line can start with <span>. Fix that.\n    sed -e ':a' -e 'N' -e '$!ba' -e 's/\\n<span/<span/g' |\n\n    # find strings that corresponds to template\n    $grepcmd '<h.*class=\"heading-element\".*</a' |\n\n    # remove code tags\n    sed 's/<code>//g' | sed 's/<\\/code>//g' |\n\n    # remove g-emoji\n    sed 's/<g-emoji[^>]*[^<]*<\\/g-emoji> //g' |\n\n    # now all rows are like:\n    #   <h1 class=\"heading-element\">title</h1><a href=\"...\"><span>..</span></a>\n    # format result line\n    #   * $0 - whole string\n    #   * last element of each row: \"</hN\" where N in (1,2,3,...)\n    echo $echoargs \"$(awk -v \"gh_url=$1\" \"$awkscript\")\"\n}\n\n        # perl -lpE 's/(\\[[^\\]]*\\]\\()(.*?)(\\))/my ($pre, $in, $post)=($1, $2, $3) ; $in =~ s{\\+}{ }g; $in =~ s{%}{\\\\x}g; $pre.$in.$post/ems')\"\n\n#\n# Returns filename only from full path or url\n#\ngh_toc_get_filename() {\n    echo \"${1##*/}\"\n}\n\nshow_version() {\n    echo \"$gh_toc_version\"\n    echo\n    echo \"os:     `uname -s`\"\n    echo \"arch:   `uname -m`\"\n    echo \"kernel: `uname -r`\"\n    echo \"shell:  `$SHELL --version`\"\n    echo\n    for tool in curl wget grep awk sed; do\n        printf \"%-5s: \" $tool\n        if type $tool &>/dev/null; then\n            $tool --version | head -n 1\n        else\n            echo \"not installed\"\n        fi\n    done\n}\n\nshow_help() {\n    local app_name\n    app_name=$(basename \"$0\")\n    echo \"GitHub TOC generator ($app_name): $gh_toc_version\"\n    echo \"\"\n    echo \"Usage:\"\n    echo \"  $app_name [options] src [src]   Create TOC for a README file (url or local path)\"\n    echo \"  $app_name -                     Create TOC for markdown from STDIN\"\n    echo \"  $app_name --help                Show help\"\n    echo \"  $app_name --version             Show version\"\n    echo \"\"\n    echo \"Options:\"\n    echo \"  --indent <NUM>      Set indent size. Default: 3.\"\n    echo \"  --insert            Insert new TOC into original file. For local files only. Default: false.\"\n    echo \"                      See https://github.com/ekalinin/github-markdown-toc/issues/41 for details.\"\n    echo \"  --no-backup         Remove backup file. Set --insert as well. Default: false.\"\n    echo \"  --hide-footer       Do not write date & author of the last TOC update. Set --insert as well. Default: false.\"\n    echo \"  --skip-header       Hide entry of the topmost headlines. Default: false.\"\n    echo \"                      See https://github.com/ekalinin/github-markdown-toc/issues/125 for details.\"\n    echo \"\"\n}\n\n#\n# Options handlers\n#\ngh_toc_app() {\n    local need_replace=\"no\"\n    local indent=3\n\n    if [ \"$1\" = '--help' ] || [ $# -eq 0 ] ; then\n        show_help\n        return\n    fi\n\n    if [ \"$1\" = '--version' ]; then\n        show_version\n        return\n    fi\n\n    if [ \"$1\" = '--indent' ]; then\n        indent=\"$2\"\n        shift 2\n    fi\n\n    if [ \"$1\" = \"-\" ]; then\n        if [ -z \"$TMPDIR\" ]; then\n            TMPDIR=\"/tmp\"\n        elif [ -n \"$TMPDIR\" ] && [ ! -d \"$TMPDIR\" ]; then\n            mkdir -p \"$TMPDIR\"\n        fi\n        local gh_tmp_md\n        if [ \"`uname -s`\" == \"OS/390\" ]; then\n            local timestamp\n            timestamp=$(date +%m%d%Y%H%M%S)\n            gh_tmp_md=\"$TMPDIR/tmp.$timestamp\"\n        else\n            gh_tmp_md=$(mktemp \"$TMPDIR/tmp.XXXXXX\")\n        fi\n        while read -r input; do\n            echo \"$input\" >> \"$gh_tmp_md\"\n        done\n        gh_toc_md2html \"$gh_tmp_md\" | gh_toc_grab \"\" \"$indent\"\n        return\n    fi\n\n    if [ \"$1\" = '--insert' ]; then\n        need_replace=\"yes\"\n        shift\n    fi\n\n    if [ \"$1\" = '--no-backup' ]; then\n        need_replace=\"yes\"\n        no_backup=\"yes\"\n        shift\n    fi\n\n    if [ \"$1\" = '--hide-footer' ]; then\n        need_replace=\"yes\"\n        no_footer=\"yes\"\n        shift\n    fi\n\n    if [ \"$1\" = '--skip-header' ]; then\n        skip_header=\"yes\"\n        shift\n    fi\n\n\n    for md in \"$@\"\n    do\n        echo \"\"\n        gh_toc \"$md\" \"$#\" \"$need_replace\" \"$no_backup\" \"$no_footer\" \"$indent\" \"$skip_header\"\n    done\n\n    echo \"\"\n    echo \"<!-- Created by https://github.com/ekalinin/github-markdown-toc -->\"\n}\n\n#\n# Entry point\n#\ngh_toc_app \"$@\"\n"
  },
  {
    "path": "pylintrc",
    "content": "[MESSAGES CONTROL]\n\n# locally disable too-many-lines is only possible if the disable statement is\n# put into the first line, which is not good practice\n# see https://github.com/SoCo/SoCo/issues/127\n# duplicate code check disabled whilst refactoring of wimp plugin is in\n# progress\n\ndisable = broad-except, invalid-name, logging-format-interpolation,\n          global-statement, unused-argument, fixme, too-many-lines,\n          missing-module-docstring, missing-function-docstring,\n          bare-except, redefined-outer-name, too-many-branches,\n          too-many-locals, too-many-nested-blocks\n\n# disable=too-many-lines,locally-disabled,duplicate-code,too-few-public-methods,\n#    bad-option-value,no-else-return,cyclic-import,too-many-public-methods,\n#    bad-continuation, broad-except, invalid-name\n\n\n[REPORTS]\n\n# Tells whether to display a full report or only the messages\nreports=no\n\n[FORMAT]\n\n# Maximum number of characters on a single line.\nmax-line-length=120\n\n[TYPECHECK]\n\n# List of module names for which member attributes should not be checked\n# (useful for modules/projects where namespaces are manipulated during runtime\n# and thus existing member attributes cannot be deduced by static analysis\n\n# ignored-modules=pytest"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\n    requires = [ \"setuptools>=61.2\",]\n    build-backend = \"setuptools.build_meta\"\n\n[project]\n    name = \"soco-cli\"\n    description = \"Sonos command line control utility, based on SoCo\"\n    classifiers = [\n        \"Programming Language :: Python :: 3\",\n        \"License :: OSI Approved :: Apache Software License\",\n        \"Operating System :: OS Independent\",\n        \"Development Status :: 4 - Beta\"\n    ]\n    requires-python = \">=3.5\"\n    dynamic = [\"version\", \"dependencies\"]\n\n[[project.authors]]\n    name = \"Avantrec Ltd\"\n    email = \"soco_cli@avantrec.com\"\n\n[project.readme]\n    file = \"PYPI_README.md\"\n    content-type = \"text/markdown\"\n\n[project.urls]\n    Homepage = \"https://github.com/avantrec/soco-cli\"\n\n[project.scripts]\n    sonos = \"soco_cli.sonos:main\"\n    soco = \"soco_cli.sonos:main\"\n    sonos-discover = \"soco_cli.sonos_discover:main\"\n    soco-discover = \"soco_cli.sonos_discover:main\"\n    sonos-http-api-server = \"soco_cli.http_api:main\"\n    soco-http-api-server = \"soco_cli.http_api:main\"\n\n[tool.setuptools]\n    include-package-data = false\n    packages = [\"soco_cli\"]\n\n[tool.setuptools.dynamic.version]\n    attr = \"soco_cli.__init__.__version__\"\n\n[tool.setuptools.dynamic.dependencies]\n    file = [\"requirements.txt\"]\n"
  },
  {
    "path": "requirements-dev.txt",
    "content": "black\npylint\nflake8\nwheel\ntwine\nisort\npytest\nmypy\nbuild\n"
  },
  {
    "path": "requirements.txt",
    "content": "soco == 0.27.1; python_version < \"3.6\"\nsoco >= 0.31.0; python_version >= \"3.6\"\nifaddr == 0.1.7; python_version < \"3.7\"\nifaddr >= 0.2.0; python_version >= \"3.7\"\ntabulate\nrangehttpserver\nxmltodict\nfastapi; python_version >= \"3.7\"\nuvicorn; python_version >= \"3.7\"\n"
  },
  {
    "path": "setup.cfg",
    "content": "[flake8]\n# E722 = do not use bare 'except'\nignore = E722\nmax-line-length = 120\n# extend-ignore = E203,W503,E231\n"
  },
  {
    "path": "setup.py",
    "content": "\"\"\"\nMinimal setup.py for compatibility with legacy builds or build tool versions.\n\"\"\"\n\nfrom setuptools import setup\n\nsetup()\n"
  },
  {
    "path": "soco_cli/__init__.py",
    "content": "\"\"\"SoCo-CLI is a command line control interface for Sonos systems.\n\nIt is a simplified wrapper around the SoCo python library, as well as providing\nan extensive range of additional features.\n\nIt can be used as a command line program, as an interactive command shell, and\nin other programs via its simple API. It can also run as a simple HTTP API\nserver, to control Sonos via HTTP requests.\n\nFor more information, please see: https://github.com/avantrec/soco-cli\n\"\"\"\n\n__version__ = \"0.4.86\"\n"
  },
  {
    "path": "soco_cli/__main__.py",
    "content": "\"\"\"The main entry point into the sonos command.\"\"\"\n\nfrom soco_cli.sonos import main\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "soco_cli/action_processor.py",
    "content": "\"\"\"The main command processing module.\n\nThis module requires refactoring, improvements to its argument handling,\nand needs to be converted to a Class.\n\"\"\"\n\nimport logging\nimport pprint\nimport time\nfrom collections import OrderedDict\nfrom datetime import datetime, timedelta\nfrom os import get_terminal_size\nfrom random import randint\n\nimport soco  # type: ignore\nimport tabulate  # type: ignore\nfrom soco.exceptions import NotSupportedException, SoCoUPnPException  # type: ignore\nfrom soco.plugins.sharelink import ShareLinkPlugin  # type: ignore\nfrom xmltodict import parse  # type: ignore\n\nfrom soco_cli import alarms\nfrom soco_cli.play_local_file import play_local_file\nfrom soco_cli.play_local_file_lists import play_directory_files, play_m3u_file\nfrom soco_cli.speaker_info import print_speaker_table\nfrom soco_cli.utils import (\n    convert_to_seconds,\n    create_list_of_items_from_range,\n    error_report,\n    event_unsubscribe,\n    find_by_name,\n    forget_event_sub,\n    get_queue_insertion_position,\n    get_right_hand_speaker,\n    get_speaker,\n    one_or_more_parameters,\n    one_or_two_parameters,\n    one_parameter,\n    parameter_type_error,\n    playback_state,\n    pretty_print_values,\n    queue_is_empty,\n    read_search,\n    remember_event_sub,\n    rename_speaker_in_cache,\n    save_queue_insertion_position,\n    save_search,\n    seconds_until,\n    two_parameters,\n    unsub_all_remembered_event_subs,\n    zero_one_or_two_parameters,\n    zero_or_one_parameter,\n    zero_parameters,\n)\nfrom soco_cli.wait_actions import process_wait\n\npp = pprint.PrettyPrinter(width=120)\nSONOS_MAX_ITEMS = 66000\n\n\ndef filter_track_info(track_info, excluded_fields):\n    \"\"\"Return a capitalised-key dict of track_info entries, excluding specified fields.\"\"\"\n    return {\n        item.capitalize(): track_info[item]\n        for item in sorted(track_info)\n        if item not in excluded_fields\n    }\n\n\ndef _get_track_position_timedelta(speaker):\n    \"\"\"Return the current track position as a timedelta.\"\"\"\n    current_position = speaker.get_current_track_info()[\"position\"]\n    logging.info(\"Current playback position is '{}'\".format(current_position))\n    h, m, s = [int(x) for x in current_position.split(\":\")]\n    return timedelta(hours=h, minutes=m, seconds=s)\n\n\ndef get_playlist(speaker, name, library=False):\n    \"\"\"Returns the playlist object with 'name' otherwise None.\"\"\"\n    if library:\n        playlists = speaker.music_library.get_playlists(complete_result=True)\n    else:\n        playlists = speaker.get_sonos_playlists(complete_result=True)\n    return find_by_name(playlists, name)\n\n\ndef print_list_header(prefix, name):\n    spacer = \"  \"\n    title = \"{} {}\".format(prefix, name)\n    underline = \"=\" * len(title)\n    print(spacer + title)\n    print(spacer + underline)\n\n\ndef get_current_queue_position(speaker, tracks=None):\n    \"\"\"Find the current queue position and whether a speaker is playing\n    from the queue.\n\n    'is_playing' will be reported correctly in most, but not all, cases.\n    \"\"\"\n    qp = 0\n    is_playing = False\n    track_title = None\n\n    try:\n        track_info = speaker.get_current_track_info()\n        qp = int(track_info[\"playlist_position\"])\n        track_title = track_info[\"title\"]\n    except Exception:\n        qp = 0\n\n    try:\n        cts = speaker.get_current_transport_info()[\"current_transport_state\"]\n        if cts == \"PLAYING\":\n            if tracks is not None:\n                try:\n                    if tracks[qp - 1].title == track_title:\n                        is_playing = True\n                    else:\n                        is_playing = False\n                        qp = 1\n                except (IndexError, AttributeError):\n                    is_playing = False\n                    qp = 1\n            else:\n                is_playing = True\n        else:\n            is_playing = False\n    except Exception:\n        is_playing = False\n\n    return qp, is_playing\n\n\ndef print_tracks(tracks, speaker=None, single_track=False, track_number=None):\n    qp = None\n    is_playing = None\n    if speaker:\n        qp, is_playing = get_current_queue_position(speaker, tracks)\n    if single_track:\n        item_number = track_number\n    else:\n        item_number = 1\n\n    for track in tracks:\n        # Assemble available track data\n        info_items = OrderedDict()\n        try:\n            info_items[\"Artist\"] = track.creator\n        except AttributeError:\n            pass\n        try:\n            info_items[\"Album\"] = track.album\n        except AttributeError:\n            pass\n        try:\n            info_items[\"Title\"] = track.title\n        except AttributeError:\n            pass\n        try:\n            if track.item_class == \"object.item.audioItem.podcast\":\n                info_items[\"Podcast Episode\"] = info_items.pop(\"Title\")\n        except (AttributeError, KeyError):\n            pass\n\n        # Assemble the info string to be printed\n        info_string = \"\"\n        first = True\n        for item, info in info_items.items():\n            if first:\n                first = False\n            else:\n                info_string += \" | \"\n            info_string += \"{}: {}\".format(item, info)\n\n        # Print the information; show position and play state if available\n        prefix = \"    \"\n        if qp == item_number:\n            if is_playing:\n                prefix = \" *> \"\n            else:\n                prefix = \" *  \"\n        print(\"{}{:3d}: {}\".format(prefix, item_number, info_string))\n\n        item_number += 1\n\n    return True\n\n\ndef print_albums(albums, omit_first=False):\n    item_number = 1\n    for album in albums:\n        try:\n            artist = album.creator\n        except AttributeError:\n            artist = \"\"\n        try:\n            title = album.title\n        except AttributeError:\n            title = \"\"\n        if item_number == 1 and omit_first:\n            omit_first = False\n        else:\n            print(\"{:7d}: Album: {} | Artist: {}\".format(item_number, title, artist))\n            item_number += 1\n    return True\n\n\ndef print_artists(artists):\n    item_number = 1\n    for artist in artists:\n        artist_name = artist.title\n        print(\"{:7d}: {}\".format(item_number, artist_name))\n        item_number += 1\n    return True\n\n\n# Action processing functions\n@zero_or_one_parameter\ndef on_off_action(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Method to deal with actions that have 'on|off semantics\"\"\"\n    if action == \"group_mute\":\n        speaker = speaker.group\n        soco_function = \"mute\"\n    np = len(args)\n    if np == 0:\n        state = \"on\" if getattr(speaker, soco_function) else \"off\"\n        print(state)\n    elif np == 1:\n        arg = args[0].lower()\n        if arg == \"on\":\n            setattr(speaker, soco_function, True)\n        elif arg == \"off\":\n            setattr(speaker, soco_function, False)\n        else:\n            parameter_type_error(action, \"on|off\")\n            return False\n    return True\n\n\n@zero_parameters\ndef true_false_action(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Method to deal with status actions that have 'true|false semantics\"\"\"\n    state = \"yes\" if getattr(speaker, soco_function) else \"no\"\n    print(state)\n    return True\n\n\n@zero_parameters\ndef no_args_no_output(speaker, action, args, soco_function, use_local_speaker_list):\n    getattr(speaker, soco_function)()\n    return True\n\n\n@zero_parameters\ndef no_args_one_output(speaker, action, args, soco_function, use_local_speaker_list):\n    result = getattr(speaker, soco_function)\n    if callable(result):\n        print(getattr(speaker, soco_function)())\n    else:\n        print(result)\n    return True\n\n\n@zero_or_one_parameter\ndef list_queue(speaker, action, args, soco_function, use_local_speaker_list):\n    queue = speaker.get_queue(max_items=SONOS_MAX_ITEMS)\n    if len(queue) == 0:\n        # print(\"Queue is empty\")\n        return True\n    if len(args) == 1:\n        try:\n            track_number = int(args[0])\n            if not 0 < track_number <= len(queue):\n                error_report(\n                    \"Track number {} is out of queue range\".format(track_number)\n                )\n                return False\n            queue = [queue[track_number - 1]]\n        except ValueError:\n            parameter_type_error(action, \"integer\")\n            return False\n    print()\n    if len(args) == 1:\n        print_tracks(queue, speaker, single_track=True, track_number=track_number)\n    else:\n        print_tracks(queue, speaker)\n    print()\n    return True\n\n\n@zero_parameters\ndef list_numbered_things(speaker, action, args, soco_function, use_local_speaker_list):\n    if soco_function in [\n        \"get_sonos_favorites\",\n        \"get_favorite_radio_stations\",\n        \"get_playlists\",\n        # \"get_tracks\",\n    ]:\n        things = getattr(speaker.music_library, soco_function)(complete_result=True)\n    else:\n        things = getattr(speaker, soco_function)(complete_result=True)\n    things_list = [thing.title for thing in things]\n    things_list.sort()\n    print()\n    index = 0\n    for thing in things_list:\n        index += 1\n        print(\"{:5d}: {}\".format(index, thing))\n    print()\n    return True\n\n\n@zero_or_one_parameter\ndef volume_actions(speaker, action, args, soco_function, use_local_speaker_list):\n    if soco_function == \"group_volume\":\n        logging.info(\"Using speaker group instead of speaker\")\n        speaker = speaker.group\n\n    np = len(args)\n    if np == 0:\n        print(speaker.volume)\n        return True\n    if np == 1:\n        try:\n            vol = int(args[0])\n            if not (0 <= vol <= 100):\n                raise ValueError\n        except ValueError:\n            parameter_type_error(action, \"integer 0 to 100\")\n            return False\n        if soco_function == \"ramp_to_volume\":\n            logging.info(\"Ramping to volume {}\".format(vol))\n            print(speaker.ramp_to_volume(vol))\n        else:\n            logging.info(\"Setting volume to {}\".format(vol))\n            speaker.volume = vol\n        return True\n\n\n@one_parameter\ndef relative_volume(speaker, action, args, soco_function, use_local_speaker_list):\n    if soco_function == \"group_relative_volume\":\n        logging.info(\"Using speaker group instead of speaker\")\n        speaker = speaker.group\n    try:\n        vol = int(args[0])\n        if not -100 <= vol <= 100:\n            raise ValueError\n    except ValueError:\n        parameter_type_error(action, \"integer from -100 to 100\")\n\n    logging.info(\"Adjusting relative volume by {}\".format(vol))\n    speaker.set_relative_volume(vol)\n    return True\n\n\n@zero_parameters\ndef print_info(speaker, action, args, soco_function, use_local_speaker_list):\n    output = getattr(speaker, soco_function)()\n    for item in sorted(output):\n        if item not in [\"metadata\", \"uri\", \"album_art\"]:\n            print(\"  {}: {}\".format(item.capitalize(), output[item]))\n    return True\n\n\n@zero_parameters\ndef track(speaker, action, args, soco_function, use_local_speaker_list):\n    state = speaker.get_current_transport_info()[\"current_transport_state\"]\n\n    if speaker.is_playing_line_in:\n        print(\"Using Line In (state: {})\".format(state))\n        return True\n\n    def title_not_useful(title):\n        indicators = [\"m3u\", \"stream\", \"sonos\", \"http\", \"=\", \"ZPSTR_\"]\n        for indicator in indicators:\n            if indicator in title:\n                return True\n        return False\n\n    stream = False\n\n    print(\" Playback is {}:\".format(playback_state(state)))\n    track_info = speaker.get_current_track_info()\n    logging.info(\"Current track info:\\n{}\".format(track_info))\n\n    # Accumulate info elements to be printed\n    elements = {\"Channel\": speaker.get_current_media_info()[\"channel\"]}\n\n    # Stream\n    if track_info[\"duration\"] in [\"0:00:00\", \"NOT_IMPLEMENTED\"]:\n        logging.info(\"Track is a radio stream\")\n        stream = True\n        elements.update(\n            filter_track_info(\n                track_info,\n                [\"metadata\", \"album_art\", \"duration\", \"playlist_position\", \"uri\"],\n            )\n        )\n\n        try:\n            metadata = parse(track_info[\"metadata\"])\n            if elements[\"Artist\"] == \"\":\n                logging.info(\"Attempting to find 'Artist' from metadata\")\n                try:\n                    elements[\"Artist\"] = metadata[\"DIDL-Lite\"][\"item\"][\"dc:creator\"]\n                except (KeyError, TypeError):\n                    logging.info(\"Unable to find 'Artist'\")\n            if elements[\"Title\"] == \"\":\n                logging.info(\"Attempting to find 'Title' from metadata\")\n                try:\n                    elements[\"Title\"] = metadata[\"DIDL-Lite\"][\"item\"][\"dc:title\"]\n                except (KeyError, TypeError):\n                    logging.info(\"Unable to find 'Title'\")\n        except Exception:\n            pass\n\n        try:\n            logging.info(\"Attempting to find 'Radio Show' using events\")\n            sub = speaker.avTransport.subscribe()\n            remember_event_sub(sub)\n            event = sub.events.get(timeout=0.5)\n            elements[\"Radio Show\"] = event.variables[\n                \"current_track_meta_data\"\n            ].radio_show.rpartition(\",\")[0]\n            event_unsubscribe(sub)\n            forget_event_sub(sub)\n        except Exception as e:\n            logging.info(\"Unable to find 'Radio Show': {}\".format(e))\n        finally:\n            unsub_all_remembered_event_subs()\n\n    # Podcast, Audio Book, or normal track\n    else:\n        logging.info(\"Track has a non-zero duration\")\n        try:\n            metadata = parse(track_info[\"metadata\"])\n            logging.info(\"Track metadata: {}\".format(metadata))\n        except Exception:\n            logging.info(\"No usable metadata available\")\n            metadata = None\n\n        # Podcast\n        if (\n            metadata\n            and metadata[\"DIDL-Lite\"][\"item\"][\"upnp:class\"]\n            == \"object.item.audioItem.podcast\"\n        ):\n            logging.info(\"Track is a podcast\")\n            try:\n                elements[\"Podcast\"] = metadata[\"DIDL-Lite\"][\"item\"][\"r:podcast\"]\n                elements[\"Release Date\"] = metadata[\"DIDL-Lite\"][\"item\"][\n                    \"r:releaseDate\"\n                ][:10]\n            except (KeyError, TypeError):\n                logging.info(\"Failed to find 'Podcast' and/or 'Release Date'\")\n            elements.update(\n                filter_track_info(\n                    track_info, [\"metadata\", \"uri\", \"album_art\", \"album\", \"artist\"]\n                )\n            )\n            try:\n                elements[\"Episode\"] = elements.pop(\"Title\")\n            except KeyError:\n                pass\n\n        # Audio book\n        elif (\n            metadata\n            and \"object.item.audioItem.audioBook\"\n            in metadata[\"DIDL-Lite\"][\"item\"][\"upnp:class\"]\n        ):\n            logging.info(\"Track is an audio book\")\n            try:\n                elements[\"Book Title\"] = elements.pop(\"Channel\", \"\")\n                elements[\"Creator(s)\"] = track_info[\"artist\"]\n                elements[\"Narrator(s)\"] = metadata[\"DIDL-Lite\"][\"item\"][\"r:narrator\"]\n                elements[\"Chapter\"] = metadata[\"DIDL-Lite\"][\"item\"][\"dc:title\"]\n            except (KeyError, TypeError):\n                logging.info(\"Failed to find book details\")\n            elements.update(\n                filter_track_info(\n                    track_info,\n                    [\n                        \"metadata\",\n                        \"uri\",\n                        \"album_art\",\n                        \"album\",\n                        \"artist\",\n                        \"title\",\n                        \"playlist_position\",\n                    ],\n                )\n            )\n\n        # Regular track\n        else:\n            logging.info(\"Track is a normal audio track\")\n            elements.update(\n                filter_track_info(track_info, [\"metadata\", \"uri\", \"album_art\"])\n            )\n            # If there's no title, look in the metadata\n            try:\n                if elements[\"Title\"] == \"\" or title_not_useful(elements[\"Title\"]):\n                    metadata = parse(track_info[\"metadata\"])\n                    elements[\"Title\"] = metadata[\"DIDL-Lite\"][\"item\"][\"dc:title\"]\n                    logging.info(\n                        \"Found title in metadata: {}\".format(elements[\"Title\"])\n                    )\n            except KeyError:\n                pass\n\n    # Remove blank and 'None' items\n    elements = {\n        key: value\n        for key, value in elements.items()\n        if value != \"\" and value is not None and value != \"NOT_IMPLEMENTED\"\n    }\n\n    # Deduplicate 'Channel' and 'Title'\n    # Remove 'Title' if it looks unuseful\n    try:\n        if (elements[\"Channel\"] == elements[\"Title\"]) or (\n            stream and title_not_useful(elements[\"Title\"])\n        ):\n            logging.info(\"Removing Title: '{}'\".format(elements[\"Title\"]))\n            elements.pop(\"Title\", None)\n    except KeyError:\n        pass\n\n    # Rename 'Playlist_position' and 'Position'\n    try:\n        if int(elements[\"Playlist_position\"]) != 0:\n            elements[\"Playlist Position\"] = elements[\"Playlist_position\"]\n        elements.pop(\"Playlist_position\", None)\n    except KeyError:\n        pass\n    try:\n        elements[\"Elapsed\"] = elements[\"Position\"]\n        elements.pop(\"Position\", None)\n    except KeyError:\n        pass\n\n    # Reorder the elements\n    element_order = [\n        \"Channel\",\n        \"Radio Show\",\n        \"Podcast\",\n        \"Artist\",\n        \"Creator(s)\",\n        \"Narrator(s)\",\n        \"Book Title\",\n        \"Chapter\",\n        \"Album\",\n        \"Title\",\n        \"Episode\",\n        \"Release Date\",\n        \"Playlist Position\",\n        \"Duration\",\n        \"Elapsed\",\n    ]\n    ordered_elements = OrderedDict()\n    for element in element_order:\n        try:\n            ordered_elements[element] = elements.pop(element)\n        except KeyError:\n            pass\n    # Add any elements we've missed\n    ordered_elements.update(elements)\n\n    logging.info(\"Items to be printed: {}\".format(ordered_elements))\n    pretty_print_values(ordered_elements, indent=3, spacing=5, sort_by_key=False)\n    return True\n\n\n@zero_or_one_parameter\ndef playback_mode(speaker, action, args, soco_function, use_local_speaker_list):\n    np = len(args)\n    possible_args = [\n        \"normal\",\n        \"repeat_all\",\n        \"repeat_one\",\n        \"shuffle\",\n        \"shuffle_norepeat\",\n        \"shuffle_repeat_one\",\n    ]\n    if np == 0:\n        print(speaker.play_mode)\n    elif np == 1:\n        if args[0].lower() in possible_args:\n            speaker.play_mode = args[0]\n        else:\n            parameter_type_error(action, possible_args)\n    return True\n\n\n@zero_or_one_parameter\ndef shuffle(speaker, action, args, soco_function, use_local_speaker_list):\n    np = len(args)\n    if np == 0:\n        if speaker.shuffle is True:\n            print(\"on\")\n        else:\n            print(\"off\")\n    elif np == 1:\n        if args[0].lower() == \"on\":\n            speaker.shuffle = True\n        elif args[0].lower() == \"off\":\n            speaker.shuffle = False\n        else:\n            error_report(\"Action '{}' takes parameter 'on' or 'off'\".format(action))\n            return False\n    return True\n\n\n@zero_or_one_parameter\ndef repeat(speaker, action, args, soco_function, use_local_speaker_list):\n    np = len(args)\n    if np == 0:\n        if speaker.repeat is True:\n            print(\"all\")\n        elif speaker.repeat is False:\n            print(\"off\")\n        else:\n            print(\"one\")\n    elif np == 1:\n        if args[0].lower() in [\"off\", \"none\"]:\n            speaker.repeat = False\n        elif args[0].lower() == \"one\":\n            speaker.repeat = \"ONE\"\n        elif args[0].lower() == \"all\":\n            speaker.repeat = True\n        else:\n            error_report(\n                \"Action '{}' takes parameter 'off', 'one', or 'all'\".format(action)\n            )\n            return False\n    return True\n\n\n@zero_parameters\ndef transport_state(speaker, action, args, soco_function, use_local_speaker_list):\n    print(speaker.get_current_transport_info()[\"current_transport_state\"])\n    return True\n\n\ndef play_favourite_core(speaker, favourite, favourite_number=None):\n    \"\"\"Core of the play_favourite action, but doesn't exit on failure\"\"\"\n\n    fs = speaker.music_library.get_sonos_favorites(complete_result=True)\n\n    if favourite_number:\n        err_msg = \"Favourite number must be integer between 1 and {}\".format(len(fs))\n        try:\n            favourite_number = int(favourite_number)\n        except ValueError:\n            return False, err_msg\n        if not 0 < favourite_number <= len(fs):\n            return False, err_msg\n\n        # List must be sorted by title to match the output of 'list_favourites'\n        fs.sort(key=lambda x: x.title)\n        the_fav = fs[favourite_number - 1]\n        logging.info(\n            \"Favourite number {} is '{}'\".format(favourite_number, the_fav.title)\n        )\n\n    else:\n        the_fav = find_by_name(fs, favourite)\n\n    if the_fav:\n        # play_uri works for some favourites\n        # TODO: this is broken and we should test for the\n        #       type of favourite\n        try:\n            uri = the_fav.get_uri()\n            metadata = the_fav.resource_meta_data\n            logging.info(\n                \"Trying 'play_uri()': URI={}, Metadata={}\".format(uri, metadata)\n            )\n            speaker.play_uri(uri=uri, meta=metadata)\n            return True, \"\"\n        except Exception as e:\n            e1 = e\n\n        # Other favourites will be added to the queue, then played\n        try:\n            # Add to the end of the current queue and play\n            logging.info(\"Trying 'add_to_queue()'\")\n            index = speaker.add_to_queue(the_fav, as_next=True)\n            speaker.play_from_queue(index, start=True)\n            return True, \"\"\n        except Exception as e2:\n            msg = \"1: {} | 2: {}\".format(str(e1), str(e2))\n            return False, msg\n    msg = \"Favourite '{}' not found\".format(favourite)\n    return False, msg\n\n\n@one_parameter\ndef play_favourite(speaker, action, args, soco_function, use_local_speaker_list):\n    result, msg = play_favourite_core(speaker, args[0])\n    if not result:\n        error_report(msg)\n        return False\n\n    return True\n\n\n@one_parameter\ndef play_favourite_number(speaker, action, args, soco_function, use_local_speaker_list):\n    logging.info(\"Playing favourite number {}\".format(args[0]))\n    result, msg = play_favourite_core(speaker, \"\", args[0])\n    if not result:\n        error_report(msg)\n        return False\n\n    return True\n\n\n@one_or_two_parameters\ndef add_favourite_to_queue(\n    speaker, action, args, soco_function, use_local_speaker_list\n):\n    favourite = args[0]\n    fs = speaker.music_library.get_sonos_favorites()\n    the_fav = find_by_name(fs, favourite)\n    if the_fav:\n        if len(args) == 2:\n            position = get_queue_insertion_position(speaker, args[1], action)\n        else:\n            position = speaker.queue_size + 1\n        try:\n            # Print the queue position and return\n            speaker.add_to_queue(the_fav, position=position)\n            save_queue_insertion_position(position)\n            print(position)\n            return True\n        except Exception as e:\n            error_report(\"{}\".format(str(e)))\n            return False\n    error_report(\"Favourite '{}' not found\".format(args[0]))\n    return False\n\n\n@one_parameter\ndef play_favourite_radio_number(\n    speaker, action, args, soco_function, use_local_speaker_list\n):\n    try:\n        fav_no = int(args[0])\n    except ValueError:\n        parameter_type_error(action, \"integer\")\n        return False\n\n    logging.info(\"Playing favourite radio station no. {}\".format(fav_no))\n\n    preset = 0\n    limit = 99\n    stations = speaker.music_library.get_favorite_radio_stations(preset, limit)\n\n    station_titles = sorted([s.title for s in stations])\n    logging.info(\"Sorted station titles are: {}\".format(station_titles))\n\n    station_title = station_titles[fav_no - 1]\n    logging.info(\"Requested station is '{}'\".format(station_title))\n\n    return play_favourite_radio(\n        speaker, action, [station_title], soco_function, use_local_speaker_list\n    )\n\n\n@one_parameter\ndef play_favourite_radio(speaker, action, args, soco_function, use_local_speaker_list):\n    favourite = args[0]\n    preset = 0\n    limit = 99\n    fs = speaker.music_library.get_favorite_radio_stations(preset, limit)\n    the_fav = find_by_name(fs, favourite)\n    if the_fav:\n        uri = the_fav.get_uri()\n        meta_template = \"\"\"\n        <DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\"\n            xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\"\n            xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\"\n            xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\">\n            <item id=\"R:0/0/0\" parentID=\"R:0/0\" restricted=\"true\">\n                <dc:title>{title}</dc:title>\n                <upnp:class>object.item.audioItem.audioBroadcast</upnp:class>\n                <desc id=\"cdudn\" nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">\n                    {service}\n                </desc>\n            </item>\n        </DIDL-Lite>' \"\"\"\n        tunein_service = \"SA_RINCON65031_\"\n        uri = uri.replace(\"&\", \"&amp;\")\n        metadata = meta_template.format(title=the_fav.title, service=tunein_service)\n        logging.info(\"Trying 'play_uri()': URI={}, Metadata={}\".format(uri, metadata))\n        speaker.play_uri(uri=uri, meta=metadata)\n        return True\n\n    error_report(\"Favourite '{}' not found\".format(args[0]))\n    return False\n\n\n@one_or_two_parameters\ndef play_uri(speaker, action, args, soco_function, use_local_speaker_list):\n    uri = args[0]\n    title = \"\" if len(args) == 1 else args[1]\n    for radio in [False, True]:\n        try:\n            speaker.play_uri(uri, title=title, force_radio=radio)\n            return True\n        except Exception:\n            continue\n\n    error_report(\"Failed to play URI: '{}'\".format(uri))\n    return False\n\n\n@zero_or_one_parameter\ndef sleep_timer(speaker, action, args, soco_function, use_local_speaker_list):\n    np = len(args)\n    if np == 0:\n        st = speaker.get_sleep_timer()\n        if st is not None:\n            time_now = datetime.now()\n            remaining_seconds = timedelta(0, st)\n            expiry_time = time_now + remaining_seconds\n            print(\n                \"Sleep timer expires in {} at {}\".format(\n                    remaining_seconds, expiry_time.strftime(\"%H:%M\")\n                )\n            )\n        else:\n            print(\"0 (No sleep timer set)\")\n    elif np == 1:\n        if args[0].lower() in [\"off\", \"cancel\"]:\n            logging.info(\"Cancelling sleep timer\")\n            speaker.set_sleep_timer(None)\n        else:\n            try:\n                duration = convert_to_seconds(args[0])\n            except ValueError:\n                parameter_type_error(\n                    action,\n                    \"number of hours, seconds or minutes + 'h/m/s', or off|cancel\",\n                )\n                return False\n            if 0 <= duration <= 86399:\n                logging.info(\"Setting sleep timer to {}s\".format(duration))\n                speaker.set_sleep_timer(duration)\n            else:\n                parameter_type_error(action, \"maximum duration is 23.999hrs\")\n                return False\n    return True\n\n\n@one_parameter\ndef sleep_at(speaker, action, args, soco_function, use_local_speaker_list):\n    try:\n        duration = seconds_until(args[0])\n    except ValueError:\n        parameter_type_error(action, \"a time in 24hr 'HH:MM' or 'HH:MM:SS' format\")\n        return False\n    if 0 <= duration <= 86399:\n        logging.info(\"Setting sleep timer to {}s\".format(duration))\n        speaker.set_sleep_timer(duration)\n    else:\n        parameter_type_error(action, \"maximum duration is 23.999hrs\")\n        return False\n    return True\n\n\n@one_parameter\ndef group_or_pair(speaker, action, args, soco_function, use_local_speaker_list):\n    speaker2 = get_speaker(args[0], use_local_speaker_list)\n    if not speaker2:\n        error_report(\"Speaker '{}' not found\".format(args[0]))\n        return False\n    if speaker == speaker2:\n        error_report(\"Speakers are the same\")\n        return False\n    logging.info(\n        \"Executing '{}' on speakers '{}', '{}'\".format(\n            soco_function, speaker.player_name, speaker2.player_name\n        )\n    )\n    getattr(speaker, soco_function)(speaker2)\n    return True\n\n\n@two_parameters\ndef add_satellite_speakers(\n    speaker, action, args, soco_function, use_local_speaker_list\n):\n    left_rear = get_speaker(args[0], use_local_speaker_list)\n    if not left_rear:\n        error_report(\"Speaker '{}' not found\".format(args[0]))\n        return False\n    right_rear = get_speaker(args[1], use_local_speaker_list)\n    if not right_rear:\n        error_report(\"Speaker '{}' not found\".format(args[1]))\n        return False\n    getattr(speaker, soco_function)(left_rear, right_rear)\n    return True\n\n\n@one_or_more_parameters\ndef multi_group(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"\n    Group one or more speakers with a coordinator speaker. Note: reverses the usual\n    order; the target speaker is the coordinator, not the speaker to be grouped.\n    \"\"\"\n    logging.info(\"Grouping speakers '{}' with '{}'\".format(args, speaker.player_name))\n    for speaker_name in args:\n        target_speaker = get_speaker(speaker_name, use_local_speaker_list)\n        if not target_speaker:\n            error_report(\"Speaker '{}' not found\".format(speaker_name))\n            continue\n        logging.info(\n            \"Grouping speaker '{}' with coordinator '{}'\".format(\n                target_speaker.player_name, speaker.player_name\n            )\n        )\n        group_or_pair(\n            target_speaker,\n            action,\n            [speaker.player_name],\n            soco_function,\n            use_local_speaker_list,\n        )\n    return True\n\n\n@zero_parameters\ndef operate_on_all(speaker, action, args, soco_function, use_local_speaker_list):\n    zones = speaker.all_zones\n    for zone in zones:\n        if zone.is_visible:\n            try:\n                logging.info(\n                    \"Executing '{}' on speaker '{}'\".format(\n                        soco_function, zone.player_name\n                    )\n                )\n                getattr(zone, soco_function)()\n            except Exception:\n                logging.info(\"Operation failed ... continuing\")\n                # Ignore errors here; don't want to halt on\n                # a failed pause (e.g., if speaker isn't playing)\n                continue\n    return True\n\n\n@zero_parameters\ndef zones(speaker, action, args, soco_function, use_local_speaker_list):\n    zones = speaker.all_zones if \"all\" in action else speaker.visible_zones\n    count = 1\n    for zone in zones:\n        if 1 < count < len(zones) + 1:\n            print(\", \", end=\"\")\n        print('\"{}\"'.format(zone.player_name), end=\"\")\n        count += 1\n    print()\n    return True\n\n\n@zero_or_one_parameter\ndef play_from_queue(speaker, action, args, soco_function, use_local_speaker_list):\n    np = len(args)\n    if np == 0:\n        speaker.play_from_queue(0)\n        return True\n    if args[0] in [\"current\", \"cp\", \"current_position\"]:\n        index, _ = get_current_queue_position(speaker)\n    elif args[0] in [\"last\", \"lp\", \"last_position\"]:\n        index = len(speaker.get_queue(max_items=SONOS_MAX_ITEMS))\n    elif args[0] in [\"random\", \"rand\", \"r\"]:\n        index = randint(1, len(speaker.get_queue(max_items=SONOS_MAX_ITEMS)))\n    elif args[0] in [\"last_added\", \"la\"]:\n        try:\n            index = get_queue_insertion_position()\n        except Exception as e:\n            error_report(\"No saved queue position: {}\".format(e))\n            return False\n    else:\n        try:\n            index = int(args[0])\n        except ValueError:\n            parameter_type_error(\n                action,\n                \"integer, 'current', 'last', or 'random'\",\n            )\n            return False\n    if 1 <= index <= speaker.queue_size:\n        speaker.play_from_queue(index - 1)\n    else:\n        error_report(\"Queue index '{}' is out of range\".format(index))\n        return False\n    return True\n\n\n@one_parameter\ndef remove_from_queue(speaker, action, args, soco_function, use_local_speaker_list):\n    # Generate a list that represents which tracks to remove, denoted by '0'\n    # Initially mark each track as '1' (retain)\n    if queue_is_empty(speaker):\n        return False\n    queue = []\n    for _ in range(speaker.queue_size):\n        queue.append(1)\n    # Catch exceptions at the end\n    # Note: this can be refactored using utils.create_list_of_items_from_range()\n    try:\n        # Create a list of items to remove based on the input args\n        # Mark these as '0'\n        items = args[0].split(\",\")\n        for index in items:\n            # Check for a range ('x-y') instead of a single integer\n            if \"-\" in index:\n                rng = index.split(\"-\")\n                if len(rng) != 2:\n                    parameter_type_error(\n                        action, \"two integers and a '-', e.g., '3-7' when using a range\"\n                    )\n                    return False\n                index_1 = int(rng[0])\n                index_2 = int(rng[1])\n                if index_1 < 1 or index_2 < 1:\n                    raise IndexError\n                if index_1 > index_2:\n                    # Reverse the indices\n                    index_2, index_1 = index_1, index_2\n                for i in range(index_1 - 1, index_2):\n                    queue[i] = 0\n            else:\n                index = int(index)\n                if index < 1:\n                    raise IndexError\n                queue[index - 1] = 0\n    # Exception handling\n    # Catch any non-integer input values\n    except ValueError:\n        parameter_type_error(\n            action,\n            \"integer, or comma-separated integers without spaces (e.g., 3,7,4)\",\n        )\n        return False\n    # Catch any out-of-range values\n    except IndexError:\n        error_report(\n            \"Queue index(es) must be between 1 and {} (inclusive)\".format(len(queue))\n        )\n        return False\n    # Walk though the list of tracks from position 1, removing items marked '0'\n    # Account for the queue shift by keeping count of those deleted\n    logging.info(\"Created map of queue items to delete (==0) {}\".format(queue))\n    # Note: do not switch the loop below to 'enumerate'. Yield behaviour breaks\n    # the sequencing of requests to Sonos.\n    # pylint: disable = consider-using-enumerate\n    count_removed = 0\n    for index in range(len(queue)):\n        if queue[index] == 0:\n            updated_index = index - count_removed\n            speaker.remove_from_queue(updated_index)\n            logging.info(\n                \"Removing queue item at (adjusted) index {}\".format(updated_index + 1)\n            )\n            count_removed += 1\n    return True\n\n\n@zero_parameters\ndef remove_current_track_from_queue(\n    speaker, action, args, soco_function, use_local_speaker_list\n):\n    if queue_is_empty(speaker):\n        return False\n    current_track = int(speaker.get_current_track_info()[\"playlist_position\"])\n    logging.info(\"Removing track {}\".format(current_track))\n    speaker.remove_from_queue(current_track - 1)\n    return True\n\n\n@zero_or_one_parameter\ndef remove_last_track_from_queue(\n    speaker, action, args, soco_function, use_local_speaker_list\n):\n    queue_size = speaker.queue_size\n    logging.info(\"Queue size is {}\".format(queue_size))\n    if queue_is_empty(speaker):\n        return False\n    if len(args) == 1:\n        try:\n            count = int(args[0])\n        except ValueError:\n            parameter_type_error(action, \"an integer > 1\")\n        if not 1 <= count <= queue_size:\n            error_report(\"parameter must be between 1 and {}\".format(queue_size))\n            return False\n    else:\n        count = 1\n    logging.info(\"Removing the last {} tracks from the queue\".format(count))\n    while count > 0:\n        logging.info(\"Removing track {}\".format(queue_size))\n        speaker.remove_from_queue(queue_size - 1)\n        queue_size -= 1\n        count -= 1\n    return True\n\n\n@one_parameter\ndef save_queue(speaker, action, args, soco_function, use_local_speaker_list):\n    if queue_is_empty(speaker):\n        return False\n    speaker.create_sonos_playlist_from_queue(args[0])\n    return True\n\n\n@one_parameter\ndef seek(speaker, action, args, soco_function, use_local_speaker_list):\n    try:\n        seconds = convert_to_seconds(args[0])\n    except ValueError:\n        parameter_type_error(action, \"a valid time format\")\n        return False\n    if seconds < 0:\n        parameter_type_error(action, \"cannot seek to before start of track\")\n        return False\n    seek_point = str(timedelta(seconds=seconds))\n    logging.info(\"Seek point is {}\".format(seek_point))\n    try:\n        # seek() will handle out-of-bounds\n        speaker.seek(seek_point)\n    except SoCoUPnPException:\n        parameter_type_error(action, \"valid time value on a seekable source\")\n        return False\n    return True\n\n\n@one_parameter\ndef seek_forward(speaker, action, args, soco_function, use_local_speaker_list):\n    # Calculate the time increment\n    increment = int(convert_to_seconds(args[0]))  # Integer number of seconds\n    if increment < 0:\n        parameter_type_error(action, \"a positive time increment\")\n        return False\n    logging.info(\"Seeking forward by {}s\".format(increment))\n\n    td_current = _get_track_position_timedelta(speaker)\n    td_increment = timedelta(seconds=increment)\n    td_new_str = str(td_current + td_increment)\n    logging.info(\n        \"Seeking forward to position '{}' ... note: might hit end of track\".format(\n            td_new_str\n        )\n    )\n    try:\n        speaker.seek(td_new_str)\n    except SoCoUPnPException:\n        parameter_type_error(action, \"time increment on a seekable source\")\n        return False\n    return True\n\n\n@one_parameter\ndef seek_back(speaker, action, args, soco_function, use_local_speaker_list):\n    # Calculate the time increment\n    increment = int(convert_to_seconds(args[0]))  # Integer number of seconds\n    if increment < 0:\n        parameter_type_error(action, \"a positive time increment\")\n        return False\n    logging.info(\"Seeking backward by {}s\".format(increment))\n\n    td_current = _get_track_position_timedelta(speaker)\n    td_increment = timedelta(seconds=increment)\n    if td_current - td_increment < timedelta():\n        logging.info(\"Cannot seek beyond start of track ... seek to start instead\")\n        td_new_str = \"00:00:00\"\n    else:\n        td_new_str = str(td_current - td_increment)\n    logging.info(\"Seeking backward to position '{}'\".format(td_new_str))\n    try:\n        speaker.seek(td_new_str)\n    except SoCoUPnPException:\n        parameter_type_error(action, \"time increment on a seekable source\")\n        return False\n    return True\n\n\n@one_or_two_parameters\ndef playlist_operations(speaker, action, args, soco_function, use_local_speaker_list):\n    name = args[0]\n    if soco_function == \"create_sonos_playlist\":\n        getattr(speaker, soco_function)(name)\n        return True\n    if soco_function == \"add_uri_to_queue\":\n        getattr(speaker, soco_function)(name)\n        return True\n\n    if soco_function == \"remove_sonos_playlist\":\n        try:\n            playlist = get_playlist(speaker, name)\n            speaker.remove_sonos_playlist(playlist)\n        except SoCoUPnPException:\n            error_report(\"Playlist '{}' not found\".format(name))\n        return True\n\n    playlist = None\n    if soco_function == \"add_to_queue\":\n        playlist = get_playlist(speaker, name)\n    elif soco_function == \"add_library_playlist_to_queue\":\n        playlist = get_playlist(speaker, name, library=True)\n\n    if playlist is not None:\n        if soco_function in [\"add_to_queue\", \"add_library_playlist_to_queue\"]:\n            if len(args) == 2:\n                position = get_queue_insertion_position(speaker, args[1], action)\n            else:\n                position = speaker.queue_size + 1\n            result = speaker.add_to_queue(playlist, position=position)\n            save_queue_insertion_position(position)\n            print(result)\n        else:\n            getattr(speaker, soco_function)(playlist)\n    else:\n        error_report(\"Playlist '{}' not found\".format(args[0]))\n        return False\n    return True\n\n\n@one_parameter\ndef list_playlist_tracks(speaker, action, args, soco_function, use_local_speaker_list):\n    playlist = get_playlist(speaker, args[0])\n    if playlist:\n        print()\n        print_list_header(\"Sonos Playlist:\", playlist.title)\n        tracks = speaker.music_library.browse_by_idstring(\n            \"sonos_playlists\", playlist.item_id, max_items=SONOS_MAX_ITEMS\n        )\n        print_tracks(tracks)\n        print()\n        save_search(tracks)\n        return True\n\n    error_report(\"Playlist '{}' not found\".format(args[0]))\n    return False\n\n\n@one_parameter\ndef list_library_playlist_tracks(\n    speaker, action, args, soco_function, use_local_speaker_list\n):\n    playlist = get_playlist(speaker, args[0], library=True)\n    if playlist:\n        print()\n        print_list_header(\"Library Playlist:\", playlist.title)\n        tracks = speaker.music_library.browse_by_idstring(\n            \"playlists\", playlist.item_id, max_items=SONOS_MAX_ITEMS\n        )\n        print_tracks(tracks)\n        print()\n        save_search(tracks)\n        return True\n\n    error_report(\"Playlist '{}' not found\".format(args[0]))\n    return False\n\n\n@two_parameters\ndef remove_from_playlist(speaker, action, args, soco_function, use_local_speaker_list):\n    name = args[0]\n    try:\n        track_number = int(args[1])\n    except ValueError:\n        parameter_type_error(action, \"integer (track number)\")\n        return False\n    playlist = get_playlist(speaker, name)\n    if playlist:\n        speaker.remove_from_sonos_playlist(playlist, track_number - 1)\n        return True\n\n    error_report(\"Playlist '{}' not found\".format(args[0]))\n    return False\n\n\n@zero_one_or_two_parameters\ndef line_in(speaker, action, args, soco_function, use_local_speaker_list):\n    return line_in_core(speaker, action, args, True, use_local_speaker_list)\n\n\n@zero_one_or_two_parameters\ndef cue_line_in(speaker, action, args, soco_function, use_local_speaker_list):\n    if len(args) == 0:\n        logging.info(\"'cue_line_in' invoked without parameters; insert 'on'\")\n        new_args = (\"on\",)\n    else:\n        new_args = args\n    return line_in_core(speaker, action, new_args, False, use_local_speaker_list)\n\n\ndef line_in_core(speaker, action, args, start_playback, use_local_speaker_list):\n    np = len(args)\n    if np == 0:\n        state = \"on\" if speaker.is_playing_line_in else \"off\"\n        state = state + \" ({})\".format(\n            speaker.get_current_transport_info()[\"current_transport_state\"]\n        )\n        print(state)\n    else:\n        source = args[0]\n        if source.lower() == \"off\":\n            logging.info(\"Stopping playback\")\n            speaker.stop()\n        elif source.lower() in [\"on\", \"left_input\"]:\n            # Switch to the speaker's own line_in\n            logging.info(\"Switching to the speaker's own Line-In\")\n            try:\n                speaker.switch_to_line_in()\n                if start_playback:\n                    logging.info(\"Starting playback\")\n                    speaker.play()\n                else:\n                    logging.info(\"Stopping playback\")\n                    speaker.stop()\n            except SoCoUPnPException:\n                error_report(\"Line In operation failed ... not supported?\")\n                return False\n        else:\n            if source.lower() == \"right_input\":\n                # We want the right-hand speaker of the stereo pair\n                logging.info(\"Looking for right-hand speaker\")\n                line_in_source = get_right_hand_speaker(speaker)\n            else:\n                # We want to use another speaker's input\n                if np == 2:  # Want to select the input of a stereo pair\n                    the_input = args[1].lower()\n                    if the_input == \"right_input\":\n                        logging.info(\"Using right-hand speaker's input\")\n                        logging.info(\"Looking for right-hand speaker\")\n                        left_speaker = get_speaker(source, use_local_speaker_list)\n                        line_in_source = get_right_hand_speaker(left_speaker)\n                    elif the_input == \"left_input\":\n                        logging.info(\"Using left-hand speaker's input\")\n                        line_in_source = get_speaker(source, use_local_speaker_list)\n                    else:\n                        parameter_type_error(\n                            action,\n                            \"second parameter (if present) must be 'left_input' or\"\n                            \" 'right_input'\",\n                        )\n                        return False\n                else:\n                    logging.info(\"Using left-hand speaker's input\")\n                    line_in_source = get_speaker(source, use_local_speaker_list)\n            if not line_in_source:\n                error_report(\"Speaker or input '{}' not found\".format(source))\n                return False\n            logging.info(\"Switching to Line-In\")\n            try:\n                speaker.switch_to_line_in(line_in_source)\n                if start_playback:\n                    logging.info(\"Starting playback\")\n                    speaker.play()\n                else:\n                    logging.info(\"Stopping playback\")\n                    speaker.stop()\n            except SoCoUPnPException:\n                error_report(\"Line In operation failed ... not supported?\")\n                return False\n    return True\n\n\n@zero_or_one_parameter\ndef eq(speaker, action, args, soco_function, use_local_speaker_list):\n    np = len(args)\n    if np == 0:\n        print(getattr(speaker, soco_function))\n    elif np == 1:\n        try:\n            setting = int(args[0])\n        except ValueError:\n            parameter_type_error(action, \"integer from -10 to 10\")\n            return False\n        if -10 <= setting <= 10:\n            setattr(speaker, soco_function, setting)\n        else:\n            parameter_type_error(action, \"integer from -10 to 10\")\n            return False\n    return True\n\n\n@one_parameter\ndef eq_relative(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Set an EQ value by a relative amount\"\"\"\n    upper_limit = 15 if soco_function == \"sub_gain\" else 10\n    lower_limit = upper_limit * -1\n    try:\n        delta = int(args[0])\n    except ValueError:\n        parameter_type_error(\n            action, \"integer from {} to {}\".format(lower_limit, upper_limit)\n        )\n        return False\n    current = getattr(speaker, soco_function)\n    new_value = current + delta\n    new_value = (\n        lower_limit\n        if new_value < lower_limit\n        else upper_limit if new_value > upper_limit else new_value\n    )\n    logging.info(\"Requested delta = '{}', new_value = '{}'\".format(delta, new_value))\n    setattr(speaker, soco_function, new_value)\n    return True\n\n\n@zero_or_one_parameter\ndef balance(speaker, action, args, soco_function, use_local_speaker_list):\n    np = len(args)\n    if np == 0:\n        left, right = getattr(speaker, soco_function)\n        # Convert to something more intelligible than a 2-tuple\n        # Use range from -100 (full left) to +100 (full right)\n        print(right - left)\n    elif np == 1:\n        try:\n            setting = int(args[0])\n        except ValueError:\n            parameter_type_error(action, \"integer from -100 to 100\")\n            return False\n        if -100 <= setting <= 100:\n            if setting >= 0:\n                left = 100 - setting\n                right = 100\n            elif setting < 0:\n                left = 100\n                right = 100 + setting\n            setattr(speaker, soco_function, (left, right))\n        else:\n            parameter_type_error(action, \"integer from -100 to 100\")\n            return False\n    return True\n\n\n@zero_parameters\ndef reindex(speaker, action, args, soco_function, use_local_speaker_list):\n    if not speaker.music_library.library_updating:\n        speaker.music_library.start_library_update()\n        print(\"Library reindex started\")\n    else:\n        print(\"A library reindex is already in progress\")\n    return True\n\n\n@zero_parameters\ndef is_indexing(speaker, action, args, soco_function, use_local_speaker_list):\n    if speaker.music_library.library_updating:\n        print(\"yes\")\n    else:\n        print(\"no\")\n    return True\n\n\n@zero_parameters\ndef info(speaker, action, args, soco_function, use_local_speaker_list):\n    info = speaker.get_speaker_info()\n    model = info[\"model_name\"].lower()\n    if not (\"boost\" in model or \"bridge\" in model):\n        info[\"volume\"] = speaker.volume\n        info[\"mute\"] = speaker.mute\n        info[\"title\"] = speaker.get_current_track_info()[\"title\"]\n        info[\"player_name\"] = speaker.player_name\n        info[\"ip_address\"] = speaker.ip_address\n        info[\"household_id\"] = speaker.household_id\n        info[\"status_light\"] = speaker.status_light\n        info[\"is_coordinator\"] = speaker.is_coordinator\n        info[\"grouped_or_paired\"] = len(speaker.group.members) > 1\n        info[\"loudness\"] = speaker.loudness\n        info[\"treble\"] = speaker.treble\n        info[\"bass\"] = speaker.bass\n        info[\"is_coordinator\"] = speaker.is_coordinator\n        if speaker.is_coordinator:\n            info[\"cross_fade\"] = speaker.cross_fade\n            info[\"state\"] = speaker.get_current_transport_info()[\n                \"current_transport_state\"\n            ]\n        else:\n            info[\"cross_fade\"] = speaker.group.coordinator.cross_fade\n            info[\"state\"] = speaker.group.coordinator.get_current_transport_info()[\n                \"current_transport_state\"\n            ]\n        info[\"balance\"] = speaker.balance\n        info[\"night_mode\"] = speaker.night_mode\n        info[\"is_soundbar\"] = speaker.is_soundbar\n        info[\"is_playing_line_in\"] = speaker.is_playing_line_in\n        info[\"is_playing_radio\"] = speaker.is_playing_radio\n        info[\"is_playing_tv\"] = speaker.is_playing_tv\n        info[\"is_visible\"] = speaker.is_visible\n        info[\"sub_gain\"] = speaker.sub_gain\n    for item in sorted(info):\n        print(\"  {} = {}\".format(item, info[item]))\n    return True\n\n\n@zero_parameters\ndef groups(speaker, action, args, soco_function, use_local_speaker_list):\n    for group in speaker.all_groups:\n        if group.coordinator.is_visible:\n            print(\"{}: \".format(group.coordinator.player_name), end=\"\")\n            first = True\n            for member in group.members:\n                if member != group.coordinator:\n                    if member.is_visible:\n                        if not first:\n                            print(\", \", end=\"\")\n                        print(\"{}\".format(member.player_name), end=\"\")\n                        first = False\n            print()\n    return True\n\n\n@zero_parameters\ndef list_libraries(speaker, action, args, soco_function, use_local_speaker_list):\n    shares = speaker.music_library.list_library_shares()\n    index = 0\n    for share in sorted(shares):\n        index += 1\n        print(\"{:2d}: {}\".format(index, share))\n    return True\n\n\n@zero_parameters\ndef system_info(speaker, action, args, soco_function, use_local_speaker_list):\n    print_speaker_table(speaker)\n    return True\n\n\n@zero_parameters\ndef list_all_playlist_tracks(\n    speaker, action, args, soco_function, use_local_speaker_list\n):\n    playlists = speaker.get_sonos_playlists(complete_result=True)\n    print()\n    for playlist in playlists:\n        print_list_header(\"Sonos Playlist:\", playlist.title)\n        tracks = speaker.music_library.browse_by_idstring(\n            \"sonos_playlists\", playlist.item_id\n        )\n        print_tracks(tracks)\n        print()\n    return True\n\n\ndef wait_stop_core(speaker, not_paused=False):\n    playing_states = [\"PLAYING\", \"TRANSITIONING\"]\n    if not_paused:\n        # Also treat 'paused' as a playing state\n        playing_states.append(\"PAUSED_PLAYBACK\")\n\n    try:\n        sub = speaker.avTransport.subscribe(auto_renew=True)\n        remember_event_sub(sub)\n    except Exception as e:\n        error_report(\"Exception {}\".format(e))\n        return False\n\n    while True:\n        try:\n            event = sub.events.get(timeout=1.0)\n            if event.variables[\"transport_state\"] not in playing_states:\n                logging.info(\n                    \"Speaker '{}' in state '{}'\".format(\n                        speaker.player_name, event.variables[\"transport_state\"]\n                    )\n                )\n                event_unsubscribe(sub)\n                forget_event_sub(sub)\n                return True\n        except Exception:\n            pass\n\n\n@zero_parameters\ndef wait_stop(speaker, action, args, soco_function, use_local_speaker_list):\n    return wait_stop_core(speaker)\n\n\n@zero_parameters\ndef wait_stop_not_pause(speaker, action, args, soco_function, use_local_speaker_list):\n    return wait_stop_core(speaker, not_paused=True)\n\n\ndef wait_stopped_for_core(speaker, action, duration_arg, not_paused=False):\n    try:\n        duration = convert_to_seconds(duration_arg)\n    except ValueError:\n        parameter_type_error(action, \"Time h/m/s or HH:MM:SS\")\n\n    logging.info(\"Waiting until playback stopped for {}s\".format(duration))\n\n    wait_stop_core(speaker, not_paused=not_paused)\n\n    playing_states = [\"PLAYING\", \"TRANSITIONING\"]\n    if not_paused:\n        # Also treat 'paused' as a playing state\n        playing_states.append(\"PAUSED_PLAYBACK\")\n\n    # Poll for changes; count down reset timer\n    # TODO: Polling is not ideal; should be redesigned using events\n    original_start_time = start_time = current_time = time.time()\n    poll_interval = 10\n    logging.info(\n        \"Checking for not {}, poll interval = {}s\".format(playing_states, poll_interval)\n    )\n    while (current_time - start_time) < duration:\n        state = speaker.get_current_transport_info()[\"current_transport_state\"]\n        logging.info(\"Transport state = '{}'\".format(state))\n        if state in playing_states:\n            # Restart the timer\n            logging.info(\"Restarting the timer\")\n            start_time = current_time\n        remaining_time = duration - (current_time - start_time)\n        logging.info(\n            \"Elapsed since last 'STOPPED' = {}s | total elapsed = {}s | remaining = {}s\".format(\n                int(current_time - start_time),\n                int(current_time - original_start_time),\n                int(remaining_time),\n            )\n        )\n        if remaining_time <= poll_interval:\n            time.sleep(remaining_time)\n        else:\n            time.sleep(poll_interval)\n        current_time = time.time()\n    logging.info(\n        \"Timer expired after 'STOPPED' for {}s | total elapsed = {}s\".format(\n            int(current_time - start_time),\n            int(current_time - original_start_time),\n        )\n    )\n    return True\n\n\n@one_parameter\ndef wait_stopped_for(speaker, action, args, soco_function, use_local_speaker_list):\n    return wait_stopped_for_core(speaker, action, args[0], not_paused=False)\n\n\n@one_parameter\ndef wait_stopped_for_not_pause(\n    speaker, action, args, soco_function, use_local_speaker_list\n):\n    return wait_stopped_for_core(speaker, action, args[0], not_paused=True)\n\n\n@zero_parameters\ndef wait_start(speaker, action, args, soco_function, use_local_speaker_list):\n    try:\n        sub = speaker.avTransport.subscribe(auto_renew=True)\n        remember_event_sub(sub)\n    except Exception as e:\n        error_report(\"Exception {}\".format(e))\n        return False\n    while True:\n        try:\n            event = sub.events.get(timeout=1.0)\n            if event.variables[\"transport_state\"] == \"PLAYING\":\n                logging.info(\n                    \"Speaker '{}' in state '{}'\".format(\n                        speaker.player_name, event.variables[\"transport_state\"]\n                    )\n                )\n                event_unsubscribe(sub)\n                forget_event_sub(sub)\n                return True\n        except Exception:\n            pass\n\n\n@one_or_two_parameters\ndef search_artists(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"\n    Search for albums featuring the specified artist\n    \"\"\"\n    ml = speaker.music_library\n    name = args[0]\n    artists = ml.get_music_library_information(\n        \"artists\", search_term=name, complete_result=True\n    )\n\n    # Accumulate search results & artist names\n    all_search_results = None\n    all_artists = \"\"\n    for index, artist in enumerate(artists):\n        if (\n            len(args) == 2\n            and \"strict\" in args[1].lower()\n            and name.lower() != artist.title.lower()\n        ):\n            continue\n        search_result = ml.get_music_library_information(\n            \"artists\", subcategories=[artist.title], max_items=SONOS_MAX_ITEMS\n        )\n        # Remove the first, unnecessary element from the list\n        search_result.pop(0)\n        if len(search_result) > 0:\n            if index == 0:\n                all_artists += artist.title\n            else:\n                all_artists += \", \" + artist.title\n        if all_search_results is None:\n            all_search_results = search_result\n        else:\n            # The SearchResult class is a subclass of List\n            all_search_results += search_result\n\n    if all_search_results is None:\n        return True\n\n    print()\n    print_list_header(\"Sonos Music Library Albums including Artist(s):\", all_artists)\n    print_albums(all_search_results, omit_first=False)\n    print()\n\n    save_search(all_search_results)\n    return True\n\n\n@zero_parameters\ndef list_artists(speaker, action, args, soco_function, use_local_speaker_list):\n    ml = speaker.music_library\n    artists = ml.get_artists(complete_result=True)\n    print()\n    print_list_header(\"Sonos Music Library Artists\", \"\")\n    print_artists(artists)\n    print()\n    return True\n\n\n@zero_parameters\ndef list_albums(speaker, action, args, soco_function, use_local_speaker_list):\n    ml = speaker.music_library\n    artists = ml.get_albums(complete_result=True)\n    print()\n    print_list_header(\"Sonos Music Library Albums\", \"\")\n    print_albums(artists)\n    print()\n    save_search(artists)\n    return True\n\n\n@one_or_two_parameters\ndef search_albums(speaker, action, args, soco_function, use_local_speaker_list):\n    ml = speaker.music_library\n    name = args[0]\n    albums = ml.get_music_library_information(\n        \"albums\", search_term=name, complete_result=True\n    )\n\n    if len(args) == 2:\n        if \"strict\" == args[1].lower():\n            albums = [album for album in albums if album.title.lower() == name.lower()]\n        else:\n            error_report(\"Second parameter must be 'strict' not '{}'\".format(args[1]))\n            return False\n\n    if len(albums) > 0:\n        print()\n        print_list_header(\"Sonos Music Library Album Search:\", name)\n        print_albums(albums)\n        print()\n        save_search(albums)\n    return True\n\n\n@one_or_two_parameters\ndef search_tracks(speaker, action, args, soco_function, use_local_speaker_list):\n    ml = speaker.music_library\n    name = args[0]\n    tracks = ml.get_music_library_information(\n        \"tracks\", search_term=name, complete_result=True\n    )\n\n    if len(args) == 2:\n        if \"strict\" == args[1].lower():\n            tracks = [track for track in tracks if track.title.lower() == name.lower()]\n        else:\n            error_report(\"Second parameter must be 'strict' not '{}'\".format(args[1]))\n            return False\n\n    if len(tracks) > 0:\n        print()\n        print_list_header(\"Sonos Music Library Track Search:\", name)\n        print_tracks(tracks)\n        print()\n        save_search(tracks)\n    return True\n\n\n@one_or_two_parameters\ndef search_library(speaker, action, args, soco_function, use_local_speaker_list):\n    search_artists(speaker, action, args, soco_function, use_local_speaker_list)\n    search_albums(speaker, action, args, soco_function, use_local_speaker_list)\n    search_tracks(speaker, action, args, soco_function, use_local_speaker_list)\n    return True\n\n\n@one_or_two_parameters\ndef tracks_in_album(speaker, action, args, soco_function, use_local_speaker_list):\n    ml = speaker.music_library\n    name = args[0]\n    albums = ml.get_music_library_information(\n        \"albums\", search_term=name, complete_result=True\n    )\n\n    if len(args) == 2:\n        if \"strict\" == args[1].lower():\n            albums = [album for album in albums if album.title.lower() == name.lower()]\n        else:\n            error_report(\"Second parameter must be 'strict' not '{}'\".format(args[1]))\n            return False\n\n    logging.info(\"Found {} album(s) matching '{}'\".format(len(albums), name))\n\n    for album in albums:\n        tracks = ml.get_music_library_information(\n            \"artists\", subcategories=[\"\", album.title], complete_result=True\n        )\n        print()\n        print_list_header(\"Sonos Music Library Tracks in Album:\", album.title)\n        print_tracks(tracks)\n        print()\n        save_search(tracks)\n\n    return True\n\n\ndef queue_item_core(speaker, action, args, info_type):\n    name = args[0]\n    items = speaker.music_library.get_music_library_information(\n        info_type, search_term=name, complete_result=True\n    )\n    if len(items) > 0:\n        if len(args) == 2:\n            position = get_queue_insertion_position(speaker, args[1], action)\n        else:\n            position = speaker.queue_size + 1\n        # Select a random entry from the list, in case there's more than one\n        item = items[randint(0, len(items) - 1)]\n        queue_position = speaker.add_to_queue(item, position=position)\n        save_queue_insertion_position(queue_position)\n        print(queue_position)\n        return True\n\n    error_report(\"'{}' not found\".format(name))\n    return False\n\n\n@one_or_two_parameters\ndef queue_album(speaker, action, args, soco_function, use_local_speaker_list):\n    return queue_item_core(speaker, action, args, \"albums\")\n\n\n@one_or_two_parameters\ndef queue_track(speaker, action, args, soco_function, use_local_speaker_list):\n    return queue_item_core(speaker, action, args, \"tracks\")\n\n\n@one_or_more_parameters\ndef if_stopped_or_playing(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"\n    Perform the action only if the speaker is currently in the desired playback state\n    \"\"\"\n    # If this is not the coordinator speaker, we need to check the state\n    # of the coordinator instead\n    state_speaker = speaker if speaker.is_coordinator else speaker.group.coordinator\n    logging.info(\n        \"Checking playback state of coordinator speaker: '{}'\".format(\n            state_speaker.player_name\n        )\n    )\n    state = state_speaker.get_current_transport_info()[\"current_transport_state\"]\n    logging.info(\n        \"Condition: '{}': Speaker '{}' is in state '{}'\".format(\n            action, state_speaker.player_name, state\n        )\n    )\n    if (state != \"PLAYING\" and action == \"if_playing\") or (\n        state == \"PLAYING\" and action == \"if_stopped\"\n    ):\n        logging.info(\"Action suppressed\")\n        return True\n\n    action = args[0]\n    args = args[1:]\n    logging.info(\n        \"Action invoked: '{} {} {}'\".format(speaker.player_name, action, \" \".join(args))\n    )\n    return process_action(\n        speaker, action, args, use_local_speaker_list=use_local_speaker_list\n    )\n\n\n@one_or_more_parameters\ndef if_coordinator(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"\n    Perform the action only if the target speaker is (or is not) a coordinator.\n    \"\"\"\n\n    if (speaker.is_coordinator and action == \"if_not_coordinator\") or (\n        not speaker.is_coordinator and action == \"if_coordinator\"\n    ):\n        logging.info(\"Action suppressed\")\n        return True\n\n    action = args[0]\n    args = args[1:]\n    logging.info(\n        \"Action invoked: '{} {} {}'\".format(speaker.player_name, action, \" \".join(args))\n    )\n    return process_action(\n        speaker, action, args, use_local_speaker_list=use_local_speaker_list\n    )\n\n\n@one_or_more_parameters\ndef if_queue(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"\n    Perform the action only if the queue is empty or non-empty\n    \"\"\"\n    # If this is not the coordinator speaker, we need to check the state\n    # of the coordinator instead\n    queue_speaker = speaker if speaker.is_coordinator else speaker.group.coordinator\n    logging.info(\n        \"Checking queue of coordinator speaker: '{}'\".format(queue_speaker.player_name)\n    )\n    logging.info(\n        \"Condition: '{}': Speaker '{}' has {} item(s) in the queue\".format(\n            action, queue_speaker.player_name, queue_speaker.queue_size\n        )\n    )\n    if (queue_speaker.queue_size == 0 and action == \"if_queue\") or (\n        queue_speaker.queue_size > 0 and action == \"if_no_queue\"\n    ):\n        logging.info(\"Action suppressed\")\n        return True\n\n    action = args[0]\n    args = args[1:]\n    logging.info(\n        \"Action invoked: '{} {} {}'\".format(speaker.player_name, action, \" \".join(args))\n    )\n    return process_action(\n        speaker, action, args, use_local_speaker_list=use_local_speaker_list\n    )\n\n\n@one_parameter\ndef cue_favourite(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Shortcut to mute, play favourite, stop favourite, and unmute.\n    Preserve the mute state\n    \"\"\"\n    if not speaker.is_coordinator:\n        error_report(\"Action '{}' can only be applied to a coordinator\".format(action))\n        return False\n    unmute = False\n    unmute_group = False\n    if not speaker.mute:\n        speaker.mute = True\n        unmute = True\n    if not speaker.group.mute:\n        speaker.group.mute = True\n        unmute_group = True\n    if action in [\"cfrs\", \"cue_favourite_radio_station\", \"cue_favorite_radio_station\"]:\n        result = play_favourite_radio(\n            speaker, action, args, soco_function, use_local_speaker_list\n        )\n        msg = \"\"\n    else:\n        result, msg = play_favourite_core(speaker, args[0])\n    speaker.stop()\n    if unmute:\n        speaker.mute = False\n    if unmute_group:\n        speaker.group.mute = False\n    if not result:\n        error_report(msg)\n        return False\n    return True\n\n\n@one_parameter\ndef transfer_playback(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Transfer playback from one speaker to another, by grouping and ungrouping.\"\"\"\n    if not speaker.is_coordinator:\n        error_report(\"Speaker '{}' is not a coordinator\".format(speaker.player_name))\n        return False\n    speaker2 = get_speaker(args[0], use_local_speaker_list)\n    if speaker == speaker2:\n        error_report(\"Source and target speakers are the same\")\n        return False\n    if speaker2:\n        speaker2.join(speaker)\n        speaker.unjoin()\n        return True\n\n    error_report(\"Speaker '{}' not found\".format(args[0]))\n    return False\n\n\n@zero_parameters\ndef queue_position(speaker, action, args, soco_function, use_local_speaker_list):\n    position, _ = get_current_queue_position(speaker)\n    print(position)\n    return True\n\n\n@zero_parameters\ndef last_search(speaker, action, args, soco_function, use_local_speaker_list):\n    items = read_search()\n    if items:\n        if len(items) > 0:\n            print()\n            print_list_header(\"Sonos Music Library: Saved Search\", \"\")\n            if items.search_type == \"albums\":\n                print_albums(items)\n            # 'artists' search_type is used for tracks when 'tracks_in_album' has\n            #  been used for the search\n            elif items.search_type in [\"tracks\", \"artists\", \"browse\"]:\n                print_tracks(items)\n            print()\n    else:\n        error_report(\"No saved search\")\n        return False\n    return True\n\n\ndef get_queue_insertion_position(speaker, insertion_point, action):\n    \"\"\"\n    Helper function to find out where to insert something in the queue.\n    Position is 1-based.\n    Options:\n       - integer queue position\n       - first/start\n       - next/play_next\n       - last/end\n    \"\"\"\n    try:\n        position = int(insertion_point)\n        if not 1 <= position <= speaker.queue_size + 1:\n            logging.info(\n                \"Position {} is out of range ... will be constrained\".format(\n                    insertion_point\n                )\n            )\n        if position < 1:\n            position = 1\n        elif position > speaker.queue_size + 1:\n            position = speaker.queue_size + 1\n        logging.info(\"Setting position to {}\".format(position))\n        return position\n    except ValueError:\n        pass\n\n    if insertion_point.lower() in [\"play_next\", \"next\"]:\n        # Check if currently playing from the queue\n        # If so, add at the next track position\n        if (\n            speaker.get_current_transport_info()[\"current_transport_state\"]\n            == \"PLAYING\"  # noqa: W503\n            and speaker.get_current_track_info()[\"position\"]  # noqa: W503\n            != \"NOT_IMPLEMENTED\"  # noqa: W503\n        ):\n            logging.info(\"Currently playing from queue; add as next track\")\n            offset = 1\n        # Otherwise use the current position\n        else:\n            logging.info(\n                \"Not currently playing from queue; add at current queue position\"\n            )\n            offset = 0\n        position = int(speaker.get_current_track_info()[\"playlist_position\"]) + offset\n    elif insertion_point.lower() in [\"first\", \"start\"]:\n        position = 1\n    elif insertion_point.lower() in [\"last\", \"end\"]:\n        position = speaker.queue_size + 1\n    else:\n        raise Exception(\n            \"Additional parameter for '{}' must be 'first/start', 'next/play_next',\"\n            \" 'last/end', or an integer queue position\".format(action)\n        )\n    logging.info(\"Setting position to {}\".format(position))\n    return position\n\n\n@one_or_two_parameters\ndef queue_search_results(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"\n    Queue one or more items from the last saved search.\n    \"\"\"\n    items = read_search()\n    if not items:\n        error_report(\"No saved search\")\n        return False\n    logging.info(\"Loaded saved search\")\n\n    item_numbers = create_list_of_items_from_range(args[0], len(items))\n    logging.info(\"Search items to add to queue: {}\".format(item_numbers))\n\n    if len(args) == 2:\n        insertion_position = get_queue_insertion_position(speaker, args[1], action)\n    else:\n        insertion_position = speaker.queue_size + 1\n    save_queue_insertion_position(insertion_position)\n    logging.info(\"Inserting at queue position: {}\".format(insertion_position))\n\n    current_position = insertion_position\n    for index, item_number in enumerate(item_numbers):\n        current_queue_size = speaker.queue_size\n        speaker.add_to_queue(items[item_number - 1], current_position)\n        if index + 1 != len(item_numbers):\n            current_position += speaker.queue_size - current_queue_size\n            logging.info(\n                \"Advancing queue insertion point to: {}\".format(current_position)\n            )\n\n    print(insertion_position)\n    return True\n\n\ndef cue_favourite_radio_station(\n    speaker, action, args, soco_function, use_local_speaker_list\n):\n    return cue_favourite(speaker, action, args, soco_function, use_local_speaker_list)\n\n\n@zero_parameters\ndef battery(speaker, action, args, soco_function, use_local_speaker_list):\n    try:\n        battery_status = speaker.get_battery_info()\n    except NotSupportedException:\n        error_report(\"Battery status not supported by '{}'\".format(speaker.player_name))\n        return False\n    except Exception:\n        error_report(\"Unable to retrieve battery status\")\n        return False\n\n    for key, value in battery_status.items():\n        if key == \"Level\":\n            value = str(value) + \"%\"\n        print(\"  \" + key + \": \" + str(value))\n\n    return True\n\n\n@one_parameter\ndef rename(speaker, action, args, soco_function, use_local_speaker_list):\n    old_name = speaker.player_name\n    new_name = args[0]\n    if old_name == new_name:\n        error_report(\"Current and new names are identical\")\n        return False\n    speaker.player_name = new_name\n    rename_speaker_in_cache(\n        old_name, new_name, use_local_speaker_list=use_local_speaker_list\n    )\n    return True\n\n\n@zero_parameters\ndef album_art(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Get a URL for the current album art\"\"\"\n\n    # Normal approach using track_info\n    try:\n        info = speaker.get_current_track_info()\n        album_art_uri = info[\"album_art\"]\n        if album_art_uri == \"\":\n            metadata = info[\"metadata\"]\n            data = parse(metadata)\n            album_art_uri = data[\"DIDL-Lite\"][\"item\"][\"upnp:albumArtURI\"]\n        logging.info(\"Found album art directly: '{}'\".format(album_art_uri))\n    except Exception:\n        logging.info(\"Unable to find album art directly\")\n        album_art_uri = None\n\n    # Try using transport events\n    if not album_art_uri:\n        try:\n            sub = speaker.avTransport.subscribe()\n            remember_event_sub(sub)\n            event = sub.events.get(timeout=0.5)\n            album_art_uri = event.variables[\"current_track_meta_data\"].album_art_uri\n            event_unsubscribe(sub)\n            forget_event_sub(sub)\n            logging.info(\"Found album art using events: '{}'\".format(album_art_uri))\n        except Exception as e:\n            logging.info(\"Unable to find album art using events: {}\".format(e))\n            album_art_uri = None\n        finally:\n            unsub_all_remembered_event_subs()\n\n    if not album_art_uri:\n        logging.info(\"Album art not available: '{}'\".format(album_art_uri))\n        error_report(\"Album art not available\")\n        return False\n\n    if not album_art_uri.startswith(\"http\"):\n        album_art_uri = \"http://\" + speaker.ip_address + \":1400\" + album_art_uri\n        logging.info(\"Prefixed HTTP: '{}'\".format(album_art_uri))\n\n    print(album_art_uri)\n    return True\n\n\n@one_or_two_parameters\ndef add_uri_to_queue(speaker, action, args, soco_function, use_local_speaker_list):\n    uri = args[0]\n    if len(args) == 2:\n        position = get_queue_insertion_position(speaker, args[1], action)\n    else:\n        position = speaker.queue_size + 1\n\n    speaker.add_uri_to_queue(uri, position=position)\n    save_queue_insertion_position(position)\n    print(position)\n    return True\n\n\n@one_or_more_parameters\ndef play_file(speaker, action, args, soco_function, use_local_speaker_list):\n    end_on_pause = True if \"_end_on_pause_\" in args else False\n    for audio_file in args:\n        if audio_file == \"_end_on_pause_\":\n            continue\n        result = play_local_file(speaker, audio_file, end_on_pause=end_on_pause)\n        if not result:\n            return False\n    return True\n\n\n@one_or_two_parameters\ndef play_m3u(speaker, action, args, soco_function, use_local_speaker_list):\n    m3u_file = args[0]\n    options = \"\" if len(args) == 1 else args[1]\n    options = options.lower()\n\n    play_m3u_file(speaker, m3u_file, options=options)\n    return True\n\n\n@one_or_two_parameters\ndef play_directory(speaker, action, args, soco_function, use_local_speaker_list):\n    directory = args[0]\n    options = \"\" if len(args) == 1 else args[1]\n    options = options.lower()\n\n    play_directory_files(speaker, directory, options=options)\n    return True\n\n\n@zero_or_one_parameter\ndef buttons(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Enable or disable a speaker's buttons\"\"\"\n    np = len(args)\n    if np == 0:\n        state = \"on\" if speaker.buttons_enabled else \"off\"\n        print(state)\n    elif np == 1:\n        arg = args[0].lower()\n        if arg == \"on\":\n            speaker.buttons_enabled = True\n        elif arg == \"off\":\n            speaker.buttons_enabled = False\n        else:\n            parameter_type_error(action, \"on|off\")\n    return True\n\n\n@zero_or_one_parameter\ndef fixed_volume(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Enable or disable whether a Connect or Port has its Fixed Volume set\"\"\"\n    np = len(args)\n    if np == 0:\n        state = \"on\" if speaker.fixed_volume else \"off\"\n        print(state)\n    elif np == 1:\n        arg = args[0].lower()\n        try:\n            if arg == \"on\":\n                speaker.fixed_volume = True\n            elif arg == \"off\":\n                speaker.fixed_volume = False\n            else:\n                parameter_type_error(action, \"on|off\")\n        except SoCoUPnPException:\n            error_report(\n                \"Fixed Volume feature not supported by '{}'\".format(speaker.player_name)\n            )\n            return False\n    return True\n\n\n@zero_or_one_parameter\ndef trueplay(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Enable or disable whether a Trueplay profile is enabled\"\"\"\n    np = len(args)\n    if np == 0:\n        state = \"on\" if speaker.trueplay else \"off\"\n        print(state)\n    elif np == 1:\n        arg = args[0].lower()\n        try:\n            if arg == \"on\":\n                speaker.trueplay = True\n            elif arg == \"off\":\n                speaker.trueplay = False\n            else:\n                parameter_type_error(action, \"on|off\")\n        except SoCoUPnPException:\n            error_report(\n                \"No Trueplay profile available for '{}' (or Trueplay not supported)\".format(\n                    speaker.player_name\n                )\n            )\n            return False\n    return True\n\n\n@zero_parameters\ndef groupstatus(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Determine the grouped/paired/bonded status of a speaker.\"\"\"\n\n    visible_speakers = False\n    invisible_speakers = False\n    coordinator = None\n\n    for grouped_speaker in speaker.group.members:\n        if speaker is grouped_speaker:\n            continue\n        if grouped_speaker.is_visible:\n            visible_speakers = True\n        if not grouped_speaker.is_visible:\n            invisible_speakers = True\n        if grouped_speaker.is_coordinator:\n            coordinator = grouped_speaker\n\n    logging.info(\n        \"Visible = {}, Coordinator = {}, Speakers in Group = {}, Other Visible Speakers\"\n        \" = {}, Other Invisible Speakers = {}\".format(\n            speaker.is_visible,\n            speaker.is_coordinator,\n            len(speaker.group.members),\n            visible_speakers,\n            invisible_speakers,\n        )\n    )\n\n    if len(speaker.group.members) == 1:\n        print(\"Standalone\")\n\n    if speaker.is_visible and speaker.is_coordinator and invisible_speakers:\n        print(\"Paired or bonded, coordinator\")\n\n    if not speaker.is_visible:\n        print(\n            \"Paired or bonded, not coordinator [coordinator = {} @ {}]\".format(\n                coordinator.player_name, coordinator.ip_address\n            )\n        )\n\n    if speaker.is_visible and speaker.is_coordinator and visible_speakers:\n        print(\"Grouped, coordinator\")\n\n    if speaker.is_visible and not speaker.is_coordinator:\n        print(\n            \"Grouped, not coordinator [coordinator = {} @ {}]\".format(\n                coordinator.player_name, coordinator.ip_address\n            )\n        )\n\n    return True\n\n\n@zero_parameters\ndef pauseplay(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Invert a STOPPED or PAUSED STATE.\"\"\"\n\n    state = speaker.get_current_transport_info()[\"current_transport_state\"]\n    logging.info(\"Speaker '{}' is in a '{}' state\".format(speaker.player_name, state))\n\n    if state in [\"PLAYING\"]:\n        try:\n            logging.info(\"Trying 'pause'\")\n            speaker.pause()\n        except SoCoUPnPException:\n            logging.info(\"'Pause' failed ... using 'stop'\")\n            speaker.stop()\n\n    elif state in [\"STOPPED\", \"PAUSED_PLAYBACK\"]:\n        logging.info(\"Trying 'play'\")\n        speaker.play()\n\n    return True\n\n\n@zero_parameters\ndef available_actions(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Determine the currently available playback control options.\"\"\"\n    print(\"Currently available playback actions: {}\".format(speaker.available_actions))\n    return True\n\n\n@zero_parameters\ndef end_control_session(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Ends a direct control session (e.g., Spotify Connect).\"\"\"\n    try:\n        speaker.end_direct_control_session()\n    except SoCoUPnPException:\n        error_report(\"Invalid operation\")\n        return False\n    return True\n\n\n@zero_parameters\ndef wait_end_track(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Wait for the end of the current track, or until playback stops/pauses.\"\"\"\n\n    try:\n        sub = speaker.avTransport.subscribe(auto_renew=True)\n        logging.info(\n            \"Subscribing to transport events from {}\".format(speaker.player_name)\n        )\n        remember_event_sub(sub)\n    except Exception as e:\n        error_report(\"Exception {}\".format(e))\n        return False\n\n    initial_title = None\n    initial_duration = None\n    initial_radio_show = None\n\n    while True:\n        try:\n            event = sub.events.get(timeout=1.0)\n            logging.info(\"Transport event received\")\n\n            if event.variables[\"transport_state\"] not in [\"PLAYING\", \"TRANSITIONING\"]:\n                logging.info(\"Speaker is not playing\")\n                event_unsubscribe(sub)\n                forget_event_sub(sub)\n                return True\n\n            if initial_title is None:\n                track_info = speaker.get_current_track_info()\n                initial_title = track_info.pop(\"title\", None)\n                initial_duration = track_info.pop(\"duration\", None)\n                try:\n                    initial_radio_show = event.variables[\n                        \"current_track_meta_data\"\n                    ].radio_show\n                except AttributeError:\n                    pass\n                logging.info(\n                    \"Initial title = '{}', initial duration = '{}', initial radio show\"\n                    \" = '{}'\".format(\n                        initial_title, initial_duration, initial_radio_show\n                    )\n                )\n\n            else:\n                track_info = speaker.get_current_track_info()\n                current_title = track_info.pop(\"title\", None)\n                current_duration = track_info.pop(\"duration\", None)\n                try:\n                    current_radio_show = event.variables[\n                        \"current_track_meta_data\"\n                    ].radio_show\n                except AttributeError:\n                    current_radio_show = None\n                logging.info(\n                    \"Current title = '{}', current duration = '{}', current radio show\"\n                    \" = '{}'\".format(\n                        current_title, current_duration, current_radio_show\n                    )\n                )\n                # Check whether track title, duration or radio show name have changed\n                if (\n                    current_title != initial_title\n                    or current_duration != initial_duration\n                    or current_radio_show != initial_radio_show\n                ):\n                    logging.info(\"Track/show has changed\")\n                    logging.info(\"Unsubscribing from events\")\n                    event_unsubscribe(sub)\n                    forget_event_sub(sub)\n                    return True\n        except Exception:\n            pass\n\n\n@zero_parameters\ndef get_uri(speaker, action, args, soco_function, use_local_speaker_list):\n    track_info = speaker.get_current_track_info()\n    print(track_info[\"uri\"])\n    return True\n\n\n@zero_parameters\ndef get_channel(speaker, action, args, soco_function, use_local_speaker_list):\n    media_info = speaker.get_current_media_info()\n    print(media_info[\"channel\"])\n    return True\n\n\ndef _is_queue_position(arg):\n    \"\"\"Return True if arg is a recognised queue insertion position.\"\"\"\n    try:\n        int(arg)\n        return True\n    except ValueError:\n        return arg.lower() in {\"first\", \"start\", \"next\", \"play_next\", \"last\", \"end\"}\n\n\n@one_or_more_parameters\ndef add_sharelink_to_queue(\n    speaker, action, args, soco_function, use_local_speaker_list\n):\n    share_link = ShareLinkPlugin(speaker)\n\n    # If the last arg is not a sharelink, treat it as a queue position\n    if len(args) > 1 and _is_queue_position(args[-1]):\n        uris = args[:-1]\n        first_position = get_queue_insertion_position(speaker, args[-1], action)\n    else:\n        uris = args\n        first_position = None  # will use queue_size + 1 for each\n\n    # Validate all URIs before adding any\n    for uri in uris:\n        if not share_link.is_share_link(uri):\n            error_report(\"Invalid sharelink: '{}'\".format(uri))\n            return False\n\n    first_queue_position = None\n    for uri in uris:\n        position = (\n            first_position if first_queue_position is None else speaker.queue_size + 1\n        )\n        if position is None:\n            position = speaker.queue_size + 1\n        try:\n            queue_position = share_link.add_share_link_to_queue(uri, position)\n            if first_queue_position is None:\n                first_queue_position = queue_position\n                save_queue_insertion_position(queue_position)\n        except SoCoUPnPException as e:\n            error_report(\"Unable to add sharelink to queue: {}\".format(e))\n            return False\n\n    print(first_queue_position)\n    return True\n\n\n@one_or_more_parameters\ndef play_sharelink(speaker, action, args, soco_function, use_local_speaker_list):\n    share_link = ShareLinkPlugin(speaker)\n\n    # If the last arg is not a sharelink, treat it as a queue position\n    if len(args) > 1 and _is_queue_position(args[-1]):\n        uris = args[:-1]\n        first_position = get_queue_insertion_position(speaker, args[-1], action)\n    else:\n        uris = args\n        first_position = None  # will use queue_size + 1 for each\n\n    # Validate all URIs before adding any\n    for uri in uris:\n        if not share_link.is_share_link(uri):\n            error_report(\"Invalid sharelink: '{}'\".format(uri))\n            return False\n\n    first_queue_position = None\n    for uri in uris:\n        position = (\n            first_position if first_queue_position is None else speaker.queue_size + 1\n        )\n        if position is None:\n            position = speaker.queue_size + 1\n        try:\n            queue_position = share_link.add_share_link_to_queue(uri, position)\n            if first_queue_position is None:\n                first_queue_position = queue_position\n                save_queue_insertion_position(queue_position)\n        except SoCoUPnPException as e:\n            error_report(\"Unable to play sharelink: {}\".format(e))\n            return False\n\n    speaker.play_from_queue(first_queue_position - 1)\n    return True\n\n\n@zero_parameters\ndef reboot_count(speaker, action, args, soco_function, use_local_speaker_list):\n    print(speaker.boot_seqnum)\n    return True\n\n\n@zero_parameters\ndef switch_to_tv(speaker, action, args, soco_function, use_local_speaker_list):\n    if speaker.is_soundbar:\n        speaker.switch_to_tv()\n        return True\n\n    error_report(\"Speaker '{}' is not a soundbar\".format(speaker.player_name))\n    return False\n\n\n@zero_parameters\ndef audio_format(speaker, action, args, soco_function, use_local_speaker_list):\n    if speaker.is_soundbar:\n        audio_format = speaker.soundbar_audio_input_format\n        if audio_format is None:\n            print(\"No audio format information is available\")\n        else:\n            print(audio_format)\n        return True\n\n    error_report(\"Speaker '{}' is not a soundbar\".format(speaker.player_name))\n    return False\n\n\n@zero_parameters\ndef mic_enabled(\n    speaker: soco.SoCo, action, args, soco_function, use_local_speaker_list\n):\n    if speaker.mic_enabled is None:\n        error_report(\n            \"Speaker '{}' has no microphone, or voice services are not enabled\".format(\n                speaker.player_name\n            )\n        )\n        return False\n\n    print(\"{}\".format(speaker.mic_enabled))\n    return True\n\n\n@zero_or_one_parameter\ndef tv_audio_delay(speaker, action, args, soco_function, use_local_speaker_list):\n    if not speaker.is_soundbar:\n        error_report(\"Speaker '{}' has no TV input\".format(speaker.player_name))\n        return False\n\n    if len(args) == 0:\n        print(speaker.audio_delay)\n    else:\n        try:\n            speaker.audio_delay = int(args[0])\n            return True\n        except ValueError:\n            error_report(\"TV audio delay must be an integer from 0 to 5\")\n            return False\n\n\n@one_parameter\ndef group_volume_equalise(speaker, action, args, soco_function, use_local_speaker_list):\n    try:\n        vol = int(args[0])\n        if not (0 <= vol <= 100):\n            raise ValueError\n    except ValueError:\n        parameter_type_error(action, \"integer 0 to 100\")\n        return False\n\n    for member in speaker.group.members:\n        if member.is_visible:\n            member.volume = vol\n            logging.info(\n                \"Setting volume of speaker '{}' to {}\".format(member.player_name, vol)\n            )\n    return True\n\n\n@zero_parameters\ndef ungroup_all_in_group(speaker, action, args, soco_function, use_local_speaker_list):\n    for member in speaker.group.members:\n        if member.is_visible:\n            if member.is_coordinator:\n                logging.info(\n                    \"Not ungrouping coordinator speaker '{}'\".format(member.player_name)\n                )\n            else:\n                member.unjoin()\n                logging.info(\"Ungrouped speaker '{}'\".format(member.player_name))\n    return True\n\n\n@zero_or_one_parameter\ndef sub_gain(speaker, action, args, soco_function, use_local_speaker_list):\n    if speaker.sub_gain is None:\n        error_report(\"Speaker '{}' doesn't include a Sub\".format(speaker.player_name))\n        return False\n    if len(args) == 0:\n        print(speaker.sub_gain)\n        return True\n    try:\n        gain = int(args[0])\n        if not -15 <= gain <= 15:\n            raise ValueError\n        speaker.sub_gain = gain\n        return True\n    except ValueError:\n        error_report(\"Sub gain must be an integer between -15 and 15\")\n        return False\n\n\n@one_parameter\ndef set_queue_position(speaker, action, args, soco_function, use_local_speaker_list):\n    try:\n        qp = int(args[0])\n    except ValueError:\n        parameter_type_error(action, \"integer\")\n        return False\n    if 1 <= qp <= speaker.queue_size:\n        speaker.stop()\n        speaker.play_from_queue(index=qp - 1, start=False)\n    else:\n        error_report(\n            \"Queue position '{}' is out of range (queue length = {})\".format(\n                qp, speaker.queue_size\n            )\n        )\n        return False\n    return True\n\n\n@zero_or_one_parameter\ndef surround_volume(speaker, action, args, soco_function, use_local_speaker_list):\n    if getattr(speaker, soco_function) is None:\n        error_report(\n            \"Speaker '{}' doesn't include surround speakers\".format(speaker.player_name)\n        )\n        return False\n    if len(args) == 0:\n        print(getattr(speaker, soco_function))\n        return True\n    try:\n        gain = int(args[0])\n        if not -15 <= gain <= 15:\n            raise ValueError\n        setattr(speaker, soco_function, gain)\n        return True\n    except ValueError:\n        error_report(\"Argument must be an integer between -15 and 15\")\n        return False\n\n\n@one_parameter\ndef process_wait_action(speaker, action, args, soco_function, use_local_speaker_list):\n    sequence = [action, args[0]]\n    logging.info(\"Processing wait: {}\".format(sequence))\n    process_wait(sequence)\n    return True\n\n\ndef process_action(speaker, action, args, use_local_speaker_list=False) -> bool:\n    sonos_function = actions.get(action, None)\n    if sonos_function:\n        if sonos_function.switch_to_coordinator:\n            if not speaker.is_coordinator:\n                speaker = speaker.group.coordinator\n                logging.info(\n                    \"Switching to coordinator speaker '{}'\".format(speaker.player_name)\n                )\n        return sonos_function.processing_function(\n            speaker,\n            action,\n            args,\n            sonos_function.soco_function,\n            use_local_speaker_list,\n        )\n    return False\n\n\nclass SonosFunction:\n    \"\"\"Maps actions into processing functions.\"\"\"\n\n    def __init__(self, function, soco_function=None, switch_to_coordinator=False):\n        self._function = function\n        self._soco_function = soco_function\n        self._switch_to_coordinator = switch_to_coordinator\n\n    @property\n    def processing_function(self):\n        return self._function\n\n    @property\n    def soco_function(self):\n        return self._soco_function\n\n    @property\n    def switch_to_coordinator(self):\n        return self._switch_to_coordinator\n\n\ndef get_actions(\n    include_loop_actions=True,\n    include_wait_actions=False,\n    include_track_follow_actions=True,\n):\n    action_list = list(actions.keys())\n    if include_loop_actions:\n        loop_actions = [\n            \"loop\",\n            \"loop_until\",\n            \"loop_for\",\n            \"loop_to_start\",\n        ]\n        action_list += loop_actions\n    if include_wait_actions:\n        wait_actions = [\"wait\", \"wait_for\", \"wait_until\"]\n        action_list += wait_actions\n    if include_track_follow_actions:\n        action_list += [\"track_follow\", \"tf\", \"track_follow_compact\", \"tfc\"]\n    return sorted(action_list)\n\n\ndef list_actions(\n    include_loop_actions=True,\n    include_wait_actions=False,\n    include_track_follow_actions=True,\n):\n    action_list = get_actions(\n        include_loop_actions=include_loop_actions,\n        include_wait_actions=include_wait_actions,\n        include_track_follow_actions=include_track_follow_actions,\n    )\n\n    longest_command = len(max(action_list, key=len))\n    item_spacing = longest_command + 2\n    try:\n        items_per_line = get_terminal_size().columns // item_spacing\n    except OSError:\n        logging.info(\"Can't determine terminal width; printing simple list\")\n        action_list = sorted(action_list)\n        for action in action_list:\n            print(action)\n        return\n\n    action_list = sorted(action_list, reverse=True)\n\n    current_line_position = 1\n    while True:\n        try:\n            command = action_list.pop()\n        except IndexError:\n            break\n        if current_line_position == items_per_line:\n            ending = \"\\n\"\n            current_line_position = 1\n        else:\n            ending = \" \" * (item_spacing - len(command))\n            current_line_position += 1\n        print(command, end=ending)\n    if current_line_position != 1:\n        print()\n\n\n# Actions and associated processing functions\nactions = {\n    \"mute\": SonosFunction(on_off_action, \"mute\"),\n    \"cross_fade\": SonosFunction(on_off_action, \"cross_fade\"),\n    \"crossfade\": SonosFunction(on_off_action, \"cross_fade\"),\n    \"fade\": SonosFunction(on_off_action, \"cross_fade\"),\n    \"loudness\": SonosFunction(on_off_action, \"loudness\"),\n    \"status_light\": SonosFunction(on_off_action, \"status_light\"),\n    \"light\": SonosFunction(on_off_action, \"status_light\"),\n    \"night_mode\": SonosFunction(on_off_action, \"night_mode\"),\n    \"night\": SonosFunction(on_off_action, \"night_mode\"),\n    \"dialog_mode\": SonosFunction(on_off_action, \"dialog_mode\"),\n    \"dialog\": SonosFunction(on_off_action, \"dialog_mode\"),\n    \"dialogue_mode\": SonosFunction(on_off_action, \"dialog_mode\"),\n    \"dialogue\": SonosFunction(on_off_action, \"dialog_mode\"),\n    \"play\": SonosFunction(no_args_no_output, \"play\", True),\n    \"start\": SonosFunction(no_args_no_output, \"play\", True),\n    \"stop\": SonosFunction(no_args_no_output, \"stop\", True),\n    \"pause\": SonosFunction(no_args_no_output, \"pause\", True),\n    \"next\": SonosFunction(no_args_no_output, \"next\", True),\n    \"previous\": SonosFunction(no_args_no_output, \"previous\", True),\n    \"prev\": SonosFunction(no_args_no_output, \"previous\", True),\n    \"list_queue\": SonosFunction(list_queue, \"get_queue\", True),\n    \"lq\": SonosFunction(list_queue, \"get_queue\", True),\n    \"queue\": SonosFunction(list_queue, \"get_queue\", True),\n    \"q\": SonosFunction(list_queue, \"get_queue\", True),\n    \"list_playlists\": SonosFunction(list_numbered_things, \"get_sonos_playlists\"),\n    \"playlists\": SonosFunction(list_numbered_things, \"get_sonos_playlists\"),\n    \"lp\": SonosFunction(list_numbered_things, \"get_sonos_playlists\"),\n    \"list_favourites\": SonosFunction(list_numbered_things, \"get_sonos_favorites\"),\n    \"list_favorites\": SonosFunction(list_numbered_things, \"get_sonos_favorites\"),\n    \"list_favs\": SonosFunction(list_numbered_things, \"get_sonos_favorites\"),\n    \"lf\": SonosFunction(list_numbered_things, \"get_sonos_favorites\"),\n    \"volume\": SonosFunction(volume_actions, \"volume\"),\n    \"vol\": SonosFunction(volume_actions, \"volume\"),\n    \"v\": SonosFunction(volume_actions, \"volume\"),\n    \"group_volume\": SonosFunction(volume_actions, \"group_volume\"),\n    \"group_vol\": SonosFunction(volume_actions, \"group_volume\"),\n    \"gv\": SonosFunction(volume_actions, \"group_volume\"),\n    \"ramp_to_volume\": SonosFunction(volume_actions, \"ramp_to_volume\"),\n    \"ramp\": SonosFunction(volume_actions, \"ramp_to_volume\"),\n    \"relative_volume\": SonosFunction(relative_volume, \"relative_volume\"),\n    \"rel_vol\": SonosFunction(relative_volume, \"relative_volume\"),\n    \"rv\": SonosFunction(relative_volume, \"relative_volume\"),\n    \"group_relative_volume\": SonosFunction(relative_volume, \"group_relative_volume\"),\n    \"group_rel_vol\": SonosFunction(relative_volume, \"group_relative_volume\"),\n    \"grv\": SonosFunction(relative_volume, \"group_relative_volume\"),\n    \"track\": SonosFunction(track, \"\", True),\n    \"play_mode\": SonosFunction(playback_mode, \"play_mode\", True),\n    \"mode\": SonosFunction(playback_mode, \"play_mode\", True),\n    \"playback_state\": SonosFunction(\n        transport_state, \"get_current_transport_info\", True\n    ),\n    \"playback\": SonosFunction(transport_state, \"get_current_transport_info\", True),\n    \"state\": SonosFunction(transport_state, \"get_current_transport_info\", True),\n    \"status\": SonosFunction(transport_state, \"get_current_transport_info\", True),\n    \"play_favourite\": SonosFunction(play_favourite, \"play_favorite\", True),\n    \"play_favorite\": SonosFunction(play_favourite, \"play_favorite\", True),\n    \"favourite\": SonosFunction(play_favourite, \"play_favorite\", True),\n    \"favorite\": SonosFunction(play_favourite, \"play_favorite\", True),\n    \"play_fav\": SonosFunction(play_favourite, \"play_favorite\", True),\n    \"fav\": SonosFunction(play_favourite, \"play_favorite\", True),\n    \"pf\": SonosFunction(play_favourite, \"play_favorite\", True),\n    \"play_uri\": SonosFunction(play_uri, \"play_uri\", True),\n    \"uri\": SonosFunction(play_uri, \"play_uri\", True),\n    \"pu\": SonosFunction(play_uri, \"play_uri\", True),\n    \"sleep_timer\": SonosFunction(sleep_timer, \"sleep_timer\", True),\n    \"sleep\": SonosFunction(sleep_timer, \"sleep_timer\", True),\n    \"group\": SonosFunction(group_or_pair, \"join\"),\n    \"g\": SonosFunction(group_or_pair, \"join\"),\n    \"multi_group\": SonosFunction(multi_group, \"join\"),\n    \"mg\": SonosFunction(multi_group, \"join\"),\n    \"ungroup\": SonosFunction(no_args_no_output, \"unjoin\"),\n    \"ug\": SonosFunction(no_args_no_output, \"unjoin\"),\n    \"u\": SonosFunction(no_args_no_output, \"unjoin\"),\n    \"party_mode\": SonosFunction(no_args_no_output, \"partymode\"),\n    \"party\": SonosFunction(no_args_no_output, \"partymode\"),\n    \"ungroup_all\": SonosFunction(operate_on_all, \"unjoin\"),\n    \"zones\": SonosFunction(zones, \"zones\"),\n    \"all_zones\": SonosFunction(zones, \"zones\"),\n    \"rooms\": SonosFunction(zones, \"zones\"),\n    \"all_rooms\": SonosFunction(zones, \"zones\"),\n    \"visible_zones\": SonosFunction(zones, \"zones\"),\n    \"visible_rooms\": SonosFunction(zones, \"zones\"),\n    \"play_from_queue\": SonosFunction(play_from_queue, \"play_from_queue\", True),\n    \"play_queue\": SonosFunction(play_from_queue, \"play_from_queue\", True),\n    \"pfq\": SonosFunction(play_from_queue, \"play_from_queue\", True),\n    \"pq\": SonosFunction(play_from_queue, \"play_from_queue\", True),\n    \"remove_from_queue\": SonosFunction(remove_from_queue, \"remove_from_queue\", True),\n    \"rfq\": SonosFunction(remove_from_queue, \"remove_from_queue\", True),\n    \"rq\": SonosFunction(remove_from_queue, \"remove_from_queue\", True),\n    \"clear_queue\": SonosFunction(no_args_no_output, \"clear_queue\", True),\n    \"cq\": SonosFunction(no_args_no_output, \"clear_queue\", True),\n    \"group_mute\": SonosFunction(on_off_action, \"group_mute\"),\n    \"save_queue\": SonosFunction(save_queue, \"create_sonos_playlist_from_queue\", True),\n    \"sq\": SonosFunction(save_queue, \"create_sonos_playlist_from_queue\", True),\n    \"create_playlist_from_queue\": SonosFunction(\n        save_queue, \"create_sonos_playlist_from_queue\", True\n    ),\n    \"queue_length\": SonosFunction(no_args_one_output, \"queue_size\", True),\n    \"ql\": SonosFunction(no_args_one_output, \"queue_size\", True),\n    \"add_playlist_to_queue\": SonosFunction(playlist_operations, \"add_to_queue\", True),\n    \"add_pl_to_queue\": SonosFunction(playlist_operations, \"add_to_queue\", True),\n    \"queue_playlist\": SonosFunction(playlist_operations, \"add_to_queue\", True),\n    \"apq\": SonosFunction(playlist_operations, \"add_to_queue\", True),\n    \"pause_all\": SonosFunction(operate_on_all, \"pause\"),\n    \"seek\": SonosFunction(seek, \"seek\", True),\n    \"seek_to\": SonosFunction(seek, \"seek\", True),\n    \"seek_forward\": SonosFunction(seek_forward, \"seek_forward\", True),\n    \"sf\": SonosFunction(seek_forward, \"seek_forward\", True),\n    \"seek_back\": SonosFunction(seek_back, \"seek_back\", True),\n    \"sb\": SonosFunction(seek_back, \"seek_back\", True),\n    \"line_in\": SonosFunction(line_in, \"\"),\n    \"cue_line_in\": SonosFunction(cue_line_in, \"\"),\n    \"bass\": SonosFunction(eq, \"bass\"),\n    \"treble\": SonosFunction(eq, \"treble\"),\n    \"balance\": SonosFunction(balance, \"balance\"),\n    \"reindex\": SonosFunction(reindex, \"start_library_update\"),\n    \"info\": SonosFunction(info, \"get_info\"),\n    \"groups\": SonosFunction(groups, \"groups\"),\n    \"pair\": SonosFunction(group_or_pair, \"create_stereo_pair\"),\n    \"unpair\": SonosFunction(no_args_no_output, \"separate_stereo_pair\"),\n    \"add_satellite_speakers\": SonosFunction(\n        add_satellite_speakers, \"add_satellite_speakers\"\n    ),\n    \"add_satellites\": SonosFunction(add_satellite_speakers, \"add_satellite_speakers\"),\n    \"separate_satellite_speakers\": SonosFunction(\n        no_args_no_output, \"separate_satellite_speakers\"\n    ),\n    \"separate_satellites\": SonosFunction(\n        no_args_no_output, \"separate_satellite_speakers\"\n    ),\n    \"delete_playlist\": SonosFunction(playlist_operations, \"remove_sonos_playlist\"),\n    \"remove_playlist\": SonosFunction(playlist_operations, \"remove_sonos_playlist\"),\n    \"clear_playlist\": SonosFunction(playlist_operations, \"clear_sonos_playlist\"),\n    \"create_playlist\": SonosFunction(playlist_operations, \"create_sonos_playlist\"),\n    # \"add_uri_to_queue\": SonosFunction(playlist_operations, \"add_uri_to_queue\"),\n    \"auq\": SonosFunction(playlist_operations, \"add_uri_to_queue\", True),\n    \"remove_from_playlist\": SonosFunction(\n        remove_from_playlist, \"remove_from_sonos_playlist\"\n    ),\n    \"rfp\": SonosFunction(remove_from_playlist, \"remove_from_sonos_playlist\"),\n    \"favorite_radio_stations\": SonosFunction(\n        list_numbered_things, \"get_favorite_radio_stations\"\n    ),\n    \"favourite_radio_stations\": SonosFunction(\n        list_numbered_things, \"get_favorite_radio_stations\"\n    ),\n    \"frs\": SonosFunction(list_numbered_things, \"get_favorite_radio_stations\"),\n    \"lfrs\": SonosFunction(list_numbered_things, \"get_favorite_radio_stations\"),\n    \"play_favourite_radio_station\": SonosFunction(play_favourite_radio, \"play_uri\"),\n    \"play_favorite_radio_station\": SonosFunction(play_favourite_radio, \"play_uri\"),\n    \"pfrs\": SonosFunction(play_favourite_radio, \"play_uri\", True),\n    # \"tracks\": SonosFunction(list_numbered_things, \"get_tracks\"),\n    \"libraries\": SonosFunction(list_libraries, \"list_library_shares\"),\n    \"shares\": SonosFunction(list_libraries, \"list_library_shares\"),\n    \"sysinfo\": SonosFunction(system_info, \"\"),\n    \"sleep_at\": SonosFunction(sleep_at, \"\", True),\n    \"add_favourite_to_queue\": SonosFunction(\n        add_favourite_to_queue, \"add_to_queue\", True\n    ),\n    \"add_favorite_to_queue\": SonosFunction(\n        add_favourite_to_queue, \"add_to_queue\", True\n    ),\n    \"add_fav_to_queue\": SonosFunction(add_favourite_to_queue, \"add_to_queue\", True),\n    \"afq\": SonosFunction(add_favourite_to_queue, \"add_to_queue\", True),\n    \"list_playlist_tracks\": SonosFunction(list_playlist_tracks, \"list_tracks\"),\n    \"lpt\": SonosFunction(list_playlist_tracks, \"list_tracks\"),\n    \"list_all_playlist_tracks\": SonosFunction(list_all_playlist_tracks, \"\"),\n    \"lapt\": SonosFunction(list_all_playlist_tracks, \"\"),\n    \"wait_stop\": SonosFunction(wait_stop, \"\", True),\n    \"wait_start\": SonosFunction(wait_start, \"\", True),\n    \"wait_stopped_for\": SonosFunction(wait_stopped_for, \"\", True),\n    \"wsf\": SonosFunction(wait_stopped_for, \"\", True),\n    \"if_stopped\": SonosFunction(if_stopped_or_playing, \"\"),\n    \"if_playing\": SonosFunction(if_stopped_or_playing, \"\"),\n    \"if_coordinator\": SonosFunction(if_coordinator, \"\"),\n    \"if_not_coordinator\": SonosFunction(if_coordinator, \"\"),\n    \"if_queue\": SonosFunction(if_queue, \"\"),\n    \"if_no_queue\": SonosFunction(if_queue, \"\"),\n    \"wait\": SonosFunction(process_wait_action, \"\"),\n    \"wait_for\": SonosFunction(process_wait_action, \"\"),\n    \"wait_until\": SonosFunction(process_wait_action, \"\"),\n    \"search_library\": SonosFunction(search_library, \"\"),\n    \"sl\": SonosFunction(search_library, \"\"),\n    \"search_artists\": SonosFunction(search_artists, \"\"),\n    \"search_artist\": SonosFunction(search_artists, \"\"),\n    \"sart\": SonosFunction(search_artists, \"\"),\n    \"search_albums\": SonosFunction(search_albums, \"\"),\n    \"search_album\": SonosFunction(search_albums, \"\"),\n    \"salb\": SonosFunction(search_albums, \"\"),\n    \"search_tracks\": SonosFunction(search_tracks, \"\"),\n    \"search_track\": SonosFunction(search_tracks, \"\"),\n    \"st\": SonosFunction(search_tracks, \"\"),\n    \"tracks_in_album\": SonosFunction(tracks_in_album, \"\"),\n    \"tia\": SonosFunction(tracks_in_album, \"\"),\n    \"lta\": SonosFunction(tracks_in_album, \"\"),\n    \"list_albums\": SonosFunction(list_albums, \"\"),\n    \"albums\": SonosFunction(list_albums, \"\"),\n    \"list_artists\": SonosFunction(list_artists, \"\"),\n    \"artists\": SonosFunction(list_artists, \"\"),\n    \"queue_album\": SonosFunction(queue_album, \"\", True),\n    \"qa\": SonosFunction(queue_album, \"\", True),\n    \"queue_track\": SonosFunction(queue_track, \"\", True),\n    \"qt\": SonosFunction(queue_track, \"\", True),\n    \"cue_favourite\": SonosFunction(cue_favourite, \"\", True),\n    \"cue_favorite\": SonosFunction(cue_favourite, \"\", True),\n    \"cue_fav\": SonosFunction(cue_favourite, \"\", True),\n    \"cf\": SonosFunction(cue_favourite, \"\", True),\n    \"transfer_playback\": SonosFunction(transfer_playback, \"\", True),\n    \"transfer_to\": SonosFunction(transfer_playback, \"\", True),\n    \"transfer\": SonosFunction(transfer_playback, \"\", True),\n    \"shuffle\": SonosFunction(shuffle, \"\", True),\n    \"sh\": SonosFunction(shuffle, \"\", True),\n    \"repeat\": SonosFunction(repeat, \"\", True),\n    \"rpt\": SonosFunction(repeat, \"\", True),\n    \"remove_current_track_from_queue\": SonosFunction(\n        remove_current_track_from_queue, \"\", True\n    ),\n    \"rctfq\": SonosFunction(remove_current_track_from_queue, \"\", True),\n    \"remove_last_track_from_queue\": SonosFunction(\n        remove_last_track_from_queue, \"\", True\n    ),\n    \"rltfq\": SonosFunction(remove_last_track_from_queue, \"\", True),\n    \"queue_position\": SonosFunction(queue_position, \"\", True),\n    \"qp\": SonosFunction(queue_position, \"\", True),\n    \"last_search\": SonosFunction(last_search, \"\", True),\n    \"ls\": SonosFunction(last_search, \"\"),\n    \"queue_search_results\": SonosFunction(queue_search_results, \"\", True),\n    \"qsr\": SonosFunction(queue_search_results, \"\", True),\n    \"queue_search_result_number\": SonosFunction(\n        queue_search_results, \"\", True\n    ),  # Legacy\n    \"queue_search_number\": SonosFunction(queue_search_results, \"\", True),  # Legacy\n    \"qsn\": SonosFunction(queue_search_results, \"\", True),  # Legacy\n    \"queue_multiple_search_results\": SonosFunction(\n        queue_search_results, \"\", True\n    ),  # Legacy\n    \"qmsr\": SonosFunction(queue_search_results, \"\", True),  # Legacy\n    \"cue_favourite_radio_station\": SonosFunction(cue_favourite_radio_station, \"\", True),\n    \"cue_favorite_radio_station\": SonosFunction(cue_favourite_radio_station, \"\", True),\n    \"cfrs\": SonosFunction(cue_favourite_radio_station, \"\", True),\n    \"battery\": SonosFunction(battery, \"\"),\n    \"rename\": SonosFunction(rename, \"\"),\n    \"play_file\": SonosFunction(play_file, \"\", True),\n    \"play_local_file\": SonosFunction(play_file, \"\", True),\n    \"play_m3u\": SonosFunction(play_m3u, \"\", True),\n    \"play_local_m3u\": SonosFunction(play_m3u, \"\", True),\n    \"add_uri_to_queue\": SonosFunction(add_uri_to_queue, \"\", True),\n    \"wait_stop_not_pause\": SonosFunction(wait_stop_not_pause, \"\", True),\n    \"wsnp\": SonosFunction(wait_stop_not_pause, \"\", True),\n    \"wait_stopped_for_not_pause\": SonosFunction(wait_stopped_for_not_pause, \"\", True),\n    \"wsfnp\": SonosFunction(wait_stopped_for_not_pause, \"\", True),\n    \"buttons\": SonosFunction(buttons, \"\"),\n    \"fixed_volume\": SonosFunction(fixed_volume, \"\"),\n    \"trueplay\": SonosFunction(trueplay, \"\"),\n    \"play_favourite_number\": SonosFunction(play_favourite_number, \"\", True),\n    \"play_favorite_number\": SonosFunction(play_favourite_number, \"\", True),\n    \"pfn\": SonosFunction(play_favourite_number, \"\", True),\n    \"play_fav_radio_station_no\": SonosFunction(play_favourite_radio_number, \"\", True),\n    \"pfrsn\": SonosFunction(play_favourite_radio_number, \"\", True),\n    \"album_art\": SonosFunction(album_art, \"\", True),\n    \"groupstatus\": SonosFunction(groupstatus),\n    \"pauseplay\": SonosFunction(pauseplay, \"\", True),\n    \"playpause\": SonosFunction(pauseplay, \"\", True),\n    \"available_actions\": SonosFunction(available_actions, \"\", True),\n    \"wait_end_track\": SonosFunction(wait_end_track, \"\", True),\n    \"alarms\": SonosFunction(alarms.list_alarms, \"get_alarms\"),\n    \"list_alarms\": SonosFunction(alarms.list_alarms, \"get_alarms\"),\n    \"remove_alarms\": SonosFunction(alarms.remove_alarms, \"\", False),\n    \"remove_alarm\": SonosFunction(alarms.remove_alarms, \"\", False),\n    \"add_alarm\": SonosFunction(alarms.add_alarm, \"\", False),\n    \"create_alarm\": SonosFunction(alarms.add_alarm, \"\", False),\n    \"enable_alarm\": SonosFunction(alarms.enable_alarms, \"\", False),\n    \"enable_alarms\": SonosFunction(alarms.enable_alarms, \"\", False),\n    \"disable_alarm\": SonosFunction(alarms.disable_alarms, \"\", False),\n    \"disable_alarms\": SonosFunction(alarms.disable_alarms, \"\", False),\n    \"modify_alarm\": SonosFunction(alarms.modify_alarm, \"\", False),\n    \"modify_alarms\": SonosFunction(alarms.modify_alarm, \"\", False),\n    \"copy_alarm\": SonosFunction(alarms.copy_alarm, \"\", False),\n    \"move_alarm\": SonosFunction(alarms.move_alarm, \"\", False),\n    \"snooze_alarm\": SonosFunction(alarms.snooze_alarm, \"\", True),\n    \"relative_bass\": SonosFunction(eq_relative, \"bass\", False),\n    \"rel_bass\": SonosFunction(eq_relative, \"bass\", False),\n    \"rb\": SonosFunction(eq_relative, \"bass\", False),\n    \"relative_treble\": SonosFunction(eq_relative, \"treble\", False),\n    \"rel_treble\": SonosFunction(eq_relative, \"treble\", False),\n    \"rt\": SonosFunction(eq_relative, \"treble\", False),\n    \"list_library_playlists\": SonosFunction(\n        list_numbered_things, \"get_playlists\", False\n    ),\n    \"llp\": SonosFunction(list_numbered_things, \"get_playlists\", False),\n    \"list_library_playlist_tracks\": SonosFunction(\n        list_library_playlist_tracks, \"\", False\n    ),\n    \"llpt\": SonosFunction(list_library_playlist_tracks, \"\", False),\n    \"add_library_playlist_to_queue\": SonosFunction(\n        playlist_operations, \"add_library_playlist_to_queue\", True\n    ),\n    \"alpq\": SonosFunction(playlist_operations, \"add_library_playlist_to_queue\", True),\n    \"get_uri\": SonosFunction(get_uri, \"\", True),\n    \"end_session\": SonosFunction(end_control_session, \"\", True),\n    \"get_channel\": SonosFunction(get_channel, \"\", True),\n    \"channel\": SonosFunction(get_channel, \"\", True),\n    \"add_sharelink_to_queue\": SonosFunction(add_sharelink_to_queue, \"\", True),\n    \"sharelink\": SonosFunction(add_sharelink_to_queue, \"\", True),\n    \"play_sharelink\": SonosFunction(play_sharelink, \"\", True),\n    \"is_indexing\": SonosFunction(is_indexing, \"\", False),\n    \"reboot_count\": SonosFunction(reboot_count, \"\", False),\n    \"play_directory\": SonosFunction(play_directory, \"\", True),\n    \"play_dir\": SonosFunction(play_directory, \"\", True),\n    \"play_cd\": SonosFunction(play_directory, \"\", True),  # Undocumented\n    \"switch_to_tv\": SonosFunction(switch_to_tv, \"\", False),\n    \"has_subwoofer\": SonosFunction(true_false_action, \"has_subwoofer\", False),\n    \"is_subwoofer\": SonosFunction(true_false_action, \"is_subwoofer\", False),\n    \"has_satellites\": SonosFunction(true_false_action, \"has_satellites\", False),\n    \"is_satellite\": SonosFunction(true_false_action, \"is_satellite\", False),\n    \"sub_enabled\": SonosFunction(on_off_action, \"sub_enabled\", False),\n    \"surround_enabled\": SonosFunction(on_off_action, \"surround_enabled\", False),\n    \"audio_format\": SonosFunction(audio_format, \"\", True),\n    \"copy_modify_alarm\": SonosFunction(alarms.copy_modify_alarm, \"\", False),\n    \"tv_audio_delay\": SonosFunction(tv_audio_delay, \"\", True),\n    \"alarms_zone\": SonosFunction(alarms.list_alarms, \"\", False),\n    \"alarms_spec\": SonosFunction(alarms.list_alarms_spec, \"\", False),\n    \"alarms_spec_zone\": SonosFunction(alarms.list_alarms_spec, \"\", False),\n    \"mic_enabled\": SonosFunction(mic_enabled, \"\", False),\n    \"group_volume_equalise\": SonosFunction(group_volume_equalise, \"\", True),\n    \"group_volume_equalize\": SonosFunction(group_volume_equalise, \"\", True),\n    \"gve\": SonosFunction(group_volume_equalise, \"\", True),\n    \"ungroup_all_in_group\": SonosFunction(ungroup_all_in_group, \"\", True),\n    \"ugaig\": SonosFunction(ungroup_all_in_group, \"\", True),\n    \"sub_gain\": SonosFunction(sub_gain, \"\", False),\n    \"relative_sub_gain\": SonosFunction(eq_relative, \"sub_gain\", False),\n    \"rel_sub_gain\": SonosFunction(eq_relative, \"sub_gain\", False),\n    \"rsg\": SonosFunction(eq_relative, \"sub_gain\", False),\n    \"surround_volume_tv\": SonosFunction(surround_volume, \"surround_volume_tv\", False),\n    \"surround_volume_music\": SonosFunction(\n        surround_volume, \"surround_volume_music\", False\n    ),\n    \"surround_full_volume_enabled\": SonosFunction(\n        on_off_action, \"surround_full_volume_enabled\", False\n    ),\n    \"playing_tv\": SonosFunction(true_false_action, \"is_playing_tv\", True),\n    \"is_playing_tv\": SonosFunction(true_false_action, \"is_playing_tv\", True),\n    \"stop_all\": SonosFunction(operate_on_all, \"stop\", False),\n    \"set_queue_position\": SonosFunction(set_queue_position, \"\", True),\n    \"sqp\": SonosFunction(set_queue_position, \"\", True),\n}\n"
  },
  {
    "path": "soco_cli/alarms.py",
    "content": "\"\"\"Processing module for alarm actions.\"\"\"\n\nimport logging\nimport time\nfrom copy import copy\nfrom datetime import datetime\n\nimport soco  # type: ignore\nimport soco.alarms  # type: ignore\nimport tabulate  # type: ignore\nfrom soco.alarms import Alarm, get_alarms\nfrom soco.core import SoCo\nfrom soco.exceptions import SoCoUPnPException  # type: ignore\n\nfrom soco_cli.utils import (\n    convert_true_false,\n    error_report,\n    one_parameter,\n    parameter_type_error,\n    two_parameters,\n    zero_parameters,\n)\n\n# Stabilisation delay (in seconds) when operating on multiple alarms\nALARM_OPERATION_DELAY = 1.0\n\n\n@zero_parameters\ndef list_alarms(speaker, action, args, soco_function, use_local_speaker_list):\n    alarms = soco.alarms.get_alarms(speaker)\n    if not alarms:\n        return True\n    details = []\n    for alarm in alarms:\n        # Skip this alarm if it's not for the target room\n        if action == \"alarms_zone\":\n            if alarm.zone != speaker:\n                continue\n        didl = alarm.program_metadata\n        title_start = didl.find(\"<dc:title>\")\n        if title_start >= 0:\n            title_start += len(\"<dc:title>\")\n            title_end = didl.find(\"</dc:title>\")\n            title = didl[title_start:title_end]\n        elif alarm.program_uri is None:\n            title = \"Sonos Chime\"\n        elif alarm.program_uri != \"\":\n            title = alarm.program_uri\n        else:\n            title = \"Unknown\"\n        time = alarm.start_time.strftime(\"%H:%M\")\n        if alarm.duration:\n            duration = alarm.duration.strftime(\"%H:%M\")\n        else:\n            duration = \"No Limit\"\n        details.append(\n            [\n                alarm.alarm_id,\n                alarm.zone.player_name,\n                time,\n                duration,\n                alarm.recurrence,\n                convert_true_false(alarm.enabled),\n                title,\n                alarm.play_mode,\n                alarm.volume,\n                convert_true_false(alarm.include_linked_zones),\n            ]\n        )\n\n    # Sort alarms by start time, room. Apply sorts in reverse order.\n    details.sort(key=lambda field: field[1])  # Room\n    details.sort(key=lambda field: field[2].split(\":\")[1])  # Minute\n    details.sort(key=lambda field: field[2].split(\":\")[0])  # Hour\n\n    headers = [\n        \"Alarm ID\",\n        \"Speaker\",\n        \"1: Start Time\",\n        \"2: Duration\",\n        \"3: Recurrence\",\n        \"4: Enabled\",\n        \"5: Title\",\n        \"6: Play Mode\",\n        \"7: Vol.\",\n        \"8: Incl. Grouped\",\n    ]\n    print()\n    print(tabulate.tabulate(details, headers, tablefmt=\"github\", numalign=\"left\"))\n    print()\n    return True\n\n\n@zero_parameters\ndef list_alarms_spec(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"List alarms in 'alarm spec' format for easy copy/paste into modify_alarm or add_alarm.\"\"\"\n    alarms = soco.alarms.get_alarms(speaker)\n    if not alarms:\n        return True\n    # Sort before building rows so quoting doesn't corrupt time-based ordering.\n    sorted_alarms = sorted(alarms, key=lambda a: (a.start_time, a.zone.player_name))\n\n    details = []\n    warned_comma = False\n    for alarm in sorted_alarms:\n        if action == \"alarms_spec_zone\":\n            if alarm.zone != speaker:\n                continue\n        didl = alarm.program_metadata\n        title_start = didl.find(\"<dc:title>\")\n        if title_start >= 0:\n            title_start += len(\"<dc:title>\")\n            title_end = didl.find(\"</dc:title>\")\n            fav = didl[title_start:title_end]\n        elif alarm.program_uri is None:\n            fav = \"chime\"\n        elif alarm.program_uri != \"\":\n            fav = alarm.program_uri\n        else:\n            fav = \"chime\"\n\n        start_time = alarm.start_time.strftime(\"%H:%M\")\n        duration = alarm.duration.strftime(\"%H:%M\") if alarm.duration else \"_\"\n        enabled = \"on\" if alarm.enabled else \"off\"\n        include_linked = \"on\" if alarm.include_linked_zones else \"off\"\n        spec = \"{},{},{},{},{},{},{},{}\".format(\n            start_time,\n            duration,\n            alarm.recurrence,\n            enabled,\n            fav,\n            alarm.play_mode,\n            alarm.volume,\n            include_linked,\n        )\n        if \",\" in fav:\n            warned_comma = True\n        if \" \" in spec:\n            spec = '\"{}\"'.format(spec)\n        details.append([alarm.alarm_id, alarm.zone.player_name, spec])\n\n    headers = [\"Alarm ID\", \"Speaker\", \"Alarm Spec\"]\n    print()\n    print(tabulate.tabulate(details, headers, tablefmt=\"github\", numalign=\"left\"))\n    if warned_comma:\n        print(\n            \"\\nNote: one or more favourite names contain a comma; those specs cannot be used directly with 'modify_alarm'.\"\n        )\n    print()\n    return True\n\n\n@one_parameter\ndef remove_alarms(speaker, action, args, soco_function, use_local_speaker_list):\n    alarms = get_alarms(speaker)\n\n    if args[0].lower() == \"all\":\n        for alarm in alarms:\n            logging.info(\"Removing alarm ID '{}'\".format(alarm.alarm_id))\n            alarm.remove()\n        return True\n\n    alarm_ids_to_delete = args[0].split(\",\")\n    alarm_ids_to_delete = set(alarm_ids_to_delete)\n    logging.info(\"Attempting to delete alarm ID(s): {}\".format(alarm_ids_to_delete))\n\n    alarm_ids = {alarm.alarm_id for alarm in alarms}\n    logging.info(\"Current alarm ID(s): {}\".format(alarm_ids))\n\n    valid_alarm_ids_to_delete = alarm_ids.intersection(alarm_ids_to_delete)\n    logging.info(\"Valid alarm ID(s) to delete: {}\".format(valid_alarm_ids_to_delete))\n\n    deleted_counter = 0\n    for alarm in alarms:\n        if alarm.alarm_id in valid_alarm_ids_to_delete:\n            logging.info(\"Deleting alarm ID: {}\".format(alarm.alarm_id))\n            alarm.remove()\n            deleted_counter += 1\n            if deleted_counter < len(valid_alarm_ids_to_delete):\n                logging.info(\n                    \"Waiting for {}s for stabilisation\".format(ALARM_OPERATION_DELAY)\n                )\n                time.sleep(ALARM_OPERATION_DELAY)\n\n    alarms_invalid = alarm_ids_to_delete.difference(valid_alarm_ids_to_delete)\n    if len(alarms_invalid) != 0:\n        print(\"Error: Alarm ID(s) {} not found\".format(alarms_invalid))\n\n    return True\n\n\n@one_parameter\ndef add_alarm(speaker, action, args, soco_function, use_local_speaker_list):\n    new_alarm = Alarm(zone=speaker)\n    if not _modify_alarm_object(speaker, new_alarm, args[0]):\n        return False\n\n    try:\n        new_alarm.save()\n    except SoCoUPnPException as e:\n        error_report(\"Failed to create alarm: {}\".format(e))\n        return False\n\n    return True\n\n\n@two_parameters\ndef modify_alarm(speaker, action, args, soco_function, use_local_speaker_list):\n    alarm_ids = args[0].lower().split(\",\")\n    all_alarms = get_alarms(speaker)\n    if alarm_ids[0] == \"all\":\n        alarms = set(all_alarms)\n    else:\n        alarms = set()\n        for alarm_id in alarm_ids:\n            for alarm in all_alarms:\n                if alarm_id == alarm.alarm_id:\n                    alarms.add(alarm)\n                    break\n            else:\n                print(\"Alarm ID '{}' not found\".format(alarm_id))\n\n    for index, alarm in enumerate(alarms):\n        if not _modify_alarm_object(speaker, alarm, args[1]):\n            continue\n        try:\n            logging.info(\"Saving alarm '{}'\".format(alarm.alarm_id))\n            alarm.save()\n            if index < len(alarms) - 1:\n                # Allow alarm update to stabilise\n                logging.info(\n                    \"Waiting {}s after saving alarm '{}'\".format(\n                        ALARM_OPERATION_DELAY, alarm.alarm_id\n                    )\n                )\n                time.sleep(ALARM_OPERATION_DELAY)\n        except SoCoUPnPException as e:\n            error_report(\"Failed to modify alarm {}: {}\".format(alarm.alarm_id, e))\n            continue\n\n    return True\n\n\n@one_parameter\ndef copy_alarm(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Copy an alarm to the target speaker.\"\"\"\n    return move_or_copy_alarm(speaker, args[0], copy=True)\n\n\n@one_parameter\ndef move_alarm(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Move an alarm to the target speaker.\"\"\"\n    return move_or_copy_alarm(speaker, args[0], copy=False)\n\n\ndef move_or_copy_alarm(speaker, alarm_id, copy=True):\n    alarms = get_alarms(speaker)\n    for alarm in alarms:\n        if alarm_id == alarm.alarm_id:\n            break\n    else:\n        error_report(\"Alarm ID '{}' not found\".format(alarm_id))\n        return False\n\n    if alarm.zone == speaker:\n        error_report(\"Cannot copy/move an alarm to the same speaker\")\n        return False\n\n    alarm.zone = speaker\n    if copy is True:\n        alarm._alarm_id = None\n    try:\n        alarm.save()\n    except SoCoUPnPException as e:\n        error_report(\"Failed to copy/move alarm: {}\".format(e))\n        return False\n\n    if copy is True:\n        print(\"Alarm ID '{}' created\".format(alarm.alarm_id))\n\n    return True\n\n\n@one_parameter\ndef enable_alarms(speaker, action, args, soco_function, use_local_speaker_list):\n    return set_alarms(speaker, args[0], enabled=True)\n\n\n@one_parameter\ndef disable_alarms(speaker, action, args, soco_function, use_local_speaker_list):\n    return set_alarms(speaker, args[0], enabled=False)\n\n\ndef set_alarms(speaker, alarm_ids, enabled=True):\n    alarms = get_alarms(speaker)\n    alarm_ids = set(alarm_ids.lower().split(\",\"))\n\n    all_alarms = False\n    if \"all\" in alarm_ids:\n        all_alarms = True\n        alarm_ids.discard(\"all\")\n    elif \"_all_\" in alarm_ids:\n        all_alarms = True\n        alarm_ids.discard(\"_all_\")\n\n    for alarm in alarms:\n        if all_alarms is True or alarm.alarm_id in alarm_ids:\n            logging.info(\n                \"Setting alarm id '{}' to enabled = {}\".format(alarm.alarm_id, enabled)\n            )\n            if alarm.enabled != enabled:\n                alarm.enabled = enabled\n                logging.info(\"Saving alarm '{}'\".format(alarm.alarm_id))\n                try:\n                    alarm.save()\n                except SoCoUPnPException as e:\n                    error_report(\n                        \"Failed to change state of alarm {}: {}\".format(\n                            alarm.alarm_id, e\n                        )\n                    )\n                if len(alarm_ids) != 0 or all_alarms:\n                    # Stabilisation delay\n                    logging.info(\n                        \"Waiting {}s after saving alarm '{}'\".format(\n                            ALARM_OPERATION_DELAY, alarm.alarm_id\n                        )\n                    )\n                    time.sleep(ALARM_OPERATION_DELAY)\n            alarm_ids.discard(alarm.alarm_id)\n\n    if len(alarm_ids) != 0:\n        print(\"Alarm IDs not found: {}\".format(alarm_ids))\n\n    return True\n\n\n@one_parameter\ndef snooze_alarm(speaker, action, args, soco_function, use_local_speaker_list):\n    \"\"\"Snooze an alarm that's currently playing\"\"\"\n\n    duration = args[0].lower()\n\n    # HH:MM:SS format\n    h_m_s = duration.split(\":\")\n    if len(h_m_s) == 3:\n        try:\n            if not (\n                0 <= int(h_m_s[0]) <= 23\n                and 0 <= int(h_m_s[1]) <= 59\n                and 0 <= int(h_m_s[2]) <= 59\n            ):\n                raise ValueError\n        except (ValueError, TypeError):\n            logging.info(\"Invalid snooze duration: '{}'\".format(args[0]))\n            parameter_type_error(\n                action,\n                \"A valid HH:MM:SS duration, or an integer number of minutes\",\n            )\n            return False\n\n    # Simple 'Nm' or 'N' for N minutes of snooze\n    else:\n        try:\n            duration = abs(int(duration.replace(\"m\", \"\")))\n            minutes = str(duration % 60).zfill(2)\n            hours = str(int(duration / 60)).zfill(2)\n            duration = hours + \":\" + minutes + \":00\"\n        except ValueError:\n            logging.info(\"Invalid snooze duration: '{}'\".format(args[0]))\n            parameter_type_error(\n                action,\n                \"An integer number of minutes, or HH:MM:SS format\",\n            )\n            return False\n\n    logging.info(\"Sending snooze command using duration '{}'\".format(duration))\n    try:\n        speaker.avTransport.SnoozeAlarm([(\"InstanceID\", 0), (\"Duration\", duration)])\n    except SoCoUPnPException as error:\n        logging.info(\"Exception: {}\".format(error))\n        if error.error_code == \"701\":\n            error_report(\"Can only snooze a playing alarm\")\n        elif error.error_code == \"402\":\n            error_report(\"Invalid snooze duration: '{}'\".format(duration))\n        else:\n            error_report(\"{}\".format(error))\n        return False\n\n    return True\n\n\n@two_parameters\ndef copy_modify_alarm(speaker, action, args, soco_function, use_local_speaker_list):\n    alarm_id = args[0]\n    alarm_parms = args[1]\n\n    # Find the alarm\n    alarms = get_alarms(speaker)\n    for alarm in alarms:\n        if alarm_id == alarm.alarm_id:\n            break\n    else:\n        error_report(\n            \"Alarm ID '{}' not found; use the 'alarms' action to find the integer ID\".format(\n                alarm_id\n            )\n        )\n        return False\n\n    # Create a new alarm from the existing one\n    new_alarm = copy(alarm)\n    new_alarm._alarm_id = None\n    new_alarm.zone = speaker\n\n    # Apply modifications\n    if not _modify_alarm_object(speaker, new_alarm, alarm_parms):\n        return False\n\n    # Save the new alarm\n    try:\n        new_alarm.save()\n    except SoCoUPnPException as e:\n        error_report(\n            \"Failed to copy/move alarm; did you remember to modify the start time?: {}\".format(\n                e\n            )\n        )\n        return False\n\n    return True\n\n\ndef _modify_alarm_object(speaker: SoCo, alarm: Alarm, parms_string: str) -> bool:\n    alarm_parameters = parms_string.split(\",\")\n    if len(alarm_parameters) != 8:\n        error_report(\n            \"8 comma-separated parameters required for alarm modification specification\"\n        )\n        return False\n\n    start_time = alarm_parameters[0]\n    if not start_time == \"_\":\n        try:\n            alarm.start_time = datetime.strptime(start_time, \"%H:%M\").time()\n        except ValueError:\n            error_report(\"Invalid time format: {}\".format(start_time))\n            return False\n\n    duration = alarm_parameters[1]\n    if not duration == \"_\":\n        try:\n            alarm.duration = datetime.strptime(duration, \"%H:%M\").time()\n        except ValueError:\n            error_report(\"Invalid time format: {}\".format(duration))\n            return False\n\n    recurrence = alarm_parameters[2]\n    if not recurrence == \"_\":\n        if not soco.alarms.is_valid_recurrence(recurrence):\n            error_report(\"'{}' is not a valid recurrence string\".format(recurrence))\n            return False\n        alarm.recurrence = recurrence\n\n    enabled = alarm_parameters[3].lower()\n    if not enabled == \"_\":\n        if enabled in [\"on\", \"yes\"]:\n            enabled = True\n        elif enabled in [\"off\", \"no\"]:\n            enabled = False\n        else:\n            error_report(\n                \"Alarm must be enabled 'on' or 'off', not '{}'\".format(\n                    alarm_parameters[3]\n                )\n            )\n            return False\n        alarm.enabled = enabled\n\n    fav = alarm_parameters[4]\n    if not fav == \"_\":\n        if fav.lower() == \"chime\":\n            alarm.program_uri = None\n        else:\n            if not set_program_data(speaker, alarm, fav):\n                return False\n\n    play_mode = alarm_parameters[5].upper()\n    if not play_mode == \"_\":\n        play_mode_options = [\n            \"NORMAL\",\n            \"SHUFFLE_NOREPEAT\",\n            \"SHUFFLE\",  # Note: this means SHUFFLE and REPEAT\n            \"REPEAT_ALL\",\n            \"REPEAT_ONE\",\n            \"SHUFFLE_REPEAT_ONE\",\n        ]\n        if play_mode not in play_mode_options:\n            error_report(\n                \"Play mode is '{}', should be one of:\\n  {}\".format(\n                    alarm_parameters[5], play_mode_options\n                )\n            )\n            return False\n        alarm.play_mode = play_mode\n\n    volume = alarm_parameters[6]\n    if not volume == \"_\":\n        try:\n            volume = int(volume)\n            if not 0 <= volume <= 100:\n                error_report(\n                    \"Alarm volume must be between 0 and 100, not '{}'\".format(\n                        alarm_parameters[6]\n                    )\n                )\n                return False\n        except ValueError:\n            error_report(\n                \"Alarm volume must be an integer between 0 and 100, not '{}'\".format(\n                    alarm_parameters[6]\n                )\n            )\n            return False\n        alarm.volume = volume\n\n    include_linked = alarm_parameters[7].lower()\n    if not include_linked == \"_\":\n        if include_linked in [\"on\", \"yes\"]:\n            include_linked = True\n        elif include_linked in [\"off\", \"no\"]:\n            include_linked = False\n        else:\n            error_report(\n                \"Linked zones must be enabled 'on' or 'off', not '{}'\".format(\n                    alarm_parameters[7]\n                )\n            )\n            return False\n        alarm.include_linked_zones = include_linked\n\n    return True\n\n\ndef set_program_data(speaker: SoCo, alarm: Alarm, fav: str) -> bool:\n    \"\"\"\n    Set the program URI and metadata for the alarm, using a selection\n    from the list of Sonos Favourites.\n    \"\"\"\n    s_favs = speaker.music_library.get_sonos_favorites(complete_result=True)\n    for s_fav in s_favs:\n        # This will pick the first, case-insensitive partial match\n        if fav.lower() in s_fav.title.lower():\n            logging.info(\n                \"Found Sonos Favourite match for '{}' = '{}'\".format(fav, s_fav.title)\n            )\n            alarm.program_metadata = s_fav.resource_meta_data\n            # Assume there's only one 'resources' object in the list\n            logging.info(\"Using URI = {}\".format(s_fav.resources[0].uri))\n            alarm.program_uri = s_fav.resources[0].uri\n            return True\n    error_report(\"Favourite '{}' not found\".format(fav))\n    return False\n"
  },
  {
    "path": "soco_cli/aliases.py",
    "content": "\"\"\"Manages aliases for use with the interactive shell.\"\"\"\n\nimport logging\nimport pickle\nfrom os import mkdir, path\nfrom typing import List, Tuple, Union\n\nCONFIG_DIR = path.join(path.expanduser(\"~\"), \".soco-cli\")\nALIAS_FILE = path.join(CONFIG_DIR, \"aliases.pickle\")\n\n\nclass AliasManager:\n    def __init__(self):\n        self._aliases = {}\n\n    def create_alias(\n        self, alias_name: str, alias_actions: Union[str, None]\n    ) -> Union[Tuple[bool, bool], bool]:\n        alias_name = alias_name.strip()\n        if alias_actions:\n            alias_actions = alias_actions.strip()\n        if alias_actions in [None, \"\"]:\n            return self.remove_alias(alias_name)\n        if self._aliases.get(alias_name, None):\n            new = False\n        else:\n            new = True\n        logging.info(\"Adding alias '{}', new = {}\".format(alias_name, new))\n        self._aliases[alias_name] = alias_actions\n        return True, new\n\n    def action(self, alias_name: str) -> Union[str, None]:\n        return self._aliases.get(alias_name, None)\n\n    def remove_alias(self, alias_name: str) -> bool:\n        alias_name = alias_name.strip()\n        try:\n            del self._aliases[alias_name]\n            logging.info(\"Removing alias '{}'\".format(alias_name))\n            return True\n        except KeyError:\n            logging.info(\"Alias '{}' not found\".format(alias_name))\n            return False\n\n    def alias_names(self) -> List[str]:\n        return list(self._aliases.keys())\n\n    def save_aliases(self) -> None:\n        if not path.exists(CONFIG_DIR):\n            try:\n                logging.info(\"Creating directory '{}'\".format(CONFIG_DIR))\n                mkdir(CONFIG_DIR)\n            except:\n                pass\n        with open(ALIAS_FILE, \"wb\") as f:\n            logging.info(\"Saving aliases\")\n            pickle.dump(self._aliases, f)\n\n    def load_aliases(self) -> None:\n        logging.info(\"Reading aliases\")\n        try:\n            with open(ALIAS_FILE, \"rb\") as f:\n                self._aliases = pickle.load(f)\n        except:\n            logging.info(\"Failed to read aliases from file\")\n\n    def print_aliases(self) -> None:\n        if len(self._aliases) == 0:\n            print(\"No current aliases\")\n            return\n        print()\n        print(self._aliases_to_text())\n\n    def save_aliases_to_file(self, filename: str) -> bool:\n        try:\n            with open(filename, \"w\") as f:\n                f.write(\"# Soco-CLI Aliases File\\n\")\n                f.write(self._aliases_to_text(raw=True))\n                return True\n        except:\n            return False\n\n    def load_aliases_from_file(self, filename: str) -> bool:\n        try:\n            with open(filename, \"r\") as f:\n                line = f.readline()\n                while line != \"\":\n                    if not line.startswith(\"#\") and line != \"\\n\":\n                        if line.count(\"=\") != 1:\n                            print(\"Malformed alias ... ignored\")\n                            print(line, end=\"\")\n                        else:\n                            alias = line.split(\"=\")\n                            self.create_alias(alias[0], alias[1])\n                    line = f.readline()\n            self.save_aliases()\n            return True\n        except:\n            return False\n\n    def _aliases_to_text(self, raw: bool = False) -> str:\n        output = \"\"\n        max_alias = len(max(self._aliases.keys(), key=len))\n        for alias_name in sorted(self._aliases.keys()):\n            if raw:\n                output = output + alias_name + \" = \" + self._aliases[alias_name] + \"\\n\"\n            else:\n                output = (\n                    output\n                    + \"  \"\n                    + alias_name.ljust(max_alias)\n                    + \" = \"\n                    + self._aliases[alias_name]\n                    + \"\\n\"\n                )\n        return output\n"
  },
  {
    "path": "soco_cli/api.py",
    "content": "\"\"\"The SoCo-CLI API.\n\nProvides a few simple, high-level functions that allow the features of SoCo-CLI\nto be used in other programs.\n\"\"\"\n\nimport logging\nimport sys\nfrom io import StringIO\nfrom signal import SIGINT, signal\nfrom typing import Tuple, Union\n\nfrom soco import SoCo  # type: ignore\n\nfrom soco_cli.action_processor import process_action\nfrom soco_cli.speakers import Speakers\nfrom soco_cli.utils import (\n    configure_logging,\n    create_speaker_cache,\n    get_speaker,\n    set_api,\n    set_speaker_list,\n    sig_handler,\n    speaker_cache,\n)\n\n\ndef run_command(\n    speaker_name: Union[str, SoCo],\n    action: str,\n    *args: str,  # Means that all args are strings\n    use_local_speaker_list: bool = False,\n    redirect_io: bool = True,\n) -> Tuple[int, str, str]:\n    \"\"\"Use SoCo-CLI to run a sonos command.\n\n    The exit code, output string and error message string are returned as a\n    three-tuple. If the exit code is non-zero, the error message will be\n    populated and the output string will always be an empty string.\n\n    All exceptions are caught when this function is run. Exception details\n    will be returned in the error message string.\n\n    Args:\n        speaker_name (str or SoCo): The name of the speaker, or its IP address.\n            Alternatively, a 'SoCo' object can be supplied.\n        action (str): The name of the SoCo-CLI action to perform.\n        *args (list[str]): The set of arguments that accompany the action.\n        use_local_speaker_list (bool, optional): Whether to use the local\n            speaker cache.\n        redirect_io (bool, optional): Whether to redirect stdout and stderr\n            to capture their output for inclusion in the return value. If\n            False, messages will be emitted directly to stdout & stderr\n            during execution of the command, and this content will not be\n            included in the return tuple.\n\n    Returns:\n        (int, str, str): a three-tuple of exit_code, output_string and\n        error_msg. If redirect_io is false, output_string will be empty.\n    \"\"\"\n\n    # Prevent errors from causing exit\n    set_api()\n\n    if redirect_io:\n        # Capture stdout and stderr for the duration of this command\n        output = StringIO()\n        sys.stdout = output\n        error = StringIO()\n        sys.stderr = error\n\n    speaker = None\n    exception_error = None\n\n    # Can pass a SoCo object instead of the speaker name\n    if isinstance(speaker_name, SoCo):\n        speaker = speaker_name\n\n    elif isinstance(speaker_name, str):\n        try:\n            speaker = _get_soco_object(\n                speaker_name, use_local_speaker_list=use_local_speaker_list\n            )\n        except Exception as e:\n            logging.info(\"Exception: {}\".format(e))\n            exception_error = e\n\n    if speaker:\n        action_return = False\n        try:\n            action_return = process_action(\n                speaker, action, args, use_local_speaker_list=use_local_speaker_list\n            )\n        except Exception as e:\n            logging.info(\"Exception: {}\".format(e))\n            exception_error = e\n\n        if redirect_io:\n            output_msg = output.getvalue().rstrip()\n            error_out = error.getvalue().rstrip()\n        else:\n            output_msg = \"\"\n            error_out = \"\"\n\n        if output_msg != \"\":\n            lines = output_msg.splitlines()\n            if len(lines) > 1 and lines[0] != \"\":\n                output_msg = \"\\n\" + output_msg\n            if len(lines) > 1 and output_msg[len(lines) - 1] != \"\":\n                output_msg = output_msg + \"\\n\"\n\n        if exception_error:\n            if error_out:\n                error_out = error_out + \"\\nError: \" + str(exception_error)\n            else:\n                error_out = \"Error: \" + str(exception_error)\n\n        if action_return is False:\n            if error_out == \"\":\n                hint = \" ... missing spaces around ':'?\" if \":\" in action else \"\"\n                error_out = \"Error: Action '{}' not recognised{}\".format(action, hint)\n            return_tuple = (1, output_msg, error_out)\n        else:\n            return_tuple = (0, output_msg, error_out)\n    else:\n        return_tuple = (\n            1,\n            \"\",\n            \"Speaker '{}' not found: {}\".format(speaker_name, exception_error),\n        )\n\n    if redirect_io:\n        # Restore stdout and stderr\n        sys.stdout = sys.__stdout__\n        sys.stderr = sys.__stderr__\n\n    logging.info(\"Return value: {}\".format(return_tuple))\n\n    return return_tuple\n\n\ndef set_log_level(log_level: str = \"None\") -> None:\n    \"\"\"Convenience function to set up logging.\n\n    Args:\n        log_level (str): Can be one of None, Critical, Error, Warn, Info, Debug.\n    \"\"\"\n    configure_logging(log_level)\n\n\ndef handle_sigint() -> None:\n    \"\"\"Convenience function to set up a graceful CTRL-C (sigint) handler.\"\"\"\n    signal(SIGINT, sig_handler)\n\n\ndef rescan_speakers(timeout: float = None) -> None:\n    \"\"\"Run full network scan to find speakers.\"\"\"\n    _check_for_speaker_cache()\n    speaker_cache().scan(reset=True, scan_timeout_override=timeout)\n\n\ndef rediscover_speakers() -> None:\n    \"\"\"Run normal SoCo discovery to discover speakers.\"\"\"\n    _check_for_speaker_cache()\n    speaker_cache().discover(reset=True)\n\n\ndef get_all_speakers(use_scan: bool = False) -> list:\n    \"\"\"Return all SoCo instances.\"\"\"\n    _check_for_speaker_cache()\n    return [s[0] for s in speaker_cache().get_all_speakers(use_scan=use_scan)]\n\n\ndef get_all_speaker_names(use_scan: bool = False) -> list:\n    \"\"\"Return all speaker names.\"\"\"\n    _check_for_speaker_cache()\n    return speaker_cache().get_all_speaker_names(use_scan=use_scan)\n\n\ndef get_soco_object(\n    speaker_name: str, use_local_speaker_list: bool = False\n) -> Tuple[Union[SoCo, None], str]:\n    \"\"\"Uses the full set of soco_cli strategies to find a speaker.\n\n    Args:\n        speaker_name (str): The name of the speaker to find.\n        use_local_speaker_list (bool, optional): Whether to use the local\n            speaker cache.\n\n    Returns:\n        (SoCo, str): Tuple of SoCo object, or None if no speaker is found,\n        and an error message.\n    \"\"\"\n    set_api()\n\n    error = StringIO()\n    sys.stderr = error\n\n    speaker = _get_soco_object(speaker_name, use_local_speaker_list)\n\n    sys.stderr = sys.__stderr__\n\n    error_msg = error.getvalue().rstrip()\n    if not speaker and error_msg == \"\":\n        error_msg = \"Speaker not found\"\n\n    return speaker, error_msg\n\n\ndef _get_soco_object(speaker_name: str, use_local_speaker_list: bool = False) -> SoCo:\n    \"\"\"Internal helper version that doesn't redirect stderr.\"\"\"\n\n    if use_local_speaker_list:\n        _setup_local_speaker_list()\n\n    _check_for_speaker_cache()\n\n    return get_speaker(speaker_name, use_local_speaker_list)\n\n\ndef _check_for_speaker_cache() -> None:\n    if not speaker_cache():\n        create_speaker_cache(max_threads=256, scan_timeout=1.0, min_netmask=24)\n\n\n# For local speaker list operations\nspeaker_list_set = False\n\n\ndef _setup_local_speaker_list() -> None:\n    global speaker_list_set\n    if not speaker_list_set:\n        speaker_list = Speakers()\n        if not speaker_list.load():\n            logging.info(\"Start speaker discovery\")\n            speaker_list.discover()\n            speaker_list.save()\n        set_speaker_list(speaker_list)\n    speaker_list_set = True\n"
  },
  {
    "path": "soco_cli/check_for_update.py",
    "content": "\"\"\"Checks GitHub for a later version of SoCo-CLI\"\"\"\n\nimport logging\nfrom typing import Union\nfrom urllib.request import urlopen\n\nfrom soco_cli.__init__ import __version__  # type: ignore\nfrom soco_cli.utils import error_report\n\ninit_file_url = (\n    \"https://raw.githubusercontent.com/avantrec/soco-cli/master/soco_cli/__init__.py\"\n)\n\n\ndef get_latest_version() -> Union[str, None]:\n    try:\n        file = urlopen(init_file_url, timeout=3.0)\n    except Exception as e:\n        error_report(\n            \"Unable to get latest version information from GitHub: {}\".format(e)\n        )\n        return None\n\n    for line in file:\n        decoded_line = line.decode(\"utf-8\")\n        if \"__version__\" in decoded_line:\n            latest_version = (\n                decoded_line.replace(\"__version__ = \", \"\")\n                .replace('\"', \"\")\n                .replace(\"\\n\", \"\")\n            )\n            logging.info(\"Latest version is v{}\".format(latest_version))\n            break\n    else:\n        logging.info(\"Unable to find latest version\")\n        return None\n\n    return latest_version\n\n\ndef print_update_status() -> bool:\n    latest_version = get_latest_version()\n    if latest_version is not None:\n        if __version__ == latest_version:\n            print(\"SoCo-CLI is up to date\")\n        else:\n            print(\"An update is available: v\" + latest_version)\n        return True\n    return False\n\n\ndef update_available() -> bool:\n    if __version__ == get_latest_version():\n        return False\n    return True\n"
  },
  {
    "path": "soco_cli/cmd_parser.py",
    "content": "\"\"\"Parse sequential command lines, using ':' as a command separator.\"\"\"\n\n\nclass CLIParser:\n    def __init__(self):\n        self._args = None\n        self._sequences = None\n        self._separator = \":\"\n\n    def parse(self, args):\n        self._args = args\n\n        sequence = []  # A single command sequence\n        sequences = []  # A list of command sequences\n        for arg in args:\n            # if len(arg) > 1 and self._separator in arg:\n            #     # Catch special cases of colon use: HH:MM(:SS) time formats,\n            #     # and URLs\n            #     if not (\n            #         sequence\n            #         and sequence[-1]\n            #         in [\n            #             \"wait\",\n            #             \"wait_for\",\n            #             \"wait_until\",\n            #             \"seek\",\n            #             \"seek_to\",\n            #             \"seek_forward\",\n            #             \"sf\",\n            #             \"seek_back\",\n            #             \"sb\",\n            #             \"sleep\",\n            #             \"sleep_timer\",\n            #             \"sleep_at\",\n            #             \"wait_stopped_for\",\n            #             \"loop_for\",\n            #             \"loop_until\",\n            #         ]\n            #         or \":/\" in arg\n            #     ):\n            #         error_and_exit(\n            #             \"Spaces are required each side of the ':' command separator\"\n            #         )\n            if arg != self._separator:\n                sequence.append(arg)\n            else:\n                sequences.append(sequence)\n                sequence = []\n        if sequence:\n            sequences.append(sequence)\n\n        self._sequences = sequences\n\n    def get_sequences(self):\n        return self._sequences\n"
  },
  {
    "path": "soco_cli/http_api.py",
    "content": "\"\"\"Implements an HTTP API server for SoCo-CLI commands.\"\"\"\n\nfrom sys import version_info\n\nif version_info.major == 3 and version_info.minor < 7:\n    print(\"HTTP API Server requires Python 3.7 or above\")\n    exit(1)\n\nimport argparse\nimport pprint\nimport shlex\nfrom os import kill, scandir\nfrom os.path import abspath\nfrom signal import SIGINT\nfrom subprocess import STDOUT, CalledProcessError, Popen, check_output\nfrom sys import exit\nfrom typing import Dict, List, Optional, Tuple\n\nimport uvicorn  # type: ignore\nfrom fastapi import FastAPI\n\nfrom soco_cli.__init__ import __version__ as version  # type: ignore\nfrom soco_cli.api import get_all_speaker_names\nfrom soco_cli.api import get_soco_object as get_speaker\nfrom soco_cli.api import rescan_speakers\nfrom soco_cli.api import run_command as sc_run\nfrom soco_cli.play_local_file import is_supported_type\nfrom soco_cli.speakers import Speakers\nfrom soco_cli.utils import version as print_version\n\n# Globals\nUSE_LOCAL = False\nPORT = 8000\nINFO = \"SoCo-CLI HTTP API Server v\" + version\nPREFIX = \"SoCo-CLI: \"\nPREFIX_MACRO = PREFIX + \"Macro: \"\nMACROS: Dict[str, str] = {}\nMACRO_FILE = \"\"\nPP = pprint.PrettyPrinter(indent=len(PREFIX_MACRO))\nASYNC_PREFIX = \"async_\"\n\n# Gets used with the local speaker list only\nSPEAKER_LIST = Speakers(network_timeout=1.0)\n\n\nclass ActiveAsyncOps:\n    \"\"\"\n    Keep track of running async processes, and allow\n    processes to be stopped.\n    \"\"\"\n\n    def __init__(self):\n        self.active_async_ops = {}\n\n    def add_async_pid(self, speaker_ip: str, pid: int):\n        self.active_async_ops.update({speaker_ip: pid})\n\n    def get_async_pid(self, speaker_ip) -> Optional[int]:\n        return self.active_async_ops.get(speaker_ip)\n\n    def remove_async_pid(self, speaker_ip) -> Optional[int]:\n        pid = self.active_async_ops.get(speaker_ip)\n        if pid is not None:\n            self.active_async_ops.pop(speaker_ip)\n        return pid\n\n    def stop_async_process(self, speaker_ip: str) -> Optional[int]:\n        pid = self.get_async_pid(speaker_ip)\n        if pid is None:\n            return None\n        try:\n            kill(pid, SIGINT)\n            self.remove_async_pid(speaker_ip)\n        except ProcessLookupError:\n            # Process already dead; clean up the stale PID\n            self.remove_async_pid(speaker_ip)\n        except Exception:\n            # Kill failed for another reason (e.g. permissions); leave PID tracked\n            return None\n        return pid\n\n\nASYNC_OPS = ActiveAsyncOps()\nASYNC_MACRO_OPS = ActiveAsyncOps()\n\n\nsc_app = FastAPI(\n    title=\"SoCo-CLI HTTP API Server\",\n    description=\"**Use this interface to review and test SoCo-CLI's HTTP API**\",\n    version=version,\n    contact={\"name\": \"Avantrec Ltd\", \"url\": \"https://github.com/avantrec/soco-cli\"},\n    license_info={\n        \"name\": \"Apache 2.0\",\n        \"url\": \"https://www.apache.org/licenses/LICENSE-2.0.html\",\n    },\n)\n\n\ndef command_core(\n    speaker: str, action: str, *args: str, use_local: bool = False\n) -> Dict:\n    device, error_msg = get_speaker(speaker, use_local_speaker_list=use_local)\n    if device:\n        speaker = device.player_name\n        if not action.startswith(ASYNC_PREFIX):\n            exit_code, result, error_msg = sc_run(\n                device, action, *args, use_local_speaker_list=use_local\n            )\n        else:\n            action = action.replace(ASYNC_PREFIX, \"\")\n            try:\n                ASYNC_OPS.stop_async_process(device.ip_address)\n                proc = Popen([\"sonos\", device.ip_address, action, *args])\n                ASYNC_OPS.add_async_pid(device.ip_address, proc.pid)\n                exit_code = 0\n                error_msg = \"\"\n                result = \"\"\n            except Exception as e:\n                exit_code = 1\n                error_msg = str(e)\n                result = \"\"\n    else:\n        exit_code = 1\n        result = \"\"\n\n    # Quote speaker names & arguments containing spaces\n    if \" \" in speaker:\n        quoted_speaker = '\"' + speaker + '\"'\n    else:\n        quoted_speaker = speaker\n    new_args = []\n    for i in range(len(args)):\n        if \" \" in args[i]:\n            new_args.append('\"' + args[i] + '\"')\n        else:\n            new_args.append(args[i])\n\n    # Print the equivalent 'sonos' command and exit code\n    if len(new_args) != 0:\n        arguments = \" \".join(new_args).rstrip()\n        print(\n            PREFIX\n            + \"Command = 'sonos {} {} {}', \".format(quoted_speaker, action, arguments),\n            end=\"\",\n        )\n    else:\n        print(\n            PREFIX + \"Command = 'sonos {} {}', \".format(quoted_speaker, action), end=\"\"\n        )\n    if exit_code == 0:\n        print(\"exit code = {}\".format(exit_code))\n    else:\n        print(\"exit code = {} [{}]\".format(exit_code, error_msg))\n\n    return {\n        \"speaker\": speaker,\n        \"action\": action,\n        \"args\": args,\n        \"exit_code\": exit_code,\n        \"result\": result,\n        \"error_msg\": error_msg,\n    }\n\n\n@sc_app.get(\"/\")\ndef root() -> Dict:\n    return {\"info\": INFO}\n\n\n@sc_app.get(\"/speakers\")\ndef speakers() -> Dict:\n    if USE_LOCAL:\n        speakers = SPEAKER_LIST.get_all_speaker_names()\n    else:\n        speakers = get_all_speaker_names()\n    print(PREFIX + \"Speakers: {}\".format(speakers))\n    return {\"speakers\": speakers}\n\n\n@sc_app.get(\"/rediscover\")\ndef rediscover() -> Dict:\n    if USE_LOCAL:\n        SPEAKER_LIST.discover()\n        SPEAKER_LIST.save()\n        print(PREFIX + \"Saved new local speaker list\")\n        speakers = SPEAKER_LIST.get_all_speaker_names()\n    else:\n        rescan_speakers(timeout=2.0)\n        speakers = get_all_speaker_names()\n    print(PREFIX + \"Speakers (re)discovered: {}\".format(speakers))\n    return {\"speakers_discovered\": speakers}\n\n\n@sc_app.get(\"/list_audio_files/{directory:path}\")\ndef list_audio_files(directory: str) -> List[str]:\n    tracks = []\n    try:\n        with scandir(directory) as files:\n            for file in files:\n                if is_supported_type(file.name):\n                    tracks.append(file.name)\n    except FileNotFoundError:\n        pass\n    return tracks\n\n\n# Deprecated\n@sc_app.get(\"/macros\", include_in_schema=False)\ndef macros() -> Dict:\n    return MACROS\n\n\n@sc_app.get(\"/macros/list\")\ndef macros_list() -> Dict:\n    return MACROS\n\n\n@sc_app.get(\"/macros/reload\")\ndef macros_reload() -> Dict:\n    global MACROS\n    _load_macros(MACROS, filename=MACRO_FILE)\n    return MACROS\n\n\n@sc_app.get(\"/macro/{macro_name}\")\ndef run_macro(macro_name: str) -> Dict:\n    command, result = _process_macro(macro_name)\n    return {\"command\": command, \"result\": result}\n\n\n@sc_app.get(\"/macro/{macro_name}/{arg_1}\")\ndef run_macro_1(macro_name: str, arg_1: str) -> Dict:\n    command, result = _process_macro(macro_name, arg_1)\n    return {\"command\": command, \"result\": result}\n\n\n@sc_app.get(\"/macro/{macro_name}/{arg_1}/{arg_2}\")\ndef run_macro_2(macro_name: str, arg_1: str, arg_2: str) -> Dict:\n    command, result = _process_macro(macro_name, arg_1, arg_2)\n    return {\"command\": command, \"result\": result}\n\n\n@sc_app.get(\"/macro/{macro_name}/{arg_1}/{arg_2}/{arg_3}\")\ndef run_macro_3(macro_name: str, arg_1: str, arg_2: str, arg_3: str) -> Dict:\n    command, result = _process_macro(macro_name, arg_1, arg_2, arg_3)\n    return {\"command\": command, \"result\": result}\n\n\n@sc_app.get(\"/macro/{macro_name}/{arg_1}/{arg_2}/{arg_3}/{arg_4}\")\ndef run_macro_4(\n    macro_name: str, arg_1: str, arg_2: str, arg_3: str, arg_4: str\n) -> Dict:\n    command, result = _process_macro(macro_name, arg_1, arg_2, arg_3, arg_4)\n    return {\"command\": command, \"result\": result}\n\n\n@sc_app.get(\"/macro/{macro_name}/{arg_1}/{arg_2}/{arg_3}/{arg_4}/{arg_5}\")\ndef run_macro_5(\n    macro_name: str, arg_1: str, arg_2: str, arg_3: str, arg_4: str, arg_5: str\n) -> Dict:\n    command, result = _process_macro(macro_name, arg_1, arg_2, arg_3, arg_4, arg_5)\n    return {\"command\": command, \"result\": result}\n\n\n@sc_app.get(\"/macro/{macro_name}/{arg_1}/{arg_2}/{arg_3}/{arg_4}/{arg_5}/{arg_6}\")\ndef run_macro_6(\n    macro_name: str,\n    arg_1: str,\n    arg_2: str,\n    arg_3: str,\n    arg_4: str,\n    arg_5: str,\n    arg_6: str,\n) -> Dict:\n    command, result = _process_macro(\n        macro_name, arg_1, arg_2, arg_3, arg_4, arg_5, arg_6\n    )\n    return {\"command\": command, \"result\": result}\n\n\n@sc_app.get(\n    \"/macro/{macro_name}/{arg_1}/{arg_2}/{arg_3}/{arg_4}/{arg_5}/{arg_6}/{arg_7}\"\n)\ndef run_macro_7(\n    macro_name: str,\n    arg_1: str,\n    arg_2: str,\n    arg_3: str,\n    arg_4: str,\n    arg_5: str,\n    arg_6: str,\n    arg_7: str,\n) -> Dict:\n    command, result = _process_macro(\n        macro_name, arg_1, arg_2, arg_3, arg_4, arg_5, arg_6, arg_7\n    )\n    return {\"command\": command, \"result\": result}\n\n\n@sc_app.get(\n    \"/macro/{macro_name}/{arg_1}/{arg_2}/{arg_3}/{arg_4}/{arg_5}/{arg_6}/{arg_7}/{arg_8}\"\n)\ndef run_macro_8(\n    macro_name: str,\n    arg_1: str,\n    arg_2: str,\n    arg_3: str,\n    arg_4: str,\n    arg_5: str,\n    arg_6: str,\n    arg_7: str,\n    arg_8: str,\n) -> Dict:\n    command, result = _process_macro(\n        macro_name, arg_1, arg_2, arg_3, arg_4, arg_5, arg_6, arg_7, arg_8\n    )\n    return {\"command\": command, \"result\": result}\n\n\n@sc_app.get(\n    \"/macro/{macro_name}/{arg_1}/{arg_2}/{arg_3}/{arg_4}/{arg_5}/{arg_6}/{arg_7}/{arg_8}/{arg_9}\"\n)\ndef run_macro_9(\n    macro_name: str,\n    arg_1: str,\n    arg_2: str,\n    arg_3: str,\n    arg_4: str,\n    arg_5: str,\n    arg_6: str,\n    arg_7: str,\n    arg_8: str,\n    arg_9: str,\n) -> Dict:\n    command, result = _process_macro(\n        macro_name, arg_1, arg_2, arg_3, arg_4, arg_5, arg_6, arg_7, arg_8, arg_9\n    )\n    return {\"command\": command, \"result\": result}\n\n\n@sc_app.get(\n    \"/macro/{macro_name}/{arg_1}/{arg_2}/{arg_3}/{arg_4}\"\n    \"/{arg_5}/{arg_6}/{arg_7}/{arg_8}/{arg_9}/{arg_10}\"\n)\ndef run_macro_10(\n    macro_name: str,\n    arg_1: str,\n    arg_2: str,\n    arg_3: str,\n    arg_4: str,\n    arg_5: str,\n    arg_6: str,\n    arg_7: str,\n    arg_8: str,\n    arg_9: str,\n    arg_10: str,\n) -> Dict:\n    command, result = _process_macro(\n        macro_name,\n        arg_1,\n        arg_2,\n        arg_3,\n        arg_4,\n        arg_5,\n        arg_6,\n        arg_7,\n        arg_8,\n        arg_9,\n        arg_10,\n    )\n    return {\"command\": command, \"result\": result}\n\n\n@sc_app.get(\n    \"/macro/{macro_name}/{arg_1}/{arg_2}/{arg_3}/{arg_4}\"\n    \"/{arg_5}/{arg_6}/{arg_7}/{arg_8}/{arg_9}/{arg_10}/{arg_11}\"\n)\ndef run_macro_11(\n    macro_name: str,\n    arg_1: str,\n    arg_2: str,\n    arg_3: str,\n    arg_4: str,\n    arg_5: str,\n    arg_6: str,\n    arg_7: str,\n    arg_8: str,\n    arg_9: str,\n    arg_10: str,\n    arg_11: str,\n) -> Dict:\n    command, result = _process_macro(\n        macro_name,\n        arg_1,\n        arg_2,\n        arg_3,\n        arg_4,\n        arg_5,\n        arg_6,\n        arg_7,\n        arg_8,\n        arg_9,\n        arg_10,\n        arg_11,\n    )\n    return {\"command\": command, \"result\": result}\n\n\n@sc_app.get(\n    \"/macro/{macro_name}/{arg_1}/{arg_2}/{arg_3}/{arg_4}\"\n    \"/{arg_5}/{arg_6}/{arg_7}/{arg_8}/{arg_9}/{arg_10}/{arg_11}/{arg_12}\"\n)\ndef run_macro_12(\n    macro_name: str,\n    arg_1: str,\n    arg_2: str,\n    arg_3: str,\n    arg_4: str,\n    arg_5: str,\n    arg_6: str,\n    arg_7: str,\n    arg_8: str,\n    arg_9: str,\n    arg_10: str,\n    arg_11: str,\n    arg_12: str,\n) -> Dict:\n    command, result = _process_macro(\n        macro_name,\n        arg_1,\n        arg_2,\n        arg_3,\n        arg_4,\n        arg_5,\n        arg_6,\n        arg_7,\n        arg_8,\n        arg_9,\n        arg_10,\n        arg_11,\n        arg_12,\n    )\n    return {\"command\": command, \"result\": result}\n\n\n@sc_app.get(\"/{speaker}/{action}\")\ndef action_0(speaker: str, action: str) -> Dict:\n    return command_core(speaker, action, use_local=USE_LOCAL)\n\n\n@sc_app.get(\"/{speaker}/{action}/{arg_1}\")\ndef action_1(speaker: str, action: str, arg_1: str) -> Dict:\n    return command_core(speaker, action, arg_1, use_local=USE_LOCAL)\n\n\n@sc_app.get(\"/{speaker}/{action}/{arg_1:path}\")\ndef action_1_path(speaker: str, action: str, arg_1: str) -> Dict:\n    \"\"\"\n    Handle the case where 'arg_1' is a path.\n    \"\"\"\n\n    # Handle special case of _end_on_pause_ being appended to a file path\n    # instead of being treated as a separate argument.\n    arg_2 = \"_end_on_pause_\"\n    if arg_1.endswith(\"/\" + arg_2):\n        arg_1 = arg_1[: -(len(arg_2) + 1)]\n        return command_core(speaker, action, arg_1, arg_2, use_local=USE_LOCAL)\n\n    return command_core(speaker, action, arg_1, use_local=USE_LOCAL)\n\n\n@sc_app.get(\"/{speaker}/{action}/{arg_1}/{arg_2}\")\ndef action_2(speaker: str, action: str, arg_1: str, arg_2: str) -> Dict:\n    return command_core(speaker, action, arg_1, arg_2, use_local=USE_LOCAL)\n\n\n@sc_app.get(\"/{speaker}/{action}/{arg_1}/{arg_2}/{arg_3}\")\ndef action_3(speaker: str, action: str, arg_1: str, arg_2: str, arg_3: str) -> Dict:\n    return command_core(speaker, action, arg_1, arg_2, arg_3, use_local=USE_LOCAL)\n\n\ndef args_processor() -> None:\n    parser = argparse.ArgumentParser(\n        prog=\"sonos-http-api-server\",\n        usage=\"%(prog)s\",\n        description=INFO,\n    )\n    parser.add_argument(\n        \"--port\",\n        \"-p\",\n        type=int,\n        help=\"The port on which to listen\",\n    )\n    parser.add_argument(\n        \"--version\",\n        \"-v\",\n        action=\"store_true\",\n        default=False,\n        help=\"Print the SoCo-CLI and SoCo versions, and exit\",\n    )\n    parser.add_argument(\n        \"--macros\",\n        \"-m\",\n        type=str,\n        default=\"macros.txt\",\n        help=\"The file containing the local macros\",\n    )\n    parser.add_argument(\n        \"--use-local-speaker-list\",\n        \"-l\",\n        action=\"store_true\",\n        default=False,\n        help=\"Use the local speaker list instead of SoCo discovery\",\n    )\n    parser.add_argument(\n        \"--subnets\",\n        type=str,\n        help=\"Only with '-l': specify the networks or IP addresses to search\",\n    )\n\n    args = parser.parse_args()\n\n    if args.version:\n        print_version()\n        exit(0)\n\n    global PORT\n    if args.port is not None:\n        PORT = args.port\n\n    global USE_LOCAL\n    USE_LOCAL = args.use_local_speaker_list\n    if USE_LOCAL and args.subnets is not None:\n        subnets = args.subnets.split(\",\")\n        SPEAKER_LIST.set_subnets_no_check(subnets)\n        print(PREFIX + \"/rediscover will use subnets = {}\".format(subnets))\n    if not USE_LOCAL and args.subnets is not None:\n        print(PREFIX + \"Option '--subnets' ignored; only valid with local cache\")\n\n    global MACRO_FILE\n    MACRO_FILE = abspath(args.macros)\n\n\ndef main() -> None:\n    args_processor()\n    print(PREFIX + \"Starting \" + INFO)\n\n    # Load local macros\n    global MACROS\n    _load_macros(MACROS, filename=MACRO_FILE)\n\n    try:\n        print(PREFIX + \"Loading speakers ... \", end=\"\", flush=True)\n        if USE_LOCAL:\n            SPEAKER_LIST.load()\n            print(SPEAKER_LIST.get_all_speaker_names())\n        else:\n            try:\n                # This forces speaker discovery\n                # For some reason, using 'get_all_speakers()' generates Uvicorn errors\n                get_speaker(\"\", USE_LOCAL)\n                print(get_all_speaker_names())\n            except:\n                print(PREFIX + \"Discovery failed: try '/rediscover'\")\n\n        # Start the server\n        try:\n            uvicorn.run(sc_app, host=\"0.0.0.0\", use_colors=False, port=PORT)\n        except KeyboardInterrupt:\n            pass\n        print(PREFIX + INFO + \" stopped\")\n        exit(0)\n\n    except Exception as error:\n        print(\"Error: {}\".format(error))\n        exit(1)\n\n\ndef _process_macro(macro_name: str, *args) -> Tuple[str, str]:\n    # Check for async prefix\n    is_async = macro_name.startswith(ASYNC_PREFIX)\n    if is_async:\n        macro_name = macro_name[len(ASYNC_PREFIX) :]\n\n    # Look up the macro\n    try:\n        macro = _lookup_macro(macro_name)\n        print(PREFIX_MACRO + \"Processing macro '{}' = '{}'\".format(macro_name, macro))\n    except KeyError:\n        print(PREFIX_MACRO + \"macro '{}' not found\".format(macro_name))\n        return \"\", \"Error: macro '{}' not found\".format(macro_name)\n\n    # Substitute variable arguments\n    sonos_command_line = _substitute_variables(macro, args)\n\n    # Finalise the command line\n    if USE_LOCAL:\n        sonos_command_line = \"sonos -l \" + sonos_command_line\n    else:\n        # Substitute speaker names for IP addresses, for efficiency\n        sonos_command_line = _substitute_speaker_ips(sonos_command_line)\n        sonos_command_line = \"sonos \" + sonos_command_line\n\n    # Execute the command\n    if is_async:\n        print(\n            PREFIX_MACRO\n            + \"Executing async: '\"\n            + sonos_command_line\n            + \"' in a background subprocess\"\n        )\n        try:\n            async_key = macro_name + (\"|\" + \"|\".join(args) if args else \"\")\n            ASYNC_MACRO_OPS.stop_async_process(async_key)\n            proc = Popen(shlex.split(sonos_command_line))\n            ASYNC_MACRO_OPS.add_async_pid(async_key, proc.pid)\n            print(PREFIX_MACRO + \"Async macro started with PID {}\".format(proc.pid))\n            return sonos_command_line, \"\"\n        except Exception as e:\n            print(PREFIX_MACRO + \"Async macro failed: {}\".format(e))\n            return sonos_command_line, \"Error: {}\".format(e)\n    else:\n        print(PREFIX_MACRO + \"Executing: '\" + sonos_command_line + \"' in a subprocess\")\n        try:\n            output = check_output(shlex.split(sonos_command_line), stderr=STDOUT)\n            print(PREFIX_MACRO + \"Exit code = 0\")\n            return sonos_command_line, output.decode(\"utf-8\").rstrip()\n        except CalledProcessError as exc:\n            error = exc.output.decode(\"utf-8\").rstrip().replace(\"\\n\", \"; \")\n            print(PREFIX_MACRO + \"Exit code = {} [{}]\".format(exc.returncode, error))\n            return sonos_command_line, error\n\n\ndef _lookup_macro(macro_name: str) -> str:\n    global MACROS\n    return MACROS[macro_name]\n\n\ndef _substitute_variables(macro: str, args: Tuple) -> str:\n    \"\"\"Substitute positional parameters with supplied variables.\"\"\"\n    parameters_list = [\n        \"%1\",\n        \"%2\",\n        \"%3\",\n        \"%4\",\n        \"%5\",\n        \"%6\",\n        \"%7\",\n        \"%8\",\n        \"%9\",\n        \"%10\",\n        \"%11\",\n        \"%12\",\n    ]\n    supplied_parameters = set(parameters_list[: len(args)])\n    parameters = set(parameters_list)\n    used_parameters = []\n    unsatisfied_parameters = set()\n    variables_used = []\n\n    elements = shlex.split(macro)\n    sonos_command_line_terms = []\n    for element in elements:\n        if element in parameters:\n            try:\n                arg_sub = _quote_if_contains_space(args[int(element[1:]) - 1])\n                if arg_sub == \"_\":\n                    # If the supplied argument is an underscore, ignore it\n                    raise IndexError\n                sonos_command_line_terms.append(arg_sub)\n                used_parameters.append(element)\n                variables_used.append(arg_sub)\n            except IndexError:\n                # Omit unsatisfied arguments and continue\n                unsatisfied_parameters.add(element)\n        else:\n            sonos_command_line_terms.append(_quote_if_contains_space(element))\n\n    used_parameters_set = set(used_parameters)\n\n    # Print out parameter usage\n    if len(args) > 0:\n        print(PREFIX_MACRO + \"Parameter variables supplied: {}\".format(list(args)))\n    if len(used_parameters) > 0:\n        print(\n            PREFIX_MACRO\n            + \"Parameter variables used: {} -> {}\".format(\n                used_parameters, variables_used\n            )\n        )\n    if len(unsatisfied_parameters) > 0:\n        print(\n            PREFIX_MACRO\n            + \"Parameter variables ignored or not supplied for: {}\".format(\n                sorted(list(unsatisfied_parameters))\n            )\n        )\n    if len(supplied_parameters - used_parameters_set) > 0:\n        unused_list = sorted(list(supplied_parameters - used_parameters_set))\n        unused_variables = []\n        for unused in unused_list:\n            unused_variables.append(_quote_if_contains_space(args[int(unused[1:]) - 1]))\n        print(\n            PREFIX_MACRO\n            + \"Parameter variables supplied but ignored or not used: {} -> {}\".format(\n                sorted(list(supplied_parameters - used_parameters_set)),\n                unused_variables,\n            )\n        )\n\n    # Return the substituted command line\n    return \" \".join(sonos_command_line_terms)\n\n\ndef _substitute_speaker_ips(macro: str, use_local: bool = False) -> str:\n    \"\"\"\n    Substitute speaker names for IP addresses, for efficiency.\n    Speaker names must be exact.\n    \"\"\"\n    elements = shlex.split(macro)\n    new_macro_list = []\n    for element in elements:\n        device, error_msg = get_speaker(element, use_local_speaker_list=use_local)\n        if device is not None and device.player_name == element:\n            new_macro_list.append(device.ip_address)\n            print(\n                PREFIX_MACRO\n                + \"Substituting speaker name '{}' by IP address '{}'\".format(\n                    device.player_name, device.ip_address\n                )\n            )\n        else:\n            new_macro_list.append(_quote_if_contains_space(element))\n    return \" \".join(new_macro_list)\n\n\ndef _load_macros(macros: dict, filename: str) -> bool:\n    print(PREFIX_MACRO + \"Attempting to (re)load macros from '{}'\".format(filename))\n    # Create the 'generic' macro\n    macros[\"__\"] = \"%1 %2 %3 %4 %5 %6 %7 %8 %9 %10 %11 %12\"\n    try:\n        with open(filename, \"r\") as f:\n            line = f.readline()\n            while line != \"\":\n                if not line.startswith(\"#\") and line != \"\\n\":\n                    if \"=\" not in line:\n                        print(\n                            PREFIX_MACRO\n                            + \"Malformed macro '{}'... ignored\".format(line)\n                        )\n                        print(line, end=\"\")\n                    else:\n                        name, _, value = line.partition(\"=\")\n                        macros[name.strip()] = value.strip()\n                line = f.readline()\n        print(PREFIX_MACRO + \"Loaded macros:\")\n        PP.pprint(macros)\n        return True\n    except Exception as e:\n        print(PREFIX_MACRO + \"Failed to load macro file: {}\".format(e))\n        return False\n\n\ndef _quote_if_contains_space(text: str) -> str:\n    if \" \" in text:\n        return '\"' + text + '\"'\n    else:\n        return text\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "soco_cli/interactive.py",
    "content": "\"\"\"SoCo-CLI interactive mode handler.\"\"\"\n\nimport logging\nimport subprocess\nimport sys\n\n# Readline is only available on Unix\ntry:\n    import readline\n\n    if \"libedit\" in readline.__doc__:\n        readline.parse_and_bind(\"bind ^I rl_complete\")\n    else:\n        readline.parse_and_bind(\"tab: complete\")\n\n    RL = True\n    UNIX = True\n    WINDOWS = False\nexcept ImportError:\n    RL = False\n    WINDOWS = True\n    UNIX = False\n\nfrom copy import deepcopy\nfrom os import chdir\nfrom shlex import split as shlex_split\nfrom typing import List, Union\n\nfrom soco import SoCo  # type: ignore\n\nfrom soco_cli.action_processor import get_actions, list_actions\nfrom soco_cli.aliases import AliasManager\nfrom soco_cli.api import get_soco_object, run_command\nfrom soco_cli.check_for_update import print_update_status\nfrom soco_cli.cmd_parser import CLIParser\nfrom soco_cli.keystroke_capture import get_keystroke\nfrom soco_cli.utils import (\n    RewindableList,\n    docs,\n    get_ctrl_c_interrupted,\n    get_readline_history,\n    get_speaker,\n    local_speaker_list,\n    save_readline_history,\n    set_ctrl_c_interrupted,\n    set_interactive,\n    set_single_keystroke,\n    set_suspend_sighandling,\n    speaker_cache,\n    version,\n)\n\n# Alias Manager\nam = AliasManager()\n\n# The following actions are run in a subprocess, to allow them to be terminated\n# without dropping out of the interactive shell.\nACTIONS_TO_EXEC = [\n    \"track_follow\",\n    \"tf\",\n    \"track_follow_compact\",\n    \"tfc\",\n    \"wait_stop\",\n    \"wait_start\",\n    \"wait_stopped_for\",\n    \"wsf\",\n    \"wait_stop_not_pause\",\n    \"wsnp\",\n    \"wait_stopped_for_not_pause\",\n    \"wsfnp\",\n    \"wait_end_track\",\n    \"play_file\",\n    \"play_local_file\",\n    \"play_m3u\",\n    \"play_local_m3u\",\n    \"play_dir\",\n    \"play_directory\",\n    \"play_cd\",\n    \"if_stopped\",\n    \"if_playing\",\n]\n\n\nACTIONS_TO_EXEC_NO_SPEAKER = [\n    \"wait\",\n    \"wait_until\",\n    \"wait_for\",\n]\n\n\nLOG_SETTING = \"\"\n\n\ndef interactive_loop(\n    speaker_name,\n    log_setting,\n    use_local_speaker_list=False,\n    no_env=False,\n    single_keystroke=False,\n):\n    \"\"\"\n    The main interactive loop for gathering and processing interactive commands.\n\n    Args:\n        speaker_name (str): The name of the speaker supplied if supplied on the command\n            line.\n        log_setting (str): The logging option.\n        use_local_speaker_list (bool): Whether to use cached discovery.\n        no_env (bool): Whether to ignore environment variables.\n        single_keystroke (bool): Whether to start in single keystroke mode.\n    \"\"\"\n\n    global LOG_SETTING\n    LOG_SETTING = \"--log=\" + log_setting\n\n    speaker = None\n    saved_speaker = None\n    pushed = False\n    temp_active_speaker = False\n\n    # Is the speaker name set on the command line?\n    # Note: ignores SPKR set as part of the environment\n    if speaker_name:\n        try:\n            speaker, error_msg = get_soco_object(\n                speaker_name, use_local_speaker_list=use_local_speaker_list\n            )\n            if not speaker:\n                print(\"Speaker '{}' not found [{}]\".format(speaker_name, error_msg))\n                speaker_name = None\n            else:\n                speaker_name = speaker.player_name\n        except Exception as e:\n            print(\"Error finding speaker '{}': {}\".format(speaker_name, e))\n            speaker_name = None\n\n    print(\"\\nEntering SoCo-CLI interactive shell.\")\n    if single_keystroke:\n        print(\"Single Keystroke Mode ... 'x' to exit.\\n\")\n        set_single_keystroke(True)\n    else:\n        print(\"Type 'help' for available shell commands.\\n\")\n    if not RL:\n        print(\"Note: Autocompletion not currently available on Windows.\\n\")\n\n    set_interactive()\n    am.load_aliases()\n\n    if RL:\n        _set_actions_and_commands_list(use_local_speaker_list=use_local_speaker_list)\n        readline.parse_and_bind(\"tab: complete\")\n        readline.set_completer(_completer)\n        readline.set_completer_delims(\" \")\n        _get_readline_history()\n\n    root_prompt = \"Sonos\"\n\n    # Input loop\n    while True:\n        # Catch all exceptions raised in the input loop\n        try:\n            if speaker_name and speaker:\n                prompt = (root_prompt + \" [{}] > \").format(speaker_name)\n            else:\n                prompt = root_prompt + \" [] > \"\n\n            # Single keystroke input handling\n            if single_keystroke:\n                prompt = prompt.replace(\">\", \">>\")\n                print(prompt, flush=True, end=\"\")\n                command_line = get_keystroke()\n                # Handle Windows CTRL-C; disable exit\n                if command_line == \"\\x03\":\n                    logging.info(\"Windows CTRL-C received ... prevent exit\")\n                    print(\"Please use 'x' to exit >> \")\n                    continue\n                print(command_line)\n                # Normal exit\n                if command_line in [\"x\", \"X\"]:\n                    logging.info(\"Exit from single keystroke mode\")\n                    single_keystroke = False\n                    set_single_keystroke(False)\n                    continue\n\n            # Normal input handling\n            else:\n                command_line = input(prompt)\n            if command_line == \"\":\n                continue\n\n            # Parse multiple action sequences\n            cli_parser = CLIParser()\n            try:\n                cli_parser.parse(shlex_split(command_line))\n            except ValueError as error:\n                print(\"Error: {}\".format(error))\n                continue\n\n            # Loop through action sequences\n            command_sequences = RewindableList(cli_parser.get_sequences())\n            logging.info(\"Command sequences = {}\".format(command_sequences))\n\n            # The command_sequence list can change, so we use pop_next() until the\n            # list is exhausted\n            while True:\n                try:\n                    command = command_sequences.pop_next()\n                    logging.info(\"Current sequence = {}\".format(command))\n                except IndexError:\n                    break\n\n                if len(command) == 0:\n                    continue\n\n                # Is this an alias?\n                if command[0] in am.alias_names():\n                    ap = AliasProcessor()\n                    index = command_sequences.index()\n                    ap.process(command, am, command_sequences)\n                    command_sequences.rewind_to(index)\n                    continue\n\n                command_lower = command[0].lower()\n\n                # Aliases are now fully unpacked. If the command sequences\n                # contain loops, execute in a subprocess unless this is just\n                # setting up an alias.\n                if not command_lower == \"alias\":\n                    if _exec_loop(\n                        speaker, command, command_sequences, use_local_speaker_list\n                    ):\n                        break\n\n                if command_lower == \"0\":\n                    # Unset the active speaker\n                    logging.info(\"Unset active speaker\")\n                    speaker_name = None\n                    speaker = None\n                    continue\n\n                if command_lower.startswith(\"exit\"):\n                    logging.info(\"Exiting interactive mode\")\n                    _save_readline_history()\n                    return True\n\n                if command_lower in [\"help\", \"?\"]:\n                    _interactive_help()\n                    continue\n\n                if command_lower == \"actions\":\n                    _show_actions()\n                    continue\n\n                if command_lower in [\"single-keystroke\", \"sk\"]:\n                    print(\"Single keystroke mode ... 'x' to exit\")\n                    single_keystroke = True\n                    set_single_keystroke(True)\n                    continue\n\n                if command_lower == \"speakers\":\n                    _print_speaker_list(use_local_speaker_list=use_local_speaker_list)\n                    continue\n\n                if command_lower in [\"version\"]:\n                    print()\n                    version()\n                    print()\n                    continue\n\n                if command_lower in [\"docs\"]:\n                    docs()\n                    continue\n\n                if command_lower in [\"check-for-update\"]:\n                    print_update_status()\n                    continue\n\n                # Is the input a number in the range of speaker numbers?\n                try:\n                    speaker_number = int(command_lower)\n                    limit = len(\n                        _get_speaker_names(\n                            use_local_speaker_list=use_local_speaker_list\n                        )\n                    )\n                    if 1 <= speaker_number <= limit:\n                        logging.info(\n                            \"Setting to active speaker no. {} \".format(speaker_number)\n                        )\n                        speaker_name = _get_speaker_names(\n                            use_local_speaker_list=use_local_speaker_list\n                        )[speaker_number - 1]\n                        speaker = get_speaker(speaker_name, use_local_speaker_list)\n                        logging.info(\"{} : {}\".format(speaker, speaker_name))\n                    else:\n                        print(\n                            \"Error: Speaker number is out of range (0 to {})\".format(\n                                limit\n                            )\n                        )\n                    continue\n                except ValueError:\n                    pass\n\n                if command_lower == \"rescan\":\n                    _rescan(use_local_speaker_list=use_local_speaker_list)\n                    continue\n\n                if command_lower == \"rescan_max\":\n                    _rescan(\n                        use_local_speaker_list=use_local_speaker_list, max_scan=True\n                    )\n                    continue\n\n                if command_lower == \"exec\":\n                    if len(command) > 1 and _exec(command[1:]):\n                        break\n                    continue\n\n                if command_lower == \"cd\":\n                    if len(command) > 1:\n                        try:\n                            logging.info(\"Attempting to cd to: '{}'\".format(command[1]))\n                            chdir(command[1])\n                        except Exception as e:\n                            print(e)\n                    continue\n\n                if command_lower == \"push\":\n                    if pushed is True:\n                        logging.info(\"Active speaker already pushed ... ignored\")\n                        continue\n                    if speaker:\n                        pushed = True\n                        logging.info(\n                            \"Pushing current active speaker: {}\".format(\n                                speaker.player_name\n                            )\n                        )\n                    else:\n                        pushed = False\n                        logging.info(\"No active speaker to push\")\n                    saved_speaker = speaker\n                    speaker_name = None\n                    speaker = None\n                    continue\n\n                if command_lower == \"pop\":\n                    if pushed is False:\n                        logging.info(\"No active speaker state to pop ... ignored\")\n                        continue\n                    logging.info(\"Popping the saved speaker state\")\n                    if saved_speaker:\n                        speaker = saved_speaker\n                        speaker_name = speaker.player_name\n                        logging.info(\"Saved speaker = '{}'\".format(speaker_name))\n                        saved_speaker = None\n                    elif pushed:\n                        saved_speaker = None\n                        speaker = None\n                        speaker_name = None\n                    else:\n                        logging.info(\"No saved speaker\")\n                    pushed = False\n                    continue\n\n                # Alias creation, update, and deletion\n                if command_lower == \"alias\":\n                    if len(command) == 1:\n                        am.print_aliases()\n                        continue\n                    # Remove 'alias'\n                    command.pop(0)\n                    alias_name = command.pop(0)\n                    if alias_name == \"alias\":\n                        print(\"Not permitted: cannot create alias for 'alias'\")\n                        break\n                    if len(command) == 0:\n                        if am.create_alias(alias_name, None):\n                            print(\"Alias '{}' removed\".format(alias_name))\n                        else:\n                            print(\"Alias '{}' not found\".format(alias_name))\n                    else:\n                        # Have to collect the remaining sequences: they're all\n                        # part of the alias. Reconstruction required.\n                        # Multi-word parameters need quotes reinstated\n                        _restore_quotes(command)\n                        actions = [\" \".join(command)]\n                        logging.info(\"Action = '{}'\".format(command))\n                        while True:\n                            try:\n                                command = command_sequences.pop_next()\n                                _restore_quotes(command)\n                                actions.append(\" \".join(command))\n                                logging.info(\"Action = '{}'\".format(command))\n                            except IndexError:\n                                break\n                        action = \" : \".join(actions)\n                        logging.info(\"Action sequence = '{}'\".format(action))\n                        _, new = am.create_alias(alias_name, action)\n                        if new:\n                            print(\"Alias '{}' created\".format(alias_name))\n                        else:\n                            print(\"Alias '{}' updated\".format(alias_name))\n                    am.save_aliases()\n                    _set_actions_and_commands_list(\n                        use_local_speaker_list=use_local_speaker_list\n                    )\n                    continue\n\n                # Command processing\n                try:\n                    args = command\n\n                    # Setting a speaker to operate on?\n                    try:\n                        if args[0] == \"set\":\n                            new_speaker_name = args[1]\n                            new_speaker = get_speaker(\n                                new_speaker_name, use_local_speaker_list\n                            )\n                            if not new_speaker:\n                                print(\n                                    \"Error: Speaker '{}' not found\".format(\n                                        new_speaker_name\n                                    )\n                                )\n                            else:\n                                logging.info(\n                                    \"Set new active speaker: '{}'\".format(\n                                        new_speaker_name\n                                    )\n                                )\n                                speaker = new_speaker\n                                speaker_name = speaker.player_name\n                            continue\n                    except IndexError:\n                        # No speaker name given\n                        logging.info(\"Unset active speaker ('set' with no arguments)\")\n                        speaker_name = None\n                        speaker = None\n                        continue\n\n                    if not speaker_name:\n                        logging.info(\n                            \"Treating first parameter '{}' as speaker name\".format(\n                                args[0]\n                            )\n                        )\n                        name = args.pop(0)\n                        if name in ACTIONS_TO_EXEC_NO_SPEAKER:\n                            print(\n                                \"Please set an active speaker to use the '{}' action\".format(\n                                    name\n                                )\n                            )\n                            continue\n                        else:\n                            speaker = get_speaker(name, use_local_speaker_list)\n                            if not speaker:\n                                print(\n                                    \"Error: Speaker '{}' not found; should an active\"\n                                    \" speaker be set?\".format(name)\n                                )\n                                continue\n                        if len(args) == 0:\n                            print(\n                                \"Error: no action or arguments supplied for speaker\"\n                                \" '{}'\".format(speaker.player_name)\n                            )\n                            continue\n\n                        # Temporarily establish an active speaker\n                        temp_active_speaker = True\n                        speaker_name = speaker.player_name\n                        logging.info(\n                            \"Temporarily establish active speaker: '{}'\".format(\n                                speaker_name\n                            )\n                        )\n                        # Replace the command sequence without the speaker name,\n                        # for processing next time round the loop\n                        command_sequences.insert(command_sequences.index(), args)\n                        logging.info(\"Reinserting command sequence: {}\".format(args))\n                        continue\n\n                    action = args.pop(0).lower()\n                    logging.info(\"Action = '{}'; args = {}\".format(action, args))\n                    # Commands often requiring CTRL-C to exit are run in a subprocess\n                    if (\n                        action in ACTIONS_TO_EXEC\n                        or action in ACTIONS_TO_EXEC_NO_SPEAKER\n                    ):\n                        if _exec_action(speaker.ip_address, action, args):\n                            break\n                    else:\n                        exit_code, output, error_msg = run_command(\n                            speaker,\n                            action,\n                            *args,\n                            use_local_speaker_list=use_local_speaker_list,\n                        )\n                        if exit_code:\n                            if error_msg != \"\":\n                                print(error_msg)\n                        else:\n                            if output != \"\":\n                                print(output)\n                            if action == \"rename\":\n                                speaker_name = speaker.get_speaker_info(refresh=True)[\n                                    \"zone_name\"\n                                ]\n                                _set_actions_and_commands_list(\n                                    use_local_speaker_list=use_local_speaker_list\n                                )\n                    if temp_active_speaker:\n                        logging.info(\n                            \"Unsetting temporary active speaker: '{}'\".format(\n                                speaker_name\n                            )\n                        )\n                        temp_active_speaker = False\n                        speaker = None\n                        speaker_name = None\n                except:\n                    print(\"Error: Invalid command\")\n\n        # Catch all exceptions in the interactive loop\n        except:\n            continue\n\n\nSHELL_COMMANDS = [\n    \"actions\",\n    \"alias \",\n    \"cd\",\n    \"check-for-update\",\n    \"docs\",\n    \"exec\",\n    \"exit\",\n    \"help\",\n    \"pop\",\n    \"push\",\n    \"rescan\",\n    \"rescan_max\",\n    \"set \",\n    \"single-keystroke\",\n    \"sk\",\n    \"speakers\",\n    \"version\",\n]\n\n\ndef _completer(text, context):\n    \"\"\"Auto-complete commands using TAB\"\"\"\n    matches = [cmd for cmd in _get_actions_and_commands() if cmd.startswith(text)]\n    return matches[context]\n\n\ndef _show_actions():\n    print()\n    print(\"Complete list of SoCo-CLI actions:\")\n    print(\"==================================\")\n    print()\n    list_actions(\n        include_loop_actions=True,\n        include_wait_actions=True,\n        include_track_follow_actions=True,\n    )\n    print()\n\n\nACTIONS_LIST = []\n\n\ndef _set_actions_and_commands_list(use_local_speaker_list=False):\n    logging.info(\"Rebuilding commands/action list\")\n    global ACTIONS_LIST\n    ACTIONS_LIST = (\n        [\n            action + \" \"\n            for action in get_actions(\n                include_loop_actions=True,\n                include_wait_actions=True,\n                include_track_follow_actions=True,\n            ) + _get_speaker_names(use_local_speaker_list=use_local_speaker_list)\n        ]\n        + SHELL_COMMANDS\n        + am.alias_names()\n    )\n\n\ndef _get_actions_and_commands():\n    return ACTIONS_LIST\n\n\ndef _interactive_help():\n    print(HELP_TEXT)\n\n\nHELP_TEXT = \"\"\"\nThis is SoCo-CLI interactive mode. Interactive commands are as follows:\n\n    '1', ...     :  Set the active speaker. Use the numbers shown by the\n                    'speakers' command. E.g., to set to speaker number 4\n                    in the list, just type '4'.\n                    '0' will unset the active speaker.\n    'actions'    :  Show the complete list of SoCo-CLI actions.\n    'alias'      :  Add an alias: alias <alias_name> <actions>\n                    Remove an alias: alias <alias_name>\n                    Update an alias by creating a new alias with the same name.\n                    Using 'alias' without parameters shows the current list of\n                    aliases.\n                    Aliases override existing actions and can contain\n                    sequences of actions.\n    'cd'         :  Change the working directory of the shell, e.g. 'cd ..'.\n                    Note that on Windows, backslashes must be doubled, e.g.:\n                    'cd C:\\\\'\n    'check-for-update'\n                 : Check whether an update is available\n    'docs'       :  Print a link to the online documentation.\n    'exec'       :  Run a shell command, e.g.: 'exec ls -l'.\n    'exit'       :  Exit the shell.\n    'help'       :  Show this help message (available shell commands).\n    'pop'        :  Restore saved active speaker state.\n    'push'       :  Save the current active speaker, and unset the active\n                    speaker.\n    'rescan'     :  If your speaker doesn't appear in the 'speakers' list,\n                    use this to perform a more comprehensive scan.\n    'rescan_max' :  Try this if you're having having trouble finding all your\n                    speakers.\n    'set <spkr>' :  Set the active speaker using its name.\n                    Use quotes when needed for the speaker name, e.g.,\n                    'set \"Front Reception\"'. Unambiguous, partial,\n                    case-insensitive matches are supported, e.g., 'set front'.\n                    To unset the active speaker, omit the speaker name,\n                    or just enter '0'.\n    'sk'         :  Enters 'single keystroke' mode. (Also 'single-keystroke'.)\n    'speakers'   :  List the names of all available speakers.\n    'version'    :  Print the versions of SoCo-CLI, SoCo, and Python in use.\n    \n    The action syntax is the same as when using 'sonos' from the command line.\n    If a speaker has been set in the shell, omit the speaker name from the\n    action.\n\n    Use the arrow keys for command history and command editing.\n    \n    [Not Available on Windows] Use the TAB key for autocompletion of shell\n    commands, SoCo-CLI actions, aliases, and speaker names.\n\"\"\"\n\n\ndef _get_speaker_names(use_local_speaker_list=False):\n    if use_local_speaker_list:\n        names = local_speaker_list().get_all_speaker_names()\n    else:\n        try:\n            names = speaker_cache().get_all_speaker_names()\n        except Exception as e:\n            print(\n                \"Speaker listing failed: please check your network connection [{}]\".format(\n                    e\n                )\n            )\n            names = []\n    return names\n\n\ndef _print_speaker_list(use_local_speaker_list=False):\n    print()\n    names = _get_speaker_names(use_local_speaker_list=use_local_speaker_list)\n    if len(names) > 0:\n        names.insert(0, \"Unset the active speaker\")\n        for index, name in enumerate(names, start=0):\n            print(\"  \", str(index).rjust(2), \":\", name)\n        print()\n\n\ndef _save_readline_history():\n    if RL:\n        logging.info(\"Saving shell history\")\n        save_readline_history()\n\n\ndef _get_readline_history():\n    if RL:\n        logging.info(\"Reading shell history\")\n        get_readline_history()\n\n\ndef _restore_quotes(command):\n    for index, parts in enumerate(command):\n        if len(parts.split()) > 1:\n            command[index] = '\"' + parts + '\"'\n\n\nclass AliasProcessor:\n    # The arg substitution names %1, ..., %9\n    _arg_names = tuple(\"%\" + str(x) for x in range(1, 10))\n\n    def __init__(self):\n        self._used_aliases = []\n        self._recurse_level = 0\n        self._command_count = 0\n        self._index = 0\n        self._command_list = []\n\n    def process(self, command, am, command_list):\n        self._recurse_level += 1\n        alias_name = command[0]\n        alias_parms = command[1:]\n        self._command_list = command_list\n        seq_number = len(command_list)\n\n        logging.info(\n            \"Alias unpacking: recursion level {}, sequence number {}\".format(\n                self._recurse_level, seq_number + 1\n            )\n        )\n\n        # Detect loops\n        for used_alias in self._used_aliases:\n            if used_alias[0] != self._recurse_level:\n                if used_alias[1] == seq_number and used_alias[2] == alias_name:\n                    # Alias name reused at different recursion levels but within\n                    # the unpacking of the same sequence signifies a loop.\n                    print(\"Error: Alias loop detected ... stopping\")\n                    self._remove_added_commands()\n                    return False\n        self._used_aliases.append((self._recurse_level, seq_number, alias_name))\n\n        alias_actions = am.action(alias_name)\n        try:\n            action_elements = shlex_split(alias_actions)\n        except ValueError as error:\n            print(\"Error: {}\".format(error))\n            return False\n\n        cli_parser = CLIParser()\n        cli_parser.parse(action_elements)\n        sequences = cli_parser.get_sequences()\n\n        logging.info(\"Unpacking the alias '{}' -> '{}'\".format(alias_name, sequences))\n\n        index = command_list.index()\n        for sequence in sequences:\n            alias_parms_local = alias_parms.copy()\n            # Positional argument substitution: %1, %2, etc.\n            alias_parms_used = []\n            for i, item in enumerate(sequence):\n                if item in self._arg_names:\n                    parm_index = int(item[1]) - 1\n                    try:\n                        sequence[i] = alias_parms_local[parm_index]\n                        logging.info(\n                            \"Substituting '{}' for arg. {}\".format(\n                                alias_parms_local[parm_index], item\n                            )\n                        )\n                        # This allows reuse of the same parameter\n                        alias_parms_used.append(parm_index)\n                    except IndexError:\n                        logging.info(\"No value found for arg. {}\".format(item))\n                        sequence[i] = None\n\n            # Remove unsatisfied arguments and substituted parameters\n            sequence = [x for x in sequence if x is not None]\n            alias_parms_local = [\n                y for x, y in enumerate(alias_parms_local) if not x in alias_parms_used\n            ]\n\n            # Recurse if the sequence is itself an alias\n            try:\n                if sequence[0] in am.alias_names():\n                    logging.info(\n                        \"Recursively unpacking the alias '{}'\".format(sequence[0])\n                    )\n                    logging.info(\"Unpacking: '{}'\".format(sequence + alias_parms_local))\n                    if self.process(sequence + alias_parms_local, am, command_list):\n                        index = command_list.index()\n                    else:\n                        return False\n\n                # Not an alias, so insert the sequence in the command list\n                # at the correct index, and increment the index\n                else:\n                    logging.info(\n                        \"Inserting new sequence {} at {}\".format(sequence, index)\n                    )\n                    command_list.insert(index, sequence)\n                    index += 1\n                    self._command_count += 1\n                    self._index = index\n                    logging.info(\"Current command list = {}\".format(command_list))\n            except IndexError:\n                logging.info(\"Empty sequence ... returning\")\n                return False\n\n        self._recurse_level -= 1\n\n        return True\n\n    def _remove_added_commands(self):\n        for _ in range(self._command_count):\n            cmd = self._command_list.pop_next()\n            logging.info(\"Removing command {}\".format(cmd))\n\n\ndef _rescan(use_local_speaker_list=False, max_scan=False):\n    try:\n        if use_local_speaker_list:\n            print(\"Using cached speaker list: no rescan performed\")\n        elif max_scan:\n            logging.info(\"Full network rescan at max strength (timeout = 10.0s)\")\n            speaker_cache().scan(reset=True, scan_timeout_override=10.0)\n            _print_speaker_list(use_local_speaker_list=use_local_speaker_list)\n        else:\n            logging.info(\"Full network rescan\")\n            speaker_cache().scan(reset=True)\n            _print_speaker_list(use_local_speaker_list=use_local_speaker_list)\n        _set_actions_and_commands_list(use_local_speaker_list=use_local_speaker_list)\n    except Exception as e:\n        print(\"Rescan failed: please check your network connection [{}]\".format(e))\n\n\ndef _exec(command_args: List[str]) -> bool:\n    \"\"\"Runs a command as a subprocess, in its own shell.\n\n    Args:\n        command_args (list): The command to execute.\n\n    Returns:\n        bool: True if the subprocess was interrupted by CTRL-C, False otherwise.\n    \"\"\"\n\n    # Check for spaces within any of the command line args,\n    # and quote if required\n    for index, cl_arg in enumerate(command_args):\n        if \" \" in cl_arg:\n            command_args[index] = '\"' + command_args[index] + '\"'\n\n    # Convert command list to a unified command line\n    command_line = \" \".join(command_args)\n\n    set_ctrl_c_interrupted(False)\n    set_suspend_sighandling(suspend=True)\n    try:\n        logging.info(\"Running command: '{}'\".format(command_line))\n        subprocess.run(command_line, shell=True)\n    except Exception as e:\n        print(e)\n    set_suspend_sighandling(suspend=False)\n    return get_ctrl_c_interrupted()\n\n\nCTRL_C_MSG_ISSUED = False\n\n\ndef _exec_action(speaker_ip: str, action: str, args: List[str]) -> bool:\n    # Commands to run in a subprocess, to allow CTRL-C\n    # to exit the subprocess only, and not the shell.\n\n    if action in ACTIONS_TO_EXEC_NO_SPEAKER:\n        command_line = [sys.argv[0], action, *args]\n    else:\n        command_line = [sys.argv[0], speaker_ip, action, *args]\n\n    # Pass through logging option\n    command_line.insert(1, LOG_SETTING)\n\n    global CTRL_C_MSG_ISSUED\n    if CTRL_C_MSG_ISSUED is False:\n        print(\"(Use CTRL-C to return to the Sonos shell prompt.)\")\n        CTRL_C_MSG_ISSUED = True\n\n    return _exec(command_line)\n\n\ndef _exec_command_line(command_line: str) -> None:\n    \"\"\"Runs a sonos command line as a subprocess, in its own shell.\n\n    Args:\n        command_line (str): The command line to execute.\n    \"\"\"\n\n    set_suspend_sighandling(suspend=True)\n    try:\n        logging.info(\"Running command: '{}'\".format(command_line))\n        subprocess.run(command_line, shell=True)\n    except Exception as e:\n        print(e)\n    set_suspend_sighandling(suspend=False)\n\n\ndef _loop_in_command_sequences(command_sequences: RewindableList) -> bool:\n    \"\"\"Is there a loop statement in any of the command sequences?\"\"\"\n    for sequence in command_sequences:\n        if any(\n            loop_action in sequence\n            for loop_action in [\"loop\", \"loop_until\", \"loop_for\"]\n        ):\n            logging.info(\"'loop' action found in command sequences\")\n            return True\n    return False\n\n\ndef _exec_loop(\n    speaker: Union[SoCo, None],\n    current_command: list,\n    remaining_sequences: RewindableList,\n    use_local: bool,\n) -> bool:\n    \"\"\"If there's a loop statement, run the actions in a subprocess.\n\n    Args:\n        speaker (SoCo, None): The speaker to which the command is targeted, or\n            None if the speaker is in the command line.\n        current_command (list): The current command sequence\n        remaining_sequences (RewindableList): The remaining list of command sequences\n        use_local (bool): use the local speaker list.\n\n    Returns:\n        bool: True if there's a loop statement, False otherwise.\n    \"\"\"\n\n    # Reassemble the complete command sequence list\n    command_sequences = deepcopy(remaining_sequences)\n    command_sequences.insert(0, current_command)\n\n    if _loop_in_command_sequences(command_sequences):\n        command_line = \"\"\n        first = True\n        while True:\n            try:\n                sequence = command_sequences.pop_next()\n                if not first:\n                    command_line += \" : \"\n                command_line += \" \".join(sequence)\n                if first:\n                    first = False\n            except IndexError:\n                break\n\n        global LOG_SETTING\n        sonos_command = \"sonos \" + LOG_SETTING + \" \"\n        if speaker is not None:\n            # This is a way of using the required speaker for each\n            # invocation in the list of commands, using the SPKR env. variable.\n            if UNIX:\n                command_line = (\n                    \"export SPKR=\"\n                    + speaker.ip_address\n                    + \" && \"\n                    + sonos_command\n                    + command_line\n                )\n            elif WINDOWS:\n                command_line = (\n                    'set \"SPKR='\n                    + speaker.ip_address\n                    + '\" && '\n                    + sonos_command\n                    + command_line\n                )\n        else:\n            if use_local:\n                sonos_command = sonos_command + \"-l \"\n            command_line = sonos_command + command_line\n        logging.info(\"'loop' statement found, command line = '{}'\".format(command_line))\n        _exec_command_line(command_line)\n        return True\n\n    return False\n"
  },
  {
    "path": "soco_cli/keystroke_capture.py",
    "content": "\"\"\"Captures single keystrokes.\"\"\"\n\nimport sys\nfrom os import name as os_name\n\nif os_name == \"nt\":\n    import msvcrt\n\nelse:\n    try:\n        import termios\n\n    except ImportError:\n        pass\n\n\ndef get_keystroke() -> str:\n    \"\"\"Wait for a keypress, then return it.\"\"\"\n\n    # Windows\n    if os_name == \"nt\":\n        result = msvcrt.getch().decode()  # type: ignore\n\n    # Unix\n    else:\n        result = None\n        fd = sys.stdin.fileno()\n        oldterm = termios.tcgetattr(fd)\n        newattr = termios.tcgetattr(fd)\n        newattr[3] = newattr[3] & ~termios.ICANON & ~termios.ECHO\n        termios.tcsetattr(fd, termios.TCSANOW, newattr)\n        try:\n            result = sys.stdin.read(1)\n        except IOError:\n            pass\n        finally:\n            termios.tcsetattr(fd, termios.TCSAFLUSH, oldterm)\n\n    return result\n"
  },
  {
    "path": "soco_cli/m3u_parser.py",
    "content": "\"\"\"Parse M3U files.\"\"\"\n\n# Derived from https://github.com/dvndrsn/M3uParser ... thanks!\n#\n# more info on the M3U file format available here:\n# http://n4k3d.com/the-m3u-file-format/\n\nfrom typing import List, Union\n\nfrom soco_cli.utils import error_report\n\n\nclass Track:\n    def __init__(\n        self, length: Union[str, None], title: Union[str, None], path: Union[str, None]\n    ) -> None:\n        self.length = length\n        self.title = title\n        self.path = path\n\n\n# song info lines are formatted like:\n# EXTINF:419,Alice In Chains - Rotten Apple\n# length (seconds)\n# Song title\n# file name - relative or absolute path of file\n\n\ndef parse_m3u(m3u_file: str) -> List[Track]:\n    with open(m3u_file, \"r\") as infile:\n        # Parse file contents. Files with an M3U/M3U8 extension must follow conventions.\n        if m3u_file.lower().endswith(\".m3u\") or m3u_file.lower().endswith(\".m3u8\"):\n            line = infile.readline()\n            if not line.startswith(\"#EXTM3U\"):\n                error_report(\"File '{}' lacks '#EXTM3U' as first line\".format(m3u_file))\n                return []\n\n        playlist = []\n        song = Track(None, None, None)\n        for line in infile:\n            line = line.strip()\n            if line.startswith(\"#EXTINF:\"):\n                # pull length and title from #EXTINF line\n                length, title = line.split(\"#EXTINF:\")[1].split(\",\", 1)\n                song = Track(length, title, None)\n            elif line.startswith(\"#\"):\n                # Comment line\n                pass\n            elif len(line) != 0:\n                # pull song path from all other, non-blank lines\n                song.path = line\n                playlist.append(song)\n                # reset the song variable so it doesn't use the same EXTINF more than once\n                song = Track(None, None, None)\n\n        return playlist\n"
  },
  {
    "path": "soco_cli/match_speaker_names.py",
    "content": "\"\"\"Matches a supplied speaker name to a stored name.\"\"\"\n\nimport logging\n\n\ndef speaker_name_matches(name_supplied, name_stored):\n    \"\"\"Matches speaker names.\n\n    Returns:\n        bool, bool: Returns whether a match is found, and whether\n            the match is considered exact or not.\n    \"\"\"\n\n    # Exact match\n    name_supplied_original = name_supplied\n    name_stored_original = name_stored\n    if name_supplied == name_stored:\n        logging.info(\n            \"Found exact speaker name match for '{}'\".format(name_stored_original)\n        )\n        return True, True\n\n    # Case insensitive match; treat as an exact match\n    name_supplied = name_supplied.lower()\n    name_stored = name_stored.lower()\n    if name_supplied == name_stored:\n        logging.info(\n            \"Found case-insensitive exact speaker name match for '{}' as '{}'\".format(\n                name_supplied_original, name_stored_original\n            )\n        )\n        return True, True\n\n    # Normalised apostrophe match; treat as an exact match\n    name_supplied = name_supplied.replace(\"’\", \"'\")\n    name_stored = name_stored.replace(\"’\", \"'\")\n    if name_supplied == name_stored:\n        logging.info(\n            \"Found apostrophe-normalised exact speaker name match for '{}' as '{}'\".format(\n                name_supplied_original, name_stored_original\n            )\n        )\n        return True, True\n\n    # Partial match with start of name\n    if name_stored.startswith(name_supplied):\n        logging.info(\n            \"Found partial, start-of-name match for '{}' as '{}'\".format(\n                name_supplied_original, name_stored_original\n            )\n        )\n        return True, False\n\n    # Partial match with any part of name\n    if name_supplied in name_stored:\n        logging.info(\n            \"Found partial, any-part-of-name match for '{}' as '{}'\".format(\n                name_supplied_original, name_stored_original\n            )\n        )\n        return True, False\n\n    # Not found\n    return False, False\n"
  },
  {
    "path": "soco_cli/play_local_file.py",
    "content": "\"\"\"Plays files from the local filesystem.\"\"\"\n\nimport functools\nimport http.client\nimport logging\nimport socket\nimport sys\nimport time\nimport urllib.parse\nfrom http.server import HTTPServer\nfrom ipaddress import IPv4Address, IPv4Network\nfrom os import chdir, path\nfrom socketserver import ThreadingMixIn\nfrom threading import Thread\nfrom typing import List, Optional\n\nimport ifaddr  # type: ignore\nfrom RangeHTTPServer import RangeRequestHandler  # type: ignore\nfrom soco import SoCo  # type: ignore\n\nfrom soco_cli.utils import (\n    error_report,\n    event_unsubscribe,\n    forget_event_sub,\n    remember_event_sub,\n    set_speaker_playing_local_file,\n)\n\n# The HTTP server port range to use\nPORT_START = 54000\nPORT_END = 54099\n\nSUPPORTED_TYPES = [\"MP3\", \"M4A\", \"MP4\", \"FLAC\", \"OGG\", \"WMA\", \"WAV\", \"AIFF\"]\n\n\nclass ThreadedHTTPServer(ThreadingMixIn, HTTPServer):\n    \"\"\"Handle requests in separate threads.\n\n    Use the MixIn approach instead of the core ThreadingHTTPServer\n    class for backwards compatibility with Python 3.5+\n    \"\"\"\n\n\nclass MyHTTPHandler(RangeRequestHandler):\n    # Handle the change to the SimpleHTTPRequestHandler __init__() in Python 3.7+\n    if sys.version_info >= (3, 7):\n\n        def __init__(self, *args, filename=None, speaker_ips=None, **kwargs):\n            self.filename = filename\n            self.speaker_ips = speaker_ips\n            super().__init__(*args, **kwargs)\n\n    else:\n\n        def __init__(\n            self, *args, filename=None, speaker_ips=None, directory=\"\", **kwargs\n        ):\n            self.filename = filename\n            self.speaker_ips = speaker_ips\n            try:\n                chdir(directory)\n            except:\n                pass\n            super().__init__(*args, **kwargs)\n\n    def do_GET(self) -> None:\n        logging.info(\"Get request received by HTTP server\")\n\n        # Only serve the specific file requested on the command line,\n        # and only to Sonos speakers in the Sonos system\n        error = False\n        if self.path.replace(\"/\", \"\") != self.filename:\n            logging.info(\"Access to file '{}' forbidden\".format(self.path))\n            error = True\n        if self.client_address[0] not in self.speaker_ips:\n            logging.info(\"Access from IP '{}' forbidden\".format(self.client_address[0]))\n            error = True\n        if error:\n            RangeRequestHandler.send_error(\n                self, code=403, message=\"SoCo-CLI HTTP Server: Access forbidden\"\n            )\n            return\n\n        # Forward the GET request\n        try:\n            super().do_GET()\n        except Exception as e:\n            # It's normal to hit some exceptions with Sonos\n            logging.info(\"Exception ignored: {}\".format(e))\n\n    def log_message(self, format, *args) -> None:\n        # Suppress HTTP logging\n        return\n\n\ndef http_server(\n    server_ip: str, directory: str, filename: str, speaker_ips: List[str]\n) -> Optional[ThreadedHTTPServer]:\n    # Set the directory from which to serve files, in the handler\n    # Set the specific filename and client IP that are authorised\n    handler = functools.partial(\n        MyHTTPHandler, filename=filename, speaker_ips=speaker_ips, directory=directory\n    )\n\n    # For possible future use: set up MIME types\n    # MyHTTPHandler.extensions_map[\".m4a\"] = \"audio/x-m4a\"\n    # MyHTTPHandler.extensions_map[\".aac\"] = \"audio/aac\"\n\n    # Find an available port by trying ports in sequence\n    for port in range(PORT_START, PORT_END + 1):\n        try:\n            httpd = ThreadedHTTPServer((server_ip, port), handler)\n            logging.info(\"Using {}:{} for web server\".format(server_ip, port))\n            httpd_thread = Thread(target=httpd.serve_forever, daemon=True)\n            httpd_thread.start()\n            logging.info(\"Web server started\")\n            return httpd\n        except OSError:\n            # Assume this means that the port is in use\n            logging.info(\n                \"Port {}:{} already in use ... trying next\".format(server_ip, port)\n            )\n            continue\n    return None\n\n\ndef get_server_ip(speaker: SoCo) -> Optional[str]:\n    # Get the host IP address to use as a server IP for Sonos\n    # on this host.\n\n    adapters = ifaddr.get_adapters()\n\n    # First, try to find a host IP address in the same network as the speaker\n    for adapter in adapters:\n        for ip in adapter.ips:\n            if ip.is_IPv4 and ip.ip != \"127.0.0.1\":\n                logging.info(\n                    \"Checking if IP address '{}' is in target speaker's network\".format(\n                        ip.ip\n                    )\n                )\n                network = IPv4Network(\n                    ip.ip + \"/\" + str(ip.network_prefix), strict=False\n                )\n                if IPv4Address(speaker.ip_address) in network:\n                    return ip.ip\n\n    # If that fails, try to find a host IP address that can reach the target speaker\n    for adapter in adapters:\n        for ip in adapter.ips:\n            if ip.is_IPv4 and ip.ip != \"127.0.0.1\":\n                # Find an available port\n                for port in range(PORT_START, PORT_END + 1):\n                    try:\n                        logging.info(\"Checking if port {} is available\".format(port))\n                        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:\n                            s.bind((ip.ip, port))\n                            available_port = port\n                            logging.info(\"Port {} is available\".format(port))\n                            break\n                    except socket.error:\n                        continue\n                else:\n                    logging.info(\"No available ports for IP address '{}'\".format(ip.ip))\n                    continue\n                try:\n                    logging.info(\n                        \"Checking target speaker's reachability from IP address '{}'\".format(\n                            ip.ip\n                        )\n                    )\n                    http_connection = http.client.HTTPConnection(\n                        speaker.ip_address,\n                        port=1400,\n                        timeout=5.0,\n                        source_address=(ip.ip, available_port),\n                    )\n                    http_connection.request(\"GET\", \"/status/info\")\n                    response = http_connection.getresponse()\n                    http_connection.close()\n                    if response.status == 200:\n                        return ip.ip\n                except:\n                    continue\n\n    return None\n\n\ndef wait_until_stopped(speaker: SoCo, uri: str, end_on_pause: bool):\n    playing_states = [\"PLAYING\", \"TRANSITIONING\"]\n    if not end_on_pause:\n        playing_states.append(\"PAUSED_PLAYBACK\")\n    logging.info(\"Playing states = {}\".format(playing_states))\n\n    try:\n        sub = speaker.avTransport.subscribe(auto_renew=True)\n        remember_event_sub(sub)\n    except Exception as e:\n        error_report(\"Exception {}\".format(e))\n        return\n\n    while True:\n        try:\n            event = sub.events.get(timeout=1.0)\n            state = event.variables[\"transport_state\"]\n            logging.info(\"Event received: playback state = '{}'\".format(state))\n\n            if state not in playing_states:\n                logging.info(\n                    \"Speaker '{}' in state '{}'\".format(\n                        speaker.player_name, event.variables[\"transport_state\"]\n                    )\n                )\n                break\n\n            # Check that the expected URI is still playing\n            try:\n                current_uri = event.variables[\"current_track_meta_data\"].get_uri()\n            except:\n                # Can only call get_uri() on certain datatypes\n                current_uri = \"\"\n            if current_uri != uri:\n                logging.info(\"Playback URI changed: exit event wait loop\")\n                break\n\n        except:\n            pass\n\n    event_unsubscribe(sub)\n    forget_event_sub(sub)\n    return\n\n\ndef is_supported_type(filename: str) -> bool:\n    file_upper = filename.upper()\n    for file_type in SUPPORTED_TYPES:\n        if file_upper.endswith(\".\" + file_type):\n            return True\n    return False\n\n\ndef play_local_file(speaker: SoCo, pathname: str, end_on_pause: bool = False) -> bool:\n    if not path.exists(pathname):\n        error_report(\"File '{}' not found\".format(pathname))\n        return False\n\n    directory, filename = path.split(pathname)\n\n    if not is_supported_type(filename):\n        error_report(\n            \"Unsupported file type; must be one of: {}\".format(SUPPORTED_TYPES)\n        )\n        return False\n\n    # Make filename compatible with URL naming\n    url_filename = urllib.parse.quote(filename)\n\n    server_ip = get_server_ip(speaker)\n    if not server_ip:\n        error_report(\"Can't determine an IP address for web server\")\n        return False\n    logging.info(\"Using server IP address: {}\".format(server_ip))\n\n    # Start the webserver (runs in a daemon thread)\n    speaker_ips = []\n    for zone in speaker.all_zones:\n        speaker_ips.append(zone.ip_address)\n    httpd = http_server(server_ip, directory, url_filename, speaker_ips)\n    if not httpd:\n        error_report(\"Cannot create HTTP server\")\n        return False\n\n    # This ensures that other running invocations of 'play_file'\n    # receive their stop events, and terminate.\n    logging.info(\"Stopping speaker '{}'\".format(speaker.player_name))\n    speaker.stop()\n\n    # Assemble the URI\n    uri = \"http://\" + server_ip + \":\" + str(httpd.server_port) + \"/\" + url_filename\n    logging.info(\"Playing file '{}' from directory '{}'\".format(filename, directory))\n    logging.info(\"Playback URI: {}\".format(uri))\n\n    logging.info(\"Send URI to '{}' for playback\".format(speaker.player_name))\n    speaker.play_uri(uri)\n\n    logging.info(\"Setting flag to stop playback on CTRL-C\")\n    set_speaker_playing_local_file(speaker)\n\n    logging.info(\"Waiting 1s for playback to start\")\n    time.sleep(1.0)\n    logging.info(\"Waiting for playback to stop\")\n    wait_until_stopped(speaker, uri, end_on_pause)\n    logging.info(\"Playback stopped ... terminating web server\")\n    httpd.shutdown()\n    logging.info(\"Web server terminated\")\n\n    set_speaker_playing_local_file(None)\n\n    return True\n"
  },
  {
    "path": "soco_cli/play_local_file_lists.py",
    "content": "\"\"\"Plays a list of files from the local filesystem, with interactive options.\"\"\"\n\nimport logging\nimport os\nimport sys\nfrom multiprocessing import Process\nfrom os import chdir, name, path, scandir\nfrom pathlib import Path\nfrom random import choice, sample\nfrom typing import List\n\nfrom soco import SoCo  # type: ignore\n\nfrom soco_cli.m3u_parser import parse_m3u\nfrom soco_cli.play_local_file import is_supported_type, play_local_file\nfrom soco_cli.utils import error_report\n\n\ndef interaction_manager(speaker_ip: str) -> None:\n    sys.stdin = open(0)\n    speaker = SoCo(speaker_ip)\n    while True:\n        try:\n            # keypress = wait_for_keypress()\n            keypress = input(\"\")[0]\n        except:\n            keypress = \"\"\n        if keypress in [\"N\", \"n\"]:\n            action = \"NEXT\"\n            print(\"Next track ...\")\n            speaker.stop()\n            logging.info(\n                \"Interactive mode: key = '{}', action = '{}'\".format(keypress, action)\n            )\n        if keypress in [\"P\", \"p\"]:\n            action = \"PAUSE\"\n            print(\"Pause playback ...\")\n            try:\n                speaker.pause()\n            except Exception as e:\n                logging.info(\"Exception ignored: {}\".format(e))\n            logging.info(\n                \"Interactive mode: key = '{}', action = '{}'\".format(keypress, action)\n            )\n        if keypress in [\"R\", \"r\"]:\n            action = \"RESUME\"\n            print(\"Resume playback ...\")\n            try:\n                speaker.play()\n            except Exception as e:\n                logging.info(\"Exception ignored: {}\".format(e))\n            logging.info(\n                \"Interactive mode: key = '{}', action = '{}'\".format(keypress, action)\n            )\n        # Windows captures CTRL-C key-presses, so we handle them directly here\n        if name == \"nt\" and keypress == \"\\x03\":\n            logging.info(\n                \"Windows CTRL-C: Stopping speaker '{}' and exiting\".format(\n                    speaker.player_name\n                )\n            )\n            speaker.stop()\n            os._exit(0)\n\n\ndef play_file_list(speaker: SoCo, tracks: List[str], options: str = \"\") -> bool:\n    \"\"\"Play a list of files (tracks) with absolute pathnames.\"\"\"\n    options = options.lower()\n\n    # Check for invalid options\n    invalid = set(options) - set(\"psri\")\n    if invalid:\n        error_report(\"Invalid option(s) '{}' supplied\".format(invalid))\n        return False\n\n    if options != \"\":\n        # Grab back stdout from api.run_command()\n        sys.stdout = sys.__stdout__\n\n    if \"r\" in options:\n        # Choose a single random track\n        track = choice(tracks)\n        tracks = [track]\n        logging.info(\"Choosing random track: {}\".format(track))\n\n    elif \"s\" in options:\n        logging.info(\"Shuffling playlist\")\n        # For some reason, 'shuffle(tracks)' does not work\n        tracks = sample(tracks, len(tracks))\n\n    # Interactive mode\n    keypress_process = None\n    if \"i\" in options:\n        print(\"Interactive mode actions: (N)ext, (P)ause, (R)esume + RETURN\")\n        try:\n            logging.info(\"Interactive mode ... starting keypress process\")\n            keypress_process = Process(\n                target=interaction_manager, args=(speaker.ip_address,), daemon=True\n            )\n            keypress_process.start()\n            logging.info(\"Process PID {} created\".format(keypress_process.pid))\n        except Exception as e:\n            logging.info(\"Exception ignored: {}\".format(e))\n            keypress_process = None\n\n    zero_pad = len(str(len(tracks)))\n    for index, track in enumerate(tracks):\n        if not path.exists(track):\n            print(\"Error: file not found:\", track)\n            continue\n\n        if not is_supported_type(track):\n            print(\"Error: unsupported file type:\", track)\n            continue\n\n        if \"p\" in options:\n            print(\n                \"Playing {} of {}:\".format(str(index + 1).zfill(zero_pad), len(tracks)),\n                track,\n            )\n\n        play_local_file(speaker, track)\n\n    if keypress_process:\n        keypress_process.terminate()\n\n    return True\n\n\ndef play_m3u_file(speaker: SoCo, m3u_file: str, options: str = \"\") -> bool:\n    if not path.exists(m3u_file):\n        error_report(\"File '{}' not found\".format(m3u_file))\n        return False\n\n    logging.info(\"Parsing file contents'{}'\".format(m3u_file))\n    track_list = parse_m3u(m3u_file)\n    if len(track_list) == 0:\n        error_report(\"No tracks found in '{}'\".format(m3u_file))\n        return False\n\n    directory, _ = path.split(m3u_file)\n    if directory != \"\":\n        chdir(directory)\n    tracks = [str(Path(track.path).absolute()) for track in track_list]  # type: ignore\n    logging.info(\"Files to to play: {}\".format(tracks))\n\n    play_file_list(speaker, tracks, options)\n    return True\n\n\ndef play_directory_files(speaker: SoCo, directory: str, options: str = \"\") -> bool:\n    \"\"\"Play all the valid audio files in a directory. Ignores subdirectories\"\"\"\n    tracks = []\n    try:\n        with scandir(directory) as files:\n            for file in files:\n                if is_supported_type(file.name):\n                    tracks.append(path.abspath(path.join(directory, file.name)))\n    except FileNotFoundError:\n        error_report(\"Directory '{}' not found\".format(directory))\n        return False\n\n    tracks.sort()\n    logging.info(\"Files to to play: {}\".format(tracks))\n    play_file_list(speaker, tracks, options)\n    return True\n"
  },
  {
    "path": "soco_cli/sonos.py",
    "content": "\"\"\"The main entry point into the 'sonos' command.\"\"\"\n\nimport argparse\nimport logging\nimport pprint\nimport sys\nimport time\nfrom os import environ as env\nfrom signal import SIGINT, SIGTERM, signal\n\nfrom soco_cli.action_processor import list_actions\nfrom soco_cli.aliases import AliasManager\nfrom soco_cli.api import get_all_speakers, run_command\nfrom soco_cli.check_for_update import print_update_status\nfrom soco_cli.cmd_parser import CLIParser\nfrom soco_cli.interactive import interactive_loop\nfrom soco_cli.speakers import Speakers\nfrom soco_cli.track_follow import track_follow\nfrom soco_cli.utils import (\n    RewindableList,\n    check_args,\n    configure_common_args,\n    configure_logging,\n    convert_to_seconds,\n    create_speaker_cache,\n    docs,\n    error_report,\n    get_speaker,\n    logo,\n    seconds_until,\n    set_speaker_list,\n    sig_handler,\n    version,\n)\n\nfrom .wait_actions import process_wait\n\n# Globals\npp = pprint.PrettyPrinter(width=100)\n\n\n# Speaker name environment variable\nENV_SPKR = \"SPKR\"\n\n# Local speaker cache environment variable\nENV_LOCAL = \"USE_LOCAL_CACHE\"\n\n\ndef main():\n    # Create the argument parser\n    parser = argparse.ArgumentParser(\n        prog=\"sonos\",\n        usage=\"%(prog)s <options> SPEAKER_NAME_OR_IP ACTION <parameters> < : ...>\",\n        description=\"Command line utility for controlling Sonos speakers\",\n    )\n    # A variable number of arguments depending on the action\n    parser.add_argument(\n        \"parameters\", nargs=\"*\", help=\"Sequences of SPEAKER ACTION <parameters> : ...\"\n    )\n    # Optional arguments\n    parser.add_argument(\n        \"--use-local-speaker-list\",\n        \"-l\",\n        action=\"store_true\",\n        default=False,\n        help=\"Use the local speaker list instead of SoCo discovery\",\n    )\n    parser.add_argument(\n        \"--refresh-local-speaker-list\",\n        \"-r\",\n        action=\"store_true\",\n        default=False,\n        help=\"Refresh the local speaker list\",\n    )\n    parser.add_argument(\n        \"--actions\",\n        action=\"store_true\",\n        default=False,\n        help=\"Print the list of available actions\",\n    )\n    parser.add_argument(\n        \"--commands\",\n        action=\"store_true\",\n        default=False,\n        help=\"Print the list of available actions\",\n    )\n    parser.add_argument(\n        \"--interactive\",\n        \"-i\",\n        action=\"store_true\",\n        default=False,\n        help=\"Enter interactive mode\",\n    )\n    parser.add_argument(\n        \"--no-env\",\n        action=\"store_true\",\n        default=False,\n        help=\"Ignore the 'SPKR' environment variable, if set\",\n    )\n    parser.add_argument(\n        \"--sk\",\n        action=\"store_true\",\n        default=False,\n        help=\"Enter single keystroke mode in the interactive shell\",\n    )\n    parser.add_argument(\n        \"--save_aliases\",\n        \"--save-aliases\",\n        type=str,\n        help=\"Save the current shell aliases to the supplied filename and exit\",\n    )\n    parser.add_argument(\n        \"--load_aliases\",\n        \"--load-aliases\",\n        type=str,\n        help=(\n            \"Load shell aliases from the supplied filename and exit (aliases are\"\n            \" merged)\"\n        ),\n    )\n    parser.add_argument(\n        \"--overwrite_aliases\",\n        \"--overwrite-aliases\",\n        type=str,\n        help=(\n            \"Overwrite current shell aliases with those from the supplied filename and\"\n            \" exit\"\n        ),\n    )\n    # The rest of the optional args are common\n    configure_common_args(parser)\n\n    # Parse the command line\n    args = parser.parse_args()\n\n    configure_logging(args.log)\n\n    signals = [SIGINT, SIGTERM]\n    logging.info(\"Setting up handlers for: {}\".format(signals))\n    for sig in signals:\n        signal(sig, sig_handler)\n\n    if args.version:\n        version()\n        exit(0)\n\n    if args.docs:\n        docs()\n        exit(0)\n\n    if args.logo:\n        logo()\n        exit(0)\n\n    if args.check_for_update:\n        print_update_status()\n        exit(0)\n\n    if args.actions or args.commands:\n        list_actions()\n        exit(0)\n\n    if args.save_aliases:\n        am = AliasManager()\n        am.load_aliases()\n        if am.save_aliases_to_file(args.save_aliases):\n            print(\"Saved shell aliases to '{}'\".format(args.save_aliases), flush=True)\n        else:\n            print(\n                \"Failed to save shell aliases to '{}'\".format(args.save_aliases),\n                flush=True,\n            )\n        exit(0)\n\n    if args.load_aliases:\n        am = AliasManager()\n        am.load_aliases()\n        if am.load_aliases_from_file(args.load_aliases):\n            print(\n                \"Loaded and merged shell aliases from '{}'\".format(args.load_aliases),\n                flush=True,\n            )\n        else:\n            print(\n                \"Failed to load shell aliases from '{}'\".format(args.load_aliases),\n                flush=True,\n            )\n        exit(0)\n\n    if args.overwrite_aliases:\n        am = AliasManager()\n        if am.load_aliases_from_file(args.overwrite_aliases):\n            print(\n                \"Loaded and saved shell aliases from '{}'\".format(\n                    args.overwrite_aliases\n                ),\n                flush=True,\n            )\n        else:\n            print(\n                \"Failed to load shell aliases from '{}'\".format(args.overwrite_aliases),\n                flush=True,\n            )\n        exit(0)\n\n    if len(args.parameters) == 0 and not args.interactive:\n        print(\n            \"No parameters supplied. Use 'sonos --help' for usage information.\",\n            flush=True,\n        )\n        exit(1)\n\n    message = check_args(args)\n    if message:\n        error_report(message)\n\n    use_local_speaker_list = args.use_local_speaker_list\n    env_local = env.get(ENV_LOCAL)\n    if env_local is not None:\n        if env.get(ENV_LOCAL).lower() == \"true\" and not args.no_env:\n            logging.info(\n                \"Env. var. '{}' set to 'TRUE ... using local speaker list\".format(\n                    ENV_LOCAL\n                )\n            )\n            use_local_speaker_list = True\n    if use_local_speaker_list:\n        speaker_list = Speakers(\n            network_threads=args.network_discovery_threads,\n            network_timeout=args.network_discovery_timeout,\n            min_netmask=args.min_netmask,\n        )\n        if args.refresh_local_speaker_list or not speaker_list.load():\n            logging.info(\"Start speaker discovery\")\n            speaker_list.discover()\n            speaker_list.save()\n        set_speaker_list(speaker_list)\n    else:\n        # Create the local speaker cache in the utils module\n        create_speaker_cache(\n            max_threads=args.network_discovery_threads,\n            scan_timeout=args.network_discovery_timeout,\n            min_netmask=args.min_netmask,\n        )\n\n    # Is $SPKR set in the environment?\n    env_speaker = None\n    if not args.no_env:\n        env_speaker = env.get(ENV_SPKR)\n        if env_speaker:\n            logging.info(\"Found 'SPKR' environment variable: '{}'\".format(env_speaker))\n        else:\n            logging.info(\"No 'SPKR' environment variable set\")\n\n    if args.interactive:\n        sk = bool(args.sk)\n        speaker_name = None\n        if len(args.parameters):\n            speaker_name = args.parameters[0]\n        interactive_loop(\n            speaker_name,\n            args.log,\n            use_local_speaker_list=use_local_speaker_list,\n            no_env=args.no_env,\n            single_keystroke=sk,\n        )\n        exit(0)\n\n    cli_parser = CLIParser()\n    cli_parser.parse(args.parameters)\n    sequences = cli_parser.get_sequences()\n\n    cumulative_exit_code = 0\n\n    # Loop through processing command sequences\n    logging.info(\"Found {} action sequence(s): {}\".format(len(sequences), sequences))\n    rewindable_sequences = RewindableList(sequences)\n    loop_iterator = None\n    sequence_pointer = 0\n\n    # There is a notional 'loop' action before the first command sequence\n    loop_pointer = -1\n\n    loop_start_time = None\n    loop_duration = None\n\n    # Keep track of SPKR environment label insertions, to avoid repeats\n    # when looping\n    env_spkr_inserted = [False for i in range(len(rewindable_sequences))]\n\n    for sequence in rewindable_sequences:\n        try:\n            speaker_name = sequence[0]\n\n            # Special case: the 'loop_to_start' action\n            if speaker_name.lower() == \"loop_to_start\":\n                if len(sequence) != 1:\n                    error_report(\"Action 'loop_to_start' takes no parameters\")\n                # Reset pointers, rewind and continue\n                loop_pointer = -1\n                sequence_pointer = 0\n                logging.info(\"Rewind to start of command sequences\")\n                rewindable_sequences.rewind()\n                continue\n\n            # Special case: the 'loop' action\n            if speaker_name.lower() == \"loop\":\n                if len(sequence) == 2:\n                    if loop_iterator is None:\n                        try:\n                            loop_iterator = int(sequence[1])\n                            if loop_iterator <= 0:\n                                raise ValueError\n                            logging.info(\n                                \"Looping for {} iteration(s)\".format(loop_iterator)\n                            )\n                        except ValueError:\n                            error_report(\n                                \"Action 'loop' takes no parameters, or a number of\"\n                                \" iterations (> 0)\"\n                            )\n                            cumulative_exit_code += 1\n                            continue\n                    loop_iterator -= 1\n                    logging.info(\"Loop iterator countdown = {}\".format(loop_iterator))\n                    if loop_iterator <= 0:\n                        # Reset variables, stop iteration and continue\n                        loop_iterator = None\n                        loop_pointer = sequence_pointer\n                        sequence_pointer += 1\n                        continue\n                logging.info(\"Rewinding to command number {}\".format(loop_pointer + 2))\n                rewindable_sequences.rewind_to(loop_pointer + 1)\n                sequence_pointer = loop_pointer + 1\n                continue\n\n            # Special case: the 'loop_for' action\n            if speaker_name.lower() == \"loop_for\":\n                if len(sequence) != 2:\n                    error_report(\n                        \"Action 'loop_for' requires one parameter (check spaces around\"\n                        \" the ':' separator)\"\n                    )\n                if loop_start_time is None:\n                    loop_start_time = time.time()\n                    try:\n                        loop_duration = convert_to_seconds(sequence[1])\n                    except ValueError:\n                        error_report(\n                            \"Action 'loop_for' requires one parameter (duration >= 0)\"\n                        )\n                        cumulative_exit_code += 1\n                    logging.info(\n                        \"Starting action 'loop_for' for duration {}s\".format(\n                            loop_duration\n                        )\n                    )\n                else:\n                    if time.time() - loop_start_time >= loop_duration:\n                        logging.info(\n                            \"Ending action 'loop_for' after duration {}s\".format(\n                                loop_duration\n                            )\n                        )\n                        loop_start_time = None\n                        continue\n                logging.info(\"Rewinding to command number {}\".format(loop_pointer + 2))\n                rewindable_sequences.rewind_to(loop_pointer + 1)\n                sequence_pointer = loop_pointer + 1\n                continue\n\n            # Special case: the 'loop_until' action\n            if speaker_name.lower() == \"loop_until\":\n                if len(sequence) != 2:\n                    error_report(\n                        \"Action 'loop_until' requires one parameter (check spaces\"\n                        \" around the ':' separator)\"\n                    )\n                if loop_start_time is None:\n                    loop_start_time = time.time()\n                    try:\n                        loop_duration = seconds_until(sequence[1])\n                    except:\n                        error_report(\n                            \"Action 'loop_until' requires one parameter (stop time)\"\n                        )\n                        cumulative_exit_code += 1\n                    logging.info(\n                        \"Starting action 'loop_until' for duration {}s\".format(\n                            loop_duration\n                        )\n                    )\n                else:\n                    if time.time() - loop_start_time >= loop_duration:\n                        logging.info(\n                            \"Ending action 'loop_until' after duration {}s\".format(\n                                loop_duration\n                            )\n                        )\n                        loop_start_time = None\n                        continue\n                logging.info(\"Rewinding to command number {}\".format(loop_pointer + 2))\n                rewindable_sequences.rewind_to(loop_pointer + 1)\n                sequence_pointer = loop_pointer + 1\n                continue\n\n            # Special case: the 'wait' actions\n            if speaker_name in [\"wait\", \"wait_for\", \"wait_until\"]:\n                process_wait(sequence)\n                continue\n\n            # Use the speaker name from the environment?\n            if env_speaker:\n                if env_spkr_inserted[sequence_pointer] is False:\n                    logging.info(\n                        \"Getting speaker name '{}' from the $SPKR environment variable\".format(\n                            env_speaker\n                        )\n                    )\n                    sequence.insert(0, env_speaker)\n                    speaker_name = env_speaker\n                    env_spkr_inserted[sequence_pointer] = True\n\n            # General action processing\n            if len(sequence) < 2:\n                error_report(\n                    \"At least 2 parameters required in action sequence '{}'; did you\"\n                    \" supply a speaker name?\".format(sequence)\n                )\n            action = sequence[1].lower()\n            args = sequence[2:]\n            if speaker_name.lower() == \"_all_\":\n                if use_local_speaker_list:\n                    speakers = speaker_list.get_all_speakers()\n                else:\n                    speakers = get_all_speakers(use_scan=True)\n                logging.info(\n                    \"Performing action '{}' on all visible speakers\".format(action)\n                )\n                last_line_was_single_line = False\n                for speaker in speakers:\n                    if speaker.is_visible:\n                        logging.info(\n                            \"Performing action '{}' on speaker '{}'\".format(\n                                action, speaker.player_name\n                            )\n                        )\n                        exit_code, output_msg, error_msg = run_command(\n                            speaker,\n                            action,\n                            *args,\n                            use_local_speaker_list=use_local_speaker_list,\n                        )\n                        if exit_code == 0:\n                            if len(output_msg) != 0:\n                                num_lines = len(output_msg.splitlines())\n                                if num_lines > 1 and last_line_was_single_line:\n                                    print()\n                                    last_line_was_single_line = False\n                                if num_lines == 1:\n                                    last_line_was_single_line = True\n                            else:\n                                output_msg = \"OK\"\n                            print(speaker.player_name + \": \", end=\"\", flush=True)\n                            print(output_msg, flush=True)\n                        elif len(error_msg) != 0:\n                            print(speaker.player_name + \": \", end=\"\", flush=True)\n                            print(error_msg, file=sys.stderr, flush=True)\n                        cumulative_exit_code += exit_code\n            else:\n                speaker = get_speaker(speaker_name, use_local_speaker_list)\n                if not speaker:\n                    print(\n                        \"Error: Speaker '{}' not found\".format(speaker_name),\n                        file=sys.stderr,\n                        flush=True,\n                    )\n                    cumulative_exit_code += 1\n                else:\n                    # Special case of 'track_follow' action\n                    if action in [\"track_follow\", \"tf\", \"track_follow_compact\", \"tfc\"]:\n                        if len(args) > 0:\n                            print(\n                                \"Error: Action '{}' takes no parameters\".format(action),\n                                file=sys.stderr,\n                                flush=True,\n                            )\n                            continue\n                        # Does not return\n                        compact = action in [\"track_follow_compact\", \"tfc\"]\n                        track_follow(\n                            speaker,\n                            use_local_speaker_list=use_local_speaker_list,\n                            break_on_pause=False,\n                            compact=compact,\n                        )\n                    # Standard action processing\n                    logging.info(\n                        \"Invoking 'run_command' with '{} {} ...'\".format(\n                            speaker, action\n                        )\n                    )\n                    exit_code, output_msg, error_msg = run_command(\n                        speaker,\n                        action,\n                        *args,\n                        use_local_speaker_list=use_local_speaker_list,\n                    )\n                    if exit_code == 0 and len(output_msg) != 0:\n                        print(output_msg, flush=True)\n                    elif len(error_msg) != 0:\n                        print(error_msg, file=sys.stderr, flush=True)\n                    cumulative_exit_code += exit_code\n\n        except Exception as e:\n            print(\"Error:\", str(e), flush=True)\n            cumulative_exit_code += 1\n\n        sequence_pointer += 1\n\n    exit(cumulative_exit_code)\n\n\nif __name__ == \"__main__\":\n    # Catch all untrapped exceptions\n    try:\n        main()\n        exit(0)\n    except Exception as error:\n        error_report(str(error))\n        exit(1)\n"
  },
  {
    "path": "soco_cli/sonos_discover.py",
    "content": "\"\"\"The main entry point into the 'sonos-discover' command.\"\"\"\n\nimport argparse\n\nfrom soco_cli.check_for_update import print_update_status\nfrom soco_cli.speakers import Speakers\nfrom soco_cli.utils import (\n    check_args,\n    configure_common_args,\n    configure_logging,\n    docs,\n    error_report,\n    logo,\n    version,\n)\n\n\ndef main():\n    # Create the argument parser\n    parser = argparse.ArgumentParser(\n        prog=\"sonos-discover\",\n        usage=\"%(prog)s\",\n        description=\"Sonos speaker discovery utility\",\n    )\n    parser.add_argument(\n        \"--print\",\n        \"-p\",\n        action=\"store_true\",\n        default=False,\n        help=\"Print the contents of the current speaker information file, and exit\",\n    )\n    parser.add_argument(\n        \"--delete-local-speaker-cache\",\n        \"-d\",\n        action=\"store_true\",\n        default=False,\n        help=\"Delete the local speaker cache, if it exists\",\n    )\n    parser.add_argument(\n        \"--subnets\",\n        type=str,\n        help=(\n            \"Specify the networks or IP addresses to search, in dotted decimal/CIDR\"\n            \" format\"\n        ),\n    )\n    # The rest of the optional args are common\n    configure_common_args(parser)\n\n    # Parse the command line\n    args = parser.parse_args()\n\n    configure_logging(args.log)\n\n    if args.version:\n        version()\n        exit(0)\n\n    if args.docs:\n        docs()\n        exit(0)\n\n    if args.logo:\n        logo()\n        exit(0)\n\n    if args.check_for_update:\n        print_update_status()\n        exit(0)\n\n    # Create the Speakers object\n    speaker_list = Speakers()\n\n    if args.print:\n        if speaker_list.load():\n            speaker_list.print()\n            exit(0)\n        else:\n            error_report(\"No current speaker data\")\n\n    if args.delete_local_speaker_cache:\n        try:\n            file = speaker_list.remove_save_file()\n            print(\"Removed file: {}\".format(file))\n            exit(0)\n        except Exception:\n            error_report(\"No current speaker data file\")\n\n    # Parameter validation for various args\n    message = check_args(args)\n    if message:\n        error_report(message)\n\n    speaker_list._network_threads = args.network_discovery_threads\n    speaker_list._network_timeout = args.network_discovery_timeout\n    speaker_list._min_netmask = args.min_netmask\n    if args.subnets is not None:\n        speaker_list.subnets = args.subnets.split(\",\")\n\n    try:\n        speaker_list.discover()\n        saved = speaker_list.save()\n        speaker_list.print()\n        if saved:\n            print(\"Saved speaker data at: {}\\n\".format(speaker_list.save_pathname))\n        else:\n            print(\"No speakers discovered. No cache data saved or overwritten.\")\n    except Exception as e:\n        error_report(str(e))\n\n\nif __name__ == \"__main__\":\n    # Catch all untrapped exceptions\n    try:\n        main()\n        exit(0)\n    except Exception as error:\n        error_report(str(error))\n        exit(1)\n"
  },
  {
    "path": "soco_cli/speaker_info.py",
    "content": "\"\"\"Prints a table of information about the Sonos system.\"\"\"\n\nimport datetime\n\nimport tabulate  # type: ignore\n\n# Collect speaker information from each speaker in turn\nheaders = [\n    \"Zone Name\",\n    \"IP Address\",\n    \"Visible\",\n    \"CoOrd\",\n    \"CoOrd IP\",\n    \"Vol.\",\n    \"Mute\",\n    \"State\",\n    \"Model Name\",\n    \"Model No.\",\n    \"HW Version\",\n    \"SW Version\",\n]\n\n\ndef print_speaker_table(device):\n    speakers = []\n    models = set()\n    errors = []\n    exceptions = []\n\n    def add_err_and_exc(err_player_name, err_ip_address, exc):\n        exceptions.append(exc)\n        errors.append(\n            (\"Could not get speaker_info for {}: {}\").format(\n                err_player_name, err_ip_address\n            )\n        )\n\n    for sco in device.all_zones:\n        # Load the speaker info\n        try:\n            sco.get_speaker_info()\n        except BaseException as e:\n            add_err_and_exc(sco.player_name, sco.ip_address, e)\n            continue\n\n        # Boost and Bridge don't support some attributes\n        if sco.is_bridge:\n            not_applicable = \"n/a\"\n            volume = not_applicable\n            mute = not_applicable\n            state = not_applicable\n        else:\n            volume = sco.volume\n            mute = \"On\" if sco.mute else \"Off\"\n            # Bonded speakers return errors for transport and track info.\n            # Wrap in an exception, and ignore.\n            try:\n                state = sco.get_current_transport_info()[\"current_transport_state\"]\n            except BaseException as e:\n                # If we're here, assume the speakers are bonded\n                # in a Home Theatre configuration\n                state = \"Bonded\"\n\n        # Find the coordinator IP\n        coord = sco.group.coordinator\n        if sco is coord:\n            coord_ip = \"\"\n        else:\n            coord_ip = coord.ip_address\n            # Overwrite the 'state'\n            if state != \"Bonded\":\n                state = sco.group.coordinator.get_current_transport_info()[\n                    \"current_transport_state\"\n                ]\n\n        # Set up the information for the speaker\n        speaker = [\n            sco.player_name,\n            sco.ip_address,\n            \"Yes\" if sco.is_visible else \"No\",\n            \"Yes\" if sco.is_coordinator else \"No\",\n            coord_ip,\n            volume,\n            mute,\n            state,\n            sco.speaker_info[\"model_name\"].replace(\"Sonos \", \"\"),\n            sco.speaker_info[\"model_number\"],\n            sco.speaker_info[\"hardware_version\"],\n            sco.speaker_info[\"software_version\"]\n            + \" (\"\n            + sco.speaker_info[\"display_version\"]\n            + \")\",\n        ]\n        speakers.append(speaker)\n        models.add(sco.speaker_info[\"model_number\"])\n\n    # Print the date and time\n    print()\n    print(\n        \"Report generated on:\",\n        datetime.datetime.now(datetime.timezone.utc).strftime(\n            \"%Y-%m-%d %H:%M UTC (%A)\"\n        ),\n    )\n\n    # Print the speaker information table in a nice format\n    print()\n    print(tabulate.tabulate(sorted(speakers), headers, numalign=\"center\"))\n\n    # Print the list of unique model numbers\n    print(\"\\nSonos model numbers present:\", end=\"\")\n    for index, model in enumerate(sorted(models)):\n        print(\" \" + model, end=\"\")\n        if index != len(models) - 1:\n            print(\",\", end=\"\")\n        else:\n            print(\".\", end=\"\")\n    print(\n        \"\\nDevice counts: {} total Sonos device(s), {} unique model(s).\".format(\n            len(speakers), len(models)\n        )\n    )\n    print()\n\n    # List any speakers that couldn't be inspected, along with the\n    # relevant exception\n    if len(errors) != 0:\n        for err, exc in zip(errors, exceptions):\n            print(err)\n            print(\"Exception: {}\".format(exc))\n        print()\n        return False\n    return True\n"
  },
  {
    "path": "soco_cli/speakers.py",
    "content": "\"\"\"Manages speaker information for Cached Discovery mode.\"\"\"\n\nimport ipaddress\nimport logging\nimport os\nimport pickle\nfrom collections import namedtuple\n\nimport soco  # type: ignore\nimport tabulate  # type: ignore\n\nfrom soco_cli.match_speaker_names import speaker_name_matches\n\n# Type for holding speaker details\nSonosDevice = namedtuple(\n    \"SonosDevice\",\n    [\n        \"household_id\",\n        \"ip_address\",\n        \"speaker_name\",\n        \"is_visible\",\n        \"model_name\",\n        \"display_version\",\n    ],\n    rename=False,\n)\n\n\nclass Speakers:\n    \"\"\"A class for discovering Sonos speakers, saving and loading speaker data,\n    and finding speakers by name. An alternative to using SoCo discovery.\n    \"\"\"\n\n    def __init__(\n        self,\n        save_directory=None,\n        save_file=None,\n        network_threads=256,\n        network_timeout=0.1,\n        min_netmask=24,\n        subnets=None,\n    ):\n        self._save_directory = (\n            save_directory\n            if save_directory\n            else os.path.expanduser(\"~\") + \"/.soco-cli/\"\n        )\n        self._save_file = save_file if save_file else \"speakers_v2.pickle\"\n        self.remove_deprecated_pickle_files()\n        self._network_threads = network_threads\n        self._network_timeout = network_timeout\n        self._min_netmask = min_netmask\n        self._speakers = []\n        self.subnets = subnets  # Calls the setter\n\n    def remove_deprecated_pickle_files(self):\n        \"\"\"Remove any older, incompatible versions of the pickle file\"\"\"\n        for old_file in [\"speakers.pickle\"]:\n            pathname = self._save_directory + old_file\n            if os.path.exists(pathname):\n                logging.info(\"Removing old local speaker cache {}\".format(pathname))\n                # print(\"Removing deprecated local speaker file:\", pathname)\n                os.remove(pathname)\n\n    @property\n    def speaker_cache_loaded(self):\n        return bool(self._speakers)\n\n    @property\n    def speaker_cache_file_exists(self):\n        return os.path.exists(self.save_pathname)\n\n    @property\n    def speakers(self):\n        return self._speakers\n\n    @property\n    def save_directory(self):\n        return self._save_directory\n\n    @save_directory.setter\n    def save_directory(self, directory):\n        self._save_directory = directory\n\n    @property\n    def save_file(self):\n        return self._save_file\n\n    @save_file.setter\n    def save_file(self, file):\n        self._save_file = file\n\n    @property\n    def save_pathname(self):\n        return self._save_directory + self._save_file\n\n    @property\n    def network_threads(self):\n        return self._network_threads\n\n    @network_threads.setter\n    def network_threads(self, threads):\n        self._network_threads = threads\n\n    @property\n    def network_timeout(self):\n        return self._network_timeout\n\n    @network_timeout.setter\n    def network_timeout(self, timeout):\n        self._network_timeout = timeout\n\n    @property\n    def min_netmask(self):\n        return self._min_netmask\n\n    @min_netmask.setter\n    def min_netmask(self, min_netmask):\n        self._min_netmask = min_netmask\n\n    @property\n    def subnets(self):\n        return self._subnets\n\n    @subnets.setter\n    def subnets(self, subnets, check_valid=True):\n        # Check for valid networks\n        if subnets is not None:\n            self._subnets_arg = True  # True if subnets has been set\n            invalid = []\n            for subnet in subnets:\n                try:\n                    _ = ipaddress.IPv4Network(subnet, strict=False)\n                except (ipaddress.AddressValueError, ValueError):\n                    logging.info(\"Invalid network/subnet: {}\".format(subnet))\n                    invalid.append(subnet)\n            for subnet in invalid:\n                subnets.remove(subnet)\n            logging.info(\"Setting search subnets to: {}\".format(subnets))\n        else:\n            self._subnets_arg = False\n        self._subnets = subnets\n\n    def set_subnets_no_check(self, subnets):\n        if subnets is not None:\n            self._subnets_arg = True\n        else:\n            self._subnets_arg = False\n        self._subnets = subnets\n\n    def save(self):\n        \"\"\"Saves the speaker list as a pickle file.\"\"\"\n        if self._speakers:\n            if not os.path.exists(self._save_directory):\n                os.mkdir(self._save_directory)\n            with open(self.save_pathname, \"wb\") as f:\n                pickle.dump(self._speakers, f)\n            return True\n        return False\n\n    def load(self):\n        \"\"\"Loads a saved speaker list\"\"\"\n        if os.path.exists(self.save_pathname):\n            try:\n                with open(self.save_pathname, \"rb\") as f:\n                    self._speakers = pickle.load(f)\n            except:\n                return False\n            return True\n        return False\n\n    def clear(self):\n        \"\"\"Clears the in-memory speaker list\"\"\"\n        self._speakers = []\n\n    def remove_save_file(self):\n        \"\"\"Removes the saved speaker list file\"\"\"\n        os.remove(self.save_pathname)\n        return self.save_pathname\n\n    def rename(self, old_name, new_name):\n        for index, speaker in enumerate(self._speakers):\n            if old_name.replace(\"’\", \"'\") == speaker.speaker_name.replace(\"’\", \"'\"):\n                # Update old record, delete, replace with new\n                new_speaker = speaker._replace(speaker_name=new_name)\n                del self._speakers[index]\n                self._speakers.append(new_speaker)\n                logging.info(\n                    \"Renamed speaker in cache: '{}' to '{}'\".format(old_name, new_name)\n                )\n                self.save()\n                logging.info(\"Saved updated cache file\")\n                return True\n        logging.info(\"Failed to find speaker '{}' for rename\".format(old_name))\n        return False\n\n    @staticmethod\n    def is_ipv4_address(ip_address):\n        \"\"\"Tests for an IPv4 address\"\"\"\n        try:\n            ipaddress.IPv4Network(ip_address)\n            return True\n        except ValueError:\n            return False\n\n    @staticmethod\n    def get_sonos_device_data(ip_addr):\n        \"\"\"Get information from a Sonos device\"\"\"\n        try:\n            speaker = soco.SoCo(str(ip_addr))\n            logging.info(\"Querying device at {}\".format(str(ip_addr)))\n            info = speaker.get_speaker_info(refresh=True, timeout=3.0)\n            if info is not None:\n                return SonosDevice(\n                    speaker.household_id,\n                    str(ip_addr),\n                    info[\"zone_name\"],\n                    speaker.is_visible,\n                    info[\"model_name\"],\n                    info[\"display_version\"],\n                )\n            else:\n                raise Exception\n        except:\n            logging.info(\"Not a Sonos device: '{}'\".format(ip_addr))\n            return None\n\n    def discover(self):\n        \"\"\"Discover the Sonos speakers on the network(s) to which\n        this host is attached.\"\"\"\n        self.clear()\n        devices = None\n        if not (self._subnets_arg and len(self.subnets) == 0):\n            devices = soco.discovery.scan_network(\n                include_invisible=True,\n                multi_household=True,\n                scan_timeout=self._network_timeout,\n                max_threads=self._network_threads,\n                min_netmask=self._min_netmask,\n                networks_to_scan=self._subnets,\n            )\n\n        if devices is None:\n            logging.info(\"No devices discovered\")\n        else:\n            # Populate the device information for each speaker\n            for device in devices:\n                speaker_data = self.get_sonos_device_data(device.ip_address)\n                if speaker_data is not None:\n                    self._speakers.append(speaker_data)\n\n    def find(self, speaker_name, require_visible=True):\n        \"\"\"Find a speaker by name and return its SoCo object.\"\"\"\n\n        speaker_names = set()\n        return_speaker = None\n\n        for speaker in self._speakers:\n            if require_visible and not speaker.is_visible:\n                continue\n\n            match, exact = speaker_name_matches(speaker_name, speaker.speaker_name)\n\n            if match and exact:\n                speaker_names.add(speaker.speaker_name)\n                return soco.SoCo(speaker.ip_address)\n\n            if match and not exact:\n                speaker_names.add(speaker.speaker_name)\n                if not return_speaker:\n                    return_speaker = soco.SoCo(speaker.ip_address)\n\n        if len(speaker_names) > 1:\n            print(\n                \"Speaker name '{}' is ambiguous within {}\".format(\n                    speaker_name, speaker_names\n                )\n            )\n            return None\n\n        return return_speaker\n\n    def get_all_speakers(self):\n        soco_speakers = []\n        for speaker in self._speakers:\n            soco_speakers.append(soco.SoCo(speaker.ip_address))\n        if soco_speakers:\n            return soco_speakers\n        return None\n\n    def get_all_speaker_names(self, include_invisible=False):\n        soco_speaker_names = []\n        for speaker in self._speakers:\n            if speaker.is_visible:\n                soco_speaker_names.append(speaker.speaker_name)\n        soco_speaker_names.sort()\n        return soco_speaker_names\n\n    def print(self):\n        if not self._speakers:\n            return\n        households = {}\n        num_devices = 0\n        for device in self._speakers:\n            if device.household_id not in households:\n                households[device.household_id] = []\n            if device.is_visible:\n                visible = \"Visible\"\n            else:\n                visible = \"Hidden\"\n            households[device.household_id].append(\n                (\n                    device.speaker_name,\n                    device.ip_address,\n                    device.model_name.replace(\"Sonos \", \"\"),\n                    visible,\n                    device.display_version,\n                )\n            )\n            num_devices += 1\n\n        headers = [\n            \"Room/Zone Name\",\n            \"IP Address\",\n            \"Device Model\",\n            \"Visibility\",\n            \"SW Version\",\n        ]\n        for household in households:\n            print()\n            print(\"Sonos Household: {}\\n\".format(household))\n            print(\n                tabulate.tabulate(\n                    sorted(households[household]),\n                    headers,\n                    numalign=\"left\",\n                    disable_numparse=True,\n                )\n            )\n            print()\n\n        print(\"{} Sonos Household(s) found\".format(len(households)))\n        print(\"{} Sonos device(s) found\".format(num_devices))\n        print()\n"
  },
  {
    "path": "soco_cli/track_follow.py",
    "content": "import logging\nimport re\nfrom datetime import datetime, timezone\n\nfrom soco import SoCo  # type: ignore\n\nfrom soco_cli.api import run_command\n\n\ndef track_follow(\n    speaker: SoCo,\n    use_local_speaker_list: bool = False,\n    break_on_pause: bool = True,\n    compact: bool = False,\n):\n    \"\"\"Print out the 'track' details each time the track changes.\n\n    Args:\n        speaker (SoCo): The speaker to follow.\n        use_local_speaker_list (bool, optional): Use cached discovery.\n        break_on_pause (bool, optional): Whether to return control if the\n            speaker enters the paused or stopped playback states.\n        compact (bool, optional): Whether to use 'compact' output mode.\n\n    This function operates as if 'outside' the main program logic, because\n    it needs to output intermediate results as it executes. Hence, the\n    'run_command()' API call is used.\n    \"\"\"\n\n    def timestamp(short=False):\n        local_tz = datetime.now(timezone.utc).astimezone().tzinfo\n        if not short:\n            return datetime.now(tz=local_tz).strftime(\"%H:%M:%S (%Z) %d-%b-%Y\")\n        else:\n            return datetime.now(tz=local_tz).strftime(\"%H:%M\")\n\n    counter = 1\n    print()\n    while True:\n        # If stopped, wait for the speaker to start playback\n        _, state, _ = run_command(\n            speaker, \"state\", use_local_speaker_list=use_local_speaker_list\n        )\n        if state in [\n            \"STOPPED\",\n            \"PAUSED_PLAYBACK\",\n        ]:\n            if not compact:\n                print(\n                    \" [{}] Playback is stopped or paused at {}\\n\".format(\n                        speaker.player_name, timestamp()\n                    ),\n                )\n            else:\n                print(\n                    \"{:5d}: [{}] Playback is stopped or paused\".format(\n                        counter, timestamp(short=True)\n                    )\n                )\n                counter += 1\n            if break_on_pause:\n                logging.info(\"Playback is paused/stopped; returning\")\n                break\n            logging.info(\"Playback is paused/stopped; waiting for start\")\n            run_command(\n                speaker, \"wait_start\", use_local_speaker_list=use_local_speaker_list\n            )\n            logging.info(\"Speaker has started playback\")\n\n        # Print the track info\n        exit_code, output, error_msg = run_command(\n            speaker, \"track\", use_local_speaker_list=use_local_speaker_list\n        )\n        if exit_code == 0:\n            # Manipulate output\n            if \"Using Line In\" in output:\n                line_in = True\n                output = \"   Playing from Line In\\n\"\n            else:\n                line_in = False\n                output = output.split(\"\\n \", 1)[1]\n            if not compact:\n                # Remove some of the line entries\n                output = re.sub(\".*Playback.*\\\\n\", \"\", output)\n                # output = re.sub(\".*Elapsed.*\\\\n\", \"\", output)\n                output = re.sub(\".*URI.*\\\\n\", \"\", output)\n                output = re.sub(\".*Uri.*\\\\n\", \"\", output)\n                # Prefix speaker name and timestamp\n                output = (\n                    \" [{}] Playing at \".format(speaker.player_name)\n                    + timestamp()\n                    + \":\\n\"\n                    + output\n                )\n            else:  # Compact (one line) output\n                if line_in:\n                    output = \"{:5d}: [{}] Playing from Line In\".format(\n                        counter, timestamp(short=True)\n                    )\n                else:\n                    # Ordering of keys determines output order\n                    keys = [\n                        \"Channel:\",\n                        \"Radio Show:\",\n                        \"Artist:\",\n                        \"Creator(s):\",\n                        \"Book Title:\",\n                        \"Chapter:\",\n                        \"Album:\",\n                        \"Podcast:\",\n                        \"Title:\",\n                        \"Episode:\",\n                        \"Release Date:\",\n                        \"Narrator(s):\",\n                    ]\n                    elements = {}\n                    for line in output.splitlines():\n                        for key in keys:\n                            if key in line:\n                                elements[key] = line.replace(key, \"\").lstrip()\n                    output = \"{:5d}: [{}] \".format(counter, timestamp(short=True))\n\n                    # Prune fields for audio books\n                    if \"Book Title:\" in elements:\n                        elements.pop(\"Title:\", None)\n                        elements.pop(\"Narrator(s):\", None)\n                    first = True\n                    for key in keys:\n                        value = elements.pop(key, None)\n                        if value:\n                            if not first:\n                                output = output + \"| \"\n                            else:\n                                first = False\n                            output = output + key + \" \" + value + \" \"\n            print(output)\n        else:\n            error_out = \"{:5d}: [{}] {}\".format(\n                counter, timestamp(short=True), error_msg\n            )\n            print(error_out)\n\n        logging.info(\"Waiting for end of track\")\n        run_command(\n            speaker, \"wait_end_track\", use_local_speaker_list=use_local_speaker_list\n        )\n        counter += 1\n"
  },
  {
    "path": "soco_cli/utils.py",
    "content": "\"\"\"Common utilities used across multiple modules.\"\"\"\n\nimport datetime\nimport logging\nimport os\nimport pickle\nimport signal\n\ntry:\n    import readline\nexcept ImportError:\n    pass\nimport sys\nfrom collections.abc import Sequence\nfrom platform import python_version\nfrom time import sleep\n\nimport soco  # type: ignore\n\nfrom soco_cli.__init__ import __version__  # type: ignore\nfrom soco_cli.match_speaker_names import speaker_name_matches\nfrom soco_cli.speakers import Speakers\n\n\ndef event_unsubscribe(sub):\n    \"\"\"Unsubscribe from events, with a try/catch wrapper, and a pause\n    introduced to yield the thread.\"\"\"\n\n    logging.info(\"Unsubscribing '{}'\".format(sub))\n    try:\n        sleep(0.2)\n        sub.unsubscribe()\n    except Exception as e:\n        logging.info(\"Failed to unsubscribe: {}\".format(e))\n    logging.info(\"Unsubscribed\")\n\n\nINTERACTIVE = False\nAPI = False\nSINGLE_KEYSTROKE = False\n\n\ndef set_interactive():\n    global INTERACTIVE\n    INTERACTIVE = True\n\n\ndef set_api():\n    global API\n    API = True\n\n\ndef set_single_keystroke(sk):\n    global SINGLE_KEYSTROKE\n    SINGLE_KEYSTROKE = sk\n\n\n# Error handling\ndef error_report(msg):\n    # Print to stderr\n    print(\"Error:\", msg, file=sys.stderr, flush=True)\n    # Use os._exit() to avoid the catch-all 'except'\n    if not (INTERACTIVE or API):\n        logging.info(\"Exiting program using os._exit(1)\")\n        os._exit(1)\n\n\ndef parameter_type_error(action, required_params):\n    msg = \"Action '{}' takes parameter(s): {}\".format(action, required_params)\n    error_report(msg)\n\n\ndef parameter_number_error(action, parameter_number):\n    msg = \"Action '{}' takes {} parameter(s)\".format(action, parameter_number)\n    error_report(msg)\n\n\n# Parameter count checking\ndef zero_parameters(f):\n    def wrapper(*args, **kwargs):\n        if len(args[2]) != 0:\n            parameter_number_error(args[1], \"no\")\n            return False\n\n        return f(*args, **kwargs)\n\n    return wrapper\n\n\ndef one_parameter(f):\n    def wrapper(*args, **kwargs):\n        if len(args[2]) != 1:\n            parameter_number_error(args[1], \"1\")\n            return False\n        return f(*args, **kwargs)\n\n    return wrapper\n\n\ndef zero_or_one_parameter(f):\n    def wrapper(*args, **kwargs):\n        if len(args[2]) not in [0, 1]:\n            parameter_number_error(args[1], \"0 or 1\")\n            return False\n        return f(*args, **kwargs)\n\n    return wrapper\n\n\ndef one_or_two_parameters(f):\n    def wrapper(*args, **kwargs):\n        if len(args[2]) not in [1, 2]:\n            parameter_number_error(args[1], \"1 or 2\")\n            return False\n        return f(*args, **kwargs)\n\n    return wrapper\n\n\ndef two_parameters(f):\n    def wrapper(*args, **kwargs):\n        if len(args[2]) != 2:\n            parameter_number_error(args[1], \"2\")\n            return False\n        return f(*args, **kwargs)\n\n    return wrapper\n\n\ndef zero_one_or_two_parameters(f):\n    def wrapper(*args, **kwargs):\n        if len(args[2]) > 2:\n            parameter_number_error(args[1], \"zero, one or two\")\n            return False\n        return f(*args, **kwargs)\n\n    return wrapper\n\n\ndef one_or_more_parameters(f):\n    def wrapper(*args, **kwargs):\n        if len(args[2]) < 1:\n            parameter_number_error(args[1], \"1 or more\")\n            return False\n        return f(*args, **kwargs)\n\n    return wrapper\n\n\n# Time manipulation\ndef seconds_until(time_str):\n    # target_time = datetime.time.fromisoformat(time_str)\n    target_time = create_time_from_str(time_str)\n    now_time = datetime.datetime.now().time()\n    delta_target = datetime.timedelta(\n        hours=target_time.hour, minutes=target_time.minute, seconds=target_time.second\n    )\n    delta_now = datetime.timedelta(\n        hours=now_time.hour, minutes=now_time.minute, seconds=now_time.second\n    )\n    diff = int((delta_target - delta_now).total_seconds())\n    # Ensure 'past' times are treated as future times by adding 24hr\n    return diff if diff > 0 else diff + 24 * 60 * 60\n\n\ndef create_time_from_str(time_str):\n    \"\"\"Process times in HH:MM(:SS) format. Return a 'time' object.\"\"\"\n    if \":\" not in time_str:\n        raise ValueError\n    parts = time_str.split(\":\")\n    if len(parts) not in [2, 3]:\n        raise ValueError\n    hours = int(parts[0])\n    minutes = int(parts[1])\n    if len(parts) == 3:\n        seconds = int(parts[2])\n    else:\n        seconds = 0\n    # Accept time strings from 00:00:00 to 23:59:59\n    if 0 <= hours <= 23 and 0 <= minutes <= 59 and 0 <= seconds <= 59:\n        return datetime.time(hour=hours, minute=minutes, second=seconds)\n\n    raise ValueError\n\n\ndef convert_to_seconds(time_str):\n    \"\"\"Convert a time string to seconds.\n    time_str can be one of Nh, Nm or Ns, or of the form HH:MM:SS\n    :raises ValueError\n    \"\"\"\n    logging.info(\"Converting '{}' to a number of seconds\".format(time_str))\n    time_str = time_str.lower()\n    try:\n        if \":\" in time_str:  # Assume form is HH:MM:SS or HH:MM\n            parts = time_str.split(\":\")\n            if len(parts) == 3:  # HH:MM:SS\n                td = datetime.timedelta(\n                    hours=int(parts[0]), minutes=int(parts[1]), seconds=int(parts[2])\n                )\n            else:  # HH:MM\n                td = datetime.timedelta(hours=int(parts[0]), minutes=int(parts[1]))\n            return td.seconds\n\n        if time_str.endswith(\"s\"):  # Seconds (explicit)\n            duration = float(time_str[:-1])\n        elif time_str.endswith(\"m\"):  # Minutes\n            duration = float(time_str[:-1]) * 60\n        elif time_str.endswith(\"h\"):  # Hours\n            duration = float(time_str[:-1]) * 60 * 60\n        else:  # Seconds (default)\n            duration = float(time_str)\n        return duration\n    except (ValueError, TypeError):\n        raise ValueError\n\n\n# Miscellaneous\ndef convert_true_false(true_or_false, conversion=\"YesOrNo\"):\n    if conversion == \"YesOrNo\":\n        return \"Yes\" if true_or_false is True else \"No\"\n    if conversion == \"onoroff\":\n        return \"on\" if true_or_false is True else \"off\"\n    return None\n\n\ndef version():\n    print(\"soco-cli version:   {}\".format(__version__), flush=True)\n    print(\"soco version:       {}\".format(soco.__version__), flush=True)\n    print(\"python version:     {}\".format(python_version()), flush=True)\n    print(\"command path:       {}\".format(sys.argv[0]), flush=True)\n\n\ndef docs():\n    version = \"v{}\".format(__version__)\n    if __version__.endswith(\"+\"):\n        url = \"https://github.com/avantrec/soco-cli/blob/next_version/README.md\"\n    else:\n        url = \"https://github.com/avantrec/soco-cli/blob/{}/README.md\".format(version)\n    print(\"Online documentation for {}: {}\".format(version, url), flush=True)\n\n\ndef logo():\n    version = \"v{}\".format(__version__)\n    if __version__.endswith(\"+\"):\n        url = \"https://raw.githubusercontent.com/avantrec/soco-cli/next_version/assets/soco-cli-logo-01-large.png\"\n    else:\n        url = \"https://raw.githubusercontent.com/avantrec/soco-cli/{}/assets/soco-cli-logo-01-large.png\".format(\n            version\n        )\n    print(\"SoCo-CLI Logo: {}\".format(url), flush=True)\n\n\n# Suspend signal handling processing for 'exec' in interactive shell\nsuspend_sighandling = False\n\n\ndef set_suspend_sighandling(suspend=True):\n    global suspend_sighandling\n    logging.info(\"Setting 'suspend_sighandling' to '{}'\".format(suspend))\n    suspend_sighandling = suspend\n\n\n# Flag set when CTRL-C interrupts a suspended-sighandling subprocess\n_ctrl_c_interrupted = False\n\n\ndef set_ctrl_c_interrupted(value=True):\n    global _ctrl_c_interrupted\n    _ctrl_c_interrupted = value\n\n\ndef get_ctrl_c_interrupted():\n    return _ctrl_c_interrupted\n\n\n# Stop a stream if playing a local file\nspeaker_playing_local_file = None\n\n\ndef set_speaker_playing_local_file(speaker):\n    global speaker_playing_local_file\n    if speaker:\n        logging.info(\n            \"Setting speaker playing local file to '{}'\".format(speaker.player_name)\n        )\n    else:\n        logging.info(\"No speaker playing local file\")\n    speaker_playing_local_file = speaker\n\n\ndef sig_handler(signal_received, frame):\n    logging.info(\"Caught signal: {}\".format(signal_received))\n\n    if suspend_sighandling:\n        if signal_received == signal.SIGINT:\n            set_ctrl_c_interrupted(True)\n        logging.info(\"Signal handling suspended ... ignoring\")\n        return\n\n    # Restore stdout and stderr ... these have been redirected if\n    # api.run_command() was used\n    sys.stdout = sys.__stdout__\n    sys.stderr = sys.__stderr__\n\n    # Prevent SIGINT (CTRL-C) exit: untidy exit from readline can leave\n    # some terminals in a broken state\n    if signal_received == signal.SIGINT:\n        if SINGLE_KEYSTROKE:\n            logging.info(\"SINGLE_KEYSTROKE set ... preventing exit\")\n            print(\"\\nPlease use 'x' to exit >> \", end=\"\", flush=True)\n            return\n\n        if INTERACTIVE:\n            logging.info(\"INTERACTIVE set ... preventing exit\")\n            print(\"\\nPlease use 'exit' to terminate the shell > \", end=\"\", flush=True)\n            if os.name == \"nt\":\n                print(flush=True)\n            return\n\n    # Allow SIGTERM termination, but issue warning if interactive\n    if signal_received == signal.SIGTERM and INTERACTIVE:\n        print(\"\\nSoCo-CLI process terminating ...\", flush=True)\n        print(\n            \"This can leave some terminals in a misconfigured state.\",\n            flush=True,\n        )\n\n    if speaker_playing_local_file:\n        logging.info(\n            \"Speaker '{}': 'play_file' active ... stopping\".format(\n                speaker_playing_local_file.player_name\n            )\n        )\n        speaker_playing_local_file.stop()\n\n    logging.info(\"Unsubscribing from event notifications\")\n    unsub_all_remembered_event_subs()\n\n    logging.info(\"Exiting program using 'os._exit(0)'\")\n    print(\"\", flush=True)\n    os._exit(0)\n\n\nclass RewindableList(Sequence):\n    \"\"\"This is a just-enough-implementation class to provide a list\n    that can be rewound during iteration.\n    \"\"\"\n\n    def __init__(self, items=[]):\n        self._items = items\n        self._index = 0\n\n    def __iter__(self):\n        self.rewind()\n        return self\n\n    def __getitem__(self, item):\n        return self._items[item]\n\n    def __len__(self):\n        return len(self._items)\n\n    def __next__(self):\n        if self._index < len(self._items):\n            item = self._items[self._index]\n            self._index += 1\n            return item\n        raise StopIteration\n\n    def rewind(self):\n        self._index = 0\n\n    def rewind_to(self, index):\n        if len(self._items) == 0 and index == 0:\n            self._index = 0\n        elif 0 <= index < len(self._items):\n            self._index = index\n        else:\n            raise IndexError\n\n    def __str__(self):\n        return str(self._items)\n\n    def index(self):\n        return self._index\n\n    def insert(self, index, element):\n        self._items.insert(index, element)\n        if index <= self._index:\n            self._index += 1\n\n    def pop_next(self):\n        item = self._items.pop(0)\n        if self._index != 0:\n            self._index -= 1\n        return item\n\n\n# Set up logging\ndef configure_logging(log_level: str) -> None:\n    log_level = log_level.lower()\n    if log_level == \"none\":\n        # Disables all logging (i.e., CRITICAL and below)\n        logging.disable(logging.CRITICAL)\n    else:\n        log_format = (\n            \"%(asctime)s %(filename)s:%(lineno)s - %(funcName)s() - %(message)s\"\n        )\n        if log_level == \"debug\":\n            logging.basicConfig(format=log_format, level=logging.DEBUG)\n        elif log_level == \"info\":\n            logging.basicConfig(format=log_format, level=logging.INFO)\n        elif log_level in [\"warn\", \"warning\"]:\n            logging.basicConfig(format=log_format, level=logging.WARNING)\n        elif log_level == \"error\":\n            logging.basicConfig(format=log_format, level=logging.ERROR)\n        elif log_level == \"critical\":\n            logging.basicConfig(format=log_format, level=logging.CRITICAL)\n        else:\n            error_report(\n                \"--log takes one of: NONE, DEBUG, INFO, WARN(ING), ERROR, CRITICAL\"\n            )\n\n\n# Local speaker list operations\nspeaker_list = None\n\n\ndef set_speaker_list(s):\n    global speaker_list\n    speaker_list = s\n\n\nclass SpeakerCache:\n    def __init__(self, max_threads=256, scan_timeout=0.1, min_netmask=24):\n        # _cache contains (soco_instance, speaker_name) tuples\n        self._cache = set()\n        self._scan_done = False\n        self._discovery_done = False\n        self._max_threads = max_threads\n        self._scan_timeout = scan_timeout\n        self._min_netmask = min_netmask\n\n    @property\n    def exists(self):\n        return bool(self._cache)\n\n    def cache_speakers(self, speakers):\n        logging.info(\"Adding speakers to cache: {}\".format(speakers))\n        for speaker in speakers:\n            self._cache.add((speaker, speaker.player_name))\n\n    def discover(self, reset=False):\n        if not self._discovery_done or reset:\n            # Clear the current cache\n            self._cache = set()\n            speakers = soco.discovery.discover(\n                allow_network_scan=True,\n                max_threads=self._max_threads,\n                scan_timeout=self._scan_timeout,\n                min_netmask=self._min_netmask,\n            )\n            if speakers:\n                self.cache_speakers(speakers)\n            else:\n                logging.info(\"No speakers found to cache\")\n            self._discovery_done = True\n\n    def scan(self, reset=False, scan_timeout_override=None):\n        if not self._scan_done or reset:\n            # Clear the current cache\n            self._cache = set()\n            scan_timeout = (\n                scan_timeout_override if scan_timeout_override else self._scan_timeout\n            )\n            logging.info(\n                \"Performing full discovery scan with timeout = {}s\".format(scan_timeout)\n            )\n            speakers = soco.discovery.scan_network(\n                multi_household=True,\n                max_threads=self._max_threads,\n                scan_timeout=scan_timeout,\n                min_netmask=self._min_netmask,\n            )\n            if speakers:\n                self.cache_speakers(speakers)\n                self._scan_done = True\n            else:\n                logging.info(\"No speakers found to cache\")\n        else:\n            logging.info(\"Full discovery scan already done, and reset not requested\")\n\n    def add(self, speaker):\n        logging.info(\"Adding speaker to cache\")\n        self._cache.add((speaker, speaker.player_name))\n\n    def find_indirect(self, name):\n        speakers_found = set()\n        speakers_found_names = set()\n        for cached, _ in self._cache:\n            for speaker in cached.visible_zones:\n                match, exact = speaker_name_matches(name, speaker.player_name)\n                if match and exact:\n                    return speaker\n                if match and not exact:\n                    speakers_found.add(speaker)\n                    speakers_found_names.add(speaker.player_name)\n\n        if len(speakers_found) == 1:\n            return speakers_found.pop()\n\n        if len(speakers_found) > 1:\n            error_report(\"'{}' is ambiguous: {}\".format(name, speakers_found_names))\n\n        return None\n\n    def find(self, name):\n        speakers_found = set()\n        speakers_found_names = set()\n        for speaker, speaker_name in self._cache:\n            match, exact = speaker_name_matches(name, speaker_name)\n            if match and exact:\n                return speaker\n            if match and not exact:\n                speakers_found.add(speaker)\n                speakers_found_names.add(speaker_name)\n\n        if len(speakers_found) == 1:\n            return speakers_found.pop()\n\n        if len(speakers_found) > 1:\n            error_report(\n                \"Speaker name '{}' is ambiguous within {}\".format(\n                    name, speakers_found_names\n                )\n            )\n\n        return None\n\n    def get_all_speakers(self, use_scan=False):\n        if use_scan:\n            self.scan()\n        else:\n            self.discover()\n        return self._cache\n\n    def get_all_speaker_names(self, use_scan=False):\n        if use_scan:\n            self.scan()\n        else:\n            self.discover()\n        names = [speaker[1] for speaker in self._cache]\n        names.sort()\n        return names\n\n    def rename_speaker(self, old_name, new_name):\n        for speaker in self._cache:\n            if speaker[1] == old_name:\n                logging.info(\"Updating speaker cache with new name\")\n                self._cache.remove(speaker)\n                self._cache.add((speaker[0], new_name))\n                return True\n        logging.info(\"Speaker with name '{}' not found\".format(old_name))\n        return False\n\n\nSPKR_CACHE = None\n\n\n# Single instance of the speaker cache\ndef create_speaker_cache(max_threads=256, scan_timeout=1.0, min_netmask=24):\n    global SPKR_CACHE\n    SPKR_CACHE = SpeakerCache(\n        max_threads=max_threads, scan_timeout=scan_timeout, min_netmask=min_netmask\n    )\n\n\ndef speaker_cache():\n    \"\"\"Return the global speaker cache object\"\"\"\n    return SPKR_CACHE\n\n\ndef local_speaker_list():\n    \"\"\"Return the global speaker list object\"\"\"\n    return speaker_list\n\n\ndef get_speaker(name, local=False):\n    # Use an IP address\n    # (Allow the use of an IP address even if 'local' is specified)\n    if Speakers.is_ipv4_address(name):\n        logging.info(\"Using IP address instead of speaker name\")\n        return soco.SoCo(name)\n\n    # Use the local speaker list\n    if local:\n        logging.info(\"Using local speaker list\")\n        return speaker_list.find(name)\n\n    # Use discovery\n    # Try various lookup methods in order of expense,\n    # and cache results where possible\n    logging.info(\"Trying direct cache lookup\")\n    speaker = SPKR_CACHE.find(name)\n    if not speaker:\n        logging.info(\"Trying indirect cache lookup\")\n        speaker = SPKR_CACHE.find_indirect(name)\n    if not speaker:\n        logging.info(\"Trying standard discovery with network scan fallback\")\n        SPKR_CACHE.discover()\n        speaker = SPKR_CACHE.find(name)\n    if not speaker:\n        logging.info(\"Trying network scan discovery\")\n        SPKR_CACHE.scan()\n        speaker = SPKR_CACHE.find(name)\n    if speaker:\n        logging.info(\"Successful speaker discovery\")\n    else:\n        logging.info(\"Failed to discover speaker\")\n    return speaker\n\n\ndef get_right_hand_speaker(left_hand_speaker):\n    # Get the right-hand speaker of a stereo pair when the\n    # left-hand speaker is supplied\n    if not left_hand_speaker.is_visible:\n        # If not visible, this is not a left-hand speaker\n        logging.info(\"Speaker is not visible: not a left-hand speaker\")\n        return None\n\n    # Find the speaker which is not visible, for which the\n    # left-hand speaker is the coordinator, and not a Sub\n    for rh_speaker in left_hand_speaker.all_zones:\n        if (\n            rh_speaker.group.coordinator.ip_address == left_hand_speaker.ip_address\n            and not rh_speaker.is_visible\n            and \"sub\" not in rh_speaker.get_speaker_info()[\"model_name\"].lower()\n        ):\n            logging.info(\n                \"Found right-hand speaker: {} / {}\".format(\n                    rh_speaker.player_name, rh_speaker.ip_address\n                )\n            )\n            return rh_speaker\n    logging.info(\"Right-hand speaker not found\")\n    return None\n\n\ndef rename_speaker_in_cache(old_name, new_name, use_local_speaker_list=True):\n    if use_local_speaker_list:\n        return speaker_list.rename(old_name, new_name)\n    return SPKR_CACHE.rename_speaker(old_name, new_name)\n\n\n# Argument processing\ndef configure_common_args(parser):\n    \"\"\"Set up the optional arguments common across the command line programs\"\"\"\n    parser.add_argument(\n        \"--network-discovery-threads\",\n        \"-t\",\n        type=int,\n        default=256,\n        help=\"Maximum number of threads for Sonos network discovery\",\n    )\n    parser.add_argument(\n        \"--network-discovery-timeout\",\n        \"-n\",\n        type=float,\n        default=1.0,\n        help=\"Network timeout for Sonos device scan (seconds)\",\n    )\n    parser.add_argument(\n        \"--min_netmask\",\n        \"--min-netmask\",\n        \"-m\",\n        type=int,\n        default=24,\n        help=\"Minimum netmask for Sonos device scan (integer 0-32)\",\n    )\n    parser.add_argument(\n        \"--version\",\n        \"-v\",\n        action=\"store_true\",\n        default=False,\n        help=\"Print the SoCo-CLI and SoCo versions and exit\",\n    )\n    parser.add_argument(\n        \"--log\",\n        type=str,\n        default=\"NONE\",\n        help=(\n            \"Set the logging level: 'NONE' (default) |'CRITICAL' | 'ERROR' | 'WARN'|\"\n            \" 'INFO' | 'DEBUG'\"\n        ),\n    )\n    parser.add_argument(\n        \"--docs\",\n        action=\"store_true\",\n        default=False,\n        help=\"Print the URL to the online documentation\",\n    )\n    parser.add_argument(\n        \"--logo\",\n        action=\"store_true\",\n        default=False,\n        help=\"Print the URL to the SoCo-CLI logo\",\n    )\n    parser.add_argument(\n        \"--check_for_update\",\n        \"--check-for-update\",\n        action=\"store_true\",\n        default=False,\n        help=\"Check for a more recent version of SoCo-CLI\",\n    )\n\n\ndef check_args(args):\n    \"\"\"Check values of parameters. Returns None, or an error message.\"\"\"\n    message = \"\"\n    if not 0 <= args.min_netmask <= 32:\n        message = (\n            message + \"\\n    Option 'min_netmask' must be an integer between 0 and 32\"\n        )\n    if not 0.0 <= args.network_discovery_timeout <= 60.0:\n        message = message + \"\\n    Option 'network_timeout' must be between 0.0 and 60s\"\n    if not 1 <= args.network_discovery_threads <= 32000:\n        message = message + \"\\n    Option 'threads' must be between 1 and 32000\"\n    if message == \"\":\n        return None\n    return message\n\n\npath = os.path.expanduser(\"~\") + \"/.soco-cli/\"\nfilename = \"saved_search.pickle\"\npathname = path + filename\n\n\ndef save_search(result):\n    if not os.path.exists(path):\n        os.mkdir(path)\n    with open(pathname, \"wb\") as f:\n        pickle.dump(result, f)\n    logging.info(\"Saved search results at {}\".format(pathname))\n    return True\n\n\ndef read_search():\n    if os.path.exists(pathname):\n        logging.info(\"Loading search results from {}\".format(pathname))\n        try:\n            with open(pathname, \"rb\") as f:\n                return pickle.load(f)\n        except Exception as e:\n            logging.info(\"Failed to load search results: %s\", e)\n    return None\n\n\nfilename = \"queue_insertion_position.pickle\"\nqueue_pathname = path + filename\n\n\ndef save_queue_insertion_position(queue_position: int):\n    if not os.path.exists(path):\n        os.mkdir(path)\n    with open(queue_pathname, \"wb\") as f:\n        pickle.dump(queue_position, f)\n    logging.info(\"Saved queue position at {}\".format(queue_pathname))\n    return True\n\n\ndef get_queue_insertion_position() -> int:\n    if os.path.exists(queue_pathname):\n        logging.info(\"Loading queue_position from {}\".format(queue_pathname))\n        try:\n            with open(queue_pathname, \"rb\") as f:\n                return pickle.load(f)\n        except Exception as e:\n            logging.info(\"Failed to load queue_position: %s\", e)\n            raise e\n    else:\n        logging.info(\"No saved queue_position\")\n        raise FileNotFoundError\n\n\n# Interactive shell history file\nSOCO_CLI_DIR = os.path.join(os.path.expanduser(\"~\"), \".soco-cli\")\nHIST_FILE = os.path.join(SOCO_CLI_DIR, \"shell-history.txt\")\nHIST_LEN = 50\n\n\ndef save_readline_history():\n    if not _confirm_soco_cli_dir():\n        return\n    logging.info(\"Saving shell history file: {}\".format(HIST_FILE))\n    try:\n        readline.write_history_file(HIST_FILE)\n    except Exception as e:\n        logging.info(\"Error saving shell history file: {}\".format(e))\n\n\ndef get_readline_history():\n    if not os.path.exists(HIST_FILE):\n        logging.info(\"No shell history file found: {}\".format(HIST_FILE))\n        return\n\n    logging.info(\"Reading shell history file: {}\".format(HIST_FILE))\n    try:\n        readline.read_history_file(HIST_FILE)\n        readline.set_history_length(HIST_LEN)\n    except Exception as e:\n        logging.info(\"Error reading shell history file: {}\".format(e))\n\n\ndef pretty_print_values(items, indent=2, separator=\":\", spacing=3, sort_by_key=False):\n    \"\"\"Print a list of keys and values.\n\n    Args:\n        items (dict): The keys and values to print.\n        indent (int): Number of spaces to use as initial indentation.\n        separator (str): The separator character(s) between the key and value.\n        spacing (int): The minimum gap between the separator and\n            the value.\n        sort_by_key (bool): Whether to sort by item key.\n\n    Example:\n        One:     First\n        Two:     Second\n        Three:   Third\n    \"\"\"\n    if len(items) == 0:\n        return\n    longest = max(len(key) for key in items)\n    prefix = \" \" * indent\n    key_vals = items.items()\n    if sort_by_key:\n        key_vals = sorted(key_vals)\n    for key, value in key_vals:\n        spacer = \" \" * (spacing + longest - len(key))\n        print(\"{}{}{}{}{}\".format(prefix, key, separator, spacer, value))\n\n\ndef playback_state(state):\n    \"\"\"Generate user-friendly playback states.\n\n    Args:\n        state (str): The Sonos-supplied state string.\n\n    Returns:\n        str: A user-friendly playback state description.\n    \"\"\"\n    playback_mapping = {\n        \"STOPPED\": \"stopped\",\n        \"PAUSED_PLAYBACK\": \"paused\",\n        \"PLAYING\": \"in progress\",\n        \"TRANSITIONING\": \"in a transitioning state\",\n    }\n    try:\n        return playback_mapping[state]\n    except KeyError:\n        return \"unknown\"\n\n\n# Ensure that event subscriptions are cleared on CTRL-C\nSUBS_LIST = set()\n\n\ndef _confirm_soco_cli_dir() -> bool:\n    if not os.path.exists(SOCO_CLI_DIR):\n        logging.info(\"Creating directory '{}'\".format(SOCO_CLI_DIR))\n        try:\n            os.mkdir(SOCO_CLI_DIR)\n            return True\n        except OSError:\n            error_report(\"Failed to create directory '{}'\".format(SOCO_CLI_DIR))\n            return False\n    else:\n        return True\n\n\ndef remember_event_sub(sub):\n    global SUBS_LIST\n    logging.info(\"Adding event subscription record: '{}'\".format(sub))\n    SUBS_LIST.add(sub)\n\n\ndef forget_event_sub(sub):\n    global SUBS_LIST\n    try:\n        logging.info(\"Removing event subscription record: '{}'\".format(sub))\n        SUBS_LIST.remove(sub)\n    except KeyError:\n        pass\n\n\ndef unsub_all_remembered_event_subs():\n    global SUBS_LIST\n    for sub in SUBS_LIST:\n        try:\n            event_unsubscribe(sub)\n        except Exception:\n            break\n    SUBS_LIST.clear()\n\n\ndef find_by_name(items, name):\n    \"\"\"Find an item by strict then fuzzy match on its .title attribute.\n\n    Returns the first matched item, or None if not found.\n    \"\"\"\n    for item in items:\n        if name == item.title:\n            logging.info(\"Strict match '{}' found\".format(item.title))\n            return item\n    name_lower = name.lower()\n    for item in items:\n        if name_lower in item.title.lower():\n            logging.info(\"Fuzzy match '{}' found\".format(item.title))\n            return item\n    return None\n\n\ndef queue_is_empty(speaker):\n    \"\"\"Return True and report error if the queue is empty, otherwise False.\"\"\"\n    if speaker.queue_size == 0:\n        error_report(\"Queue is empty\")\n        return True\n    return False\n\n\ndef create_list_of_items_from_range(range_definition: str, upper_limit: int):\n    \"\"\"\n    Take a range string and generate a set of items defined by the\n    range.\n    \"\"\"\n    if \"all\" in range_definition.lower():\n        return [item for item in range(1, upper_limit + 1)]\n    items_set = set()\n    for range_element in range_definition.split(\",\"):\n        # Check for a range ('x-y') instead of a single integer\n        if \"-\" in range_element:\n            rng = range_element.split(\"-\")\n            if len(rng) != 2:\n                raise IndexError(\n                    \"Invalid range specification '{}'\".format(range_element)\n                )\n            index_1 = int(rng[0])\n            index_2 = int(rng[1])\n            if index_1 > index_2:  # Reverse the indices\n                index_2, index_1 = index_1, index_2\n            if not (0 < index_1 <= upper_limit and index_1 <= index_2 <= upper_limit):\n                raise IndexError(\"Item(s) out of range in '{}'\".format(range_element))\n            for i in range(index_1, index_2 + 1):\n                items_set.add(i)\n        else:\n            index = int(range_element)\n            if not 0 < index <= upper_limit:\n                raise IndexError(\"Item out of range '{}'\".format(range_element))\n            items_set.add(index)\n    return sorted(list(items_set))\n"
  },
  {
    "path": "soco_cli/wait_actions.py",
    "content": "\"\"\"Process the speaker-independent 'wait' actions.\"\"\"\n\nimport logging\nimport time\nfrom typing import List\n\nfrom soco_cli.utils import convert_to_seconds, error_report, seconds_until\n\n\ndef process_wait(sequence: List):\n    if sequence[0] in [\"wait\", \"wait_for\"]:\n        duration = 0\n        if len(sequence) != 2:\n            error_report(\n                \"Action 'wait' requires 1 parameter (check spaces around the ':'\"\n                \" separator?)\"\n            )\n            return\n        action = sequence[1].lower()\n        try:\n            duration = convert_to_seconds(action)\n        except ValueError:\n            error_report(\n                \"Action 'wait' requires positive number of hours, seconds or minutes +\"\n                \" 'h/m/s', or HH:MM(:SS)\"\n            )\n        logging.info(\"Waiting for {}s\".format(duration))\n        time.sleep(duration)\n\n    # Special case: the 'wait_until' action\n    elif sequence[0] in [\"wait_until\"]:\n        if len(sequence) != 2:\n            error_report(\n                \"'wait_until' requires 1 parameter (check spaces around the ':'\"\n                \" separator?)\"\n            )\n            return\n        try:\n            action = sequence[1].lower()\n            duration = seconds_until(action)\n            logging.info(\"Waiting for {}s\".format(duration))\n            time.sleep(duration)\n        except ValueError:\n            error_report(\n                \"'wait_until' requires parameter: time in 24hr HH:MM(:SS) format\"\n            )\n"
  },
  {
    "path": "tests/test_action_processor.py",
    "content": "\"\"\"Tests for action_processor.py.\n\nCovers pure helpers, output formatters, and action-processing functions\nthat can be exercised without a live Sonos network.\n\"\"\"\n\nfrom collections import OrderedDict\nfrom unittest.mock import MagicMock, call, patch\n\nimport pytest\n\nimport soco_cli.utils as utils\nfrom soco_cli.action_processor import (\n    SonosFunction,\n    _is_queue_position,\n    add_favourite_to_queue,\n    audio_format,\n    filter_track_info,\n    get_actions,\n    get_current_queue_position,\n    list_queue,\n    mic_enabled,\n    on_off_action,\n    play_favourite_core,\n    play_uri,\n    playback_mode,\n    print_albums,\n    print_list_header,\n    print_tracks,\n    process_action,\n    repeat,\n    set_queue_position,\n    shuffle,\n    sleep_timer,\n    surround_volume,\n    switch_to_tv,\n    tv_audio_delay,\n    volume_actions,\n)\n\n# ---------------------------------------------------------------------------\n# Autouse fixture: run every test in API mode so error_report never calls\n# os._exit()\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture(autouse=True)\ndef api_mode():\n    original = utils.API\n    utils.API = True\n    yield\n    utils.API = original\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\n\ndef _make_speaker(**kwargs):\n    speaker = MagicMock()\n    for k, v in kwargs.items():\n        setattr(speaker, k, v)\n    return speaker\n\n\ndef _make_track(title=\"Track\", creator=\"Artist\", album=\"Album\", item_class=None):\n    track = MagicMock()\n    track.title = title\n    track.creator = creator\n    track.album = album\n    if item_class is not None:\n        track.item_class = item_class\n    else:\n        del track.item_class  # AttributeError on access\n    return track\n\n\ndef _make_favourite(title, uri=\"http://example.com/stream\", meta=\"<meta/>\"):\n    fav = MagicMock()\n    fav.title = title\n    fav.get_uri.return_value = uri\n    fav.resource_meta_data = meta\n    return fav\n\n\ndef _call(func, speaker, args, action=None, soco_function=\"\", use_local=False):\n    return func(speaker, action or func.__name__, args, soco_function, use_local)\n\n\n# ===========================================================================\n# _is_queue_position\n# ===========================================================================\n\n\nclass TestIsQueuePosition:\n    def test_integer_strings(self):\n        assert _is_queue_position(\"1\") is True\n        assert _is_queue_position(\"0\") is True\n        assert _is_queue_position(\"100\") is True\n        assert _is_queue_position(\"-1\") is True\n\n    def test_named_keywords_case_insensitive(self):\n        for kw in [\"first\", \"FIRST\", \"First\", \"start\", \"START\"]:\n            assert _is_queue_position(kw) is True\n        for kw in [\"next\", \"NEXT\", \"play_next\", \"PLAY_NEXT\"]:\n            assert _is_queue_position(kw) is True\n        for kw in [\"last\", \"LAST\", \"end\", \"END\"]:\n            assert _is_queue_position(kw) is True\n\n    def test_invalid_strings(self):\n        assert _is_queue_position(\"middle\") is False\n        assert _is_queue_position(\"not_a_sharelink\") is False\n        assert _is_queue_position(\"https://open.spotify.com/track/AAA\") is False\n        assert _is_queue_position(\"\") is False\n        assert _is_queue_position(\"1.5\") is False\n\n    def test_close_but_not_valid(self):\n        assert _is_queue_position(\"starts\") is False\n        assert _is_queue_position(\"ending\") is False\n        assert _is_queue_position(\"nexxt\") is False\n\n\n# ===========================================================================\n# filter_track_info\n# ===========================================================================\n\n\nclass TestFilterTrackInfo:\n    def test_capitalises_keys(self):\n        result = filter_track_info({\"artist\": \"Bach\", \"title\": \"Toccata\"}, [])\n        assert \"Artist\" in result\n        assert \"Title\" in result\n\n    def test_excludes_specified_fields(self):\n        result = filter_track_info(\n            {\"artist\": \"Bach\", \"uri\": \"x\", \"title\": \"T\"}, [\"uri\"]\n        )\n        assert \"Uri\" not in result\n        assert \"Artist\" in result\n        assert \"Title\" in result\n\n    def test_preserves_values(self):\n        result = filter_track_info({\"artist\": \"Bach\"}, [])\n        assert result[\"Artist\"] == \"Bach\"\n\n    def test_empty_input(self):\n        assert filter_track_info({}, []) == {}\n\n    def test_all_excluded(self):\n        result = filter_track_info(\n            {\"artist\": \"Bach\", \"title\": \"T\"}, [\"artist\", \"title\"]\n        )\n        assert result == {}\n\n    def test_output_order_follows_sorted_input_keys(self):\n        result = filter_track_info({\"z_key\": 1, \"a_key\": 2, \"m_key\": 3}, [])\n        assert list(result.keys()) == [\"A_key\", \"M_key\", \"Z_key\"]\n\n    def test_capitalize_only_first_char(self):\n        # str.capitalize() uppercases first char only; doesn't affect rest\n        result = filter_track_info({\"album_art\": \"url\"}, [])\n        assert \"Album_art\" in result\n\n\n# ===========================================================================\n# print_list_header\n# ===========================================================================\n\n\nclass TestPrintListHeader:\n    def test_output_format(self, capsys):\n        print_list_header(\"Sonos\", \"Favourites\")\n        out = capsys.readouterr().out\n        lines = out.splitlines()\n        assert \"Sonos Favourites\" in lines[0]\n        # Underline length matches title length\n        title = \"Sonos Favourites\"\n        assert lines[1].strip() == \"=\" * len(title)\n\n    def test_unicode_name(self, capsys):\n        print_list_header(\"\", \"Ünïcödé\")\n        out = capsys.readouterr().out\n        assert \"Ünïcödé\" in out\n\n\n# ===========================================================================\n# get_current_queue_position\n# ===========================================================================\n\n\nclass TestGetCurrentQueuePosition:\n    def _make_speaker(self, playlist_position=\"3\", title=\"A Track\", state=\"PLAYING\"):\n        speaker = MagicMock()\n        speaker.get_current_track_info.return_value = {\n            \"playlist_position\": playlist_position,\n            \"title\": title,\n        }\n        speaker.get_current_transport_info.return_value = {\n            \"current_transport_state\": state\n        }\n        return speaker\n\n    def test_playing_no_tracks_returns_qp_and_true(self):\n        speaker = self._make_speaker(playlist_position=\"3\", state=\"PLAYING\")\n        qp, is_playing = get_current_queue_position(speaker)\n        assert qp == 3\n        assert is_playing is True\n\n    def test_paused_returns_qp_and_false(self):\n        speaker = self._make_speaker(state=\"PAUSED_PLAYBACK\")\n        qp, is_playing = get_current_queue_position(speaker)\n        assert qp == 3\n        assert is_playing is False\n\n    def test_playing_with_matching_track_title(self):\n        speaker = self._make_speaker(\n            playlist_position=\"2\", title=\"My Song\", state=\"PLAYING\"\n        )\n        tracks = [MagicMock(), MagicMock()]\n        tracks[1].title = \"My Song\"\n        qp, is_playing = get_current_queue_position(speaker, tracks)\n        assert qp == 2\n        assert is_playing is True\n\n    def test_playing_with_mismatched_track_title_resets_to_1(self):\n        speaker = self._make_speaker(\n            playlist_position=\"2\", title=\"My Song\", state=\"PLAYING\"\n        )\n        tracks = [MagicMock(), MagicMock()]\n        tracks[1].title = \"Different Song\"\n        qp, is_playing = get_current_queue_position(speaker, tracks)\n        assert qp == 1\n        assert is_playing is False\n\n    def test_track_info_exception_returns_zero(self):\n        speaker = MagicMock()\n        speaker.get_current_track_info.side_effect = Exception(\"network error\")\n        speaker.get_current_transport_info.return_value = {\n            \"current_transport_state\": \"PLAYING\"\n        }\n        qp, is_playing = get_current_queue_position(speaker)\n        assert qp == 0\n        assert is_playing is True  # PLAYING with no tracks → True without title check\n\n    def test_transport_info_exception_returns_false(self):\n        speaker = MagicMock()\n        speaker.get_current_track_info.return_value = {\n            \"playlist_position\": \"2\",\n            \"title\": \"T\",\n        }\n        speaker.get_current_transport_info.side_effect = Exception(\"network error\")\n        qp, is_playing = get_current_queue_position(speaker)\n        assert qp == 2\n        assert is_playing is False\n\n    def test_index_error_on_tracks_access_resets_to_1(self):\n        speaker = self._make_speaker(playlist_position=\"99\", state=\"PLAYING\")\n        tracks = [MagicMock()]  # only 1 track; position 99 is out of range\n        tracks[0].title = \"different\"\n        qp, is_playing = get_current_queue_position(speaker, tracks)\n        assert qp == 1\n        assert is_playing is False\n\n\n# ===========================================================================\n# print_tracks\n# ===========================================================================\n\n\nclass TestPrintTracks:\n    def test_prints_track_info(self, capsys):\n        tracks = [_make_track(title=\"Toccata\", creator=\"Bach\", album=\"Organ Works\")]\n        print_tracks(tracks)\n        out = capsys.readouterr().out\n        assert \"Toccata\" in out\n        assert \"Bach\" in out\n        assert \"Organ Works\" in out\n\n    def test_missing_attributes_are_skipped(self, capsys):\n        track = MagicMock()\n        track.title = \"Only Title\"\n        del track.creator\n        del track.album\n        del track.item_class\n        print_tracks([track])\n        out = capsys.readouterr().out\n        assert \"Only Title\" in out\n        assert \"Artist\" not in out\n        assert \"Album\" not in out\n\n    def test_podcast_track_renames_title_field(self, capsys):\n        track = _make_track(\n            title=\"Episode 1\", item_class=\"object.item.audioItem.podcast\"\n        )\n        print_tracks([track])\n        out = capsys.readouterr().out\n        assert \"Podcast Episode\" in out\n        assert \"Episode 1\" in out\n\n    def test_numbered_sequentially(self, capsys):\n        tracks = [_make_track(title=f\"Track {i}\") for i in range(1, 4)]\n        print_tracks(tracks)\n        out = capsys.readouterr().out\n        assert \"  1:\" in out\n        assert \"  2:\" in out\n        assert \"  3:\" in out\n\n    def test_current_track_prefix_playing(self, capsys):\n        tracks = [_make_track(title=\"Active\"), _make_track(title=\"Next\")]\n        with patch(\n            \"soco_cli.action_processor.get_current_queue_position\",\n            return_value=(1, True),\n        ):\n            print_tracks(tracks, speaker=MagicMock())\n        out = capsys.readouterr().out\n        lines = [l for l in out.splitlines() if \"Active\" in l]\n        assert lines[0].startswith(\" *> \")\n\n    def test_current_track_prefix_paused(self, capsys):\n        tracks = [_make_track(title=\"Paused\"), _make_track(title=\"Next\")]\n        with patch(\n            \"soco_cli.action_processor.get_current_queue_position\",\n            return_value=(1, False),\n        ):\n            print_tracks(tracks, speaker=MagicMock())\n        out = capsys.readouterr().out\n        lines = [l for l in out.splitlines() if \"Paused\" in l]\n        assert lines[0].startswith(\" *  \")\n\n    def test_non_current_track_has_plain_prefix(self, capsys):\n        tracks = [_make_track(title=\"T1\"), _make_track(title=\"T2\")]\n        with patch(\n            \"soco_cli.action_processor.get_current_queue_position\",\n            return_value=(1, True),\n        ):\n            print_tracks(tracks, speaker=MagicMock())\n        out = capsys.readouterr().out\n        lines = [l for l in out.splitlines() if \"T2\" in l]\n        assert lines[0].startswith(\"    \")\n\n    def test_single_track_mode_uses_given_number(self, capsys):\n        tracks = [_make_track(title=\"Solo\")]\n        print_tracks(tracks, single_track=True, track_number=7)\n        out = capsys.readouterr().out\n        assert \"  7:\" in out\n\n    def test_returns_true(self):\n        assert print_tracks([]) is True\n\n\n# ===========================================================================\n# print_albums\n# ===========================================================================\n\n\nclass TestPrintAlbums:\n    def _make_album(self, title, creator=\"Artist\"):\n        a = MagicMock()\n        a.title = title\n        a.creator = creator\n        return a\n\n    def test_prints_albums(self, capsys):\n        albums = [self._make_album(\"Abbey Road\", \"Beatles\")]\n        print_albums(albums)\n        out = capsys.readouterr().out\n        assert \"Abbey Road\" in out\n        assert \"Beatles\" in out\n\n    def test_omit_first(self, capsys):\n        albums = [self._make_album(\"First\"), self._make_album(\"Second\")]\n        print_albums(albums, omit_first=True)\n        out = capsys.readouterr().out\n        assert \"First\" not in out\n        assert \"Second\" in out\n\n    def test_numbering_with_omit_first_restarts_at_1(self, capsys):\n        # omit_first skips the first album but does NOT advance the counter,\n        # so the remaining albums are numbered starting from 1.\n        albums = [self._make_album(\"A\"), self._make_album(\"B\"), self._make_album(\"C\")]\n        print_albums(albums, omit_first=True)\n        out = capsys.readouterr().out\n        assert \"      1:\" in out\n        assert \"      2:\" in out\n\n    def test_missing_creator_defaults_to_empty(self, capsys):\n        a = MagicMock()\n        a.title = \"Untitled\"\n        del a.creator\n        print_albums([a])\n        out = capsys.readouterr().out\n        assert \"Untitled\" in out\n\n    def test_returns_true(self):\n        assert print_albums([]) is True\n\n\n# ===========================================================================\n# on_off_action\n# ===========================================================================\n\n\nclass TestOnOffAction:\n    def test_get_state_on(self, capsys):\n        speaker = _make_speaker()\n        speaker.loudness = True\n        _call(on_off_action, speaker, [], soco_function=\"loudness\")\n        assert capsys.readouterr().out.strip() == \"on\"\n\n    def test_get_state_off(self, capsys):\n        speaker = _make_speaker()\n        speaker.loudness = False\n        _call(on_off_action, speaker, [], soco_function=\"loudness\")\n        assert capsys.readouterr().out.strip() == \"off\"\n\n    def test_set_on(self):\n        speaker = _make_speaker()\n        result = _call(on_off_action, speaker, [\"on\"], soco_function=\"loudness\")\n        assert result is True\n        assert speaker.loudness is True\n\n    def test_set_off(self):\n        speaker = _make_speaker()\n        result = _call(on_off_action, speaker, [\"off\"], soco_function=\"loudness\")\n        assert result is True\n        assert speaker.loudness is False\n\n    def test_set_case_insensitive(self):\n        speaker = _make_speaker()\n        assert _call(on_off_action, speaker, [\"ON\"], soco_function=\"loudness\") is True\n        assert _call(on_off_action, speaker, [\"OFF\"], soco_function=\"loudness\") is True\n\n    def test_invalid_arg_returns_false(self, capsys):\n        speaker = _make_speaker()\n        result = _call(on_off_action, speaker, [\"yes\"], soco_function=\"loudness\")\n        assert result is False\n\n    def test_group_mute_switches_to_group(self):\n        speaker = MagicMock()\n        speaker.group.mute = False\n        on_off_action(speaker, \"group_mute\", [\"on\"], \"mute\", False)\n        assert speaker.group.mute is True\n\n\n# ===========================================================================\n# volume_actions\n# ===========================================================================\n\n\nclass TestVolumeActions:\n    def test_get_volume(self, capsys):\n        speaker = _make_speaker(volume=42)\n        _call(volume_actions, speaker, [], soco_function=\"volume\")\n        assert capsys.readouterr().out.strip() == \"42\"\n\n    def test_set_volume(self):\n        speaker = _make_speaker()\n        result = _call(volume_actions, speaker, [\"75\"], soco_function=\"volume\")\n        assert result is True\n        assert speaker.volume == 75\n\n    def test_set_volume_boundary_values(self):\n        for v in [0, 100]:\n            speaker = _make_speaker()\n            assert (\n                _call(volume_actions, speaker, [str(v)], soco_function=\"volume\") is True\n            )\n            assert speaker.volume == v\n\n    def test_set_volume_out_of_range(self, capsys):\n        for v in [\"-1\", \"101\"]:\n            speaker = _make_speaker()\n            result = _call(volume_actions, speaker, [v], soco_function=\"volume\")\n            assert result is False\n\n    def test_set_volume_invalid_arg(self, capsys):\n        speaker = _make_speaker()\n        result = _call(volume_actions, speaker, [\"loud\"], soco_function=\"volume\")\n        assert result is False\n\n    def test_group_volume_uses_group(self, capsys):\n        speaker = MagicMock()\n        speaker.group.volume = 30\n        _call(volume_actions, speaker, [], soco_function=\"group_volume\")\n        assert capsys.readouterr().out.strip() == \"30\"\n\n    def test_ramp_to_volume(self):\n        speaker = _make_speaker()\n        speaker.ramp_to_volume.return_value = 60\n        with patch(\"builtins.print\"):\n            result = _call(\n                volume_actions, speaker, [\"60\"], soco_function=\"ramp_to_volume\"\n            )\n        assert result is True\n        speaker.ramp_to_volume.assert_called_once_with(60)\n\n\n# ===========================================================================\n# shuffle\n# ===========================================================================\n\n\nclass TestShuffle:\n    def test_get_shuffle_on(self, capsys):\n        speaker = _make_speaker(shuffle=True)\n        _call(shuffle, speaker, [])\n        assert capsys.readouterr().out.strip() == \"on\"\n\n    def test_get_shuffle_off(self, capsys):\n        speaker = _make_speaker(shuffle=False)\n        _call(shuffle, speaker, [])\n        assert capsys.readouterr().out.strip() == \"off\"\n\n    def test_set_shuffle_on(self):\n        speaker = _make_speaker()\n        assert _call(shuffle, speaker, [\"on\"]) is True\n        assert speaker.shuffle is True\n\n    def test_set_shuffle_off(self):\n        speaker = _make_speaker()\n        assert _call(shuffle, speaker, [\"off\"]) is True\n        assert speaker.shuffle is False\n\n    def test_set_shuffle_case_insensitive(self):\n        speaker = _make_speaker()\n        assert _call(shuffle, speaker, [\"ON\"]) is True\n        assert _call(shuffle, speaker, [\"OFF\"]) is True\n\n    def test_invalid_arg_returns_false(self, capsys):\n        speaker = _make_speaker()\n        assert _call(shuffle, speaker, [\"yes\"]) is False\n        assert \"Error\" in capsys.readouterr().err\n\n\n# ===========================================================================\n# repeat\n# ===========================================================================\n\n\nclass TestRepeat:\n    def test_get_repeat_all(self, capsys):\n        speaker = _make_speaker(repeat=True)\n        _call(repeat, speaker, [])\n        assert capsys.readouterr().out.strip() == \"all\"\n\n    def test_get_repeat_off(self, capsys):\n        speaker = _make_speaker(repeat=False)\n        _call(repeat, speaker, [])\n        assert capsys.readouterr().out.strip() == \"off\"\n\n    def test_get_repeat_one(self, capsys):\n        speaker = _make_speaker(repeat=\"ONE\")\n        _call(repeat, speaker, [])\n        assert capsys.readouterr().out.strip() == \"one\"\n\n    def test_set_off(self):\n        speaker = _make_speaker()\n        assert _call(repeat, speaker, [\"off\"]) is True\n        assert speaker.repeat is False\n\n    def test_set_none_alias(self):\n        speaker = _make_speaker()\n        assert _call(repeat, speaker, [\"none\"]) is True\n        assert speaker.repeat is False\n\n    def test_set_one(self):\n        speaker = _make_speaker()\n        assert _call(repeat, speaker, [\"one\"]) is True\n        assert speaker.repeat == \"ONE\"\n\n    def test_set_all(self):\n        speaker = _make_speaker()\n        assert _call(repeat, speaker, [\"all\"]) is True\n        assert speaker.repeat is True\n\n    def test_set_case_insensitive(self):\n        speaker = _make_speaker()\n        assert _call(repeat, speaker, [\"ALL\"]) is True\n        assert _call(repeat, speaker, [\"OFF\"]) is True\n\n    def test_invalid_arg_returns_false(self, capsys):\n        speaker = _make_speaker()\n        assert _call(repeat, speaker, [\"twice\"]) is False\n        assert \"Error\" in capsys.readouterr().err\n\n\n# ===========================================================================\n# playback_mode\n# ===========================================================================\n\n\nclass TestPlaybackMode:\n    def test_get_mode(self, capsys):\n        speaker = _make_speaker(play_mode=\"SHUFFLE\")\n        _call(playback_mode, speaker, [])\n        assert capsys.readouterr().out.strip() == \"SHUFFLE\"\n\n    def test_set_valid_modes(self):\n        for mode in [\n            \"normal\",\n            \"repeat_all\",\n            \"repeat_one\",\n            \"shuffle\",\n            \"shuffle_norepeat\",\n            \"shuffle_repeat_one\",\n        ]:\n            speaker = _make_speaker()\n            result = _call(playback_mode, speaker, [mode])\n            assert result is True\n            assert speaker.play_mode == mode\n\n    def test_set_mode_case_insensitive(self):\n        speaker = _make_speaker()\n        _call(playback_mode, speaker, [\"NORMAL\"])\n        assert speaker.play_mode == \"NORMAL\"\n\n    def test_invalid_mode_still_returns_true(self, capsys):\n        # Known behaviour: playback_mode always returns True even on invalid input\n        speaker = _make_speaker()\n        result = _call(playback_mode, speaker, [\"random\"])\n        assert result is True\n        # But the speaker's play_mode should not have been set\n        (\n            speaker.play_mode.__set__.assert_not_called()\n            if hasattr(speaker.play_mode, \"__set__\")\n            else None\n        )\n\n\n# ===========================================================================\n# sleep_timer\n# ===========================================================================\n\n\nclass TestSleepTimer:\n    def test_get_no_timer(self, capsys):\n        speaker = _make_speaker()\n        speaker.get_sleep_timer.return_value = None\n        _call(sleep_timer, speaker, [])\n        assert \"No sleep timer set\" in capsys.readouterr().out\n\n    def test_get_timer_active(self, capsys):\n        speaker = _make_speaker()\n        speaker.get_sleep_timer.return_value = 600  # 10 minutes\n        _call(sleep_timer, speaker, [])\n        out = capsys.readouterr().out\n        assert \"expires\" in out\n\n    def test_cancel_timer(self):\n        speaker = _make_speaker()\n        result = _call(sleep_timer, speaker, [\"off\"])\n        assert result is True\n        speaker.set_sleep_timer.assert_called_once_with(None)\n\n    def test_cancel_alias(self):\n        speaker = _make_speaker()\n        _call(sleep_timer, speaker, [\"cancel\"])\n        speaker.set_sleep_timer.assert_called_once_with(None)\n\n    def test_set_timer_seconds(self):\n        speaker = _make_speaker()\n        result = _call(sleep_timer, speaker, [\"120s\"])\n        assert result is True\n        speaker.set_sleep_timer.assert_called_once_with(120)\n\n    def test_set_timer_minutes(self):\n        speaker = _make_speaker()\n        result = _call(sleep_timer, speaker, [\"30m\"])\n        assert result is True\n        speaker.set_sleep_timer.assert_called_once_with(1800)\n\n    def test_set_timer_invalid_format(self, capsys):\n        speaker = _make_speaker()\n        result = _call(sleep_timer, speaker, [\"tomorrow\"])\n        assert result is False\n\n    def test_set_timer_exceeds_max(self, capsys):\n        speaker = _make_speaker()\n        result = _call(sleep_timer, speaker, [\"90000s\"])  # > 86399\n        assert result is False\n\n\n# ===========================================================================\n# switch_to_tv\n# ===========================================================================\n\n\nclass TestSwitchToTv:\n    def test_soundbar_switches(self):\n        speaker = _make_speaker(is_soundbar=True)\n        result = _call(switch_to_tv, speaker, [])\n        assert result is True\n        speaker.switch_to_tv.assert_called_once()\n\n    def test_non_soundbar_returns_false(self, capsys):\n        speaker = _make_speaker(is_soundbar=False, player_name=\"Kitchen\")\n        result = _call(switch_to_tv, speaker, [])\n        assert result is False\n        assert \"Error\" in capsys.readouterr().err\n\n\n# ===========================================================================\n# audio_format\n# ===========================================================================\n\n\nclass TestAudioFormat:\n    def test_soundbar_with_format(self, capsys):\n        speaker = _make_speaker(\n            is_soundbar=True, soundbar_audio_input_format=\"Dolby Atmos\"\n        )\n        result = _call(audio_format, speaker, [])\n        assert result is True\n        assert \"Dolby Atmos\" in capsys.readouterr().out\n\n    def test_soundbar_no_format(self, capsys):\n        speaker = _make_speaker(is_soundbar=True, soundbar_audio_input_format=None)\n        result = _call(audio_format, speaker, [])\n        assert result is True\n        assert \"No audio format information\" in capsys.readouterr().out\n\n    def test_non_soundbar_returns_false(self, capsys):\n        speaker = _make_speaker(is_soundbar=False, player_name=\"Kitchen\")\n        result = _call(audio_format, speaker, [])\n        assert result is False\n        assert \"Error\" in capsys.readouterr().err\n\n\n# ===========================================================================\n# mic_enabled\n# ===========================================================================\n\n\nclass TestMicEnabled:\n    def test_mic_enabled_true(self, capsys):\n        speaker = _make_speaker(mic_enabled=True)\n        result = _call(mic_enabled, speaker, [])\n        assert result is True\n        assert \"True\" in capsys.readouterr().out\n\n    def test_mic_enabled_false(self, capsys):\n        speaker = _make_speaker(mic_enabled=False)\n        result = _call(mic_enabled, speaker, [])\n        assert result is True\n        assert \"False\" in capsys.readouterr().out\n\n    def test_mic_none_returns_false(self, capsys):\n        speaker = _make_speaker(mic_enabled=None, player_name=\"Kitchen\")\n        result = _call(mic_enabled, speaker, [])\n        assert result is False\n        assert \"Error\" in capsys.readouterr().err\n\n\n# ===========================================================================\n# tv_audio_delay\n# ===========================================================================\n\n\nclass TestTvAudioDelay:\n    def test_non_soundbar_returns_false(self, capsys):\n        speaker = _make_speaker(is_soundbar=False, player_name=\"Kitchen\")\n        result = _call(tv_audio_delay, speaker, [])\n        assert result is False\n\n    def test_get_delay(self, capsys):\n        speaker = _make_speaker(is_soundbar=True, audio_delay=2)\n        _call(tv_audio_delay, speaker, [])\n        assert \"2\" in capsys.readouterr().out\n\n    def test_set_delay(self):\n        speaker = _make_speaker(is_soundbar=True)\n        result = _call(tv_audio_delay, speaker, [\"3\"])\n        assert result is True\n        assert speaker.audio_delay == 3\n\n    def test_set_delay_invalid(self, capsys):\n        speaker = _make_speaker(is_soundbar=True)\n        result = _call(tv_audio_delay, speaker, [\"abc\"])\n        assert result is False\n        assert \"Error\" in capsys.readouterr().err\n\n\n# ===========================================================================\n# set_queue_position\n# ===========================================================================\n\n\nclass TestSetQueuePosition:\n    def test_valid_position(self):\n        speaker = _make_speaker(queue_size=10)\n        result = _call(set_queue_position, speaker, [\"5\"])\n        assert result is True\n        speaker.stop.assert_called_once()\n        speaker.play_from_queue.assert_called_once_with(index=4, start=False)\n\n    def test_boundary_first(self):\n        speaker = _make_speaker(queue_size=5)\n        result = _call(set_queue_position, speaker, [\"1\"])\n        assert result is True\n        speaker.play_from_queue.assert_called_once_with(index=0, start=False)\n\n    def test_boundary_last(self):\n        speaker = _make_speaker(queue_size=5)\n        result = _call(set_queue_position, speaker, [\"5\"])\n        assert result is True\n        speaker.play_from_queue.assert_called_once_with(index=4, start=False)\n\n    def test_out_of_range_low(self, capsys):\n        speaker = _make_speaker(queue_size=5)\n        result = _call(set_queue_position, speaker, [\"0\"])\n        assert result is False\n        speaker.stop.assert_not_called()\n\n    def test_out_of_range_high(self, capsys):\n        speaker = _make_speaker(queue_size=5)\n        result = _call(set_queue_position, speaker, [\"6\"])\n        assert result is False\n        speaker.stop.assert_not_called()\n\n    def test_non_integer_returns_false(self, capsys):\n        speaker = _make_speaker(queue_size=5)\n        result = _call(set_queue_position, speaker, [\"abc\"])\n        assert result is False\n        speaker.stop.assert_not_called()\n\n\n# ===========================================================================\n# surround_volume\n# ===========================================================================\n\n\nclass TestSurroundVolume:\n    def test_no_surround_returns_false(self, capsys):\n        speaker = _make_speaker(player_name=\"Kitchen\")\n        speaker.sub_gain = None\n        result = surround_volume(speaker, \"surround_volume\", [], \"sub_gain\", False)\n        assert result is False\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_get_gain(self, capsys):\n        speaker = _make_speaker()\n        speaker.sub_gain = 5\n        result = surround_volume(speaker, \"surround_volume\", [], \"sub_gain\", False)\n        assert result is True\n        assert \"5\" in capsys.readouterr().out\n\n    def test_set_gain(self):\n        speaker = _make_speaker()\n        speaker.sub_gain = 0\n        result = surround_volume(speaker, \"surround_volume\", [\"10\"], \"sub_gain\", False)\n        assert result is True\n        assert speaker.sub_gain == 10\n\n    def test_set_gain_boundary(self):\n        for v in [-15, 0, 15]:\n            speaker = _make_speaker()\n            speaker.sub_gain = 0\n            result = surround_volume(\n                speaker, \"surround_volume\", [str(v)], \"sub_gain\", False\n            )\n            assert result is True\n\n    def test_set_gain_out_of_range(self, capsys):\n        for v in [\"-16\", \"16\"]:\n            speaker = _make_speaker()\n            speaker.sub_gain = 0\n            result = surround_volume(speaker, \"surround_volume\", [v], \"sub_gain\", False)\n            assert result is False\n\n    def test_set_gain_invalid(self, capsys):\n        speaker = _make_speaker()\n        speaker.sub_gain = 0\n        result = surround_volume(speaker, \"surround_volume\", [\"abc\"], \"sub_gain\", False)\n        assert result is False\n\n\n# ===========================================================================\n# play_uri\n# ===========================================================================\n\n\nclass TestPlayUri:\n    def test_success_on_first_attempt(self):\n        speaker = _make_speaker()\n        result = _call(play_uri, speaker, [\"http://stream.example.com/radio\"])\n        assert result is True\n        speaker.play_uri.assert_called_once_with(\n            \"http://stream.example.com/radio\", title=\"\", force_radio=False\n        )\n\n    def test_falls_back_to_force_radio(self):\n        speaker = _make_speaker()\n        speaker.play_uri.side_effect = [Exception(\"fail\"), None]\n        result = _call(play_uri, speaker, [\"http://stream.example.com/radio\"])\n        assert result is True\n        assert speaker.play_uri.call_count == 2\n        assert speaker.play_uri.call_args_list[1][1][\"force_radio\"] is True\n\n    def test_both_attempts_fail_returns_false(self, capsys):\n        speaker = _make_speaker()\n        speaker.play_uri.side_effect = Exception(\"fail\")\n        result = _call(play_uri, speaker, [\"http://bad.uri/\"])\n        assert result is False\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_passes_title(self):\n        speaker = _make_speaker()\n        _call(play_uri, speaker, [\"http://stream.example.com/\", \"My Station\"])\n        assert speaker.play_uri.call_args[1][\"title\"] == \"My Station\"\n\n    def test_title_defaults_to_empty_string(self):\n        speaker = _make_speaker()\n        _call(play_uri, speaker, [\"http://stream.example.com/\"])\n        assert speaker.play_uri.call_args[1][\"title\"] == \"\"\n\n\n# ===========================================================================\n# play_favourite_core\n# ===========================================================================\n\n\nclass TestPlayFavouriteCore:\n    def _setup_speaker(self, favs):\n        speaker = MagicMock()\n        speaker.music_library.get_sonos_favorites.return_value = favs\n        return speaker\n\n    def test_found_by_name_play_uri_succeeds(self):\n        fav = _make_favourite(\"Radio 4\")\n        speaker = self._setup_speaker([fav])\n        result, msg = play_favourite_core(speaker, \"Radio 4\")\n        assert result is True\n        assert msg == \"\"\n        speaker.play_uri.assert_called_once()\n\n    def test_found_by_fuzzy_name(self):\n        fav = _make_favourite(\"BBC Radio 4\")\n        speaker = self._setup_speaker([fav])\n        result, msg = play_favourite_core(speaker, \"Radio 4\")\n        assert result is True\n\n    def test_not_found_returns_false_with_message(self):\n        speaker = self._setup_speaker([_make_favourite(\"Radio 3\")])\n        result, msg = play_favourite_core(speaker, \"Radio 4\")\n        assert result is False\n        assert \"not found\" in msg\n\n    def test_play_uri_fails_falls_back_to_queue(self):\n        fav = _make_favourite(\"Radio 4\")\n        speaker = self._setup_speaker([fav])\n        speaker.play_uri.side_effect = Exception(\"unsupported\")\n        speaker.add_to_queue.return_value = 3\n        result, msg = play_favourite_core(speaker, \"Radio 4\")\n        assert result is True\n        speaker.add_to_queue.assert_called_once_with(fav, as_next=True)\n        speaker.play_from_queue.assert_called_once_with(3, start=True)\n\n    def test_both_strategies_fail_returns_error(self):\n        fav = _make_favourite(\"Radio 4\")\n        speaker = self._setup_speaker([fav])\n        speaker.play_uri.side_effect = Exception(\"e1\")\n        speaker.add_to_queue.side_effect = Exception(\"e2\")\n        result, msg = play_favourite_core(speaker, \"Radio 4\")\n        assert result is False\n        assert \"e1\" in msg\n        assert \"e2\" in msg\n\n    def test_by_number_valid(self):\n        favs = [\n            _make_favourite(\"Alpha\"),\n            _make_favourite(\"Beta\"),\n            _make_favourite(\"Gamma\"),\n        ]\n        speaker = self._setup_speaker(favs)\n        # Sorted by title: Alpha, Beta, Gamma → number 2 = Beta\n        result, msg = play_favourite_core(speaker, \"\", favourite_number=\"2\")\n        assert result is True\n        # Verify Beta's URI was used\n        assert speaker.play_uri.call_args[1][\"uri\"] == \"http://example.com/stream\"\n\n    def test_by_number_out_of_range(self):\n        favs = [_make_favourite(\"Only\")]\n        speaker = self._setup_speaker(favs)\n        result, msg = play_favourite_core(speaker, \"\", favourite_number=\"5\")\n        assert result is False\n        assert \"1 and 1\" in msg\n\n    def test_by_number_zero_is_out_of_range(self):\n        favs = [_make_favourite(\"Only\")]\n        speaker = self._setup_speaker(favs)\n        result, msg = play_favourite_core(speaker, \"\", favourite_number=\"0\")\n        assert result is False\n\n    def test_by_number_non_integer(self):\n        favs = [_make_favourite(\"Only\")]\n        speaker = self._setup_speaker(favs)\n        result, msg = play_favourite_core(speaker, \"\", favourite_number=\"abc\")\n        assert result is False\n\n    def test_by_number_sorted_by_title(self):\n        favs = [\n            _make_favourite(\"Zebra\"),\n            _make_favourite(\"Apple\"),\n            _make_favourite(\"Mango\"),\n        ]\n        speaker = self._setup_speaker(favs)\n        play_favourite_core(speaker, \"\", favourite_number=\"1\")\n        # Number 1 sorted = Apple; verify Apple's URI was used\n        assert speaker.play_uri.called\n        used_uri = speaker.play_uri.call_args[1][\"uri\"]\n        assert used_uri == \"http://example.com/stream\"  # all share the same mock URI\n\n\n# ===========================================================================\n# add_favourite_to_queue\n# ===========================================================================\n\n\nclass TestAddFavouriteToQueue:\n    def _setup(self, fav_titles, queue_size=5):\n        speaker = MagicMock()\n        speaker.queue_size = queue_size\n        favs = [_make_favourite(t) for t in fav_titles]\n        speaker.music_library.get_sonos_favorites.return_value = favs\n        return speaker\n\n    def test_found_appends_to_end(self, capsys):\n        speaker = self._setup([\"Radio 4\"], queue_size=5)\n        with patch(\"soco_cli.action_processor.save_queue_insertion_position\"):\n            result = add_favourite_to_queue(\n                speaker, \"add_favourite_to_queue\", [\"Radio 4\"], \"\", False\n            )\n        assert result is True\n        speaker.add_to_queue.assert_called_once()\n        assert \"6\" in capsys.readouterr().out  # queue_size + 1\n\n    def test_found_with_position(self):\n        speaker = self._setup([\"Radio 4\"])\n        with patch(\n            \"soco_cli.action_processor.get_queue_insertion_position\", return_value=3\n        ):\n            with patch(\"soco_cli.action_processor.save_queue_insertion_position\"):\n                result = add_favourite_to_queue(\n                    speaker, \"add_favourite_to_queue\", [\"Radio 4\", \"3\"], \"\", False\n                )\n        assert result is True\n        call_args = speaker.add_to_queue.call_args\n        assert call_args[1][\"position\"] == 3\n\n    def test_not_found_returns_false(self, capsys):\n        speaker = self._setup([\"Radio 3\"])\n        result = add_favourite_to_queue(\n            speaker, \"add_favourite_to_queue\", [\"Radio 4\"], \"\", False\n        )\n        assert result is False\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_add_to_queue_exception_returns_false(self, capsys):\n        speaker = self._setup([\"Radio 4\"])\n        speaker.add_to_queue.side_effect = Exception(\"UPnP error\")\n        with patch(\"soco_cli.action_processor.save_queue_insertion_position\"):\n            result = add_favourite_to_queue(\n                speaker, \"add_favourite_to_queue\", [\"Radio 4\"], \"\", False\n            )\n        assert result is False\n        assert \"Error\" in capsys.readouterr().err\n\n\n# ===========================================================================\n# list_queue\n# ===========================================================================\n\n\nclass TestListQueue:\n    def test_empty_queue_returns_true(self):\n        speaker = _make_speaker()\n        speaker.get_queue.return_value = []\n        result = list_queue(speaker, \"list_queue\", [], \"\", False)\n        assert result is True\n\n    def test_full_queue_calls_print_tracks(self):\n        speaker = _make_speaker()\n        tracks = [_make_track(f\"T{i}\") for i in range(3)]\n        speaker.get_queue.return_value = tracks\n        with patch(\"soco_cli.action_processor.print_tracks\") as mock_print:\n            list_queue(speaker, \"list_queue\", [], \"\", False)\n        mock_print.assert_called_once()\n\n    def test_single_track_by_number(self):\n        speaker = _make_speaker()\n        tracks = [_make_track(f\"T{i}\") for i in range(5)]\n        speaker.get_queue.return_value = tracks\n        with patch(\"soco_cli.action_processor.print_tracks\") as mock_print:\n            result = list_queue(speaker, \"list_queue\", [\"3\"], \"\", False)\n        assert result is True\n        # print_tracks called with the single-track slice\n        called_tracks = mock_print.call_args[0][0]\n        assert len(called_tracks) == 1\n\n    def test_track_number_out_of_range(self, capsys):\n        speaker = _make_speaker()\n        speaker.get_queue.return_value = [_make_track(\"T\")]\n        result = list_queue(speaker, \"list_queue\", [\"5\"], \"\", False)\n        assert result is False\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_track_number_zero_out_of_range(self, capsys):\n        speaker = _make_speaker()\n        speaker.get_queue.return_value = [_make_track(\"T\")]\n        result = list_queue(speaker, \"list_queue\", [\"0\"], \"\", False)\n        assert result is False\n\n    def test_non_integer_track_number(self, capsys):\n        speaker = _make_speaker()\n        speaker.get_queue.return_value = [_make_track(\"T\")]\n        result = list_queue(speaker, \"list_queue\", [\"abc\"], \"\", False)\n        assert result is False\n\n\n# ===========================================================================\n# process_action\n# ===========================================================================\n\n\nclass TestProcessAction:\n    def test_unknown_action_returns_false(self):\n        speaker = _make_speaker()\n        result = process_action(speaker, \"no_such_action\", [])\n        assert result is False\n\n    def test_known_action_is_dispatched(self):\n        speaker = _make_speaker()\n        mock_fn = MagicMock(return_value=True)\n        fake_actions = {\n            \"test_action\": SonosFunction(mock_fn, \"some_fn\", False),\n        }\n        with patch(\"soco_cli.action_processor.actions\", fake_actions):\n            result = process_action(speaker, \"test_action\", [\"arg1\"])\n        assert result is True\n        mock_fn.assert_called_once_with(\n            speaker, \"test_action\", [\"arg1\"], \"some_fn\", False\n        )\n\n    def test_switch_to_coordinator_when_not_coordinator(self):\n        speaker = MagicMock()\n        speaker.is_coordinator = False\n        coordinator = MagicMock()\n        speaker.group.coordinator = coordinator\n        mock_fn = MagicMock(return_value=True)\n        fake_actions = {\n            \"coord_action\": SonosFunction(mock_fn, \"\", True),\n        }\n        with patch(\"soco_cli.action_processor.actions\", fake_actions):\n            process_action(speaker, \"coord_action\", [])\n        # Function should have been called with the coordinator, not the original speaker\n        called_speaker = mock_fn.call_args[0][0]\n        assert called_speaker is coordinator\n\n    def test_switch_to_coordinator_when_already_coordinator(self):\n        speaker = MagicMock()\n        speaker.is_coordinator = True\n        mock_fn = MagicMock(return_value=True)\n        fake_actions = {\n            \"coord_action\": SonosFunction(mock_fn, \"\", True),\n        }\n        with patch(\"soco_cli.action_processor.actions\", fake_actions):\n            process_action(speaker, \"coord_action\", [])\n        called_speaker = mock_fn.call_args[0][0]\n        assert called_speaker is speaker\n"
  },
  {
    "path": "tests/test_aliases.py",
    "content": "\"\"\"Tests for aliases.py — AliasManager.\"\"\"\n\nimport os\nimport tempfile\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom soco_cli.aliases import AliasManager\n\n# ---------------------------------------------------------------------------\n# create_alias / action / remove_alias / alias_names\n# ---------------------------------------------------------------------------\n\n\nclass TestCreateAndRetrieve:\n    def test_create_new_alias(self):\n        am = AliasManager()\n        result, new = am.create_alias(\"vol50\", \"volume 50\")\n        assert result is True\n        assert new is True\n        assert am.action(\"vol50\") == \"volume 50\"\n\n    def test_update_existing_alias(self):\n        am = AliasManager()\n        am.create_alias(\"vol\", \"volume 50\")\n        result, new = am.create_alias(\"vol\", \"volume 75\")\n        assert result is True\n        assert new is False\n        assert am.action(\"vol\") == \"volume 75\"\n\n    def test_none_action_removes_alias(self):\n        am = AliasManager()\n        am.create_alias(\"vol\", \"volume 50\")\n        result = am.create_alias(\"vol\", None)\n        assert result is True  # remove_alias returns True\n        assert am.action(\"vol\") is None\n\n    def test_empty_string_action_removes_alias(self):\n        am = AliasManager()\n        am.create_alias(\"vol\", \"volume 50\")\n        am.create_alias(\"vol\", \"\")\n        assert am.action(\"vol\") is None\n\n    def test_alias_name_whitespace_stripped(self):\n        am = AliasManager()\n        am.create_alias(\"  vol  \", \"volume 50\")\n        assert am.action(\"vol\") == \"volume 50\"\n\n    def test_action_returns_none_for_unknown_alias(self):\n        am = AliasManager()\n        assert am.action(\"nonexistent\") is None\n\n    def test_action_value_whitespace_stripped(self):\n        am = AliasManager()\n        am.create_alias(\"vol\", \"  volume 50  \")\n        assert am.action(\"vol\") == \"volume 50\"\n\n\nclass TestRemoveAlias:\n    def test_remove_existing_alias(self):\n        am = AliasManager()\n        am.create_alias(\"vol\", \"volume 50\")\n        result = am.remove_alias(\"vol\")\n        assert result is True\n        assert am.action(\"vol\") is None\n\n    def test_remove_nonexistent_alias_returns_false(self):\n        am = AliasManager()\n        result = am.remove_alias(\"nonexistent\")\n        assert result is False\n\n    def test_remove_name_whitespace_stripped(self):\n        am = AliasManager()\n        am.create_alias(\"vol\", \"volume 50\")\n        result = am.remove_alias(\"  vol  \")\n        assert result is True\n\n\nclass TestAliasNames:\n    def test_empty_manager_returns_empty_list(self):\n        am = AliasManager()\n        assert am.alias_names() == []\n\n    def test_returns_all_alias_names(self):\n        am = AliasManager()\n        am.create_alias(\"a\", \"action_a\")\n        am.create_alias(\"b\", \"action_b\")\n        names = am.alias_names()\n        assert set(names) == {\"a\", \"b\"}\n\n    def test_removed_alias_not_in_names(self):\n        am = AliasManager()\n        am.create_alias(\"vol\", \"volume 50\")\n        am.remove_alias(\"vol\")\n        assert \"vol\" not in am.alias_names()\n\n\n# ---------------------------------------------------------------------------\n# _aliases_to_text\n# ---------------------------------------------------------------------------\n\n\nclass TestAliasesToText:\n    def test_raw_format(self):\n        am = AliasManager()\n        am.create_alias(\"vol\", \"volume 50\")\n        text = am._aliases_to_text(raw=True)\n        assert \"vol\" in text\n        assert \"volume 50\" in text\n        assert \"=\" in text\n\n    def test_pretty_format_indented(self):\n        am = AliasManager()\n        am.create_alias(\"vol\", \"volume 50\")\n        text = am._aliases_to_text(raw=False)\n        # Pretty format has leading spaces and padding\n        assert text.startswith(\"  \")\n\n    def test_multiple_aliases_sorted(self):\n        am = AliasManager()\n        am.create_alias(\"z_alias\", \"z action\")\n        am.create_alias(\"a_alias\", \"a action\")\n        text = am._aliases_to_text(raw=True)\n        assert text.index(\"a_alias\") < text.index(\"z_alias\")\n\n\n# ---------------------------------------------------------------------------\n# save_aliases_to_file / load_aliases_from_file\n# ---------------------------------------------------------------------------\n\n\nclass TestFileRoundTrip:\n    def test_save_and_load_round_trip(self):\n        am = AliasManager()\n        am.create_alias(\"vol50\", \"volume 50\")\n        am.create_alias(\"playjazz\", \"play_favourite Jazz\")\n\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".txt\", delete=False) as f:\n            tmpfile = f.name\n        try:\n            am.save_aliases_to_file(tmpfile)\n\n            am2 = AliasManager()\n            with patch.object(am2, \"save_aliases\"):  # don't touch ~/.soco-cli\n                result = am2.load_aliases_from_file(tmpfile)\n\n            assert result is True\n            assert am2.action(\"vol50\") == \"volume 50\"\n            assert am2.action(\"playjazz\") == \"play_favourite Jazz\"\n        finally:\n            os.unlink(tmpfile)\n\n    def test_saved_file_has_header_comment(self):\n        am = AliasManager()\n        am.create_alias(\"vol\", \"volume 50\")\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".txt\", delete=False) as f:\n            tmpfile = f.name\n        try:\n            am.save_aliases_to_file(tmpfile)\n            with open(tmpfile) as f:\n                first_line = f.readline()\n            assert first_line.startswith(\"#\")\n        finally:\n            os.unlink(tmpfile)\n\n    def test_save_to_nonexistent_path_returns_false(self):\n        am = AliasManager()\n        am.create_alias(\"vol\", \"volume 50\")\n        result = am.save_aliases_to_file(\"/nonexistent_dir/aliases.txt\")\n        assert result is False\n\n    def test_load_comment_lines_ignored(self):\n        content = \"# This is a comment\\nvol = volume 50\\n\"\n        with tempfile.NamedTemporaryFile(\n            mode=\"w\", suffix=\".txt\", delete=False, delete_on_close=False\n        ) as f:\n            f.write(content)\n            tmpfile = f.name\n        try:\n            am = AliasManager()\n            with patch.object(am, \"save_aliases\"):\n                am.load_aliases_from_file(tmpfile)\n            assert am.action(\"vol\") == \"volume 50\"\n        finally:\n            os.unlink(tmpfile)\n\n    def test_load_malformed_line_skipped(self, capsys):\n        # Line without exactly one '=' is malformed and skipped\n        content = \"malformed_no_equals\\nvol = volume 50\\n\"\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".txt\", delete=False) as f:\n            f.write(content)\n            tmpfile = f.name\n        try:\n            am = AliasManager()\n            with patch.object(am, \"save_aliases\"):\n                am.load_aliases_from_file(tmpfile)\n            assert am.action(\"vol\") == \"volume 50\"\n            assert \"Malformed\" in capsys.readouterr().out\n        finally:\n            os.unlink(tmpfile)\n\n    def test_load_from_nonexistent_file_returns_false(self):\n        am = AliasManager()\n        result = am.load_aliases_from_file(\"/nonexistent_path/aliases.txt\")\n        assert result is False\n\n    def test_load_blank_lines_skipped(self):\n        content = \"\\nvol = volume 50\\n\\n\"\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".txt\", delete=False) as f:\n            f.write(content)\n            tmpfile = f.name\n        try:\n            am = AliasManager()\n            with patch.object(am, \"save_aliases\"):\n                am.load_aliases_from_file(tmpfile)\n            assert am.action(\"vol\") == \"volume 50\"\n        finally:\n            os.unlink(tmpfile)\n\n\n# ---------------------------------------------------------------------------\n# print_aliases\n# ---------------------------------------------------------------------------\n\n\nclass TestPrintAliases:\n    def test_empty_manager_prints_message(self, capsys):\n        am = AliasManager()\n        am.print_aliases()\n        assert \"No current aliases\" in capsys.readouterr().out\n\n    def test_aliases_printed(self, capsys):\n        am = AliasManager()\n        am.create_alias(\"vol\", \"volume 50\")\n        am.print_aliases()\n        out = capsys.readouterr().out\n        assert \"vol\" in out\n        assert \"volume 50\" in out\n"
  },
  {
    "path": "tests/test_check_for_update.py",
    "content": "\"\"\"Tests for check_for_update.py.\"\"\"\n\nfrom io import BytesIO\nfrom unittest.mock import patch\n\nimport pytest\n\nimport soco_cli.utils as utils\nfrom soco_cli.check_for_update import (\n    get_latest_version,\n    print_update_status,\n    update_available,\n)\n\n\n@pytest.fixture(autouse=True)\ndef api_mode():\n    original = utils.API\n    utils.API = True\n    yield\n    utils.API = original\n\n\ndef _fake_urlopen(lines):\n    \"\"\"Return a mock file-like object yielding the given byte lines.\"\"\"\n    return BytesIO(b\"\\n\".join(line.encode() for line in lines))\n\n\nclass TestGetLatestVersion:\n    def test_successful_fetch_returns_version(self):\n        content = _fake_urlopen(['__version__ = \"1.2.3\"'])\n        with patch(\"soco_cli.check_for_update.urlopen\", return_value=content):\n            result = get_latest_version()\n        assert result == \"1.2.3\"\n\n    def test_version_string_without_spaces(self):\n        content = _fake_urlopen(['__version__ = \"0.4.86\"'])\n        with patch(\"soco_cli.check_for_update.urlopen\", return_value=content):\n            result = get_latest_version()\n        assert result == \"0.4.86\"\n\n    def test_version_line_with_trailing_newline_stripped(self):\n        content = BytesIO(b'__version__ = \"1.0.0\"\\n')\n        with patch(\"soco_cli.check_for_update.urlopen\", return_value=content):\n            result = get_latest_version()\n        assert result == \"1.0.0\"\n\n    def test_no_version_line_returns_none(self):\n        content = _fake_urlopen([\"# no version here\", \"some_other = 42\"])\n        with patch(\"soco_cli.check_for_update.urlopen\", return_value=content):\n            result = get_latest_version()\n        assert result is None\n\n    def test_network_error_returns_none(self, capsys):\n        with patch(\n            \"soco_cli.check_for_update.urlopen\", side_effect=Exception(\"timeout\")\n        ):\n            result = get_latest_version()\n        assert result is None\n        assert \"Error\" in capsys.readouterr().err\n\n\nclass TestPrintUpdateStatus:\n    def test_up_to_date(self, capsys):\n        import soco_cli.check_for_update as m\n\n        with patch.object(m, \"__version__\", \"1.0.0\"):\n            with patch(\n                \"soco_cli.check_for_update.get_latest_version\", return_value=\"1.0.0\"\n            ):\n                result = print_update_status()\n        assert result is True\n        assert \"up to date\" in capsys.readouterr().out\n\n    def test_update_available(self, capsys):\n        import soco_cli.check_for_update as m\n\n        with patch.object(m, \"__version__\", \"1.0.0\"):\n            with patch(\n                \"soco_cli.check_for_update.get_latest_version\", return_value=\"1.1.0\"\n            ):\n                result = print_update_status()\n        assert result is True\n        assert \"1.1.0\" in capsys.readouterr().out\n\n    def test_network_failure_returns_false(self):\n        with patch(\"soco_cli.check_for_update.get_latest_version\", return_value=None):\n            result = print_update_status()\n        assert result is False\n\n\nclass TestUpdateAvailable:\n    def test_same_version_returns_false(self):\n        import soco_cli.check_for_update as m\n\n        with patch.object(m, \"__version__\", \"1.0.0\"):\n            with patch(\n                \"soco_cli.check_for_update.get_latest_version\", return_value=\"1.0.0\"\n            ):\n                assert update_available() is False\n\n    def test_different_version_returns_true(self):\n        import soco_cli.check_for_update as m\n\n        with patch.object(m, \"__version__\", \"1.0.0\"):\n            with patch(\n                \"soco_cli.check_for_update.get_latest_version\", return_value=\"1.1.0\"\n            ):\n                assert update_available() is True\n\n    def test_none_version_returns_true(self):\n        # get_latest_version() returns None on network error; None != any version\n        import soco_cli.check_for_update as m\n\n        with patch.object(m, \"__version__\", \"1.0.0\"):\n            with patch(\n                \"soco_cli.check_for_update.get_latest_version\", return_value=None\n            ):\n                assert update_available() is True\n"
  },
  {
    "path": "tests/test_cli.py",
    "content": "import unittest\n\nimport soco  # type: ignore\n\nfrom soco_cli import action_processor as ap\nfrom soco_cli.api import run_command\n\nspeaker_1 = soco.SoCo(\"192.168.0.42\")\nspeaker_2 = soco.SoCo(\"192.168.0.39\")\n\ntests = [\n    [speaker_1, \"volume\", [\"25\"], \"\"],\n    [speaker_1, \"volume\", [], \"25\\n\"],\n    [speaker_1, \"mute\", [\"on\"], \"\"],\n    [speaker_1, \"mute\", [], \"on\\n\"],\n    [speaker_1, \"mute\", [\"off\"], \"\"],\n    [speaker_1, \"mute\", [], \"off\\n\"],\n    [speaker_1, \"bass\", [\"0\"], \"\"],\n    [speaker_1, \"bass\", [], \"0\\n\"],\n    [speaker_1, \"loudness\", [\"off\"], \"\"],\n    [speaker_1, \"loudness\", [], \"off\\n\"],\n    [speaker_1, \"loudness\", [\"on\"], \"\"],\n    [speaker_1, \"loudness\", [], \"on\\n\"],\n]\n\n\ndef test_cli(capsys):\n    for test in tests:\n        ap.process_action(test[0], test[1], test[2], use_local_speaker_list=True)\n        out, err = capsys.readouterr()\n        assert out == test[3]\n\n\ndef test_api():\n    for test in tests:\n        exit_code, output, error_msg = run_command(\n            test[0], test[1], *test[2], use_local_speaker_list=True\n        )\n        assert output == test[3].rstrip()\n\n\n# class TestVolEQ(unittest.TestCase):\n#     def test_volume(self, capsys):\n#         sys.argv = [\"-l\", \"stu\", \"track\"]\n#         sonos.main()\n#         captured = capsys\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "tests/test_cmd_parser.py",
    "content": "\"\"\"Tests for cmd_parser.py.\"\"\"\n\nfrom soco_cli.cmd_parser import CLIParser\n\n\nclass TestCLIParser:\n    # --- basic parsing ---\n\n    def test_empty_args_produces_no_sequences(self):\n        p = CLIParser()\n        p.parse([])\n        assert p.get_sequences() == []\n\n    def test_single_token(self):\n        p = CLIParser()\n        p.parse([\"play\"])\n        assert p.get_sequences() == [[\"play\"]]\n\n    def test_single_sequence_multiple_tokens(self):\n        p = CLIParser()\n        p.parse([\"volume\", \"50\"])\n        assert p.get_sequences() == [[\"volume\", \"50\"]]\n\n    # --- colon separation ---\n\n    def test_two_sequences_separated_by_colon(self):\n        p = CLIParser()\n        p.parse([\"play\", \":\", \"volume\", \"50\"])\n        assert p.get_sequences() == [[\"play\"], [\"volume\", \"50\"]]\n\n    def test_three_sequences(self):\n        p = CLIParser()\n        p.parse([\"play\", \":\", \"volume\", \"50\", \":\", \"mute\", \"off\"])\n        assert p.get_sequences() == [[\"play\"], [\"volume\", \"50\"], [\"mute\", \"off\"]]\n\n    def test_ordering_preserved(self):\n        p = CLIParser()\n        p.parse([\"a\", \"b\", \"c\", \":\", \"d\", \"e\"])\n        assert p.get_sequences() == [[\"a\", \"b\", \"c\"], [\"d\", \"e\"]]\n\n    # --- edge cases ---\n\n    def test_colon_at_start_produces_empty_first_sequence(self):\n        p = CLIParser()\n        p.parse([\":\", \"play\"])\n        assert p.get_sequences() == [[], [\"play\"]]\n\n    def test_colon_at_end_has_no_trailing_sequence(self):\n        # Trailing colon: the empty final sequence is not appended\n        # because the code only appends when 'if sequence' is True.\n        p = CLIParser()\n        p.parse([\"play\", \":\"])\n        assert p.get_sequences() == [[\"play\"]]\n\n    def test_consecutive_colons_produce_empty_middle_sequence(self):\n        p = CLIParser()\n        p.parse([\"play\", \":\", \":\", \"pause\"])\n        assert p.get_sequences() == [[\"play\"], [], [\"pause\"]]\n\n    def test_colon_only_produces_one_empty_sequence(self):\n        p = CLIParser()\n        p.parse([\":\"])\n        assert p.get_sequences() == [[]]\n\n    def test_parse_can_be_called_multiple_times(self):\n        p = CLIParser()\n        p.parse([\"play\"])\n        p.parse([\"volume\", \"50\"])\n        assert p.get_sequences() == [[\"volume\", \"50\"]]\n"
  },
  {
    "path": "tests/test_comprehensive.py",
    "content": "\"\"\"Comprehensive unit tests for SoCo-CLI utilities.\n\nTests cover all pure-Python logic that does not require a live Sonos network:\ntime conversion, name matching, the RewindableList sequence, CLIParser, the\nparameter-count decorators, AliasManager, action registry helpers, the\ninterrupt-flag mechanism, wait-action dispatch, and more.\n\"\"\"\n\nimport datetime\nimport types\nfrom unittest.mock import MagicMock, call, patch\n\nimport pytest\nfrom soco.exceptions import SoCoUPnPException\n\nimport soco_cli.utils as utils\nfrom soco_cli.action_processor import (\n    SonosFunction,\n    add_sharelink_to_queue,\n    get_actions,\n    play_sharelink,\n)\nfrom soco_cli.aliases import AliasManager\nfrom soco_cli.cmd_parser import CLIParser\nfrom soco_cli.match_speaker_names import speaker_name_matches\nfrom soco_cli.utils import (\n    RewindableList,\n    check_args,\n    convert_to_seconds,\n    convert_true_false,\n    create_list_of_items_from_range,\n    create_time_from_str,\n    get_ctrl_c_interrupted,\n    playback_state,\n    pretty_print_values,\n    set_ctrl_c_interrupted,\n    set_suspend_sighandling,\n)\nfrom soco_cli.wait_actions import process_wait\n\n# ---------------------------------------------------------------------------\n# Fixture: run every test in API mode so error_report() never calls os._exit\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture(autouse=True)\ndef api_mode():\n    \"\"\"Prevent os._exit() calls by enabling API mode for the duration of each test.\"\"\"\n    original = utils.API\n    utils.API = True\n    yield\n    utils.API = original\n\n\n# ===========================================================================\n# convert_to_seconds\n# ===========================================================================\n\n\nclass TestConvertToSeconds:\n    def test_hh_mm_ss(self):\n        assert convert_to_seconds(\"00:01:01\") == 61\n        assert convert_to_seconds(\"01:00:00\") == 3600\n        assert convert_to_seconds(\"00:00:00\") == 0\n        assert convert_to_seconds(\"00:61:65\") == (61 * 60) + 65\n\n    def test_hh_mm(self):\n        assert convert_to_seconds(\"01:30\") == 90 * 60\n        assert convert_to_seconds(\"00:00\") == 0\n        assert convert_to_seconds(\"02:00\") == 7200\n\n    def test_seconds_suffix(self):\n        assert convert_to_seconds(\"12s\") == 12\n        assert convert_to_seconds(\"0s\") == 0\n        assert convert_to_seconds(\"1.5s\") == 1.5\n\n    def test_minutes_suffix(self):\n        assert convert_to_seconds(\"3m\") == 3 * 60\n        assert convert_to_seconds(\"0.5m\") == 30\n\n    def test_hours_suffix(self):\n        assert convert_to_seconds(\"2h\") == 2 * 3600\n        assert convert_to_seconds(\"0.5h\") == 1800\n\n    def test_plain_number_defaults_to_seconds(self):\n        assert convert_to_seconds(\"10\") == 10\n        assert convert_to_seconds(\"0\") == 0\n\n    def test_uppercase_suffix_accepted(self):\n        # lower() is applied before suffix checks\n        assert convert_to_seconds(\"5S\") == 5\n        assert convert_to_seconds(\"2M\") == 120\n        assert convert_to_seconds(\"1H\") == 3600\n\n    def test_invalid_raises_value_error(self):\n        with pytest.raises(ValueError):\n            convert_to_seconds(\"\")\n        with pytest.raises(ValueError):\n            convert_to_seconds(\"abc\")\n        with pytest.raises(ValueError):\n            convert_to_seconds(\"1x\")\n\n\n# ===========================================================================\n# create_time_from_str\n# ===========================================================================\n\n\nclass TestCreateTimeFromStr:\n    def test_hh_mm(self):\n        t = create_time_from_str(\"09:30\")\n        assert t == datetime.time(9, 30, 0)\n\n    def test_hh_mm_ss(self):\n        t = create_time_from_str(\"23:59:59\")\n        assert t == datetime.time(23, 59, 59)\n\n    def test_midnight(self):\n        assert create_time_from_str(\"00:00:00\") == datetime.time(0, 0, 0)\n        assert create_time_from_str(\"00:00\") == datetime.time(0, 0, 0)\n\n    def test_no_colon_raises(self):\n        with pytest.raises(ValueError):\n            create_time_from_str(\"1200\")\n\n    def test_out_of_range_hour_raises(self):\n        with pytest.raises(ValueError):\n            create_time_from_str(\"24:00:00\")\n\n    def test_out_of_range_minute_raises(self):\n        with pytest.raises(ValueError):\n            create_time_from_str(\"12:60:00\")\n\n    def test_out_of_range_second_raises(self):\n        with pytest.raises(ValueError):\n            create_time_from_str(\"12:00:60\")\n\n    def test_too_many_parts_raises(self):\n        with pytest.raises(ValueError):\n            create_time_from_str(\"01:02:03:04\")\n\n    def test_too_few_parts_raises(self):\n        with pytest.raises(ValueError):\n            create_time_from_str(\"12\")\n\n\n# ===========================================================================\n# convert_true_false\n# ===========================================================================\n\n\nclass TestConvertTrueFalse:\n    def test_yes_or_no_true(self):\n        assert convert_true_false(True) == \"Yes\"\n\n    def test_yes_or_no_false(self):\n        assert convert_true_false(False) == \"No\"\n\n    def test_on_or_off_true(self):\n        assert convert_true_false(True, \"onoroff\") == \"on\"\n\n    def test_on_or_off_false(self):\n        assert convert_true_false(False, \"onoroff\") == \"off\"\n\n    def test_unknown_conversion_returns_none(self):\n        assert convert_true_false(True, \"unknown_mode\") is None\n        assert convert_true_false(False, \"unknown_mode\") is None\n\n\n# ===========================================================================\n# playback_state\n# ===========================================================================\n\n\nclass TestPlaybackState:\n    def test_stopped(self):\n        assert playback_state(\"STOPPED\") == \"stopped\"\n\n    def test_paused(self):\n        assert playback_state(\"PAUSED_PLAYBACK\") == \"paused\"\n\n    def test_playing(self):\n        assert playback_state(\"PLAYING\") == \"in progress\"\n\n    def test_transitioning(self):\n        assert playback_state(\"TRANSITIONING\") == \"in a transitioning state\"\n\n    def test_unknown_state(self):\n        assert playback_state(\"SOMETHING_ELSE\") == \"unknown\"\n        assert playback_state(\"\") == \"unknown\"\n\n\n# ===========================================================================\n# create_list_of_items_from_range\n# ===========================================================================\n\n\nclass TestCreateListOfItemsFromRange:\n    def test_single_item(self):\n        assert create_list_of_items_from_range(\"3\", 10) == [3]\n\n    def test_comma_separated(self):\n        assert create_list_of_items_from_range(\"1,3,5\", 10) == [1, 3, 5]\n\n    def test_simple_range(self):\n        assert create_list_of_items_from_range(\"2-5\", 10) == [2, 3, 4, 5]\n\n    def test_all_keyword(self):\n        assert create_list_of_items_from_range(\"all\", 5) == [1, 2, 3, 4, 5]\n\n    def test_all_keyword_case_insensitive(self):\n        assert create_list_of_items_from_range(\"ALL\", 3) == [1, 2, 3]\n\n    def test_mixed_range_and_singles(self):\n        result = create_list_of_items_from_range(\"1,3-5,7\", 10)\n        assert result == [1, 3, 4, 5, 7]\n\n    def test_reversed_range_is_sorted(self):\n        assert create_list_of_items_from_range(\"5-2\", 10) == [2, 3, 4, 5]\n\n    def test_duplicates_deduplicated(self):\n        assert create_list_of_items_from_range(\"1,1,2\", 5) == [1, 2]\n\n    def test_result_is_sorted(self):\n        result = create_list_of_items_from_range(\"5,3,1\", 10)\n        assert result == sorted(result)\n\n    def test_item_out_of_range_raises(self):\n        with pytest.raises(IndexError):\n            create_list_of_items_from_range(\"11\", 10)\n\n    def test_zero_raises(self):\n        with pytest.raises(IndexError):\n            create_list_of_items_from_range(\"0\", 10)\n\n    def test_range_exceeds_limit_raises(self):\n        with pytest.raises(IndexError):\n            create_list_of_items_from_range(\"8-12\", 10)\n\n    def test_malformed_range_raises(self):\n        with pytest.raises((IndexError, ValueError)):\n            create_list_of_items_from_range(\"1-2-3\", 10)\n\n    def test_at_upper_limit(self):\n        assert create_list_of_items_from_range(\"10\", 10) == [10]\n\n\n# ===========================================================================\n# pretty_print_values\n# ===========================================================================\n\n\nclass TestPrettyPrintValues:\n    def test_basic_output(self, capsys):\n        pretty_print_values({\"Key\": \"Value\"})\n        out, _ = capsys.readouterr()\n        assert \"Key\" in out\n        assert \"Value\" in out\n\n    def test_empty_dict_produces_no_output(self, capsys):\n        pretty_print_values({})\n        out, _ = capsys.readouterr()\n        assert out == \"\"\n\n    def test_alignment(self, capsys):\n        pretty_print_values({\"Short\": \"MARKER_A\", \"LongerKey\": \"MARKER_B\"})\n        out, _ = capsys.readouterr()\n        # Don't use .strip() before splitlines — it would eat the indent on the first line\n        lines = [l for l in out.splitlines() if l]\n        assert len(lines) == 2\n        # Values should start at the same column regardless of key length\n        assert lines[0].index(\"MARKER_A\") == lines[1].index(\"MARKER_B\")\n\n    def test_custom_separator(self, capsys):\n        pretty_print_values({\"K\": \"V\"}, separator=\"=\")\n        out, _ = capsys.readouterr()\n        assert \"=\" in out\n\n    def test_sort_by_key(self, capsys):\n        pretty_print_values({\"Zebra\": \"z\", \"Apple\": \"a\"}, sort_by_key=True)\n        out, _ = capsys.readouterr()\n        lines = out.strip().splitlines()\n        assert \"Apple\" in lines[0]\n        assert \"Zebra\" in lines[1]\n\n\n# ===========================================================================\n# RewindableList\n# ===========================================================================\n\n\nclass TestRewindableList:\n    def test_basic_iteration(self):\n        rl = RewindableList([1, 2, 3])\n        assert list(rl) == [1, 2, 3]\n\n    def test_rewind_restarts_iteration(self):\n        rl = RewindableList([10, 20, 30])\n        first = list(rl)\n        second = list(rl)\n        assert first == second == [10, 20, 30]\n\n    def test_rewind_mid_iteration(self):\n        rl = RewindableList([1, 2, 3])\n        it = iter(rl)\n        assert next(it) == 1\n        rl.rewind()\n        assert next(it) == 1  # Restarted from beginning\n\n    def test_rewind_to_valid_index(self):\n        rl = RewindableList([10, 20, 30])\n        iter(rl)  # initialise\n        next(rl)\n        next(rl)\n        rl.rewind_to(1)\n        assert next(rl) == 20\n\n    def test_rewind_to_zero_on_empty(self):\n        rl = RewindableList([])\n        rl.rewind_to(0)  # Should not raise\n        assert rl.index() == 0\n\n    def test_rewind_to_out_of_bounds_raises(self):\n        rl = RewindableList([1, 2, 3])\n        with pytest.raises(IndexError):\n            rl.rewind_to(5)\n\n    def test_rewind_to_negative_raises(self):\n        rl = RewindableList([1, 2, 3])\n        with pytest.raises(IndexError):\n            rl.rewind_to(-1)\n\n    def test_len(self):\n        assert len(RewindableList([1, 2, 3])) == 3\n        assert len(RewindableList([])) == 0\n\n    def test_getitem(self):\n        rl = RewindableList([\"a\", \"b\", \"c\"])\n        assert rl[0] == \"a\"\n        assert rl[2] == \"c\"\n\n    def test_index_tracks_position(self):\n        rl = RewindableList([1, 2, 3])\n        it = iter(rl)\n        assert rl.index() == 0\n        next(it)\n        assert rl.index() == 1\n        next(it)\n        assert rl.index() == 2\n\n    def test_str(self):\n        rl = RewindableList([1, 2])\n        assert \"1\" in str(rl) and \"2\" in str(rl)\n\n    def test_stop_iteration(self):\n        rl = RewindableList([1])\n        it = iter(rl)\n        next(it)\n        with pytest.raises(StopIteration):\n            next(it)\n\n    def test_insert_before_current_index_adjusts_index(self):\n        rl = RewindableList([1, 2, 3])\n        it = iter(rl)\n        next(it)  # index is now 1\n        rl.insert(0, 99)  # Insert before current position\n        assert rl.index() == 2  # Index should have incremented\n        assert rl[0] == 99\n\n    def test_insert_after_current_index_does_not_adjust(self):\n        rl = RewindableList([1, 2, 3])\n        it = iter(rl)\n        next(it)  # index is now 1\n        rl.insert(2, 99)  # Insert after current position\n        assert rl.index() == 1  # Index unchanged\n\n    def test_pop_next_removes_first_item(self):\n        rl = RewindableList([10, 20, 30])\n        item = rl.pop_next()\n        assert item == 10\n        assert len(rl) == 2\n        assert rl[0] == 20\n\n    def test_pop_next_adjusts_index(self):\n        rl = RewindableList([1, 2, 3])\n        it = iter(rl)\n        next(it)  # index becomes 1\n        rl.pop_next()\n        assert rl.index() == 0  # Decremented because index was > 0\n\n\n# ===========================================================================\n# CLIParser\n# ===========================================================================\n\n\nclass TestCLIParser:\n    def test_single_sequence_no_separator(self):\n        p = CLIParser()\n        p.parse([\"speaker\", \"volume\", \"25\"])\n        assert p.get_sequences() == [[\"speaker\", \"volume\", \"25\"]]\n\n    def test_two_sequences(self):\n        p = CLIParser()\n        p.parse([\"s1\", \"play\", \":\", \"s2\", \"pause\"])\n        assert p.get_sequences() == [[\"s1\", \"play\"], [\"s2\", \"pause\"]]\n\n    def test_three_sequences(self):\n        p = CLIParser()\n        p.parse([\"a\", \":\", \"b\", \":\", \"c\"])\n        assert p.get_sequences() == [[\"a\"], [\"b\"], [\"c\"]]\n\n    def test_empty_args(self):\n        p = CLIParser()\n        p.parse([])\n        assert p.get_sequences() == []\n\n    def test_trailing_separator_creates_empty_sequence_excluded(self):\n        # A trailing ':' with nothing after it — the empty sequence is not appended\n        p = CLIParser()\n        p.parse([\"a\", \"b\", \":\"])\n        seqs = p.get_sequences()\n        assert seqs == [[\"a\", \"b\"]]\n\n    def test_single_separator_only(self):\n        p = CLIParser()\n        p.parse([\":\"])\n        # Produces one empty sequence before the colon; the trailing nothing is dropped\n        assert p.get_sequences() == [[]]\n\n    def test_colon_in_value_not_treated_as_separator(self):\n        # Only a standalone ':' token acts as a separator\n        p = CLIParser()\n        p.parse([\"speaker\", \"seek\", \"01:30:00\"])\n        assert p.get_sequences() == [[\"speaker\", \"seek\", \"01:30:00\"]]\n\n\n# ===========================================================================\n# speaker_name_matches\n# ===========================================================================\n\n\nclass TestSpeakerNameMatches:\n    def test_exact_match(self):\n        found, exact = speaker_name_matches(\"Bedroom\", \"Bedroom\")\n        assert found is True\n        assert exact is True\n\n    def test_case_insensitive_is_exact(self):\n        found, exact = speaker_name_matches(\"bedroom\", \"Bedroom\")\n        assert found is True\n        assert exact is True\n\n    def test_apostrophe_normalisation_is_exact(self):\n        # curly apostrophe in stored name, straight in supplied\n        found, exact = speaker_name_matches(\"Kids\\u2019 Room\", \"Kids\\u2019 Room\")\n        assert found is True\n        assert exact is True\n\n    def test_partial_start_of_name(self):\n        found, exact = speaker_name_matches(\"bed\", \"bedroom\")\n        assert found is True\n        assert exact is False\n\n    def test_partial_any_part_of_name(self):\n        found, exact = speaker_name_matches(\"room\", \"Bedroom\")\n        assert found is True\n        assert exact is False\n\n    def test_no_match(self):\n        found, exact = speaker_name_matches(\"Kitchen\", \"Bedroom\")\n        assert found is False\n        assert exact is False\n\n    def test_empty_string_matches_any_start(self):\n        # Empty string is a prefix of every string\n        found, exact = speaker_name_matches(\"\", \"Bedroom\")\n        assert found is True\n\n    def test_longer_name_not_partial_match(self):\n        found, _ = speaker_name_matches(\"BedroomExtra\", \"Bedroom\")\n        assert found is False\n\n\n# ===========================================================================\n# AliasManager\n# ===========================================================================\n\n\nclass TestAliasManager:\n    def test_create_new_alias(self):\n        am = AliasManager()\n        result, is_new = am.create_alias(\"sk\", \"Kitchen stop\")\n        assert result is True\n        assert is_new is True\n\n    def test_create_updates_existing_alias(self):\n        am = AliasManager()\n        am.create_alias(\"sk\", \"Kitchen stop\")\n        result, is_new = am.create_alias(\"sk\", \"Kitchen play\")\n        assert result is True\n        assert is_new is False\n\n    def test_action_returns_correct_string(self):\n        am = AliasManager()\n        am.create_alias(\"sk\", \"Kitchen stop\")\n        assert am.action(\"sk\") == \"Kitchen stop\"\n\n    def test_action_returns_none_for_missing(self):\n        am = AliasManager()\n        assert am.action(\"nonexistent\") is None\n\n    def test_create_with_none_actions_removes_alias(self):\n        am = AliasManager()\n        am.create_alias(\"sk\", \"Kitchen stop\")\n        result = am.create_alias(\"sk\", None)\n        assert result is True\n        assert am.action(\"sk\") is None\n\n    def test_create_with_empty_string_removes_alias(self):\n        am = AliasManager()\n        am.create_alias(\"sk\", \"Kitchen stop\")\n        result = am.create_alias(\"sk\", \"\")\n        assert result is True\n        assert am.action(\"sk\") is None\n\n    def test_remove_existing_alias(self):\n        am = AliasManager()\n        am.create_alias(\"sk\", \"Kitchen stop\")\n        assert am.remove_alias(\"sk\") is True\n        assert am.action(\"sk\") is None\n\n    def test_remove_nonexistent_alias(self):\n        am = AliasManager()\n        assert am.remove_alias(\"ghost\") is False\n\n    def test_alias_names_empty(self):\n        am = AliasManager()\n        assert am.alias_names() == []\n\n    def test_alias_names_lists_all(self):\n        am = AliasManager()\n        am.create_alias(\"a\", \"action a\")\n        am.create_alias(\"b\", \"action b\")\n        assert set(am.alias_names()) == {\"a\", \"b\"}\n\n    def test_alias_names_after_remove(self):\n        am = AliasManager()\n        am.create_alias(\"a\", \"action a\")\n        am.create_alias(\"b\", \"action b\")\n        am.remove_alias(\"a\")\n        assert am.alias_names() == [\"b\"]\n\n    def test_alias_name_stripped(self):\n        am = AliasManager()\n        am.create_alias(\"  sk  \", \"Kitchen stop\")\n        assert am.action(\"sk\") == \"Kitchen stop\"\n\n    def test_print_aliases_empty(self, capsys):\n        am = AliasManager()\n        am.print_aliases()\n        out, _ = capsys.readouterr()\n        assert \"No current aliases\" in out\n\n    def test_print_aliases_shows_names(self, capsys):\n        am = AliasManager()\n        am.create_alias(\"myalias\", \"Kitchen play\")\n        am.print_aliases()\n        out, _ = capsys.readouterr()\n        assert \"myalias\" in out\n        assert \"Kitchen play\" in out\n\n    def test_save_and_load_aliases_to_file(self, tmp_path):\n        am = AliasManager()\n        am.create_alias(\"sk\", \"Kitchen stop\")\n        am.create_alias(\"sp\", \"Kitchen play\")\n        filepath = str(tmp_path / \"aliases.txt\")\n        assert am.save_aliases_to_file(filepath) is True\n\n        am2 = AliasManager()\n        with patch.object(am2, \"save_aliases\"):  # Don't write pickle during test\n            assert am2.load_aliases_from_file(filepath) is True\n        assert am2.action(\"sk\") == \"Kitchen stop\"\n        assert am2.action(\"sp\") == \"Kitchen play\"\n\n    def test_load_aliases_from_file_ignores_comments(self, tmp_path):\n        filepath = tmp_path / \"aliases.txt\"\n        filepath.write_text(\"# This is a comment\\nsk = Kitchen stop\\n\")\n        am = AliasManager()\n        with patch.object(am, \"save_aliases\"):\n            am.load_aliases_from_file(str(filepath))\n        # create_alias strips the value, so leading/trailing whitespace is removed\n        assert am.action(\"sk\") == \"Kitchen stop\"\n\n    def test_load_aliases_from_nonexistent_file(self):\n        am = AliasManager()\n        assert am.load_aliases_from_file(\"/nonexistent/path/aliases.txt\") is False\n\n    def test_aliases_to_text_raw(self):\n        am = AliasManager()\n        am.create_alias(\"sk\", \"Kitchen stop\")\n        text = am._aliases_to_text(raw=True)\n        assert \"sk = Kitchen stop\" in text\n\n    def test_aliases_to_text_formatted(self):\n        am = AliasManager()\n        am.create_alias(\"sk\", \"STOP_ACTION\")\n        am.create_alias(\"longeralias\", \"PLAY_ACTION\")\n        text = am._aliases_to_text(raw=False)\n        # Find each line by its unique value — don't use .strip() which eats indent\n        sk_line = next(l for l in text.splitlines() if \"STOP_ACTION\" in l)\n        long_line = next(l for l in text.splitlines() if \"PLAY_ACTION\" in l)\n        # Shorter alias should be padded so '=' aligns with the longer alias line\n        assert sk_line.index(\"=\") == long_line.index(\"=\")\n\n\n# ===========================================================================\n# Parameter-count decorators\n# ===========================================================================\n\n\nclass TestParameterDecorators:\n    \"\"\"The decorators inspect args[2] (the parameter list) and args[1] (action name).\"\"\"\n\n    def _make_call(self, decorated_func, params):\n        \"\"\"Invoke a decorated action-style function with the given param list.\"\"\"\n        return decorated_func(None, \"test_action\", params, None, False)\n\n    def test_zero_parameters_allows_empty(self):\n        from soco_cli.utils import zero_parameters\n\n        @zero_parameters\n        def fn(speaker, action, params, soco_fn, use_local):\n            return \"ok\"\n\n        assert self._make_call(fn, []) == \"ok\"\n\n    def test_zero_parameters_rejects_one(self, capsys):\n        from soco_cli.utils import zero_parameters\n\n        @zero_parameters\n        def fn(speaker, action, params, soco_fn, use_local):\n            return \"ok\"\n\n        assert self._make_call(fn, [\"x\"]) is False\n        _, err = capsys.readouterr()\n        assert \"Error\" in err\n\n    def test_one_parameter_allows_one(self):\n        from soco_cli.utils import one_parameter\n\n        @one_parameter\n        def fn(speaker, action, params, soco_fn, use_local):\n            return \"ok\"\n\n        assert self._make_call(fn, [\"x\"]) == \"ok\"\n\n    def test_one_parameter_rejects_zero(self, capsys):\n        from soco_cli.utils import one_parameter\n\n        @one_parameter\n        def fn(speaker, action, params, soco_fn, use_local):\n            return \"ok\"\n\n        assert self._make_call(fn, []) is False\n\n    def test_one_parameter_rejects_two(self, capsys):\n        from soco_cli.utils import one_parameter\n\n        @one_parameter\n        def fn(speaker, action, params, soco_fn, use_local):\n            return \"ok\"\n\n        assert self._make_call(fn, [\"x\", \"y\"]) is False\n\n    def test_zero_or_one_parameter(self):\n        from soco_cli.utils import zero_or_one_parameter\n\n        @zero_or_one_parameter\n        def fn(speaker, action, params, soco_fn, use_local):\n            return \"ok\"\n\n        assert self._make_call(fn, []) == \"ok\"\n        assert self._make_call(fn, [\"x\"]) == \"ok\"\n        assert self._make_call(fn, [\"x\", \"y\"]) is False\n\n    def test_one_or_two_parameters(self):\n        from soco_cli.utils import one_or_two_parameters\n\n        @one_or_two_parameters\n        def fn(speaker, action, params, soco_fn, use_local):\n            return \"ok\"\n\n        assert self._make_call(fn, [\"x\"]) == \"ok\"\n        assert self._make_call(fn, [\"x\", \"y\"]) == \"ok\"\n        assert self._make_call(fn, []) is False\n        assert self._make_call(fn, [\"x\", \"y\", \"z\"]) is False\n\n    def test_two_parameters(self):\n        from soco_cli.utils import two_parameters\n\n        @two_parameters\n        def fn(speaker, action, params, soco_fn, use_local):\n            return \"ok\"\n\n        assert self._make_call(fn, [\"x\", \"y\"]) == \"ok\"\n        assert self._make_call(fn, [\"x\"]) is False\n        assert self._make_call(fn, []) is False\n        assert self._make_call(fn, [\"x\", \"y\", \"z\"]) is False\n\n    def test_zero_one_or_two_parameters(self):\n        from soco_cli.utils import zero_one_or_two_parameters\n\n        @zero_one_or_two_parameters\n        def fn(speaker, action, params, soco_fn, use_local):\n            return \"ok\"\n\n        assert self._make_call(fn, []) == \"ok\"\n        assert self._make_call(fn, [\"x\"]) == \"ok\"\n        assert self._make_call(fn, [\"x\", \"y\"]) == \"ok\"\n        assert self._make_call(fn, [\"x\", \"y\", \"z\"]) is False\n\n    def test_one_or_more_parameters(self):\n        from soco_cli.utils import one_or_more_parameters\n\n        @one_or_more_parameters\n        def fn(speaker, action, params, soco_fn, use_local):\n            return \"ok\"\n\n        assert self._make_call(fn, [\"x\"]) == \"ok\"\n        assert self._make_call(fn, [\"x\", \"y\", \"z\"]) == \"ok\"\n        assert self._make_call(fn, []) is False\n\n\n# ===========================================================================\n# ctrl-c interrupt flag\n# ===========================================================================\n\n\nclass TestCtrlCInterruptFlag:\n    def setup_method(self):\n        # Reset to known state before each test\n        set_ctrl_c_interrupted(False)\n        set_suspend_sighandling(False)\n\n    def teardown_method(self):\n        set_ctrl_c_interrupted(False)\n        set_suspend_sighandling(False)\n\n    def test_default_is_false(self):\n        assert get_ctrl_c_interrupted() is False\n\n    def test_set_true(self):\n        set_ctrl_c_interrupted(True)\n        assert get_ctrl_c_interrupted() is True\n\n    def test_set_false(self):\n        set_ctrl_c_interrupted(True)\n        set_ctrl_c_interrupted(False)\n        assert get_ctrl_c_interrupted() is False\n\n    def test_sig_handler_sets_flag_when_suspended(self):\n        import signal as signal_mod\n\n        set_suspend_sighandling(True)\n        set_ctrl_c_interrupted(False)\n        utils.sig_handler(signal_mod.SIGINT, None)\n        assert get_ctrl_c_interrupted() is True\n\n    def test_sig_handler_does_not_set_flag_for_sigterm_when_suspended(self):\n        import signal as signal_mod\n\n        set_suspend_sighandling(True)\n        set_ctrl_c_interrupted(False)\n        utils.sig_handler(signal_mod.SIGTERM, None)\n        assert get_ctrl_c_interrupted() is False\n\n\n# ===========================================================================\n# SonosFunction\n# ===========================================================================\n\n\nclass TestSonosFunction:\n    def test_properties(self):\n        fn = lambda: None\n        sf = SonosFunction(fn, \"play\", True)\n        assert sf.processing_function is fn\n        assert sf.soco_function == \"play\"\n        assert sf.switch_to_coordinator is True\n\n    def test_defaults(self):\n        fn = lambda: None\n        sf = SonosFunction(fn)\n        assert sf.soco_function is None\n        assert sf.switch_to_coordinator is False\n\n    def test_switch_to_coordinator_false(self):\n        sf = SonosFunction(lambda: None, \"volume\", False)\n        assert sf.switch_to_coordinator is False\n\n\n# ===========================================================================\n# get_actions\n# ===========================================================================\n\n\nclass TestGetActions:\n    def test_returns_list(self):\n        actions = get_actions()\n        assert isinstance(actions, list)\n        assert len(actions) > 0\n\n    def test_is_sorted(self):\n        actions = get_actions()\n        assert actions == sorted(actions)\n\n    def test_known_actions_present(self):\n        actions = get_actions()\n        for expected in (\"volume\", \"mute\", \"play\", \"pause\", \"pair\", \"unpair\"):\n            assert expected in actions\n\n    def test_satellite_actions_present(self):\n        actions = get_actions()\n        assert \"add_satellite_speakers\" in actions\n        assert \"add_satellites\" in actions\n        assert \"separate_satellite_speakers\" in actions\n        assert \"separate_satellites\" in actions\n\n    def test_loop_actions_included_by_default(self):\n        actions = get_actions(include_loop_actions=True)\n        for a in (\"loop\", \"loop_until\", \"loop_for\", \"loop_to_start\"):\n            assert a in actions\n\n    def test_loop_actions_excluded(self):\n        actions = get_actions(include_loop_actions=False)\n        for a in (\"loop\", \"loop_until\", \"loop_for\"):\n            assert a not in actions\n\n    def test_wait_actions_always_present(self):\n        # wait/wait_for/wait_until live in the main ACTIONS dict, so they are\n        # always returned regardless of the include_wait_actions flag\n        for flag in (True, False):\n            actions = get_actions(include_wait_actions=flag)\n            assert \"wait\" in actions\n\n    def test_track_follow_actions_excluded(self):\n        actions = get_actions(include_track_follow_actions=False)\n        for a in (\"track_follow\", \"tf\", \"track_follow_compact\", \"tfc\"):\n            assert a not in actions\n\n    def test_track_follow_actions_included_by_default(self):\n        actions = get_actions(include_track_follow_actions=True)\n        assert \"track_follow\" in actions\n\n\n# ===========================================================================\n# check_args\n# ===========================================================================\n\n\nclass TestCheckArgs:\n    def _args(self, min_netmask=24, timeout=1.0, threads=256):\n        return types.SimpleNamespace(\n            min_netmask=min_netmask,\n            network_discovery_timeout=timeout,\n            network_discovery_threads=threads,\n        )\n\n    def test_valid_args_returns_none(self):\n        assert check_args(self._args()) is None\n\n    def test_boundary_values_valid(self):\n        assert check_args(self._args(min_netmask=0, timeout=0.0, threads=1)) is None\n        assert (\n            check_args(self._args(min_netmask=32, timeout=60.0, threads=32000)) is None\n        )\n\n    def test_invalid_min_netmask_low(self):\n        msg = check_args(self._args(min_netmask=-1))\n        assert msg is not None\n        assert \"min_netmask\" in msg\n\n    def test_invalid_min_netmask_high(self):\n        msg = check_args(self._args(min_netmask=33))\n        assert msg is not None\n        assert \"min_netmask\" in msg\n\n    def test_invalid_timeout_low(self):\n        msg = check_args(self._args(timeout=-0.1))\n        assert msg is not None\n        assert \"network_timeout\" in msg\n\n    def test_invalid_timeout_high(self):\n        msg = check_args(self._args(timeout=60.1))\n        assert msg is not None\n        assert \"network_timeout\" in msg\n\n    def test_invalid_threads_low(self):\n        msg = check_args(self._args(threads=0))\n        assert msg is not None\n        assert \"threads\" in msg\n\n    def test_invalid_threads_high(self):\n        msg = check_args(self._args(threads=32001))\n        assert msg is not None\n        assert \"threads\" in msg\n\n    def test_multiple_invalid_args_all_reported(self):\n        msg = check_args(self._args(min_netmask=99, timeout=99.0, threads=0))\n        assert \"min_netmask\" in msg\n        assert \"network_timeout\" in msg\n        assert \"threads\" in msg\n\n\n# ===========================================================================\n# process_wait (wait_actions)\n# ===========================================================================\n\n\nclass TestProcessWait:\n    def test_wait_sleeps_for_correct_duration(self):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait\", \"10s\"])\n            mock_sleep.assert_called_once_with(10.0)\n\n    def test_wait_for_sleeps_for_correct_duration(self):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait_for\", \"2m\"])\n            mock_sleep.assert_called_once_with(120.0)\n\n    def test_wait_hh_mm_ss_format(self):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait\", \"00:01:30\"])\n            mock_sleep.assert_called_once_with(90)\n\n    def test_wait_missing_param_reports_error(self, capsys):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait\"])\n            mock_sleep.assert_not_called()\n            _, err = capsys.readouterr()\n            assert \"Error\" in err\n\n    def test_wait_until_calls_sleep(self):\n        # Mock seconds_until to return a fixed duration\n        with patch(\"soco_cli.wait_actions.seconds_until\", return_value=300):\n            with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n                process_wait([\"wait_until\", \"12:00\"])\n                mock_sleep.assert_called_once_with(300)\n\n    def test_wait_until_missing_param_reports_error(self, capsys):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait_until\"])\n            mock_sleep.assert_not_called()\n            _, err = capsys.readouterr()\n            assert \"Error\" in err\n\n    def test_wait_invalid_time_format_reports_error(self, capsys):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait\", \"notatime\"])\n            # Error is reported; sleep is still called with the fallback duration of 0\n            mock_sleep.assert_called_once_with(0)\n            _, err = capsys.readouterr()\n            assert \"Error\" in err\n\n\n# ===========================================================================\n# add_sharelink_to_queue / play_sharelink\n# ===========================================================================\n\nSPOTIFY_URI_1 = \"https://open.spotify.com/track/AAA\"\nSPOTIFY_URI_2 = \"https://open.spotify.com/album/BBB\"\nINVALID_URI = \"not_a_sharelink\"\n\n\ndef _make_speaker(queue_size=5):\n    speaker = MagicMock()\n    speaker.queue_size = queue_size\n    return speaker\n\n\ndef _make_share_link_plugin(valid_uris, add_return_values=None):\n    \"\"\"\n    Return a mock ShareLinkPlugin instance.\n    valid_uris: set of URIs that is_share_link() should accept.\n    add_return_values: list of return values for sequential add_share_link_to_queue calls.\n    \"\"\"\n    plugin = MagicMock()\n    plugin.is_share_link.side_effect = lambda uri: uri in valid_uris\n    if add_return_values is not None:\n        plugin.add_share_link_to_queue.side_effect = add_return_values\n    return plugin\n\n\nclass TestAddSharelinkToQueue:\n    def _call(self, speaker, args):\n        return add_sharelink_to_queue(\n            speaker, \"add_sharelink_to_queue\", args, None, False\n        )\n\n    def test_single_uri_appends_and_prints_position(self, capsys):\n        speaker = _make_speaker(queue_size=4)\n        plugin = _make_share_link_plugin({SPOTIFY_URI_1}, add_return_values=[5])\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            with patch(\n                \"soco_cli.action_processor.save_queue_insertion_position\"\n            ) as mock_save:\n                result = self._call(speaker, [SPOTIFY_URI_1])\n        assert result is True\n        plugin.add_share_link_to_queue.assert_called_once_with(SPOTIFY_URI_1, 5)\n        mock_save.assert_called_once_with(5)\n        assert capsys.readouterr().out.strip() == \"5\"\n\n    def test_single_uri_with_position(self):\n        speaker = _make_speaker(queue_size=10)\n        plugin = _make_share_link_plugin({SPOTIFY_URI_1}, add_return_values=[3])\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            with patch(\n                \"soco_cli.action_processor.get_queue_insertion_position\", return_value=3\n            ) as mock_pos:\n                with patch(\"soco_cli.action_processor.save_queue_insertion_position\"):\n                    result = self._call(speaker, [SPOTIFY_URI_1, \"3\"])\n        assert result is True\n        mock_pos.assert_called_once()\n        plugin.add_share_link_to_queue.assert_called_once_with(SPOTIFY_URI_1, 3)\n\n    def test_multiple_uris_appended_in_order(self, capsys):\n        speaker = _make_speaker(queue_size=4)\n        plugin = _make_share_link_plugin(\n            {SPOTIFY_URI_1, SPOTIFY_URI_2}, add_return_values=[5, 8]\n        )\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            with patch(\"soco_cli.action_processor.save_queue_insertion_position\"):\n                result = self._call(speaker, [SPOTIFY_URI_1, SPOTIFY_URI_2])\n        assert result is True\n        assert plugin.add_share_link_to_queue.call_count == 2\n        # First call uses queue_size + 1; second also uses queue_size + 1 (appended)\n        first_call_pos = plugin.add_share_link_to_queue.call_args_list[0][0][1]\n        assert first_call_pos == 5\n        assert capsys.readouterr().out.strip() == \"5\"\n\n    def test_multiple_uris_with_position_first_uses_position(self):\n        speaker = _make_speaker(queue_size=10)\n        plugin = _make_share_link_plugin(\n            {SPOTIFY_URI_1, SPOTIFY_URI_2}, add_return_values=[3, 7]\n        )\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            with patch(\n                \"soco_cli.action_processor.get_queue_insertion_position\", return_value=3\n            ):\n                with patch(\"soco_cli.action_processor.save_queue_insertion_position\"):\n                    result = self._call(speaker, [SPOTIFY_URI_1, SPOTIFY_URI_2, \"3\"])\n        assert result is True\n        first_call_pos = plugin.add_share_link_to_queue.call_args_list[0][0][1]\n        second_call_pos = plugin.add_share_link_to_queue.call_args_list[1][0][1]\n        assert first_call_pos == 3\n        # Second uses queue_size + 1, not the position\n        assert second_call_pos == speaker.queue_size + 1\n\n    def test_only_first_position_is_saved(self):\n        speaker = _make_speaker(queue_size=4)\n        plugin = _make_share_link_plugin(\n            {SPOTIFY_URI_1, SPOTIFY_URI_2}, add_return_values=[5, 9]\n        )\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            with patch(\n                \"soco_cli.action_processor.save_queue_insertion_position\"\n            ) as mock_save:\n                self._call(speaker, [SPOTIFY_URI_1, SPOTIFY_URI_2])\n        mock_save.assert_called_once_with(5)\n\n    def test_invalid_single_uri_returns_false(self, capsys):\n        speaker = _make_speaker()\n        plugin = _make_share_link_plugin(set())\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            result = self._call(speaker, [INVALID_URI])\n        assert result is False\n        plugin.add_share_link_to_queue.assert_not_called()\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_invalid_uri_in_list_prevents_all_adds(self):\n        \"\"\"Validation happens before any URI is added.\"\"\"\n        speaker = _make_speaker()\n        plugin = _make_share_link_plugin({SPOTIFY_URI_1})  # SPOTIFY_URI_2 invalid\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            result = self._call(speaker, [SPOTIFY_URI_1, SPOTIFY_URI_2])\n        assert result is False\n        plugin.add_share_link_to_queue.assert_not_called()\n\n    def test_invalid_last_arg_treated_as_uri_not_position(self, capsys):\n        \"\"\"An unrecognised final arg is validated as a URI, not passed to\n        get_queue_insertion_position.\"\"\"\n        speaker = _make_speaker()\n        plugin = _make_share_link_plugin({SPOTIFY_URI_1})  # INVALID_URI not valid\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            result = self._call(speaker, [SPOTIFY_URI_1, INVALID_URI])\n        assert result is False\n        plugin.add_share_link_to_queue.assert_not_called()\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_upnp_exception_returns_false(self, capsys):\n        speaker = _make_speaker()\n        plugin = _make_share_link_plugin({SPOTIFY_URI_1})\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            with patch(\"soco_cli.action_processor.SoCoUPnPException\", Exception):\n                plugin.add_share_link_to_queue.side_effect = Exception(\"fail\")\n                result = self._call(speaker, [SPOTIFY_URI_1])\n        assert result is False\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_zero_args_rejected_by_decorator(self, capsys):\n        speaker = _make_speaker()\n        result = add_sharelink_to_queue(\n            speaker, \"add_sharelink_to_queue\", [], None, False\n        )\n        assert result is False\n\n\nclass TestPlaySharelink:\n    def _call(self, speaker, args):\n        return play_sharelink(speaker, \"play_sharelink\", args, None, False)\n\n    def test_single_uri_adds_and_plays(self):\n        speaker = _make_speaker(queue_size=4)\n        plugin = _make_share_link_plugin({SPOTIFY_URI_1}, add_return_values=[5])\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            with patch(\"soco_cli.action_processor.save_queue_insertion_position\"):\n                result = self._call(speaker, [SPOTIFY_URI_1])\n        assert result is True\n        plugin.add_share_link_to_queue.assert_called_once_with(SPOTIFY_URI_1, 5)\n        # play_from_queue uses 0-based index\n        speaker.play_from_queue.assert_called_once_with(4)\n\n    def test_single_uri_with_position(self):\n        speaker = _make_speaker(queue_size=10)\n        plugin = _make_share_link_plugin({SPOTIFY_URI_1}, add_return_values=[3])\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            with patch(\n                \"soco_cli.action_processor.get_queue_insertion_position\", return_value=3\n            ):\n                with patch(\"soco_cli.action_processor.save_queue_insertion_position\"):\n                    result = self._call(speaker, [SPOTIFY_URI_1, \"3\"])\n        assert result is True\n        plugin.add_share_link_to_queue.assert_called_once_with(SPOTIFY_URI_1, 3)\n        speaker.play_from_queue.assert_called_once_with(2)\n\n    def test_multiple_uris_plays_from_first_position(self):\n        speaker = _make_speaker(queue_size=4)\n        plugin = _make_share_link_plugin(\n            {SPOTIFY_URI_1, SPOTIFY_URI_2}, add_return_values=[5, 8]\n        )\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            with patch(\"soco_cli.action_processor.save_queue_insertion_position\"):\n                result = self._call(speaker, [SPOTIFY_URI_1, SPOTIFY_URI_2])\n        assert result is True\n        assert plugin.add_share_link_to_queue.call_count == 2\n        # Playback starts at the first added position (0-based)\n        speaker.play_from_queue.assert_called_once_with(4)\n\n    def test_multiple_uris_with_position_first_uses_position(self):\n        speaker = _make_speaker(queue_size=10)\n        plugin = _make_share_link_plugin(\n            {SPOTIFY_URI_1, SPOTIFY_URI_2}, add_return_values=[3, 9]\n        )\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            with patch(\n                \"soco_cli.action_processor.get_queue_insertion_position\", return_value=3\n            ):\n                with patch(\"soco_cli.action_processor.save_queue_insertion_position\"):\n                    result = self._call(speaker, [SPOTIFY_URI_1, SPOTIFY_URI_2, \"3\"])\n        assert result is True\n        first_call_pos = plugin.add_share_link_to_queue.call_args_list[0][0][1]\n        assert first_call_pos == 3\n        speaker.play_from_queue.assert_called_once_with(2)\n\n    def test_invalid_uri_prevents_any_add_or_play(self, capsys):\n        speaker = _make_speaker()\n        plugin = _make_share_link_plugin(set())\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            result = self._call(speaker, [INVALID_URI])\n        assert result is False\n        plugin.add_share_link_to_queue.assert_not_called()\n        speaker.play_from_queue.assert_not_called()\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_invalid_uri_in_list_prevents_all_adds_and_play(self):\n        \"\"\"Validation happens before any URI is added.\"\"\"\n        speaker = _make_speaker()\n        plugin = _make_share_link_plugin({SPOTIFY_URI_1})  # SPOTIFY_URI_2 invalid\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            result = self._call(speaker, [SPOTIFY_URI_1, SPOTIFY_URI_2])\n        assert result is False\n        plugin.add_share_link_to_queue.assert_not_called()\n        speaker.play_from_queue.assert_not_called()\n\n    def test_invalid_last_arg_treated_as_uri_not_position(self, capsys):\n        \"\"\"An unrecognised final arg is validated as a URI, not passed to\n        get_queue_insertion_position.\"\"\"\n        speaker = _make_speaker()\n        plugin = _make_share_link_plugin({SPOTIFY_URI_1})  # INVALID_URI not valid\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            result = self._call(speaker, [SPOTIFY_URI_1, INVALID_URI])\n        assert result is False\n        plugin.add_share_link_to_queue.assert_not_called()\n        speaker.play_from_queue.assert_not_called()\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_upnp_exception_does_not_play(self, capsys):\n        speaker = _make_speaker()\n        plugin = _make_share_link_plugin({SPOTIFY_URI_1})\n        with patch(\"soco_cli.action_processor.ShareLinkPlugin\", return_value=plugin):\n            with patch(\"soco_cli.action_processor.SoCoUPnPException\", Exception):\n                plugin.add_share_link_to_queue.side_effect = Exception(\"fail\")\n                result = self._call(speaker, [SPOTIFY_URI_1])\n        assert result is False\n        speaker.play_from_queue.assert_not_called()\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_zero_args_rejected_by_decorator(self):\n        speaker = _make_speaker()\n        result = play_sharelink(speaker, \"play_sharelink\", [], None, False)\n        assert result is False\n"
  },
  {
    "path": "tests/test_http_api.py",
    "content": "\"\"\"Tests for the HTTP API server (http_api.py).\n\nCovers: ActiveAsyncOps, pure helper functions, macro loading/substitution,\n_process_macro (sync and async), command_core, and FastAPI endpoints via\nTestClient. No live Sonos network or running server required.\n\"\"\"\n\nimport os\nimport tempfile\nfrom subprocess import CalledProcessError\nfrom unittest.mock import MagicMock, call, patch\n\nimport pytest\nfrom fastapi.testclient import TestClient\n\nimport soco_cli.http_api as http_api\nfrom soco_cli.http_api import (\n    ASYNC_PREFIX,\n    ActiveAsyncOps,\n    _load_macros,\n    _lookup_macro,\n    _process_macro,\n    _quote_if_contains_space,\n    _substitute_variables,\n    command_core,\n    sc_app,\n)\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture(autouse=True)\ndef reset_globals():\n    \"\"\"Restore mutated module-level globals after each test.\"\"\"\n    original_macros = dict(http_api.MACROS)\n    original_use_local = http_api.USE_LOCAL\n    http_api.ASYNC_OPS.active_async_ops.clear()\n    http_api.ASYNC_MACRO_OPS.active_async_ops.clear()\n    yield\n    http_api.MACROS.clear()\n    http_api.MACROS.update(original_macros)\n    http_api.USE_LOCAL = original_use_local\n    http_api.ASYNC_OPS.active_async_ops.clear()\n    http_api.ASYNC_MACRO_OPS.active_async_ops.clear()\n\n\n@pytest.fixture\ndef client():\n    return TestClient(sc_app)\n\n\n@pytest.fixture\ndef loaded_macros():\n    \"\"\"Populate MACROS with a small set of test macros.\"\"\"\n    http_api.MACROS.clear()\n    http_api.MACROS.update(\n        {\n            \"__\": \"%1 %2 %3 %4 %5 %6 %7 %8 %9 %10 %11 %12\",\n            \"vol\": \"Kitchen volume %1\",\n            \"morning\": \"Kitchen play_favourite Radio4 : Bedroom group Kitchen\",\n            \"two_rooms\": \"%1 volume %2 : %3 volume %4\",\n        }\n    )\n    return http_api.MACROS\n\n\n# ===========================================================================\n# ActiveAsyncOps\n# ===========================================================================\n\n\nclass TestActiveAsyncOps:\n    def test_add_and_get(self):\n        ops = ActiveAsyncOps()\n        ops.add_async_pid(\"192.168.0.1\", 1234)\n        assert ops.get_async_pid(\"192.168.0.1\") == 1234\n\n    def test_get_missing_returns_none(self):\n        ops = ActiveAsyncOps()\n        assert ops.get_async_pid(\"192.168.0.99\") is None\n\n    def test_remove_returns_pid_and_clears(self):\n        ops = ActiveAsyncOps()\n        ops.add_async_pid(\"192.168.0.1\", 5678)\n        pid = ops.remove_async_pid(\"192.168.0.1\")\n        assert pid == 5678\n        assert ops.get_async_pid(\"192.168.0.1\") is None\n\n    def test_remove_missing_returns_none(self):\n        ops = ActiveAsyncOps()\n        assert ops.remove_async_pid(\"192.168.0.99\") is None\n\n    def test_stop_sends_sigint_and_removes(self):\n        ops = ActiveAsyncOps()\n        ops.add_async_pid(\"192.168.0.1\", 9999)\n        with patch(\"soco_cli.http_api.kill\") as mock_kill:\n            from signal import SIGINT\n\n            pid = ops.stop_async_process(\"192.168.0.1\")\n        mock_kill.assert_called_once_with(9999, SIGINT)\n        assert pid == 9999\n        assert ops.get_async_pid(\"192.168.0.1\") is None\n\n    def test_stop_missing_returns_none(self):\n        ops = ActiveAsyncOps()\n        assert ops.stop_async_process(\"192.168.0.99\") is None\n\n    def test_stop_already_dead_process_cleans_up_pid(self):\n        ops = ActiveAsyncOps()\n        ops.add_async_pid(\"192.168.0.1\", 9999)\n        with patch(\"soco_cli.http_api.kill\", side_effect=ProcessLookupError):\n            pid = ops.stop_async_process(\"192.168.0.1\")\n        assert pid == 9999\n        assert ops.get_async_pid(\"192.168.0.1\") is None\n\n    def test_stop_unkillable_process_leaves_pid_tracked(self):\n        ops = ActiveAsyncOps()\n        ops.add_async_pid(\"192.168.0.1\", 9999)\n        with patch(\"soco_cli.http_api.kill\", side_effect=PermissionError):\n            result = ops.stop_async_process(\"192.168.0.1\")\n        assert result is None\n        assert ops.get_async_pid(\"192.168.0.1\") == 9999\n\n    def test_overwrite_pid(self):\n        ops = ActiveAsyncOps()\n        ops.add_async_pid(\"192.168.0.1\", 100)\n        ops.add_async_pid(\"192.168.0.1\", 200)\n        assert ops.get_async_pid(\"192.168.0.1\") == 200\n\n    def test_multiple_keys_are_independent(self):\n        ops = ActiveAsyncOps()\n        ops.add_async_pid(\"192.168.0.1\", 1)\n        ops.add_async_pid(\"192.168.0.2\", 2)\n        assert ops.get_async_pid(\"192.168.0.1\") == 1\n        assert ops.get_async_pid(\"192.168.0.2\") == 2\n        ops.remove_async_pid(\"192.168.0.1\")\n        assert ops.get_async_pid(\"192.168.0.2\") == 2\n\n\n# ===========================================================================\n# _quote_if_contains_space\n# ===========================================================================\n\n\nclass TestQuoteIfContainsSpace:\n    def test_no_space(self):\n        assert _quote_if_contains_space(\"Kitchen\") == \"Kitchen\"\n\n    def test_with_space(self):\n        assert _quote_if_contains_space(\"Living Room\") == '\"Living Room\"'\n\n    def test_empty_string(self):\n        assert _quote_if_contains_space(\"\") == \"\"\n\n    def test_multiple_spaces(self):\n        assert _quote_if_contains_space(\"a b c\") == '\"a b c\"'\n\n\n# ===========================================================================\n# _substitute_variables\n# ===========================================================================\n\n\nclass TestSubstituteVariables:\n    def test_no_parameters(self):\n        assert _substitute_variables(\"Kitchen volume 30\", ()) == \"Kitchen volume 30\"\n\n    def test_single_substitution(self):\n        result = _substitute_variables(\"Kitchen volume %1\", (\"40\",))\n        assert result == \"Kitchen volume 40\"\n\n    def test_multiple_substitutions(self):\n        result = _substitute_variables(\"%1 volume %2\", (\"Kitchen\", \"50\"))\n        assert result == \"Kitchen volume 50\"\n\n    def test_unused_args_are_ignored(self):\n        result = _substitute_variables(\"Kitchen volume %1\", (\"30\", \"extra\"))\n        assert result == \"Kitchen volume 30\"\n\n    def test_unsatisfied_parameter_omitted(self):\n        result = _substitute_variables(\"Kitchen volume %1 : Bedroom volume %2\", (\"30\",))\n        assert result == \"Kitchen volume 30 : Bedroom volume\"\n\n    def test_underscore_arg_is_skipped(self):\n        # An underscore argument causes the positional parameter to be omitted\n        result = _substitute_variables(\"%1 volume %2\", (\"Kitchen\", \"_\"))\n        assert result == \"Kitchen volume\"\n\n    def test_arg_with_space_is_quoted(self):\n        result = _substitute_variables(\"%1 volume 30\", (\"Living Room\",))\n        assert result == '\"Living Room\" volume 30'\n\n    def test_all_twelve_params(self):\n        macro = \" \".join(\"%{}\".format(i) for i in range(1, 13))\n        args = tuple(str(i) for i in range(1, 13))\n        result = _substitute_variables(macro, args)\n        assert result == \" \".join(str(i) for i in range(1, 13))\n\n\n# ===========================================================================\n# _load_macros\n# ===========================================================================\n\n\nclass TestLoadMacros:\n    def test_loads_valid_file(self):\n        macros = {}\n        content = \"# comment\\nvol = Kitchen volume %1\\nmorning = Kitchen play_favourite Radio4\\n\"\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".txt\", delete=False) as f:\n            f.write(content)\n            name = f.name\n        try:\n            result = _load_macros(macros, name)\n            assert result is True\n            assert macros[\"vol\"] == \"Kitchen volume %1\"\n            assert macros[\"morning\"] == \"Kitchen play_favourite Radio4\"\n            # Generic macro always added\n            assert \"__\" in macros\n        finally:\n            os.unlink(name)\n\n    def test_ignores_blank_lines_and_comments(self):\n        macros = {}\n        content = \"# header\\n\\nvol = Kitchen volume %1\\n\\n# tail\\n\"\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".txt\", delete=False) as f:\n            f.write(content)\n            name = f.name\n        try:\n            _load_macros(macros, name)\n            assert list(k for k in macros if k != \"__\") == [\"vol\"]\n        finally:\n            os.unlink(name)\n\n    def test_missing_file_returns_false(self):\n        macros = {}\n        result = _load_macros(macros, \"/nonexistent/path/macros.txt\")\n        assert result is False\n        # Generic macro is still added even when file is missing\n        assert macros[\"__\"] == \"%1 %2 %3 %4 %5 %6 %7 %8 %9 %10 %11 %12\"\n\n    def test_malformed_line_is_skipped(self):\n        macros = {}\n        content = \"bad line without equals\\nvol = Kitchen volume %1\\n\"\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".txt\", delete=False) as f:\n            f.write(content)\n            name = f.name\n        try:\n            _load_macros(macros, name)\n            assert \"vol\" in macros\n            assert \"bad line without equals\" not in macros\n        finally:\n            os.unlink(name)\n\n    def test_value_containing_equals_is_accepted(self):\n        macros = {}\n        content = \"stream = Kitchen play_uri http://host?key=value\\n\"\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".txt\", delete=False) as f:\n            f.write(content)\n            name = f.name\n        try:\n            _load_macros(macros, name)\n            assert macros[\"stream\"] == \"Kitchen play_uri http://host?key=value\"\n        finally:\n            os.unlink(name)\n\n    def test_strips_whitespace_from_name_and_value(self):\n        macros = {}\n        content = \"  vol  =  Kitchen volume %1  \\n\"\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".txt\", delete=False) as f:\n            f.write(content)\n            name = f.name\n        try:\n            _load_macros(macros, name)\n            assert macros[\"vol\"] == \"Kitchen volume %1\"\n        finally:\n            os.unlink(name)\n\n\n# ===========================================================================\n# _lookup_macro\n# ===========================================================================\n\n\nclass TestLookupMacro:\n    def test_found(self, loaded_macros):\n        assert _lookup_macro(\"vol\") == \"Kitchen volume %1\"\n\n    def test_not_found_raises_key_error(self):\n        with pytest.raises(KeyError):\n            _lookup_macro(\"no_such_macro\")\n\n\n# ===========================================================================\n# _process_macro — synchronous\n# ===========================================================================\n\n\nclass TestProcessMacroSync:\n    def test_unknown_macro_returns_error(self):\n        command, result = _process_macro(\"no_such_macro\")\n        assert command == \"\"\n        assert \"not found\" in result\n\n    def test_successful_sync_execution(self, loaded_macros):\n        with patch(\"soco_cli.http_api.check_output\", return_value=b\"30\") as mock_co:\n            with patch(\n                \"soco_cli.http_api._substitute_speaker_ips\",\n                side_effect=lambda x, **kw: x,\n            ):\n                command, result = _process_macro(\"vol\", \"30\")\n        assert result == \"30\"\n        assert \"sonos\" in command\n        assert mock_co.called\n\n    def test_sync_failure_returns_error_output(self, loaded_macros):\n        exc = CalledProcessError(1, \"sonos\", output=b\"error detail\")\n        with patch(\"soco_cli.http_api.check_output\", side_effect=exc):\n            with patch(\n                \"soco_cli.http_api._substitute_speaker_ips\",\n                side_effect=lambda x, **kw: x,\n            ):\n                command, result = _process_macro(\"vol\", \"30\")\n        assert result == \"error detail\"\n\n    def test_use_local_prepends_flag(self, loaded_macros):\n        http_api.USE_LOCAL = True\n        with patch(\"soco_cli.http_api.check_output\", return_value=b\"\") as mock_co:\n            command, _ = _process_macro(\"morning\")\n        args = mock_co.call_args[0][0]\n        assert args[1] == \"-l\"\n\n    def test_command_contains_substituted_arg(self, loaded_macros):\n        with patch(\"soco_cli.http_api.check_output\", return_value=b\"\") as mock_co:\n            with patch(\n                \"soco_cli.http_api._substitute_speaker_ips\",\n                side_effect=lambda x, **kw: x,\n            ):\n                command, _ = _process_macro(\"vol\", \"99\")\n        assert \"99\" in command\n\n\n# ===========================================================================\n# _process_macro — async\n# ===========================================================================\n\n\nclass TestProcessMacroAsync:\n    def test_async_runs_popen_not_check_output(self, loaded_macros):\n        mock_proc = MagicMock()\n        mock_proc.pid = 1111\n        with patch(\"soco_cli.http_api.Popen\", return_value=mock_proc) as mock_popen:\n            with patch(\"soco_cli.http_api.check_output\") as mock_co:\n                with patch(\n                    \"soco_cli.http_api._substitute_speaker_ips\",\n                    side_effect=lambda x, **kw: x,\n                ):\n                    command, result = _process_macro(\"async_vol\", \"50\")\n        mock_popen.assert_called_once()\n        mock_co.assert_not_called()\n        assert result == \"\"\n\n    def test_async_returns_immediately_with_empty_result(self, loaded_macros):\n        mock_proc = MagicMock()\n        mock_proc.pid = 2222\n        with patch(\"soco_cli.http_api.Popen\", return_value=mock_proc):\n            with patch(\n                \"soco_cli.http_api._substitute_speaker_ips\",\n                side_effect=lambda x, **kw: x,\n            ):\n                command, result = _process_macro(\"async_vol\", \"40\")\n        assert result == \"\"\n        assert \"sonos\" in command\n\n    def test_async_strips_prefix_before_macro_lookup(self, loaded_macros):\n        # \"async_vol\" should resolve to the \"vol\" macro, not fail\n        mock_proc = MagicMock()\n        mock_proc.pid = 3333\n        with patch(\"soco_cli.http_api.Popen\", return_value=mock_proc):\n            with patch(\n                \"soco_cli.http_api._substitute_speaker_ips\",\n                side_effect=lambda x, **kw: x,\n            ):\n                command, result = _process_macro(\"async_vol\", \"20\")\n        assert \"not found\" not in result\n\n    def test_async_unknown_macro_returns_error(self):\n        command, result = _process_macro(\"async_no_such_macro\")\n        assert command == \"\"\n        assert \"not found\" in result\n\n    def test_async_pid_is_tracked(self, loaded_macros):\n        mock_proc = MagicMock()\n        mock_proc.pid = 4444\n        with patch(\"soco_cli.http_api.Popen\", return_value=mock_proc):\n            with patch(\n                \"soco_cli.http_api._substitute_speaker_ips\",\n                side_effect=lambda x, **kw: x,\n            ):\n                _process_macro(\"async_vol\", \"30\")\n        assert http_api.ASYNC_MACRO_OPS.get_async_pid(\"vol|30\") == 4444\n\n    def test_async_same_name_and_args_cancels_previous(self, loaded_macros):\n        mock_proc = MagicMock()\n        mock_proc.pid = 5555\n        with patch(\"soco_cli.http_api.Popen\", return_value=mock_proc):\n            with patch(\n                \"soco_cli.http_api._substitute_speaker_ips\",\n                side_effect=lambda x, **kw: x,\n            ):\n                _process_macro(\"async_vol\", \"30\")\n\n        mock_proc2 = MagicMock()\n        mock_proc2.pid = 6666\n        with patch(\"soco_cli.http_api.Popen\", return_value=mock_proc2):\n            with patch(\n                \"soco_cli.http_api._substitute_speaker_ips\",\n                side_effect=lambda x, **kw: x,\n            ):\n                with patch(\"soco_cli.http_api.kill\") as mock_kill:\n                    _process_macro(\"async_vol\", \"30\")\n        from signal import SIGINT\n\n        mock_kill.assert_called_once_with(5555, SIGINT)\n        assert http_api.ASYNC_MACRO_OPS.get_async_pid(\"vol|30\") == 6666\n\n    def test_async_different_args_run_concurrently(self, loaded_macros):\n        \"\"\"Same macro with different args should NOT cancel each other.\"\"\"\n        mock_proc_a = MagicMock()\n        mock_proc_a.pid = 7777\n        mock_proc_b = MagicMock()\n        mock_proc_b.pid = 8888\n\n        with patch(\"soco_cli.http_api.Popen\", return_value=mock_proc_a):\n            with patch(\n                \"soco_cli.http_api._substitute_speaker_ips\",\n                side_effect=lambda x, **kw: x,\n            ):\n                _process_macro(\"async_vol\", \"30\")\n\n        with patch(\"soco_cli.http_api.Popen\", return_value=mock_proc_b):\n            with patch(\n                \"soco_cli.http_api._substitute_speaker_ips\",\n                side_effect=lambda x, **kw: x,\n            ):\n                with patch(\"soco_cli.http_api.kill\") as mock_kill:\n                    _process_macro(\"async_vol\", \"40\")\n\n        mock_kill.assert_not_called()\n        assert http_api.ASYNC_MACRO_OPS.get_async_pid(\"vol|30\") == 7777\n        assert http_api.ASYNC_MACRO_OPS.get_async_pid(\"vol|40\") == 8888\n\n    def test_async_popen_failure_returns_error(self, loaded_macros):\n        with patch(\"soco_cli.http_api.Popen\", side_effect=OSError(\"boom\")):\n            with patch(\n                \"soco_cli.http_api._substitute_speaker_ips\",\n                side_effect=lambda x, **kw: x,\n            ):\n                command, result = _process_macro(\"async_vol\", \"30\")\n        assert \"boom\" in result\n\n\n# ===========================================================================\n# command_core\n# ===========================================================================\n\n\nclass TestCommandCore:\n    def _make_device(self, name=\"Kitchen\", ip=\"192.168.0.10\"):\n        device = MagicMock()\n        device.player_name = name\n        device.ip_address = ip\n        return device\n\n    def test_speaker_not_found_returns_exit_code_1(self):\n        with patch(\"soco_cli.http_api.get_speaker\", return_value=(None, \"not found\")):\n            result = command_core(\"Nonexistent\", \"volume\")\n        assert result[\"exit_code\"] == 1\n\n    def test_successful_sync_action(self):\n        device = self._make_device()\n        with patch(\"soco_cli.http_api.get_speaker\", return_value=(device, \"\")):\n            with patch(\n                \"soco_cli.http_api.sc_run\", return_value=(0, \"30\", \"\")\n            ) as mock_run:\n                result = command_core(\"Kitchen\", \"volume\")\n        assert result[\"exit_code\"] == 0\n        assert result[\"result\"] == \"30\"\n        assert result[\"speaker\"] == \"Kitchen\"\n        mock_run.assert_called_once()\n\n    def test_sync_action_failure(self):\n        device = self._make_device()\n        with patch(\"soco_cli.http_api.get_speaker\", return_value=(device, \"\")):\n            with patch(\"soco_cli.http_api.sc_run\", return_value=(1, \"\", \"bad action\")):\n                result = command_core(\"Kitchen\", \"bad_action\")\n        assert result[\"exit_code\"] == 1\n\n    def test_async_action_uses_popen(self):\n        device = self._make_device()\n        mock_proc = MagicMock()\n        mock_proc.pid = 1234\n        with patch(\"soco_cli.http_api.get_speaker\", return_value=(device, \"\")):\n            with patch(\"soco_cli.http_api.Popen\", return_value=mock_proc) as mock_popen:\n                with patch(\"soco_cli.http_api.sc_run\") as mock_run:\n                    result = command_core(\"Kitchen\", \"async_play_file\", \"track.mp3\")\n        mock_popen.assert_called_once()\n        mock_run.assert_not_called()\n        assert result[\"exit_code\"] == 0\n\n    def test_async_action_tracks_pid(self):\n        device = self._make_device(ip=\"192.168.0.20\")\n        mock_proc = MagicMock()\n        mock_proc.pid = 9876\n        with patch(\"soco_cli.http_api.get_speaker\", return_value=(device, \"\")):\n            with patch(\"soco_cli.http_api.Popen\", return_value=mock_proc):\n                command_core(\"Kitchen\", \"async_play_file\", \"track.mp3\")\n        assert http_api.ASYNC_OPS.get_async_pid(\"192.168.0.20\") == 9876\n\n    def test_speaker_name_with_spaces_is_quoted_in_log(self, capsys):\n        device = self._make_device(name=\"Living Room\", ip=\"192.168.0.30\")\n        with patch(\"soco_cli.http_api.get_speaker\", return_value=(device, \"\")):\n            with patch(\"soco_cli.http_api.sc_run\", return_value=(0, \"\", \"\")):\n                command_core(\"Living Room\", \"volume\")\n        out = capsys.readouterr().out\n        assert '\"Living Room\"' in out\n\n\n# ===========================================================================\n# FastAPI endpoints\n# ===========================================================================\n\n\nclass TestEndpoints:\n    def test_root(self, client):\n        response = client.get(\"/\")\n        assert response.status_code == 200\n        assert \"info\" in response.json()\n\n    def test_macros_list_empty(self, client):\n        http_api.MACROS.clear()\n        response = client.get(\"/macros/list\")\n        assert response.status_code == 200\n        assert response.json() == {}\n\n    def test_macros_list_populated(self, client, loaded_macros):\n        response = client.get(\"/macros/list\")\n        assert response.status_code == 200\n        data = response.json()\n        assert \"vol\" in data\n        assert \"morning\" in data\n\n    def test_macro_not_found(self, client):\n        response = client.get(\"/macro/no_such_macro\")\n        assert response.status_code == 200\n        data = response.json()\n        assert \"not found\" in data[\"result\"]\n\n    def test_macro_sync_success(self, client, loaded_macros):\n        with patch(\"soco_cli.http_api.check_output\", return_value=b\"30\"):\n            with patch(\n                \"soco_cli.http_api._substitute_speaker_ips\",\n                side_effect=lambda x, **kw: x,\n            ):\n                response = client.get(\"/macro/vol/30\")\n        assert response.status_code == 200\n        assert response.json()[\"result\"] == \"30\"\n\n    def test_macro_async_success(self, client, loaded_macros):\n        mock_proc = MagicMock()\n        mock_proc.pid = 1111\n        with patch(\"soco_cli.http_api.Popen\", return_value=mock_proc):\n            with patch(\n                \"soco_cli.http_api._substitute_speaker_ips\",\n                side_effect=lambda x, **kw: x,\n            ):\n                response = client.get(\"/macro/async_vol/50\")\n        assert response.status_code == 200\n        assert response.json()[\"result\"] == \"\"\n\n    def test_macros_reload(self, client):\n        with patch(\"soco_cli.http_api._load_macros\") as mock_load:\n            response = client.get(\"/macros/reload\")\n        assert response.status_code == 200\n        mock_load.assert_called_once()\n\n    def test_speaker_action_endpoint(self, client):\n        device = MagicMock()\n        device.player_name = \"Kitchen\"\n        device.ip_address = \"192.168.0.10\"\n        with patch(\"soco_cli.http_api.get_speaker\", return_value=(device, \"\")):\n            with patch(\"soco_cli.http_api.sc_run\", return_value=(0, \"25\", \"\")):\n                response = client.get(\"/Kitchen/volume\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"exit_code\"] == 0\n        assert data[\"result\"] == \"25\"\n\n    def test_speaker_action_with_arg(self, client):\n        device = MagicMock()\n        device.player_name = \"Kitchen\"\n        device.ip_address = \"192.168.0.10\"\n        with patch(\"soco_cli.http_api.get_speaker\", return_value=(device, \"\")):\n            with patch(\n                \"soco_cli.http_api.sc_run\", return_value=(0, \"\", \"\")\n            ) as mock_run:\n                response = client.get(\"/Kitchen/volume/40\")\n        assert response.status_code == 200\n        assert mock_run.called\n\n    def test_end_on_pause_suffix_split_correctly(self, client):\n        \"\"\"_end_on_pause_ appended to a path is split into a separate arg.\"\"\"\n        device = MagicMock()\n        device.player_name = \"Kitchen\"\n        device.ip_address = \"192.168.0.10\"\n        with patch(\"soco_cli.http_api.get_speaker\", return_value=(device, \"\")):\n            with patch(\n                \"soco_cli.http_api.sc_run\", return_value=(0, \"\", \"\")\n            ) as mock_run:\n                response = client.get(\n                    \"/Kitchen/play_file/music/track.mp3/_end_on_pause_\"\n                )\n        assert response.status_code == 200\n        call_args = mock_run.call_args[0]\n        # arg_1 should be the path without the suffix, arg_2 should be the sentinel\n        assert \"_end_on_pause_\" not in call_args[2]\n        assert call_args[3] == \"_end_on_pause_\"\n\n    def test_filename_ending_with_end_on_pause_not_falsely_split(self, client):\n        \"\"\"A filename that ends with _end_on_pause_ (no preceding slash) is NOT split.\"\"\"\n        device = MagicMock()\n        device.player_name = \"Kitchen\"\n        device.ip_address = \"192.168.0.10\"\n        with patch(\"soco_cli.http_api.get_speaker\", return_value=(device, \"\")):\n            with patch(\n                \"soco_cli.http_api.sc_run\", return_value=(0, \"\", \"\")\n            ) as mock_run:\n                response = client.get(\n                    \"/Kitchen/play_file/music/track_end_on_pause_.mp3\"\n                )\n        assert response.status_code == 200\n        call_args = mock_run.call_args[0]\n        # Only one path arg, no sentinel injected\n        assert call_args[2] == \"music/track_end_on_pause_.mp3\"\n        assert len(call_args) == 3\n\n    def test_speakers_endpoint(self, client):\n        with patch(\n            \"soco_cli.http_api.get_all_speaker_names\",\n            return_value=[\"Kitchen\", \"Bedroom\"],\n        ):\n            response = client.get(\"/speakers\")\n        assert response.status_code == 200\n        assert response.json()[\"speakers\"] == [\"Kitchen\", \"Bedroom\"]\n"
  },
  {
    "path": "tests/test_interactive.py",
    "content": "\"\"\"Tests for interactive.py.\n\nCovers pure helpers and logic-bearing functions that can be exercised\nwithout a running REPL or live Sonos network.\n\"\"\"\n\nimport sys\nfrom copy import deepcopy\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nimport soco_cli.interactive as interactive_mod\nfrom soco_cli.aliases import AliasManager\nfrom soco_cli.interactive import (\n    AliasProcessor,\n    _completer,\n    _exec,\n    _exec_action,\n    _exec_loop,\n    _get_speaker_names,\n    _loop_in_command_sequences,\n    _print_speaker_list,\n    _restore_quotes,\n    _set_actions_and_commands_list,\n)\nfrom soco_cli.utils import RewindableList\n\n\n@pytest.fixture(autouse=True)\ndef api_mode():\n    \"\"\"Prevent os._exit() calls during tests.\"\"\"\n    import soco_cli.utils as utils\n\n    original = utils.API\n    utils.API = True\n    yield\n    utils.API = original\n\n\n# ---------------------------------------------------------------------------\n# _restore_quotes\n# ---------------------------------------------------------------------------\n\n\nclass TestRestoreQuotes:\n    def test_single_word_items_unchanged(self):\n        cmd = [\"play\", \"volume\", \"50\"]\n        _restore_quotes(cmd)\n        assert cmd == [\"play\", \"volume\", \"50\"]\n\n    def test_multi_word_item_gets_quoted(self):\n        cmd = [\"set\", \"Front Room\"]\n        _restore_quotes(cmd)\n        assert cmd == [\"set\", '\"Front Room\"']\n\n    def test_multiple_multi_word_items(self):\n        cmd = [\"Living Room\", \"play_favourite\", \"Classic FM\"]\n        _restore_quotes(cmd)\n        assert cmd == ['\"Living Room\"', \"play_favourite\", '\"Classic FM\"']\n\n    def test_three_word_item_gets_quoted(self):\n        cmd = [\"Great Big Hall\"]\n        _restore_quotes(cmd)\n        assert cmd == ['\"Great Big Hall\"']\n\n    def test_empty_list_unchanged(self):\n        cmd = []\n        _restore_quotes(cmd)\n        assert cmd == []\n\n    def test_already_present_quotes_not_doubled(self):\n        # _restore_quotes only checks for spaces, not existing quotes\n        cmd = ['\"Already Quoted\"']\n        _restore_quotes(cmd)\n        # Has a space inside → gets wrapped again\n        assert cmd == ['\"\"Already Quoted\"\"']\n\n\n# ---------------------------------------------------------------------------\n# _loop_in_command_sequences\n# ---------------------------------------------------------------------------\n\n\nclass TestLoopInCommandSequences:\n    def test_no_loop_returns_false(self):\n        seqs = RewindableList([[\"volume\", \"50\"], [\"play\"]])\n        assert _loop_in_command_sequences(seqs) is False\n\n    def test_loop_keyword_detected(self):\n        seqs = RewindableList([[\"volume\", \"50\"], [\"loop\"]])\n        assert _loop_in_command_sequences(seqs) is True\n\n    def test_loop_until_detected(self):\n        seqs = RewindableList([[\"loop_until\", \"stopped\"]])\n        assert _loop_in_command_sequences(seqs) is True\n\n    def test_loop_for_detected(self):\n        seqs = RewindableList([[\"loop_for\", \"10m\"]])\n        assert _loop_in_command_sequences(seqs) is True\n\n    def test_loop_in_second_sequence(self):\n        seqs = RewindableList([[\"play\"], [\"loop\"]])\n        assert _loop_in_command_sequences(seqs) is True\n\n    def test_loop_in_non_first_position_of_sequence(self):\n        # \"loop\" anywhere in a sequence triggers detection\n        seqs = RewindableList([[\"volume\", \"loop\"]])\n        assert _loop_in_command_sequences(seqs) is True\n\n    def test_empty_sequences_returns_false(self):\n        seqs = RewindableList([])\n        assert _loop_in_command_sequences(seqs) is False\n\n\n# ---------------------------------------------------------------------------\n# _completer\n# ---------------------------------------------------------------------------\n\n\nclass TestCompleter:\n    def test_returns_first_match(self):\n        with patch.object(\n            interactive_mod, \"ACTIONS_LIST\", [\"play \", \"pause \", \"play_favourite \"]\n        ):\n            assert _completer(\"play\", 0) == \"play \"\n\n    def test_returns_second_match_at_context_1(self):\n        with patch.object(\n            interactive_mod, \"ACTIONS_LIST\", [\"play \", \"pause \", \"play_favourite \"]\n        ):\n            assert _completer(\"play\", 1) == \"play_favourite \"\n\n    def test_no_match_raises_index_error(self):\n        with patch.object(interactive_mod, \"ACTIONS_LIST\", [\"play \"]):\n            with pytest.raises(IndexError):\n                _completer(\"xyz\", 0)\n\n    def test_empty_prefix_matches_all(self):\n        with patch.object(interactive_mod, \"ACTIONS_LIST\", [\"play \", \"pause \"]):\n            assert _completer(\"\", 0) == \"play \"\n            assert _completer(\"\", 1) == \"pause \"\n\n    def test_context_beyond_matches_raises_index_error(self):\n        with patch.object(interactive_mod, \"ACTIONS_LIST\", [\"play \"]):\n            with pytest.raises(IndexError):\n                _completer(\"play\", 1)\n\n\n# ---------------------------------------------------------------------------\n# _get_speaker_names\n# ---------------------------------------------------------------------------\n\n\nclass TestGetSpeakerNames:\n    def test_local_uses_local_speaker_list(self):\n        mock_cache = MagicMock()\n        mock_cache.get_all_speaker_names.return_value = [\"Kitchen\", \"Bedroom\"]\n        with patch(\"soco_cli.interactive.local_speaker_list\", return_value=mock_cache):\n            result = _get_speaker_names(use_local_speaker_list=True)\n        assert result == [\"Kitchen\", \"Bedroom\"]\n\n    def test_non_local_uses_speaker_cache(self):\n        mock_cache = MagicMock()\n        mock_cache.get_all_speaker_names.return_value = [\"Living Room\", \"Office\"]\n        with patch(\"soco_cli.interactive.speaker_cache\", return_value=mock_cache):\n            result = _get_speaker_names(use_local_speaker_list=False)\n        assert result == [\"Living Room\", \"Office\"]\n\n    def test_exception_returns_empty_list(self, capsys):\n        with patch(\n            \"soco_cli.interactive.speaker_cache\", side_effect=Exception(\"network error\")\n        ):\n            result = _get_speaker_names(use_local_speaker_list=False)\n        assert result == []\n        assert \"network error\" in capsys.readouterr().out\n\n\n# ---------------------------------------------------------------------------\n# _print_speaker_list\n# ---------------------------------------------------------------------------\n\n\nclass TestPrintSpeakerList:\n    def test_empty_list_prints_only_blank_line(self, capsys):\n        # _print_speaker_list always emits one leading newline, but no speaker\n        # entries when the list is empty.\n        with patch(\"soco_cli.interactive._get_speaker_names\", return_value=[]):\n            _print_speaker_list()\n        out = capsys.readouterr().out\n        assert out.strip() == \"\"\n\n    def test_speakers_listed_with_zero_unset_entry(self, capsys):\n        with patch(\n            \"soco_cli.interactive._get_speaker_names\",\n            return_value=[\"Kitchen\", \"Bedroom\"],\n        ):\n            _print_speaker_list()\n        out = capsys.readouterr().out\n        assert \"0\" in out\n        assert \"Unset the active speaker\" in out\n\n    def test_speakers_numbered_from_one(self, capsys):\n        with patch(\n            \"soco_cli.interactive._get_speaker_names\",\n            return_value=[\"Kitchen\", \"Bedroom\"],\n        ):\n            _print_speaker_list()\n        out = capsys.readouterr().out\n        assert \"1\" in out\n        assert \"Kitchen\" in out\n        assert \"2\" in out\n        assert \"Bedroom\" in out\n\n    def test_zero_entry_comes_before_speakers(self, capsys):\n        with patch(\n            \"soco_cli.interactive._get_speaker_names\", return_value=[\"OneSpeaker\"]\n        ):\n            _print_speaker_list()\n        lines = [l for l in capsys.readouterr().out.splitlines() if l.strip()]\n        assert lines[0].lstrip().startswith(\"0\")\n        assert lines[1].lstrip().startswith(\"1\")\n\n\n# ---------------------------------------------------------------------------\n# _set_actions_and_commands_list\n# ---------------------------------------------------------------------------\n\n\nclass TestSetActionsAndCommandsList:\n    def test_list_contains_actions_with_trailing_space(self):\n        mock_am = MagicMock()\n        mock_am.alias_names.return_value = []\n        with (\n            patch(\"soco_cli.interactive.get_actions\", return_value=[\"volume\", \"play\"]),\n            patch(\"soco_cli.interactive._get_speaker_names\", return_value=[]),\n            patch.object(interactive_mod, \"am\", mock_am),\n        ):\n            _set_actions_and_commands_list()\n        assert \"volume \" in interactive_mod.ACTIONS_LIST\n        assert \"play \" in interactive_mod.ACTIONS_LIST\n\n    def test_list_contains_speaker_names_with_trailing_space(self):\n        mock_am = MagicMock()\n        mock_am.alias_names.return_value = []\n        with (\n            patch(\"soco_cli.interactive.get_actions\", return_value=[]),\n            patch(\n                \"soco_cli.interactive._get_speaker_names\",\n                return_value=[\"Kitchen\", \"Office\"],\n            ),\n            patch.object(interactive_mod, \"am\", mock_am),\n        ):\n            _set_actions_and_commands_list()\n        assert \"Kitchen \" in interactive_mod.ACTIONS_LIST\n        assert \"Office \" in interactive_mod.ACTIONS_LIST\n\n    def test_list_contains_shell_commands(self):\n        mock_am = MagicMock()\n        mock_am.alias_names.return_value = []\n        with (\n            patch(\"soco_cli.interactive.get_actions\", return_value=[]),\n            patch(\"soco_cli.interactive._get_speaker_names\", return_value=[]),\n            patch.object(interactive_mod, \"am\", mock_am),\n        ):\n            _set_actions_and_commands_list()\n        assert \"exit\" in interactive_mod.ACTIONS_LIST\n        assert \"help\" in interactive_mod.ACTIONS_LIST\n\n    def test_list_contains_alias_names(self):\n        mock_am = MagicMock()\n        mock_am.alias_names.return_value = [\"myfav\", \"playjazz\"]\n        with (\n            patch(\"soco_cli.interactive.get_actions\", return_value=[]),\n            patch(\"soco_cli.interactive._get_speaker_names\", return_value=[]),\n            patch.object(interactive_mod, \"am\", mock_am),\n        ):\n            _set_actions_and_commands_list()\n        assert \"myfav\" in interactive_mod.ACTIONS_LIST\n        assert \"playjazz\" in interactive_mod.ACTIONS_LIST\n\n\n# ---------------------------------------------------------------------------\n# _exec\n# ---------------------------------------------------------------------------\n\n\nclass TestExec:\n    def test_calls_subprocess_run_with_shell_true(self):\n        with patch(\"soco_cli.interactive.subprocess.run\") as mock_run:\n            _exec([\"ls\", \"-l\"])\n        mock_run.assert_called_once_with(\"ls -l\", shell=True)\n\n    def test_arg_with_space_gets_quoted(self):\n        with patch(\"soco_cli.interactive.subprocess.run\") as mock_run:\n            _exec([\"echo\", \"hello world\"])\n        mock_run.assert_called_once_with('echo \"hello world\"', shell=True)\n\n    def test_multiple_args_with_spaces_quoted(self):\n        with patch(\"soco_cli.interactive.subprocess.run\") as mock_run:\n            _exec([\"cmd\", \"arg one\", \"arg two\"])\n        mock_run.assert_called_once_with('cmd \"arg one\" \"arg two\"', shell=True)\n\n    def test_returns_false_when_no_ctrl_c(self):\n        with patch(\"soco_cli.interactive.subprocess.run\"):\n            with patch(\n                \"soco_cli.interactive.get_ctrl_c_interrupted\", return_value=False\n            ):\n                result = _exec([\"ls\"])\n        assert result is False\n\n    def test_returns_true_when_ctrl_c_interrupted(self):\n        with patch(\"soco_cli.interactive.subprocess.run\"):\n            with patch(\n                \"soco_cli.interactive.get_ctrl_c_interrupted\", return_value=True\n            ):\n                result = _exec([\"ls\"])\n        assert result is True\n\n    def test_subprocess_exception_does_not_propagate(self, capsys):\n        with patch(\"soco_cli.interactive.subprocess.run\", side_effect=OSError(\"bad\")):\n            _exec([\"bad_cmd\"])\n        assert \"bad\" in capsys.readouterr().out\n\n\n# ---------------------------------------------------------------------------\n# _exec_action\n# ---------------------------------------------------------------------------\n\n\nclass TestExecAction:\n    def setup_method(self):\n        self._orig_log = interactive_mod.LOG_SETTING\n        interactive_mod.LOG_SETTING = \"--log=none\"\n\n    def teardown_method(self):\n        interactive_mod.LOG_SETTING = self._orig_log\n\n    def test_speaker_action_includes_ip_in_command(self):\n        captured = {}\n\n        def fake_exec(args):\n            captured[\"args\"] = args[:]\n            return False\n\n        with patch(\"soco_cli.interactive._exec\", side_effect=fake_exec):\n            with patch.object(sys, \"argv\", [\"sonos\"]):\n                _exec_action(\"192.168.1.10\", \"volume\", [\"50\"])\n\n        assert \"192.168.1.10\" in captured[\"args\"]\n        assert \"volume\" in captured[\"args\"]\n        assert \"50\" in captured[\"args\"]\n\n    def test_no_speaker_action_omits_ip(self):\n        captured = {}\n\n        def fake_exec(args):\n            captured[\"args\"] = args[:]\n            return False\n\n        with patch(\"soco_cli.interactive._exec\", side_effect=fake_exec):\n            with patch.object(sys, \"argv\", [\"sonos\"]):\n                _exec_action(\"192.168.1.10\", \"wait\", [\"10\"])\n\n        assert \"192.168.1.10\" not in captured[\"args\"]\n        assert \"wait\" in captured[\"args\"]\n        assert \"10\" in captured[\"args\"]\n\n    def test_log_setting_inserted_at_position_1(self):\n        captured = {}\n\n        def fake_exec(args):\n            captured[\"args\"] = args[:]\n            return False\n\n        interactive_mod.LOG_SETTING = \"--log=debug\"\n        with patch(\"soco_cli.interactive._exec\", side_effect=fake_exec):\n            with patch.object(sys, \"argv\", [\"sonos\"]):\n                _exec_action(\"1.2.3.4\", \"play\", [])\n\n        assert captured[\"args\"][1] == \"--log=debug\"\n\n    def test_returns_exec_result_true(self):\n        with patch(\"soco_cli.interactive._exec\", return_value=True):\n            with patch.object(sys, \"argv\", [\"sonos\"]):\n                result = _exec_action(\"1.2.3.4\", \"play\", [])\n        assert result is True\n\n    def test_returns_exec_result_false(self):\n        with patch(\"soco_cli.interactive._exec\", return_value=False):\n            with patch.object(sys, \"argv\", [\"sonos\"]):\n                result = _exec_action(\"1.2.3.4\", \"play\", [])\n        assert result is False\n\n\n# ---------------------------------------------------------------------------\n# _exec_loop\n# ---------------------------------------------------------------------------\n\n\nclass TestExecLoop:\n    def setup_method(self):\n        self._orig_log = interactive_mod.LOG_SETTING\n        interactive_mod.LOG_SETTING = \"--log=none\"\n\n    def teardown_method(self):\n        interactive_mod.LOG_SETTING = self._orig_log\n\n    def test_no_loop_returns_false(self):\n        speaker = MagicMock()\n        seqs = RewindableList([[\"volume\", \"50\"]])\n        with patch(\"soco_cli.interactive._exec_command_line\") as mock_exec:\n            result = _exec_loop(speaker, [\"play\"], seqs, False)\n        assert result is False\n        mock_exec.assert_not_called()\n\n    def test_loop_action_returns_true(self):\n        speaker = MagicMock()\n        seqs = RewindableList([[\"loop\"]])\n        with patch(\"soco_cli.interactive._exec_command_line\"):\n            result = _exec_loop(speaker, [\"play\"], seqs, False)\n        assert result is True\n\n    def test_loop_calls_exec_command_line_once(self):\n        speaker = MagicMock()\n        seqs = RewindableList([[\"loop\"]])\n        with patch(\"soco_cli.interactive._exec_command_line\") as mock_exec:\n            _exec_loop(speaker, [\"play\"], seqs, False)\n        mock_exec.assert_called_once()\n\n    def test_unix_includes_export_spkr_env(self):\n        speaker = MagicMock()\n        speaker.ip_address = \"192.168.1.5\"\n        seqs = RewindableList([[\"loop\"]])\n        with patch(\"soco_cli.interactive._exec_command_line\") as mock_exec:\n            with patch.object(interactive_mod, \"UNIX\", True):\n                with patch.object(interactive_mod, \"WINDOWS\", False):\n                    _exec_loop(speaker, [\"play\"], seqs, False)\n        cmd = mock_exec.call_args[0][0]\n        assert \"export SPKR=192.168.1.5\" in cmd\n        assert \"&&\" in cmd\n\n    def test_windows_includes_set_spkr_env(self):\n        speaker = MagicMock()\n        speaker.ip_address = \"192.168.1.5\"\n        seqs = RewindableList([[\"loop\"]])\n        with patch(\"soco_cli.interactive._exec_command_line\") as mock_exec:\n            with patch.object(interactive_mod, \"UNIX\", False):\n                with patch.object(interactive_mod, \"WINDOWS\", True):\n                    _exec_loop(speaker, [\"play\"], seqs, False)\n        cmd = mock_exec.call_args[0][0]\n        assert 'set \"SPKR=192.168.1.5\"' in cmd\n        assert \"&&\" in cmd\n\n    def test_no_speaker_excludes_spkr_env(self):\n        seqs = RewindableList([[\"loop\"]])\n        with patch(\"soco_cli.interactive._exec_command_line\") as mock_exec:\n            _exec_loop(None, [\"play\"], seqs, False)\n        cmd = mock_exec.call_args[0][0]\n        assert \"SPKR\" not in cmd\n\n    def test_use_local_adds_flag_when_no_speaker(self):\n        seqs = RewindableList([[\"loop\"]])\n        with patch(\"soco_cli.interactive._exec_command_line\") as mock_exec:\n            _exec_loop(None, [\"play\"], seqs, use_local=True)\n        cmd = mock_exec.call_args[0][0]\n        assert \"-l \" in cmd\n\n    def test_sequences_joined_with_colon_separator(self):\n        speaker = MagicMock()\n        speaker.ip_address = \"1.2.3.4\"\n        seqs = RewindableList([[\"volume\", \"50\"], [\"loop\"]])\n        with patch(\"soco_cli.interactive._exec_command_line\") as mock_exec:\n            with patch.object(interactive_mod, \"UNIX\", True):\n                with patch.object(interactive_mod, \"WINDOWS\", False):\n                    _exec_loop(speaker, [\"play\"], seqs, False)\n        cmd = mock_exec.call_args[0][0]\n        assert \"play : volume 50 : loop\" in cmd\n\n    def test_does_not_mutate_remaining_sequences(self):\n        speaker = MagicMock()\n        seqs = RewindableList([[\"volume\", \"50\"], [\"loop\"]])\n        original_items = deepcopy(list(seqs))\n        with patch(\"soco_cli.interactive._exec_command_line\"):\n            _exec_loop(speaker, [\"play\"], seqs, False)\n        assert list(seqs) == original_items\n\n\n# ---------------------------------------------------------------------------\n# AliasProcessor helpers\n# ---------------------------------------------------------------------------\n\n\ndef _make_am(*alias_pairs):\n    \"\"\"Create an AliasManager pre-populated with (name, action) pairs.\"\"\"\n    am = AliasManager()\n    for name, action in alias_pairs:\n        am.create_alias(name, action)\n    return am\n\n\n# ---------------------------------------------------------------------------\n# AliasProcessor — basic expansion\n# ---------------------------------------------------------------------------\n\n\nclass TestAliasProcessorBasicExpansion:\n    def test_simple_expansion(self):\n        am = _make_am((\"vol50\", \"volume 50\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"vol50\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"volume\", \"50\"]]\n\n    def test_expansion_inserts_before_existing_commands(self):\n        am = _make_am((\"vol50\", \"volume 50\"))\n        cmd_list = RewindableList([[\"play\"]])\n        result = AliasProcessor().process([\"vol50\"], am, cmd_list)\n        assert result is True\n        items = list(cmd_list)\n        assert items[0] == [\"volume\", \"50\"]\n        assert items[1] == [\"play\"]\n\n    def test_multi_token_expansion(self):\n        am = _make_am((\"pf\", \"play_favourite Jazz\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"pf\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"play_favourite\", \"Jazz\"]]\n\n    def test_extra_args_without_placeholders_are_unused(self):\n        # Args beyond what %N placeholders consume are silently dropped\n        am = _make_am((\"greet\", \"play\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"greet\", \"extra\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"play\"]]\n\n\n# ---------------------------------------------------------------------------\n# AliasProcessor — argument substitution\n# ---------------------------------------------------------------------------\n\n\nclass TestAliasProcessorArgSubstitution:\n    def test_single_arg_substituted(self):\n        am = _make_am((\"vol\", \"volume %1\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"vol\", \"75\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"volume\", \"75\"]]\n\n    def test_two_args_substituted(self):\n        am = _make_am((\"fav\", \"play_favourite %1 %2\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"fav\", \"Jazz\", \"next\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"play_favourite\", \"Jazz\", \"next\"]]\n\n    def test_unsatisfied_placeholder_removed(self):\n        # %2 is not provided — the placeholder token is dropped\n        am = _make_am((\"vol\", \"volume %1 %2\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"vol\", \"50\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"volume\", \"50\"]]\n\n    def test_no_args_all_placeholders_removed(self):\n        am = _make_am((\"vol\", \"volume %1\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"vol\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"volume\"]]\n\n    def test_same_placeholder_usable_once(self):\n        # %1 appears twice; each occurrence is filled independently\n        am = _make_am((\"dup\", \"echo %1 %1\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"dup\", \"hello\"], am, cmd_list)\n        assert result is True\n        # After first %1 is substituted, the arg is marked used so second %1\n        # can still access it (alias_parms_used tracks indices, not values)\n        assert list(cmd_list) == [[\"echo\", \"hello\", \"hello\"]]\n\n    def test_arg_substitution_in_multi_sequence_alias(self):\n        am = _make_am((\"pv\", \"play : volume %1\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"pv\", \"30\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"play\"], [\"volume\", \"30\"]]\n\n\n# ---------------------------------------------------------------------------\n# AliasProcessor — multi-sequence aliases\n# ---------------------------------------------------------------------------\n\n\nclass TestAliasProcessorMultiSequence:\n    def test_two_sequence_alias_inserted_in_order(self):\n        am = _make_am((\"playvol\", \"play : volume 50\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"playvol\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"play\"], [\"volume\", \"50\"]]\n\n    def test_three_sequence_alias(self):\n        am = _make_am((\"triple\", \"play : volume 50 : mute off\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"triple\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"play\"], [\"volume\", \"50\"], [\"mute\", \"off\"]]\n\n    def test_multi_sequence_prepended_to_existing_commands(self):\n        am = _make_am((\"pv\", \"play : volume 50\"))\n        cmd_list = RewindableList([[\"pause\"]])\n        result = AliasProcessor().process([\"pv\"], am, cmd_list)\n        assert result is True\n        items = list(cmd_list)\n        assert items[0] == [\"play\"]\n        assert items[1] == [\"volume\", \"50\"]\n        assert items[2] == [\"pause\"]\n\n\n# ---------------------------------------------------------------------------\n# AliasProcessor — nested / recursive alias expansion\n# ---------------------------------------------------------------------------\n\n\nclass TestAliasProcessorRecursion:\n    def test_one_level_nested_alias(self):\n        am = _make_am((\"inner\", \"volume 50\"), (\"outer\", \"inner\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"outer\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"volume\", \"50\"]]\n\n    def test_two_levels_nested(self):\n        am = _make_am(\n            (\"base\", \"play\"),\n            (\"mid\", \"base\"),\n            (\"top\", \"mid\"),\n        )\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"top\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"play\"]]\n\n    def test_nested_alias_with_arg_propagation(self):\n        # setvol %1 → vol %1 → volume %1\n        am = _make_am((\"vol\", \"volume %1\"), (\"setvol\", \"vol %1\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"setvol\", \"80\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"volume\", \"80\"]]\n\n    def test_nested_alias_mixed_with_real_action(self):\n        # outer = \"inner : pause\", inner = \"play\"\n        am = _make_am((\"inner\", \"play\"), (\"outer\", \"inner : pause\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"outer\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"play\"], [\"pause\"]]\n\n    def test_nested_alias_arg_propagated_two_levels(self):\n        # top %1 → mid %1 → volume %1\n        am = _make_am(\n            (\"vol\", \"volume %1\"),\n            (\"mid\", \"vol %1\"),\n            (\"top\", \"mid %1\"),\n        )\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"top\", \"42\"], am, cmd_list)\n        assert result is True\n        assert list(cmd_list) == [[\"volume\", \"42\"]]\n\n\n# ---------------------------------------------------------------------------\n# AliasProcessor — loop detection\n# ---------------------------------------------------------------------------\n\n\nclass TestAliasProcessorLoopDetection:\n    def test_self_referential_loop_returns_false(self, capsys):\n        am = _make_am((\"a\", \"a\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"a\"], am, cmd_list)\n        assert result is False\n        assert \"loop\" in capsys.readouterr().out.lower()\n\n    def test_mutual_recursion_ab_returns_false(self, capsys):\n        am = _make_am((\"a\", \"b\"), (\"b\", \"a\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"a\"], am, cmd_list)\n        assert result is False\n        assert \"loop\" in capsys.readouterr().out.lower()\n\n    def test_three_way_loop_detected(self, capsys):\n        am = _make_am((\"a\", \"b\"), (\"b\", \"c\"), (\"c\", \"a\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"a\"], am, cmd_list)\n        assert result is False\n        assert \"loop\" in capsys.readouterr().out.lower()\n\n    def test_loop_cleanup_removes_partially_inserted_commands(self, capsys):\n        # \"first\" successfully inserts \"volume 50\" before encountering the\n        # self-loop via \"loopy\".  The cleanup must remove that partial work.\n        am = _make_am(\n            (\"loopy\", \"loopy\"),\n            (\"first\", \"volume 50 : loopy\"),\n        )\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"first\"], am, cmd_list)\n        assert result is False\n        assert list(cmd_list) == []\n        assert \"loop\" in capsys.readouterr().out.lower()\n\n    def test_loop_cleanup_leaves_pre_existing_commands_intact(self, capsys):\n        # Commands already in cmd_list before the alias is processed must\n        # survive the cleanup after loop detection.\n        am = _make_am((\"a\", \"a\"))\n        cmd_list = RewindableList([[\"pre_existing\"]])\n        result = AliasProcessor().process([\"a\"], am, cmd_list)\n        assert result is False\n        # The pre-existing command should still be there\n        assert [\"pre_existing\"] in list(cmd_list)\n        capsys.readouterr()  # suppress output\n\n    def test_non_looping_alias_not_misidentified(self):\n        # Alias \"a\" appears twice in a sequence but the second use is a\n        # separate invocation (different seq_number context) — not a loop.\n        # Simplest proxy: just confirm a valid two-sequence alias works.\n        am = _make_am((\"a\", \"play\"))\n        cmd_list = RewindableList([])\n        result = AliasProcessor().process([\"a\"], am, cmd_list)\n        assert result is True\n\n\n# ---------------------------------------------------------------------------\n# AliasProcessor — _remove_added_commands\n# ---------------------------------------------------------------------------\n\n\nclass TestAliasProcessorRemoveAdded:\n    def test_removes_exact_command_count(self):\n        cmd_list = RewindableList([[\"volume\", \"50\"], [\"play\"], [\"pause\"]])\n        ap = AliasProcessor()\n        ap._command_count = 2\n        ap._command_list = cmd_list\n        ap._remove_added_commands()\n        assert list(cmd_list) == [[\"pause\"]]\n\n    def test_zero_count_removes_nothing(self):\n        cmd_list = RewindableList([[\"play\"]])\n        ap = AliasProcessor()\n        ap._command_count = 0\n        ap._command_list = cmd_list\n        ap._remove_added_commands()\n        assert list(cmd_list) == [[\"play\"]]\n\n    def test_command_count_after_successful_expansion(self):\n        # Two-sequence alias should report _command_count == 2\n        am = _make_am((\"pv\", \"play : volume 50\"))\n        cmd_list = RewindableList([])\n        ap = AliasProcessor()\n        ap.process([\"pv\"], am, cmd_list)\n        assert ap._command_count == 2\n"
  },
  {
    "path": "tests/test_m3u_parser.py",
    "content": "\"\"\"Tests for m3u_parser.py.\"\"\"\n\nimport os\nimport tempfile\n\nimport pytest\n\nimport soco_cli.utils as utils\nfrom soco_cli.m3u_parser import Track, parse_m3u\n\n\n@pytest.fixture(autouse=True)\ndef api_mode():\n    original = utils.API\n    utils.API = True\n    yield\n    utils.API = original\n\n\ndef _write_tempfile(content, suffix=\".m3u\"):\n    \"\"\"Write content to a named temporary file and return its path.\"\"\"\n    f = tempfile.NamedTemporaryFile(\n        mode=\"w\", suffix=suffix, delete=False, encoding=\"utf-8\"\n    )\n    f.write(content)\n    f.close()\n    return f.name\n\n\n# ---------------------------------------------------------------------------\n# Track\n# ---------------------------------------------------------------------------\n\n\nclass TestTrack:\n    def test_attributes_set(self):\n        t = Track(\"300\", \"My Song\", \"/path/to/song.mp3\")\n        assert t.length == \"300\"\n        assert t.title == \"My Song\"\n        assert t.path == \"/path/to/song.mp3\"\n\n    def test_none_values_allowed(self):\n        t = Track(None, None, None)\n        assert t.length is None\n        assert t.title is None\n        assert t.path is None\n\n\n# ---------------------------------------------------------------------------\n# parse_m3u — .m3u files (require #EXTM3U header)\n# ---------------------------------------------------------------------------\n\n\nclass TestParseMp3u:\n    def test_valid_m3u_with_extinf(self):\n        content = \"#EXTM3U\\n\" \"#EXTINF:300,Artist - Title\\n\" \"/music/track.mp3\\n\"\n        path = _write_tempfile(content)\n        try:\n            tracks = parse_m3u(path)\n        finally:\n            os.unlink(path)\n        assert len(tracks) == 1\n        assert tracks[0].length == \"300\"\n        assert tracks[0].title == \"Artist - Title\"\n        assert tracks[0].path == \"/music/track.mp3\"\n\n    def test_multiple_tracks(self):\n        content = (\n            \"#EXTM3U\\n\"\n            \"#EXTINF:120,Track One\\n\"\n            \"/music/one.mp3\\n\"\n            \"#EXTINF:240,Track Two\\n\"\n            \"/music/two.flac\\n\"\n        )\n        path = _write_tempfile(content)\n        try:\n            tracks = parse_m3u(path)\n        finally:\n            os.unlink(path)\n        assert len(tracks) == 2\n        assert tracks[0].title == \"Track One\"\n        assert tracks[1].title == \"Track Two\"\n\n    def test_missing_extinf_header_still_adds_track(self):\n        # A path line without a preceding #EXTINF creates a Track with None metadata\n        content = \"#EXTM3U\\n/music/track.mp3\\n\"\n        path = _write_tempfile(content)\n        try:\n            tracks = parse_m3u(path)\n        finally:\n            os.unlink(path)\n        assert len(tracks) == 1\n        assert tracks[0].path == \"/music/track.mp3\"\n        assert tracks[0].length is None\n        assert tracks[0].title is None\n\n    def test_comment_lines_skipped(self):\n        content = (\n            \"#EXTM3U\\n\"\n            \"# This is a comment\\n\"\n            \"#EXTINF:60,My Track\\n\"\n            \"/music/track.mp3\\n\"\n        )\n        path = _write_tempfile(content)\n        try:\n            tracks = parse_m3u(path)\n        finally:\n            os.unlink(path)\n        assert len(tracks) == 1\n\n    def test_blank_lines_skipped(self):\n        content = \"#EXTM3U\\n\" \"\\n\" \"#EXTINF:60,My Track\\n\" \"\\n\" \"/music/track.mp3\\n\"\n        path = _write_tempfile(content)\n        try:\n            tracks = parse_m3u(path)\n        finally:\n            os.unlink(path)\n        assert len(tracks) == 1\n\n    def test_missing_extm3u_header_returns_empty(self, capsys):\n        content = \"#EXTINF:60,My Track\\n/music/track.mp3\\n\"\n        path = _write_tempfile(content, suffix=\".m3u\")\n        try:\n            tracks = parse_m3u(path)\n        finally:\n            os.unlink(path)\n        assert tracks == []\n        assert \"lacks '#EXTM3U'\" in capsys.readouterr().err\n\n    def test_empty_playlist(self):\n        content = \"#EXTM3U\\n\"\n        path = _write_tempfile(content)\n        try:\n            tracks = parse_m3u(path)\n        finally:\n            os.unlink(path)\n        assert tracks == []\n\n    def test_title_with_comma_preserved(self):\n        # split(\",\", 1) means only the first comma is the EXTINF delimiter\n        content = \"#EXTM3U\\n#EXTINF:180,Artist, Title With Comma\\n/music/t.mp3\\n\"\n        path = _write_tempfile(content)\n        try:\n            tracks = parse_m3u(path)\n        finally:\n            os.unlink(path)\n        assert tracks[0].title == \"Artist, Title With Comma\"\n\n    def test_path_whitespace_stripped(self):\n        content = \"#EXTM3U\\n#EXTINF:60,T\\n  /music/track.mp3  \\n\"\n        path = _write_tempfile(content)\n        try:\n            tracks = parse_m3u(path)\n        finally:\n            os.unlink(path)\n        assert tracks[0].path == \"/music/track.mp3\"\n\n    # --- non-.m3u extension: header not required ---\n\n    def test_non_m3u_extension_skips_header_check(self):\n        content = \"#EXTINF:60,My Track\\n/music/track.mp3\\n\"\n        path = _write_tempfile(content, suffix=\".txt\")\n        try:\n            tracks = parse_m3u(path)\n        finally:\n            os.unlink(path)\n        assert len(tracks) == 1\n\n    def test_m3u8_extension_requires_header(self, capsys):\n        content = \"#EXTINF:60,My Track\\n/music/track.mp3\\n\"\n        path = _write_tempfile(content, suffix=\".m3u8\")\n        try:\n            tracks = parse_m3u(path)\n        finally:\n            os.unlink(path)\n        assert tracks == []\n        capsys.readouterr()  # suppress output\n"
  },
  {
    "path": "tests/test_match_speaker_names.py",
    "content": "\"\"\"Tests for match_speaker_names.py.\"\"\"\n\nfrom soco_cli.match_speaker_names import speaker_name_matches\n\n\nclass TestSpeakerNameMatches:\n    # --- exact match ---\n\n    def test_exact_match(self):\n        match, exact = speaker_name_matches(\"Kitchen\", \"Kitchen\")\n        assert match is True\n        assert exact is True\n\n    def test_exact_match_with_spaces(self):\n        match, exact = speaker_name_matches(\"Living Room\", \"Living Room\")\n        assert match is True\n        assert exact is True\n\n    # --- case-insensitive exact match ---\n\n    def test_case_insensitive_match(self):\n        match, exact = speaker_name_matches(\"kitchen\", \"Kitchen\")\n        assert match is True\n        assert exact is True\n\n    def test_uppercase_supplied(self):\n        match, exact = speaker_name_matches(\"KITCHEN\", \"Kitchen\")\n        assert match is True\n        assert exact is True\n\n    def test_mixed_case(self):\n        match, exact = speaker_name_matches(\"kItChEn\", \"Kitchen\")\n        assert match is True\n        assert exact is True\n\n    # --- apostrophe normalisation exact match ---\n\n    def test_straight_apostrophe_matches_curly(self):\n        # Supplied uses straight apostrophe; stored uses curly/Unicode\n        match, exact = speaker_name_matches(\"Bob's Room\", \"Bob\\u2019s Room\")\n        assert match is True\n        assert exact is True\n\n    def test_curly_apostrophe_matches_straight(self):\n        match, exact = speaker_name_matches(\"Bob\\u2019s Room\", \"Bob's Room\")\n        assert match is True\n        assert exact is True\n\n    # --- partial start-of-name match ---\n\n    def test_partial_start_of_name(self):\n        match, exact = speaker_name_matches(\"Kit\", \"Kitchen\")\n        assert match is True\n        assert exact is False\n\n    def test_partial_first_word_of_multi_word_name(self):\n        match, exact = speaker_name_matches(\"living\", \"Living Room\")\n        assert match is True\n        assert exact is False\n\n    # --- partial anywhere-in-name match ---\n\n    def test_partial_middle_of_name(self):\n        match, exact = speaker_name_matches(\"itchen\", \"Kitchen\")\n        assert match is True\n        assert exact is False\n\n    def test_partial_end_of_name(self):\n        match, exact = speaker_name_matches(\"room\", \"Living Room\")\n        assert match is True\n        assert exact is False\n\n    def test_partial_substring_match(self):\n        match, exact = speaker_name_matches(\"ing Ro\", \"Living Room\")\n        assert match is True\n        assert exact is False\n\n    # --- no match ---\n\n    def test_no_match(self):\n        match, exact = speaker_name_matches(\"Bedroom\", \"Kitchen\")\n        assert match is False\n        assert exact is False\n\n    def test_no_match_superset_not_counted(self):\n        # Supplied is longer than stored — not a partial match\n        match, exact = speaker_name_matches(\"Kitchen Cabinet\", \"Kitchen\")\n        assert match is False\n        assert exact is False\n\n    # --- edge cases ---\n\n    def test_empty_supplied_matches_anything_partial(self):\n        # Empty string is always a start-of-name match\n        match, exact = speaker_name_matches(\"\", \"Kitchen\")\n        assert match is True\n        assert exact is False\n\n    def test_single_character_match(self):\n        match, exact = speaker_name_matches(\"K\", \"Kitchen\")\n        assert match is True\n        assert exact is False\n\n    def test_identical_single_character(self):\n        match, exact = speaker_name_matches(\"K\", \"K\")\n        assert match is True\n        assert exact is True\n"
  },
  {
    "path": "tests/test_play_local_file.py",
    "content": "\"\"\"Tests for play_local_file.py — testable portions only.\"\"\"\n\nimport pytest\n\nfrom soco_cli.play_local_file import SUPPORTED_TYPES, is_supported_type\n\n\nclass TestIsSupportedType:\n    # --- supported types ---\n\n    def test_all_supported_types_recognised(self):\n        for ext in SUPPORTED_TYPES:\n            assert is_supported_type(\"track.\" + ext.lower()), ext\n            assert is_supported_type(\"track.\" + ext.upper()), ext\n\n    def test_mp3_lowercase(self):\n        assert is_supported_type(\"song.mp3\") is True\n\n    def test_flac_uppercase(self):\n        assert is_supported_type(\"song.FLAC\") is True\n\n    def test_wav_mixed_case(self):\n        assert is_supported_type(\"song.Wav\") is True\n\n    def test_path_with_directories(self):\n        assert is_supported_type(\"/home/user/music/song.mp3\") is True\n\n    def test_path_with_spaces(self):\n        assert is_supported_type(\"/home/user/my music/song.flac\") is True\n\n    # --- unsupported types ---\n\n    def test_txt_not_supported(self):\n        assert is_supported_type(\"file.txt\") is False\n\n    def test_mp4_is_supported(self):\n        assert is_supported_type(\"video.mp4\") is True\n\n    def test_avi_not_supported(self):\n        assert is_supported_type(\"video.avi\") is False\n\n    def test_no_extension(self):\n        assert is_supported_type(\"noextension\") is False\n\n    def test_dot_only(self):\n        assert is_supported_type(\"file.\") is False\n\n    def test_extension_only_dot(self):\n        assert is_supported_type(\".mp3\") is True\n\n    def test_multiple_dots_uses_last_extension(self):\n        # The function checks the whole uppercased filename for endings\n        assert is_supported_type(\"my.song.mp3\") is True\n\n    def test_empty_string(self):\n        assert is_supported_type(\"\") is False\n"
  },
  {
    "path": "tests/test_speakers.py",
    "content": "\"\"\"Tests for speakers.py — Speakers class.\"\"\"\n\nimport os\nimport tempfile\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom soco_cli.speakers import SonosDevice, Speakers\n\n\ndef _make_device(name, ip=\"192.168.1.1\", visible=True, household=\"HH1\"):\n    return SonosDevice(\n        household_id=household,\n        ip_address=ip,\n        speaker_name=name,\n        is_visible=visible,\n        model_name=\"Sonos One\",\n        display_version=\"15.0\",\n    )\n\n\n# ---------------------------------------------------------------------------\n# is_ipv4_address\n# ---------------------------------------------------------------------------\n\n\nclass TestIsIpv4Address:\n    def test_valid_ip(self):\n        assert Speakers.is_ipv4_address(\"192.168.1.1\") is True\n\n    def test_valid_ip_class_a(self):\n        assert Speakers.is_ipv4_address(\"10.0.0.1\") is True\n\n    def test_valid_loopback(self):\n        assert Speakers.is_ipv4_address(\"127.0.0.1\") is True\n\n    def test_cidr_notation(self):\n        # IPv4Network also accepts CIDR notation\n        assert Speakers.is_ipv4_address(\"192.168.1.0/24\") is True\n\n    def test_hostname_returns_false(self):\n        assert Speakers.is_ipv4_address(\"sonos-kitchen\") is False\n\n    def test_ipv6_returns_false(self):\n        assert Speakers.is_ipv4_address(\"::1\") is False\n\n    def test_empty_string_returns_false(self):\n        assert Speakers.is_ipv4_address(\"\") is False\n\n    def test_out_of_range_octet_returns_false(self):\n        assert Speakers.is_ipv4_address(\"999.0.0.1\") is False\n\n    def test_partial_ip_returns_false(self):\n        assert Speakers.is_ipv4_address(\"192.168.1\") is False\n\n\n# ---------------------------------------------------------------------------\n# save / load\n# ---------------------------------------------------------------------------\n\n\nclass TestSaveLoad:\n    def test_save_returns_false_when_no_speakers(self):\n        with tempfile.TemporaryDirectory() as tmpdir:\n            s = Speakers(save_directory=tmpdir + \"/\", save_file=\"test.pickle\")\n            assert s.save() is False\n\n    def test_save_and_load_round_trip(self):\n        device = _make_device(\"Kitchen\", \"192.168.1.10\")\n        with tempfile.TemporaryDirectory() as tmpdir:\n            s = Speakers(save_directory=tmpdir + \"/\", save_file=\"test.pickle\")\n            s._speakers = [device]\n            assert s.save() is True\n\n            s2 = Speakers(save_directory=tmpdir + \"/\", save_file=\"test.pickle\")\n            assert s2.load() is True\n            assert len(s2.speakers) == 1\n            assert s2.speakers[0].speaker_name == \"Kitchen\"\n\n    def test_load_returns_false_when_no_file(self):\n        with tempfile.TemporaryDirectory() as tmpdir:\n            s = Speakers(save_directory=tmpdir + \"/\", save_file=\"missing.pickle\")\n            assert s.load() is False\n\n    def test_speaker_cache_file_exists_property(self):\n        with tempfile.TemporaryDirectory() as tmpdir:\n            s = Speakers(save_directory=tmpdir + \"/\", save_file=\"test.pickle\")\n            assert s.speaker_cache_file_exists is False\n            s._speakers = [_make_device(\"Kitchen\")]\n            s.save()\n            assert s.speaker_cache_file_exists is True\n\n    def test_speaker_cache_loaded_property(self):\n        with tempfile.TemporaryDirectory() as tmpdir:\n            s = Speakers(save_directory=tmpdir + \"/\", save_file=\"test.pickle\")\n            assert s.speaker_cache_loaded is False\n            s._speakers = [_make_device(\"Kitchen\")]\n            assert s.speaker_cache_loaded is True\n\n\n# ---------------------------------------------------------------------------\n# clear / remove_save_file\n# ---------------------------------------------------------------------------\n\n\nclass TestClearAndRemove:\n    def test_clear_empties_speakers_list(self):\n        s = Speakers()\n        s._speakers = [_make_device(\"Kitchen\")]\n        s.clear()\n        assert s.speakers == []\n\n    def test_remove_save_file(self):\n        with tempfile.TemporaryDirectory() as tmpdir:\n            s = Speakers(save_directory=tmpdir + \"/\", save_file=\"test.pickle\")\n            s._speakers = [_make_device(\"Kitchen\")]\n            s.save()\n            assert os.path.exists(s.save_pathname)\n            s.remove_save_file()\n            assert not os.path.exists(s.save_pathname)\n\n\n# ---------------------------------------------------------------------------\n# rename\n# ---------------------------------------------------------------------------\n\n\nclass TestRename:\n    def test_rename_existing_speaker(self):\n        with tempfile.TemporaryDirectory() as tmpdir:\n            s = Speakers(save_directory=tmpdir + \"/\", save_file=\"test.pickle\")\n            s._speakers = [_make_device(\"Kitchen\")]\n            result = s.rename(\"Kitchen\", \"Dining Room\")\n            assert result is True\n            names = [sp.speaker_name for sp in s.speakers]\n            assert \"Dining Room\" in names\n            assert \"Kitchen\" not in names\n\n    def test_rename_nonexistent_returns_false(self):\n        s = Speakers()\n        s._speakers = [_make_device(\"Kitchen\")]\n        result = s.rename(\"Bedroom\", \"Office\")\n        assert result is False\n\n    def test_rename_with_apostrophe_normalisation(self):\n        with tempfile.TemporaryDirectory() as tmpdir:\n            s = Speakers(save_directory=tmpdir + \"/\", save_file=\"test.pickle\")\n            # Stored name uses curly apostrophe\n            s._speakers = [_make_device(\"Bob\\u2019s Room\")]\n            # Look up with straight apostrophe\n            result = s.rename(\"Bob's Room\", \"Guest Room\")\n            assert result is True\n\n\n# ---------------------------------------------------------------------------\n# subnets setter\n# ---------------------------------------------------------------------------\n\n\nclass TestSubnetsSetter:\n    def test_none_subnets_sets_none(self):\n        s = Speakers(subnets=None)\n        assert s.subnets is None\n\n    def test_valid_subnets_kept(self):\n        s = Speakers(subnets=[\"192.168.1.0/24\", \"10.0.0.0/8\"])\n        assert \"192.168.1.0/24\" in s.subnets\n        assert \"10.0.0.0/8\" in s.subnets\n\n    def test_invalid_subnets_removed(self):\n        s = Speakers(subnets=[\"192.168.1.0/24\", \"not_a_network\"])\n        assert \"192.168.1.0/24\" in s.subnets\n        assert \"not_a_network\" not in s.subnets\n\n    def test_all_invalid_subnets_leaves_empty_list(self):\n        s = Speakers(subnets=[\"bad1\", \"bad2\"])\n        assert s.subnets == []\n\n\n# ---------------------------------------------------------------------------\n# get_all_speaker_names\n# ---------------------------------------------------------------------------\n\n\nclass TestGetAllSpeakerNames:\n    def test_returns_sorted_visible_names(self):\n        s = Speakers()\n        s._speakers = [\n            _make_device(\"Zebra\", visible=True),\n            _make_device(\"Apple\", visible=True),\n            _make_device(\"Mango\", visible=True),\n        ]\n        names = s.get_all_speaker_names()\n        assert names == [\"Apple\", \"Mango\", \"Zebra\"]\n\n    def test_invisible_speakers_excluded(self):\n        s = Speakers()\n        s._speakers = [\n            _make_device(\"Visible\", visible=True),\n            _make_device(\"Hidden\", visible=False),\n        ]\n        names = s.get_all_speaker_names()\n        assert \"Visible\" in names\n        assert \"Hidden\" not in names\n\n    def test_empty_list_returns_empty(self):\n        s = Speakers()\n        assert s.get_all_speaker_names() == []\n\n\n# ---------------------------------------------------------------------------\n# find (uses soco.SoCo — patched)\n# ---------------------------------------------------------------------------\n\n\nclass TestFind:\n    def test_exact_match_returns_soco_object(self):\n        s = Speakers()\n        s._speakers = [_make_device(\"Kitchen\", ip=\"192.168.1.10\")]\n        mock_soco = MagicMock()\n        with patch(\"soco_cli.speakers.soco.SoCo\", return_value=mock_soco):\n            result = s.find(\"Kitchen\")\n        assert result is mock_soco\n\n    def test_partial_match_returns_soco_object(self):\n        s = Speakers()\n        s._speakers = [_make_device(\"Kitchen\", ip=\"192.168.1.10\")]\n        mock_soco = MagicMock()\n        with patch(\"soco_cli.speakers.soco.SoCo\", return_value=mock_soco):\n            result = s.find(\"Kit\")\n        assert result is mock_soco\n\n    def test_no_match_returns_none(self):\n        s = Speakers()\n        s._speakers = [_make_device(\"Kitchen\")]\n        result = s.find(\"Bedroom\")\n        assert result is None\n\n    def test_invisible_speaker_excluded_by_default(self):\n        s = Speakers()\n        s._speakers = [_make_device(\"Hidden\", visible=False)]\n        result = s.find(\"Hidden\")\n        assert result is None\n\n    def test_invisible_speaker_found_when_not_requiring_visible(self):\n        s = Speakers()\n        s._speakers = [_make_device(\"Hidden\", ip=\"192.168.1.20\", visible=False)]\n        mock_soco = MagicMock()\n        with patch(\"soco_cli.speakers.soco.SoCo\", return_value=mock_soco):\n            result = s.find(\"Hidden\", require_visible=False)\n        assert result is mock_soco\n\n    def test_ambiguous_partial_match_returns_none(self, capsys):\n        s = Speakers()\n        s._speakers = [\n            _make_device(\"Kitchen Front\", ip=\"192.168.1.10\"),\n            _make_device(\"Kitchen Back\", ip=\"192.168.1.11\"),\n        ]\n        with patch(\"soco_cli.speakers.soco.SoCo\", return_value=MagicMock()):\n            result = s.find(\"Kitchen\")\n        assert result is None\n        assert \"ambiguous\" in capsys.readouterr().out\n"
  },
  {
    "path": "tests/test_utils.py",
    "content": "import unittest\n\nfrom soco_cli.utils import convert_to_seconds\n\n\nclass ConvertToSeconds(unittest.TestCase):\n    def test_colon_separated(self):\n        assert convert_to_seconds(\"00:01:01\") == 61\n        assert convert_to_seconds(\"01:30\") == 90 * 60\n        with self.assertRaises(ValueError):\n            seconds = convert_to_seconds(\"\")\n        assert convert_to_seconds(\"00:61:65\") == (61 * 60) + 65\n\n    def test_hms(self):\n        assert convert_to_seconds(\"12s\") == 12\n        assert convert_to_seconds(\"3m\") == 3 * 60\n        assert convert_to_seconds(\"2h\") == 2 * 60 * 60\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "tests/test_utils_extended.py",
    "content": "\"\"\"Extended tests for utils.py — covering functions not tested in test_utils.py.\"\"\"\n\nimport argparse\nimport datetime as real_datetime\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nimport soco_cli.utils as utils\nfrom soco_cli.utils import (\n    RewindableList,\n    SpeakerCache,\n    check_args,\n    convert_true_false,\n    create_list_of_items_from_range,\n    create_time_from_str,\n    find_by_name,\n    forget_event_sub,\n    one_or_more_parameters,\n    one_or_two_parameters,\n    one_parameter,\n    playback_state,\n    pretty_print_values,\n    remember_event_sub,\n    seconds_until,\n    two_parameters,\n    unsub_all_remembered_event_subs,\n    zero_one_or_two_parameters,\n    zero_or_one_parameter,\n    zero_parameters,\n)\n\n\n@pytest.fixture(autouse=True)\ndef api_mode():\n    original = utils.API\n    utils.API = True\n    yield\n    utils.API = original\n\n\n@pytest.fixture(autouse=True)\ndef reset_subs():\n    \"\"\"Ensure the global SUBS_LIST is empty between tests.\"\"\"\n    utils.SUBS_LIST = set()\n    yield\n    utils.SUBS_LIST = set()\n\n\n# ---------------------------------------------------------------------------\n# create_time_from_str\n# ---------------------------------------------------------------------------\n\n\nclass TestCreateTimeFromStr:\n    def test_hh_mm(self):\n        t = create_time_from_str(\"13:30\")\n        assert t == real_datetime.time(13, 30, 0)\n\n    def test_hh_mm_ss(self):\n        t = create_time_from_str(\"08:15:45\")\n        assert t == real_datetime.time(8, 15, 45)\n\n    def test_midnight(self):\n        t = create_time_from_str(\"00:00:00\")\n        assert t == real_datetime.time(0, 0, 0)\n\n    def test_end_of_day(self):\n        t = create_time_from_str(\"23:59:59\")\n        assert t == real_datetime.time(23, 59, 59)\n\n    def test_no_colon_raises(self):\n        with pytest.raises(ValueError):\n            create_time_from_str(\"1330\")\n\n    def test_too_many_parts_raises(self):\n        with pytest.raises(ValueError):\n            create_time_from_str(\"13:30:00:00\")\n\n    def test_out_of_range_hour_raises(self):\n        with pytest.raises(ValueError):\n            create_time_from_str(\"25:00:00\")\n\n    def test_out_of_range_minute_raises(self):\n        with pytest.raises(ValueError):\n            create_time_from_str(\"12:60:00\")\n\n    def test_out_of_range_second_raises(self):\n        with pytest.raises(ValueError):\n            create_time_from_str(\"12:30:60\")\n\n    def test_non_numeric_raises(self):\n        with pytest.raises((ValueError, TypeError)):\n            create_time_from_str(\"HH:MM\")\n\n\n# ---------------------------------------------------------------------------\n# seconds_until\n# ---------------------------------------------------------------------------\n\n\nclass TestSecondsUntil:\n    def _mock_now(self, h, m, s=0):\n        \"\"\"Patch datetime.datetime.now() to return a fixed time.\"\"\"\n        mock_dt = MagicMock()\n        mock_dt.time.return_value = real_datetime.time(h, m, s)\n        return mock_dt\n\n    def test_future_time_returns_positive_seconds(self):\n        with patch(\"soco_cli.utils.datetime\") as mock_dt_mod:\n            mock_dt_mod.time = real_datetime.time\n            mock_dt_mod.timedelta = real_datetime.timedelta\n            mock_dt_mod.datetime.now.return_value = self._mock_now(12, 0, 0)\n            result = seconds_until(\"13:00:00\")\n        assert result == 3600\n\n    def test_past_time_wraps_to_next_day(self):\n        with patch(\"soco_cli.utils.datetime\") as mock_dt_mod:\n            mock_dt_mod.time = real_datetime.time\n            mock_dt_mod.timedelta = real_datetime.timedelta\n            mock_dt_mod.datetime.now.return_value = self._mock_now(14, 0, 0)\n            result = seconds_until(\"13:00:00\")\n        # 13:00 has passed; next occurrence is 23 hours away\n        assert result == 23 * 3600\n\n    def test_hh_mm_format(self):\n        with patch(\"soco_cli.utils.datetime\") as mock_dt_mod:\n            mock_dt_mod.time = real_datetime.time\n            mock_dt_mod.timedelta = real_datetime.timedelta\n            mock_dt_mod.datetime.now.return_value = self._mock_now(10, 0, 0)\n            result = seconds_until(\"10:30\")\n        assert result == 1800\n\n    def test_invalid_format_raises(self):\n        with pytest.raises(ValueError):\n            seconds_until(\"not_a_time\")\n\n\n# ---------------------------------------------------------------------------\n# convert_true_false\n# ---------------------------------------------------------------------------\n\n\nclass TestConvertTrueFalse:\n    def test_yes_or_no_true(self):\n        assert convert_true_false(True) == \"Yes\"\n\n    def test_yes_or_no_false(self):\n        assert convert_true_false(False) == \"No\"\n\n    def test_on_or_off_true(self):\n        assert convert_true_false(True, \"onoroff\") == \"on\"\n\n    def test_on_or_off_false(self):\n        assert convert_true_false(False, \"onoroff\") == \"off\"\n\n    def test_unknown_conversion_returns_none(self):\n        assert convert_true_false(True, \"unknown\") is None\n\n\n# ---------------------------------------------------------------------------\n# playback_state\n# ---------------------------------------------------------------------------\n\n\nclass TestPlaybackState:\n    def test_stopped(self):\n        assert playback_state(\"STOPPED\") == \"stopped\"\n\n    def test_paused(self):\n        assert playback_state(\"PAUSED_PLAYBACK\") == \"paused\"\n\n    def test_playing(self):\n        assert playback_state(\"PLAYING\") == \"in progress\"\n\n    def test_transitioning(self):\n        assert playback_state(\"TRANSITIONING\") == \"in a transitioning state\"\n\n    def test_unknown_state(self):\n        assert playback_state(\"SOMETHING_ELSE\") == \"unknown\"\n\n\n# ---------------------------------------------------------------------------\n# find_by_name\n# ---------------------------------------------------------------------------\n\n\nclass TestFindByName:\n    def _item(self, title):\n        m = MagicMock()\n        m.title = title\n        return m\n\n    def test_strict_match_found(self):\n        items = [self._item(\"Jazz\"), self._item(\"Classical\")]\n        result = find_by_name(items, \"Jazz\")\n        assert result.title == \"Jazz\"\n\n    def test_fuzzy_match_case_insensitive(self):\n        items = [self._item(\"Classic FM\"), self._item(\"Jazz Radio\")]\n        result = find_by_name(items, \"classic\")\n        assert result.title == \"Classic FM\"\n\n    def test_strict_match_takes_priority_over_fuzzy(self):\n        items = [self._item(\"My Jazz\"), self._item(\"Jazz\")]\n        # \"Jazz\" is an exact match for items[1]; items[0] is fuzzy\n        result = find_by_name(items, \"Jazz\")\n        assert result.title == \"Jazz\"\n\n    def test_not_found_returns_none(self):\n        items = [self._item(\"Jazz\"), self._item(\"Classical\")]\n        assert find_by_name(items, \"Rock\") is None\n\n    def test_empty_list_returns_none(self):\n        assert find_by_name([], \"Jazz\") is None\n\n    def test_fuzzy_substring_match(self):\n        items = [self._item(\"BBC Radio 6 Music\")]\n        result = find_by_name(items, \"Radio 6\")\n        assert result is not None\n\n\n# ---------------------------------------------------------------------------\n# create_list_of_items_from_range\n# ---------------------------------------------------------------------------\n\n\nclass TestCreateListOfItemsFromRange:\n    def test_single_item(self):\n        assert create_list_of_items_from_range(\"3\", 5) == [3]\n\n    def test_range(self):\n        assert create_list_of_items_from_range(\"2-4\", 5) == [2, 3, 4]\n\n    def test_reversed_range_normalised(self):\n        assert create_list_of_items_from_range(\"4-2\", 5) == [2, 3, 4]\n\n    def test_multiple_items_comma_separated(self):\n        assert create_list_of_items_from_range(\"1,3,5\", 5) == [1, 3, 5]\n\n    def test_mix_of_single_and_range(self):\n        result = create_list_of_items_from_range(\"1,3-5\", 5)\n        assert result == [1, 3, 4, 5]\n\n    def test_all_keyword(self):\n        assert create_list_of_items_from_range(\"all\", 4) == [1, 2, 3, 4]\n\n    def test_all_keyword_case_insensitive(self):\n        assert create_list_of_items_from_range(\"ALL\", 3) == [1, 2, 3]\n\n    def test_duplicates_removed(self):\n        result = create_list_of_items_from_range(\"1,1,2\", 5)\n        assert result == [1, 2]\n\n    def test_item_out_of_range_raises(self):\n        with pytest.raises(IndexError):\n            create_list_of_items_from_range(\"6\", 5)\n\n    def test_zero_out_of_range_raises(self):\n        with pytest.raises(IndexError):\n            create_list_of_items_from_range(\"0\", 5)\n\n    def test_range_exceeds_limit_raises(self):\n        with pytest.raises(IndexError):\n            create_list_of_items_from_range(\"3-7\", 5)\n\n    def test_result_is_sorted(self):\n        result = create_list_of_items_from_range(\"5,1,3\", 5)\n        assert result == sorted(result)\n\n\n# ---------------------------------------------------------------------------\n# pretty_print_values\n# ---------------------------------------------------------------------------\n\n\nclass TestPrettyPrintValues:\n    def test_basic_output(self, capsys):\n        pretty_print_values({\"Name\": \"Kitchen\", \"IP\": \"192.168.1.1\"})\n        out = capsys.readouterr().out\n        assert \"Name\" in out\n        assert \"Kitchen\" in out\n        assert \"IP\" in out\n        assert \"192.168.1.1\" in out\n\n    def test_empty_dict_prints_nothing(self, capsys):\n        pretty_print_values({})\n        assert capsys.readouterr().out == \"\"\n\n    def test_values_aligned(self, capsys):\n        # The spacer pads after the separator so that values start at the same column,\n        # not the colons (which immediately follow the key).\n        pretty_print_values({\"Short\": \"a_val\", \"LongerKey\": \"b_val\"})\n        out = capsys.readouterr().out\n        lines = [l for l in out.splitlines() if l.strip()]\n        value_positions = [l.index(\"_val\") - 1 for l in lines]\n        assert len(set(value_positions)) == 1\n\n    def test_sort_by_key(self, capsys):\n        pretty_print_values({\"Z\": \"last\", \"A\": \"first\"}, sort_by_key=True)\n        out = capsys.readouterr().out\n        assert out.index(\"A\") < out.index(\"Z\")\n\n\n# ---------------------------------------------------------------------------\n# RewindableList\n# ---------------------------------------------------------------------------\n\n\nclass TestRewindableList:\n    def test_len(self):\n        rl = RewindableList([1, 2, 3])\n        assert len(rl) == 3\n\n    def test_getitem(self):\n        rl = RewindableList([\"a\", \"b\", \"c\"])\n        assert rl[0] == \"a\"\n        assert rl[2] == \"c\"\n\n    def test_iteration(self):\n        rl = RewindableList([10, 20, 30])\n        assert list(rl) == [10, 20, 30]\n\n    def test_iteration_rewinds_on_each_iter_call(self):\n        rl = RewindableList([1, 2])\n        list(rl)  # consume\n        assert list(rl) == [1, 2]  # rewinds automatically\n\n    def test_rewind_resets_index(self):\n        rl = RewindableList([1, 2, 3])\n        next(rl)\n        next(rl)\n        rl.rewind()\n        assert rl.index() == 0\n\n    def test_rewind_to_valid_index(self):\n        rl = RewindableList([1, 2, 3])\n        rl.rewind_to(1)\n        assert rl.index() == 1\n\n    def test_rewind_to_zero_on_empty_list(self):\n        rl = RewindableList([])\n        rl.rewind_to(0)\n        assert rl.index() == 0\n\n    def test_rewind_to_invalid_raises(self):\n        rl = RewindableList([1, 2])\n        with pytest.raises(IndexError):\n            rl.rewind_to(5)\n\n    def test_index_increments_on_next(self):\n        rl = RewindableList([1, 2, 3])\n        assert rl.index() == 0\n        next(rl)\n        assert rl.index() == 1\n\n    def test_pop_next_removes_first_element(self):\n        rl = RewindableList([10, 20, 30])\n        item = rl.pop_next()\n        assert item == 10\n        assert list(rl) == [20, 30]\n\n    def test_pop_next_on_empty_raises(self):\n        rl = RewindableList([])\n        with pytest.raises(IndexError):\n            rl.pop_next()\n\n    def test_insert_at_zero_increments_index(self):\n        rl = RewindableList([2, 3])\n        next(rl)  # index -> 1\n        rl.insert(0, 1)\n        assert rl.index() == 2\n        assert rl[0] == 1\n\n    def test_insert_at_index_equal_to_current_increments_index(self):\n        # insert(index, e) increments _index when index <= _index (uses <=, not <)\n        rl = RewindableList([1, 3])\n        next(rl)  # _index -> 1\n        rl.insert(1, 2)\n        assert rl.index() == 2  # incremented because 1 <= 1\n        assert rl[1] == 2\n\n    def test_str_representation(self):\n        rl = RewindableList([1, 2])\n        assert \"[1, 2]\" in str(rl)\n\n    def test_stop_iteration_at_end(self):\n        rl = RewindableList([1])\n        next(rl)\n        with pytest.raises(StopIteration):\n            next(rl)\n\n\n# ---------------------------------------------------------------------------\n# Parameter decorators\n# ---------------------------------------------------------------------------\n\n\ndef _make_action(decorator):\n    \"\"\"Wrap a trivial function with the given decorator.\"\"\"\n\n    @decorator\n    def action(speaker, action_name, args, soco_fn, use_local):\n        return \"ok\"\n\n    return action\n\n\nclass TestParameterDecorators:\n    def test_zero_parameters_passes_on_empty(self):\n        f = _make_action(zero_parameters)\n        assert f(None, \"play\", [], None, False) == \"ok\"\n\n    def test_zero_parameters_fails_on_one(self):\n        f = _make_action(zero_parameters)\n        assert f(None, \"play\", [\"extra\"], None, False) is False\n\n    def test_one_parameter_passes_on_one(self):\n        f = _make_action(one_parameter)\n        assert f(None, \"vol\", [\"50\"], None, False) == \"ok\"\n\n    def test_one_parameter_fails_on_zero(self):\n        f = _make_action(one_parameter)\n        assert f(None, \"vol\", [], None, False) is False\n\n    def test_one_parameter_fails_on_two(self):\n        f = _make_action(one_parameter)\n        assert f(None, \"vol\", [\"50\", \"extra\"], None, False) is False\n\n    def test_zero_or_one_passes_on_zero(self):\n        f = _make_action(zero_or_one_parameter)\n        assert f(None, \"play\", [], None, False) == \"ok\"\n\n    def test_zero_or_one_passes_on_one(self):\n        f = _make_action(zero_or_one_parameter)\n        assert f(None, \"play\", [\"arg\"], None, False) == \"ok\"\n\n    def test_zero_or_one_fails_on_two(self):\n        f = _make_action(zero_or_one_parameter)\n        assert f(None, \"play\", [\"a\", \"b\"], None, False) is False\n\n    def test_one_or_two_passes_on_one(self):\n        f = _make_action(one_or_two_parameters)\n        assert f(None, \"fav\", [\"Jazz\"], None, False) == \"ok\"\n\n    def test_one_or_two_passes_on_two(self):\n        f = _make_action(one_or_two_parameters)\n        assert f(None, \"fav\", [\"Jazz\", \"next\"], None, False) == \"ok\"\n\n    def test_one_or_two_fails_on_zero(self):\n        f = _make_action(one_or_two_parameters)\n        assert f(None, \"fav\", [], None, False) is False\n\n    def test_two_parameters_passes_on_two(self):\n        f = _make_action(two_parameters)\n        assert f(None, \"eq\", [\"bass\", \"5\"], None, False) == \"ok\"\n\n    def test_two_parameters_fails_on_one(self):\n        f = _make_action(two_parameters)\n        assert f(None, \"eq\", [\"bass\"], None, False) is False\n\n    def test_zero_one_or_two_passes_on_zero(self):\n        f = _make_action(zero_one_or_two_parameters)\n        assert f(None, \"x\", [], None, False) == \"ok\"\n\n    def test_zero_one_or_two_passes_on_two(self):\n        f = _make_action(zero_one_or_two_parameters)\n        assert f(None, \"x\", [\"a\", \"b\"], None, False) == \"ok\"\n\n    def test_zero_one_or_two_fails_on_three(self):\n        f = _make_action(zero_one_or_two_parameters)\n        assert f(None, \"x\", [\"a\", \"b\", \"c\"], None, False) is False\n\n    def test_one_or_more_passes_on_one(self):\n        f = _make_action(one_or_more_parameters)\n        assert f(None, \"x\", [\"a\"], None, False) == \"ok\"\n\n    def test_one_or_more_passes_on_many(self):\n        f = _make_action(one_or_more_parameters)\n        assert f(None, \"x\", [\"a\", \"b\", \"c\", \"d\"], None, False) == \"ok\"\n\n    def test_one_or_more_fails_on_zero(self):\n        f = _make_action(one_or_more_parameters)\n        assert f(None, \"x\", [], None, False) is False\n\n\n# ---------------------------------------------------------------------------\n# check_args\n# ---------------------------------------------------------------------------\n\n\nclass TestCheckArgs:\n    def _make_args(self, min_netmask=24, timeout=1.0, threads=256):\n        args = MagicMock()\n        args.min_netmask = min_netmask\n        args.network_discovery_timeout = timeout\n        args.network_discovery_threads = threads\n        return args\n\n    def test_valid_args_returns_none(self):\n        assert check_args(self._make_args()) is None\n\n    def test_invalid_netmask_low(self):\n        result = check_args(self._make_args(min_netmask=-1))\n        assert result is not None\n        assert \"min_netmask\" in result\n\n    def test_invalid_netmask_high(self):\n        result = check_args(self._make_args(min_netmask=33))\n        assert result is not None\n\n    def test_boundary_netmask_0(self):\n        assert check_args(self._make_args(min_netmask=0)) is None\n\n    def test_boundary_netmask_32(self):\n        assert check_args(self._make_args(min_netmask=32)) is None\n\n    def test_invalid_timeout_negative(self):\n        result = check_args(self._make_args(timeout=-1.0))\n        assert result is not None\n\n    def test_invalid_timeout_too_large(self):\n        result = check_args(self._make_args(timeout=61.0))\n        assert result is not None\n\n    def test_invalid_threads_zero(self):\n        result = check_args(self._make_args(threads=0))\n        assert result is not None\n\n    def test_multiple_errors_reported(self):\n        result = check_args(self._make_args(min_netmask=-1, timeout=-1.0))\n        assert result is not None\n        assert len(result) > 10  # contains two error messages\n\n\n# ---------------------------------------------------------------------------\n# Event subscription tracking\n# ---------------------------------------------------------------------------\n\n\nclass TestEventSubscriptions:\n    def test_remember_and_forget(self):\n        sub = MagicMock()\n        remember_event_sub(sub)\n        assert sub in utils.SUBS_LIST\n        forget_event_sub(sub)\n        assert sub not in utils.SUBS_LIST\n\n    def test_forget_nonexistent_does_not_raise(self):\n        sub = MagicMock()\n        forget_event_sub(sub)  # should not raise\n\n    def test_unsub_all_calls_unsubscribe(self):\n        sub1 = MagicMock()\n        sub2 = MagicMock()\n        remember_event_sub(sub1)\n        remember_event_sub(sub2)\n        with patch(\"soco_cli.utils.event_unsubscribe\") as mock_unsub:\n            unsub_all_remembered_event_subs()\n        assert mock_unsub.call_count == 2\n        assert utils.SUBS_LIST == set()\n\n    def test_unsub_all_clears_list(self):\n        sub = MagicMock()\n        remember_event_sub(sub)\n        with patch(\"soco_cli.utils.event_unsubscribe\"):\n            unsub_all_remembered_event_subs()\n        assert utils.SUBS_LIST == set()\n\n\n# ---------------------------------------------------------------------------\n# SpeakerCache — in-memory operations (no network)\n# ---------------------------------------------------------------------------\n\n\nclass TestSpeakerCache:\n    def test_cache_speakers(self):\n        sc = SpeakerCache()\n        mock_spk = MagicMock()\n        mock_spk.player_name = \"Kitchen\"\n        sc.cache_speakers([mock_spk])\n        assert (mock_spk, \"Kitchen\") in sc._cache\n\n    def test_add_speaker(self):\n        sc = SpeakerCache()\n        mock_spk = MagicMock()\n        mock_spk.player_name = \"Bedroom\"\n        sc.add(mock_spk)\n        assert (mock_spk, \"Bedroom\") in sc._cache\n\n    def test_exists_false_when_empty(self):\n        sc = SpeakerCache()\n        assert sc.exists is False\n\n    def test_exists_true_after_add(self):\n        sc = SpeakerCache()\n        mock_spk = MagicMock()\n        mock_spk.player_name = \"Kitchen\"\n        sc.add(mock_spk)\n        assert sc.exists is True\n\n    def test_find_exact_match(self):\n        sc = SpeakerCache()\n        mock_spk = MagicMock()\n        sc._cache.add((mock_spk, \"Kitchen\"))\n        assert sc.find(\"Kitchen\") is mock_spk\n\n    def test_find_partial_match(self):\n        sc = SpeakerCache()\n        mock_spk = MagicMock()\n        sc._cache.add((mock_spk, \"Kitchen\"))\n        assert sc.find(\"Kit\") is mock_spk\n\n    def test_find_no_match_returns_none(self):\n        sc = SpeakerCache()\n        mock_spk = MagicMock()\n        sc._cache.add((mock_spk, \"Kitchen\"))\n        assert sc.find(\"Bedroom\") is None\n\n    def test_find_ambiguous_returns_none(self, capsys):\n        sc = SpeakerCache()\n        sc._cache.add((MagicMock(), \"Kitchen Front\"))\n        sc._cache.add((MagicMock(), \"Kitchen Back\"))\n        result = sc.find(\"Kitchen\")\n        assert result is None\n        capsys.readouterr()  # suppress error output\n\n    def test_rename_speaker(self):\n        sc = SpeakerCache()\n        mock_spk = MagicMock()\n        sc._cache.add((mock_spk, \"Kitchen\"))\n        result = sc.rename_speaker(\"Kitchen\", \"Dining Room\")\n        assert result is True\n        assert (mock_spk, \"Dining Room\") in sc._cache\n        assert (mock_spk, \"Kitchen\") not in sc._cache\n\n    def test_rename_nonexistent_returns_false(self):\n        sc = SpeakerCache()\n        assert sc.rename_speaker(\"Nonexistent\", \"New Name\") is False\n\n    def test_find_indirect_exact_match(self):\n        sc = SpeakerCache()\n        inner = MagicMock()\n        inner.player_name = \"Kitchen\"\n        outer = MagicMock()\n        outer.visible_zones = [inner]\n        sc._cache.add((outer, \"GroupName\"))\n        assert sc.find_indirect(\"Kitchen\") is inner\n\n    def test_find_indirect_no_match_returns_none(self):\n        sc = SpeakerCache()\n        inner = MagicMock()\n        inner.player_name = \"Kitchen\"\n        outer = MagicMock()\n        outer.visible_zones = [inner]\n        sc._cache.add((outer, \"GroupName\"))\n        assert sc.find_indirect(\"Bedroom\") is None\n"
  },
  {
    "path": "tests/test_wait_actions.py",
    "content": "\"\"\"Tests for wait_actions.py.\"\"\"\n\nfrom unittest.mock import patch\n\nimport pytest\n\nimport soco_cli.utils as utils\nfrom soco_cli.wait_actions import process_wait\n\n\n@pytest.fixture(autouse=True)\ndef api_mode():\n    original = utils.API\n    utils.API = True\n    yield\n    utils.API = original\n\n\nclass TestProcessWaitAndWaitFor:\n    @pytest.mark.parametrize(\"action\", [\"wait\", \"wait_for\"])\n    def test_waits_for_given_seconds(self, action):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([action, \"30s\"])\n        mock_sleep.assert_called_once_with(30.0)\n\n    def test_wait_minutes(self):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait\", \"2m\"])\n        mock_sleep.assert_called_once_with(120.0)\n\n    def test_wait_hours(self):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait\", \"1h\"])\n        mock_sleep.assert_called_once_with(3600.0)\n\n    def test_wait_hh_mm_ss_format(self):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait\", \"00:01:30\"])\n        mock_sleep.assert_called_once_with(90.0)\n\n    def test_wait_hh_mm_format(self):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait\", \"01:00\"])\n        mock_sleep.assert_called_once_with(3600.0)\n\n    def test_missing_parameter_skips_sleep(self, capsys):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait\"])\n        mock_sleep.assert_not_called()\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_too_many_parameters_skips_sleep(self, capsys):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait\", \"10s\", \"extra\"])\n        mock_sleep.assert_not_called()\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_invalid_duration_reports_error_then_sleeps_zero(self, capsys):\n        # Invalid duration: error_report is called, but duration stays 0\n        # and time.sleep(0) is still called (no early return after ValueError)\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait\", \"invalid\"])\n        assert \"Error\" in capsys.readouterr().err\n        mock_sleep.assert_called_once_with(0)\n\n\nclass TestProcessWaitUntil:\n    def test_waits_until_given_time(self):\n        with patch(\"soco_cli.wait_actions.seconds_until\", return_value=300) as mock_su:\n            with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n                process_wait([\"wait_until\", \"12:30\"])\n        mock_su.assert_called_once_with(\"12:30\")\n        mock_sleep.assert_called_once_with(300)\n\n    def test_missing_parameter_skips_sleep(self, capsys):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait_until\"])\n        mock_sleep.assert_not_called()\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_too_many_parameters_skips_sleep(self, capsys):\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait_until\", \"12:30\", \"extra\"])\n        mock_sleep.assert_not_called()\n        assert \"Error\" in capsys.readouterr().err\n\n    def test_invalid_time_format_reports_error_no_sleep(self, capsys):\n        # seconds_until raises ValueError for bad input; caught → error_report, no sleep\n        with patch(\"soco_cli.wait_actions.time.sleep\") as mock_sleep:\n            process_wait([\"wait_until\", \"notatime\"])\n        assert \"Error\" in capsys.readouterr().err\n        mock_sleep.assert_not_called()\n"
  }
]