diff --git a/.github/workflows/codeql-analysis_v3.yml b/.github/workflows/codeql-analysis_v3.yml index d06cce1a5..a284d7691 100644 --- a/.github/workflows/codeql-analysis_v3.yml +++ b/.github/workflows/codeql-analysis_v3.yml @@ -38,7 +38,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies @@ -51,9 +51,9 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt - # Set the `CODEQL-PYTHON` environment variable to the Python executable + # Set the `CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION` environment variable to the Python executable # that includes the dependencies - echo "CODEQL_PYTHON=$(which python)" >> $GITHUB_ENV + echo "CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION=$(which python)" >> $GITHUB_ENV # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/pythonpackage_future3.yml b/.github/workflows/pythonpackage_future3.yml index da7007b97..3bc2c1174 100644 --- a/.github/workflows/pythonpackage_future3.yml +++ b/.github/workflows/pythonpackage_future3.yml @@ -22,9 +22,9 @@ jobs: python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/documentation/developers/docker.md b/documentation/developers/docker.md index 74d95c112..6a0af80c7 100644 --- a/documentation/developers/docker.md +++ b/documentation/developers/docker.md @@ -123,7 +123,7 @@ They can be run individually or in combination. To do that, we use load-module module-native-protocol-tcp auth-ip-acl=127.0.0.1 ``` -1. Edit `$INSTALL_DIR/etc/pulse//etc/pulse/daemon.conf`, find the +1. Edit `$INSTALL_DIR/etc/pulse/daemon.conf`, find the following line and change it to: ``` bash diff --git a/installation/routines/setup_jukebox_core.sh b/installation/routines/setup_jukebox_core.sh index 9dcca2256..2e4fbad39 100644 --- a/installation/routines/setup_jukebox_core.sh +++ b/installation/routines/setup_jukebox_core.sh @@ -86,7 +86,7 @@ _jukebox_core_build_and_install_pyzmq() { fi ZMQ_PREFIX="${JUKEBOX_ZMQ_PREFIX}" ZMQ_DRAFT_API=1 \ - pip install -v pyzmq --no-binary pyzmq + pip install -v 'pyzmq<26' --no-binary pyzmq else print_lc " Skipping. pyzmq already installed" fi @@ -120,6 +120,20 @@ _jukebox_core_check() { local pip_modules=$(get_args_from_file "${INSTALLATION_PATH}/requirements.txt") verify_pip_modules pyzmq $pip_modules + log " Verify ZMQ version '${JUKEBOX_ZMQ_VERSION}'" + local zmq_version=$(python -c 'import zmq; print(f"{zmq.zmq_version()}")') + if [[ "${zmq_version}" != "${JUKEBOX_ZMQ_VERSION}" ]]; then + exit_on_error "ERROR: ZMQ version '${zmq_version}' differs from expected '${JUKEBOX_ZMQ_VERSION}'!" + fi + log " CHECK" + + log " Verify ZMQ has 'DRAFT-API' activated" + local zmq_hasDraftApi=$(python -c 'import zmq; print(f"{zmq.DRAFT_API}")') + if [[ "${zmq_hasDraftApi}" != "True" ]]; then + exit_on_error "ERROR: ZMQ has 'DRAFT-API' '${zmq_hasDraftApi}' differs from expected 'True'!" + fi + log " CHECK" + verify_files_chmod_chown 644 "${CURRENT_USER}" "${CURRENT_USER_GROUP}" "${JUKEBOX_PULSE_CONFIG}" verify_files_chmod_chown 644 "${CURRENT_USER}" "${CURRENT_USER_GROUP}" "${SETTINGS_PATH}/jukebox.yaml" diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index d87c676fa..8de0da2b1 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -87,6 +87,12 @@ playermpd: update_on_startup: true check_user_rights: true mpd_conf: ~/.config/mpd/mpd.conf + # Must be one of: 'none', 'stop', 'rewind': + end_of_playlist_next_action: none + # Must be one of: 'none', 'prev', 'rewind': + stopped_prev_action: prev + # Must be one of: 'none', 'next', 'rewind': + stopped_next_action: next rpc: tcp_port: 5555 websocket_port: 5556 diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py index dcbef2ea8..86dbc60ab 100644 --- a/src/jukebox/components/playermpd/__init__.py +++ b/src/jukebox/components/playermpd/__init__.py @@ -156,6 +156,31 @@ def __init__(self): self.second_swipe_action = None self.decode_2nd_swipe_option() + self.end_of_playlist_next_action = utils.get_config_action(cfg, + 'playermpd', + 'end_of_playlist_next_action', + 'none', + {'rewind': self.rewind, + 'stop': self.stop, + 'none': lambda: None}, + logger) + self.stopped_prev_action = utils.get_config_action(cfg, + 'playermpd', + 'stopped_prev_action', + 'prev', + {'rewind': self.rewind, + 'prev': self._prev_in_stopped_state, + 'none': lambda: None}, + logger) + self.stopped_next_action = utils.get_config_action(cfg, + 'playermpd', + 'stopped_next_action', + 'next', + {'rewind': self.rewind, + 'next': self._next_in_stopped_state, + 'none': lambda: None}, + logger) + self.mpd_client = mpd.MPDClient() self.coverart_cache_manager = CoverartCacheManager() @@ -327,15 +352,48 @@ def pause(self, state: int = 1): @plugs.tag def prev(self): logger.debug("Prev") + if self.mpd_status['state'] == 'stop': + logger.debug('Player is stopped, calling stopped_prev_action') + return self.stopped_prev_action() + try: + with self.mpd_lock: + self.mpd_client.previous() + except mpd.base.CommandError: + # This shouldn't happen in reality, but we still catch + # this error to avoid crashing the player thread: + logger.warning('Failed to go to previous song, ignoring') + + def _prev_in_stopped_state(self): with self.mpd_lock: - self.mpd_client.previous() + self.mpd_client.play(max(0, int(self.mpd_status['pos']) - 1)) @plugs.tag def next(self): """Play next track in current playlist""" logger.debug("Next") + if self.mpd_status['state'] == 'stop': + logger.debug('Player is stopped, calling stopped_next_action') + return self.stopped_next_action() + playlist_len = int(self.mpd_status.get('playlistlength', -1)) + current_pos = int(self.mpd_status.get('pos', 0)) + if current_pos == playlist_len - 1: + logger.debug(f'next() called during last song ({current_pos}) of ' + f'playlist (len={playlist_len}), running end_of_playlist_next_action.') + return self.end_of_playlist_next_action() + try: + with self.mpd_lock: + self.mpd_client.next() + except mpd.base.CommandError: + # This shouldn't happen in reality, but we still catch + # this error to avoid crashing the player thread: + logger.warning('Failed to go to next song, ignoring') + + def _next_in_stopped_state(self): + pos = int(self.mpd_status['pos']) + 1 + if pos > int(self.mpd_status['playlistlength']) - 1: + return self.end_of_playlist_next_action() with self.mpd_lock: - self.mpd_client.next() + self.mpd_client.play(pos) @plugs.tag def seek(self, new_time): diff --git a/src/jukebox/jukebox/utils.py b/src/jukebox/jukebox/utils.py index dbd647490..4cc0270ae 100644 --- a/src/jukebox/jukebox/utils.py +++ b/src/jukebox/jukebox/utils.py @@ -183,6 +183,19 @@ def rpc_call_to_str(cfg_rpc_call: Dict, with_args=True) -> str: return readable +def get_config_action(cfg, section, option, default, valid_actions_dict, logger): + """ + Looks up the given {section}.{option} config option and returns + the associated entry from valid_actions_dict, if valid. Falls back to the given + default otherwise. + """ + action = cfg.setndefault(section, option, value='').lower() + if action not in valid_actions_dict: + logger.error(f"Config {section}.{option} must be one of {valid_actions_dict.keys()}. Using default '{default}'") + action = default + return valid_actions_dict[action] + + def indent(doc, spaces=4): lines = doc.split('\n') for i in range(0, len(lines)):