From c3549ebb3afafcf6d3a7d2483628ad7160b691b3 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Tue, 30 Nov 2021 20:37:25 +0100 Subject: [PATCH 001/109] Asking user for spotify --- installation/includes/01_default_config.sh | 3 ++ installation/routines/customize_options.sh | 37 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/installation/includes/01_default_config.sh b/installation/includes/01_default_config.sh index 93cace7e4..36b5a0f51 100644 --- a/installation/includes/01_default_config.sh +++ b/installation/includes/01_default_config.sh @@ -13,6 +13,9 @@ DISABLE_SSH_QOS=true DISABLE_BOOT_SCREEN=true DISABLE_BOOT_LOGS_PRINT=true SETUP_MPD=true +SETUP_SPOTIFY=false +SPOTIFY_USERNAME="ANONYMOUS" +SPOTIFY_PASSWORD="PASSWORD" UPDATE_RASPI_OS=false ENABLE_SAMBA=true ENABLE_WEBAPP=true diff --git a/installation/routines/customize_options.sh b/installation/routines/customize_options.sh index 14cf7af87..7275a6ce6 100644 --- a/installation/routines/customize_options.sh +++ b/installation/routines/customize_options.sh @@ -212,6 +212,43 @@ Build and serve docs locally? [y/N] " 1>&3 } +_option_spotify() { + # SETUP_SPOTIFY + echo "Do you want to enable Spotify? +You need Spotify Premium to use these functionality. +[y/N] " 1>&3 + read -r response + case "$response" in + [yY]) + SETUP_SPOTIFY=true + ;; + *) + ;; + esac + + if [ "$SETUP_SPOTIFY" = true ]; then + while [ "${spotify_username}" == "" ] + do + echo "Please provide your spotify username." 1>&3 + read -r spotify_username + done + SPOTIFY_USERNAME="${spotify_username}" + + while [ "${spotify_password}" == "" ] + do + echo "Please provide your spotify password." 1>&3 + read -r -s spotify_password + done + SPOTIFY_PASSWORD="${spotify_password}" + + echo "SETUP_SPOTIFY=${SETUP_SPOTIFY}" + if [ "$SETUP_SPOTIFY" = true ]; then + echo "SPOTIFY_USERNAME=${SPOTIFY_USERNAME}" + echo "SPOTIFY_PASSWORD=${SPOTIFY_PASSWORD}" + fi + fi +} + customize_options() { echo "Customize Options starts" From 5aaa2d11e2a1962891f34af221fad80613463898 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Tue, 30 Nov 2021 21:27:04 +0100 Subject: [PATCH 002/109] activate spotify installation --- installation/routines/install.sh | 1 + installation/routines/setup_spotify.sh | 49 +++++++++++ .../default-services/jukebox-spotify.service | 17 ++++ .../default-settings/spotify.config.toml | 81 +++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 installation/routines/setup_spotify.sh create mode 100644 resources/default-services/jukebox-spotify.service create mode 100644 resources/default-settings/spotify.config.toml diff --git a/installation/routines/install.sh b/installation/routines/install.sh index 1cc08e6f5..de6fd19e1 100644 --- a/installation/routines/install.sh +++ b/installation/routines/install.sh @@ -10,6 +10,7 @@ install() { if [ "$ENABLE_SAMBA" = true ] ; then setup_samba; fi; if [ "$ENABLE_WEBAPP" = true ] ; then setup_jukebox_webapp; fi; if [ "$ENABLE_KIOSK_MODE" = true ] ; then setup_kiosk_mode; fi; + if [ "$SETUP_SPOTIFY" = true ] ; then setup_spotify; fi; setup_rfid_reader optimize_boot_time if [ "$ENABLE_AUTOHOTSPOT" = true ] ; then setup_autohotspot; fi; diff --git a/installation/routines/setup_spotify.sh b/installation/routines/setup_spotify.sh new file mode 100644 index 000000000..02d8991c8 --- /dev/null +++ b/installation/routines/setup_spotify.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +# Constants +LIBRESPOT_JAVA_VERSION="1.6.1" +LIBRESPOT_JAVA_JAR="librespot-api-${LIBRESPOT_JAVA_VERSION}.jar" +LIBRESPOT_JAVA_API_URL="https://github.com/librespot-org/librespot-java/releases/download/v${LIBRESPOT_JAVA_VERSION}/${LIBRESPOT_JAVA_JAR}" + + +_install_packages() { + echo "Installing openjdk-11-jre package to be able to run librespot-java" + sudo apt-get -y install openjdk-11-jre +} + +_download_jar() { + echo "Downloading API jar from github" + wget -O "${SHARED_PATH}/spotify/${LIBRESPOT_JAVA_JAR}" "${LIBRESPOT_JAVA_API_URL}" +} + +_configure_librespot_java() { + ehco "Placing config file and inserting username and password" + SPOTIFY_CONFIG_FILE="${SHARED_PATH}/spotify/config.toml" + cp "${INSTALLATION_PATH}"/resources/default-settings/spotify.config.toml "${SPOTIFY_CONFIG_FILE}" + sed -i "s/HERE_USERNAME/${SPOTIFY_USERNAME}/g" "${SPOTIFY_CONFIG_FILE}" + sed -i "s/HERE_PASSWORD/${SPOTIFY_PASSWORD}/g" "${SPOTIFY_CONFIG_FILE}" +} + +_install_service() { + echo "Installing jukebox-spotify service" + SPOTIFY_SERVICE_RESOURCE="${INSTALLATION_PATH}/resources/default-services/jukebox-spotify.service" + sed -i "s#HERE_DIR#${SHARED_PATH}/spotify#g" "${SPOTIFY_SERVICE_RESOURCE}" + sed -i "s#HERE_JAR_FILE#${SHARED_PATH}/spotify/${LIBRESPOT_JAVA_JAR}#g" "${SPOTIFY_SERVICE_RESOURCE}" + + sudo cp -f "${SPOTIFY_SERVICE_RESOURCE}" "${SYSTEMD_PATH}" + sudo chmod 644 "${SYSTEMD_PATH}"/jukebox-spotify.service + + sudo systemctl enable jukebox-spotify.service + sudo systemctl daemon-reload +} + +setup_spotify() { + echo "Install Spotify functionality" | tee /dev/fd/3 + + _install_packages + _download_jar + _configure_librespot_java + _install_service + + echo "DONE: setup_spotify" +} diff --git a/resources/default-services/jukebox-spotify.service b/resources/default-services/jukebox-spotify.service new file mode 100644 index 000000000..b2f0cc962 --- /dev/null +++ b/resources/default-services/jukebox-spotify.service @@ -0,0 +1,17 @@ +[Unit] +Description=Junkebox-Spotify +Wants=network-online.target +After=network.target network-online.target +Requires=network-online.target + +[Service] +Type=simple +Restart=always +RestartSec=10 +PermissionsStartOnly=true +WorkingDirectory=HERE_DIR +ExecStartPre=/bin/sh -c 'until ping -c1 spotify.com; do sleep 5; done;' +ExecStart=/usr/bin/java -jar HERE_JAR_FILE + +[Install] +WantedBy=multi-user.target diff --git a/resources/default-settings/spotify.config.toml b/resources/default-settings/spotify.config.toml new file mode 100644 index 000000000..507f51675 --- /dev/null +++ b/resources/default-settings/spotify.config.toml @@ -0,0 +1,81 @@ +deviceId = "" ### Device ID (40 chars, leave empty for random) ### +deviceName = "Phoniebox" ### Device name ### +deviceType = "SPEAKER" ### Device type (COMPUTER, TABLET, SMARTPHONE, SPEAKER, TV, AVR, STB, AUDIO_DONGLE, GAME_CONSOLE, CAST_VIDEO, CAST_AUDIO, AUTOMOBILE, WEARABLE, UNKNOWN_SPOTIFY, CAR_THING, UNKNOWN) ### +preferredLocale = "de" ### Preferred locale ### +logLevel = "TRACE" ### Log level (OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE, ALL) ### + +[auth] ### Authentication ### +strategy = "USER_PASS" # Strategy (USER_PASS, ZEROCONF, BLOB, FACEBOOK, STORED) +username = "HERE_USERNAME" # Spotify username (BLOB, USER_PASS only) +password = "HERE_PASSWORD" # Spotify password (USER_PASS only) +blob = "" # Spotify authentication blob Base64-encoded (BLOB only) +storeCredentials = false # Whether to store reusable credentials on disk (not a plain password) +credentialsFile = "credentials.json" # Credentials file (JSON) + +[zeroconf] ### Zeroconf ### +listenPort = -1 # Listen on this TCP port (`-1` for random) +listenAll = true # Listen on all interfaces (overrides `zeroconf.interfaces`) +interfaces = "" # Listen on these interfaces (comma separated list of names) + +[cache] ### Cache ### +enabled = true # Cache enabled +dir = "./cache/" +doCleanUp = true + +[network] ### Network ### +connectionTimeout = 10 # If ping isn't received within this amount of seconds, reconnect + +[preload] ### Preload ### +enabled = true # Preload enabled + +[time] ### Time correction ### +synchronizationMethod = "NTP" # Time synchronization method (NTP, PING, MELODY, MANUAL) +manualCorrection = 0 # Manual time correction in millis + +[player] ### Player ### +autoplayEnabled = true # Autoplay similar songs when your music ends +preferredAudioQuality = "NORMAL" # Preferred audio quality (NORMAL, HIGH, VERY_HIGH) +enableNormalisation = true # Whether to apply the Spotify loudness normalisation +normalisationPregain = +3.0 # Normalisation pregain in decibels (loud at +6, normal at +3, quiet at -5) +initialVolume = 65536 # Initial volume (0-65536) +volumeSteps = 64 # Number of volume notches +logAvailableMixers = true # Log available mixers +mixerSearchKeywords = "" # Mixer/backend search keywords (semicolon separated) +crossfadeDuration = 0 # Crossfade overlap time (in milliseconds) +output = "MIXER" # Audio output device (MIXER, PIPE, STDOUT, CUSTOM) +outputClass = "" # Audio output Java class name +releaseLineDelay = 20 # Release mixer line after set delay (in seconds) +pipe = "" # Output raw (signed) PCM to this file (`player.output` must be PIPE) +retryOnChunkError = true # Whether the player should retry fetching a chuck if it fails +metadataPipe = "" # Output metadata in Shairport Sync format (https://github.com/mikebrady/shairport-sync-metadata-reader) +bypassSinkVolume = false # Whether librespot-java should ignore volume events, sink volume is set to the max +localFilesPath = "" # Where librespot-java should search for local files + +[api] ### API ### +port = 24879 # API port (`api` module only) +host = "0.0.0.0" # API listen interface (`api` module only) + +[proxy] ### Proxy ### +enabled = false # Whether the proxy is enabled +type = "HTTP" # The proxy type (HTTP, SOCKS) +address = "" # The proxy hostname +port = 0 # The proxy port +auth = false # Whether authentication is enabled on the server +username = "" # Basic auth username +password = "" # Basic auth password + +[shell] ### Shell ### +enabled = false # Shell events enabled +executeWithBash = false # Execute the command with `bash -c` +onContextChanged = "" +onTrackChanged = "" +onPlaybackEnded = "" +onPlaybackPaused = "" +onPlaybackResumed = "" +onTrackSeeked = "" +onMetadataAvailable = "" +onVolumeChanged = "" +onInactiveSession = "" +onPanicState = "" +onConnectionDropped = "" +onConnectionEstablished = "" From 79926625510b20ffe8e6063a47224b3b897907c3 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Thu, 16 Dec 2021 20:27:12 +0100 Subject: [PATCH 003/109] Enable spotify installation --- installation/routines/customize_options.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/installation/routines/customize_options.sh b/installation/routines/customize_options.sh index 6f7554697..1163b305b 100644 --- a/installation/routines/customize_options.sh +++ b/installation/routines/customize_options.sh @@ -287,6 +287,7 @@ customize_options() { _option_disable_onboard_audio _option_samba _option_webapp + _option_spotify _option_build_local_docs if [ "$ENABLE_WEBAPP" = true ] ; then _option_kiosk_mode From 6b89ddf7121e6e2afbe0f4caa78a9caacbfbf440 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Thu, 16 Dec 2021 20:55:29 +0100 Subject: [PATCH 004/109] Introduce default spotify folder --- shared/spotify/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 shared/spotify/.gitkeep diff --git a/shared/spotify/.gitkeep b/shared/spotify/.gitkeep new file mode 100644 index 000000000..e69de29bb From 189001688f905f2064a360035795565b18daaf05 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 20 Dec 2021 13:06:33 +0100 Subject: [PATCH 005/109] Added information for installation --- installation/routines/setup_spotify.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installation/routines/setup_spotify.sh b/installation/routines/setup_spotify.sh index 02d8991c8..f183fcca1 100644 --- a/installation/routines/setup_spotify.sh +++ b/installation/routines/setup_spotify.sh @@ -17,7 +17,7 @@ _download_jar() { } _configure_librespot_java() { - ehco "Placing config file and inserting username and password" + echo "Placing config file and inserting username and password" SPOTIFY_CONFIG_FILE="${SHARED_PATH}/spotify/config.toml" cp "${INSTALLATION_PATH}"/resources/default-settings/spotify.config.toml "${SPOTIFY_CONFIG_FILE}" sed -i "s/HERE_USERNAME/${SPOTIFY_USERNAME}/g" "${SPOTIFY_CONFIG_FILE}" From 32d53af22d85a05fe6132914716ce3d8e2493040 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Mon, 20 Dec 2021 14:18:17 +0100 Subject: [PATCH 006/109] Setup librespot-java in Docker for development --- docker/config/docker.spotify.config.toml | 84 ++++++++++++++++++++++++ docker/docker-compose.linux.yml | 4 ++ docker/docker-compose.mac.yml | 7 ++ docker/docker-compose.windows.yml | 4 ++ docker/docker-compose.yml | 12 ++++ docker/spotify.Dockerfile | 26 ++++++++ docs/sphinx/developer/docker.rst | 17 +++++ 7 files changed, 154 insertions(+) create mode 100644 docker/config/docker.spotify.config.toml create mode 100644 docker/spotify.Dockerfile diff --git a/docker/config/docker.spotify.config.toml b/docker/config/docker.spotify.config.toml new file mode 100644 index 000000000..1f1eeb406 --- /dev/null +++ b/docker/config/docker.spotify.config.toml @@ -0,0 +1,84 @@ +deviceId = "" ### Device ID (40 chars, leave empty for random) ### +deviceName = "docker-spotify" ### Device name ### +deviceType = "SPEAKER" ### Device type (COMPUTER, TABLET, SMARTPHONE, SPEAKER, TV, AVR, STB, AUDIO_DONGLE, GAME_CONSOLE, CAST_VIDEO, CAST_AUDIO, AUTOMOBILE, WEARABLE, UNKNOWN_SPOTIFY, CAR_THING, UNKNOWN) ### +preferredLocale = "en" ### Preferred locale ### +logLevel = "TRACE" ### Log level (OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE, ALL) ### + +[auth] ### Authentication ### +strategy = "USER_PASS" # Strategy (USER_PASS, ZEROCONF, BLOB, FACEBOOK, STORED) +username = "" # Spotify username (BLOB, USER_PASS only) +password = "" # Spotify password (USER_PASS only) +blob = "" # Spotify authentication blob Base64-encoded (BLOB only) +storeCredentials = false # Whether to store reusable credentials on disk (not a plain password) +credentialsFile = "credentials.json" # Credentials file (JSON) + +[zeroconf] ### Zeroconf ### +listenPort = 12345 # Listen on this TCP port (`-1` for random) +listenAll = true # Listen on all interfaces (overrides `zeroconf.interfaces`) +interfaces = "" # Listen on these interfaces (comma separated list of names) + +[cache] ### Cache ### +enabled = false # Cache enabled +dir = "./cache/" +doCleanUp = true + +[network] ### Network ### +connectionTimeout = 10 # If ping isn't received within this amount of seconds, reconnect + +[preload] ### Preload ### +enabled = true # Preload enabled + +[time] ### Time correction ### +synchronizationMethod = "NTP" # Time synchronization method (NTP, PING, MELODY, MANUAL) +manualCorrection = 0 # Manual time correction in millis + +[player] ### Player ### +autoplayEnabled = true # Autoplay similar songs when your music ends +preferredAudioQuality = "NORMAL" # Preferred audio quality (NORMAL, HIGH, VERY_HIGH) +enableNormalisation = true # Whether to apply the Spotify loudness normalisation +normalisationPregain = +3.0 # Normalisation pregain in decibels (loud at +6, normal at +3, quiet at -5) +initialVolume = 65536 # Initial volume (0-65536) +volumeSteps = 64 # Number of volume notches +logAvailableMixers = true # Log available mixers +mixerSearchKeywords = "" # Mixer/backend search keywords (semicolon separated) +crossfadeDuration = 0 # Crossfade overlap time (in milliseconds) +output = "MIXER" # Audio output device (MIXER, PIPE, STDOUT, CUSTOM) +outputClass = "" # Audio output Java class name +releaseLineDelay = 20 # Release mixer line after set delay (in seconds) +pipe = "" # Output raw (signed) PCM to this file (`player.output` must be PIPE) +retryOnChunkError = true # Whether the player should retry fetching a chuck if it fails +metadataPipe = "" # Output metadata in Shairport Sync format (https://github.com/mikebrady/shairport-sync-metadata-reader) +bypassSinkVolume = false # Whether librespot-java should ignore volume events, sink volume is set to the max +localFilesPath = "" # Where librespot-java should search for local files + +[api] ### API ### +port = 24879 # API port (`api` module only) +host = "0.0.0.0" # API listen interface (`api` module only) + +[proxy] ### Proxy ### +enabled = false # Whether the proxy is enabled +type = "HTTP" # The proxy type (HTTP, SOCKS) +ssl = false # Connect to proxy using SSL (HTTP only) +address = "" # The proxy hostname +port = 0 # The proxy port +auth = false # Whether authentication is enabled on the server +username = "" # Basic auth username +password = "" # Basic auth password + +[shell] ### Shell ### +enabled = false # Shell events enabled +executeWithBash = false # Execute the command with `bash -c` +onContextChanged = "" +onTrackChanged = "" +onPlaybackEnded = "" +onPlaybackPaused = "" +onPlaybackResumed = "" +onTrackSeeked = "" +onMetadataAvailable = "" +onVolumeChanged = "" +onInactiveSession = "" +onPanicState = "" +onConnectionDropped = "" +onConnectionEstablished = "" +onStartedLoading = "" +onFinishedLoading = "" \ No newline at end of file diff --git a/docker/docker-compose.linux.yml b/docker/docker-compose.linux.yml index 2bc9ab812..c5221e17f 100755 --- a/docker/docker-compose.linux.yml +++ b/docker/docker-compose.linux.yml @@ -5,6 +5,10 @@ services: devices: - /dev/snd + spotify: + devices: + - /dev/snd + jukebox: devices: - /dev/snd diff --git a/docker/docker-compose.mac.yml b/docker/docker-compose.mac.yml index 27163cb93..49819d1b9 100644 --- a/docker/docker-compose.mac.yml +++ b/docker/docker-compose.mac.yml @@ -9,6 +9,13 @@ services: - ~/.config/pulse:/root/.config/pulse - /usr/local/Cellar/pulseaudio/14.2/etc/pulse:/etc/pulse + spotify: + environment: + - PULSE_SERVER=tcp:host.docker.internal:4713 + volumes: + - ~/.config/pulse:/root/.config/pulse + - /usr/local/Cellar/pulseaudio/14.2/etc/pulse:/etc/pulse + jukebox: environment: - PULSE_SERVER=tcp:host.docker.internal:4713 diff --git a/docker/docker-compose.windows.yml b/docker/docker-compose.windows.yml index 51134451d..6bba5aa8d 100755 --- a/docker/docker-compose.windows.yml +++ b/docker/docker-compose.windows.yml @@ -7,6 +7,10 @@ services: volumes: - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf:rw + spotify: + environment: + - PULSE_SERVER=tcp:host.docker.internal:4713 + jukebox: environment: - PULSE_SERVER=tcp:host.docker.internal:4713 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 73c462c31..780b4e5a5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,6 +15,18 @@ services: - ../shared/playlists:/root/.config/mpd/playlists:rw - ./config/docker.mpd.conf:/root/.config/mpd/mpd.conf:rw + spotify: + build: + context: ../ + dockerfile: ./docker/spotify.Dockerfile + container_name: spotify + ports: + - 12345:12345 + - 24879:24879 + restart: unless-stopped + volumes: + - ./config/docker.spotify.config.toml:/home/pi/librespot-java/config.toml:rw + jukebox: build: context: ../ diff --git a/docker/spotify.Dockerfile b/docker/spotify.Dockerfile new file mode 100644 index 000000000..f3518e774 --- /dev/null +++ b/docker/spotify.Dockerfile @@ -0,0 +1,26 @@ +FROM debian:bullseye-slim + +RUN set -eux ; \ + apt-get update && apt-get install -y \ + alsa-utils \ + libasound2-dev \ + libasound2-plugins \ + pulseaudio \ + pulseaudio-utils \ + default-jdk \ + wget + +RUN usermod -aG audio,pulse,pulse-access root + +ENV INSTALLATION_PATH /home/pi/librespot-java +ENV LIBRESPOT_JAVA_VERSION 1.6.2 + +WORKDIR $INSTALLATION_PATH +VOLUME $INSTALLATION_PATH + +RUN wget https://github.com/librespot-org/librespot-java/releases/download/v${LIBRESPOT_JAVA_VERSION}/librespot-api-${LIBRESPOT_JAVA_VERSION}.jar + +EXPOSE 12345 +EXPOSE 24879 + +CMD java -jar ${INSTALLATION_PATH}/librespot-api-${LIBRESPOT_JAVA_VERSION}.jar diff --git a/docs/sphinx/developer/docker.rst b/docs/sphinx/developer/docker.rst index 3a2150a21..8bb7de2c9 100644 --- a/docs/sphinx/developer/docker.rst +++ b/docs/sphinx/developer/docker.rst @@ -239,6 +239,23 @@ The following command can be run on a Mac. -e PULSE_SERVER=tcp:host.docker.internal:4713 \ --name jukebox jukebox + +Run Spotify in a single container like this (on Mac) + +.. code-block:: bash + + $ docker build -f docker/spotify.Dockerfile -t spotify . + $ docker run -it --rm \ + -v $(PWD)/docker/config/docker.spotify.config.toml:/home/pi/librespot-java/config.toml:rw \ + --hostname spotify \ + -p 12345:12345 \ + -p 24879:24879 \ + -v ~/.config/pulse:/root/.config/pulse \ + -v /usr/local/Cellar/pulseaudio/14.2/etc/pulse/:/etc/pulse \ + -e PULSE_SERVER=tcp:host.docker.internal:4713 \ + --name spotify spotify + + Resources ^^^^^^^^^^^ From 00d1ea5cad227b5efbe3c1a3dca449a4f71e4c1e Mon Sep 17 00:00:00 2001 From: Groovylein Date: Tue, 21 Dec 2021 08:05:01 +0100 Subject: [PATCH 007/109] Added docker for spotify development --- docker/config/docker.spotify.config.toml | 84 ++++++++++++++++++++++++ docker/docker-compose.linux.yml | 4 ++ docker/docker-compose.mac.yml | 7 ++ docker/docker-compose.windows.yml | 4 ++ docker/docker-compose.yml | 12 ++++ docker/spotify.Dockerfile | 26 ++++++++ 6 files changed, 137 insertions(+) create mode 100644 docker/config/docker.spotify.config.toml create mode 100644 docker/spotify.Dockerfile diff --git a/docker/config/docker.spotify.config.toml b/docker/config/docker.spotify.config.toml new file mode 100644 index 000000000..1f1eeb406 --- /dev/null +++ b/docker/config/docker.spotify.config.toml @@ -0,0 +1,84 @@ +deviceId = "" ### Device ID (40 chars, leave empty for random) ### +deviceName = "docker-spotify" ### Device name ### +deviceType = "SPEAKER" ### Device type (COMPUTER, TABLET, SMARTPHONE, SPEAKER, TV, AVR, STB, AUDIO_DONGLE, GAME_CONSOLE, CAST_VIDEO, CAST_AUDIO, AUTOMOBILE, WEARABLE, UNKNOWN_SPOTIFY, CAR_THING, UNKNOWN) ### +preferredLocale = "en" ### Preferred locale ### +logLevel = "TRACE" ### Log level (OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE, ALL) ### + +[auth] ### Authentication ### +strategy = "USER_PASS" # Strategy (USER_PASS, ZEROCONF, BLOB, FACEBOOK, STORED) +username = "" # Spotify username (BLOB, USER_PASS only) +password = "" # Spotify password (USER_PASS only) +blob = "" # Spotify authentication blob Base64-encoded (BLOB only) +storeCredentials = false # Whether to store reusable credentials on disk (not a plain password) +credentialsFile = "credentials.json" # Credentials file (JSON) + +[zeroconf] ### Zeroconf ### +listenPort = 12345 # Listen on this TCP port (`-1` for random) +listenAll = true # Listen on all interfaces (overrides `zeroconf.interfaces`) +interfaces = "" # Listen on these interfaces (comma separated list of names) + +[cache] ### Cache ### +enabled = false # Cache enabled +dir = "./cache/" +doCleanUp = true + +[network] ### Network ### +connectionTimeout = 10 # If ping isn't received within this amount of seconds, reconnect + +[preload] ### Preload ### +enabled = true # Preload enabled + +[time] ### Time correction ### +synchronizationMethod = "NTP" # Time synchronization method (NTP, PING, MELODY, MANUAL) +manualCorrection = 0 # Manual time correction in millis + +[player] ### Player ### +autoplayEnabled = true # Autoplay similar songs when your music ends +preferredAudioQuality = "NORMAL" # Preferred audio quality (NORMAL, HIGH, VERY_HIGH) +enableNormalisation = true # Whether to apply the Spotify loudness normalisation +normalisationPregain = +3.0 # Normalisation pregain in decibels (loud at +6, normal at +3, quiet at -5) +initialVolume = 65536 # Initial volume (0-65536) +volumeSteps = 64 # Number of volume notches +logAvailableMixers = true # Log available mixers +mixerSearchKeywords = "" # Mixer/backend search keywords (semicolon separated) +crossfadeDuration = 0 # Crossfade overlap time (in milliseconds) +output = "MIXER" # Audio output device (MIXER, PIPE, STDOUT, CUSTOM) +outputClass = "" # Audio output Java class name +releaseLineDelay = 20 # Release mixer line after set delay (in seconds) +pipe = "" # Output raw (signed) PCM to this file (`player.output` must be PIPE) +retryOnChunkError = true # Whether the player should retry fetching a chuck if it fails +metadataPipe = "" # Output metadata in Shairport Sync format (https://github.com/mikebrady/shairport-sync-metadata-reader) +bypassSinkVolume = false # Whether librespot-java should ignore volume events, sink volume is set to the max +localFilesPath = "" # Where librespot-java should search for local files + +[api] ### API ### +port = 24879 # API port (`api` module only) +host = "0.0.0.0" # API listen interface (`api` module only) + +[proxy] ### Proxy ### +enabled = false # Whether the proxy is enabled +type = "HTTP" # The proxy type (HTTP, SOCKS) +ssl = false # Connect to proxy using SSL (HTTP only) +address = "" # The proxy hostname +port = 0 # The proxy port +auth = false # Whether authentication is enabled on the server +username = "" # Basic auth username +password = "" # Basic auth password + +[shell] ### Shell ### +enabled = false # Shell events enabled +executeWithBash = false # Execute the command with `bash -c` +onContextChanged = "" +onTrackChanged = "" +onPlaybackEnded = "" +onPlaybackPaused = "" +onPlaybackResumed = "" +onTrackSeeked = "" +onMetadataAvailable = "" +onVolumeChanged = "" +onInactiveSession = "" +onPanicState = "" +onConnectionDropped = "" +onConnectionEstablished = "" +onStartedLoading = "" +onFinishedLoading = "" \ No newline at end of file diff --git a/docker/docker-compose.linux.yml b/docker/docker-compose.linux.yml index 2bc9ab812..c5221e17f 100755 --- a/docker/docker-compose.linux.yml +++ b/docker/docker-compose.linux.yml @@ -5,6 +5,10 @@ services: devices: - /dev/snd + spotify: + devices: + - /dev/snd + jukebox: devices: - /dev/snd diff --git a/docker/docker-compose.mac.yml b/docker/docker-compose.mac.yml index 27163cb93..49819d1b9 100644 --- a/docker/docker-compose.mac.yml +++ b/docker/docker-compose.mac.yml @@ -9,6 +9,13 @@ services: - ~/.config/pulse:/root/.config/pulse - /usr/local/Cellar/pulseaudio/14.2/etc/pulse:/etc/pulse + spotify: + environment: + - PULSE_SERVER=tcp:host.docker.internal:4713 + volumes: + - ~/.config/pulse:/root/.config/pulse + - /usr/local/Cellar/pulseaudio/14.2/etc/pulse:/etc/pulse + jukebox: environment: - PULSE_SERVER=tcp:host.docker.internal:4713 diff --git a/docker/docker-compose.windows.yml b/docker/docker-compose.windows.yml index 51134451d..6bba5aa8d 100755 --- a/docker/docker-compose.windows.yml +++ b/docker/docker-compose.windows.yml @@ -7,6 +7,10 @@ services: volumes: - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf:rw + spotify: + environment: + - PULSE_SERVER=tcp:host.docker.internal:4713 + jukebox: environment: - PULSE_SERVER=tcp:host.docker.internal:4713 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 73c462c31..780b4e5a5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,6 +15,18 @@ services: - ../shared/playlists:/root/.config/mpd/playlists:rw - ./config/docker.mpd.conf:/root/.config/mpd/mpd.conf:rw + spotify: + build: + context: ../ + dockerfile: ./docker/spotify.Dockerfile + container_name: spotify + ports: + - 12345:12345 + - 24879:24879 + restart: unless-stopped + volumes: + - ./config/docker.spotify.config.toml:/home/pi/librespot-java/config.toml:rw + jukebox: build: context: ../ diff --git a/docker/spotify.Dockerfile b/docker/spotify.Dockerfile new file mode 100644 index 000000000..f3518e774 --- /dev/null +++ b/docker/spotify.Dockerfile @@ -0,0 +1,26 @@ +FROM debian:bullseye-slim + +RUN set -eux ; \ + apt-get update && apt-get install -y \ + alsa-utils \ + libasound2-dev \ + libasound2-plugins \ + pulseaudio \ + pulseaudio-utils \ + default-jdk \ + wget + +RUN usermod -aG audio,pulse,pulse-access root + +ENV INSTALLATION_PATH /home/pi/librespot-java +ENV LIBRESPOT_JAVA_VERSION 1.6.2 + +WORKDIR $INSTALLATION_PATH +VOLUME $INSTALLATION_PATH + +RUN wget https://github.com/librespot-org/librespot-java/releases/download/v${LIBRESPOT_JAVA_VERSION}/librespot-api-${LIBRESPOT_JAVA_VERSION}.jar + +EXPOSE 12345 +EXPOSE 24879 + +CMD java -jar ${INSTALLATION_PATH}/librespot-api-${LIBRESPOT_JAVA_VERSION}.jar From a19382b19ca69f0478dbd7c88b7b924b6d4622da Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Tue, 21 Dec 2021 21:30:36 +0100 Subject: [PATCH 008/109] Improve Docker file --- docker/spotify.Dockerfile | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docker/spotify.Dockerfile b/docker/spotify.Dockerfile index f3518e774..f0ba5df5d 100644 --- a/docker/spotify.Dockerfile +++ b/docker/spotify.Dockerfile @@ -7,8 +7,7 @@ RUN set -eux ; \ libasound2-plugins \ pulseaudio \ pulseaudio-utils \ - default-jdk \ - wget + default-jdk RUN usermod -aG audio,pulse,pulse-access root @@ -18,9 +17,9 @@ ENV LIBRESPOT_JAVA_VERSION 1.6.2 WORKDIR $INSTALLATION_PATH VOLUME $INSTALLATION_PATH -RUN wget https://github.com/librespot-org/librespot-java/releases/download/v${LIBRESPOT_JAVA_VERSION}/librespot-api-${LIBRESPOT_JAVA_VERSION}.jar +ADD https://github.com/librespot-org/librespot-java/releases/download/v${LIBRESPOT_JAVA_VERSION}/librespot-api-${LIBRESPOT_JAVA_VERSION}.jar ${INSTALLATION_PATH} EXPOSE 12345 EXPOSE 24879 -CMD java -jar ${INSTALLATION_PATH}/librespot-api-${LIBRESPOT_JAVA_VERSION}.jar +CMD java -jar librespot-api-${LIBRESPOT_JAVA_VERSION}.jar From 98cb97d299f44074b4749c678f8587190015170e Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Tue, 21 Dec 2021 21:42:58 +0100 Subject: [PATCH 009/109] Ignore spotify config.toml file to avoid accidental credential commit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 48270aedb..160efe998 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ /shared/*.* /shared/* +# Docker +/docker/config/docker.spotify.config.toml + # Documentation builder /docs/sphinx/_build/ From d1536de2283b0969e1b31763379452f6e6e467cd Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Tue, 21 Dec 2021 22:03:18 +0100 Subject: [PATCH 010/109] Create default config.toml file --- docs/sphinx/developer/docker.rst | 9 ++++++++- .../default-settings/spotify.config.toml | 0 2 files changed, 8 insertions(+), 1 deletion(-) rename docker/config/docker.spotify.config.toml => resources/default-settings/spotify.config.toml (100%) diff --git a/docs/sphinx/developer/docker.rst b/docs/sphinx/developer/docker.rst index 8bb7de2c9..4356092c6 100644 --- a/docs/sphinx/developer/docker.rst +++ b/docs/sphinx/developer/docker.rst @@ -35,7 +35,7 @@ Prerequisites 2. Pull the Jukebox repository: ``git clone https://github.com/MiczFlor/RPi-Jukebox-RFID.git`` -3. Create a jukebox.yaml file +3. Create a ``jukebox.yaml`` file * Copy the ``./resources/default-settings/jukebox.default.yaml`` to ``./shared/settings`` and rename the file to ``jukebox.yaml``. @@ -51,6 +51,13 @@ Prerequisites 4. Change directory into the ``./RPi-Jukebox-RFID/shared/audiofolders`` and copy a set of MP3 files into this folder (for more fun when testing). +5. If you like to use Spotify as well, you need to create a ``docker.spotify.config.toml`` file + + * Copy the ``./resources/default-settings/spotify.config.toml`` to ``./docker/config/docker.spotify.config.toml`` and + update the username (under ``[auth]``) and password within the file. + + ``$ cp ./resources/default-settings/spotify.config.toml ./docker/config/docker.spotify.config.toml`` + Run development environment ------------------------------ diff --git a/docker/config/docker.spotify.config.toml b/resources/default-settings/spotify.config.toml similarity index 100% rename from docker/config/docker.spotify.config.toml rename to resources/default-settings/spotify.config.toml From efb35900a71483ccabb5f65945c75be8441c8798 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 22 Dec 2021 08:24:46 +0100 Subject: [PATCH 011/109] Ignore spotify config.toml file to avoid accidental credential commit --- .gitignore | 3 +++ docker/spotify.Dockerfile | 7 +++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 48270aedb..160efe998 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ /shared/*.* /shared/* +# Docker +/docker/config/docker.spotify.config.toml + # Documentation builder /docs/sphinx/_build/ diff --git a/docker/spotify.Dockerfile b/docker/spotify.Dockerfile index f3518e774..f0ba5df5d 100644 --- a/docker/spotify.Dockerfile +++ b/docker/spotify.Dockerfile @@ -7,8 +7,7 @@ RUN set -eux ; \ libasound2-plugins \ pulseaudio \ pulseaudio-utils \ - default-jdk \ - wget + default-jdk RUN usermod -aG audio,pulse,pulse-access root @@ -18,9 +17,9 @@ ENV LIBRESPOT_JAVA_VERSION 1.6.2 WORKDIR $INSTALLATION_PATH VOLUME $INSTALLATION_PATH -RUN wget https://github.com/librespot-org/librespot-java/releases/download/v${LIBRESPOT_JAVA_VERSION}/librespot-api-${LIBRESPOT_JAVA_VERSION}.jar +ADD https://github.com/librespot-org/librespot-java/releases/download/v${LIBRESPOT_JAVA_VERSION}/librespot-api-${LIBRESPOT_JAVA_VERSION}.jar ${INSTALLATION_PATH} EXPOSE 12345 EXPOSE 24879 -CMD java -jar ${INSTALLATION_PATH}/librespot-api-${LIBRESPOT_JAVA_VERSION}.jar +CMD java -jar librespot-api-${LIBRESPOT_JAVA_VERSION}.jar From 01ff28d953f52f02e68fecd0375b689f9885ecc2 Mon Sep 17 00:00:00 2001 From: Kiriakos Antoniadis Date: Fri, 24 Dec 2021 12:16:28 +0100 Subject: [PATCH 012/109] Delete docker.spotify.config.toml --- docker/config/docker.spotify.config.toml | 84 ------------------------ 1 file changed, 84 deletions(-) delete mode 100644 docker/config/docker.spotify.config.toml diff --git a/docker/config/docker.spotify.config.toml b/docker/config/docker.spotify.config.toml deleted file mode 100644 index 1f1eeb406..000000000 --- a/docker/config/docker.spotify.config.toml +++ /dev/null @@ -1,84 +0,0 @@ -deviceId = "" ### Device ID (40 chars, leave empty for random) ### -deviceName = "docker-spotify" ### Device name ### -deviceType = "SPEAKER" ### Device type (COMPUTER, TABLET, SMARTPHONE, SPEAKER, TV, AVR, STB, AUDIO_DONGLE, GAME_CONSOLE, CAST_VIDEO, CAST_AUDIO, AUTOMOBILE, WEARABLE, UNKNOWN_SPOTIFY, CAR_THING, UNKNOWN) ### -preferredLocale = "en" ### Preferred locale ### -logLevel = "TRACE" ### Log level (OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE, ALL) ### - -[auth] ### Authentication ### -strategy = "USER_PASS" # Strategy (USER_PASS, ZEROCONF, BLOB, FACEBOOK, STORED) -username = "" # Spotify username (BLOB, USER_PASS only) -password = "" # Spotify password (USER_PASS only) -blob = "" # Spotify authentication blob Base64-encoded (BLOB only) -storeCredentials = false # Whether to store reusable credentials on disk (not a plain password) -credentialsFile = "credentials.json" # Credentials file (JSON) - -[zeroconf] ### Zeroconf ### -listenPort = 12345 # Listen on this TCP port (`-1` for random) -listenAll = true # Listen on all interfaces (overrides `zeroconf.interfaces`) -interfaces = "" # Listen on these interfaces (comma separated list of names) - -[cache] ### Cache ### -enabled = false # Cache enabled -dir = "./cache/" -doCleanUp = true - -[network] ### Network ### -connectionTimeout = 10 # If ping isn't received within this amount of seconds, reconnect - -[preload] ### Preload ### -enabled = true # Preload enabled - -[time] ### Time correction ### -synchronizationMethod = "NTP" # Time synchronization method (NTP, PING, MELODY, MANUAL) -manualCorrection = 0 # Manual time correction in millis - -[player] ### Player ### -autoplayEnabled = true # Autoplay similar songs when your music ends -preferredAudioQuality = "NORMAL" # Preferred audio quality (NORMAL, HIGH, VERY_HIGH) -enableNormalisation = true # Whether to apply the Spotify loudness normalisation -normalisationPregain = +3.0 # Normalisation pregain in decibels (loud at +6, normal at +3, quiet at -5) -initialVolume = 65536 # Initial volume (0-65536) -volumeSteps = 64 # Number of volume notches -logAvailableMixers = true # Log available mixers -mixerSearchKeywords = "" # Mixer/backend search keywords (semicolon separated) -crossfadeDuration = 0 # Crossfade overlap time (in milliseconds) -output = "MIXER" # Audio output device (MIXER, PIPE, STDOUT, CUSTOM) -outputClass = "" # Audio output Java class name -releaseLineDelay = 20 # Release mixer line after set delay (in seconds) -pipe = "" # Output raw (signed) PCM to this file (`player.output` must be PIPE) -retryOnChunkError = true # Whether the player should retry fetching a chuck if it fails -metadataPipe = "" # Output metadata in Shairport Sync format (https://github.com/mikebrady/shairport-sync-metadata-reader) -bypassSinkVolume = false # Whether librespot-java should ignore volume events, sink volume is set to the max -localFilesPath = "" # Where librespot-java should search for local files - -[api] ### API ### -port = 24879 # API port (`api` module only) -host = "0.0.0.0" # API listen interface (`api` module only) - -[proxy] ### Proxy ### -enabled = false # Whether the proxy is enabled -type = "HTTP" # The proxy type (HTTP, SOCKS) -ssl = false # Connect to proxy using SSL (HTTP only) -address = "" # The proxy hostname -port = 0 # The proxy port -auth = false # Whether authentication is enabled on the server -username = "" # Basic auth username -password = "" # Basic auth password - -[shell] ### Shell ### -enabled = false # Shell events enabled -executeWithBash = false # Execute the command with `bash -c` -onContextChanged = "" -onTrackChanged = "" -onPlaybackEnded = "" -onPlaybackPaused = "" -onPlaybackResumed = "" -onTrackSeeked = "" -onMetadataAvailable = "" -onVolumeChanged = "" -onInactiveSession = "" -onPanicState = "" -onConnectionDropped = "" -onConnectionEstablished = "" -onStartedLoading = "" -onFinishedLoading = "" \ No newline at end of file From 19ebc60b6d3100c19288f31faee35c27a0922311 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sun, 26 Dec 2021 13:16:09 +0100 Subject: [PATCH 013/109] First implementation of spotify functions: play, stop, pause, prev, next, seek, shuffle, and many more --- .../default-settings/jukebox.default.yaml | 8 + src/jukebox/components/playerspot/__init__.py | 523 ++++++++++++++++++ .../components/playerspot/json_example.json | 507 +++++++++++++++++ 3 files changed, 1038 insertions(+) create mode 100644 src/jukebox/components/playerspot/__init__.py create mode 100644 src/jukebox/components/playerspot/json_example.json diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index 4132d00dd..44c005a89 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -87,6 +87,14 @@ playermpd: update_on_startup: true check_user_rights: true mpd_conf: ~/.config/mpd/mpd.conf +playerspot: + host: localhost + status_file: ../../shared/settings/spotify_player_status.json + second_swipe_action: + # Note: Does not follow the RPC alias convention (yet) + # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' + alias: toggle + spot_conf: ../../shared/spotify/config.toml" rpc: tcp_port: 5555 websocket_port: 5556 diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py new file mode 100644 index 000000000..71e0449ff --- /dev/null +++ b/src/jukebox/components/playerspot/__init__.py @@ -0,0 +1,523 @@ +# -*- coding: utf-8 -*- +""" +Package for interfacing with the librespot-java API + +Saving +{'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', + 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'}, + 'audio_folder_status': + {'TraumfaengerStarkeLieder': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', + 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'stop', 'RESUME': 'OFF', + 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}, + 'Giraffenaffen': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', + 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'play', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', + 'LOOP': 'OFF', 'SINGLE': 'OFF'}}} + +References: +https://github.com/librespot-org/librespot-java +https://github.com/librespot-org/librespot-java/tree/dev/api +https://github.com/spocon/spocon +""" + +import logging +import functools +import threading +import urllib.parse +import requests + +import components.player +import jukebox.cfghandler +import jukebox.utils as utils +import jukebox.plugs as plugs +import jukebox.multitimer as multitimer +import jukebox.publishing as publishing +import misc + +from jukebox.NvManager import nv_manager + +logger = logging.getLogger('jb.PlayerSpot') +cfg = jukebox.cfghandler.get_handler('jukebox') + +test_dict = {'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', + 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'}, + 'audio_folder_status': + {'TraumfaengerStarkeLieder': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', + 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'stop', 'RESUME': 'OFF', + 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}, + 'Giraffenaffen': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', + 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'play', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', + 'LOOP': 'OFF', 'SINGLE': 'OFF'}}} + + +class SpotLock: + def __init__(self, host: str, port: int): + self._lock = threading.RLock() + self.host = host + self.port = port + + def _try_connect(self): + pass + + def __enter__(self): + self._lock.acquire() + self._try_connect() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self._lock.release() + + def acquire(self, blocking: bool = True, timeout: float = -1) -> bool: + locked = self._lock.acquire(blocking, timeout) + if locked: + self._try_connect() + return locked + + def release(self): + self._lock.release() + + def locked(self): + return self._lock.locked() + + +class PlayerSpot: + """Interface to librespot-java API""" + + # ToDo: spot_state + # ToDo: response handling + def __init__(self): + self.nvm = nv_manager() + self.spot_host = cfg.getn('playerspot', 'host') + self.spot_api_port = 24879 + self.spot_api_baseurl = f"{self.spot_host}:{self.spot_api_port}" + self.music_player_status = self.nvm.load(cfg.getn('playermpd', 'status_file')) + + self.second_swipe_action_dict = {'toggle': self.toggle, + 'play': self.play, + 'skip': self.next, + 'rewind': self.rewind, + 'replay': self.replay, + 'replay_if_stopped': self.replay_if_stopped} + self.second_swipe_action = None + self.decode_2nd_swipe_option() + + self.current_folder_status = {} + if not self.music_player_status: + self.music_player_status['player_status'] = {} + self.music_player_status['audio_folder_status'] = {} + self.music_player_status.save_to_json() + self.current_folder_status = {} + self.music_player_status['player_status']['last_played_folder'] = '' + else: + last_played_folder = self.music_player_status['player_status'].get('last_played_folder') + if last_played_folder: + # current_folder_status is a dict, but last_played_folder a str + self.current_folder_status = self.music_player_status['audio_folder_status'][last_played_folder] + logger.info(f"Last Played Folder: {last_played_folder}") + + # Clear last folder played, as we actually did not play any folder yet + # Needed for second swipe detection + # TODO: This will loose the last_played_folder information is the box is started and closed with + # playing anything... + # Change this to last_played_folder and shutdown_state (for restoring) + self.music_player_status['player_status']['last_played_folder'] = '' + + self.old_song = None + self.mpd_status = {} + self.mpd_status_poll_interval = 0.25 + # ToDo: check of spot_lock works + self.spot_lock = SpotLock(self.spot_host, self.spot_api_port) + self.status_is_closing = False + # self.status_thread = threading.Timer(self.mpd_status_poll_interval, self._mpd_status_poll).start() + + self.status_thread = multitimer.GenericEndlessTimerClass('mpd.timer_status', + self.mpd_status_poll_interval, self._mpd_status_poll) + self.status_thread.start() + + self.old_song = None + self.spot_status_poll_interval = 0.25 + self.status_is_closing = False + + def exit(self): + logger.debug("Exit routine of playerspot started") + self.nvm.save_all() + api_path = "/instance/close" + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + return "payerspot exited" + + def decode_2nd_swipe_option(self): + cfg_2nd_swipe_action = cfg.setndefault('playermpd', 'second_swipe_action', 'alias', value='none').lower() + if cfg_2nd_swipe_action not in [*self.second_swipe_action_dict.keys(), 'none', 'custom']: + logger.error(f"Config mpd.second_swipe_action must be one of " + f"{[*self.second_swipe_action_dict.keys(), 'none', 'custom']}. Ignore setting.") + if cfg_2nd_swipe_action in self.second_swipe_action_dict.keys(): + self.second_swipe_action = self.second_swipe_action_dict[cfg_2nd_swipe_action] + if cfg_2nd_swipe_action == 'custom': + custom_action = utils.decode_rpc_call(cfg.getn('playermpd', 'second_swipe_action', default=None)) + self.second_swipe_action = functools.partial(plugs.call_ignore_errors, + custom_action['package'], + custom_action['plugin'], + custom_action['method'], + custom_action['args'], + custom_action['kwargs']) + + def _spot_status_poll(self): + """ + this method polls the status from mpd and stores the important inforamtion in the music_player_status, + it will repeat itself in the intervall specified by self.mpd_status_poll_interval + """ + self.mpd_status.update(self.mpd_retry_with_mutex(self.mpd_client.status)) + self.mpd_status.update(self.mpd_retry_with_mutex(self.mpd_client.currentsong)) + + if self.mpd_status.get('elapsed') is not None: + self.current_folder_status["ELAPSED"] = self.mpd_status['elapsed'] + self.music_player_status['player_status']["CURRENTSONGPOS"] = self.mpd_status['song'] + self.music_player_status['player_status']["CURRENTFILENAME"] = self.mpd_status['file'] + + if self.mpd_status.get('file') is not None: + self.current_folder_status["CURRENTFILENAME"] = self.mpd_status['file'] + self.current_folder_status["CURRENTSONGPOS"] = self.mpd_status['song'] + self.current_folder_status["ELAPSED"] = self.mpd_status.get('elapsed', '0.0') + self.current_folder_status["PLAYSTATUS"] = self.mpd_status['state'] + self.current_folder_status["RESUME"] = "OFF" + self.current_folder_status["SHUFFLE"] = "OFF" + self.current_folder_status["LOOP"] = "OFF" + self.current_folder_status["SINGLE"] = "OFF" + + # Delete the volume key to avoid confusion + # Volume is published via the 'volume' component! + try: + del self.mpd_status['volume'] + except KeyError: + pass + publishing.get_publisher().send('playerstatus', self.mpd_status) + + @plugs.tag + def load(self, uri: str, start_playing: bool): + self.check_uri(uri) + api_path = f"/player/load?uri={uri}&play={start_playing}" + with self.spot_lock: + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + + @plugs.tag + def play(self): + api_path = "/player/resume" + with self.spot_lock: + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + + @plugs.tag + def stop(self): + self.pause(state=1) + + @plugs.tag + def pause(self, state: int = 1): + """Enforce pause to state (1: pause, 0: resume) + + This is what you want as card removal action: pause the playback, so it can be resumed when card is placed + on the reader again. What happens on re-placement depends on configured second swipe option + """ + if state == 1: + api_path = "/player/pause" + with self.spot_lock: + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + else: + self.play() + + @plugs.tag + def prev(self): + api_path = "/player/prev" + with self.spot_lock: + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + + @plugs.tag + def next(self): + api_path = "/player/next" + with self.spot_lock: + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + + @plugs.tag + def seek(self, new_time: int): + """ + Seek to a given position in milliseconds specified by new_time + """ + api_path = f"/player/seek?position_ms={new_time}" + with self.spot_lock: + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + + @plugs.tag + def shuffle(self, random: bool): + api_path = f"/player/shuffle?state={1 if random else 0}" + with self.spot_lock: + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + + @plugs.tag + def rewind(self): + """ + Re-start current playlist from first track + + Note: Will not re-read folder config, but leave settings untouched""" + logger.debug("Rewind") + self.seek(0) + + @plugs.tag + def replay(self): + """ + Re-start playing the last-played playlist + + Will reset settings to folder config""" + logger.debug("Replay") + with self.spot_lock: + self.play_playlist(self.music_player_status['player_status']['last_played_folder']) + + @plugs.tag + def toggle(self): + """Toggle pause state, i.e. do a pause / resume depending on current state""" + logger.debug("Toggle") + api_path = "/player/play-pause" + with self.spot_lock: + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + + @plugs.tag + def replay_if_stopped(self): + """ + Re-start playing the last-played folder unless playlist is still playing + + .. note:: To me this seems much like the behaviour of play, + but we keep it as it is specifically implemented in box 2.X""" + logger.debug("replay_if_stopped") + with self.spot_lock: + if self.mpd_status['state'] == 'stop': + self.play_playlist(self.music_player_status['player_status']['last_played_folder']) + + @plugs.tag + def repeatmode(self, mode: str): + if mode == 'repeat': + rep_state = "context" + elif mode == 'single': + rep_state = "track" + else: + rep_state = "none" + api_path = f"/player/repeat?state={rep_state}" + with self.spot_lock: + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + + @plugs.tag + def get_current_song(self, param): + return self.spot_status + + @plugs.tag + def map_filename_to_playlist_pos(self, filename): + raise NotImplementedError + + @plugs.tag + def remove(self): + raise NotImplementedError + + @plugs.tag + def move(self): + raise NotImplementedError + + @plugs.tag + def play_single(self, song_uri: str): + self.check_uri(song_uri) + api_path = f"/player/repeat?uri={song_uri}&play=true" + with self.spot_lock: + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + + @plugs.tag + def resume(self): + with self.spot_lock: + songpos = self.current_folder_status["CURRENTSONGPOS"] + self.seek(songpos) + self.play() + + @plugs.tag + def play_card(self, folder: str, recursive: bool = False): + """ + Main entry point for trigger music playing from RFID reader. Decodes second swipe options before + playing folder content + + Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action + accordingly. + + :param folder: Folder path relative to music library path + :param recursive: Add folder recursively + """ + # Developers notes: + # + # * 2nd swipe trigger may also happen, if playlist has already stopped playing + # --> Generally, treat as first swipe + # * 2nd swipe of same Card ID may also happen if a different song has been played in between from WebUI + # --> Treat as first swipe + # * With place-not-swipe: Card is placed on reader until playlist expieres. Music stop. Card is removed and + # placed again on the reader: Should be like first swipe + # * TODO: last_played_folder is restored after box start, so first swipe of last played card may look like + # second swipe + # + logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}") + logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}") + with self.spot_lock: + is_second_swipe = self.music_player_status['player_status']['last_played_folder'] == folder + if self.second_swipe_action is not None and is_second_swipe: + logger.debug('Calling second swipe action') + self.second_swipe_action() + else: + logger.debug('Calling first swipe action') + self.play_playlist(folder, recursive) + + @plugs.tag + def get_playlist_content(self, playlist_uri: str): + """ + Get the spotify playlist content as content list with meta-information + + :param playlist_uri: URI for the spotify playlist as string + """ + # ToDo: implement + track_list = [] + return track_list + + @plugs.tag + def play_playlist(self, playlist_uri: str, recursive: bool = False) -> None: + """ + Playback a spotify playlist. + + :param playlist_uri: Folder path relative to music library path + :param recursive: Add folder recursively + """ + logger.debug("play_folder") + # TODO: This changes the current state -> Need to save last state + with self.spot_lock: + logger.info(f"Play spotify playlist: '{playlist_uri}'") + + self.music_player_status['player_status']['last_played_folder'] = playlist_uri + + self.current_folder_status = self.music_player_status['audio_folder_status'].get(playlist_uri) + if self.current_folder_status is None: + self.current_folder_status = self.music_player_status['audio_folder_status'][playlist_uri] = {} + + self.load(self.current_folder_status, start_playing=True) + + @plugs.tag + def play_album(self, album_uri: str): + """ + Playback a album from spotify. + + :param album_uri: Album URI from spotify + """ + logger.debug("play_album") + with self.spot_lock: + logger.info(f"Play album: '{album_uri}'") + self.load(album_uri, start_playing=True) + + @plugs.tag + def queue_load(self, folder): + # There was something playing before -> stop and save state + # Clear the queue + # Check / Create the playlist + # - not needed if same folder is played again? Buf what if files have been added a mpc update has been run? + # - and this a re-trigger to start the new playlist + # If we must update the playlists everytime anyway why write them to file and not just keep them in the queue? + # Load the playlist + # Get folder config and apply settings + pass + + @plugs.tag + def playerstatus(self): + return self.spot_status + + @plugs.tag + def playlistinfo(self): + with self.spot_lock: + # ToDo: implement + value = ["this is a list"] + return value + + @plugs.tag + def list_all_dirs(self): + raise NotImplementedError + + @plugs.tag + def list_albums(self): + with self.spot_lock: + albums = ["this is a list"] + return albums + + @plugs.tag + def list_song_by_artist_and_album(self, albumartist, album): + # with self.mpd_lock: + # albums = self.mpd_retry_with_mutex(self.mpd_client.find, 'albumartist', albumartist, 'album', album) + # + # return albums + pass + + def get_volume(self): + """ + Get the current volume + + For volume control do not use directly, but use through the plugin 'volume', + as the user may have configured a volume control manager other than Spotify""" + # ToDo: get volume from Playback state + # https://developer.spotify.com/documentation/web-api/reference/#/operations/get-information-about-the-users-current-playback + pass + + def set_volume(self, volume): + """ + Set the volume + + For volume control do not use directly, but use through the plugin 'volume', + as the user may have configured a volume control manager other than Spotify""" + api_path = f"/player/volume?volume_percent={volume}" + with self.spot_lock: + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + return self.get_volume() + + @staticmethod + def check_uri(uri: str): + """ + Checking that the uri has the right syntax + """ + check_list = uri.split(":") + valid_play_type = ["album", "track"] + if check_list[1] == "user": + assert len(check_list) == 5, f"URI {uri} is missing information." + assert check_list[0] == "spotify", f"URI {uri} does not start with spotify" + assert check_list[1] == "user", f"URI {uri} does not contain a valid type on pos 2" + assert type(check_list[2]) is int, f"URI {uri} does not contain the right user id on pos 3" + assert check_list[3] == "playlist", f"URI {uri} does not contain a valid type playlist on pos 4" + + else: + assert len(check_list) == 3, f"URI {uri} is missing information." + assert check_list[0] == "spotify", f"URI {uri} does not start with spotify" + assert check_list[1] in valid_play_type, f"URI {uri} does not contain a valid type on pos 2" + + +# --------------------------------------------------------------------------- +# Plugin Initializer / Finalizer +# --------------------------------------------------------------------------- + +player_ctrl: PlayerSpot + + +@plugs.initialize +def initialize(): + global player_ctrl + player_ctrl = PlayerSpot() + plugs.register(player_ctrl, name='ctrl') + + # Update mpc library + library_update = cfg.setndefault('playermpd', 'library', 'update_on_startup', value=True) + if library_update: + player_ctrl.update() + + # Check user rights on music library + library_check_user_rights = cfg.setndefault('playermpd', 'library', 'check_user_rights', value=True) + if library_check_user_rights is True: + music_library_path = components.player.get_music_library_path() + if music_library_path is not None: + logger.info(f"Change user rights for {music_library_path}") + misc.recursive_chmod(music_library_path, mode_files=0o666, mode_dirs=0o777) + + +@plugs.atexit +def atexit(**ignored_kwargs): + global player_ctrl + return player_ctrl.exit() diff --git a/src/jukebox/components/playerspot/json_example.json b/src/jukebox/components/playerspot/json_example.json new file mode 100644 index 000000000..88076791e --- /dev/null +++ b/src/jukebox/components/playerspot/json_example.json @@ -0,0 +1,507 @@ +{ + "device":{ + "id":"45eb684ffece0712ebdc11b502bbb242604d3d13", + "is_active":true, + "is_private_session":false, + "is_restricted":false, + "name":"Pixel 3a", + "type":"Smartphone", + "volume_percent":100 + }, + "shuffle_state":false, + "repeat_state":"off", + "timestamp":1640345447023, + "context":{ + "external_urls":{ + "spotify":"https://open.spotify.com/playlist/37i9dQZF1EJy9NVWJjerLg" + }, + "href":"https://api.spotify.com/v1/playlists/37i9dQZF1EJy9NVWJjerLg", + "type":"playlist", + "uri":"spotify:playlist:37i9dQZF1EJy9NVWJjerLg" + }, + "progress_ms":104784, + "item":{ + "album":{ + "album_type":"single", + "artists":[ + { + "external_urls":{ + "spotify":"https://open.spotify.com/artist/1fd3fmwlhrDl2U5wbbPQYN" + }, + "href":"https://api.spotify.com/v1/artists/1fd3fmwlhrDl2U5wbbPQYN", + "id":"1fd3fmwlhrDl2U5wbbPQYN", + "name":"Apashe", + "type":"artist", + "uri":"spotify:artist:1fd3fmwlhrDl2U5wbbPQYN" + }, + { + "external_urls":{ + "spotify":"https://open.spotify.com/artist/4Nl6PVYLwbCFfr3UqQlFtE" + }, + "href":"https://api.spotify.com/v1/artists/4Nl6PVYLwbCFfr3UqQlFtE", + "id":"4Nl6PVYLwbCFfr3UqQlFtE", + "name":"VOLAC", + "type":"artist", + "uri":"spotify:artist:4Nl6PVYLwbCFfr3UqQlFtE" + } + ], + "available_markets":[ + "AD", + "AE", + "AG", + "AL", + "AM", + "AO", + "AR", + "AT", + "AU", + "AZ", + "BA", + "BB", + "BD", + "BE", + "BF", + "BG", + "BH", + "BI", + "BJ", + "BN", + "BO", + "BR", + "BS", + "BT", + "BW", + "BY", + "BZ", + "CA", + "CD", + "CG", + "CH", + "CI", + "CL", + "CM", + "CO", + "CR", + "CV", + "CW", + "CY", + "CZ", + "DE", + "DJ", + "DK", + "DM", + "DO", + "DZ", + "EC", + "EE", + "EG", + "ES", + "FI", + "FJ", + "FM", + "FR", + "GA", + "GB", + "GD", + "GE", + "GH", + "GM", + "GN", + "GQ", + "GR", + "GT", + "GW", + "GY", + "HK", + "HN", + "HR", + "HT", + "HU", + "ID", + "IE", + "IL", + "IN", + "IQ", + "IS", + "IT", + "JM", + "JO", + "JP", + "KE", + "KG", + "KH", + "KI", + "KM", + "KN", + "KR", + "KW", + "KZ", + "LA", + "LB", + "LC", + "LI", + "LK", + "LR", + "LS", + "LT", + "LU", + "LV", + "LY", + "MA", + "MC", + "MD", + "ME", + "MG", + "MH", + "MK", + "ML", + "MN", + "MO", + "MR", + "MT", + "MU", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NE", + "NG", + "NI", + "NL", + "NO", + "NP", + "NR", + "NZ", + "OM", + "PA", + "PE", + "PG", + "PH", + "PK", + "PL", + "PS", + "PT", + "PW", + "PY", + "QA", + "RO", + "RS", + "RU", + "RW", + "SA", + "SB", + "SC", + "SE", + "SG", + "SI", + "SK", + "SL", + "SM", + "SN", + "SR", + "ST", + "SV", + "SZ", + "TD", + "TG", + "TH", + "TJ", + "TL", + "TN", + "TO", + "TR", + "TT", + "TV", + "TW", + "TZ", + "UA", + "UG", + "US", + "UY", + "UZ", + "VC", + "VE", + "VN", + "VU", + "WS", + "XK", + "ZA", + "ZM", + "ZW" + ], + "external_urls":{ + "spotify":"https://open.spotify.com/album/4DV2LgtYZbumkW6ifBZkzh" + }, + "href":"https://api.spotify.com/v1/albums/4DV2LgtYZbumkW6ifBZkzh", + "id":"4DV2LgtYZbumkW6ifBZkzh", + "images":[ + { + "height":640, + "url":"https://i.scdn.co/image/ab67616d0000b273967a9c4c384f097d3091777b", + "width":640 + }, + { + "height":300, + "url":"https://i.scdn.co/image/ab67616d00001e02967a9c4c384f097d3091777b", + "width":300 + }, + { + "height":64, + "url":"https://i.scdn.co/image/ab67616d00004851967a9c4c384f097d3091777b", + "width":64 + } + ], + "name":"Distance (Volac Remix)", + "release_date":"2021-01-08", + "release_date_precision":"day", + "total_tracks":1, + "type":"album", + "uri":"spotify:album:4DV2LgtYZbumkW6ifBZkzh" + }, + "artists":[ + { + "external_urls":{ + "spotify":"https://open.spotify.com/artist/1fd3fmwlhrDl2U5wbbPQYN" + }, + "href":"https://api.spotify.com/v1/artists/1fd3fmwlhrDl2U5wbbPQYN", + "id":"1fd3fmwlhrDl2U5wbbPQYN", + "name":"Apashe", + "type":"artist", + "uri":"spotify:artist:1fd3fmwlhrDl2U5wbbPQYN" + }, + { + "external_urls":{ + "spotify":"https://open.spotify.com/artist/4Nl6PVYLwbCFfr3UqQlFtE" + }, + "href":"https://api.spotify.com/v1/artists/4Nl6PVYLwbCFfr3UqQlFtE", + "id":"4Nl6PVYLwbCFfr3UqQlFtE", + "name":"VOLAC", + "type":"artist", + "uri":"spotify:artist:4Nl6PVYLwbCFfr3UqQlFtE" + }, + { + "external_urls":{ + "spotify":"https://open.spotify.com/artist/0VzoflxRgSVEWHYmCbMOJJ" + }, + "href":"https://api.spotify.com/v1/artists/0VzoflxRgSVEWHYmCbMOJJ", + "id":"0VzoflxRgSVEWHYmCbMOJJ", + "name":"Geoffroy", + "type":"artist", + "uri":"spotify:artist:0VzoflxRgSVEWHYmCbMOJJ" + } + ], + "available_markets":[ + "AD", + "AE", + "AG", + "AL", + "AM", + "AO", + "AR", + "AT", + "AU", + "AZ", + "BA", + "BB", + "BD", + "BE", + "BF", + "BG", + "BH", + "BI", + "BJ", + "BN", + "BO", + "BR", + "BS", + "BT", + "BW", + "BY", + "BZ", + "CA", + "CD", + "CG", + "CH", + "CI", + "CL", + "CM", + "CO", + "CR", + "CV", + "CW", + "CY", + "CZ", + "DE", + "DJ", + "DK", + "DM", + "DO", + "DZ", + "EC", + "EE", + "EG", + "ES", + "FI", + "FJ", + "FM", + "FR", + "GA", + "GB", + "GD", + "GE", + "GH", + "GM", + "GN", + "GQ", + "GR", + "GT", + "GW", + "GY", + "HK", + "HN", + "HR", + "HT", + "HU", + "ID", + "IE", + "IL", + "IN", + "IQ", + "IS", + "IT", + "JM", + "JO", + "JP", + "KE", + "KG", + "KH", + "KI", + "KM", + "KN", + "KR", + "KW", + "KZ", + "LA", + "LB", + "LC", + "LI", + "LK", + "LR", + "LS", + "LT", + "LU", + "LV", + "LY", + "MA", + "MC", + "MD", + "ME", + "MG", + "MH", + "MK", + "ML", + "MN", + "MO", + "MR", + "MT", + "MU", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NE", + "NG", + "NI", + "NL", + "NO", + "NP", + "NR", + "NZ", + "OM", + "PA", + "PE", + "PG", + "PH", + "PK", + "PL", + "PS", + "PT", + "PW", + "PY", + "QA", + "RO", + "RS", + "RU", + "RW", + "SA", + "SB", + "SC", + "SE", + "SG", + "SI", + "SK", + "SL", + "SM", + "SN", + "SR", + "ST", + "SV", + "SZ", + "TD", + "TG", + "TH", + "TJ", + "TL", + "TN", + "TO", + "TR", + "TT", + "TV", + "TW", + "TZ", + "UA", + "UG", + "US", + "UY", + "UZ", + "VC", + "VE", + "VN", + "VU", + "WS", + "XK", + "ZA", + "ZM", + "ZW" + ], + "disc_number":1, + "duration_ms":215390, + "explicit":false, + "external_ids":{ + "isrc":"CA5KR2124377" + }, + "external_urls":{ + "spotify":"https://open.spotify.com/track/42BdPV3FB2HVsvdokaIDJt" + }, + "href":"https://api.spotify.com/v1/tracks/42BdPV3FB2HVsvdokaIDJt", + "id":"42BdPV3FB2HVsvdokaIDJt", + "is_local":false, + "name":"Distance - Volac Remix", + "popularity":57, + "preview_url":"https://p.scdn.co/mp3-preview/1fcbfaddb5370c0d8420229e451df0672ae9b5f4?cid=65b708073fc0480ea92a077233ca87bd", + "track_number":1, + "type":"track", + "uri":"spotify:track:42BdPV3FB2HVsvdokaIDJt" + }, + "currently_playing_type":"track", + "actions":{ + "disallows":{ + "resuming":true + } + }, + "is_playing":true +} From 6646f9a36dc0b485dff12f7cb7f0cd492d808fba Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 27 Dec 2021 20:16:47 +0100 Subject: [PATCH 014/109] Removed SpotLock --- src/jukebox/components/playerspot/__init__.py | 110 +++++------------- 1 file changed, 28 insertions(+), 82 deletions(-) diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index 71e0449ff..c2dd6744b 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -49,36 +49,6 @@ 'LOOP': 'OFF', 'SINGLE': 'OFF'}}} -class SpotLock: - def __init__(self, host: str, port: int): - self._lock = threading.RLock() - self.host = host - self.port = port - - def _try_connect(self): - pass - - def __enter__(self): - self._lock.acquire() - self._try_connect() - return self - - def __exit__(self, exc_type, exc_value, traceback): - self._lock.release() - - def acquire(self, blocking: bool = True, timeout: float = -1) -> bool: - locked = self._lock.acquire(blocking, timeout) - if locked: - self._try_connect() - return locked - - def release(self): - self._lock.release() - - def locked(self): - return self._lock.locked() - - class PlayerSpot: """Interface to librespot-java API""" @@ -125,7 +95,6 @@ def __init__(self): self.mpd_status = {} self.mpd_status_poll_interval = 0.25 # ToDo: check of spot_lock works - self.spot_lock = SpotLock(self.spot_host, self.spot_api_port) self.status_is_closing = False # self.status_thread = threading.Timer(self.mpd_status_poll_interval, self._mpd_status_poll).start() @@ -195,14 +164,12 @@ def _spot_status_poll(self): def load(self, uri: str, start_playing: bool): self.check_uri(uri) api_path = f"/player/load?uri={uri}&play={start_playing}" - with self.spot_lock: - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) @plugs.tag def play(self): api_path = "/player/resume" - with self.spot_lock: - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) @plugs.tag def stop(self): @@ -217,22 +184,19 @@ def pause(self, state: int = 1): """ if state == 1: api_path = "/player/pause" - with self.spot_lock: - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) else: self.play() @plugs.tag def prev(self): api_path = "/player/prev" - with self.spot_lock: - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) @plugs.tag def next(self): api_path = "/player/next" - with self.spot_lock: - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) @plugs.tag def seek(self, new_time: int): @@ -240,14 +204,12 @@ def seek(self, new_time: int): Seek to a given position in milliseconds specified by new_time """ api_path = f"/player/seek?position_ms={new_time}" - with self.spot_lock: - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) @plugs.tag def shuffle(self, random: bool): api_path = f"/player/shuffle?state={1 if random else 0}" - with self.spot_lock: - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) @plugs.tag def rewind(self): @@ -265,16 +227,14 @@ def replay(self): Will reset settings to folder config""" logger.debug("Replay") - with self.spot_lock: - self.play_playlist(self.music_player_status['player_status']['last_played_folder']) + self.play_playlist(self.music_player_status['player_status']['last_played_folder']) @plugs.tag def toggle(self): """Toggle pause state, i.e. do a pause / resume depending on current state""" logger.debug("Toggle") api_path = "/player/play-pause" - with self.spot_lock: - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) @plugs.tag def replay_if_stopped(self): @@ -284,9 +244,8 @@ def replay_if_stopped(self): .. note:: To me this seems much like the behaviour of play, but we keep it as it is specifically implemented in box 2.X""" logger.debug("replay_if_stopped") - with self.spot_lock: - if self.mpd_status['state'] == 'stop': - self.play_playlist(self.music_player_status['player_status']['last_played_folder']) + if self.mpd_status['state'] == 'stop': + self.play_playlist(self.music_player_status['player_status']['last_played_folder']) @plugs.tag def repeatmode(self, mode: str): @@ -297,8 +256,7 @@ def repeatmode(self, mode: str): else: rep_state = "none" api_path = f"/player/repeat?state={rep_state}" - with self.spot_lock: - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) @plugs.tag def get_current_song(self, param): @@ -320,15 +278,13 @@ def move(self): def play_single(self, song_uri: str): self.check_uri(song_uri) api_path = f"/player/repeat?uri={song_uri}&play=true" - with self.spot_lock: - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) @plugs.tag def resume(self): - with self.spot_lock: - songpos = self.current_folder_status["CURRENTSONGPOS"] - self.seek(songpos) - self.play() + songpos = self.current_folder_status["CURRENTSONGPOS"] + self.seek(songpos) + self.play() @plugs.tag def play_card(self, folder: str, recursive: bool = False): @@ -355,8 +311,7 @@ def play_card(self, folder: str, recursive: bool = False): # logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}") logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}") - with self.spot_lock: - is_second_swipe = self.music_player_status['player_status']['last_played_folder'] == folder + is_second_swipe = self.music_player_status['player_status']['last_played_folder'] == folder if self.second_swipe_action is not None and is_second_swipe: logger.debug('Calling second swipe action') self.second_swipe_action() @@ -385,16 +340,15 @@ def play_playlist(self, playlist_uri: str, recursive: bool = False) -> None: """ logger.debug("play_folder") # TODO: This changes the current state -> Need to save last state - with self.spot_lock: - logger.info(f"Play spotify playlist: '{playlist_uri}'") + logger.info(f"Play spotify playlist: '{playlist_uri}'") - self.music_player_status['player_status']['last_played_folder'] = playlist_uri + self.music_player_status['player_status']['last_played_folder'] = playlist_uri - self.current_folder_status = self.music_player_status['audio_folder_status'].get(playlist_uri) - if self.current_folder_status is None: - self.current_folder_status = self.music_player_status['audio_folder_status'][playlist_uri] = {} + self.current_folder_status = self.music_player_status['audio_folder_status'].get(playlist_uri) + if self.current_folder_status is None: + self.current_folder_status = self.music_player_status['audio_folder_status'][playlist_uri] = {} - self.load(self.current_folder_status, start_playing=True) + self.load(self.current_folder_status, start_playing=True) @plugs.tag def play_album(self, album_uri: str): @@ -426,9 +380,8 @@ def playerstatus(self): @plugs.tag def playlistinfo(self): - with self.spot_lock: - # ToDo: implement - value = ["this is a list"] + # ToDo: implement + value = ["this is a list"] return value @plugs.tag @@ -437,8 +390,7 @@ def list_all_dirs(self): @plugs.tag def list_albums(self): - with self.spot_lock: - albums = ["this is a list"] + albums = ["this is a list"] return albums @plugs.tag @@ -466,8 +418,7 @@ def set_volume(self, volume): For volume control do not use directly, but use through the plugin 'volume', as the user may have configured a volume control manager other than Spotify""" api_path = f"/player/volume?volume_percent={volume}" - with self.spot_lock: - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) return self.get_volume() @staticmethod @@ -503,13 +454,8 @@ def initialize(): player_ctrl = PlayerSpot() plugs.register(player_ctrl, name='ctrl') - # Update mpc library - library_update = cfg.setndefault('playermpd', 'library', 'update_on_startup', value=True) - if library_update: - player_ctrl.update() - # Check user rights on music library - library_check_user_rights = cfg.setndefault('playermpd', 'library', 'check_user_rights', value=True) + library_check_user_rights = cfg.setndefault('playerspot', 'library', 'check_user_rights', value=True) if library_check_user_rights is True: music_library_path = components.player.get_music_library_path() if music_library_path is not None: From 6e0a3f854f432b5eac1c12acce875cd6b5ed8369 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 27 Dec 2021 20:48:59 +0100 Subject: [PATCH 015/109] Cleanup and device_info implementation --- src/jukebox/components/playerspot/__init__.py | 74 ++++++++----------- 1 file changed, 32 insertions(+), 42 deletions(-) diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index c2dd6744b..be9d3863f 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -18,10 +18,8 @@ https://github.com/librespot-org/librespot-java/tree/dev/api https://github.com/spocon/spocon """ - import logging import functools -import threading import urllib.parse import requests @@ -59,7 +57,16 @@ def __init__(self): self.spot_host = cfg.getn('playerspot', 'host') self.spot_api_port = 24879 self.spot_api_baseurl = f"{self.spot_host}:{self.spot_api_port}" - self.music_player_status = self.nvm.load(cfg.getn('playermpd', 'status_file')) + + # Info as dict + # Example: {"device_id":"ABC", + # "device_name":"Phoniebox", + # "device_type":"SPEAKER", + # "country_code":"DE", + # "preferred_locale":"de"} + self.device_info = requests.get(urllib.parse.urljoin(self.spot_api_baseurl, "/instance")).json() + + self.music_player_status = self.nvm.load(cfg.getn('playerspot', 'status_file')) self.second_swipe_action_dict = {'toggle': self.toggle, 'play': self.play, @@ -92,14 +99,13 @@ def __init__(self): self.music_player_status['player_status']['last_played_folder'] = '' self.old_song = None - self.mpd_status = {} - self.mpd_status_poll_interval = 0.25 + self.spot_status = {} + self.spot_status_poll_interval = 0.25 # ToDo: check of spot_lock works self.status_is_closing = False - # self.status_thread = threading.Timer(self.mpd_status_poll_interval, self._mpd_status_poll).start() - self.status_thread = multitimer.GenericEndlessTimerClass('mpd.timer_status', - self.mpd_status_poll_interval, self._mpd_status_poll) + self.status_thread = multitimer.GenericEndlessTimerClass('spot.timer_status', + self.spot_status_poll_interval, self._spot_status_poll) self.status_thread.start() self.old_song = None @@ -114,14 +120,14 @@ def exit(self): return "payerspot exited" def decode_2nd_swipe_option(self): - cfg_2nd_swipe_action = cfg.setndefault('playermpd', 'second_swipe_action', 'alias', value='none').lower() + cfg_2nd_swipe_action = cfg.setndefault('playerspot', 'second_swipe_action', 'alias', value='none').lower() if cfg_2nd_swipe_action not in [*self.second_swipe_action_dict.keys(), 'none', 'custom']: - logger.error(f"Config mpd.second_swipe_action must be one of " + logger.error(f"Config spot.second_swipe_action must be one of " f"{[*self.second_swipe_action_dict.keys(), 'none', 'custom']}. Ignore setting.") if cfg_2nd_swipe_action in self.second_swipe_action_dict.keys(): self.second_swipe_action = self.second_swipe_action_dict[cfg_2nd_swipe_action] if cfg_2nd_swipe_action == 'custom': - custom_action = utils.decode_rpc_call(cfg.getn('playermpd', 'second_swipe_action', default=None)) + custom_action = utils.decode_rpc_call(cfg.getn('playerspot', 'second_swipe_action', default=None)) self.second_swipe_action = functools.partial(plugs.call_ignore_errors, custom_action['package'], custom_action['plugin'], @@ -131,34 +137,17 @@ def decode_2nd_swipe_option(self): def _spot_status_poll(self): """ - this method polls the status from mpd and stores the important inforamtion in the music_player_status, - it will repeat itself in the intervall specified by self.mpd_status_poll_interval + this method polls the status from spot and stores the important inforamtion in the music_player_status, + it will repeat itself in the intervall specified by self.spot_status_poll_interval """ - self.mpd_status.update(self.mpd_retry_with_mutex(self.mpd_client.status)) - self.mpd_status.update(self.mpd_retry_with_mutex(self.mpd_client.currentsong)) - - if self.mpd_status.get('elapsed') is not None: - self.current_folder_status["ELAPSED"] = self.mpd_status['elapsed'] - self.music_player_status['player_status']["CURRENTSONGPOS"] = self.mpd_status['song'] - self.music_player_status['player_status']["CURRENTFILENAME"] = self.mpd_status['file'] - - if self.mpd_status.get('file') is not None: - self.current_folder_status["CURRENTFILENAME"] = self.mpd_status['file'] - self.current_folder_status["CURRENTSONGPOS"] = self.mpd_status['song'] - self.current_folder_status["ELAPSED"] = self.mpd_status.get('elapsed', '0.0') - self.current_folder_status["PLAYSTATUS"] = self.mpd_status['state'] - self.current_folder_status["RESUME"] = "OFF" - self.current_folder_status["SHUFFLE"] = "OFF" - self.current_folder_status["LOOP"] = "OFF" - self.current_folder_status["SINGLE"] = "OFF" # Delete the volume key to avoid confusion # Volume is published via the 'volume' component! try: - del self.mpd_status['volume'] + del self.spot_status['volume'] except KeyError: pass - publishing.get_publisher().send('playerstatus', self.mpd_status) + publishing.get_publisher().send('playerstatus', self.spot_status) @plugs.tag def load(self, uri: str, start_playing: bool): @@ -244,7 +233,7 @@ def replay_if_stopped(self): .. note:: To me this seems much like the behaviour of play, but we keep it as it is specifically implemented in box 2.X""" logger.debug("replay_if_stopped") - if self.mpd_status['state'] == 'stop': + if self.spot_status['state'] == 'stop': self.play_playlist(self.music_player_status['player_status']['last_played_folder']) @plugs.tag @@ -322,11 +311,11 @@ def play_card(self, folder: str, recursive: bool = False): @plugs.tag def get_playlist_content(self, playlist_uri: str): """ - Get the spotify playlist content as content list with meta-information + Get the spotify playlist as content list with meta-information :param playlist_uri: URI for the spotify playlist as string """ - # ToDo: implement + # ToDo: implement using status track_list = [] return track_list @@ -358,9 +347,8 @@ def play_album(self, album_uri: str): :param album_uri: Album URI from spotify """ logger.debug("play_album") - with self.spot_lock: - logger.info(f"Play album: '{album_uri}'") - self.load(album_uri, start_playing=True) + logger.info(f"Play album: '{album_uri}'") + self.load(album_uri, start_playing=True) @plugs.tag def queue_load(self, folder): @@ -395,10 +383,7 @@ def list_albums(self): @plugs.tag def list_song_by_artist_and_album(self, albumartist, album): - # with self.mpd_lock: - # albums = self.mpd_retry_with_mutex(self.mpd_client.find, 'albumartist', albumartist, 'album', album) - # - # return albums + # ToDo: Do we need this for spotify? pass def get_volume(self): @@ -421,6 +406,11 @@ def set_volume(self, volume): requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) return self.get_volume() + def get_playback_state(self): + api_path = "/web-api/v1/me/player" + playback_state = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)).json() + return playback_state if playback_state["device"]["id"] == self.device_info["device_id"] else {} + @staticmethod def check_uri(uri: str): """ From 5c1b84deb16562808a92da41f44c854997a1f3ef Mon Sep 17 00:00:00 2001 From: Groovylein Date: Tue, 28 Dec 2021 19:53:01 +0100 Subject: [PATCH 016/109] Playlist and volume implemented --- src/jukebox/components/playerspot/__init__.py | 63 +++++++++++-------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index be9d3863f..2d42d4e7f 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -36,15 +36,15 @@ logger = logging.getLogger('jb.PlayerSpot') cfg = jukebox.cfghandler.get_handler('jukebox') -test_dict = {'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', - 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'}, - 'audio_folder_status': - {'TraumfaengerStarkeLieder': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', - 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'stop', 'RESUME': 'OFF', - 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}, - 'Giraffenaffen': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', - 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'play', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', - 'LOOP': 'OFF', 'SINGLE': 'OFF'}}} +# test_dict = {'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', +# 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'}, +# 'audio_folder_status': +# {'TraumfaengerStarkeLieder': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', +# 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'stop', 'RESUME': 'OFF', +# 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}, +# 'Giraffenaffen': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', +# 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'play', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', +# 'LOOP': 'OFF', 'SINGLE': 'OFF'}}} class PlayerSpot: @@ -311,12 +311,25 @@ def play_card(self, folder: str, recursive: bool = False): @plugs.tag def get_playlist_content(self, playlist_uri: str): """ - Get the spotify playlist as content list with meta-information + Get the spotify playlist as content list with spotify id + + Example: + ["artists" : [{ + "id" : "5lpH0xAS4fVfLkACg9DAuM", + "name" : "Wham!" + }], + "id" : "2FRnf9qhLbvw8fu4IBXx78", + "name" : "Last Christmas" + }] + :param playlist_uri: URI for the spotify playlist as string """ - # ToDo: implement using status track_list = [] + api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name,id,artists(name,id))" + playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)).json() + for elem in playlist_response["items"]: + track_list.append(elem["track"]) return track_list @plugs.tag @@ -328,7 +341,6 @@ def play_playlist(self, playlist_uri: str, recursive: bool = False) -> None: :param recursive: Add folder recursively """ logger.debug("play_folder") - # TODO: This changes the current state -> Need to save last state logger.info(f"Play spotify playlist: '{playlist_uri}'") self.music_player_status['player_status']['last_played_folder'] = playlist_uri @@ -368,23 +380,26 @@ def playerstatus(self): @plugs.tag def playlistinfo(self): - # ToDo: implement - value = ["this is a list"] - return value - - @plugs.tag - def list_all_dirs(self): - raise NotImplementedError + """ + Returns a list of all songs in the playlist + """ + track_list = [] + playlist_uri = self.get_playback_state()["context"]["uri"] + api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name))" + playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)).json() + for elem in playlist_response["items"]: + track_list.append(elem["track"]["name"]) + return track_list @plugs.tag def list_albums(self): - albums = ["this is a list"] - return albums + # ToDo: Do we need this for spotify? + raise NotImplementedError @plugs.tag def list_song_by_artist_and_album(self, albumartist, album): # ToDo: Do we need this for spotify? - pass + raise NotImplementedError def get_volume(self): """ @@ -392,9 +407,7 @@ def get_volume(self): For volume control do not use directly, but use through the plugin 'volume', as the user may have configured a volume control manager other than Spotify""" - # ToDo: get volume from Playback state - # https://developer.spotify.com/documentation/web-api/reference/#/operations/get-information-about-the-users-current-playback - pass + return self.get_playback_state()["device"]["volume_percent"] def set_volume(self, volume): """ From c74ead501d51b5ba06124c0b84aa8cdde146eb04 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 29 Dec 2021 13:48:04 +0100 Subject: [PATCH 017/109] Added HTTP Error handling for spotify requests --- src/jukebox/components/playerspot/__init__.py | 135 ++++++++++++++---- 1 file changed, 107 insertions(+), 28 deletions(-) diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index 2d42d4e7f..c830c4236 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -51,20 +51,24 @@ class PlayerSpot: """Interface to librespot-java API""" # ToDo: spot_state - # ToDo: response handling def __init__(self): self.nvm = nv_manager() self.spot_host = cfg.getn('playerspot', 'host') self.spot_api_port = 24879 self.spot_api_baseurl = f"{self.spot_host}:{self.spot_api_port}" - - # Info as dict - # Example: {"device_id":"ABC", - # "device_name":"Phoniebox", - # "device_type":"SPEAKER", - # "country_code":"DE", - # "preferred_locale":"de"} - self.device_info = requests.get(urllib.parse.urljoin(self.spot_api_baseurl, "/instance")).json() + try: + # Info as dict + # Example: {"device_id":"ABC", + # "device_name":"Phoniebox", + # "device_type":"SPEAKER", + # "country_code":"DE", + # "preferred_locale":"de"} + self.device_info = requests.get(urllib.parse.urljoin(self.spot_api_baseurl, "/instance")).json() + self.device_info.raise_for_status() + except requests.HTTPError as http_error: + logger.error("Could not get device information") + logger.error(f"Reason: {http_error}") + self.device_info = {} self.music_player_status = self.nvm.load(cfg.getn('playerspot', 'status_file')) @@ -151,14 +155,25 @@ def _spot_status_poll(self): @plugs.tag def load(self, uri: str, start_playing: bool): + logger.debug(f"loading playlist {uri} and with option playing={start_playing}") self.check_uri(uri) api_path = f"/player/load?uri={uri}&play={start_playing}" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + try: + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response.raise_for_status() + except requests.HTTPError as http_error: + logger.error(f"Could not load playlist {uri}") + logger.error(f"Reason: {http_error}") @plugs.tag def play(self): api_path = "/player/resume" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + try: + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response.raise_for_status() + except requests.HTTPError as http_error: + logger.error("Could not execute play command") + logger.error(f"Reason: {http_error}") @plugs.tag def stop(self): @@ -173,19 +188,34 @@ def pause(self, state: int = 1): """ if state == 1: api_path = "/player/pause" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + try: + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response.raise_for_status() + except requests.HTTPError as http_error: + logger.error("Could not execute pause command") + logger.error(f"Reason: {http_error}") else: self.play() @plugs.tag def prev(self): api_path = "/player/prev" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + try: + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response.raise_for_status() + except requests.HTTPError as http_error: + logger.error("Could not execute prev command") + logger.error(f"Reason: {http_error}") @plugs.tag def next(self): api_path = "/player/next" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + try: + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response.raise_for_status() + except requests.HTTPError as http_error: + logger.error("Could not execute next command") + logger.error(f"Reason: {http_error}") @plugs.tag def seek(self, new_time: int): @@ -193,12 +223,22 @@ def seek(self, new_time: int): Seek to a given position in milliseconds specified by new_time """ api_path = f"/player/seek?position_ms={new_time}" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + try: + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response.raise_for_status() + except requests.HTTPError as http_error: + logger.error("Could not execute seek command") + logger.error(f"Reason: {http_error}") @plugs.tag def shuffle(self, random: bool): api_path = f"/player/shuffle?state={1 if random else 0}" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + try: + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response.raise_for_status() + except requests.HTTPError as http_error: + logger.error("Could not execute shuffle command") + logger.error(f"Reason: {http_error}") @plugs.tag def rewind(self): @@ -223,7 +263,12 @@ def toggle(self): """Toggle pause state, i.e. do a pause / resume depending on current state""" logger.debug("Toggle") api_path = "/player/play-pause" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + try: + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response.raise_for_status() + except requests.HTTPError as http_error: + logger.error("Could not execute toggle command") + logger.error(f"Reason: {http_error}") @plugs.tag def replay_if_stopped(self): @@ -245,7 +290,12 @@ def repeatmode(self, mode: str): else: rep_state = "none" api_path = f"/player/repeat?state={rep_state}" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + try: + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response.raise_for_status() + except requests.HTTPError as http_error: + logger.error("Could not execute repeat command") + logger.error(f"Reason: {http_error}") @plugs.tag def get_current_song(self, param): @@ -267,7 +317,12 @@ def move(self): def play_single(self, song_uri: str): self.check_uri(song_uri) api_path = f"/player/repeat?uri={song_uri}&play=true" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + try: + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response.raise_for_status() + except requests.HTTPError as http_error: + logger.error("Could not execute play single track command") + logger.error(f"Reason: {http_error}") @plugs.tag def resume(self): @@ -327,9 +382,15 @@ def get_playlist_content(self, playlist_uri: str): """ track_list = [] api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name,id,artists(name,id))" - playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)).json() - for elem in playlist_response["items"]: - track_list.append(elem["track"]) + try: + playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + playlist_response.raise_for_status() + playlist_dict = playlist_response.json() + for elem in playlist_dict["items"]: + track_list.append(elem["track"]) + except requests.HTTPError as http_error: + logger.error("Could not get playlist content") + logger.error(f"Reason: {http_error}") return track_list @plugs.tag @@ -386,9 +447,15 @@ def playlistinfo(self): track_list = [] playlist_uri = self.get_playback_state()["context"]["uri"] api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name))" - playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)).json() - for elem in playlist_response["items"]: - track_list.append(elem["track"]["name"]) + try: + playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + playlist_response.raise_for_status() + playlist_dict = playlist_response.json() + for elem in playlist_dict["items"]: + track_list.append(elem["track"]["name"]) + except requests.HTTPError as http_error: + logger.error("Could not get playlist info") + logger.error(f"Reason: {http_error}") return track_list @plugs.tag @@ -416,13 +483,25 @@ def set_volume(self, volume): For volume control do not use directly, but use through the plugin 'volume', as the user may have configured a volume control manager other than Spotify""" api_path = f"/player/volume?volume_percent={volume}" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + try: + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response.raise_for_status() + except requests.HTTPError as http_error: + logger.error("Could not set spotify volume") + logger.error(f"Reason: {http_error}") return self.get_volume() def get_playback_state(self): + playback_state_dict = {} api_path = "/web-api/v1/me/player" - playback_state = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)).json() - return playback_state if playback_state["device"]["id"] == self.device_info["device_id"] else {} + try: + playback_state = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + playback_state.raise_for_status() + playback_state_dict = playback_state.json() + except requests.HTTPError as http_error: + logger.error("Could get the current playback state") + logger.error(f"Reason: {http_error}") + return playback_state_dict if playback_state_dict["device"]["id"] == self.device_info["device_id"] else {} @staticmethod def check_uri(uri: str): From cbd59de5c68a7e812c053be24518a712038751af Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 29 Dec 2021 13:52:48 +0100 Subject: [PATCH 018/109] Catch other exception for spotify api --- src/jukebox/components/playerspot/__init__.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index c830c4236..9a25e994f 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -69,6 +69,9 @@ def __init__(self): logger.error("Could not get device information") logger.error(f"Reason: {http_error}") self.device_info = {} + except Exception as err: + logger.error(f"Other error occurred: {err}") + self.device_info = {} self.music_player_status = self.nvm.load(cfg.getn('playerspot', 'status_file')) @@ -164,6 +167,8 @@ def load(self, uri: str, start_playing: bool): except requests.HTTPError as http_error: logger.error(f"Could not load playlist {uri}") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") @plugs.tag def play(self): @@ -174,6 +179,8 @@ def play(self): except requests.HTTPError as http_error: logger.error("Could not execute play command") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") @plugs.tag def stop(self): @@ -194,6 +201,8 @@ def pause(self, state: int = 1): except requests.HTTPError as http_error: logger.error("Could not execute pause command") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") else: self.play() @@ -206,6 +215,8 @@ def prev(self): except requests.HTTPError as http_error: logger.error("Could not execute prev command") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") @plugs.tag def next(self): @@ -216,6 +227,8 @@ def next(self): except requests.HTTPError as http_error: logger.error("Could not execute next command") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") @plugs.tag def seek(self, new_time: int): @@ -229,6 +242,8 @@ def seek(self, new_time: int): except requests.HTTPError as http_error: logger.error("Could not execute seek command") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") @plugs.tag def shuffle(self, random: bool): @@ -239,6 +254,8 @@ def shuffle(self, random: bool): except requests.HTTPError as http_error: logger.error("Could not execute shuffle command") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") @plugs.tag def rewind(self): @@ -269,6 +286,8 @@ def toggle(self): except requests.HTTPError as http_error: logger.error("Could not execute toggle command") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") @plugs.tag def replay_if_stopped(self): @@ -296,6 +315,8 @@ def repeatmode(self, mode: str): except requests.HTTPError as http_error: logger.error("Could not execute repeat command") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") @plugs.tag def get_current_song(self, param): @@ -323,6 +344,8 @@ def play_single(self, song_uri: str): except requests.HTTPError as http_error: logger.error("Could not execute play single track command") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") @plugs.tag def resume(self): @@ -391,6 +414,8 @@ def get_playlist_content(self, playlist_uri: str): except requests.HTTPError as http_error: logger.error("Could not get playlist content") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") return track_list @plugs.tag @@ -456,6 +481,8 @@ def playlistinfo(self): except requests.HTTPError as http_error: logger.error("Could not get playlist info") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") return track_list @plugs.tag @@ -489,6 +516,8 @@ def set_volume(self, volume): except requests.HTTPError as http_error: logger.error("Could not set spotify volume") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") return self.get_volume() def get_playback_state(self): @@ -501,6 +530,8 @@ def get_playback_state(self): except requests.HTTPError as http_error: logger.error("Could get the current playback state") logger.error(f"Reason: {http_error}") + except Exception as err: + logger.error(f"Other error occurred: {err}") return playback_state_dict if playback_state_dict["device"]["id"] == self.device_info["device_id"] else {} @staticmethod From 81143cb8f78286b07e9036ad303972be15c0f0a1 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 29 Dec 2021 14:19:02 +0100 Subject: [PATCH 019/109] Added headers for requests calls --- src/jukebox/components/playerspot/__init__.py | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index 9a25e994f..1707cb03a 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -36,6 +36,7 @@ logger = logging.getLogger('jb.PlayerSpot') cfg = jukebox.cfghandler.get_handler('jukebox') + # test_dict = {'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', # 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'}, # 'audio_folder_status': @@ -56,6 +57,7 @@ def __init__(self): self.spot_host = cfg.getn('playerspot', 'host') self.spot_api_port = 24879 self.spot_api_baseurl = f"{self.spot_host}:{self.spot_api_port}" + self.requests_json_headers = {'content-type': 'application/json'} try: # Info as dict # Example: {"device_id":"ABC", @@ -63,7 +65,8 @@ def __init__(self): # "device_type":"SPEAKER", # "country_code":"DE", # "preferred_locale":"de"} - self.device_info = requests.get(urllib.parse.urljoin(self.spot_api_baseurl, "/instance")).json() + self.device_info = requests.get(urllib.parse.urljoin(self.spot_api_baseurl, "/instance"), + headers=self.requests_json_headers).json() self.device_info.raise_for_status() except requests.HTTPError as http_error: logger.error("Could not get device information") @@ -123,7 +126,7 @@ def exit(self): logger.debug("Exit routine of playerspot started") self.nvm.save_all() api_path = "/instance/close" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) return "payerspot exited" def decode_2nd_swipe_option(self): @@ -162,7 +165,8 @@ def load(self, uri: str, start_playing: bool): self.check_uri(uri) api_path = f"/player/load?uri={uri}&play={start_playing}" try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) spot_response.raise_for_status() except requests.HTTPError as http_error: logger.error(f"Could not load playlist {uri}") @@ -174,7 +178,8 @@ def load(self, uri: str, start_playing: bool): def play(self): api_path = "/player/resume" try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) spot_response.raise_for_status() except requests.HTTPError as http_error: logger.error("Could not execute play command") @@ -196,7 +201,8 @@ def pause(self, state: int = 1): if state == 1: api_path = "/player/pause" try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) spot_response.raise_for_status() except requests.HTTPError as http_error: logger.error("Could not execute pause command") @@ -210,7 +216,8 @@ def pause(self, state: int = 1): def prev(self): api_path = "/player/prev" try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) spot_response.raise_for_status() except requests.HTTPError as http_error: logger.error("Could not execute prev command") @@ -222,7 +229,8 @@ def prev(self): def next(self): api_path = "/player/next" try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) spot_response.raise_for_status() except requests.HTTPError as http_error: logger.error("Could not execute next command") @@ -237,7 +245,8 @@ def seek(self, new_time: int): """ api_path = f"/player/seek?position_ms={new_time}" try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) spot_response.raise_for_status() except requests.HTTPError as http_error: logger.error("Could not execute seek command") @@ -249,7 +258,8 @@ def seek(self, new_time: int): def shuffle(self, random: bool): api_path = f"/player/shuffle?state={1 if random else 0}" try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) spot_response.raise_for_status() except requests.HTTPError as http_error: logger.error("Could not execute shuffle command") @@ -281,7 +291,8 @@ def toggle(self): logger.debug("Toggle") api_path = "/player/play-pause" try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) spot_response.raise_for_status() except requests.HTTPError as http_error: logger.error("Could not execute toggle command") @@ -310,7 +321,8 @@ def repeatmode(self, mode: str): rep_state = "none" api_path = f"/player/repeat?state={rep_state}" try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) spot_response.raise_for_status() except requests.HTTPError as http_error: logger.error("Could not execute repeat command") @@ -339,7 +351,8 @@ def play_single(self, song_uri: str): self.check_uri(song_uri) api_path = f"/player/repeat?uri={song_uri}&play=true" try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) spot_response.raise_for_status() except requests.HTTPError as http_error: logger.error("Could not execute play single track command") @@ -406,7 +419,8 @@ def get_playlist_content(self, playlist_uri: str): track_list = [] api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name,id,artists(name,id))" try: - playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) playlist_response.raise_for_status() playlist_dict = playlist_response.json() for elem in playlist_dict["items"]: @@ -473,7 +487,8 @@ def playlistinfo(self): playlist_uri = self.get_playback_state()["context"]["uri"] api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name))" try: - playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) playlist_response.raise_for_status() playlist_dict = playlist_response.json() for elem in playlist_dict["items"]: @@ -511,7 +526,8 @@ def set_volume(self, volume): as the user may have configured a volume control manager other than Spotify""" api_path = f"/player/volume?volume_percent={volume}" try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) spot_response.raise_for_status() except requests.HTTPError as http_error: logger.error("Could not set spotify volume") @@ -524,7 +540,8 @@ def get_playback_state(self): playback_state_dict = {} api_path = "/web-api/v1/me/player" try: - playback_state = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + playback_state = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) playback_state.raise_for_status() playback_state_dict = playback_state.json() except requests.HTTPError as http_error: From 93fc3a9e61b96dc25cfedcc16ccd24d64482836f Mon Sep 17 00:00:00 2001 From: Groovylein Date: Tue, 11 Jan 2022 19:39:09 +0100 Subject: [PATCH 020/109] Refactored http error handling --- src/jukebox/components/playerspot/__init__.py | 182 ++++++------------ 1 file changed, 57 insertions(+), 125 deletions(-) diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index 1707cb03a..903af3c10 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -55,6 +55,7 @@ class PlayerSpot: def __init__(self): self.nvm = nv_manager() self.spot_host = cfg.getn('playerspot', 'host') + logger.debug(f"Using spotify host: {self.spot_host}") self.spot_api_port = 24879 self.spot_api_baseurl = f"{self.spot_host}:{self.spot_api_port}" self.requests_json_headers = {'content-type': 'application/json'} @@ -159,33 +160,34 @@ def _spot_status_poll(self): pass publishing.get_publisher().send('playerstatus', self.spot_status) - @plugs.tag - def load(self, uri: str, start_playing: bool): - logger.debug(f"loading playlist {uri} and with option playing={start_playing}") - self.check_uri(uri) - api_path = f"/player/load?uri={uri}&play={start_playing}" + @staticmethod + def _handle_http_errors(requests_response: requests.Response): try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) - spot_response.raise_for_status() + requests_response.raise_for_status() except requests.HTTPError as http_error: - logger.error(f"Could not load playlist {uri}") + logger.error("Could not communicate with spotify API") logger.error(f"Reason: {http_error}") + return False except Exception as err: logger.error(f"Other error occurred: {err}") + return False + return True + + @plugs.tag + def load(self, uri: str, start_playing: bool): + logger.debug(f"loading playlist {uri} and with option playing={start_playing}") + self.check_uri(uri) + api_path = f"/player/load?uri={uri}&play={start_playing}" + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + self._handle_http_errors(spot_response) @plugs.tag def play(self): api_path = "/player/resume" - try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) - spot_response.raise_for_status() - except requests.HTTPError as http_error: - logger.error("Could not execute play command") - logger.error(f"Reason: {http_error}") - except Exception as err: - logger.error(f"Other error occurred: {err}") + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + self._handle_http_errors(spot_response) @plugs.tag def stop(self): @@ -200,43 +202,26 @@ def pause(self, state: int = 1): """ if state == 1: api_path = "/player/pause" - try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) - spot_response.raise_for_status() - except requests.HTTPError as http_error: - logger.error("Could not execute pause command") - logger.error(f"Reason: {http_error}") - except Exception as err: - logger.error(f"Other error occurred: {err}") + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + self._handle_http_errors(spot_response) else: self.play() @plugs.tag def prev(self): api_path = "/player/prev" - try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) - spot_response.raise_for_status() - except requests.HTTPError as http_error: - logger.error("Could not execute prev command") - logger.error(f"Reason: {http_error}") - except Exception as err: - logger.error(f"Other error occurred: {err}") + + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + self._handle_http_errors(spot_response) @plugs.tag def next(self): api_path = "/player/next" - try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) - spot_response.raise_for_status() - except requests.HTTPError as http_error: - logger.error("Could not execute next command") - logger.error(f"Reason: {http_error}") - except Exception as err: - logger.error(f"Other error occurred: {err}") + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + self._handle_http_errors(spot_response) @plugs.tag def seek(self, new_time: int): @@ -244,28 +229,16 @@ def seek(self, new_time: int): Seek to a given position in milliseconds specified by new_time """ api_path = f"/player/seek?position_ms={new_time}" - try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) - spot_response.raise_for_status() - except requests.HTTPError as http_error: - logger.error("Could not execute seek command") - logger.error(f"Reason: {http_error}") - except Exception as err: - logger.error(f"Other error occurred: {err}") + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + self._handle_http_errors(spot_response) @plugs.tag def shuffle(self, random: bool): api_path = f"/player/shuffle?state={1 if random else 0}" - try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) - spot_response.raise_for_status() - except requests.HTTPError as http_error: - logger.error("Could not execute shuffle command") - logger.error(f"Reason: {http_error}") - except Exception as err: - logger.error(f"Other error occurred: {err}") + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + self._handle_http_errors(spot_response) @plugs.tag def rewind(self): @@ -290,15 +263,9 @@ def toggle(self): """Toggle pause state, i.e. do a pause / resume depending on current state""" logger.debug("Toggle") api_path = "/player/play-pause" - try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) - spot_response.raise_for_status() - except requests.HTTPError as http_error: - logger.error("Could not execute toggle command") - logger.error(f"Reason: {http_error}") - except Exception as err: - logger.error(f"Other error occurred: {err}") + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + self._handle_http_errors(spot_response) @plugs.tag def replay_if_stopped(self): @@ -320,15 +287,9 @@ def repeatmode(self, mode: str): else: rep_state = "none" api_path = f"/player/repeat?state={rep_state}" - try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) - spot_response.raise_for_status() - except requests.HTTPError as http_error: - logger.error("Could not execute repeat command") - logger.error(f"Reason: {http_error}") - except Exception as err: - logger.error(f"Other error occurred: {err}") + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + self._handle_http_errors(spot_response) @plugs.tag def get_current_song(self, param): @@ -350,15 +311,9 @@ def move(self): def play_single(self, song_uri: str): self.check_uri(song_uri) api_path = f"/player/repeat?uri={song_uri}&play=true" - try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) - spot_response.raise_for_status() - except requests.HTTPError as http_error: - logger.error("Could not execute play single track command") - logger.error(f"Reason: {http_error}") - except Exception as err: - logger.error(f"Other error occurred: {err}") + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + self._handle_http_errors(spot_response) @plugs.tag def resume(self): @@ -418,18 +373,12 @@ def get_playlist_content(self, playlist_uri: str): """ track_list = [] api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name,id,artists(name,id))" - try: - playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) - playlist_response.raise_for_status() + playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + if self._handle_http_errors(playlist_response): playlist_dict = playlist_response.json() for elem in playlist_dict["items"]: track_list.append(elem["track"]) - except requests.HTTPError as http_error: - logger.error("Could not get playlist content") - logger.error(f"Reason: {http_error}") - except Exception as err: - logger.error(f"Other error occurred: {err}") return track_list @plugs.tag @@ -486,18 +435,13 @@ def playlistinfo(self): track_list = [] playlist_uri = self.get_playback_state()["context"]["uri"] api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name))" - try: - playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + if self._handle_http_errors(playlist_response): playlist_response.raise_for_status() playlist_dict = playlist_response.json() for elem in playlist_dict["items"]: track_list.append(elem["track"]["name"]) - except requests.HTTPError as http_error: - logger.error("Could not get playlist info") - logger.error(f"Reason: {http_error}") - except Exception as err: - logger.error(f"Other error occurred: {err}") return track_list @plugs.tag @@ -525,30 +469,18 @@ def set_volume(self, volume): For volume control do not use directly, but use through the plugin 'volume', as the user may have configured a volume control manager other than Spotify""" api_path = f"/player/volume?volume_percent={volume}" - try: - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) - spot_response.raise_for_status() - except requests.HTTPError as http_error: - logger.error("Could not set spotify volume") - logger.error(f"Reason: {http_error}") - except Exception as err: - logger.error(f"Other error occurred: {err}") + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + self._handle_http_errors(spot_response) return self.get_volume() def get_playback_state(self): playback_state_dict = {} api_path = "/web-api/v1/me/player" - try: - playback_state = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) - playback_state.raise_for_status() + playback_state = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + headers=self.requests_json_headers) + if self._handle_http_errors(playback_state): playback_state_dict = playback_state.json() - except requests.HTTPError as http_error: - logger.error("Could get the current playback state") - logger.error(f"Reason: {http_error}") - except Exception as err: - logger.error(f"Other error occurred: {err}") return playback_state_dict if playback_state_dict["device"]["id"] == self.device_info["device_id"] else {} @staticmethod From 98da1229aa253d79e7b06d0693d297384518b3c2 Mon Sep 17 00:00:00 2001 From: Kiriakos Antoniadis Date: Tue, 11 Jan 2022 21:02:07 +0100 Subject: [PATCH 021/109] Delete json_example.json --- .../components/playerspot/json_example.json | 507 ------------------ 1 file changed, 507 deletions(-) delete mode 100644 src/jukebox/components/playerspot/json_example.json diff --git a/src/jukebox/components/playerspot/json_example.json b/src/jukebox/components/playerspot/json_example.json deleted file mode 100644 index 88076791e..000000000 --- a/src/jukebox/components/playerspot/json_example.json +++ /dev/null @@ -1,507 +0,0 @@ -{ - "device":{ - "id":"45eb684ffece0712ebdc11b502bbb242604d3d13", - "is_active":true, - "is_private_session":false, - "is_restricted":false, - "name":"Pixel 3a", - "type":"Smartphone", - "volume_percent":100 - }, - "shuffle_state":false, - "repeat_state":"off", - "timestamp":1640345447023, - "context":{ - "external_urls":{ - "spotify":"https://open.spotify.com/playlist/37i9dQZF1EJy9NVWJjerLg" - }, - "href":"https://api.spotify.com/v1/playlists/37i9dQZF1EJy9NVWJjerLg", - "type":"playlist", - "uri":"spotify:playlist:37i9dQZF1EJy9NVWJjerLg" - }, - "progress_ms":104784, - "item":{ - "album":{ - "album_type":"single", - "artists":[ - { - "external_urls":{ - "spotify":"https://open.spotify.com/artist/1fd3fmwlhrDl2U5wbbPQYN" - }, - "href":"https://api.spotify.com/v1/artists/1fd3fmwlhrDl2U5wbbPQYN", - "id":"1fd3fmwlhrDl2U5wbbPQYN", - "name":"Apashe", - "type":"artist", - "uri":"spotify:artist:1fd3fmwlhrDl2U5wbbPQYN" - }, - { - "external_urls":{ - "spotify":"https://open.spotify.com/artist/4Nl6PVYLwbCFfr3UqQlFtE" - }, - "href":"https://api.spotify.com/v1/artists/4Nl6PVYLwbCFfr3UqQlFtE", - "id":"4Nl6PVYLwbCFfr3UqQlFtE", - "name":"VOLAC", - "type":"artist", - "uri":"spotify:artist:4Nl6PVYLwbCFfr3UqQlFtE" - } - ], - "available_markets":[ - "AD", - "AE", - "AG", - "AL", - "AM", - "AO", - "AR", - "AT", - "AU", - "AZ", - "BA", - "BB", - "BD", - "BE", - "BF", - "BG", - "BH", - "BI", - "BJ", - "BN", - "BO", - "BR", - "BS", - "BT", - "BW", - "BY", - "BZ", - "CA", - "CD", - "CG", - "CH", - "CI", - "CL", - "CM", - "CO", - "CR", - "CV", - "CW", - "CY", - "CZ", - "DE", - "DJ", - "DK", - "DM", - "DO", - "DZ", - "EC", - "EE", - "EG", - "ES", - "FI", - "FJ", - "FM", - "FR", - "GA", - "GB", - "GD", - "GE", - "GH", - "GM", - "GN", - "GQ", - "GR", - "GT", - "GW", - "GY", - "HK", - "HN", - "HR", - "HT", - "HU", - "ID", - "IE", - "IL", - "IN", - "IQ", - "IS", - "IT", - "JM", - "JO", - "JP", - "KE", - "KG", - "KH", - "KI", - "KM", - "KN", - "KR", - "KW", - "KZ", - "LA", - "LB", - "LC", - "LI", - "LK", - "LR", - "LS", - "LT", - "LU", - "LV", - "LY", - "MA", - "MC", - "MD", - "ME", - "MG", - "MH", - "MK", - "ML", - "MN", - "MO", - "MR", - "MT", - "MU", - "MV", - "MW", - "MX", - "MY", - "MZ", - "NA", - "NE", - "NG", - "NI", - "NL", - "NO", - "NP", - "NR", - "NZ", - "OM", - "PA", - "PE", - "PG", - "PH", - "PK", - "PL", - "PS", - "PT", - "PW", - "PY", - "QA", - "RO", - "RS", - "RU", - "RW", - "SA", - "SB", - "SC", - "SE", - "SG", - "SI", - "SK", - "SL", - "SM", - "SN", - "SR", - "ST", - "SV", - "SZ", - "TD", - "TG", - "TH", - "TJ", - "TL", - "TN", - "TO", - "TR", - "TT", - "TV", - "TW", - "TZ", - "UA", - "UG", - "US", - "UY", - "UZ", - "VC", - "VE", - "VN", - "VU", - "WS", - "XK", - "ZA", - "ZM", - "ZW" - ], - "external_urls":{ - "spotify":"https://open.spotify.com/album/4DV2LgtYZbumkW6ifBZkzh" - }, - "href":"https://api.spotify.com/v1/albums/4DV2LgtYZbumkW6ifBZkzh", - "id":"4DV2LgtYZbumkW6ifBZkzh", - "images":[ - { - "height":640, - "url":"https://i.scdn.co/image/ab67616d0000b273967a9c4c384f097d3091777b", - "width":640 - }, - { - "height":300, - "url":"https://i.scdn.co/image/ab67616d00001e02967a9c4c384f097d3091777b", - "width":300 - }, - { - "height":64, - "url":"https://i.scdn.co/image/ab67616d00004851967a9c4c384f097d3091777b", - "width":64 - } - ], - "name":"Distance (Volac Remix)", - "release_date":"2021-01-08", - "release_date_precision":"day", - "total_tracks":1, - "type":"album", - "uri":"spotify:album:4DV2LgtYZbumkW6ifBZkzh" - }, - "artists":[ - { - "external_urls":{ - "spotify":"https://open.spotify.com/artist/1fd3fmwlhrDl2U5wbbPQYN" - }, - "href":"https://api.spotify.com/v1/artists/1fd3fmwlhrDl2U5wbbPQYN", - "id":"1fd3fmwlhrDl2U5wbbPQYN", - "name":"Apashe", - "type":"artist", - "uri":"spotify:artist:1fd3fmwlhrDl2U5wbbPQYN" - }, - { - "external_urls":{ - "spotify":"https://open.spotify.com/artist/4Nl6PVYLwbCFfr3UqQlFtE" - }, - "href":"https://api.spotify.com/v1/artists/4Nl6PVYLwbCFfr3UqQlFtE", - "id":"4Nl6PVYLwbCFfr3UqQlFtE", - "name":"VOLAC", - "type":"artist", - "uri":"spotify:artist:4Nl6PVYLwbCFfr3UqQlFtE" - }, - { - "external_urls":{ - "spotify":"https://open.spotify.com/artist/0VzoflxRgSVEWHYmCbMOJJ" - }, - "href":"https://api.spotify.com/v1/artists/0VzoflxRgSVEWHYmCbMOJJ", - "id":"0VzoflxRgSVEWHYmCbMOJJ", - "name":"Geoffroy", - "type":"artist", - "uri":"spotify:artist:0VzoflxRgSVEWHYmCbMOJJ" - } - ], - "available_markets":[ - "AD", - "AE", - "AG", - "AL", - "AM", - "AO", - "AR", - "AT", - "AU", - "AZ", - "BA", - "BB", - "BD", - "BE", - "BF", - "BG", - "BH", - "BI", - "BJ", - "BN", - "BO", - "BR", - "BS", - "BT", - "BW", - "BY", - "BZ", - "CA", - "CD", - "CG", - "CH", - "CI", - "CL", - "CM", - "CO", - "CR", - "CV", - "CW", - "CY", - "CZ", - "DE", - "DJ", - "DK", - "DM", - "DO", - "DZ", - "EC", - "EE", - "EG", - "ES", - "FI", - "FJ", - "FM", - "FR", - "GA", - "GB", - "GD", - "GE", - "GH", - "GM", - "GN", - "GQ", - "GR", - "GT", - "GW", - "GY", - "HK", - "HN", - "HR", - "HT", - "HU", - "ID", - "IE", - "IL", - "IN", - "IQ", - "IS", - "IT", - "JM", - "JO", - "JP", - "KE", - "KG", - "KH", - "KI", - "KM", - "KN", - "KR", - "KW", - "KZ", - "LA", - "LB", - "LC", - "LI", - "LK", - "LR", - "LS", - "LT", - "LU", - "LV", - "LY", - "MA", - "MC", - "MD", - "ME", - "MG", - "MH", - "MK", - "ML", - "MN", - "MO", - "MR", - "MT", - "MU", - "MV", - "MW", - "MX", - "MY", - "MZ", - "NA", - "NE", - "NG", - "NI", - "NL", - "NO", - "NP", - "NR", - "NZ", - "OM", - "PA", - "PE", - "PG", - "PH", - "PK", - "PL", - "PS", - "PT", - "PW", - "PY", - "QA", - "RO", - "RS", - "RU", - "RW", - "SA", - "SB", - "SC", - "SE", - "SG", - "SI", - "SK", - "SL", - "SM", - "SN", - "SR", - "ST", - "SV", - "SZ", - "TD", - "TG", - "TH", - "TJ", - "TL", - "TN", - "TO", - "TR", - "TT", - "TV", - "TW", - "TZ", - "UA", - "UG", - "US", - "UY", - "UZ", - "VC", - "VE", - "VN", - "VU", - "WS", - "XK", - "ZA", - "ZM", - "ZW" - ], - "disc_number":1, - "duration_ms":215390, - "explicit":false, - "external_ids":{ - "isrc":"CA5KR2124377" - }, - "external_urls":{ - "spotify":"https://open.spotify.com/track/42BdPV3FB2HVsvdokaIDJt" - }, - "href":"https://api.spotify.com/v1/tracks/42BdPV3FB2HVsvdokaIDJt", - "id":"42BdPV3FB2HVsvdokaIDJt", - "is_local":false, - "name":"Distance - Volac Remix", - "popularity":57, - "preview_url":"https://p.scdn.co/mp3-preview/1fcbfaddb5370c0d8420229e451df0672ae9b5f4?cid=65b708073fc0480ea92a077233ca87bd", - "track_number":1, - "type":"track", - "uri":"spotify:track:42BdPV3FB2HVsvdokaIDJt" - }, - "currently_playing_type":"track", - "actions":{ - "disallows":{ - "resuming":true - } - }, - "is_playing":true -} From 1c505b5a6c16188e084672bbc0350dc774d926a5 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 12 Jan 2022 08:52:00 +0100 Subject: [PATCH 022/109] Spotify websocket initialization --- requirements.txt | 3 + src/jukebox/components/playerspot/__init__.py | 79 ++++++++----------- 2 files changed, 37 insertions(+), 45 deletions(-) diff --git a/requirements.txt b/requirements.txt index a37fe1c03..9d78a880f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,9 @@ requests eyed3 # For the publisher event reactor loop: tornado +# For spotify +websockets +asyncio # PyZMQ is a special case: # On the PI, it needs to be compiled with special options to enable Websocket support diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index 903af3c10..3b8c1c2ce 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -21,7 +21,10 @@ import logging import functools import urllib.parse + +import asyncio import requests +import websockets import components.player import jukebox.cfghandler @@ -58,6 +61,7 @@ def __init__(self): logger.debug(f"Using spotify host: {self.spot_host}") self.spot_api_port = 24879 self.spot_api_baseurl = f"{self.spot_host}:{self.spot_api_port}" + self.spot_websocket_uri = f"{self.spot_host}:{self.spot_api_port}/events" self.requests_json_headers = {'content-type': 'application/json'} try: # Info as dict @@ -77,51 +81,36 @@ def __init__(self): logger.error(f"Other error occurred: {err}") self.device_info = {} - self.music_player_status = self.nvm.load(cfg.getn('playerspot', 'status_file')) - - self.second_swipe_action_dict = {'toggle': self.toggle, - 'play': self.play, - 'skip': self.next, - 'rewind': self.rewind, - 'replay': self.replay, - 'replay_if_stopped': self.replay_if_stopped} - self.second_swipe_action = None - self.decode_2nd_swipe_option() - - self.current_folder_status = {} - if not self.music_player_status: - self.music_player_status['player_status'] = {} - self.music_player_status['audio_folder_status'] = {} - self.music_player_status.save_to_json() - self.current_folder_status = {} - self.music_player_status['player_status']['last_played_folder'] = '' - else: - last_played_folder = self.music_player_status['player_status'].get('last_played_folder') - if last_played_folder: - # current_folder_status is a dict, but last_played_folder a str - self.current_folder_status = self.music_player_status['audio_folder_status'][last_played_folder] - logger.info(f"Last Played Folder: {last_played_folder}") - - # Clear last folder played, as we actually did not play any folder yet - # Needed for second swipe detection - # TODO: This will loose the last_played_folder information is the box is started and closed with - # playing anything... - # Change this to last_played_folder and shutdown_state (for restoring) - self.music_player_status['player_status']['last_played_folder'] = '' - - self.old_song = None - self.spot_status = {} - self.spot_status_poll_interval = 0.25 - # ToDo: check of spot_lock works - self.status_is_closing = False - - self.status_thread = multitimer.GenericEndlessTimerClass('spot.timer_status', - self.spot_status_poll_interval, self._spot_status_poll) - self.status_thread.start() - - self.old_song = None - self.spot_status_poll_interval = 0.25 - self.status_is_closing = False + # Establish WebSocket connection: + loop = asyncio.get_event_loop() + self.spot_websocket_connection = loop.run_until_complete(self._connect_websocket()) + loop.run_until_complete( + asyncio.wait(asyncio.ensure_future(self.receive_message_from_websocket(self.spot_websocket_connection)))) + + async def _connect_websocket(self): + """ + Connecting to spotify webSocket server + + websockets.client.connect returns a WebSocketClientProtocol, which is used to receive messages + """ + ws_connection = await websockets.client.connect(self.spot_websocket_uri) + if ws_connection.open: + logger.debug(f"Connection to websocket sever established." + f"Client correcly connected to {self.spot_websocket_uri}") + return ws_connection + + async def receive_message_from_websocket(self, ws_connection): + """ + Receiving all server messages and handling them + """ + while True: + try: + message = await ws_connection.recv() + logger.debug(f"Received message from server: {message}") + # ToDo: handle messages + except websockets.exceptions.ConnectionClosed: + logger.debug("Connection with websocket server closed") + break def exit(self): logger.debug("Exit routine of playerspot started") From 1b4b54076792c632c6092afb01f1719719e0a985 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 14 Jan 2022 19:53:10 +0100 Subject: [PATCH 023/109] Bugfixing: import, paths, asyncio --- requirements.txt | 1 + .../default-settings/jukebox.default.yaml | 1 + src/jukebox/components/playerspot/__init__.py | 73 ++++++++++--------- 3 files changed, 40 insertions(+), 35 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9d78a880f..6a4dd9af5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,6 +18,7 @@ tornado # For spotify websockets asyncio +nest_asyncio # PyZMQ is a special case: # On the PI, it needs to be compiled with special options to enable Websocket support diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index cea53ed68..ee46961a6 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -13,6 +13,7 @@ modules: jingle.alsawave: jingle.alsawave jingle.jinglemp3: jingle.jinglemp3 player: playermpd + spotify: playerspot cards: rfid.cards rfid: rfid.reader timers: timers diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index 3b8c1c2ce..fc99fe9d6 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -20,25 +20,26 @@ """ import logging import functools -import urllib.parse - +import os import asyncio import requests import websockets - import components.player import jukebox.cfghandler import jukebox.utils as utils import jukebox.plugs as plugs -import jukebox.multitimer as multitimer import jukebox.publishing as publishing import misc +import nest_asyncio from jukebox.NvManager import nv_manager logger = logging.getLogger('jb.PlayerSpot') cfg = jukebox.cfghandler.get_handler('jukebox') +# Patch asyncio to make its event loop reentrant. +nest_asyncio.apply() + # test_dict = {'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', # 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'}, @@ -61,7 +62,9 @@ def __init__(self): logger.debug(f"Using spotify host: {self.spot_host}") self.spot_api_port = 24879 self.spot_api_baseurl = f"{self.spot_host}:{self.spot_api_port}" + logger.debug(f"Using spotify api base url: {self.spot_api_baseurl}") self.spot_websocket_uri = f"{self.spot_host}:{self.spot_api_port}/events" + logger.debug(f"Using spotify websocket uri: {self.spot_websocket_uri}") self.requests_json_headers = {'content-type': 'application/json'} try: # Info as dict @@ -70,7 +73,7 @@ def __init__(self): # "device_type":"SPEAKER", # "country_code":"DE", # "preferred_locale":"de"} - self.device_info = requests.get(urllib.parse.urljoin(self.spot_api_baseurl, "/instance"), + self.device_info = requests.get(os.path.join(self.spot_api_baseurl, "instance"), headers=self.requests_json_headers).json() self.device_info.raise_for_status() except requests.HTTPError as http_error: @@ -115,8 +118,8 @@ async def receive_message_from_websocket(self, ws_connection): def exit(self): logger.debug("Exit routine of playerspot started") self.nvm.save_all() - api_path = "/instance/close" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) + api_path = "instance/close" + requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) return "payerspot exited" def decode_2nd_swipe_option(self): @@ -166,15 +169,15 @@ def _handle_http_errors(requests_response: requests.Response): def load(self, uri: str, start_playing: bool): logger.debug(f"loading playlist {uri} and with option playing={start_playing}") self.check_uri(uri) - api_path = f"/player/load?uri={uri}&play={start_playing}" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + api_path = f"player/load?uri={uri}&play={start_playing}" + spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @plugs.tag def play(self): - api_path = "/player/resume" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + api_path = "player/resume" + spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @@ -190,8 +193,8 @@ def pause(self, state: int = 1): on the reader again. What happens on re-placement depends on configured second swipe option """ if state == 1: - api_path = "/player/pause" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + api_path = "player/pause" + spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) else: @@ -199,16 +202,16 @@ def pause(self, state: int = 1): @plugs.tag def prev(self): - api_path = "/player/prev" + api_path = "player/prev" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @plugs.tag def next(self): - api_path = "/player/next" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + api_path = "player/next" + spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @@ -217,15 +220,15 @@ def seek(self, new_time: int): """ Seek to a given position in milliseconds specified by new_time """ - api_path = f"/player/seek?position_ms={new_time}" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + api_path = f"player/seek?position_ms={new_time}" + spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @plugs.tag def shuffle(self, random: bool): - api_path = f"/player/shuffle?state={1 if random else 0}" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + api_path = f"player/shuffle?state={1 if random else 0}" + spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @@ -251,8 +254,8 @@ def replay(self): def toggle(self): """Toggle pause state, i.e. do a pause / resume depending on current state""" logger.debug("Toggle") - api_path = "/player/play-pause" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + api_path = "player/play-pause" + spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @@ -275,8 +278,8 @@ def repeatmode(self, mode: str): rep_state = "track" else: rep_state = "none" - api_path = f"/player/repeat?state={rep_state}" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + api_path = f"player/repeat?state={rep_state}" + spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @@ -299,8 +302,8 @@ def move(self): @plugs.tag def play_single(self, song_uri: str): self.check_uri(song_uri) - api_path = f"/player/repeat?uri={song_uri}&play=true" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + api_path = f"player/repeat?uri={song_uri}&play=true" + spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @@ -361,8 +364,8 @@ def get_playlist_content(self, playlist_uri: str): :param playlist_uri: URI for the spotify playlist as string """ track_list = [] - api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name,id,artists(name,id))" - playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + api_path = f"web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name,id,artists(name,id))" + playlist_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) if self._handle_http_errors(playlist_response): playlist_dict = playlist_response.json() @@ -423,8 +426,8 @@ def playlistinfo(self): """ track_list = [] playlist_uri = self.get_playback_state()["context"]["uri"] - api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name))" - playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + api_path = f"web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name))" + playlist_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) if self._handle_http_errors(playlist_response): playlist_response.raise_for_status() @@ -457,16 +460,16 @@ def set_volume(self, volume): For volume control do not use directly, but use through the plugin 'volume', as the user may have configured a volume control manager other than Spotify""" - api_path = f"/player/volume?volume_percent={volume}" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + api_path = f"player/volume?volume_percent={volume}" + spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) return self.get_volume() def get_playback_state(self): playback_state_dict = {} - api_path = "/web-api/v1/me/player" - playback_state = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), + api_path = "web-api/v1/me/player" + playback_state = requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) if self._handle_http_errors(playback_state): playback_state_dict = playback_state.json() From 998ed069d8ce4f1ef7e450682126d94dc6bc3236 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 14 Jan 2022 21:04:50 +0100 Subject: [PATCH 024/109] Bugfixing: URL paths --- src/jukebox/components/playerspot/__init__.py | 71 ++++++++++--------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index fc99fe9d6..6f0cf21b4 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -20,8 +20,9 @@ """ import logging import functools -import os import asyncio +import urllib.parse + import requests import websockets import components.player @@ -61,9 +62,9 @@ def __init__(self): self.spot_host = cfg.getn('playerspot', 'host') logger.debug(f"Using spotify host: {self.spot_host}") self.spot_api_port = 24879 - self.spot_api_baseurl = f"{self.spot_host}:{self.spot_api_port}" + self.spot_api_baseurl = f"http://{self.spot_host}:{self.spot_api_port}" logger.debug(f"Using spotify api base url: {self.spot_api_baseurl}") - self.spot_websocket_uri = f"{self.spot_host}:{self.spot_api_port}/events" + self.spot_websocket_uri = urllib.parse.urljoin(f"ws://{self.spot_host}:{self.spot_api_port}", "/events") logger.debug(f"Using spotify websocket uri: {self.spot_websocket_uri}") self.requests_json_headers = {'content-type': 'application/json'} try: @@ -73,7 +74,7 @@ def __init__(self): # "device_type":"SPEAKER", # "country_code":"DE", # "preferred_locale":"de"} - self.device_info = requests.get(os.path.join(self.spot_api_baseurl, "instance"), + self.device_info = requests.get(urllib.parse.urljoin(self.spot_api_baseurl, "/instance"), headers=self.requests_json_headers).json() self.device_info.raise_for_status() except requests.HTTPError as http_error: @@ -96,7 +97,7 @@ async def _connect_websocket(self): websockets.client.connect returns a WebSocketClientProtocol, which is used to receive messages """ - ws_connection = await websockets.client.connect(self.spot_websocket_uri) + ws_connection = await websockets.connect(self.spot_websocket_uri) if ws_connection.open: logger.debug(f"Connection to websocket sever established." f"Client correcly connected to {self.spot_websocket_uri}") @@ -118,8 +119,8 @@ async def receive_message_from_websocket(self, ws_connection): def exit(self): logger.debug("Exit routine of playerspot started") self.nvm.save_all() - api_path = "instance/close" - requests.post(os.path.join(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) + api_path = "/instance/close" + requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) return "payerspot exited" def decode_2nd_swipe_option(self): @@ -169,15 +170,15 @@ def _handle_http_errors(requests_response: requests.Response): def load(self, uri: str, start_playing: bool): logger.debug(f"loading playlist {uri} and with option playing={start_playing}") self.check_uri(uri) - api_path = f"player/load?uri={uri}&play={start_playing}" - spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), + api_path = f"/player/load?uri={uri}&play={start_playing}" + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @plugs.tag def play(self): - api_path = "player/resume" - spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), + api_path = "/player/resume" + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @@ -193,8 +194,8 @@ def pause(self, state: int = 1): on the reader again. What happens on re-placement depends on configured second swipe option """ if state == 1: - api_path = "player/pause" - spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), + api_path = "/player/pause" + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) else: @@ -202,16 +203,16 @@ def pause(self, state: int = 1): @plugs.tag def prev(self): - api_path = "player/prev" + api_path = "/player/prev" - spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @plugs.tag def next(self): - api_path = "player/next" - spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), + api_path = "/player/next" + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @@ -220,15 +221,15 @@ def seek(self, new_time: int): """ Seek to a given position in milliseconds specified by new_time """ - api_path = f"player/seek?position_ms={new_time}" - spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), + api_path = f"/player/seek?position_ms={new_time}" + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @plugs.tag def shuffle(self, random: bool): - api_path = f"player/shuffle?state={1 if random else 0}" - spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), + api_path = f"/player/shuffle?state={1 if random else 0}" + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @@ -254,8 +255,8 @@ def replay(self): def toggle(self): """Toggle pause state, i.e. do a pause / resume depending on current state""" logger.debug("Toggle") - api_path = "player/play-pause" - spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), + api_path = "/player/play-pause" + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @@ -278,8 +279,8 @@ def repeatmode(self, mode: str): rep_state = "track" else: rep_state = "none" - api_path = f"player/repeat?state={rep_state}" - spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), + api_path = f"/player/repeat?state={rep_state}" + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @@ -302,8 +303,8 @@ def move(self): @plugs.tag def play_single(self, song_uri: str): self.check_uri(song_uri) - api_path = f"player/repeat?uri={song_uri}&play=true" - spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), + api_path = f"/player/repeat?uri={song_uri}&play=true" + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) @@ -364,8 +365,8 @@ def get_playlist_content(self, playlist_uri: str): :param playlist_uri: URI for the spotify playlist as string """ track_list = [] - api_path = f"web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name,id,artists(name,id))" - playlist_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), + api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name,id,artists(name,id))" + playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) if self._handle_http_errors(playlist_response): playlist_dict = playlist_response.json() @@ -426,8 +427,8 @@ def playlistinfo(self): """ track_list = [] playlist_uri = self.get_playback_state()["context"]["uri"] - api_path = f"web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name))" - playlist_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), + api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name))" + playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) if self._handle_http_errors(playlist_response): playlist_response.raise_for_status() @@ -460,16 +461,16 @@ def set_volume(self, volume): For volume control do not use directly, but use through the plugin 'volume', as the user may have configured a volume control manager other than Spotify""" - api_path = f"player/volume?volume_percent={volume}" - spot_response = requests.post(os.path.join(self.spot_api_baseurl, api_path), + api_path = f"/player/volume?volume_percent={volume}" + spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) self._handle_http_errors(spot_response) return self.get_volume() def get_playback_state(self): playback_state_dict = {} - api_path = "web-api/v1/me/player" - playback_state = requests.post(os.path.join(self.spot_api_baseurl, api_path), + api_path = "/web-api/v1/me/player" + playback_state = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) if self._handle_http_errors(playback_state): playback_state_dict = playback_state.json() From ade20234c92d5916c5bc6d6e4427ae97efd34d07 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 14 Jan 2022 21:53:37 +0100 Subject: [PATCH 025/109] Bugfixing: wait until connection to spotify api possible --- requirements.txt | 1 + src/jukebox/components/playerspot/__init__.py | 63 +++++++++---------- 2 files changed, 31 insertions(+), 33 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6a4dd9af5..d01d00f5a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ tornado websockets asyncio nest_asyncio +urllib3 # PyZMQ is a special case: # On the PI, it needs to be compiled with special options to enable Websocket support diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index 6f0cf21b4..d90c04024 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -25,6 +25,9 @@ import requests import websockets +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + import components.player import jukebox.cfghandler import jukebox.utils as utils @@ -41,6 +44,16 @@ # Patch asyncio to make its event loop reentrant. nest_asyncio.apply() +# We need to wait until the spotify API is up and running. +# Therefore, we need to implement a retry option to wait for the connection. +requests_session = requests.Session() + +retries = Retry(total=5, + backoff_factor=5, + status_forcelist=[500, 502, 503, 504]) + +requests_session.mount('http://', HTTPAdapter(max_retries=retries)) +requests_session.headers.update({'content-type': 'application/json'}) # test_dict = {'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', # 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'}, @@ -66,7 +79,6 @@ def __init__(self): logger.debug(f"Using spotify api base url: {self.spot_api_baseurl}") self.spot_websocket_uri = urllib.parse.urljoin(f"ws://{self.spot_host}:{self.spot_api_port}", "/events") logger.debug(f"Using spotify websocket uri: {self.spot_websocket_uri}") - self.requests_json_headers = {'content-type': 'application/json'} try: # Info as dict # Example: {"device_id":"ABC", @@ -74,8 +86,7 @@ def __init__(self): # "device_type":"SPEAKER", # "country_code":"DE", # "preferred_locale":"de"} - self.device_info = requests.get(urllib.parse.urljoin(self.spot_api_baseurl, "/instance"), - headers=self.requests_json_headers).json() + self.device_info = requests_session.get(urllib.parse.urljoin(self.spot_api_baseurl, "/instance")).json() self.device_info.raise_for_status() except requests.HTTPError as http_error: logger.error("Could not get device information") @@ -112,7 +123,7 @@ async def receive_message_from_websocket(self, ws_connection): message = await ws_connection.recv() logger.debug(f"Received message from server: {message}") # ToDo: handle messages - except websockets.exceptions.ConnectionClosed: + except websockets.ConnectionClosed: logger.debug("Connection with websocket server closed") break @@ -120,7 +131,7 @@ def exit(self): logger.debug("Exit routine of playerspot started") self.nvm.save_all() api_path = "/instance/close" - requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), headers=self.requests_json_headers) + requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) return "payerspot exited" def decode_2nd_swipe_option(self): @@ -171,15 +182,13 @@ def load(self, uri: str, start_playing: bool): logger.debug(f"loading playlist {uri} and with option playing={start_playing}") self.check_uri(uri) api_path = f"/player/load?uri={uri}&play={start_playing}" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) self._handle_http_errors(spot_response) @plugs.tag def play(self): api_path = "/player/resume" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) self._handle_http_errors(spot_response) @plugs.tag @@ -195,8 +204,7 @@ def pause(self, state: int = 1): """ if state == 1: api_path = "/player/pause" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) self._handle_http_errors(spot_response) else: self.play() @@ -205,15 +213,13 @@ def pause(self, state: int = 1): def prev(self): api_path = "/player/prev" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) self._handle_http_errors(spot_response) @plugs.tag def next(self): api_path = "/player/next" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) self._handle_http_errors(spot_response) @plugs.tag @@ -222,15 +228,13 @@ def seek(self, new_time: int): Seek to a given position in milliseconds specified by new_time """ api_path = f"/player/seek?position_ms={new_time}" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) self._handle_http_errors(spot_response) @plugs.tag def shuffle(self, random: bool): api_path = f"/player/shuffle?state={1 if random else 0}" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) self._handle_http_errors(spot_response) @plugs.tag @@ -256,8 +260,7 @@ def toggle(self): """Toggle pause state, i.e. do a pause / resume depending on current state""" logger.debug("Toggle") api_path = "/player/play-pause" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) self._handle_http_errors(spot_response) @plugs.tag @@ -280,8 +283,7 @@ def repeatmode(self, mode: str): else: rep_state = "none" api_path = f"/player/repeat?state={rep_state}" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) self._handle_http_errors(spot_response) @plugs.tag @@ -304,8 +306,7 @@ def move(self): def play_single(self, song_uri: str): self.check_uri(song_uri) api_path = f"/player/repeat?uri={song_uri}&play=true" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) self._handle_http_errors(spot_response) @plugs.tag @@ -366,8 +367,7 @@ def get_playlist_content(self, playlist_uri: str): """ track_list = [] api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name,id,artists(name,id))" - playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + playlist_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) if self._handle_http_errors(playlist_response): playlist_dict = playlist_response.json() for elem in playlist_dict["items"]: @@ -428,8 +428,7 @@ def playlistinfo(self): track_list = [] playlist_uri = self.get_playback_state()["context"]["uri"] api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name))" - playlist_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + playlist_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) if self._handle_http_errors(playlist_response): playlist_response.raise_for_status() playlist_dict = playlist_response.json() @@ -462,16 +461,14 @@ def set_volume(self, volume): For volume control do not use directly, but use through the plugin 'volume', as the user may have configured a volume control manager other than Spotify""" api_path = f"/player/volume?volume_percent={volume}" - spot_response = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) self._handle_http_errors(spot_response) return self.get_volume() def get_playback_state(self): playback_state_dict = {} api_path = "/web-api/v1/me/player" - playback_state = requests.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path), - headers=self.requests_json_headers) + playback_state = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) if self._handle_http_errors(playback_state): playback_state_dict = playback_state.json() return playback_state_dict if playback_state_dict["device"]["id"] == self.device_info["device_id"] else {} From 2b4e66663b4cb81e5700d16de100cf8af133f550 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 14 Jan 2022 21:56:13 +0100 Subject: [PATCH 026/109] Bugfixing: Handle device_info right --- src/jukebox/components/playerspot/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index d90c04024..3533d7b60 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -86,8 +86,9 @@ def __init__(self): # "device_type":"SPEAKER", # "country_code":"DE", # "preferred_locale":"de"} - self.device_info = requests_session.get(urllib.parse.urljoin(self.spot_api_baseurl, "/instance")).json() - self.device_info.raise_for_status() + requests_response = requests_session.get(urllib.parse.urljoin(self.spot_api_baseurl, "/instance")) + requests_response.raise_for_status() + self.device_info = requests_response.json() except requests.HTTPError as http_error: logger.error("Could not get device information") logger.error(f"Reason: {http_error}") From 29cc50a0ad3ff41d06b319578689003b35869cc8 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 14 Jan 2022 22:21:57 +0100 Subject: [PATCH 027/109] Docker adjustments --- docker/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 780b4e5a5..7edd6a229 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -34,6 +34,7 @@ services: container_name: jukebox depends_on: - mpd + - spotify links: - mpd ports: From d4ddadc4bf9f24aa5a76039caf212ea83303542d Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 14 Jan 2022 22:23:00 +0100 Subject: [PATCH 028/109] Adjustment in Websocket loop --- src/jukebox/components/playerspot/__init__.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index 3533d7b60..d357dfd23 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -43,6 +43,7 @@ # Patch asyncio to make its event loop reentrant. nest_asyncio.apply() +loop = asyncio.get_event_loop() # We need to wait until the spotify API is up and running. # Therefore, we need to implement a retry option to wait for the connection. @@ -98,10 +99,14 @@ def __init__(self): self.device_info = {} # Establish WebSocket connection: - loop = asyncio.get_event_loop() - self.spot_websocket_connection = loop.run_until_complete(self._connect_websocket()) - loop.run_until_complete( - asyncio.wait(asyncio.ensure_future(self.receive_message_from_websocket(self.spot_websocket_connection)))) + self.spot_websocket_connection = asyncio.run_coroutine_threadsafe(self._connect_websocket(), loop) + tasks = [ + asyncio.ensure_future( + self.receive_message_from_websocket( + self.spot_websocket_connection + )), + ] + loop.run_until_complete(asyncio.wait(tasks)) async def _connect_websocket(self): """ @@ -121,7 +126,7 @@ async def receive_message_from_websocket(self, ws_connection): """ while True: try: - message = await ws_connection.recv() + message = await ws_connection.result() logger.debug(f"Received message from server: {message}") # ToDo: handle messages except websockets.ConnectionClosed: From e1c25a7d22d992095201d51373c805d74f1bf846 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Wed, 5 Jan 2022 21:17:50 +0100 Subject: [PATCH 029/109] Use JRE instead of JDE --- docker/spotify.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/spotify.Dockerfile b/docker/spotify.Dockerfile index f0ba5df5d..59730cb1f 100644 --- a/docker/spotify.Dockerfile +++ b/docker/spotify.Dockerfile @@ -7,7 +7,7 @@ RUN set -eux ; \ libasound2-plugins \ pulseaudio \ pulseaudio-utils \ - default-jdk + default-jre RUN usermod -aG audio,pulse,pulse-access root From 2d8ca15148f7500deb051cfb5fb81dd3355f91ac Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sun, 16 Jan 2022 00:20:37 +0100 Subject: [PATCH 030/109] Outsource http and ws client into own class (ws non functional!) --- src/jukebox/components/playerspot/__init__.py | 103 +++------------- .../components/playerspot/http_client.py | 110 ++++++++++++++++++ .../components/playerspot/ws_client.py | 58 +++++++++ 3 files changed, 181 insertions(+), 90 deletions(-) create mode 100644 src/jukebox/components/playerspot/http_client.py create mode 100644 src/jukebox/components/playerspot/ws_client.py diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index d357dfd23..3f5a2a122 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -20,118 +20,41 @@ """ import logging import functools -import asyncio import urllib.parse -import requests -import websockets -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - import components.player import jukebox.cfghandler import jukebox.utils as utils import jukebox.plugs as plugs import jukebox.publishing as publishing import misc -import nest_asyncio + +from .http_client import SpotifyHttpClient +from .ws_client import SpotifyWsClient from jukebox.NvManager import nv_manager logger = logging.getLogger('jb.PlayerSpot') cfg = jukebox.cfghandler.get_handler('jukebox') -# Patch asyncio to make its event loop reentrant. -nest_asyncio.apply() -loop = asyncio.get_event_loop() - -# We need to wait until the spotify API is up and running. -# Therefore, we need to implement a retry option to wait for the connection. -requests_session = requests.Session() - -retries = Retry(total=5, - backoff_factor=5, - status_forcelist=[500, 502, 503, 504]) - -requests_session.mount('http://', HTTPAdapter(max_retries=retries)) -requests_session.headers.update({'content-type': 'application/json'}) - -# test_dict = {'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', -# 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'}, -# 'audio_folder_status': -# {'TraumfaengerStarkeLieder': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', -# 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'stop', 'RESUME': 'OFF', -# 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}, -# 'Giraffenaffen': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', -# 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'play', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', -# 'LOOP': 'OFF', 'SINGLE': 'OFF'}}} - class PlayerSpot: """Interface to librespot-java API""" - # ToDo: spot_state def __init__(self): self.nvm = nv_manager() - self.spot_host = cfg.getn('playerspot', 'host') - logger.debug(f"Using spotify host: {self.spot_host}") - self.spot_api_port = 24879 - self.spot_api_baseurl = f"http://{self.spot_host}:{self.spot_api_port}" - logger.debug(f"Using spotify api base url: {self.spot_api_baseurl}") - self.spot_websocket_uri = urllib.parse.urljoin(f"ws://{self.spot_host}:{self.spot_api_port}", "/events") - logger.debug(f"Using spotify websocket uri: {self.spot_websocket_uri}") - try: - # Info as dict - # Example: {"device_id":"ABC", - # "device_name":"Phoniebox", - # "device_type":"SPEAKER", - # "country_code":"DE", - # "preferred_locale":"de"} - requests_response = requests_session.get(urllib.parse.urljoin(self.spot_api_baseurl, "/instance")) - requests_response.raise_for_status() - self.device_info = requests_response.json() - except requests.HTTPError as http_error: - logger.error("Could not get device information") - logger.error(f"Reason: {http_error}") - self.device_info = {} - except Exception as err: - logger.error(f"Other error occurred: {err}") - self.device_info = {} - - # Establish WebSocket connection: - self.spot_websocket_connection = asyncio.run_coroutine_threadsafe(self._connect_websocket(), loop) - tasks = [ - asyncio.ensure_future( - self.receive_message_from_websocket( - self.spot_websocket_connection - )), - ] - loop.run_until_complete(asyncio.wait(tasks)) - - async def _connect_websocket(self): - """ - Connecting to spotify webSocket server + host = cfg.getn('playerspot', 'host') + http_client = SpotifyHttpClient(host) + http_client.play_uri('spotify:track:3u0W3gJQNV5gegMmntzby8') - websockets.client.connect returns a WebSocketClientProtocol, which is used to receive messages - """ - ws_connection = await websockets.connect(self.spot_websocket_uri) - if ws_connection.open: - logger.debug(f"Connection to websocket sever established." - f"Client correcly connected to {self.spot_websocket_uri}") - return ws_connection + # ws_client = SpotifyWsClient(host) + # ws_client.connect() + + + ### + # TODO: The following functions have not been adopted to the new clients + ### - async def receive_message_from_websocket(self, ws_connection): - """ - Receiving all server messages and handling them - """ - while True: - try: - message = await ws_connection.result() - logger.debug(f"Received message from server: {message}") - # ToDo: handle messages - except websockets.ConnectionClosed: - logger.debug("Connection with websocket server closed") - break def exit(self): logger.debug("Exit routine of playerspot started") diff --git a/src/jukebox/components/playerspot/http_client.py b/src/jukebox/components/playerspot/http_client.py new file mode 100644 index 000000000..2b195b288 --- /dev/null +++ b/src/jukebox/components/playerspot/http_client.py @@ -0,0 +1,110 @@ +import logging +import requests +from requests.adapters import HTTPAdapter +import urllib +from urllib3.util.retry import Retry + +logger = logging.getLogger('jb.SpotifyHttpClient') + +class SpotifyHttpClient: + def __init__(self, host: str, port = 24879): + self.protocol = 'http' + self.host = host + self.port = port + self.authority = f'{self.protocol}://{self.host}:{self.port}' + + self.session = requests.Session() + retries = Retry( + total = 5, + backoff_factor = 5, + status_forcelist = [500, 502, 503, 504] + ) + + self.session.mount( + self.protocol + '://', + HTTPAdapter(max_retries = retries) + ) + self.session.headers.update({'content-type': 'application/json'}) + logger.debug(f'Spotify HTTP Client initialized. Will connect to {self.authority}') + + + def _request(self, request_func, path: str): + try: + url = urllib.parse.urljoin(self.authority, path) + logger.debug(f'Requesting "{self.authority}"') + + response = request_func(url) + response.raise_for_status() + + except requests.HTTPError as http_error: + response = {} + logger.error(f'HTTPError: {http_error}') + + except Exception as error: + response = {} + logger.error(f'Error {error}') + + return response + + + def _get_request(self, path: str): + response = self._request(self.session.get, path) + return response + + + def _post_request(self, path: str): + response = self._request(self.session.post, path) + return response + + + def play_uri(self, uri: str, play: bool = True): + json = self._post_request(f'/player/load?uri={uri}&play={play}') + return json + + + def play(self): + json = self._post_request('/player/resume') + return json + + + def pause(self): + json = self._post_request('/player/pause') + return json + + + def prev(self): + json = self._post_request('/player/prev') + return json + + + def next(self): + json = self._post_request('/player/next') + return json + + + def seek(self, new_time: int): + json = self._post_request(f'/player/seek?position_ms={new_time}') + return json + + + def shuffle(self, random: bool): + json = self._post_request(f'/player/shuffle?state={1 if random else 0}') + return json + + + def repeatmode(self, mode: str): + if mode == 'repeat': + repeat_state = 'context' + elif mode == 'single': + repeat_state = 'track' + else: + repeat_state = 'none' + + json = self._post_request(f'/player/repeat?state={repeat_state}') + return json + + + + def exit(self): + logger.debug("Exiting Spotify HTTP session") + self._post_request('/instance/close') diff --git a/src/jukebox/components/playerspot/ws_client.py b/src/jukebox/components/playerspot/ws_client.py new file mode 100644 index 000000000..419ede125 --- /dev/null +++ b/src/jukebox/components/playerspot/ws_client.py @@ -0,0 +1,58 @@ +import asyncio +import logging +import nest_asyncio +import websockets + +import jukebox.publishing as publishing + +logger = logging.getLogger('jb.SpotifyWsClient') + + +# Patch asyncio to make its event loop reentrant. +nest_asyncio.apply() +loop = asyncio.get_event_loop() + + +class SpotifyWsClient: + def __init__(self, host: str, port = 24879): + self.protocol = 'ws' + self.host = host + self.port = port + self.authority = f'{self.protocol}://{self.host}:{self.port}' + self.socket = None + logger.debug(f'Spotify WS Client initialized') + + + def connect(self): + self.socket = asyncio.run_coroutine_threadsafe(self._connect_to_socket(), loop) + tasks = [ asyncio.ensure_future(self._receive_message()) ] + loop.run_until_complete(asyncio.wait(tasks)) + logger.debug(f'Connected to {self.authority}') + + + async def _connect_to_socket(self): + """ + Connecting to Spotify web socket + """ + try: + connection = await websockets.connect(self.authority) + if connection.open: + logger.debug(f'Web Socket connection established to {self.authority}') + return self.connection + except Exception as error: + logger.error(f'Could not establish websocket connection: {error}') + + + async def _receive_message(self): + """ + Receiving all server messages and handling them + """ + while True: + try: + message = await self.socket.result() + publishing.get_publisher().send('spotify.events', message) + logger.debug(f'Received message from server: {message}') + + except websockets.ConnectionClosed: + logger.debug('Connection with websocket server closed') + break From a955afbdca8f839de4a72c7920a1a0be17090935 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sun, 16 Jan 2022 23:24:10 +0100 Subject: [PATCH 031/109] Use websocket-client and make http-client useful --- requirements.txt | 4 +- src/jukebox/components/playerspot/__init__.py | 654 ++++++++---------- .../components/playerspot/http_client.py | 19 +- .../components/playerspot/ws_client.py | 76 +- 4 files changed, 349 insertions(+), 404 deletions(-) diff --git a/requirements.txt b/requirements.txt index d01d00f5a..a9884ddec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,9 +16,7 @@ eyed3 # For the publisher event reactor loop: tornado # For spotify -websockets -asyncio -nest_asyncio +websocket-client urllib3 # PyZMQ is a special case: diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index 3f5a2a122..bfdc880f5 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -19,14 +19,10 @@ https://github.com/spocon/spocon """ import logging -import functools -import urllib.parse import components.player import jukebox.cfghandler -import jukebox.utils as utils import jukebox.plugs as plugs -import jukebox.publishing as publishing import misc from .http_client import SpotifyHttpClient @@ -44,407 +40,367 @@ class PlayerSpot: def __init__(self): self.nvm = nv_manager() host = cfg.getn('playerspot', 'host') - http_client = SpotifyHttpClient(host) - http_client.play_uri('spotify:track:3u0W3gJQNV5gegMmntzby8') + self.http_client = SpotifyHttpClient(host) + self.http_client.play_uri('spotify:track:4xkOaSrkexMciUUogZKVTS') - # ws_client = SpotifyWsClient(host) - # ws_client.connect() + self.ws_client = SpotifyWsClient(host) + self.ws_client.connect() ### # TODO: The following functions have not been adopted to the new clients ### - - def exit(self): logger.debug("Exit routine of playerspot started") self.nvm.save_all() - api_path = "/instance/close" - requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - return "payerspot exited" - - def decode_2nd_swipe_option(self): - cfg_2nd_swipe_action = cfg.setndefault('playerspot', 'second_swipe_action', 'alias', value='none').lower() - if cfg_2nd_swipe_action not in [*self.second_swipe_action_dict.keys(), 'none', 'custom']: - logger.error(f"Config spot.second_swipe_action must be one of " - f"{[*self.second_swipe_action_dict.keys(), 'none', 'custom']}. Ignore setting.") - if cfg_2nd_swipe_action in self.second_swipe_action_dict.keys(): - self.second_swipe_action = self.second_swipe_action_dict[cfg_2nd_swipe_action] - if cfg_2nd_swipe_action == 'custom': - custom_action = utils.decode_rpc_call(cfg.getn('playerspot', 'second_swipe_action', default=None)) - self.second_swipe_action = functools.partial(plugs.call_ignore_errors, - custom_action['package'], - custom_action['plugin'], - custom_action['method'], - custom_action['args'], - custom_action['kwargs']) - - def _spot_status_poll(self): - """ - this method polls the status from spot and stores the important inforamtion in the music_player_status, - it will repeat itself in the intervall specified by self.spot_status_poll_interval - """ + self.http_client.close() + self.ws_client.close() + + + # def decode_2nd_swipe_option(self): + # cfg_2nd_swipe_action = cfg.setndefault('playerspot', 'second_swipe_action', 'alias', value='none').lower() + # if cfg_2nd_swipe_action not in [*self.second_swipe_action_dict.keys(), 'none', 'custom']: + # logger.error(f"Config spot.second_swipe_action must be one of " + # f"{[*self.second_swipe_action_dict.keys(), 'none', 'custom']}. Ignore setting.") + # if cfg_2nd_swipe_action in self.second_swipe_action_dict.keys(): + # self.second_swipe_action = self.second_swipe_action_dict[cfg_2nd_swipe_action] + # if cfg_2nd_swipe_action == 'custom': + # custom_action = utils.decode_rpc_call(cfg.getn('playerspot', 'second_swipe_action', default=None)) + # self.second_swipe_action = functools.partial(plugs.call_ignore_errors, + # custom_action['package'], + # custom_action['plugin'], + # custom_action['method'], + # custom_action['args'], + # custom_action['kwargs']) + + + # def _spot_status_poll(self): + # """ + # this method polls the status from spot and stores the important inforamtion in the music_player_status, + # it will repeat itself in the intervall specified by self.spot_status_poll_interval + # """ + + + # # Delete the volume key to avoid confusion + # # Volume is published via the 'volume' component! + # try: + # del self.spot_status['volume'] + # except KeyError: + # pass + # publishing.get_publisher().send('playerstatus', self.spot_status) - # Delete the volume key to avoid confusion - # Volume is published via the 'volume' component! - try: - del self.spot_status['volume'] - except KeyError: - pass - publishing.get_publisher().send('playerstatus', self.spot_status) - - @staticmethod - def _handle_http_errors(requests_response: requests.Response): - try: - requests_response.raise_for_status() - except requests.HTTPError as http_error: - logger.error("Could not communicate with spotify API") - logger.error(f"Reason: {http_error}") - return False - except Exception as err: - logger.error(f"Other error occurred: {err}") - return False - return True @plugs.tag - def load(self, uri: str, start_playing: bool): - logger.debug(f"loading playlist {uri} and with option playing={start_playing}") - self.check_uri(uri) - api_path = f"/player/load?uri={uri}&play={start_playing}" - spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - self._handle_http_errors(spot_response) + def play_uri(self, uri: str, play: bool = True): + self.http_client.play_uri(uri, play) + @plugs.tag def play(self): - api_path = "/player/resume" - spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - self._handle_http_errors(spot_response) + self.http_client.play() + @plugs.tag def stop(self): self.pause(state=1) + @plugs.tag def pause(self, state: int = 1): - """Enforce pause to state (1: pause, 0: resume) - - This is what you want as card removal action: pause the playback, so it can be resumed when card is placed - on the reader again. What happens on re-placement depends on configured second swipe option - """ if state == 1: - api_path = "/player/pause" - spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - self._handle_http_errors(spot_response) + self.http_client.pause() else: self.play() + @plugs.tag def prev(self): - api_path = "/player/prev" + self.http_client.prev() - spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - self._handle_http_errors(spot_response) @plugs.tag def next(self): - api_path = "/player/next" - spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - self._handle_http_errors(spot_response) + self.http_client.next() + @plugs.tag def seek(self, new_time: int): """ Seek to a given position in milliseconds specified by new_time """ - api_path = f"/player/seek?position_ms={new_time}" - spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - self._handle_http_errors(spot_response) + self.http_client.seek(new_time) - @plugs.tag - def shuffle(self, random: bool): - api_path = f"/player/shuffle?state={1 if random else 0}" - spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - self._handle_http_errors(spot_response) - - @plugs.tag - def rewind(self): - """ - Re-start current playlist from first track - - Note: Will not re-read folder config, but leave settings untouched""" - logger.debug("Rewind") - self.seek(0) @plugs.tag - def replay(self): - """ - Re-start playing the last-played playlist + def shuffle(self, val: bool): + self.http_client.shuffle(val) - Will reset settings to folder config""" - logger.debug("Replay") - self.play_playlist(self.music_player_status['player_status']['last_played_folder']) - - @plugs.tag - def toggle(self): - """Toggle pause state, i.e. do a pause / resume depending on current state""" - logger.debug("Toggle") - api_path = "/player/play-pause" - spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - self._handle_http_errors(spot_response) - - @plugs.tag - def replay_if_stopped(self): - """ - Re-start playing the last-played folder unless playlist is still playing - - .. note:: To me this seems much like the behaviour of play, - but we keep it as it is specifically implemented in box 2.X""" - logger.debug("replay_if_stopped") - if self.spot_status['state'] == 'stop': - self.play_playlist(self.music_player_status['player_status']['last_played_folder']) @plugs.tag def repeatmode(self, mode: str): - if mode == 'repeat': - rep_state = "context" - elif mode == 'single': - rep_state = "track" - else: - rep_state = "none" - api_path = f"/player/repeat?state={rep_state}" - spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - self._handle_http_errors(spot_response) - - @plugs.tag - def get_current_song(self, param): - return self.spot_status - - @plugs.tag - def map_filename_to_playlist_pos(self, filename): - raise NotImplementedError - - @plugs.tag - def remove(self): - raise NotImplementedError - - @plugs.tag - def move(self): - raise NotImplementedError - - @plugs.tag - def play_single(self, song_uri: str): - self.check_uri(song_uri) - api_path = f"/player/repeat?uri={song_uri}&play=true" - spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - self._handle_http_errors(spot_response) - - @plugs.tag - def resume(self): - songpos = self.current_folder_status["CURRENTSONGPOS"] - self.seek(songpos) - self.play() - - @plugs.tag - def play_card(self, folder: str, recursive: bool = False): - """ - Main entry point for trigger music playing from RFID reader. Decodes second swipe options before - playing folder content - - Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action - accordingly. - - :param folder: Folder path relative to music library path - :param recursive: Add folder recursively - """ - # Developers notes: - # - # * 2nd swipe trigger may also happen, if playlist has already stopped playing - # --> Generally, treat as first swipe - # * 2nd swipe of same Card ID may also happen if a different song has been played in between from WebUI - # --> Treat as first swipe - # * With place-not-swipe: Card is placed on reader until playlist expieres. Music stop. Card is removed and - # placed again on the reader: Should be like first swipe - # * TODO: last_played_folder is restored after box start, so first swipe of last played card may look like - # second swipe - # - logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}") - logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}") - is_second_swipe = self.music_player_status['player_status']['last_played_folder'] == folder - if self.second_swipe_action is not None and is_second_swipe: - logger.debug('Calling second swipe action') - self.second_swipe_action() - else: - logger.debug('Calling first swipe action') - self.play_playlist(folder, recursive) - - @plugs.tag - def get_playlist_content(self, playlist_uri: str): - """ - Get the spotify playlist as content list with spotify id - - Example: - ["artists" : [{ - "id" : "5lpH0xAS4fVfLkACg9DAuM", - "name" : "Wham!" - }], - "id" : "2FRnf9qhLbvw8fu4IBXx78", - "name" : "Last Christmas" - }] - - - :param playlist_uri: URI for the spotify playlist as string - """ - track_list = [] - api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name,id,artists(name,id))" - playlist_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - if self._handle_http_errors(playlist_response): - playlist_dict = playlist_response.json() - for elem in playlist_dict["items"]: - track_list.append(elem["track"]) - return track_list - - @plugs.tag - def play_playlist(self, playlist_uri: str, recursive: bool = False) -> None: - """ - Playback a spotify playlist. - - :param playlist_uri: Folder path relative to music library path - :param recursive: Add folder recursively - """ - logger.debug("play_folder") - logger.info(f"Play spotify playlist: '{playlist_uri}'") - - self.music_player_status['player_status']['last_played_folder'] = playlist_uri - - self.current_folder_status = self.music_player_status['audio_folder_status'].get(playlist_uri) - if self.current_folder_status is None: - self.current_folder_status = self.music_player_status['audio_folder_status'][playlist_uri] = {} - - self.load(self.current_folder_status, start_playing=True) - - @plugs.tag - def play_album(self, album_uri: str): - """ - Playback a album from spotify. - - :param album_uri: Album URI from spotify - """ - logger.debug("play_album") - logger.info(f"Play album: '{album_uri}'") - self.load(album_uri, start_playing=True) - - @plugs.tag - def queue_load(self, folder): - # There was something playing before -> stop and save state - # Clear the queue - # Check / Create the playlist - # - not needed if same folder is played again? Buf what if files have been added a mpc update has been run? - # - and this a re-trigger to start the new playlist - # If we must update the playlists everytime anyway why write them to file and not just keep them in the queue? - # Load the playlist - # Get folder config and apply settings - pass - - @plugs.tag - def playerstatus(self): - return self.spot_status - - @plugs.tag - def playlistinfo(self): - """ - Returns a list of all songs in the playlist - """ - track_list = [] - playlist_uri = self.get_playback_state()["context"]["uri"] - api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name))" - playlist_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - if self._handle_http_errors(playlist_response): - playlist_response.raise_for_status() - playlist_dict = playlist_response.json() - for elem in playlist_dict["items"]: - track_list.append(elem["track"]["name"]) - return track_list - - @plugs.tag - def list_albums(self): - # ToDo: Do we need this for spotify? - raise NotImplementedError - - @plugs.tag - def list_song_by_artist_and_album(self, albumartist, album): - # ToDo: Do we need this for spotify? - raise NotImplementedError - - def get_volume(self): - """ - Get the current volume - - For volume control do not use directly, but use through the plugin 'volume', - as the user may have configured a volume control manager other than Spotify""" - return self.get_playback_state()["device"]["volume_percent"] - - def set_volume(self, volume): - """ - Set the volume - - For volume control do not use directly, but use through the plugin 'volume', - as the user may have configured a volume control manager other than Spotify""" - api_path = f"/player/volume?volume_percent={volume}" - spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - self._handle_http_errors(spot_response) - return self.get_volume() - - def get_playback_state(self): - playback_state_dict = {} - api_path = "/web-api/v1/me/player" - playback_state = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - if self._handle_http_errors(playback_state): - playback_state_dict = playback_state.json() - return playback_state_dict if playback_state_dict["device"]["id"] == self.device_info["device_id"] else {} - - @staticmethod - def check_uri(uri: str): - """ - Checking that the uri has the right syntax - """ - check_list = uri.split(":") - valid_play_type = ["album", "track"] - if check_list[1] == "user": - assert len(check_list) == 5, f"URI {uri} is missing information." - assert check_list[0] == "spotify", f"URI {uri} does not start with spotify" - assert check_list[1] == "user", f"URI {uri} does not contain a valid type on pos 2" - assert type(check_list[2]) is int, f"URI {uri} does not contain the right user id on pos 3" - assert check_list[3] == "playlist", f"URI {uri} does not contain a valid type playlist on pos 4" - - else: - assert len(check_list) == 3, f"URI {uri} is missing information." - assert check_list[0] == "spotify", f"URI {uri} does not start with spotify" - assert check_list[1] in valid_play_type, f"URI {uri} does not contain a valid type on pos 2" + # modes are none, track, context + self.http_client.repeatmode(mode) + + + # @plugs.tag + # def rewind(self): + # """ + # Re-start current playlist from first track + + # Note: Will not re-read folder config, but leave settings untouched""" + # logger.debug("Rewind") + # self.seek(0) + + # @plugs.tag + # def replay(self): + # """ + # Re-start playing the last-played playlist + + # Will reset settings to folder config""" + # logger.debug("Replay") + # self.play_playlist(self.music_player_status['player_status']['last_played_folder']) + + # @plugs.tag + # def toggle(self): + # """Toggle pause state, i.e. do a pause / resume depending on current state""" + # logger.debug("Toggle") + # api_path = "/player/play-pause" + # spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + # self._handle_http_errors(spot_response) + + # @plugs.tag + # def replay_if_stopped(self): + # """ + # Re-start playing the last-played folder unless playlist is still playing + + # .. note:: To me this seems much like the behaviour of play, + # but we keep it as it is specifically implemented in box 2.X""" + # logger.debug("replay_if_stopped") + # if self.spot_status['state'] == 'stop': + # self.play_playlist(self.music_player_status['player_status']['last_played_folder']) + + # @plugs.tag + # def get_current_song(self, param): + # return self.spot_status + + # @plugs.tag + # def map_filename_to_playlist_pos(self, filename): + # raise NotImplementedError + + # @plugs.tag + # def remove(self): + # raise NotImplementedError + + # @plugs.tag + # def move(self): + # raise NotImplementedError + + # @plugs.tag + # def play_single(self, song_uri: str): + # self.check_uri(song_uri) + # api_path = f"/player/repeat?uri={song_uri}&play=true" + # spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + # self._handle_http_errors(spot_response) + + # @plugs.tag + # def resume(self): + # songpos = self.current_folder_status["CURRENTSONGPOS"] + # self.seek(songpos) + # self.play() + + # @plugs.tag + # def play_card(self, folder: str, recursive: bool = False): + # """ + # Main entry point for trigger music playing from RFID reader. Decodes second swipe options before + # playing folder content + + # Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action + # accordingly. + + # :param folder: Folder path relative to music library path + # :param recursive: Add folder recursively + # """ + # # Developers notes: + # # + # # * 2nd swipe trigger may also happen, if playlist has already stopped playing + # # --> Generally, treat as first swipe + # # * 2nd swipe of same Card ID may also happen if a different song has been played in between from WebUI + # # --> Treat as first swipe + # # * With place-not-swipe: Card is placed on reader until playlist expieres. Music stop. Card is removed and + # # placed again on the reader: Should be like first swipe + # # * TODO: last_played_folder is restored after box start, so first swipe of last played card may look like + # # second swipe + # # + # logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}") + # logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}") + # is_second_swipe = self.music_player_status['player_status']['last_played_folder'] == folder + # if self.second_swipe_action is not None and is_second_swipe: + # logger.debug('Calling second swipe action') + # self.second_swipe_action() + # else: + # logger.debug('Calling first swipe action') + # self.play_playlist(folder, recursive) + + # @plugs.tag + # def get_playlist_content(self, playlist_uri: str): + # """ + # Get the spotify playlist as content list with spotify id + + # Example: + # ["artists" : [{ + # "id" : "5lpH0xAS4fVfLkACg9DAuM", + # "name" : "Wham!" + # }], + # "id" : "2FRnf9qhLbvw8fu4IBXx78", + # "name" : "Last Christmas" + # }] + + + # :param playlist_uri: URI for the spotify playlist as string + # """ + # track_list = [] + # api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name,id,artists(name,id))" + # playlist_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + # if self._handle_http_errors(playlist_response): + # playlist_dict = playlist_response.json() + # for elem in playlist_dict["items"]: + # track_list.append(elem["track"]) + # return track_list + + # @plugs.tag + # def play_playlist(self, playlist_uri: str, recursive: bool = False) -> None: + # """ + # Playback a spotify playlist. + + # :param playlist_uri: Folder path relative to music library path + # :param recursive: Add folder recursively + # """ + # logger.debug("play_folder") + # logger.info(f"Play spotify playlist: '{playlist_uri}'") + + # self.music_player_status['player_status']['last_played_folder'] = playlist_uri + + # self.current_folder_status = self.music_player_status['audio_folder_status'].get(playlist_uri) + # if self.current_folder_status is None: + # self.current_folder_status = self.music_player_status['audio_folder_status'][playlist_uri] = {} + + # self.load(self.current_folder_status, start_playing=True) + + # @plugs.tag + # def play_album(self, album_uri: str): + # """ + # Playback a album from spotify. + + # :param album_uri: Album URI from spotify + # """ + # logger.debug("play_album") + # logger.info(f"Play album: '{album_uri}'") + # self.load(album_uri, start_playing=True) + + # @plugs.tag + # def queue_load(self, folder): + # # There was something playing before -> stop and save state + # # Clear the queue + # # Check / Create the playlist + # # - not needed if same folder is played again? Buf what if files have been added a mpc update has been run? + # # - and this a re-trigger to start the new playlist + # # If we must update the playlists everytime anyway why write them to file and not just keep them in the queue? + # # Load the playlist + # # Get folder config and apply settings + # pass + + # @plugs.tag + # def playerstatus(self): + # return self.spot_status + + # @plugs.tag + # def playlistinfo(self): + # """ + # Returns a list of all songs in the playlist + # """ + # track_list = [] + # playlist_uri = self.get_playback_state()["context"]["uri"] + # api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name))" + # playlist_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + # if self._handle_http_errors(playlist_response): + # playlist_response.raise_for_status() + # playlist_dict = playlist_response.json() + # for elem in playlist_dict["items"]: + # track_list.append(elem["track"]["name"]) + # return track_list + + # @plugs.tag + # def list_albums(self): + # # ToDo: Do we need this for spotify? + # raise NotImplementedError + + # @plugs.tag + # def list_song_by_artist_and_album(self, albumartist, album): + # # ToDo: Do we need this for spotify? + # raise NotImplementedError + + # def get_volume(self): + # """ + # Get the current volume + + # For volume control do not use directly, but use through the plugin 'volume', + # as the user may have configured a volume control manager other than Spotify""" + # return self.get_playback_state()["device"]["volume_percent"] + + # def set_volume(self, volume): + # """ + # Set the volume + + # For volume control do not use directly, but use through the plugin 'volume', + # as the user may have configured a volume control manager other than Spotify""" + # api_path = f"/player/volume?volume_percent={volume}" + # spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + # self._handle_http_errors(spot_response) + # return self.get_volume() + + # def get_playback_state(self): + # playback_state_dict = {} + # api_path = "/web-api/v1/me/player" + # playback_state = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) + # if self._handle_http_errors(playback_state): + # playback_state_dict = playback_state.json() + # return playback_state_dict if playback_state_dict["device"]["id"] == self.device_info["device_id"] else {} + + # @staticmethod + # def check_uri(uri: str): + # """ + # Checking that the uri has the right syntax + # """ + # check_list = uri.split(":") + # valid_play_type = ["album", "track"] + # if check_list[1] == "user": + # assert len(check_list) == 5, f"URI {uri} is missing information." + # assert check_list[0] == "spotify", f"URI {uri} does not start with spotify" + # assert check_list[1] == "user", f"URI {uri} does not contain a valid type on pos 2" + # assert type(check_list[2]) is int, f"URI {uri} does not contain the right user id on pos 3" + # assert check_list[3] == "playlist", f"URI {uri} does not contain a valid type playlist on pos 4" + + # else: + # assert len(check_list) == 3, f"URI {uri} is missing information." + # assert check_list[0] == "spotify", f"URI {uri} does not start with spotify" + # assert check_list[1] in valid_play_type, f"URI {uri} does not contain a valid type on pos 2" # --------------------------------------------------------------------------- # Plugin Initializer / Finalizer # --------------------------------------------------------------------------- -player_ctrl: PlayerSpot +playerspot_ctrl: PlayerSpot @plugs.initialize def initialize(): - global player_ctrl - player_ctrl = PlayerSpot() - plugs.register(player_ctrl, name='ctrl') - - # Check user rights on music library - library_check_user_rights = cfg.setndefault('playerspot', 'library', 'check_user_rights', value=True) - if library_check_user_rights is True: - music_library_path = components.player.get_music_library_path() - if music_library_path is not None: - logger.info(f"Change user rights for {music_library_path}") - misc.recursive_chmod(music_library_path, mode_files=0o666, mode_dirs=0o777) + global playerspot_ctrl + playerspot_ctrl = PlayerSpot() + plugs.register(playerspot_ctrl, name='ctrl') @plugs.atexit def atexit(**ignored_kwargs): - global player_ctrl - return player_ctrl.exit() + global playerspot_ctrl + return playerspot_ctrl.exit() diff --git a/src/jukebox/components/playerspot/http_client.py b/src/jukebox/components/playerspot/http_client.py index 2b195b288..c6985224d 100644 --- a/src/jukebox/components/playerspot/http_client.py +++ b/src/jukebox/components/playerspot/http_client.py @@ -28,6 +28,11 @@ def __init__(self, host: str, port = 24879): logger.debug(f'Spotify HTTP Client initialized. Will connect to {self.authority}') + def close(self): + logger.debug("Exiting Spotify HTTP session") + self._post_request('/instance/close') + + def _request(self, request_func, path: str): try: url = urllib.parse.urljoin(self.authority, path) @@ -83,12 +88,12 @@ def next(self): def seek(self, new_time: int): - json = self._post_request(f'/player/seek?position_ms={new_time}') + json = self._post_request(f'/player/seek?pos={new_time}') return json - def shuffle(self, random: bool): - json = self._post_request(f'/player/shuffle?state={1 if random else 0}') + def shuffle(self, val: bool): + json = self._post_request(f'/player/shuffle?val={1 if val else 0}') return json @@ -100,11 +105,5 @@ def repeatmode(self, mode: str): else: repeat_state = 'none' - json = self._post_request(f'/player/repeat?state={repeat_state}') + json = self._post_request(f'/player/repeat?val={repeat_state}') return json - - - - def exit(self): - logger.debug("Exiting Spotify HTTP session") - self._post_request('/instance/close') diff --git a/src/jukebox/components/playerspot/ws_client.py b/src/jukebox/components/playerspot/ws_client.py index 419ede125..b9956d914 100644 --- a/src/jukebox/components/playerspot/ws_client.py +++ b/src/jukebox/components/playerspot/ws_client.py @@ -1,58 +1,50 @@ -import asyncio import logging -import nest_asyncio -import websockets +import websocket +import threading +from time import sleep import jukebox.publishing as publishing logger = logging.getLogger('jb.SpotifyWsClient') - -# Patch asyncio to make its event loop reentrant. -nest_asyncio.apply() -loop = asyncio.get_event_loop() - - class SpotifyWsClient: def __init__(self, host: str, port = 24879): self.protocol = 'ws' self.host = host self.port = port - self.authority = f'{self.protocol}://{self.host}:{self.port}' + self.url = f'{self.protocol}://{self.host}:{self.port}/events' self.socket = None + self.thread = None logger.debug(f'Spotify WS Client initialized') def connect(self): - self.socket = asyncio.run_coroutine_threadsafe(self._connect_to_socket(), loop) - tasks = [ asyncio.ensure_future(self._receive_message()) ] - loop.run_until_complete(asyncio.wait(tasks)) - logger.debug(f'Connected to {self.authority}') - - - async def _connect_to_socket(self): - """ - Connecting to Spotify web socket - """ - try: - connection = await websockets.connect(self.authority) - if connection.open: - logger.debug(f'Web Socket connection established to {self.authority}') - return self.connection - except Exception as error: - logger.error(f'Could not establish websocket connection: {error}') - - - async def _receive_message(self): - """ - Receiving all server messages and handling them - """ - while True: - try: - message = await self.socket.result() - publishing.get_publisher().send('spotify.events', message) - logger.debug(f'Received message from server: {message}') - - except websockets.ConnectionClosed: - logger.debug('Connection with websocket server closed') - break + websocket.enableTrace(True) + self.socket = websocket.WebSocketApp( + self.url, + on_close = self._on_close, + on_error = self._on_error, + on_message = self._on_message + ) + self.thread = threading.Thread(target = self.socket.run_forever) + self.thread.daemon = True + self.thread.start() + + logger.debug(f'Websocket connection established to {self.url}') + + + def close(self): + self.socket.close() + logger.debug('Websocket connection closed') + + + def _on_message(self, socket, message): + logger.debug(f'Websocket message received: {message}') + + + def _on_close(self, socket): + logger.debug('Connection with websocket server closed') + + + def _on_error(self, socket, error): + logger.error(f'Websocket error: {error}') From 5aef09f9238bba3cb45ac0c234cce206dd67834e Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 17 Jan 2022 13:28:10 +0100 Subject: [PATCH 032/109] Reformating code --- .../components/playerspot/http_client.py | 189 +++++++++--------- .../components/playerspot/ws_client.py | 78 ++++---- 2 files changed, 125 insertions(+), 142 deletions(-) diff --git a/src/jukebox/components/playerspot/http_client.py b/src/jukebox/components/playerspot/http_client.py index c6985224d..e4e65a60b 100644 --- a/src/jukebox/components/playerspot/http_client.py +++ b/src/jukebox/components/playerspot/http_client.py @@ -6,104 +6,93 @@ logger = logging.getLogger('jb.SpotifyHttpClient') -class SpotifyHttpClient: - def __init__(self, host: str, port = 24879): - self.protocol = 'http' - self.host = host - self.port = port - self.authority = f'{self.protocol}://{self.host}:{self.port}' - - self.session = requests.Session() - retries = Retry( - total = 5, - backoff_factor = 5, - status_forcelist = [500, 502, 503, 504] - ) - - self.session.mount( - self.protocol + '://', - HTTPAdapter(max_retries = retries) - ) - self.session.headers.update({'content-type': 'application/json'}) - logger.debug(f'Spotify HTTP Client initialized. Will connect to {self.authority}') - - - def close(self): - logger.debug("Exiting Spotify HTTP session") - self._post_request('/instance/close') - - - def _request(self, request_func, path: str): - try: - url = urllib.parse.urljoin(self.authority, path) - logger.debug(f'Requesting "{self.authority}"') - - response = request_func(url) - response.raise_for_status() - - except requests.HTTPError as http_error: - response = {} - logger.error(f'HTTPError: {http_error}') - - except Exception as error: - response = {} - logger.error(f'Error {error}') - - return response - - - def _get_request(self, path: str): - response = self._request(self.session.get, path) - return response - - - def _post_request(self, path: str): - response = self._request(self.session.post, path) - return response - - def play_uri(self, uri: str, play: bool = True): - json = self._post_request(f'/player/load?uri={uri}&play={play}') - return json - - - def play(self): - json = self._post_request('/player/resume') - return json - - - def pause(self): - json = self._post_request('/player/pause') - return json - - - def prev(self): - json = self._post_request('/player/prev') - return json - - - def next(self): - json = self._post_request('/player/next') - return json - - - def seek(self, new_time: int): - json = self._post_request(f'/player/seek?pos={new_time}') - return json - - - def shuffle(self, val: bool): - json = self._post_request(f'/player/shuffle?val={1 if val else 0}') - return json - - - def repeatmode(self, mode: str): - if mode == 'repeat': - repeat_state = 'context' - elif mode == 'single': - repeat_state = 'track' - else: - repeat_state = 'none' - - json = self._post_request(f'/player/repeat?val={repeat_state}') - return json +class SpotifyHttpClient: + def __init__(self, host: str, port=24879): + self.protocol = 'http' + self.host = host + self.port = port + self.authority = f'{self.protocol}://{self.host}:{self.port}' + + self.session = requests.Session() + retries = Retry( + total=5, + backoff_factor=5, + status_forcelist=[500, 502, 503, 504] + ) + + self.session.mount( + self.protocol + '://', + HTTPAdapter(max_retries=retries) + ) + self.session.headers.update({'content-type': 'application/json'}) + logger.debug(f'Spotify HTTP Client initialized. Will connect to {self.authority}') + + def close(self): + logger.debug("Exiting Spotify HTTP session") + self._post_request('/instance/close') + + def _request(self, request_func, path: str): + try: + url = urllib.parse.urljoin(self.authority, path) + logger.debug(f'Requesting "{self.authority}"') + + response = request_func(url) + response.raise_for_status() + + except requests.HTTPError as http_error: + response = {} + logger.error(f'HTTPError: {http_error}') + + except Exception as error: + response = {} + logger.error(f'Error {error}') + + return response + + def _get_request(self, path: str): + response = self._request(self.session.get, path) + return response + + def _post_request(self, path: str): + response = self._request(self.session.post, path) + return response + + def play_uri(self, uri: str, play: bool = True): + json = self._post_request(f'/player/load?uri={uri}&play={play}') + return json + + def play(self): + json = self._post_request('/player/resume') + return json + + def pause(self): + json = self._post_request('/player/pause') + return json + + def prev(self): + json = self._post_request('/player/prev') + return json + + def next(self): + json = self._post_request('/player/next') + return json + + def seek(self, new_time: int): + json = self._post_request(f'/player/seek?pos={new_time}') + return json + + def shuffle(self, val: bool): + json = self._post_request(f'/player/shuffle?val={1 if val else 0}') + return json + + def repeatmode(self, mode: str): + if mode == 'repeat': + repeat_state = 'context' + elif mode == 'single': + repeat_state = 'track' + else: + repeat_state = 'none' + + json = self._post_request(f'/player/repeat?val={repeat_state}') + return json diff --git a/src/jukebox/components/playerspot/ws_client.py b/src/jukebox/components/playerspot/ws_client.py index b9956d914..da2ce9da6 100644 --- a/src/jukebox/components/playerspot/ws_client.py +++ b/src/jukebox/components/playerspot/ws_client.py @@ -1,50 +1,44 @@ import logging import websocket import threading -from time import sleep - -import jukebox.publishing as publishing logger = logging.getLogger('jb.SpotifyWsClient') -class SpotifyWsClient: - def __init__(self, host: str, port = 24879): - self.protocol = 'ws' - self.host = host - self.port = port - self.url = f'{self.protocol}://{self.host}:{self.port}/events' - self.socket = None - self.thread = None - logger.debug(f'Spotify WS Client initialized') - - - def connect(self): - websocket.enableTrace(True) - self.socket = websocket.WebSocketApp( - self.url, - on_close = self._on_close, - on_error = self._on_error, - on_message = self._on_message - ) - self.thread = threading.Thread(target = self.socket.run_forever) - self.thread.daemon = True - self.thread.start() - - logger.debug(f'Websocket connection established to {self.url}') - - - def close(self): - self.socket.close() - logger.debug('Websocket connection closed') - - - def _on_message(self, socket, message): - logger.debug(f'Websocket message received: {message}') - - - def _on_close(self, socket): - logger.debug('Connection with websocket server closed') +class SpotifyWsClient: - def _on_error(self, socket, error): - logger.error(f'Websocket error: {error}') + def __init__(self, host: str, port=24879): + self.protocol = 'ws' + self.host = host + self.port = port + self.url = f'{self.protocol}://{self.host}:{self.port}/events' + self.socket = None + self.thread = None + logger.debug("Spotify WS Client initialized") + + def connect(self): + websocket.enableTrace(True) + self.socket = websocket.WebSocketApp( + self.url, + on_close=self._on_close, + on_error=self._on_error, + on_message=self._on_message + ) + self.thread = threading.Thread(target=self.socket.run_forever) + self.thread.daemon = True + self.thread.start() + + logger.debug(f'Websocket connection established to {self.url}') + + def close(self): + self.socket.close() + logger.debug('Websocket connection closed') + + def _on_message(self, socket, message): + logger.debug(f'Websocket message received: {message}') + + def _on_close(self, socket): + logger.debug('Connection with websocket server closed') + + def _on_error(self, socket, error): + logger.error(f'Websocket error: {error}') From 6b3b6fc52ac5152a0d2edac0315207e9fd66a42e Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 17 Jan 2022 21:48:47 +0100 Subject: [PATCH 033/109] Enhance spotify websocket to publish player status --- src/jukebox/components/playerspot/__init__.py | 44 ------------- .../components/playerspot/ws_client.py | 63 ++++++++++++++++--- 2 files changed, 55 insertions(+), 52 deletions(-) diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py index bfdc880f5..f82f0a0f8 100644 --- a/src/jukebox/components/playerspot/__init__.py +++ b/src/jukebox/components/playerspot/__init__.py @@ -2,32 +2,17 @@ """ Package for interfacing with the librespot-java API -Saving -{'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', - 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'}, - 'audio_folder_status': - {'TraumfaengerStarkeLieder': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', - 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'stop', 'RESUME': 'OFF', - 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}, - 'Giraffenaffen': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', - 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'play', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', - 'LOOP': 'OFF', 'SINGLE': 'OFF'}}} - References: https://github.com/librespot-org/librespot-java https://github.com/librespot-org/librespot-java/tree/dev/api https://github.com/spocon/spocon """ import logging - -import components.player import jukebox.cfghandler import jukebox.plugs as plugs -import misc from .http_client import SpotifyHttpClient from .ws_client import SpotifyWsClient - from jukebox.NvManager import nv_manager logger = logging.getLogger('jb.PlayerSpot') @@ -46,7 +31,6 @@ def __init__(self): self.ws_client = SpotifyWsClient(host) self.ws_client.connect() - ### # TODO: The following functions have not been adopted to the new clients ### @@ -56,7 +40,6 @@ def exit(self): self.http_client.close() self.ws_client.close() - # def decode_2nd_swipe_option(self): # cfg_2nd_swipe_action = cfg.setndefault('playerspot', 'second_swipe_action', 'alias', value='none').lower() # if cfg_2nd_swipe_action not in [*self.second_swipe_action_dict.keys(), 'none', 'custom']: @@ -73,38 +56,18 @@ def exit(self): # custom_action['args'], # custom_action['kwargs']) - - # def _spot_status_poll(self): - # """ - # this method polls the status from spot and stores the important inforamtion in the music_player_status, - # it will repeat itself in the intervall specified by self.spot_status_poll_interval - # """ - - - # # Delete the volume key to avoid confusion - # # Volume is published via the 'volume' component! - # try: - # del self.spot_status['volume'] - # except KeyError: - # pass - # publishing.get_publisher().send('playerstatus', self.spot_status) - - @plugs.tag def play_uri(self, uri: str, play: bool = True): self.http_client.play_uri(uri, play) - @plugs.tag def play(self): self.http_client.play() - @plugs.tag def stop(self): self.pause(state=1) - @plugs.tag def pause(self, state: int = 1): if state == 1: @@ -112,17 +75,14 @@ def pause(self, state: int = 1): else: self.play() - @plugs.tag def prev(self): self.http_client.prev() - @plugs.tag def next(self): self.http_client.next() - @plugs.tag def seek(self, new_time: int): """ @@ -130,18 +90,15 @@ def seek(self, new_time: int): """ self.http_client.seek(new_time) - @plugs.tag def shuffle(self, val: bool): self.http_client.shuffle(val) - @plugs.tag def repeatmode(self, mode: str): # modes are none, track, context self.http_client.repeatmode(mode) - # @plugs.tag # def rewind(self): # """ @@ -255,7 +212,6 @@ def repeatmode(self, mode: str): # "name" : "Last Christmas" # }] - # :param playlist_uri: URI for the spotify playlist as string # """ # track_list = [] diff --git a/src/jukebox/components/playerspot/ws_client.py b/src/jukebox/components/playerspot/ws_client.py index da2ce9da6..ac6ec7406 100644 --- a/src/jukebox/components/playerspot/ws_client.py +++ b/src/jukebox/components/playerspot/ws_client.py @@ -1,19 +1,30 @@ +import json import logging import websocket import threading -logger = logging.getLogger('jb.SpotifyWsClient') +from jukebox import publishing + +logger = logging.getLogger("jb.SpotifyWsClient") class SpotifyWsClient: def __init__(self, host: str, port=24879): - self.protocol = 'ws' + self.protocol = "ws" self.host = host self.port = port - self.url = f'{self.protocol}://{self.host}:{self.port}/events' + self.url = f"{self.protocol}://{self.host}:{self.port}/events" self.socket = None self.thread = None + self.known_state = {"contextChanged": self.context_changed, + "trackChanged": self.track_changed, + "playbackPaused": self.playback_paused, + "playbackResumed": self.playback_resumed, + "trackSeeked": self.track_seeked, + "metadataAvailable": self.metadata_available, + } + self.spot_status = {} logger.debug("Spotify WS Client initialized") def connect(self): @@ -28,17 +39,53 @@ def connect(self): self.thread.daemon = True self.thread.start() - logger.debug(f'Websocket connection established to {self.url}') + logger.debug(f"Websocket connection established to {self.url}") def close(self): self.socket.close() - logger.debug('Websocket connection closed') + logger.debug("Websocket connection closed") def _on_message(self, socket, message): - logger.debug(f'Websocket message received: {message}') + logger.debug(f"Websocket message received: {message}") + converted_message = json.loads(message) + event = converted_message["event"] + logger.debug(f"Event from Message: {event}") + if event in self.known_state.keys(): + func = self.known_state.get(event) + func(converted_message) + publishing.get_publisher().send('playerspotstatus', self.spot_status) def _on_close(self, socket): - logger.debug('Connection with websocket server closed') + logger.debug("Connection with websocket server closed") def _on_error(self, socket, error): - logger.error(f'Websocket error: {error}') + logger.error(f"Websocket error: {error}") + + def context_changed(self, message: dict): + logger.debug("contextChanged called") + self.spot_status["context-uri"] = message["uri"] + + def track_changed(self, message: dict): + logger.debug("trackChanged called") + self.spot_status["track-uri"] = message["uri"] + + def playback_paused(self, message: dict): + logger.debug("playbackPaused called") + self.spot_status["state"] = "paused" + self.spot_status["trackTime"] = message["trackTime"] + + def playback_resumed(self, message: dict): + logger.debug("playbackResumed called") + self.spot_status["state"] = "play" + self.spot_status["trackTime"] = message["trackTime"] + + def track_seeked(self, message: dict): + logger.debug("trackSeeked called") + self.spot_status["trackTime"] = message["trackTime"] + + def metadata_available(self, message: dict): + logger.debug("metadataAvailable called") + self.spot_status["title"] = message["track"]["name"] + self.spot_status["artist"] = message["track"]["artist"][0]["name"] + self.spot_status["album"] = message["track"]["album"]["name"] + self.spot_status["albumartist"] = message["track"]["album"]["artist"][0]["name"] From 9928012adea4ae822e68ad7cde37a7f12bc8503f Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Mon, 17 Jan 2022 14:23:39 +0100 Subject: [PATCH 034/109] Fixing bugs in Contributing.md --- CONTRIBUTING.md | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f9cf28c8..1f35d4a8e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ # Differences to Version 2 -The naming conventions have changed from Version 2 to Version 3. Do use the new naming convention! +The naming conventions have changed from Version 2 to Version 3. Do use the new naming convention! # Naming conventions @@ -26,8 +26,8 @@ The Jukebox core app is written entirely in Python. Therefore, we follow the [Py * **Documentation** * You are expected to write some Documentation. It's easy. **Very** easy actually with [Python Docstrings](https://www.geeksforgeeks.org/python-docstrings/) - * If you dare, you may add the python documentation reference to the Sphinx documentation build. But we are also ok with doing that for you - + * If you dare, you may add the python documentation reference to the Sphinx documentation build. But we are also ok with doing that for you + # Structure of files and folders Inside the root folder or the repo, these folders are important: @@ -38,25 +38,25 @@ Inside the root folder or the repo, these folders are important: * contains the Python packages that are loaded using the plugin interface * `src/webapp` * contains the Web Interface -* `src/docs/sphinx` - * contains the documentation sources and build flow using Sphinx +* `src/docs/sphinx` + * contains the documentation sources and build flow using Sphinx All folders on all hierarchy levels starting with `scratch*` are ignored by git and flake8. These are intended -as local, temporary scratch areas. +as local, temporary scratch areas. # How to contribute Contributors have played a bigger role over time to keep Phoniebox on the edge of innovation :) -We want to keep it as easy as possible to contribute changes that get things working in your environment. +We want to keep it as easy as possible to contribute changes that get things working in your environment. There are a few guidelines that we need contributors to follow so that we can have a chance of keeping on top of things. Development for Version 3 is done on the git branch `future3/develop`. How to move to that branch, see below. For bug fixes and improvements just open an issue or PR as described below. If you plan to port -a feature from Version 2.X or implement a new feature, it is advisable to contact us first. In this case, +a feature from Version 2.X or implement a new feature, it is advisable to contact us first. In this case, also open an issue describing what you are planning to do. We will just check that nobody else is already -on the subject. We are looking forward to your work. Check the current +on the subject. We are looking forward to your work. Check the current [feature list](file:///home/chris/PycharmProjects/RPi-Jukebox-RFID/docs/sphinx/_build/html/featurelist.html#) for available features and work in progress. @@ -64,8 +64,8 @@ for available features and work in progress. * Make sure you have a [GitHub account](https://github.com/signup/free) * Open an issue if one does not already exist - * Mark the issue with the `future3` label. This is important to us, to distinguish between the versions. - Version 2 will continue to live for quite a while. + * Mark the issue with the `future3` label. This is important to us, to distinguish between the versions. + Version 2 will continue to live for quite a while. * Clearly describe the issue including steps to reproduce when it is a bug * Make sure you fill in the earliest version that you know has the issue * By default this will get you to the `future3/master` branch. You will move to the `future3/develop` branch, do this: @@ -78,8 +78,8 @@ git reset --hard origin/future3/develop git pull ~~~ -The preferred way of code contributions are [pull requests (follow this link for a small howto)](https://www.digitalocean.com/community/tutorials/how-to-create-a-pull-request-on-github). -And, ideally pull requests use the "running code" on the `future3/develop` branch of your Phoniebox. +The preferred way of code contributions are [pull requests (follow this link for a small howto)](https://www.digitalocean.com/community/tutorials/how-to-create-a-pull-request-on-github). +And, ideally pull requests use the "running code" on the `future3/develop` branch of your Phoniebox. Alternatively, feel free to post tweaks, suggestions and snippets in the ["issues" section](https://github.com/MiczFlor/RPi-Jukebox-RFID/issues). @@ -110,7 +110,7 @@ respectively. As new commits appear on Github you want to stay on the edge - especially if you are continuing to contribute. From time to time, you will need to update the Web App or the dependencies. To find out when, we provide a -git hook script. To activate simply copy it in the git hook folder +git hook script. To activate simply copy it in the git hook folder ~~~ cp .githooks/post-merge .git/hooks/. @@ -122,12 +122,12 @@ Run the checks below on the code. Fix those issues! Or you are running in delays We provide git hooks for those checks for convenience. To activate ~~~ -cp .githooks/pre-commit` .git/hooks/. -~~~ +cp .githooks/pre-commit .git/hooks/. +~~~ ### Python Code -If you touched *any* Python file (even if only for fixing spelling errors), run flake8 in the top-level folder. +If you touched *any* Python file (even if only for fixing spelling errors), run flake8 in the top-level folder. It contains out setup file. ~~~ @@ -135,12 +135,12 @@ $ cd /home/pi/RPi-Jukebox-RFID $ ./run_flake8.sh ~~~ -If you are convinced some issue should not apply to your case or would require extensive re-coding, that could be OK. -Let us know in the pull request - we will look at it. +If you are convinced some issue should not apply to your case or would require extensive re-coding, that could be OK. +Let us know in the pull request - we will look at it. ### Documentation -When adding or improving documentation, build the documentation and look at it locally. +When adding or improving documentation, build the documentation and look at it locally. If you are contributing to existing Python modules, be aware that these are already included in the documentation flow. Also run through this step in this case! Fix all warnings! @@ -179,8 +179,8 @@ to detect in advance. If the code change results in a test failure, we will make our best effort to correct the error. If a fix cannot be determined and committed within 24 hours of its discovery, the commit(s) responsible _may_ be reverted, at the -discretion of the committer and Phonie maintainers. -The original contributor will be notified of the revert. +discretion of the committer and Phonie maintainers. +The original contributor will be notified of the revert. ### Summary From cbe474901ac017e13ea96685590f6de8975bbc80 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Wed, 19 Jan 2022 00:25:45 +0100 Subject: [PATCH 035/109] Abstract implementation of Player Factory --- src/jukebox/components/players/__init__.py | 38 +++++++ .../components/players/mpd/__init__.py | 26 +++++ .../components/players/spotify/__init__.py | 29 ++++++ .../components/players/spotify/http_client.py | 98 +++++++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 src/jukebox/components/players/__init__.py create mode 100644 src/jukebox/components/players/mpd/__init__.py create mode 100644 src/jukebox/components/players/spotify/__init__.py create mode 100644 src/jukebox/components/players/spotify/http_client.py diff --git a/src/jukebox/components/players/__init__.py b/src/jukebox/components/players/__init__.py new file mode 100644 index 000000000..3f92be8da --- /dev/null +++ b/src/jukebox/components/players/__init__.py @@ -0,0 +1,38 @@ +import logging +import jukebox.cfghandler +import jukebox.plugs as plugin + +from .mpd import MpdPlayerBuilder +from .spotify import SpotifyPlayerBuilder + +logger = logging.getLogger('jb.player') +cfg = jukebox.cfghandler.get_handler('jukebox') + + +class PlayerFactory: + def __init__(self): + self._builders = {} + + def register_builder(self, key, builder): + self._builders[key] = builder + + def create(self, key, **kwargs): + builder = self._builders.get(key) + if not builder: + raise ValueError(key) + return builder(**kwargs) + +factory: PlayerFactory + + +@plugin.initialize +def initialize(): + global players + players = PlayerFactory() + players.register_builder('Spotify', SpotifyPlayerBuilder) + players.register_builder('MPD', MpdPlayerBuilder) + + +@plugin.register +def play_single(player: str, uri: str): + players.get(player).play_single(uri) diff --git a/src/jukebox/components/players/mpd/__init__.py b/src/jukebox/components/players/mpd/__init__.py new file mode 100644 index 000000000..38d431b8e --- /dev/null +++ b/src/jukebox/components/players/mpd/__init__.py @@ -0,0 +1,26 @@ +import logging +import mpd + +logger = logging.getLogger('jb.players.mpd') + +# MPD Interface +class MpdPlayer: + def __init__(self): + logger.debug('Init MPD') + self.mpd_client = mpd.MPDClient() # This is pseudo code, not functionl yet + + def play_single(self, uri: str): + self.mpd_client.clear() + self.mpd_client.addid(uri) + self.mpd_client.play() + + +class MpdPlayerBuilder: + def __init__(self): + self._instance = None + + def __call__(self, **_ignored): + if not self._instance: + self._instance = MpdPlayer() + + return self._instance diff --git a/src/jukebox/components/players/spotify/__init__.py b/src/jukebox/components/players/spotify/__init__.py new file mode 100644 index 000000000..f509bbddd --- /dev/null +++ b/src/jukebox/components/players/spotify/__init__.py @@ -0,0 +1,29 @@ +import logging +import jukebox.cfghandler + +from .http_client import SpotifyHttpClient + +logger = logging.getLogger('jb.players.spotify') +cfg = jukebox.cfghandler.get_handler('jukebox') + +# Spotify Interface +class SpotifyPlayer: + def __init__(self): + logger.debug('Init Spotify') + host = cfg.getn('playerspot', 'host') + self.http_client = SpotifyHttpClient(host) + + def play_single(self, uri: str): + play = True + self.http_client.play_uri(uri, play) + + +class SpotifyPlayerBuilder: + def __init__(self): + self._instance = None + + def __call__(self, **_ignored): + if not self._instance: + self._instance = SpotifyPlayer() + + return self._instance diff --git a/src/jukebox/components/players/spotify/http_client.py b/src/jukebox/components/players/spotify/http_client.py new file mode 100644 index 000000000..e4e65a60b --- /dev/null +++ b/src/jukebox/components/players/spotify/http_client.py @@ -0,0 +1,98 @@ +import logging +import requests +from requests.adapters import HTTPAdapter +import urllib +from urllib3.util.retry import Retry + +logger = logging.getLogger('jb.SpotifyHttpClient') + + +class SpotifyHttpClient: + def __init__(self, host: str, port=24879): + self.protocol = 'http' + self.host = host + self.port = port + self.authority = f'{self.protocol}://{self.host}:{self.port}' + + self.session = requests.Session() + retries = Retry( + total=5, + backoff_factor=5, + status_forcelist=[500, 502, 503, 504] + ) + + self.session.mount( + self.protocol + '://', + HTTPAdapter(max_retries=retries) + ) + self.session.headers.update({'content-type': 'application/json'}) + logger.debug(f'Spotify HTTP Client initialized. Will connect to {self.authority}') + + def close(self): + logger.debug("Exiting Spotify HTTP session") + self._post_request('/instance/close') + + def _request(self, request_func, path: str): + try: + url = urllib.parse.urljoin(self.authority, path) + logger.debug(f'Requesting "{self.authority}"') + + response = request_func(url) + response.raise_for_status() + + except requests.HTTPError as http_error: + response = {} + logger.error(f'HTTPError: {http_error}') + + except Exception as error: + response = {} + logger.error(f'Error {error}') + + return response + + def _get_request(self, path: str): + response = self._request(self.session.get, path) + return response + + def _post_request(self, path: str): + response = self._request(self.session.post, path) + return response + + def play_uri(self, uri: str, play: bool = True): + json = self._post_request(f'/player/load?uri={uri}&play={play}') + return json + + def play(self): + json = self._post_request('/player/resume') + return json + + def pause(self): + json = self._post_request('/player/pause') + return json + + def prev(self): + json = self._post_request('/player/prev') + return json + + def next(self): + json = self._post_request('/player/next') + return json + + def seek(self, new_time: int): + json = self._post_request(f'/player/seek?pos={new_time}') + return json + + def shuffle(self, val: bool): + json = self._post_request(f'/player/shuffle?val={1 if val else 0}') + return json + + def repeatmode(self, mode: str): + if mode == 'repeat': + repeat_state = 'context' + elif mode == 'single': + repeat_state = 'track' + else: + repeat_state = 'none' + + json = self._post_request(f'/player/repeat?val={repeat_state}') + return json From e12a53bbca5e6c02b3a0f3d936ed47dc6b9eae77 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Wed, 19 Jan 2022 10:36:29 +0100 Subject: [PATCH 036/109] Update factory naming --- src/jukebox/components/players/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/jukebox/components/players/__init__.py b/src/jukebox/components/players/__init__.py index 3f92be8da..9e523b206 100644 --- a/src/jukebox/components/players/__init__.py +++ b/src/jukebox/components/players/__init__.py @@ -5,11 +5,11 @@ from .mpd import MpdPlayerBuilder from .spotify import SpotifyPlayerBuilder -logger = logging.getLogger('jb.player') +logger = logging.getLogger('jb.players') cfg = jukebox.cfghandler.get_handler('jukebox') -class PlayerFactory: +class PlayersFactory: def __init__(self): self._builders = {} @@ -22,13 +22,13 @@ def create(self, key, **kwargs): raise ValueError(key) return builder(**kwargs) -factory: PlayerFactory +factory: PlayersFactory @plugin.initialize def initialize(): global players - players = PlayerFactory() + players = PlayersFactory() players.register_builder('Spotify', SpotifyPlayerBuilder) players.register_builder('MPD', MpdPlayerBuilder) From 5bf3b40631d066f625f75efc00860ed867d03793 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Wed, 19 Jan 2022 22:03:45 +0100 Subject: [PATCH 037/109] Fix builder init --- src/jukebox/components/players/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/jukebox/components/players/__init__.py b/src/jukebox/components/players/__init__.py index 9e523b206..cfd1d8732 100644 --- a/src/jukebox/components/players/__init__.py +++ b/src/jukebox/components/players/__init__.py @@ -22,6 +22,9 @@ def create(self, key, **kwargs): raise ValueError(key) return builder(**kwargs) + def get(self, service_id, **kwargs): + return self.create(service_id, **kwargs) + factory: PlayersFactory @@ -29,8 +32,8 @@ def create(self, key, **kwargs): def initialize(): global players players = PlayersFactory() - players.register_builder('Spotify', SpotifyPlayerBuilder) - players.register_builder('MPD', MpdPlayerBuilder) + players.register_builder('Spotify', SpotifyPlayerBuilder()) + players.register_builder('MPD', MpdPlayerBuilder()) @plugin.register From d0f64f40a5ea13ee6008d248ca25bf4d6d40ed49 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Thu, 20 Jan 2022 19:01:09 +0100 Subject: [PATCH 038/109] Introduce PlayerStatus class and WS client for Spotify --- src/jukebox/components/players/__init__.py | 90 ++++- .../components/players/player_status.py | 41 ++ .../components/players/spotify/__init__.py | 81 +++- .../components/players/spotify/http_client.py | 17 +- .../components/players/spotify/ws_client.py | 99 +++++ src/jukebox/components/playerspot/__init__.py | 362 ------------------ .../components/playerspot/http_client.py | 98 ----- .../components/playerspot/ws_client.py | 91 ----- src/webapp/src/config.js | 1 + 9 files changed, 307 insertions(+), 573 deletions(-) create mode 100644 src/jukebox/components/players/player_status.py create mode 100644 src/jukebox/components/players/spotify/ws_client.py delete mode 100644 src/jukebox/components/playerspot/__init__.py delete mode 100644 src/jukebox/components/playerspot/http_client.py delete mode 100644 src/jukebox/components/playerspot/ws_client.py diff --git a/src/jukebox/components/players/__init__.py b/src/jukebox/components/players/__init__.py index cfd1d8732..51c14b127 100644 --- a/src/jukebox/components/players/__init__.py +++ b/src/jukebox/components/players/__init__.py @@ -2,7 +2,8 @@ import jukebox.cfghandler import jukebox.plugs as plugin -from .mpd import MpdPlayerBuilder +from .player_status import PlayerStatus +# from .mpd import MpdPlayerBuilder from .spotify import SpotifyPlayerBuilder logger = logging.getLogger('jb.players') @@ -22,8 +23,8 @@ def create(self, key, **kwargs): raise ValueError(key) return builder(**kwargs) - def get(self, service_id, **kwargs): - return self.create(service_id, **kwargs) + def get(self, player_name, **kwargs): + return self.create(player_name, **kwargs) factory: PlayersFactory @@ -31,11 +32,90 @@ def get(self, service_id, **kwargs): @plugin.initialize def initialize(): global players + player_status = PlayerStatus() players = PlayersFactory() - players.register_builder('Spotify', SpotifyPlayerBuilder()) - players.register_builder('MPD', MpdPlayerBuilder()) + players.register_builder('Spotify', SpotifyPlayerBuilder(player_status)) + # players.register_builder('MPD', MpdPlayerBuilder()) + + +@plugin.atexit +def atexit(**ignored_kwargs): + global players + for player in players.keys(): + players.get(player).exit() @plugin.register def play_single(player: str, uri: str): + """Play a single song""" players.get(player).play_single(uri) + + +# TODO: Currently not implemented for MPD +@plugin.register +def play_playlist(player: str, uri: str): + """Play a playlist""" + if player == 'Spotify': + players.get(player).play_playlist(uri) + + +@plugin.register +def play_album(player: str, album: str, albumartist: str = None): + """Play an album""" + if player == 'MPD': + if not albumartist: + return logger.error('Missing arguments for MPD operation, skipping operation') + + return players.get(player).play_album(album, albumartist) + + if player == 'Spotify': + return players.get(player).play_album(uri = album) + + +@plugin.register +def play_folder(player: str, folder: str): + """Play a folder""" + if player == 'MPD': + players.get(player).play_folder(folder) + + +@plugin.register +def play(player: str): + """Start playing the current song""" + players.get(player).play() + + +@plugin.register +def pause(player: str): + """Pause playback""" + players.get(player).pause() + + +@plugin.register +def prev(player: str): + """Skip to previous track""" + players.get(player).prev() + + +@plugin.register +def next(player: str): + """Skip to next track""" + players.get(player).next() + + +@plugin.register +def shuffle(player: str, value: int = -1): + """Toggle or set shuffle (-1 toggle, 0 no suffle, 1 shuffle)""" + players.get(player).shuffle(value) + + +@plugin.register +def repeat(player: str, value: int = -1): + """Toggle or set repeat (-1 toggle, 0 no repeat, 1 context, 2 single)""" + players.get(player).repeat(value) + + +@plugin.register +def seek(player: str, pos: int = 0): + """Seek to a position of the current song in ms""" + players.get(player).seek(pos) diff --git a/src/jukebox/components/players/player_status.py b/src/jukebox/components/players/player_status.py new file mode 100644 index 000000000..f0e297924 --- /dev/null +++ b/src/jukebox/components/players/player_status.py @@ -0,0 +1,41 @@ +import logging +import jukebox.cfghandler +from jukebox import publishing + +logger = logging.getLogger('jb.players.player_status') +cfg = jukebox.cfghandler.get_handler('jukebox') + +class PlayerStatus: + ATTRS = [ + 'player', + 'playing', # bool + 'shuffle', + 'repeat', + 'trackid', # was `songid` before + 'title', + 'artist', + 'albumartist', + 'album', + 'timeTotal', # was `elapsed` before + 'timeElapsed', # was `duration` before + 'file', # required for MPD // check if really is required + ] + + def __init__(self): + self._player_status = dict.fromkeys(self.ATTRS) + + + def update(self, **kwargs): + for key, value in kwargs.items(): + if key in self.ATTRS: + self._player_status[key] = value + + self.publish() + + + def publish(self): + logger.debug(f'Published: {self._player_status}') + return publishing.get_publisher().send( + 'player_status', + self._player_status + ) diff --git a/src/jukebox/components/players/spotify/__init__.py b/src/jukebox/components/players/spotify/__init__.py index f509bbddd..5cb4303b4 100644 --- a/src/jukebox/components/players/spotify/__init__.py +++ b/src/jukebox/components/players/spotify/__init__.py @@ -2,28 +2,99 @@ import jukebox.cfghandler from .http_client import SpotifyHttpClient +from .ws_client import SpotifyWsClient logger = logging.getLogger('jb.players.spotify') cfg = jukebox.cfghandler.get_handler('jukebox') # Spotify Interface class SpotifyPlayer: - def __init__(self): + def __init__(self, player_status): logger.debug('Init Spotify') host = cfg.getn('playerspot', 'host') + self.player_status = player_status + self.http_client = SpotifyHttpClient(host) + self.ws_client = SpotifyWsClient( + host = host, + player_status = self.player_status + ) + self.ws_client.connect() + + def exit(self): + logger.debug('Exiting Spotify ...') + self.http_client.close() + self.ws_client.close() + + + # TODO: Stop playout after the song + # Spotify would continue automatically def play_single(self, uri: str): - play = True - self.http_client.play_uri(uri, play) + if not uri.startswith('spotify:track:'): + return logger.error('Provided URI does not match a single track') + + self.http_client.play_uri(uri) + + + def play_album(self, uri: str): + if not uri.startswith('spotify:album:'): + return logger.error('Provided ID does not match an album URI') + + self.http_client.play_uri(uri) + + + def play_playlist(self, uri: str): + if not id.startswith('spotify:playlist:'): + return logger.error('Provided URI does not match a playlist') + + self.http_client.play_uri(uri) + + + def play(self): + self.http_client.play() + + + def pause(self): + self.http_client.pause() + + + def prev(self): + self.http_client.prev() + + + def next(self): + self.http_client.next() + + + def shuffle(self, value: int = -1): + if value > -1: + return self.http_client.shuffle(value) + # TODO: Get status first and determine current shuffle state + # else: + # return self.http_client.shuffle(value) + + def repeat(self, value: int = -1): + if value == 0: + state = 'none' + elif value == 1: + state = 'context' + elif value == 2: + state = 'track' + else: + # TODO: Get status first and determine current repeat state + state = 'none' + + return self.http_client.repeat(value) class SpotifyPlayerBuilder: - def __init__(self): + def __init__(self, player_status): self._instance = None + self._player_status = player_status def __call__(self, **_ignored): if not self._instance: - self._instance = SpotifyPlayer() + self._instance = SpotifyPlayer(self._player_status) return self._instance diff --git a/src/jukebox/components/players/spotify/http_client.py b/src/jukebox/components/players/spotify/http_client.py index e4e65a60b..44e59159f 100644 --- a/src/jukebox/components/players/spotify/http_client.py +++ b/src/jukebox/components/players/spotify/http_client.py @@ -58,8 +58,8 @@ def _post_request(self, path: str): response = self._request(self.session.post, path) return response - def play_uri(self, uri: str, play: bool = True): - json = self._post_request(f'/player/load?uri={uri}&play={play}') + def play_uri(self, uri: str, play: bool = True, shuffle: bool = False): + json = self._post_request(f'/player/load?uri={uri}&play={play}&shuffle={shuffle}') return json def play(self): @@ -83,16 +83,9 @@ def seek(self, new_time: int): return json def shuffle(self, val: bool): - json = self._post_request(f'/player/shuffle?val={1 if val else 0}') + json = self._post_request(f'/player/shuffle?val={val}') return json - def repeatmode(self, mode: str): - if mode == 'repeat': - repeat_state = 'context' - elif mode == 'single': - repeat_state = 'track' - else: - repeat_state = 'none' - - json = self._post_request(f'/player/repeat?val={repeat_state}') + def repeat(self, val: str): + json = self._post_request(f'/player/repeat?val={val}') return json diff --git a/src/jukebox/components/players/spotify/ws_client.py b/src/jukebox/components/players/spotify/ws_client.py new file mode 100644 index 000000000..d2640ccaa --- /dev/null +++ b/src/jukebox/components/players/spotify/ws_client.py @@ -0,0 +1,99 @@ +import json +import logging +import websocket +import threading + +logger = logging.getLogger("jb.SpotifyWsClient") + +class SpotifyWsClient: + def __init__(self, host: str, player_status, port: int = 24879): + self.protocol = 'ws' + self.host = host + self.port = port + self.url = f'{self.protocol}://{self.host}:{self.port}/events' + + self.player_status = player_status + + self.socket = None + self.thread = None + + self.state_callbacks = { + 'contextChanged': self.context_changed, + 'trackChanged': self.track_changed, + 'playbackPaused': self.playback_paused, + 'playbackResumed': self.playback_resumed, + 'trackSeeked': self.track_seeked, + 'metadataAvailable': self.metadata_available, + } + + logger.debug('Spotify WS Client initialized') + + def connect(self): + websocket.enableTrace(True) + self.socket = websocket.WebSocketApp( + self.url, + on_close = self._on_close, + on_error = self._on_error, + on_message = self._on_message + ) + self.thread = threading.Thread(target = self.socket.run_forever) + self.thread.daemon = True + self.thread.start() + + logger.debug(f'Websocket connection established to {self.url}') + + def close(self): + self.socket.close() + + def _on_message(self, socket, message): + logger.debug(f'Message received: {message}') + data = json.loads(message) + event = data['event'] + + callback = self.state_callbacks.get(event) + if not callback: + raise ValueError(event) + + callback(data) + + def _on_close(self, socket): + logger.debug('Connection with websocket server closed') + + def _on_error(self, socket, error): + logger.error(f'Websocket error: {error}') + + def metadata_available(self, message: dict): + self.player_status.update( + player = 'Spotify', # TODO: Should this be done differently? + title = message['track']['name'], + artist = message['track']['artist'][0]['name'], + album = message['track']['album']['name'], + albumartist = message['track']['album']['artist'][0]['name'], + totalTime = message['track']['duration'] + ) + + def context_changed(self, message: dict): + # ['context-uri'] = message['uri'] + pass + + def track_changed(self, message: dict): + # ['track-uri'] = message['uri'] + pass + + def playback_paused(self, message: dict): + self.player_status.update( + playing = False, + timeElapsed = message['trackTime'] + ) + + def playback_resumed(self, message: dict): + self.player_status.update( + playing = True, + timeElapsed = message['trackTime'] + ) + + def track_seeked(self, message: dict): + self.player_status.update( + timeElapsed = message['trackTime'] + ) + diff --git a/src/jukebox/components/playerspot/__init__.py b/src/jukebox/components/playerspot/__init__.py deleted file mode 100644 index f82f0a0f8..000000000 --- a/src/jukebox/components/playerspot/__init__.py +++ /dev/null @@ -1,362 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Package for interfacing with the librespot-java API - -References: -https://github.com/librespot-org/librespot-java -https://github.com/librespot-org/librespot-java/tree/dev/api -https://github.com/spocon/spocon -""" -import logging -import jukebox.cfghandler -import jukebox.plugs as plugs - -from .http_client import SpotifyHttpClient -from .ws_client import SpotifyWsClient -from jukebox.NvManager import nv_manager - -logger = logging.getLogger('jb.PlayerSpot') -cfg = jukebox.cfghandler.get_handler('jukebox') - - -class PlayerSpot: - """Interface to librespot-java API""" - - def __init__(self): - self.nvm = nv_manager() - host = cfg.getn('playerspot', 'host') - self.http_client = SpotifyHttpClient(host) - self.http_client.play_uri('spotify:track:4xkOaSrkexMciUUogZKVTS') - - self.ws_client = SpotifyWsClient(host) - self.ws_client.connect() - - ### - # TODO: The following functions have not been adopted to the new clients - ### - def exit(self): - logger.debug("Exit routine of playerspot started") - self.nvm.save_all() - self.http_client.close() - self.ws_client.close() - - # def decode_2nd_swipe_option(self): - # cfg_2nd_swipe_action = cfg.setndefault('playerspot', 'second_swipe_action', 'alias', value='none').lower() - # if cfg_2nd_swipe_action not in [*self.second_swipe_action_dict.keys(), 'none', 'custom']: - # logger.error(f"Config spot.second_swipe_action must be one of " - # f"{[*self.second_swipe_action_dict.keys(), 'none', 'custom']}. Ignore setting.") - # if cfg_2nd_swipe_action in self.second_swipe_action_dict.keys(): - # self.second_swipe_action = self.second_swipe_action_dict[cfg_2nd_swipe_action] - # if cfg_2nd_swipe_action == 'custom': - # custom_action = utils.decode_rpc_call(cfg.getn('playerspot', 'second_swipe_action', default=None)) - # self.second_swipe_action = functools.partial(plugs.call_ignore_errors, - # custom_action['package'], - # custom_action['plugin'], - # custom_action['method'], - # custom_action['args'], - # custom_action['kwargs']) - - @plugs.tag - def play_uri(self, uri: str, play: bool = True): - self.http_client.play_uri(uri, play) - - @plugs.tag - def play(self): - self.http_client.play() - - @plugs.tag - def stop(self): - self.pause(state=1) - - @plugs.tag - def pause(self, state: int = 1): - if state == 1: - self.http_client.pause() - else: - self.play() - - @plugs.tag - def prev(self): - self.http_client.prev() - - @plugs.tag - def next(self): - self.http_client.next() - - @plugs.tag - def seek(self, new_time: int): - """ - Seek to a given position in milliseconds specified by new_time - """ - self.http_client.seek(new_time) - - @plugs.tag - def shuffle(self, val: bool): - self.http_client.shuffle(val) - - @plugs.tag - def repeatmode(self, mode: str): - # modes are none, track, context - self.http_client.repeatmode(mode) - - # @plugs.tag - # def rewind(self): - # """ - # Re-start current playlist from first track - - # Note: Will not re-read folder config, but leave settings untouched""" - # logger.debug("Rewind") - # self.seek(0) - - # @plugs.tag - # def replay(self): - # """ - # Re-start playing the last-played playlist - - # Will reset settings to folder config""" - # logger.debug("Replay") - # self.play_playlist(self.music_player_status['player_status']['last_played_folder']) - - # @plugs.tag - # def toggle(self): - # """Toggle pause state, i.e. do a pause / resume depending on current state""" - # logger.debug("Toggle") - # api_path = "/player/play-pause" - # spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - # self._handle_http_errors(spot_response) - - # @plugs.tag - # def replay_if_stopped(self): - # """ - # Re-start playing the last-played folder unless playlist is still playing - - # .. note:: To me this seems much like the behaviour of play, - # but we keep it as it is specifically implemented in box 2.X""" - # logger.debug("replay_if_stopped") - # if self.spot_status['state'] == 'stop': - # self.play_playlist(self.music_player_status['player_status']['last_played_folder']) - - # @plugs.tag - # def get_current_song(self, param): - # return self.spot_status - - # @plugs.tag - # def map_filename_to_playlist_pos(self, filename): - # raise NotImplementedError - - # @plugs.tag - # def remove(self): - # raise NotImplementedError - - # @plugs.tag - # def move(self): - # raise NotImplementedError - - # @plugs.tag - # def play_single(self, song_uri: str): - # self.check_uri(song_uri) - # api_path = f"/player/repeat?uri={song_uri}&play=true" - # spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - # self._handle_http_errors(spot_response) - - # @plugs.tag - # def resume(self): - # songpos = self.current_folder_status["CURRENTSONGPOS"] - # self.seek(songpos) - # self.play() - - # @plugs.tag - # def play_card(self, folder: str, recursive: bool = False): - # """ - # Main entry point for trigger music playing from RFID reader. Decodes second swipe options before - # playing folder content - - # Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action - # accordingly. - - # :param folder: Folder path relative to music library path - # :param recursive: Add folder recursively - # """ - # # Developers notes: - # # - # # * 2nd swipe trigger may also happen, if playlist has already stopped playing - # # --> Generally, treat as first swipe - # # * 2nd swipe of same Card ID may also happen if a different song has been played in between from WebUI - # # --> Treat as first swipe - # # * With place-not-swipe: Card is placed on reader until playlist expieres. Music stop. Card is removed and - # # placed again on the reader: Should be like first swipe - # # * TODO: last_played_folder is restored after box start, so first swipe of last played card may look like - # # second swipe - # # - # logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}") - # logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}") - # is_second_swipe = self.music_player_status['player_status']['last_played_folder'] == folder - # if self.second_swipe_action is not None and is_second_swipe: - # logger.debug('Calling second swipe action') - # self.second_swipe_action() - # else: - # logger.debug('Calling first swipe action') - # self.play_playlist(folder, recursive) - - # @plugs.tag - # def get_playlist_content(self, playlist_uri: str): - # """ - # Get the spotify playlist as content list with spotify id - - # Example: - # ["artists" : [{ - # "id" : "5lpH0xAS4fVfLkACg9DAuM", - # "name" : "Wham!" - # }], - # "id" : "2FRnf9qhLbvw8fu4IBXx78", - # "name" : "Last Christmas" - # }] - - # :param playlist_uri: URI for the spotify playlist as string - # """ - # track_list = [] - # api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name,id,artists(name,id))" - # playlist_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - # if self._handle_http_errors(playlist_response): - # playlist_dict = playlist_response.json() - # for elem in playlist_dict["items"]: - # track_list.append(elem["track"]) - # return track_list - - # @plugs.tag - # def play_playlist(self, playlist_uri: str, recursive: bool = False) -> None: - # """ - # Playback a spotify playlist. - - # :param playlist_uri: Folder path relative to music library path - # :param recursive: Add folder recursively - # """ - # logger.debug("play_folder") - # logger.info(f"Play spotify playlist: '{playlist_uri}'") - - # self.music_player_status['player_status']['last_played_folder'] = playlist_uri - - # self.current_folder_status = self.music_player_status['audio_folder_status'].get(playlist_uri) - # if self.current_folder_status is None: - # self.current_folder_status = self.music_player_status['audio_folder_status'][playlist_uri] = {} - - # self.load(self.current_folder_status, start_playing=True) - - # @plugs.tag - # def play_album(self, album_uri: str): - # """ - # Playback a album from spotify. - - # :param album_uri: Album URI from spotify - # """ - # logger.debug("play_album") - # logger.info(f"Play album: '{album_uri}'") - # self.load(album_uri, start_playing=True) - - # @plugs.tag - # def queue_load(self, folder): - # # There was something playing before -> stop and save state - # # Clear the queue - # # Check / Create the playlist - # # - not needed if same folder is played again? Buf what if files have been added a mpc update has been run? - # # - and this a re-trigger to start the new playlist - # # If we must update the playlists everytime anyway why write them to file and not just keep them in the queue? - # # Load the playlist - # # Get folder config and apply settings - # pass - - # @plugs.tag - # def playerstatus(self): - # return self.spot_status - - # @plugs.tag - # def playlistinfo(self): - # """ - # Returns a list of all songs in the playlist - # """ - # track_list = [] - # playlist_uri = self.get_playback_state()["context"]["uri"] - # api_path = f"/web-api/v1/playlists/{playlist_uri}/tracks?fields=items(track(name))" - # playlist_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - # if self._handle_http_errors(playlist_response): - # playlist_response.raise_for_status() - # playlist_dict = playlist_response.json() - # for elem in playlist_dict["items"]: - # track_list.append(elem["track"]["name"]) - # return track_list - - # @plugs.tag - # def list_albums(self): - # # ToDo: Do we need this for spotify? - # raise NotImplementedError - - # @plugs.tag - # def list_song_by_artist_and_album(self, albumartist, album): - # # ToDo: Do we need this for spotify? - # raise NotImplementedError - - # def get_volume(self): - # """ - # Get the current volume - - # For volume control do not use directly, but use through the plugin 'volume', - # as the user may have configured a volume control manager other than Spotify""" - # return self.get_playback_state()["device"]["volume_percent"] - - # def set_volume(self, volume): - # """ - # Set the volume - - # For volume control do not use directly, but use through the plugin 'volume', - # as the user may have configured a volume control manager other than Spotify""" - # api_path = f"/player/volume?volume_percent={volume}" - # spot_response = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - # self._handle_http_errors(spot_response) - # return self.get_volume() - - # def get_playback_state(self): - # playback_state_dict = {} - # api_path = "/web-api/v1/me/player" - # playback_state = requests_session.post(urllib.parse.urljoin(self.spot_api_baseurl, api_path)) - # if self._handle_http_errors(playback_state): - # playback_state_dict = playback_state.json() - # return playback_state_dict if playback_state_dict["device"]["id"] == self.device_info["device_id"] else {} - - # @staticmethod - # def check_uri(uri: str): - # """ - # Checking that the uri has the right syntax - # """ - # check_list = uri.split(":") - # valid_play_type = ["album", "track"] - # if check_list[1] == "user": - # assert len(check_list) == 5, f"URI {uri} is missing information." - # assert check_list[0] == "spotify", f"URI {uri} does not start with spotify" - # assert check_list[1] == "user", f"URI {uri} does not contain a valid type on pos 2" - # assert type(check_list[2]) is int, f"URI {uri} does not contain the right user id on pos 3" - # assert check_list[3] == "playlist", f"URI {uri} does not contain a valid type playlist on pos 4" - - # else: - # assert len(check_list) == 3, f"URI {uri} is missing information." - # assert check_list[0] == "spotify", f"URI {uri} does not start with spotify" - # assert check_list[1] in valid_play_type, f"URI {uri} does not contain a valid type on pos 2" - - -# --------------------------------------------------------------------------- -# Plugin Initializer / Finalizer -# --------------------------------------------------------------------------- - -playerspot_ctrl: PlayerSpot - - -@plugs.initialize -def initialize(): - global playerspot_ctrl - playerspot_ctrl = PlayerSpot() - plugs.register(playerspot_ctrl, name='ctrl') - - -@plugs.atexit -def atexit(**ignored_kwargs): - global playerspot_ctrl - return playerspot_ctrl.exit() diff --git a/src/jukebox/components/playerspot/http_client.py b/src/jukebox/components/playerspot/http_client.py deleted file mode 100644 index e4e65a60b..000000000 --- a/src/jukebox/components/playerspot/http_client.py +++ /dev/null @@ -1,98 +0,0 @@ -import logging -import requests -from requests.adapters import HTTPAdapter -import urllib -from urllib3.util.retry import Retry - -logger = logging.getLogger('jb.SpotifyHttpClient') - - -class SpotifyHttpClient: - def __init__(self, host: str, port=24879): - self.protocol = 'http' - self.host = host - self.port = port - self.authority = f'{self.protocol}://{self.host}:{self.port}' - - self.session = requests.Session() - retries = Retry( - total=5, - backoff_factor=5, - status_forcelist=[500, 502, 503, 504] - ) - - self.session.mount( - self.protocol + '://', - HTTPAdapter(max_retries=retries) - ) - self.session.headers.update({'content-type': 'application/json'}) - logger.debug(f'Spotify HTTP Client initialized. Will connect to {self.authority}') - - def close(self): - logger.debug("Exiting Spotify HTTP session") - self._post_request('/instance/close') - - def _request(self, request_func, path: str): - try: - url = urllib.parse.urljoin(self.authority, path) - logger.debug(f'Requesting "{self.authority}"') - - response = request_func(url) - response.raise_for_status() - - except requests.HTTPError as http_error: - response = {} - logger.error(f'HTTPError: {http_error}') - - except Exception as error: - response = {} - logger.error(f'Error {error}') - - return response - - def _get_request(self, path: str): - response = self._request(self.session.get, path) - return response - - def _post_request(self, path: str): - response = self._request(self.session.post, path) - return response - - def play_uri(self, uri: str, play: bool = True): - json = self._post_request(f'/player/load?uri={uri}&play={play}') - return json - - def play(self): - json = self._post_request('/player/resume') - return json - - def pause(self): - json = self._post_request('/player/pause') - return json - - def prev(self): - json = self._post_request('/player/prev') - return json - - def next(self): - json = self._post_request('/player/next') - return json - - def seek(self, new_time: int): - json = self._post_request(f'/player/seek?pos={new_time}') - return json - - def shuffle(self, val: bool): - json = self._post_request(f'/player/shuffle?val={1 if val else 0}') - return json - - def repeatmode(self, mode: str): - if mode == 'repeat': - repeat_state = 'context' - elif mode == 'single': - repeat_state = 'track' - else: - repeat_state = 'none' - - json = self._post_request(f'/player/repeat?val={repeat_state}') - return json diff --git a/src/jukebox/components/playerspot/ws_client.py b/src/jukebox/components/playerspot/ws_client.py deleted file mode 100644 index ac6ec7406..000000000 --- a/src/jukebox/components/playerspot/ws_client.py +++ /dev/null @@ -1,91 +0,0 @@ -import json -import logging -import websocket -import threading - -from jukebox import publishing - -logger = logging.getLogger("jb.SpotifyWsClient") - - -class SpotifyWsClient: - - def __init__(self, host: str, port=24879): - self.protocol = "ws" - self.host = host - self.port = port - self.url = f"{self.protocol}://{self.host}:{self.port}/events" - self.socket = None - self.thread = None - self.known_state = {"contextChanged": self.context_changed, - "trackChanged": self.track_changed, - "playbackPaused": self.playback_paused, - "playbackResumed": self.playback_resumed, - "trackSeeked": self.track_seeked, - "metadataAvailable": self.metadata_available, - } - self.spot_status = {} - logger.debug("Spotify WS Client initialized") - - def connect(self): - websocket.enableTrace(True) - self.socket = websocket.WebSocketApp( - self.url, - on_close=self._on_close, - on_error=self._on_error, - on_message=self._on_message - ) - self.thread = threading.Thread(target=self.socket.run_forever) - self.thread.daemon = True - self.thread.start() - - logger.debug(f"Websocket connection established to {self.url}") - - def close(self): - self.socket.close() - logger.debug("Websocket connection closed") - - def _on_message(self, socket, message): - logger.debug(f"Websocket message received: {message}") - converted_message = json.loads(message) - event = converted_message["event"] - logger.debug(f"Event from Message: {event}") - if event in self.known_state.keys(): - func = self.known_state.get(event) - func(converted_message) - publishing.get_publisher().send('playerspotstatus', self.spot_status) - - def _on_close(self, socket): - logger.debug("Connection with websocket server closed") - - def _on_error(self, socket, error): - logger.error(f"Websocket error: {error}") - - def context_changed(self, message: dict): - logger.debug("contextChanged called") - self.spot_status["context-uri"] = message["uri"] - - def track_changed(self, message: dict): - logger.debug("trackChanged called") - self.spot_status["track-uri"] = message["uri"] - - def playback_paused(self, message: dict): - logger.debug("playbackPaused called") - self.spot_status["state"] = "paused" - self.spot_status["trackTime"] = message["trackTime"] - - def playback_resumed(self, message: dict): - logger.debug("playbackResumed called") - self.spot_status["state"] = "play" - self.spot_status["trackTime"] = message["trackTime"] - - def track_seeked(self, message: dict): - logger.debug("trackSeeked called") - self.spot_status["trackTime"] = message["trackTime"] - - def metadata_available(self, message: dict): - logger.debug("metadataAvailable called") - self.spot_status["title"] = message["track"]["name"] - self.spot_status["artist"] = message["track"]["artist"][0]["name"] - self.spot_status["album"] = message["track"]["album"]["name"] - self.spot_status["albumartist"] = message["track"]["album"]["artist"][0]["name"] diff --git a/src/webapp/src/config.js b/src/webapp/src/config.js index 5cd4c985a..ec4d5c1ce 100644 --- a/src/webapp/src/config.js +++ b/src/webapp/src/config.js @@ -13,6 +13,7 @@ const SUBSCRIPTIONS = [ 'host.timer.cputemp', 'host.temperature.cpu', 'playerstatus', + 'player_status', 'rfid.card_id', 'volume.level', ]; From f7d5dff2911e1b5a73abeca1fe835b9e7a7329db Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Fri, 21 Jan 2022 00:22:27 +0100 Subject: [PATCH 039/109] Show Title, Album, CoverArt in webapp, prepare controls --- src/jukebox/components/players/__init__.py | 1 + .../components/players/player_status.py | 33 ++++---- .../components/players/spotify/__init__.py | 2 +- .../components/players/spotify/http_client.py | 9 ++- .../components/players/spotify/ws_client.py | 50 ++++++------ src/webapp/src/commands/index.js | 35 ++++---- src/webapp/src/components/Player/controls.js | 81 ++++++++----------- src/webapp/src/components/Player/cover.js | 2 +- src/webapp/src/components/Player/display.js | 14 ++-- src/webapp/src/components/Player/index.js | 29 +++---- 10 files changed, 119 insertions(+), 137 deletions(-) diff --git a/src/jukebox/components/players/__init__.py b/src/jukebox/components/players/__init__.py index 51c14b127..3b5f2d13d 100644 --- a/src/jukebox/components/players/__init__.py +++ b/src/jukebox/components/players/__init__.py @@ -33,6 +33,7 @@ def get(self, player_name, **kwargs): def initialize(): global players player_status = PlayerStatus() + player_status.publish() players = PlayersFactory() players.register_builder('Spotify', SpotifyPlayerBuilder(player_status)) # players.register_builder('MPD', MpdPlayerBuilder()) diff --git a/src/jukebox/components/players/player_status.py b/src/jukebox/components/players/player_status.py index f0e297924..24a4d74ce 100644 --- a/src/jukebox/components/players/player_status.py +++ b/src/jukebox/components/players/player_status.py @@ -6,28 +6,29 @@ cfg = jukebox.cfghandler.get_handler('jukebox') class PlayerStatus: - ATTRS = [ - 'player', - 'playing', # bool - 'shuffle', - 'repeat', - 'trackid', # was `songid` before - 'title', - 'artist', - 'albumartist', - 'album', - 'timeTotal', # was `elapsed` before - 'timeElapsed', # was `duration` before - 'file', # required for MPD // check if really is required - ] + STATUS = { + 'player': '', + 'playing': False, + 'shuffle': False, + 'repeat': 0, + 'trackid': '', + 'title': '', + 'artist': '', + 'albumartist': '', + 'album': '', + 'timeTotal': 0, + 'timeElapsed': 0, + 'file': '', # required for MPD // check if really is required + 'coverArt': '' + } def __init__(self): - self._player_status = dict.fromkeys(self.ATTRS) + self._player_status = self.STATUS def update(self, **kwargs): for key, value in kwargs.items(): - if key in self.ATTRS: + if key in self.STATUS: self._player_status[key] = value self.publish() diff --git a/src/jukebox/components/players/spotify/__init__.py b/src/jukebox/components/players/spotify/__init__.py index 5cb4303b4..7b23a30c9 100644 --- a/src/jukebox/components/players/spotify/__init__.py +++ b/src/jukebox/components/players/spotify/__init__.py @@ -45,7 +45,7 @@ def play_album(self, uri: str): def play_playlist(self, uri: str): - if not id.startswith('spotify:playlist:'): + if not uri.startswith('spotify:playlist:'): return logger.error('Provided URI does not match a playlist') self.http_client.play_uri(uri) diff --git a/src/jukebox/components/players/spotify/http_client.py b/src/jukebox/components/players/spotify/http_client.py index 44e59159f..7b25b1782 100644 --- a/src/jukebox/components/players/spotify/http_client.py +++ b/src/jukebox/components/players/spotify/http_client.py @@ -1,3 +1,4 @@ +import json import logging import requests from requests.adapters import HTTPAdapter @@ -48,7 +49,7 @@ def _request(self, request_func, path: str): response = {} logger.error(f'Error {error}') - return response + return json.loads(response.content) def _get_request(self, path: str): response = self._request(self.session.get, path) @@ -58,6 +59,12 @@ def _post_request(self, path: str): response = self._request(self.session.post, path) return response + def get_status(self): + # json = self._get_request('/web-api/v1//me/player') + json = self._post_request('/player/current') + logger.debug(json) + return json + def play_uri(self, uri: str, play: bool = True, shuffle: bool = False): json = self._post_request(f'/player/load?uri={uri}&play={play}&shuffle={shuffle}') return json diff --git a/src/jukebox/components/players/spotify/ws_client.py b/src/jukebox/components/players/spotify/ws_client.py index d2640ccaa..0a288ed35 100644 --- a/src/jukebox/components/players/spotify/ws_client.py +++ b/src/jukebox/components/players/spotify/ws_client.py @@ -18,12 +18,12 @@ def __init__(self, host: str, player_status, port: int = 24879): self.thread = None self.state_callbacks = { - 'contextChanged': self.context_changed, - 'trackChanged': self.track_changed, 'playbackPaused': self.playback_paused, 'playbackResumed': self.playback_resumed, + 'playbackHaltStateChanged': self.playback_halted, 'trackSeeked': self.track_seeked, 'metadataAvailable': self.metadata_available, + 'inactiveSession': self.inactive_session, } logger.debug('Spotify WS Client initialized') @@ -46,7 +46,7 @@ def close(self): self.socket.close() def _on_message(self, socket, message): - logger.debug(f'Message received: {message}') + logger.debug(f'_on_message: {message}') data = json.loads(message) event = data['event'] @@ -62,38 +62,42 @@ def _on_close(self, socket): def _on_error(self, socket, error): logger.error(f'Websocket error: {error}') - def metadata_available(self, message: dict): + def metadata_available(self, data: dict): self.player_status.update( player = 'Spotify', # TODO: Should this be done differently? - title = message['track']['name'], - artist = message['track']['artist'][0]['name'], - album = message['track']['album']['name'], - albumartist = message['track']['album']['artist'][0]['name'], - totalTime = message['track']['duration'] + trackid = data['track']['gid'], + title = data['track']['name'], + artist = data['track']['artist'][0]['name'], + album = data['track']['album']['name'], + albumartist = data['track']['album']['artist'][0]['name'], + totalTime = data['track']['duration'], + coverArt = data['track']['album']['coverGroup']['image'][2]['fileId'].lower() ) - def context_changed(self, message: dict): - # ['context-uri'] = message['uri'] - pass - - def track_changed(self, message: dict): - # ['track-uri'] = message['uri'] - pass - - def playback_paused(self, message: dict): + def playback_paused(self, data: dict): self.player_status.update( playing = False, - timeElapsed = message['trackTime'] + timeElapsed = data['trackTime'] ) - def playback_resumed(self, message: dict): + def playback_resumed(self, data: dict): self.player_status.update( playing = True, - timeElapsed = message['trackTime'] + timeElapsed = data['trackTime'] + ) + + def playback_halted(self, data: dict): + self.player_status.update( + playing = data['halted'], + timeElapsed = data['trackTime'] ) - def track_seeked(self, message: dict): + def track_seeked(self, data: dict): self.player_status.update( - timeElapsed = message['trackTime'] + timeElapsed = data['trackTime'] ) + # When Spotify session is routed to another device, + # the local session goes inactive + def inactive_session(self, data: dict): + self.player_status.update(playing = False) diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index 40db693e7..ffe2bf54b 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -50,9 +50,8 @@ const commands = { // Player Actions play: { - _package: 'player', - plugin: 'ctrl', - method: 'play', + _package: 'players', + plugin: 'play', }, play_single: { _package: 'player', @@ -73,34 +72,28 @@ const commands = { argKeys: ['albumartist', 'album'] }, pause: { - _package: 'player', - plugin: 'ctrl', - method: 'pause', + _package: 'players', + plugin: 'pause', }, previous: { - _package: 'player', - plugin: 'ctrl', - method: 'prev', + _package: 'players', + plugin: 'prev', }, next: { - _package: 'player', - plugin: 'ctrl', - method: 'next', + _package: 'players', + plugin: 'next', }, shuffle: { - _package: 'player', - plugin: 'ctrl', - method: 'shuffle', + _package: 'players', + plugin: 'shuffle', }, repeat: { - _package: 'player', - plugin: 'ctrl', - method: 'repeatmode', + _package: 'players', + plugin: 'repeat', }, seek: { - _package: 'player', - plugin: 'ctrl', - method: 'seek', + _package: 'players', + plugin: 'seek', }, // Volume diff --git a/src/webapp/src/components/Player/controls.js b/src/webapp/src/components/Player/controls.js index dc433cef8..df88eb9b3 100644 --- a/src/webapp/src/components/Player/controls.js +++ b/src/webapp/src/components/Player/controls.js @@ -1,4 +1,4 @@ -import React, { memo, useContext, useEffect } from 'react'; +import React, { memo, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import Grid from '@mui/material/Grid'; @@ -11,61 +11,48 @@ import ShuffleRoundedIcon from '@mui/icons-material/ShuffleRounded'; import RepeatRoundedIcon from '@mui/icons-material/RepeatRounded'; import RepeatOneRoundedIcon from '@mui/icons-material/RepeatOneRounded'; -import PlayerContext from '../../context/player/context'; +import PubSubContext from '../../context/pubsub/context'; import request from '../../utils/request'; // TODO: Should be broken up in sub-modules const Controls = () => { const { t } = useTranslation(); - const { - state, - setState, - } = useContext(PlayerContext); + const { state: { player_status } } = useContext(PubSubContext); const { - isPlaying, - playerstatus, - isShuffle, - isRepeat, - isSingle, - songIsScheduled - } = state; + playing, + shuffle, + repeat, + trackid, + } = player_status; const toggleShuffle = () => { - request('shuffle', { random: !isShuffle }); + request('shuffle', { val: !shuffle }); } const toggleRepeat = () => { - let mode = null; - if (!isRepeat && !isSingle) mode = 'repeat'; - if (isRepeat && !isSingle) mode = 'single'; - - request('repeat', { mode }); + let mode = repeat + 1; + if (mode > 2) mode = -1; + request('repeat', { val: mode }); } - useEffect(() => { - setState({ - ...state, - isPlaying: playerstatus?.state === 'play' ? true : false, - songIsScheduled: playerstatus?.songid ? true : false, - isShuffle: playerstatus?.random === '1' ? true : false, - isRepeat: playerstatus?.repeat === '1' ? true : false, - isSingle: playerstatus?.single === '1' ? true : false, - }); - }, [playerstatus]); - const iconStyles = { padding: '7px' }; const labelShuffle = () => ( - isShuffle + shuffle ? t('player.controls.shuffle.deactivate') : t('player.controls.shuffle.activate') ); + // Toggle or set repeat (-1 toggle, 0 no repeat, 1 context, 2 single) const labelRepeat = () => { - if (!isRepeat) return t('player.controls.repeat.activate'); - if (isRepeat && !isSingle) return t('player.controls.repeat.activate-single'); - if (isRepeat && isSingle) return t('player.controls.repeat.deactivate'); + const labels = [ + t('player.controls.repeat.activate'), + t('player.controls.repeat.activate-single'), + t('player.controls.repeat.deactivate'), + ]; + + return labels[repeat]; }; return ( @@ -80,7 +67,7 @@ const Controls = () => { {/* Shuffle */} { {/* Skip previous track */} request('previous')} + disabled={!trackid} + onClick={() => request('previous')} size="large" sx={iconStyles} title={t('player.controls.skip')} @@ -102,11 +89,11 @@ const Controls = () => { {/* Play */} - {!isPlaying && + {!playing && request('play')} - disabled={!songIsScheduled} + onClick={() => request('play')} + disabled={!trackid} size="large" sx={iconStyles} title={t('player.controls.play')} @@ -115,10 +102,10 @@ const Controls = () => { } {/* Pause */} - {isPlaying && + {playing && request('pause')} + onClick={() => request('pause')} size="large" sx={iconStyles} title={t('player.controls.pause')} @@ -130,8 +117,8 @@ const Controls = () => { {/* Skip next track */} request('next')} + disabled={!trackid} + onClick={() => request('next')} size="large" sx={iconStyles} title={t('player.controls.next')} @@ -142,18 +129,18 @@ const Controls = () => { {/* Repeat */} 0 ? 'primary' : undefined} onClick={toggleRepeat} size="large" sx={iconStyles} title={labelRepeat()} > { - !isSingle && + repeat < 2 && } { - isSingle && + repeat === 2 && } diff --git a/src/webapp/src/components/Player/cover.js b/src/webapp/src/components/Player/cover.js index e44e38e6b..d20133acb 100644 --- a/src/webapp/src/components/Player/cover.js +++ b/src/webapp/src/components/Player/cover.js @@ -32,7 +32,7 @@ const Cover = ({ coverImage }) => { {coverImage && {t('player.cover.title')}} {!coverImage && diff --git a/src/webapp/src/components/Player/display.js b/src/webapp/src/components/Player/display.js index 446eba349..6c1acff16 100644 --- a/src/webapp/src/components/Player/display.js +++ b/src/webapp/src/components/Player/display.js @@ -1,14 +1,14 @@ import React, { useContext } from 'react'; import { useTranslation } from 'react-i18next'; -import PlayerContext from '../../context/player/context'; +import PubSubContext from '../../context/pubsub/context'; import Grid from '@mui/material/Grid'; import Typography from '@mui/material/Typography'; const Display = () => { const { t } = useTranslation(); - const { state: { playerstatus } } = useContext(PlayerContext); + const { state: { player_status } } = useContext(PubSubContext); const dontBreak = { whiteSpace: 'nowrap', @@ -20,15 +20,15 @@ const Display = () => { return ( - {playerstatus?.songid - ? (playerstatus?.title || t('player.display.unknown-title')) + {player_status.trackid + ? (player_status.title || t('player.display.unknown-title')) : t('player.display.no-song-in-queue') } - {playerstatus?.songid && (playerstatus?.artist || t('player.display.unknown-artist')) } - - {playerstatus?.songid && (playerstatus?.album || playerstatus?.file) } + {player_status.trackid && (player_status.artist || t('player.display.unknown-artist')) } + + {player_status.trackid && (player_status.album || player_status.file) } ); diff --git a/src/webapp/src/components/Player/index.js b/src/webapp/src/components/Player/index.js index 5efa5e10a..2a3a0f3c8 100644 --- a/src/webapp/src/components/Player/index.js +++ b/src/webapp/src/components/Player/index.js @@ -8,35 +8,24 @@ import Display from './display'; import SeekBar from './seekbar'; import Volume from './volume'; -import PlayerContext from '../../context/player/context'; import PubSubContext from '../../context/pubsub/context'; -import request from '../../utils/request'; -import { pluginIsLoaded } from '../../utils/utils'; const Player = () => { - const { state: { playerstatus } } = useContext(PlayerContext); - const { state: { 'core.plugins.loaded': plugins } } = useContext(PubSubContext); - const { file } = playerstatus || {}; + const { state: { player_status } } = useContext(PubSubContext); const [coverImage, setCoverImage] = useState(undefined); const [backgroundImage, setBackgroundImage] = useState('none'); useEffect(() => { - const getMusicCover = async () => { - const { result } = await request('musicCoverByFilenameAsBase64', { audio_src: file }); - if (result) { - setCoverImage(result); - setBackgroundImage([ - 'linear-gradient(to bottom, rgba(18, 18, 18, 0.7), rgba(18, 18, 18, 1))', - `url(data:image/jpeg;base64,${result})` - ].join(',')); - }; + if (player_status?.coverArt) { + const coverImageSrc = `https://i.scdn.co/image/${player_status.coverArt}`; + setCoverImage(coverImageSrc); + setBackgroundImage([ + 'linear-gradient(to bottom, rgba(18, 18, 18, 0.7), rgba(18, 18, 18, 1))', + `url(${coverImageSrc})` + ].join(',')); } - - if (pluginIsLoaded(plugins, 'music_cover_art') && file) { - getMusicCover(); - } - }, [file, plugins]); + }, [player_status]); return ( Date: Fri, 21 Jan 2022 18:46:45 +0100 Subject: [PATCH 040/109] Introduce Player Counter on React side --- .../components/players/player_status.py | 18 +++---- .../components/players/spotify/ws_client.py | 19 +++++--- src/webapp/src/components/Player/seekbar.js | 35 ++++++++------ .../src/components/Settings/timers/timer.js | 4 +- .../src/components/general/Countdown.js | 31 ------------ src/webapp/src/components/general/Counter.js | 48 +++++++++++++++++++ src/webapp/src/components/general/index.js | 4 +- 7 files changed, 94 insertions(+), 65 deletions(-) delete mode 100644 src/webapp/src/components/general/Countdown.js create mode 100644 src/webapp/src/components/general/Counter.js diff --git a/src/jukebox/components/players/player_status.py b/src/jukebox/components/players/player_status.py index 24a4d74ce..c7c7124e2 100644 --- a/src/jukebox/components/players/player_status.py +++ b/src/jukebox/components/players/player_status.py @@ -7,19 +7,19 @@ class PlayerStatus: STATUS = { - 'player': '', + 'album': '', + 'albumartist': '', + 'artist': '', + 'coverArt': '', + 'duration': 0, + 'elapsed': 0, + 'file': '', # required for MPD // check if really is required + 'player': '', # TODO: TBD, Spotify or MPD 'playing': False, 'shuffle': False, 'repeat': 0, - 'trackid': '', 'title': '', - 'artist': '', - 'albumartist': '', - 'album': '', - 'timeTotal': 0, - 'timeElapsed': 0, - 'file': '', # required for MPD // check if really is required - 'coverArt': '' + 'trackid': '', } def __init__(self): diff --git a/src/jukebox/components/players/spotify/ws_client.py b/src/jukebox/components/players/spotify/ws_client.py index 0a288ed35..0a81c547e 100644 --- a/src/jukebox/components/players/spotify/ws_client.py +++ b/src/jukebox/components/players/spotify/ws_client.py @@ -62,7 +62,14 @@ def _on_close(self, socket): def _on_error(self, socket, error): logger.error(f'Websocket error: {error}') + + # We only care about seconds, not ms as provided by Spotify + def _round_time_to_seconds(self, time): + return '{:.1f}'.format(time / 1000) + def metadata_available(self, data: dict): + cover_art = data['track']['album']['coverGroup']['image'][2]['fileId'].lower() + self.player_status.update( player = 'Spotify', # TODO: Should this be done differently? trackid = data['track']['gid'], @@ -70,31 +77,31 @@ def metadata_available(self, data: dict): artist = data['track']['artist'][0]['name'], album = data['track']['album']['name'], albumartist = data['track']['album']['artist'][0]['name'], - totalTime = data['track']['duration'], - coverArt = data['track']['album']['coverGroup']['image'][2]['fileId'].lower() + duration = self._round_time_to_seconds(data['track']['duration']), + coverArt =cover_art ) def playback_paused(self, data: dict): self.player_status.update( playing = False, - timeElapsed = data['trackTime'] + elapsed = self._round_time_to_seconds(data['trackTime']) ) def playback_resumed(self, data: dict): self.player_status.update( playing = True, - timeElapsed = data['trackTime'] + elapsed = self._round_time_to_seconds(data['trackTime']) ) def playback_halted(self, data: dict): self.player_status.update( playing = data['halted'], - timeElapsed = data['trackTime'] + elapsed = self._round_time_to_seconds(data['trackTime']) ) def track_seeked(self, data: dict): self.player_status.update( - timeElapsed = data['trackTime'] + elapsed = self._round_time_to_seconds(data['trackTime']) ) # When Spotify session is routed to another device, diff --git a/src/webapp/src/components/Player/seekbar.js b/src/webapp/src/components/Player/seekbar.js index aab3682ee..1717a053b 100644 --- a/src/webapp/src/components/Player/seekbar.js +++ b/src/webapp/src/components/Player/seekbar.js @@ -1,28 +1,28 @@ import React, { useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import PlayerContext from '../../context/player/context'; +import Grid from '@mui/material/Grid'; +import Slider from '@mui/material/Slider'; +import Typography from '@mui/material/Typography'; + +import PubSubContext from '../../context/pubsub/context'; +import { Counter } from '../general'; import { progressToTime, timeToProgress, toHHMMSS, } from '../../utils/utils'; - -import Grid from '@mui/material/Grid'; -import Slider from '@mui/material/Slider'; -import Typography from '@mui/material/Typography'; - import request from '../../utils/request'; const SeekBar = () => { const { t } = useTranslation(); - const { state } = useContext(PlayerContext); - const { playerstatus } = state; + const { state: { player_status } } = useContext(PubSubContext); + const [isRunning, setIsRunning] = useState(player_status?.playing); const [isSeeking, setIsSeeking] = useState(false); const [progress, setProgress] = useState(0); - const [timeElapsed, setTimeElapsed] = useState(parseFloat(playerstatus?.elapsed) || 0); - const timeTotal = parseFloat(playerstatus?.duration) || 0; + const [timeElapsed, setTimeElapsed] = useState(parseFloat(player_status?.elapsed) || 0); + const timeTotal = parseFloat(player_status?.duration) || 0; const updateTimeAndProgress = (newTime) => { setTimeElapsed(newTime); @@ -35,7 +35,7 @@ const SeekBar = () => { updateTimeAndProgress(progressToTime(timeTotal, newPosition)); }; - // Only send commend to backend when user committed to new position + // Only send command to backend when user committed to new position // We don't send it while seeking (too many useless requests) const playFromNewTime = () => { request('seek', { new_time: timeElapsed.toFixed(3) }); @@ -46,16 +46,16 @@ const SeekBar = () => { // Avoid updating time and progress when user is seeking to new // song position if (!isSeeking) { - updateTimeAndProgress(playerstatus?.elapsed); + updateTimeAndProgress(player_status?.elapsed); } - }, [playerstatus]); + }, [player_status]); return <> { > - {toHHMMSS(parseInt(timeElapsed))} + diff --git a/src/webapp/src/components/Settings/timers/timer.js b/src/webapp/src/components/Settings/timers/timer.js index 20b990edc..c6323fd4b 100644 --- a/src/webapp/src/components/Settings/timers/timer.js +++ b/src/webapp/src/components/Settings/timers/timer.js @@ -11,7 +11,7 @@ import { useTheme } from '@mui/material/styles'; import request from '../../../utils/request'; import { - Countdown, + Counter, SliderTimer } from '../../general'; @@ -86,7 +86,7 @@ const Timer = ({ type }) => { marginLeft: '0', }}> {status?.enabled && - setEnabled(false)} stringEnded={t('settings.timers.ended')} diff --git a/src/webapp/src/components/general/Countdown.js b/src/webapp/src/components/general/Countdown.js deleted file mode 100644 index 84e91cb88..000000000 --- a/src/webapp/src/components/general/Countdown.js +++ /dev/null @@ -1,31 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { toHHMMSS } from '../../utils/utils'; - -const Countdown = ({ onEnd, seconds, stringEnded = undefined }) => { - // This is required to avoid async updates on unmounted compomemts - // https://github.com/facebook/react/issues/14227 - const isMounted = useRef(null); - const [time, setTime] = useState(seconds); - - const onEndCallback = useCallback(() => onEnd(), [onEnd]); - - useEffect(() => { - isMounted.current = true; - - if (time === 0) return onEndCallback(); - setTimeout(() => { - if (isMounted.current) setTime(time - 1) - }, 1000); - - return () => { - isMounted.current = false; - } - }, [onEndCallback, time]); - - if (time) return toHHMMSS(time); - if (stringEnded) return stringEnded; - return toHHMMSS(0); -} - -export default Countdown; diff --git a/src/webapp/src/components/general/Counter.js b/src/webapp/src/components/general/Counter.js new file mode 100644 index 000000000..26c5884c7 --- /dev/null +++ b/src/webapp/src/components/general/Counter.js @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { toHHMMSS } from '../../utils/utils'; + +const Counter = ({ + seconds, + direction = 'down', + end = 0, + paused = false, + onEnd = () => {}, + stringEnded = undefined +}) => { + // This is required to avoid async updates on unmounted components + // https://github.com/facebook/react/issues/14227 + const isMounted = useRef(null); + const [time, setTime] = useState(parseInt(seconds)); + + const onEndCallback = useCallback(() => onEnd(), [onEnd]); + + useEffect(() => { + isMounted.current = true; + + const summand = direction === 'down' ? -1 : 1; + + if (!paused) { + if (time >= end) return onEndCallback(); + setTimeout(() => { + if (isMounted.current) setTime(time + summand) + }, 1000); + } + + return () => { + isMounted.current = false; + } + }, [ + direction, + end, + onEndCallback, + paused, + time, + ]); + + if (time) return toHHMMSS(time); + if (stringEnded) return stringEnded; + return toHHMMSS(0); +} + +export default Counter; diff --git a/src/webapp/src/components/general/index.js b/src/webapp/src/components/general/index.js index a97998977..e4aedf07e 100644 --- a/src/webapp/src/components/general/index.js +++ b/src/webapp/src/components/general/index.js @@ -1,9 +1,9 @@ -import Countdown from "./Countdown" +import Counter from "./Counter" import SliderTimer from "./SliderTimer" import SwitchWithLoader from "./SwitchWithLoader" export { - Countdown, + Counter, SliderTimer, SwitchWithLoader, }; From df893570120febab5e3cc5f09e0a344a1e3d9d38 Mon Sep 17 00:00:00 2001 From: ChisSoc <75833833+ChisSoc@users.noreply.github.com> Date: Sat, 22 Jan 2022 23:34:30 +0100 Subject: [PATCH 041/109] Initial proposal for new player API with multi-backend handling --- src/jukebox/components/players/player_main.py | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 src/jukebox/components/players/player_main.py diff --git a/src/jukebox/components/players/player_main.py b/src/jukebox/components/players/player_main.py new file mode 100644 index 000000000..0e98a3907 --- /dev/null +++ b/src/jukebox/components/players/player_main.py @@ -0,0 +1,252 @@ +""" +Backend: + An interface to an player we use for playback, i.e. MDP or Spotify + +Flavor: + An abstraction layer between backend and API, to enable different types of playback contents with the same backend + With MPD, we can playback by album or disk folder. + We also use MPD to play back Podcasts (with a custom decoder int the middle) + This all needs different ways of retrieving and storing playlists, status, etc + But, we still want to use the same MPD instance + +PlayerCtrl: + Top-level player which abstracts the various flavors and backends. All play, stop etc commands controlling + current playback go through this instance. This is mandatory, as we may need to switch between player backends from + one playback to another + +Playlist: + Any kind of concatenated set of songs that somehow belong together and are indexed by a URI. + E.g.the content folder, the songs of an album, the music files of a podcast + +URI: + The link to a playlist. I.e. the path to a directory, the album_artist & album to identify the album + the path to a podcast link file, spotify uri file, ... + +Queue: + The currently active playlist as loaded in the backend + +How it works: + +The backends register the flavor(s) with the top-level player control. The WebApp goes through player.play(flavor, uri) to +trigger a playback. Function like next, prev also go through the player control - here the currently active flavor is automatically +selected by the player control. + +To get all playlists and/or playlist entries, the WebApp also goes through the player control - it is the same function +but takes different arguments depending on player control. And returns different results (possibly in different formats?) + +Displaying what can be played back: + +get_list: List all possible playlists (can be playlists only for a URI prefix - e.g. a folder) + +get_content: List all songs in a playlist + +Examples: + player.play(flavor=folder, uri='conni_backt') + > plays folder content of audiopath/conni_backt/* + + player.play(flavor=spotify, uri=lausemaus/leo_will_nicht_teilen.spotify') + > plays a spotify playlist for which the Spotify URI is in file lausemaus/leo_will_nicht_teilen.spotify + + player.play(flavor=album, album_artist=benjamin, album=ImZirkus) + > plays form MPD database the songs from the album that matches album_artist=benjamin, album=ImZirkus) + + player.get_list(flavor=folder, uri='.') + > [conni_backt, connie_zeltet] + + player.get_content(flavor=folder, uri='connie_backt') + > [01-song.mp3, 02-intro, ...] + + NOTE: list and get_content return not only names of files, but list of tuples which also contain path and filetype (dir, file) + + ... + +""" + +from typing import Dict, Callable, Optional + + +class PlayerFlavorEntry: + def __init__(self, flavor_name: str, + backend, + play_callable: Callable, + get_list_callable: Callable, + get_content_callable: Callable): + self._backend = backend + self._flavor_name = flavor_name + self._play = play_callable + self._list = get_list_callable + self._content = get_content_callable + + @property + def play(self): + return self._play + + @property + def get_list(self): + return self._list + + @property + def get_content(self): + return self._content + + def __getattr__(self, attr): + """Forward all not specially mapped function calls to the actual backend""" + return getattr(self.backend, attr) + + +class PlayerCtrl: + """The top-level player instance through which all calls go. Arbitrates between the different backends""" + + def __init__(self): + self._flavors: Dict[str, PlayerFlavorEntry] = {} + self._active: Optional[PlayerFlavorEntry] = None + + def register(self, flavor: str, backend, + play_callable: Callable, + get_list_callable: Callable, + get_content_callable: Callable): + self._flavors[flavor] = PlayerFlavorEntry(flavor, backend, play_callable, get_list_callable, get_content_callable) + + def play(self, flavor, check_second_swipe=False, **kwargs): + # Save the current state (if something is playing) + # Stop the current playback + # Decode card second swipe + # And finally play + self._active = self._flavors[flavor] + self._active.play(**kwargs) + + def stop(self): + # Save current state for resume functionality + self._save_state() + + self._active.stop() + + def next(self): + self._active.next() + + def get_queue(self): + self._active.get_queue() + + def _save_state(self): + # Get the backend to save the state of the current playlist to the URI's config file + self._active.save_queue_state_to_uri() + # Also need to save which backend and URI was currently playing to be able to restore it after reboot + pass + + +class BackendMPD: + """Example Backend for MPD - do the same for other backends""" + + # def play(self, uri): + # # Get URI state + # get_uri_state() + # # Apply last state + # # play + + # ---------------------------------- + # Stuff that replaces the current playlist and starts a new playback for URI + # + + def play_folder(self, uri, recursive=False): + pass + + def play_single(self, uri): + pass + + def play_album(self, album_artist: str, album: str): + pass + + def play_podcast(self, uri): + pass + + # ---------------------------------- + # Get lists of playlists (one for each flavor) + + def get_folder_list(self, uri): + """List folder contents (files and directories)""" + pass + + def get_album_list(self): + """Returns all albums in database""" + pass + + def get_podcast_list(self, uri): + """List all podcasts in directory :attr:`uri`""" + pass + + # ---------------------------------- + # Get all songs of a playlists (one function for each flavor) + + def get_folder_content(self, uri): + """Just to unify the API for all flavors""" + return self.get_folder_list(uri) + + def get_album_content(self, album_artist, album): + """Returns all song of an album""" + pass + + def get_podcast_content(self, uri): + """Lists playlist of one podcast file""" + pass + + # ---------------------------------- + # Stuff that controls current playback (i.e. moves around in the current playlist, termed "the queue") + + def next(self): + pass + + def seek(self, time): + """Seek to position :attr:`time` in current song""" + pass + + def jump(self, position): + """Jump to song at position is in the active playback queue""" + # Play song (id, uri, ##?) that is in current playlist + # but without changing the current playlist (i.e. like going next, next, next instead of play_single(URI) + pass + + # ---------------------------------- + # Stuff that modifies the queue or informs about it + # We do not allow modifying the queue at the moment + + def get_queue(self): + """Displays a list of all songs in the currently active playlist""" + pass + + # ---------------------------------- + # Modifying playback behaviour + + def set_queue_config(self, resume=None, random=None, single=None, loop=None): + """Sets the config for the currently active playback + + These settings will also be saved automatically to URI config!""" + pass + + def save_queue_state_to_uri(self): + """Save the current queue state (resume, random, ...) and current song position to the URI the queue was loaded from""" + # Get state (resume, ..., elapsed, current song) + # Save to database + pass + + # ---------------------------------- + # Modifying playlist's config independent of the current queue + + def set_playlist_config(self, uri, resume=None, random=None, single=None, loop=None): + """Change the config for a specific playlist w/o touching current playback""" + pass + + +def initialize(): + player = PlayerCtrl() + mpd = BackendMPD() + player.register('album', + mpd, + lambda album_artist, album, **ignored_kwargs: mpd.play_album(album_artist=album_artist, album=album), + lambda **ignored_kwargs: mpd.get_album_list(), + lambda album_artist, album, **ignored_kwargs: mpd.get_album_content(album_artist=album_artist, album=album)) + player.register('folder', + mpd, + lambda uri, recursive=False, **ignore_kwargs: mpd.play_folder(uri=uri, recursive=recursive), + lambda uri, **ignore_kwargs: mpd.get_folder_list(uri=uri), + lambda uri, **ignore_kwargs: mpd.get_folder_content(uri=uri)) + From 6e644664b50e7bf237fd654a7302921b7ababca6 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 28 Jan 2022 18:55:24 +0100 Subject: [PATCH 042/109] Flake8 fixes --- src/jukebox/components/players/__init__.py | 3 +- .../components/players/mpd/__init__.py | 3 +- .../components/players/player_status.py | 7 ++--- .../components/players/spotify/__init__.py | 31 +++++++------------ 4 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/jukebox/components/players/__init__.py b/src/jukebox/components/players/__init__.py index 3b5f2d13d..89cd6861c 100644 --- a/src/jukebox/components/players/__init__.py +++ b/src/jukebox/components/players/__init__.py @@ -26,6 +26,7 @@ def create(self, key, **kwargs): def get(self, player_name, **kwargs): return self.create(player_name, **kwargs) + factory: PlayersFactory @@ -70,7 +71,7 @@ def play_album(player: str, album: str, albumartist: str = None): return players.get(player).play_album(album, albumartist) if player == 'Spotify': - return players.get(player).play_album(uri = album) + return players.get(player).play_album(uri=album) @plugin.register diff --git a/src/jukebox/components/players/mpd/__init__.py b/src/jukebox/components/players/mpd/__init__.py index 38d431b8e..4a0e2c434 100644 --- a/src/jukebox/components/players/mpd/__init__.py +++ b/src/jukebox/components/players/mpd/__init__.py @@ -3,11 +3,12 @@ logger = logging.getLogger('jb.players.mpd') + # MPD Interface class MpdPlayer: def __init__(self): logger.debug('Init MPD') - self.mpd_client = mpd.MPDClient() # This is pseudo code, not functionl yet + self.mpd_client = mpd.MPDClient() # This is pseudo code, not functionl yet def play_single(self, uri: str): self.mpd_client.clear() diff --git a/src/jukebox/components/players/player_status.py b/src/jukebox/components/players/player_status.py index c7c7124e2..f5b9f476d 100644 --- a/src/jukebox/components/players/player_status.py +++ b/src/jukebox/components/players/player_status.py @@ -5,6 +5,7 @@ logger = logging.getLogger('jb.players.player_status') cfg = jukebox.cfghandler.get_handler('jukebox') + class PlayerStatus: STATUS = { 'album': '', @@ -13,8 +14,8 @@ class PlayerStatus: 'coverArt': '', 'duration': 0, 'elapsed': 0, - 'file': '', # required for MPD // check if really is required - 'player': '', # TODO: TBD, Spotify or MPD + 'file': '', # required for MPD // check if really is required + 'player': '', # TODO: TBD, Spotify or MPD 'playing': False, 'shuffle': False, 'repeat': 0, @@ -25,7 +26,6 @@ class PlayerStatus: def __init__(self): self._player_status = self.STATUS - def update(self, **kwargs): for key, value in kwargs.items(): if key in self.STATUS: @@ -33,7 +33,6 @@ def update(self, **kwargs): self.publish() - def publish(self): logger.debug(f'Published: {self._player_status}') return publishing.get_publisher().send( diff --git a/src/jukebox/components/players/spotify/__init__.py b/src/jukebox/components/players/spotify/__init__.py index 7b23a30c9..171a9ba88 100644 --- a/src/jukebox/components/players/spotify/__init__.py +++ b/src/jukebox/components/players/spotify/__init__.py @@ -7,6 +7,7 @@ logger = logging.getLogger('jb.players.spotify') cfg = jukebox.cfghandler.get_handler('jukebox') + # Spotify Interface class SpotifyPlayer: def __init__(self, player_status): @@ -17,8 +18,8 @@ def __init__(self, player_status): self.http_client = SpotifyHttpClient(host) self.ws_client = SpotifyWsClient( - host = host, - player_status = self.player_status + host=host, + player_status=self.player_status ) self.ws_client.connect() @@ -27,7 +28,6 @@ def exit(self): self.http_client.close() self.ws_client.close() - # TODO: Stop playout after the song # Spotify would continue automatically def play_single(self, uri: str): @@ -36,37 +36,30 @@ def play_single(self, uri: str): self.http_client.play_uri(uri) - def play_album(self, uri: str): if not uri.startswith('spotify:album:'): return logger.error('Provided ID does not match an album URI') self.http_client.play_uri(uri) - def play_playlist(self, uri: str): if not uri.startswith('spotify:playlist:'): return logger.error('Provided URI does not match a playlist') self.http_client.play_uri(uri) - def play(self): self.http_client.play() - def pause(self): self.http_client.pause() - def prev(self): self.http_client.prev() - def next(self): self.http_client.next() - def shuffle(self, value: int = -1): if value > -1: return self.http_client.shuffle(value) @@ -75,15 +68,15 @@ def shuffle(self, value: int = -1): # return self.http_client.shuffle(value) def repeat(self, value: int = -1): - if value == 0: - state = 'none' - elif value == 1: - state = 'context' - elif value == 2: - state = 'track' - else: - # TODO: Get status first and determine current repeat state - state = 'none' + # if value == 0: + # state = 'none' + # elif value == 1: + # state = 'context' + # elif value == 2: + # state = 'track' + # else: + # # TODO: Get status first and determine current repeat state + # state = 'none' return self.http_client.repeat(value) From 5847dad73ad27f49becf74c2b31f00f903613985 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 28 Jan 2022 19:01:13 +0100 Subject: [PATCH 043/109] Flake8 fixes #2 --- .../components/players/spotify/ws_client.py | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/jukebox/components/players/spotify/ws_client.py b/src/jukebox/components/players/spotify/ws_client.py index 0a81c547e..96e34015b 100644 --- a/src/jukebox/components/players/spotify/ws_client.py +++ b/src/jukebox/components/players/spotify/ws_client.py @@ -5,6 +5,7 @@ logger = logging.getLogger("jb.SpotifyWsClient") + class SpotifyWsClient: def __init__(self, host: str, player_status, port: int = 24879): self.protocol = 'ws' @@ -32,11 +33,11 @@ def connect(self): websocket.enableTrace(True) self.socket = websocket.WebSocketApp( self.url, - on_close = self._on_close, - on_error = self._on_error, - on_message = self._on_message + on_close=self._on_close, + on_error=self._on_error, + on_message=self._on_message ) - self.thread = threading.Thread(target = self.socket.run_forever) + self.thread = threading.Thread(target=self.socket.run_forever) self.thread.daemon = True self.thread.start() @@ -62,7 +63,6 @@ def _on_close(self, socket): def _on_error(self, socket, error): logger.error(f'Websocket error: {error}') - # We only care about seconds, not ms as provided by Spotify def _round_time_to_seconds(self, time): return '{:.1f}'.format(time / 1000) @@ -71,40 +71,40 @@ def metadata_available(self, data: dict): cover_art = data['track']['album']['coverGroup']['image'][2]['fileId'].lower() self.player_status.update( - player = 'Spotify', # TODO: Should this be done differently? - trackid = data['track']['gid'], - title = data['track']['name'], - artist = data['track']['artist'][0]['name'], - album = data['track']['album']['name'], - albumartist = data['track']['album']['artist'][0]['name'], - duration = self._round_time_to_seconds(data['track']['duration']), - coverArt =cover_art + player='Spotify', # TODO: Should this be done differently? + trackid=data['track']['gid'], + title=data['track']['name'], + artist=data['track']['artist'][0]['name'], + album=data['track']['album']['name'], + albumartist=data['track']['album']['artist'][0]['name'], + duration=self._round_time_to_seconds(data['track']['duration']), + coverArt=cover_art ) def playback_paused(self, data: dict): self.player_status.update( - playing = False, - elapsed = self._round_time_to_seconds(data['trackTime']) + playing=False, + elapsed=self._round_time_to_seconds(data['trackTime']) ) def playback_resumed(self, data: dict): self.player_status.update( - playing = True, - elapsed = self._round_time_to_seconds(data['trackTime']) + playing=True, + elapsed=self._round_time_to_seconds(data['trackTime']) ) def playback_halted(self, data: dict): self.player_status.update( - playing = data['halted'], - elapsed = self._round_time_to_seconds(data['trackTime']) + playing=data['halted'], + elapsed=self._round_time_to_seconds(data['trackTime']) ) def track_seeked(self, data: dict): self.player_status.update( - elapsed = self._round_time_to_seconds(data['trackTime']) + elapsed=self._round_time_to_seconds(data['trackTime']) ) # When Spotify session is routed to another device, # the local session goes inactive def inactive_session(self, data: dict): - self.player_status.update(playing = False) + self.player_status.update(playing=False) From 244a0cf100f9bffc70700876ed0a156c422cbfdf Mon Sep 17 00:00:00 2001 From: ChisSoc <75833833+ChisSoc@users.noreply.github.com> Date: Wed, 2 Feb 2022 22:11:01 +0100 Subject: [PATCH 044/109] Reworked multi-backend proposal + AsyncIO MPD stub --- .../default-settings/jukebox.default.yaml | 2 +- .../playern/backends/interfacing_mpd.py | 271 ++++++++++++++++++ .../components/playern/core/__init__.py | 113 ++++++++ .../components/playern/plugin/__init__.py | 70 +++++ 4 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 src/jukebox/components/playern/backends/interfacing_mpd.py create mode 100644 src/jukebox/components/playern/core/__init__.py create mode 100644 src/jukebox/components/playern/plugin/__init__.py diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index ee46961a6..0d9cb321d 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -12,7 +12,7 @@ modules: jingle: jingle jingle.alsawave: jingle.alsawave jingle.jinglemp3: jingle.jinglemp3 - player: playermpd + player: playern.plugin spotify: playerspot cards: rfid.cards rfid: rfid.reader diff --git a/src/jukebox/components/playern/backends/interfacing_mpd.py b/src/jukebox/components/playern/backends/interfacing_mpd.py new file mode 100644 index 000000000..20c7dd08d --- /dev/null +++ b/src/jukebox/components/playern/backends/interfacing_mpd.py @@ -0,0 +1,271 @@ +# Copyright: 2022 +# SPDX License Identifier: MIT License + +import asyncio +import logging +import os.path +import re + +import jukebox.plugs as plugin +import jukebox.cfghandler + +from mpd.asyncio import MPDClient + +logger = logging.getLogger('jb.mpd') +cfg = jukebox.cfghandler.get_handler('jukebox') + + +def sanitize(path: str): + return path.lstrip('./') + + +class MPDBackend: + + def __init__(self, event_loop): + self.client = MPDClient() + self.loop = event_loop + self.host = 'localhost' + self.port = '6600' + self._flavors = {'folder': self.play_folder, + 'album': self.play_album_uri, + 'albumartist': self.play_album_artist_uri, + 'file': self.play_file, + 'podcast': self.play_podcast, + 'livestream': self.play_livestream} + # TODO: If connect fails on first try this is non recoverable + self.connect() + # Start the status listener in an endless loop in the event loop + asyncio.run_coroutine_threadsafe(self._status_listener(), self.loop) + + async def _connect(self): + return await self.client.connect(self.host, self.port) + + def connect(self): + # May raise: mpd.base.ConnectionError: Can not send command to disconnected client + result = asyncio.run_coroutine_threadsafe(self._connect(), self.loop).result() + logger.debug(f"Connected to MPD version {self.client.mpd_version} @ {self.host}:{self.port}") + return result + + # ----------------------------------------------------- + # Check and update statues + + async def _status_listener(self): + """The endless status listener: updates the status whenever there is a change in one MPD subsystem""" + # Calls to logger do not work + # logger.debug("MPD Status Listener started") + async for subsystem in self.client.idle(): + # logger.debug("MPD: Idle change in", subsystem) + s = await self.client.status() + # logger.debug(f"MPD: New Status: {s.result()}") + print(f"MPD: New Status: {type(s)} // {s}") + # Now, do something with it ... + + async def _status(self): + return await self.client.status() + + @plugin.tag + def status(self): + """Refresh the current MPD status (by a manual, sync trigger)""" + f = asyncio.run_coroutine_threadsafe(self._status(), self.loop).result() + print(f"Status: {f}") + # Put it into unified structure and notify global player control + + # ----------------------------------------------------- + # Stuff that controls current playback (i.e. moves around in the current playlist, termed "the queue") + + async def _next(self): + return await self.client.next() + + def next(self): + logger.debug('Next') + return asyncio.run_coroutine_threadsafe(self._next(), self.loop).result() + + async def _prev(self): + return await self.client.next() + + def prev(self): + return asyncio.run_coroutine_threadsafe(self._prev(), self.loop).result() + + async def _stop(self): + return await self.client.stop() + + def stop(self): + return asyncio.run_coroutine_threadsafe(self._stop(), self.loop).result() + + # ----------------------------------------------------- + # Volume control (for developing only) + + async def _volume(self, value): + return await self.client.setvol(value) + + @plugin.tag + def set_volume(self, value): + return asyncio.run_coroutine_threadsafe(self._volume(value), self.loop).result() + + # ---------------------------------- + # Stuff that replaces the current playlist and starts a new playback for URI + + @plugin.tag + def play_uri(self, uri: str, **kwargs): + """Decode URI and forward play call + + mpd:folder:path/to/folder + --> Build playlist from $MUSICLIB_DIR/path/to/folder/* + + mpd:file:path/to/file.mp3 + --> Plays single file + + mpd:album:Feuerwehr:albumartist:Benjamin + -> Searches MPD database for album Feuerwehr from artist Benjamin + + mpd:podcast:path/to/file.yaml + --> Reads local file: $PODCAST_FOLDER/path/to/file.yaml + --> which contains: https://cool-stuff.de/podcast.xml + + mpd:livestream:path/to/file.yaml + --> Reads local file: $LIVESTREAM_FOLDER/path/to/file.yaml + --> which contains: https://hot-stuff.de/livestream.mp3 + Why go via a local file? We need to have a database with all podcasts that we can pull out and display + to the user so he can select "play this one" + + """ + player_type, list_type, path = uri.split(':', 2) + if player_type != 'mpd': + raise KeyError(f"URI prefix must be 'mpd' not '{player_type}") + func = self._flavors.get(list_type) + if func is None: + raise KeyError(f"URI flavor '{list_type}' unknown. Must be one of: {self._flavors.keys()}.") + return func(path, **kwargs) + + def play_folder(self, path, recursive=False): + logger.debug(f"Play folder: {path}") + # MPD command to get files in folder non-recursive: client.lsinfo('Conni') + # MPD command to get files in folder recursive: client.find('base', 'Conni') + pass + + def play_file(self, path): + pass + + def play_album(self, album_artist: str, album: str): + # MPD command client.findadd('albumartist', albumartist, 'album', album) + pass + + def play_album_uri(self, uri: str): + p = re.match(r"album:(.*):albumartist:(.*)", uri) + if p: + album = p.group(1) + album_artist = p.group(2) + self.play_album(album_artist=album_artist, album=album) + else: + raise ValueError(f"Cannot decode album and/or album artist from URI: '{uri}'") + + def play_album_artist_uri(self, uri: str): + p = re.match(r"albumartist:(.*):album:(.*)", uri) + if p: + album = p.group(2) + album_artist = p.group(1) + self.play_album(album_artist=album_artist, album=album) + else: + raise ValueError(f"Cannot decode album and/or album artist from URI: '{uri}'") + + def play_podcast(self, path): + # If uri == file, decode and play all entries + # If uri == folder, decode and play all files? + pass + + def play_livestream(self, path): + pass + + # ---------------------------------- + # Get track lists + + async def _get_single_file(self, path): + return await self.client.find('file', path) + + async def _get_folder_recursive(self, path): + return await self.client.find('base', path) + + async def _get_folder(self, path): + return await self.client.lsinfo(path) + + @plugin.tag + def get_files(self, path, recursive=False): + """ + List file meta data for single file or all files of folder + + :returns: List of file(s) and directories including meta data + """ + path = sanitize(path) + if os.path.isfile(path): + files = asyncio.run_coroutine_threadsafe(self._get_single_file(path), self.loop).result() + elif not recursive: + files = asyncio.run_coroutine_threadsafe(self._get_folder(path), self.loop).result() + else: + files = asyncio.run_coroutine_threadsafe(self._get_folder_recursive(path), self.loop).result() + return files + + # ---------------------------------- + # Get albums / album tracks + + async def _get_albums(self): + return await self.client.list('album', 'group', 'albumartist') + + @plugin.tag + def get_albums(self): + """Returns all albums in database""" + return asyncio.run_coroutine_threadsafe(self._get_albums(), self.loop).result() + + async def _get_album_tracks(self, album_artist, album): + return await self.client.find('albumartist', album_artist, 'album', album) + + @plugin.tag + def get_album_tracks(self, album_artist, album): + """Returns all song of an album""" + return asyncio.run_coroutine_threadsafe(self._get_album_tracks(album_artist, album), self.loop).result() + + # ---------------------------------- + # Get podcasts / livestreams + + def _get_podcast_items(self, path): + """Decode playlist of one podcast file""" + pass + + @plugin.tag + def get_podcast(self, path): + """ + If :attr:`path is a + + * directory: List all stored podcasts in directory + * file: List podcast playlist + + """ + pass + + def _get_livestream_items(self, path): + """Decode playlist of one livestream file""" + pass + + @plugin.tag + def get_livestream(self, path): + """ + If :attr:`path is a + + * directory: List all stored livestreams in directory + * file: List livestream playlist + + """ + pass + + # ---------------------------------- + # Conceptual + + def get_uri(self, uri, **kwargs): + """Maps to get_* depending on URI prefix?""" + pass + + # ----------------------------------------------------- + # Queue / URI state (save + restore e.g. random, resume, ...) + + def save_uri_state(self): + """Save the configuration and state of the current URI playback to the URIs state file""" + pass diff --git a/src/jukebox/components/playern/core/__init__.py b/src/jukebox/components/playern/core/__init__.py new file mode 100644 index 000000000..b6b04701b --- /dev/null +++ b/src/jukebox/components/playern/core/__init__.py @@ -0,0 +1,113 @@ +# Copyright: 2022 +# SPDX License Identifier: MIT License + +""" +Top-level player control across all player backends + +Usage concept: + +# Stuff to control playback +player.ctrl.play(...) +player.ctrl.next() + +# To get MPD specific playlists +player.mpd.get_album(...) +player.mpd.get_...(...) + +""" + +import logging +from typing import Dict, Callable, Optional, Any + +import jukebox.plugs as plugin + +logger = logging.getLogger('jb.player') + + +class PlayerCtrl: + """The top-level player instance through which all calls go. Arbitrates between the different backends""" + + def __init__(self): + self._backends: Dict[str, Any] = {} + self._active = None + + def register(self, name: str, backend): + self._backends[name] = backend + # For now simply default to first registered backend + if self._active is None: + self._active = self._backends.values().__iter__().__next__() + + @plugin.tag + def get_active(self): + return self._active.flavor + + @plugin.tag + def list_backends(self): + return [b for b in self._backends.items()] + + @plugin.tag + def play_uri(self, uri, check_second_swipe=False, **kwargs): + # Save the current state (if something is playing) + # Stop the current playback + # Decode card second swipe + # And finally play + player_type, _ = uri.split(':', 1) + inst = self._backends.get(player_type) + if inst is None: + raise KeyError(f"URI player type unknown: '{player_type}'. Available backends are: {self._backends.keys()}.") + self._active = self._backends.get(player_type) + self._active.play_uri(uri, **kwargs) + + def _is_second_swipe(self): + """ + Check if play request is a second swipe + + Definition second swipe: + successive swipes of the same registered ID card + + A second swipe triggers a different action than the first swipe. In certain scenarios a second + swipe needs to be treated as a first swipe: + + * if playlist has stopped playing + * playlist has run out + * playlist was stopped by trigger + * if place-not-swipe: Card remains on reader until playlist expires and player enters state stop. + Card is the removed and triggers 'card removal action' on stopped playlist. Card is placed on reader + again and must be treated as first swipe + * after reboot when last state is restored (first swipe is play which starts from the beginning or resumes, depending + on playlist configuration) + + Second swipe actions can be + + * toggle + * ignore (do nothing) + * next + * restart playlist --> is always like first swipe? + + """ + pass + + @plugin.tag + def stop(self): + # Save current state for resume functionality + self._save_state() + self._active.stop() + + @plugin.tag + def next(self): + self._active.next() + + @plugin.tag + def prev(self): + self._active.prev() + + @plugin.tag + def get_queue(self): + self._active.get_queue() + + def _save_state(self): + # Get the backend to save the state of the current playlist to the URI's config file + self._active.save_uri_state() + # Also need to save which backend and URI was currently playing to be able to restore it after reboot + pass + diff --git a/src/jukebox/components/playern/plugin/__init__.py b/src/jukebox/components/playern/plugin/__init__.py new file mode 100644 index 000000000..ed04b31e3 --- /dev/null +++ b/src/jukebox/components/playern/plugin/__init__.py @@ -0,0 +1,70 @@ +# Copyright: 2022 +# SPDX License Identifier: MIT License + +import asyncio +import logging +import threading +from typing import Optional + +import jukebox.plugs as plugin +import jukebox.cfghandler +from components.playern.backends.interfacing_mpd import MPDBackend +from components.playern.core import PlayerCtrl + + +logger = logging.getLogger('jb.player') +cfg = jukebox.cfghandler.get_handler('jukebox') + +# Background event loop in a separate thread to be used by backends as needed for asyncio tasks +event_loop: asyncio.AbstractEventLoop + +# The top-level player arbiter that acts as the single interface to the outside +player_arbiter: PlayerCtrl + +# The various backends +backend_mpd: Optional[MPDBackend] = None + + +def start_event_loop(loop: asyncio.AbstractEventLoop): + # https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.shutdown_asyncgens + logger.debug("Start player AsyncIO Background Event Loop") + try: + loop.run_forever() + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + + +def register_mpd(): + global event_loop + global backend_mpd + global player_arbiter + + backend_mpd = MPDBackend(event_loop) + # Register with plugin interface to call directly + plugin.register(backend_mpd, package='player', name='mpd') + player_arbiter.register('mpd', backend_mpd) + + +@plugin.initialize +def init(): + global event_loop + global player_arbiter + # Create the event loop and start it in a background task + # the event loop can be shared across different backends (if the backends require a async event loop) + event_loop = asyncio.new_event_loop() + t = threading.Thread(target=start_event_loop, args=(event_loop,), daemon=True, name='PlayerEventLoop') + t.start() + + player_arbiter = PlayerCtrl() + + # Create and register the players (this is explicit for the moment) + register_mpd() + + plugin.register(player_arbiter, package='player', name='ctrl') + + +@plugin.atexit +def atexit(**ignored_kwargs): + global event_loop + event_loop.stop() From 2bf8fbc948bab1cd56311c602d6c915a68741315 Mon Sep 17 00:00:00 2001 From: ChisSoc <75833833+ChisSoc@users.noreply.github.com> Date: Tue, 8 Feb 2022 21:45:19 +0100 Subject: [PATCH 045/109] Implement folder and album playback --- .../playern/backends/interfacing_mpd.py | 224 +++++++++++++----- .../components/playern/core/__init__.py | 51 ++-- 2 files changed, 199 insertions(+), 76 deletions(-) diff --git a/src/jukebox/components/playern/backends/interfacing_mpd.py b/src/jukebox/components/playern/backends/interfacing_mpd.py index 20c7dd08d..2896db8c3 100644 --- a/src/jukebox/components/playern/backends/interfacing_mpd.py +++ b/src/jukebox/components/playern/backends/interfacing_mpd.py @@ -16,7 +16,7 @@ def sanitize(path: str): - return path.lstrip('./') + return os.path.normpath(path).lstrip('./') class MPDBackend: @@ -26,17 +26,38 @@ def __init__(self, event_loop): self.loop = event_loop self.host = 'localhost' self.port = '6600' - self._flavors = {'folder': self.play_folder, - 'album': self.play_album_uri, - 'albumartist': self.play_album_artist_uri, - 'file': self.play_file, - 'podcast': self.play_podcast, - 'livestream': self.play_livestream} + self._flavors = {'folder': self.get_files, + 'file': self.get_track, + 'album': self.get_album_from_uri, + 'podcast': self.get_podcast, + 'livestream': self.get_livestream} + self._active_uri = '' # TODO: If connect fails on first try this is non recoverable self.connect() # Start the status listener in an endless loop in the event loop asyncio.run_coroutine_threadsafe(self._status_listener(), self.loop) + # ------------------------------------------------------------------------------------------------------ + # Bring calls to client functions from the synchronous part into the async domain + # Async function of the MPD client return a asyncio.future as a result + # That means we must + # - first await the function execution in the event loop + # _run_cmd_async: an async function + # - second then wait for the future result to be available in the sync domain + # _run_cmd: a sync function that schedules the async function in the event loop for execution + # and wait for the future result by calling ..., self.loop).result() + # Since this must be done for every command crossing the async/sync domain, we keep it generic and + # pass method and arguments to these two wrapper functions that do the scheduling and waiting + + async def _run_cmd_async(self, afunc, *args, **kwargs): + return await afunc(*args, **kwargs) + + def _run_cmd(self, afunc, *args, **kwargs): + return asyncio.run_coroutine_threadsafe(self._run_cmd_async(afunc, *args, **kwargs), self.loop).result() + + # ----------------------------------------------------- + # Check and update statues + async def _connect(self): return await self.client.connect(self.host, self.port) @@ -86,11 +107,37 @@ async def _prev(self): def prev(self): return asyncio.run_coroutine_threadsafe(self._prev(), self.loop).result() - async def _stop(self): - return await self.client.stop() + @plugin.tag + def play(self, idx=None): + """ + If idx /= None, start playing song idx from queue + If stopped, start with first song in queue + If paused, resume playback at current position + """ + # self.client.play() continues playing at current position + if idx is None: + return self._run_cmd(self.client.play) + else: + return self._run_cmd(self.client.play, idx) + + def toggle(self): + """Toggle between playback / pause""" + return self._run_cmd(self.client.pause) + + def pause(self): + """Pause playback if playing + + This is what you want as card removal action: pause the playback, so it can be resumed when card is placed + on the reader again. What happens on re-placement depends on configured second swipe option + """ + return self._run_cmd(self.client.pause, 1) def stop(self): - return asyncio.run_coroutine_threadsafe(self._stop(), self.loop).result() + return self._run_cmd(self.client.stop) + + @plugin.tag + def get_queue(self): + return self._run_cmd(self.client.playlistinfo) # ----------------------------------------------------- # Volume control (for developing only) @@ -118,6 +165,7 @@ def play_uri(self, uri: str, **kwargs): mpd:album:Feuerwehr:albumartist:Benjamin -> Searches MPD database for album Feuerwehr from artist Benjamin + Conceptual at the moment (i.e. means it will likely change): mpd:podcast:path/to/file.yaml --> Reads local file: $PODCAST_FOLDER/path/to/file.yaml --> which contains: https://cool-stuff.de/podcast.xml @@ -129,6 +177,32 @@ def play_uri(self, uri: str, **kwargs): to the user so he can select "play this one" """ + self.clear() + # Clear the active uri before retrieving the track list, to avoid stale active uri in case something goes wrong + self._active_uri = '' + tracklist = self.get_from_uri(uri, **kwargs) + self._active_uri = uri + self.enqueue(tracklist) + self._restore_state() + self.play() + + def clear(self): + return self._run_cmd(self.client.clear) + + async def _enqueue(self, tracklist): + for entry in tracklist: + path = entry.get('file') + if path is not None: + await self.client.add(path) + + def enqueue(self, tracklist): + return asyncio.run_coroutine_threadsafe(self._enqueue(tracklist), self.loop).result() + + # ---------------------------------- + # Get track lists + + @plugin.tag + def get_from_uri(self, uri: str, **kwargs): player_type, list_type, path = uri.split(':', 2) if player_type != 'mpd': raise KeyError(f"URI prefix must be 'mpd' not '{player_type}") @@ -137,48 +211,6 @@ def play_uri(self, uri: str, **kwargs): raise KeyError(f"URI flavor '{list_type}' unknown. Must be one of: {self._flavors.keys()}.") return func(path, **kwargs) - def play_folder(self, path, recursive=False): - logger.debug(f"Play folder: {path}") - # MPD command to get files in folder non-recursive: client.lsinfo('Conni') - # MPD command to get files in folder recursive: client.find('base', 'Conni') - pass - - def play_file(self, path): - pass - - def play_album(self, album_artist: str, album: str): - # MPD command client.findadd('albumartist', albumartist, 'album', album) - pass - - def play_album_uri(self, uri: str): - p = re.match(r"album:(.*):albumartist:(.*)", uri) - if p: - album = p.group(1) - album_artist = p.group(2) - self.play_album(album_artist=album_artist, album=album) - else: - raise ValueError(f"Cannot decode album and/or album artist from URI: '{uri}'") - - def play_album_artist_uri(self, uri: str): - p = re.match(r"albumartist:(.*):album:(.*)", uri) - if p: - album = p.group(2) - album_artist = p.group(1) - self.play_album(album_artist=album_artist, album=album) - else: - raise ValueError(f"Cannot decode album and/or album artist from URI: '{uri}'") - - def play_podcast(self, path): - # If uri == file, decode and play all entries - # If uri == folder, decode and play all files? - pass - - def play_livestream(self, path): - pass - - # ---------------------------------- - # Get track lists - async def _get_single_file(self, path): return await self.client.find('file', path) @@ -204,6 +236,16 @@ def get_files(self, path, recursive=False): files = asyncio.run_coroutine_threadsafe(self._get_folder_recursive(path), self.loop).result() return files + @plugin.tag + def get_track(self, path): + playlist = asyncio.run_coroutine_threadsafe(self._get_single_file(path), self.loop).result() + if len(playlist) != 1: + raise ValueError(f"Path decodes to more than one file: '{path}'") + file = playlist[0].get('file') + if file is None: + raise ValueError(f"Not a music file: '{path}'") + return playlist + # ---------------------------------- # Get albums / album tracks @@ -223,6 +265,13 @@ def get_album_tracks(self, album_artist, album): """Returns all song of an album""" return asyncio.run_coroutine_threadsafe(self._get_album_tracks(album_artist, album), self.loop).result() + def get_album_from_uri(self, uri: str): + """Accepts full or partial uri (partial means without leading 'mpd:')""" + p = re.match(r"(mpd:)?album:(.*):albumartist:(.*)", uri) + if not p: + raise ValueError(f"Cannot decode album and/or album artist from URI: '{uri}'") + return self.get_album_tracks(album_artist=p.group(3), album=p.group(2)) + # ---------------------------------- # Get podcasts / livestreams @@ -256,16 +305,71 @@ def get_livestream(self, path): """ pass - # ---------------------------------- - # Conceptual - - def get_uri(self, uri, **kwargs): - """Maps to get_* depending on URI prefix?""" - pass - # ----------------------------------------------------- # Queue / URI state (save + restore e.g. random, resume, ...) - def save_uri_state(self): + def save_state(self): """Save the configuration and state of the current URI playback to the URIs state file""" pass + + def _restore_state(self): + """ + Restore the configuration state and last played status for current active URI + """ + pass + + # ---------------------- + + @plugin.tag + def play_folder(self, path, recursive=False): + logger.debug(f"Play folder: {path}") + self.queue_and_play(self.get_files(path, recursive)) + + def play_file(self, path): + playlist = self.get_files(path, recursive=False) + if len(playlist) != 1: + raise ValueError('Path must point to single file!') + file = playlist[0].get('file') + if file is None: + raise ValueError('Path does not point to actual file!') + self.queue_and_play(playlist) + + def play_album(self, album_artist: str, album: str): + self.queue_and_play(self.get_album_tracks(album_artist, album)) + + def play_album_uri(self, uri: str): + p = re.match(r"album:(.*):albumartist:(.*)", uri) + if p: + album = p.group(1) + album_artist = p.group(2) + self.play_album(album_artist=album_artist, album=album) + else: + raise ValueError(f"Cannot decode album and/or album artist from URI: '{uri}'") + + def play_album_artist_uri(self, uri: str): + p = re.match(r"albumartist:(.*):album:(.*)", uri) + if p: + album = p.group(2) + album_artist = p.group(1) + self.play_album(album_artist=album_artist, album=album) + else: + raise ValueError(f"Cannot decode album and/or album artist from URI: '{uri}'") + + def play_podcast(self, path): + # If uri == file, decode and play all entries + # If uri == folder, decode and play all files? + pass + + def play_livestream(self, path): + pass + + async def _queue_and_play(self, playlist): + await self.client.clear() + for entry in playlist: + path = entry.get('file') + if path is not None: + await self.client.add(path) + await self.client.play() + + def queue_and_play(self, playlist): + return asyncio.run_coroutine_threadsafe(self._queue_and_play(playlist), self.loop).result() diff --git a/src/jukebox/components/playern/core/__init__.py b/src/jukebox/components/playern/core/__init__.py index b6b04701b..0180e27b7 100644 --- a/src/jukebox/components/playern/core/__init__.py +++ b/src/jukebox/components/playern/core/__init__.py @@ -39,7 +39,12 @@ def register(self, name: str, backend): @plugin.tag def get_active(self): - return self._active.flavor + name = 'None' + for n, b in self._backends.items(): + if self._active == b: + name = n + break + return name @plugin.tag def list_backends(self): @@ -47,11 +52,14 @@ def list_backends(self): @plugin.tag def play_uri(self, uri, check_second_swipe=False, **kwargs): - # Save the current state (if something is playing) - # Stop the current playback - # Decode card second swipe + # Save the current state and stop the current playback + self.stop() + # Decode card second swipe (TBD) # And finally play - player_type, _ = uri.split(':', 1) + try: + player_type, _ = uri.split(':', 1) + except ValueError: + raise ValueError(f"Malformed URI: {uri}") inst = self._backends.get(player_type) if inst is None: raise KeyError(f"URI player type unknown: '{player_type}'. Available backends are: {self._backends.keys()}.") @@ -71,11 +79,11 @@ def _is_second_swipe(self): * if playlist has stopped playing * playlist has run out * playlist was stopped by trigger - * if place-not-swipe: Card remains on reader until playlist expires and player enters state stop. + * if in a place-not-swipe setup, the card remains on reader until playlist expires and player enters state stop. Card is the removed and triggers 'card removal action' on stopped playlist. Card is placed on reader again and must be treated as first swipe - * after reboot when last state is restored (first swipe is play which starts from the beginning or resumes, depending - on playlist configuration) + * after reboot when last state is restored (first swipe is play which starts from the beginning or resumes, + depending on playlist configuration) Second swipe actions can be @@ -87,12 +95,6 @@ def _is_second_swipe(self): """ pass - @plugin.tag - def stop(self): - # Save current state for resume functionality - self._save_state() - self._active.stop() - @plugin.tag def next(self): self._active.next() @@ -101,13 +103,30 @@ def next(self): def prev(self): self._active.prev() + @plugin.tag + def play(self): + self._active.play() + + @plugin.tag + def toggle(self): + self._active.toggle() + + @plugin.tag + def pause(self): + self._active.pause() + + @plugin.tag + def stop(self): + # Save current state for resume functionality + self._save_state() + self._active.stop() + @plugin.tag def get_queue(self): self._active.get_queue() def _save_state(self): # Get the backend to save the state of the current playlist to the URI's config file - self._active.save_uri_state() + self._active.save_state() # Also need to save which backend and URI was currently playing to be able to restore it after reboot pass - From de9cbb7d0aec8b0d52e0aa897c4be8ac46194cd5 Mon Sep 17 00:00:00 2001 From: ChisSoc <75833833+ChisSoc@users.noreply.github.com> Date: Tue, 8 Feb 2022 21:56:20 +0100 Subject: [PATCH 046/109] Clean up --- .../playern/backends/interfacing_mpd.py | 97 ++----------------- 1 file changed, 10 insertions(+), 87 deletions(-) diff --git a/src/jukebox/components/playern/backends/interfacing_mpd.py b/src/jukebox/components/playern/backends/interfacing_mpd.py index 2896db8c3..1b5273116 100644 --- a/src/jukebox/components/playern/backends/interfacing_mpd.py +++ b/src/jukebox/components/playern/backends/interfacing_mpd.py @@ -94,18 +94,11 @@ def status(self): # ----------------------------------------------------- # Stuff that controls current playback (i.e. moves around in the current playlist, termed "the queue") - async def _next(self): - return await self.client.next() - def next(self): - logger.debug('Next') - return asyncio.run_coroutine_threadsafe(self._next(), self.loop).result() - - async def _prev(self): - return await self.client.next() + return self._run_cmd(self.client.next) def prev(self): - return asyncio.run_coroutine_threadsafe(self._prev(), self.loop).result() + return self._run_cmd(self.client.prev) @plugin.tag def play(self, idx=None): @@ -211,15 +204,6 @@ def get_from_uri(self, uri: str, **kwargs): raise KeyError(f"URI flavor '{list_type}' unknown. Must be one of: {self._flavors.keys()}.") return func(path, **kwargs) - async def _get_single_file(self, path): - return await self.client.find('file', path) - - async def _get_folder_recursive(self, path): - return await self.client.find('base', path) - - async def _get_folder(self, path): - return await self.client.lsinfo(path) - @plugin.tag def get_files(self, path, recursive=False): """ @@ -229,16 +213,16 @@ def get_files(self, path, recursive=False): """ path = sanitize(path) if os.path.isfile(path): - files = asyncio.run_coroutine_threadsafe(self._get_single_file(path), self.loop).result() + files = self._run_cmd(self.client.find, 'file', path) elif not recursive: - files = asyncio.run_coroutine_threadsafe(self._get_folder(path), self.loop).result() + files = self._run_cmd(self.client.lsinfo, path) else: - files = asyncio.run_coroutine_threadsafe(self._get_folder_recursive(path), self.loop).result() + files = self._run_cmd(self.client.find, 'base', path) return files @plugin.tag def get_track(self, path): - playlist = asyncio.run_coroutine_threadsafe(self._get_single_file(path), self.loop).result() + playlist = self._run_cmd(self.client.find, 'file', path) if len(playlist) != 1: raise ValueError(f"Path decodes to more than one file: '{path}'") file = playlist[0].get('file') @@ -249,21 +233,16 @@ def get_track(self, path): # ---------------------------------- # Get albums / album tracks - async def _get_albums(self): - return await self.client.list('album', 'group', 'albumartist') - @plugin.tag def get_albums(self): """Returns all albums in database""" - return asyncio.run_coroutine_threadsafe(self._get_albums(), self.loop).result() - - async def _get_album_tracks(self, album_artist, album): - return await self.client.find('albumartist', album_artist, 'album', album) + # return asyncio.run_coroutine_threadsafe(self._get_albums(), self.loop).result() + return self._run_cmd(self.client.list, 'album', 'group', 'albumartist') @plugin.tag def get_album_tracks(self, album_artist, album): - """Returns all song of an album""" - return asyncio.run_coroutine_threadsafe(self._get_album_tracks(album_artist, album), self.loop).result() + """Returns all songs of an album""" + return self._run_cmd(self.client.find, 'albumartist', album_artist, 'album', album) def get_album_from_uri(self, uri: str): """Accepts full or partial uri (partial means without leading 'mpd:')""" @@ -317,59 +296,3 @@ def _restore_state(self): Restore the configuration state and last played status for current active URI """ pass - - # ---------------------- - - @plugin.tag - def play_folder(self, path, recursive=False): - logger.debug(f"Play folder: {path}") - self.queue_and_play(self.get_files(path, recursive)) - - def play_file(self, path): - playlist = self.get_files(path, recursive=False) - if len(playlist) != 1: - raise ValueError('Path must point to single file!') - file = playlist[0].get('file') - if file is None: - raise ValueError('Path does not point to actual file!') - self.queue_and_play(playlist) - - def play_album(self, album_artist: str, album: str): - self.queue_and_play(self.get_album_tracks(album_artist, album)) - - def play_album_uri(self, uri: str): - p = re.match(r"album:(.*):albumartist:(.*)", uri) - if p: - album = p.group(1) - album_artist = p.group(2) - self.play_album(album_artist=album_artist, album=album) - else: - raise ValueError(f"Cannot decode album and/or album artist from URI: '{uri}'") - - def play_album_artist_uri(self, uri: str): - p = re.match(r"albumartist:(.*):album:(.*)", uri) - if p: - album = p.group(2) - album_artist = p.group(1) - self.play_album(album_artist=album_artist, album=album) - else: - raise ValueError(f"Cannot decode album and/or album artist from URI: '{uri}'") - - def play_podcast(self, path): - # If uri == file, decode and play all entries - # If uri == folder, decode and play all files? - pass - - def play_livestream(self, path): - pass - - async def _queue_and_play(self, playlist): - await self.client.clear() - for entry in playlist: - path = entry.get('file') - if path is not None: - await self.client.add(path) - await self.client.play() - - def queue_and_play(self, playlist): - return asyncio.run_coroutine_threadsafe(self._queue_and_play(playlist), self.loop).result() From 665a53f8738482bc0abba17a0b6a008e2b3549d9 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 9 Feb 2022 19:51:23 +0100 Subject: [PATCH 047/109] Disable autoplay --- resources/default-settings/spotify.config.toml | 2 +- .../components/playern/backends/{ => mpd}/interfacing_mpd.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/jukebox/components/playern/backends/{ => mpd}/interfacing_mpd.py (100%) diff --git a/resources/default-settings/spotify.config.toml b/resources/default-settings/spotify.config.toml index 29011b016..daf154c35 100644 --- a/resources/default-settings/spotify.config.toml +++ b/resources/default-settings/spotify.config.toml @@ -33,7 +33,7 @@ synchronizationMethod = "NTP" # Time synchronization method (NTP, PING, MELODY, manualCorrection = 0 # Manual time correction in millis [player] ### Player ### -autoplayEnabled = true # Autoplay similar songs when your music ends +autoplayEnabled = false # Autoplay similar songs when your music ends preferredAudioQuality = "NORMAL" # Preferred audio quality (NORMAL, HIGH, VERY_HIGH) enableNormalisation = true # Whether to apply the Spotify loudness normalisation normalisationPregain = +3.0 # Normalisation pregain in decibels (loud at +6, normal at +3, quiet at -5) diff --git a/src/jukebox/components/playern/backends/interfacing_mpd.py b/src/jukebox/components/playern/backends/mpd/interfacing_mpd.py similarity index 100% rename from src/jukebox/components/playern/backends/interfacing_mpd.py rename to src/jukebox/components/playern/backends/mpd/interfacing_mpd.py From 8db761fc0cc0e83edec4c8c8a5325e5950e4aef0 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 11 Feb 2022 10:19:58 +0100 Subject: [PATCH 048/109] First attempt Spotify Backend --- .../default-settings/jukebox.default.yaml | 3 +- .../spotify_collection.example.yaml | 9 ++ shared/audio/.gitkeep | 0 .../playern/backends/spotify/http_client.py | 90 ++++++++++++++ .../backends/spotify/interfacing_spotify.py | 91 +++++++++++++++ .../playern/backends/spotify/ws_client.py | 110 ++++++++++++++++++ .../components/playern/core/player_status.py | 39 +++++++ .../components/playern/plugin/__init__.py | 25 +++- 8 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 resources/default-settings/spotify_collection.example.yaml create mode 100644 shared/audio/.gitkeep create mode 100644 src/jukebox/components/playern/backends/spotify/http_client.py create mode 100644 src/jukebox/components/playern/backends/spotify/interfacing_spotify.py create mode 100644 src/jukebox/components/playern/backends/spotify/ws_client.py create mode 100644 src/jukebox/components/playern/core/player_status.py diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index 0d9cb321d..2f8905bda 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -13,7 +13,7 @@ modules: jingle.alsawave: jingle.alsawave jingle.jinglemp3: jingle.jinglemp3 player: playern.plugin - spotify: playerspot + # spotify: playerspot cards: rfid.cards rfid: rfid.reader timers: timers @@ -92,6 +92,7 @@ playermpd: playerspot: host: localhost status_file: ../../shared/settings/spotify_player_status.json + collection_file: ../../shared/audio/spotify/spotify_collection.yaml second_swipe_action: # Note: Does not follow the RPC alias convention (yet) # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' diff --git a/resources/default-settings/spotify_collection.example.yaml b/resources/default-settings/spotify_collection.example.yaml new file mode 100644 index 000000000..d662215d6 --- /dev/null +++ b/resources/default-settings/spotify_collection.example.yaml @@ -0,0 +1,9 @@ +playlist: + - name: Gute Nacht! + uri: spotify:playlist:37i9dQZF1DWSVYS2LMyMFg +album: + - name: 001/Die Ritterburg (und 5 weitere Geschichten) + uri: spotify:album:0IakudBXfN2aYXChl5sEK7 +track: + - name: Die Ritterburg - Teil 1 + uri: spotify:track:6Ac7ZQZZF57jeQnW6AVqY8 diff --git a/shared/audio/.gitkeep b/shared/audio/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/jukebox/components/playern/backends/spotify/http_client.py b/src/jukebox/components/playern/backends/spotify/http_client.py new file mode 100644 index 000000000..0ccc7eb05 --- /dev/null +++ b/src/jukebox/components/playern/backends/spotify/http_client.py @@ -0,0 +1,90 @@ +import json +import logging +import requests +from requests.adapters import HTTPAdapter +import urllib +from urllib3.util.retry import Retry + +logger = logging.getLogger('jb.spotify.SpotifyHttpClient') + + +class SpotifyHttpClient: + def __init__(self, host: str, port=24879): + self.protocol = 'http' + self.host = host + self.port = port + self.authority = f'{self.protocol}://{self.host}:{self.port}' + + self.session = requests.Session() + retries = Retry( + total=5, + backoff_factor=5, + status_forcelist=[500, 502, 503, 504] + ) + + self.session.mount( + self.protocol + '://', + HTTPAdapter(max_retries=retries) + ) + self.session.headers.update({'content-type': 'application/json'}) + logger.debug(f'Spotify HTTP Client initialized. Will connect to {self.authority}') + + def close(self): + logger.debug("Exiting Spotify HTTP session") + self._post_request('/instance/close') + + def _request(self, request_func, path: str): + try: + url = urllib.parse.urljoin(self.authority, path) + logger.debug(f'Requesting "{self.authority}"') + + response = request_func(url) + response.raise_for_status() + + except requests.HTTPError as http_error: + response = {} + logger.error(f'HTTPError: {http_error}') + + except Exception as error: + response = {} + logger.error(f'Error {error}') + + return json.loads(response.content) + + def _get_request(self, path: str): + response = self._request(self.session.get, path) + return response + + def _post_request(self, path: str): + response = self._request(self.session.post, path) + return response + + def get_status(self): + # json = self._get_request('/web-api/v1//me/player') + response_json = self._post_request('/player/current') + logger.debug(response_json) + return response_json + + def play_uri(self, uri: str, play: bool = True, shuffle: bool = False): + return self._post_request(f'/player/load?uri={uri}&play={play}&shuffle={shuffle}') + + def play(self): + return self._post_request('/player/resume') + + def pause(self): + return self._post_request('/player/pause') + + def prev(self): + return self._post_request('/player/prev') + + def next(self): + return self._post_request('/player/next') + + def seek(self, new_time: int): + return self._post_request(f'/player/seek?pos={new_time}') + + def shuffle(self, val: bool): + return self._post_request(f'/player/shuffle?val={val}') + + def repeat(self, val: str): + return self._post_request(f'/player/repeat?val={val}') diff --git a/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py b/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py new file mode 100644 index 000000000..3bf5fdae5 --- /dev/null +++ b/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py @@ -0,0 +1,91 @@ +# Copyright: 2022 +# SPDX License Identifier: MIT License + +import logging +import os.path + +from ruamel import yaml + +import jukebox.plugs as plugin +import jukebox.cfghandler +from components.playern.backends.spotify.http_client import SpotifyHttpClient +from components.playern.backends.spotify.ws_client import SpotifyWsClient + +logger = logging.getLogger('jb.spotify') +cfg = jukebox.cfghandler.get_handler('jukebox') + + +def sanitize(path: str): + return os.path.normpath(path).lstrip('./') + + +class SPOTBackend: + def __init__(self, player_status): + host = cfg.getn('playerspot', 'host') + self.player_status = player_status + + self.http_client = SpotifyHttpClient(host) + + self.ws_client = SpotifyWsClient( + host=host, + player_status=self.player_status + ) + self.ws_client.connect() + + self.collection_file_location = cfg.setndefault('playerspot', 'collection_file', + value="../../shared/audio/spotify/spotify_collection.yaml") + self.spotify_collection_data = self._read_data_file() + + def _read_data_file(self) -> dict: + try: + with open(self.collection_file_location, "r") as collection_file: + return yaml.safe_load(collection_file.read()) + except Exception as err: + logger.error(f"Could not open spotify collection file {self.collection_file_location}") + logger.debug(f"Error: {err}") + logger.debug("Continuing with empty dictionary") + return {} + + def play(self): + self.http_client.play() + + def pause(self): + self.http_client.pause() + + def prev(self): + self.http_client.prev() + + def next(self): + self.http_client.next() + + def toggle(self): + pass + + def get_queue(self): + pass + + @plugin.tag + def play_uri(self, uri: str, **kwargs): + """Decode URI and forward play call + + spotify:playlist:0 + --> search in the yaml-file for the type "playlist" and play the first uri + """ + player_type, list_type, index = uri.split(':', 2) + if player_type != 'spotify': + raise KeyError(f"URI prefix must be 'spotify' not '{player_type}") + + self.http_client.play_uri(self.spotify_collection_data.get(list_type)[int(index)].get("uri")) + + # ----------------------------------------------------- + # Queue / URI state (save + restore e.g. random, resume, ...) + + def save_state(self): + """Save the configuration and state of the current URI playback to the URIs state file""" + pass + + def _restore_state(self): + """ + Restore the configuration state and last played status for current active URI + """ + pass diff --git a/src/jukebox/components/playern/backends/spotify/ws_client.py b/src/jukebox/components/playern/backends/spotify/ws_client.py new file mode 100644 index 000000000..3750b93ee --- /dev/null +++ b/src/jukebox/components/playern/backends/spotify/ws_client.py @@ -0,0 +1,110 @@ +import json +import logging +import websocket +import threading + +logger = logging.getLogger("jb.spotify.SpotifyWsClient") + + +class SpotifyWsClient: + def __init__(self, host: str, player_status, port: int = 24879): + self.protocol = 'ws' + self.host = host + self.port = port + self.url = f'{self.protocol}://{self.host}:{self.port}/events' + + self.player_status = player_status + + self.socket = None + self.thread = None + + self.state_callbacks = { + 'playbackPaused': self.playback_paused, + 'playbackResumed': self.playback_resumed, + 'playbackHaltStateChanged': self.playback_halted, + 'trackSeeked': self.track_seeked, + 'metadataAvailable': self.metadata_available, + 'inactiveSession': self.inactive_session, + } + + logger.debug('Spotify WS Client initialized') + + def connect(self): + websocket.enableTrace(True) + self.socket = websocket.WebSocketApp( + self.url, + on_close=self._on_close, + on_error=self._on_error, + on_message=self._on_message + ) + self.thread = threading.Thread(target=self.socket.run_forever) + self.thread.daemon = True + self.thread.start() + + logger.debug(f'Websocket connection established to {self.url}') + + def close(self): + self.socket.close() + + def _on_message(self, socket, message): + logger.debug(f'_on_message: {message}') + data = json.loads(message) + event = data['event'] + + callback = self.state_callbacks.get(event) + if not callback: + raise ValueError(event) + + callback(data) + + def _on_close(self, socket): + logger.debug('Connection with websocket server closed') + + def _on_error(self, socket, error): + logger.error(f'Websocket error: {error}') + + # We only care about seconds, not ms as provided by Spotify + def _round_time_to_seconds(self, time): + return '{:.1f}'.format(time / 1000) + + def metadata_available(self, data: dict): + cover_art = data['track']['album']['coverGroup']['image'][2]['fileId'].lower() + + self.player_status.update( + player='Spotify', # TODO: Should this be done differently? + trackid=data['track']['gid'], + title=data['track']['name'], + artist=data['track']['artist'][0]['name'], + album=data['track']['album']['name'], + albumartist=data['track']['album']['artist'][0]['name'], + duration=self._round_time_to_seconds(data['track']['duration']), + coverArt=cover_art + ) + + def playback_paused(self, data: dict): + self.player_status.update( + playing=False, + elapsed=self._round_time_to_seconds(data['trackTime']) + ) + + def playback_resumed(self, data: dict): + self.player_status.update( + playing=True, + elapsed=self._round_time_to_seconds(data['trackTime']) + ) + + def playback_halted(self, data: dict): + self.player_status.update( + playing=data['halted'], + elapsed=self._round_time_to_seconds(data['trackTime']) + ) + + def track_seeked(self, data: dict): + self.player_status.update( + elapsed=self._round_time_to_seconds(data['trackTime']) + ) + + # When Spotify session is routed to another device, + # the local session goes inactive + def inactive_session(self, data: dict): + self.player_status.update(playing=False) diff --git a/src/jukebox/components/playern/core/player_status.py b/src/jukebox/components/playern/core/player_status.py new file mode 100644 index 000000000..b7fccb3a2 --- /dev/null +++ b/src/jukebox/components/playern/core/player_status.py @@ -0,0 +1,39 @@ +import logging +from jukebox import publishing + +logger = logging.getLogger('jb.player') + + +class PlayerStatus: + STATUS = { + 'album': '', + 'albumartist': '', + 'artist': '', + 'coverArt': '', + 'duration': 0, + 'elapsed': 0, + 'file': '', # required for MPD // check if really is required + 'player': '', # TODO: TBD, Spotify or MPD + 'playing': False, + 'shuffle': False, + 'repeat': 0, + 'title': '', + 'trackid': '', + } + + def __init__(self): + self._player_status = self.STATUS + + def update(self, **kwargs): + for key, value in kwargs.items(): + if key in self.STATUS: + self._player_status[key] = value + + self.publish() + + def publish(self): + logger.debug(f'Published: {self._player_status}') + return publishing.get_publisher().send( + 'player_status', + self._player_status + ) diff --git a/src/jukebox/components/playern/plugin/__init__.py b/src/jukebox/components/playern/plugin/__init__.py index ed04b31e3..2850a7e6f 100644 --- a/src/jukebox/components/playern/plugin/__init__.py +++ b/src/jukebox/components/playern/plugin/__init__.py @@ -8,9 +8,10 @@ import jukebox.plugs as plugin import jukebox.cfghandler -from components.playern.backends.interfacing_mpd import MPDBackend +from components.playern.backends.mpd.interfacing_mpd import MPDBackend +from components.playern.backends.spotify.interfacing_spotify import SPOTBackend from components.playern.core import PlayerCtrl - +from components.playern.core.player_status import PlayerStatus logger = logging.getLogger('jb.player') cfg = jukebox.cfghandler.get_handler('jukebox') @@ -21,8 +22,12 @@ # The top-level player arbiter that acts as the single interface to the outside player_arbiter: PlayerCtrl +# Player status needed for webapp +player_status: PlayerStatus + # The various backends backend_mpd: Optional[MPDBackend] = None +backend_spot: Optional[SPOTBackend] = None def start_event_loop(loop: asyncio.AbstractEventLoop): @@ -46,10 +51,22 @@ def register_mpd(): player_arbiter.register('mpd', backend_mpd) +def register_spotify(): + global backend_spot + global player_arbiter + global player_status + + backend_spot = SPOTBackend(player_status) + # Register with plugin interface to call directly + plugin.register(backend_spot, package='player', name='spotify') + player_arbiter.register('spotify', backend_spot) + + @plugin.initialize def init(): global event_loop global player_arbiter + global player_status # Create the event loop and start it in a background task # the event loop can be shared across different backends (if the backends require a async event loop) event_loop = asyncio.new_event_loop() @@ -58,8 +75,12 @@ def init(): player_arbiter = PlayerCtrl() + player_status = PlayerStatus() + player_status.publish() + # Create and register the players (this is explicit for the moment) register_mpd() + register_spotify() plugin.register(player_arbiter, package='player', name='ctrl') From 55d721a19970733f704abdae57c0b72bdfc2b1f8 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 11 Feb 2022 10:25:14 +0100 Subject: [PATCH 049/109] Flake 8 corrections --- src/jukebox/components/players/player_main.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/jukebox/components/players/player_main.py b/src/jukebox/components/players/player_main.py index 0e98a3907..aa5c67882 100644 --- a/src/jukebox/components/players/player_main.py +++ b/src/jukebox/components/players/player_main.py @@ -27,9 +27,9 @@ How it works: -The backends register the flavor(s) with the top-level player control. The WebApp goes through player.play(flavor, uri) to -trigger a playback. Function like next, prev also go through the player control - here the currently active flavor is automatically -selected by the player control. +The backends register the flavor(s) with the top-level player control. The WebApp goes through player.play(flavor, uri) +to trigger a playback. Function like next, prev also go through the player control - here the currently active flavor is +automatically selected by the player control. To get all playlists and/or playlist entries, the WebApp also goes through the player control - it is the same function but takes different arguments depending on player control. And returns different results (possibly in different formats?) @@ -56,7 +56,8 @@ player.get_content(flavor=folder, uri='connie_backt') > [01-song.mp3, 02-intro, ...] - NOTE: list and get_content return not only names of files, but list of tuples which also contain path and filetype (dir, file) + NOTE: list and get_content return not only names of files, but list of tuples which also contain path and + filetype (dir, file) ... @@ -105,7 +106,8 @@ def register(self, flavor: str, backend, play_callable: Callable, get_list_callable: Callable, get_content_callable: Callable): - self._flavors[flavor] = PlayerFlavorEntry(flavor, backend, play_callable, get_list_callable, get_content_callable) + self._flavors[flavor] = PlayerFlavorEntry(flavor, backend, play_callable, get_list_callable, + get_content_callable) def play(self, flavor, check_second_swipe=False, **kwargs): # Save the current state (if something is playing) @@ -241,12 +243,13 @@ def initialize(): mpd = BackendMPD() player.register('album', mpd, - lambda album_artist, album, **ignored_kwargs: mpd.play_album(album_artist=album_artist, album=album), + lambda album_artist, album, **ignored_kwargs: mpd.play_album(album_artist=album_artist, + album=album), lambda **ignored_kwargs: mpd.get_album_list(), - lambda album_artist, album, **ignored_kwargs: mpd.get_album_content(album_artist=album_artist, album=album)) + lambda album_artist, album, **ignored_kwargs: mpd.get_album_content(album_artist=album_artist, + album=album)) player.register('folder', mpd, lambda uri, recursive=False, **ignore_kwargs: mpd.play_folder(uri=uri, recursive=recursive), lambda uri, **ignore_kwargs: mpd.get_folder_list(uri=uri), lambda uri, **ignore_kwargs: mpd.get_folder_content(uri=uri)) - From 5412b5909714bd6cdd2caa44b0e6efd05aa4b4b6 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sun, 13 Feb 2022 23:09:40 +0100 Subject: [PATCH 050/109] Read mpd host config from jukebox.yaml --- resources/default-settings/jukebox.default.yaml | 3 +++ src/jukebox/components/playern/backends/interfacing_mpd.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index 0d9cb321d..56cf5b0e0 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -78,6 +78,9 @@ jinglemp3: alsawave: # Config of the Wave through ALSA Jingle Service device: default +players: + mpd: + host: localhost playermpd: host: localhost status_file: ../../shared/settings/music_player_status.json diff --git a/src/jukebox/components/playern/backends/interfacing_mpd.py b/src/jukebox/components/playern/backends/interfacing_mpd.py index 1b5273116..cda2dc7f0 100644 --- a/src/jukebox/components/playern/backends/interfacing_mpd.py +++ b/src/jukebox/components/playern/backends/interfacing_mpd.py @@ -24,7 +24,7 @@ class MPDBackend: def __init__(self, event_loop): self.client = MPDClient() self.loop = event_loop - self.host = 'localhost' + self.host = cfg.getn('players', 'mpd', 'host', default='localhost') self.port = '6600' self._flavors = {'folder': self.get_files, 'file': self.get_track, From ece4c2b06c3bb82f4ec0561ba8761bb4fcee4a30 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Tue, 15 Feb 2022 21:30:13 +0100 Subject: [PATCH 051/109] [Webapp] Interim MPD player adjustment to new player backend --- .../playern/backends/interfacing_mpd.py | 2 +- src/webapp/src/commands/index.js | 66 +++++++++---------- .../components/Library/lists/albums/index.js | 2 +- .../Library/lists/albums/song-list/index.js | 32 ++++----- .../albums/song-list/song-list-controls.js | 12 +++- .../albums/song-list/song-list-headline.js | 2 +- .../lists/albums/song-list/song-list-item.js | 12 ++-- .../Library/lists/folders/folder-link.js | 4 +- .../lists/folders/folder-list-item-back.js | 4 +- .../Library/lists/folders/folder-list-item.js | 15 +++-- .../Library/lists/folders/folder-list.js | 19 +++--- .../components/Library/lists/folders/index.js | 10 +-- .../src/components/Library/lists/index.js | 5 +- src/webapp/src/components/Player/controls.js | 14 ++-- 14 files changed, 103 insertions(+), 96 deletions(-) diff --git a/src/jukebox/components/playern/backends/interfacing_mpd.py b/src/jukebox/components/playern/backends/interfacing_mpd.py index cda2dc7f0..b8e605a21 100644 --- a/src/jukebox/components/playern/backends/interfacing_mpd.py +++ b/src/jukebox/components/playern/backends/interfacing_mpd.py @@ -98,7 +98,7 @@ def next(self): return self._run_cmd(self.client.next) def prev(self): - return self._run_cmd(self.client.prev) + return self._run_cmd(self.client.previous) @plugin.tag def play(self, idx=None): diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index ffe2bf54b..8c4e6820a 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -9,15 +9,16 @@ const commands = { plugin: 'ctrl', method: 'list_all_dirs', }, - albumList: { + 'mpd.get_albums': { _package: 'player', - plugin: 'ctrl', - method: 'list_albums', + plugin: 'mpd', + method: 'get_albums', }, - songList: { + 'mpd.get_album_tracks': { _package: 'player', - plugin: 'ctrl', - method: 'list_song_by_artist_and_album', + plugin: 'mpd', + method: 'get_album_tracks', + argKeys: ['album_artist', 'album'] }, getSongByUrl: { _package: 'player', @@ -25,10 +26,11 @@ const commands = { method: 'get_song_by_url', argKeys: ['song_url'] }, - folderList: { + 'mpd.get_files': { _package: 'player', - plugin: 'ctrl', - method: 'get_folder_content', + plugin: 'mpd', + method: 'get_files', + argKeys: ['path'] }, cardsList: { _package: 'cards', @@ -50,51 +52,45 @@ const commands = { // Player Actions play: { - _package: 'players', - plugin: 'play', - }, - play_single: { _package: 'player', plugin: 'ctrl', - method: 'play_single', - argKeys: ['song_url'] + method: 'play', }, - play_folder: { + pause: { _package: 'player', plugin: 'ctrl', - method: 'play_folder', - argKeys: ['folder'] + method: 'pause', }, - play_album: { + previous: { _package: 'player', plugin: 'ctrl', - method: 'play_album', - argKeys: ['albumartist', 'album'] - }, - pause: { - _package: 'players', - plugin: 'pause', - }, - previous: { - _package: 'players', - plugin: 'prev', + method: 'prev', }, next: { - _package: 'players', - plugin: 'next', + _package: 'player', + plugin: 'ctrl', + method: 'next', }, shuffle: { - _package: 'players', - plugin: 'shuffle', + _package: 'player', + plugin: 'ctrl', + method: 'shuffle', }, repeat: { - _package: 'players', - plugin: 'repeat', + _package: 'player', + plugin: 'ctrl', + method: 'repeat', }, seek: { _package: 'players', plugin: 'seek', }, + 'mpd.play_uri': { + _package: 'player', + plugin: 'mpd', + method: 'play_uri', + argKeys: ['uri'] + }, // Volume setVolume: { diff --git a/src/webapp/src/components/Library/lists/albums/index.js b/src/webapp/src/components/Library/lists/albums/index.js index 3e915a9fa..92b1caedd 100644 --- a/src/webapp/src/components/Library/lists/albums/index.js +++ b/src/webapp/src/components/Library/lists/albums/index.js @@ -30,7 +30,7 @@ const Albums = ({ musicFilter }) => { useEffect(() => { const fetchAlbumList = async () => { setIsLoading(true); - const { result, error } = await request('albumList'); + const { result, error } = await request('mpd.get_albums'); setIsLoading(false); if(result) setAlbums(result.reduce(flatByAlbum, [])); diff --git a/src/webapp/src/components/Library/lists/albums/song-list/index.js b/src/webapp/src/components/Library/lists/albums/song-list/index.js index 006ab791b..707964e2b 100644 --- a/src/webapp/src/components/Library/lists/albums/song-list/index.js +++ b/src/webapp/src/components/Library/lists/albums/song-list/index.js @@ -30,10 +30,10 @@ const SongList = ({ const getSongList = async () => { setIsLoading(true); const { result, error } = await request( - 'songList', + 'mpd.get_album_tracks', { + album_artist: decodeURIComponent(artist), album: decodeURIComponent(album), - albumartist: decodeURIComponent(artist), } ); setIsLoading(false); @@ -70,21 +70,23 @@ const SongList = ({ marginTop: '0' }} > - {isLoading - ? - : - {songs.map(song => - - )} - + {isLoading && } + {!isLoading && !error && + + {songs.map(song => + + )} + } {error && - {`${t('library.albums.no-songs-in-album')} 🤔`} + + {`${t('library.albums.no-songs-in-album')} 🤔`} + } diff --git a/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js b/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js index b2391819e..4423c5896 100644 --- a/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js +++ b/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js @@ -18,14 +18,20 @@ const SongListControls = ({ isSelecting }) => { const { t } = useTranslation(); - const command = 'play_album'; + + const command = 'mpd.play_uri'; + const uri = [ + 'mpd', + 'album', encodeURI(album), + 'albumartist', encodeURI(albumartist) + ].join(':'); const playAlbum = () => ( - request(command, { albumartist, album }) + request(command, { uri }) ); const registerAlbumToCard = () => ( - registerMusicToCard(command, { albumartist, album }) + registerMusicToCard(command, { uri }) ); return ( diff --git a/src/webapp/src/components/Library/lists/albums/song-list/song-list-headline.js b/src/webapp/src/components/Library/lists/albums/song-list/song-list-headline.js index 55f1ec9b7..7cf33153d 100644 --- a/src/webapp/src/components/Library/lists/albums/song-list/song-list-headline.js +++ b/src/webapp/src/components/Library/lists/albums/song-list/song-list-headline.js @@ -6,7 +6,7 @@ import { } from '@mui/material'; const SongListHeadline = ({ artist, album }) => ( - + {album} diff --git a/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js b/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js index 0f22d2df3..a367aa3c4 100644 --- a/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js +++ b/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js @@ -17,7 +17,7 @@ const SongListItem = ({ }) => { const { t } = useTranslation(); - const command = 'play_single'; + const command = 'mpd.play_uri'; const { artist, duration, @@ -25,12 +25,14 @@ const SongListItem = ({ title, } = song; - const playSingle = () => { - request(command, { song_url: file }) - } + const uri = `mpd:file:${file}`; + + const playSingle = () => ( + request(command, { uri }) + ); const registerSongToCard = () => ( - registerMusicToCard(command, { song_url: file }) + registerMusicToCard(command, { uri }) ); return ( diff --git a/src/webapp/src/components/Library/lists/folders/folder-link.js b/src/webapp/src/components/Library/lists/folders/folder-link.js index 1f55415c3..95d05a33f 100644 --- a/src/webapp/src/components/Library/lists/folders/folder-link.js +++ b/src/webapp/src/components/Library/lists/folders/folder-link.js @@ -7,10 +7,10 @@ import { const FolderLink = forwardRef((props, ref) => { const { search: urlSearch } = useLocation(); const { data } = props; - const dir = encodeURIComponent(data?.dir); + const path = encodeURIComponent(data?.path); // TODO: Introduce fallback incase artist or album are undefined - const location = `/library/folders/${dir}${urlSearch}`; + const location = `/library/folders/${path}${urlSearch}`; return }); diff --git a/src/webapp/src/components/Library/lists/folders/folder-list-item-back.js b/src/webapp/src/components/Library/lists/folders/folder-list-item-back.js index 2ff14667b..d406359fa 100644 --- a/src/webapp/src/components/Library/lists/folders/folder-list-item-back.js +++ b/src/webapp/src/components/Library/lists/folders/folder-list-item-back.js @@ -11,14 +11,14 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import FolderLink from './folder-link'; -const FolderListItemBack = ({ dir }) => { +const FolderListItemBack = ({ path }) => { const { t } = useTranslation(); return ( diff --git a/src/webapp/src/components/Library/lists/folders/folder-list-item.js b/src/webapp/src/components/Library/lists/folders/folder-list-item.js index 755feef15..c6b37f58f 100644 --- a/src/webapp/src/components/Library/lists/folders/folder-list-item.js +++ b/src/webapp/src/components/Library/lists/folders/folder-list-item.js @@ -21,12 +21,15 @@ const FolderListItem = ({ registerMusicToCard, }) => { const { t } = useTranslation(); - const { type, name, path } = folder; + const { directory, file } = folder; + const type = directory ? 'directory' : 'file'; + const path = directory || file; + const name = directory || folder.title; const playItem = () => { switch(type) { - case 'directory': return request('play_folder', { folder: path, recursive: true }); - case 'file': return request('play_single', { song_url: path.replace(`${DEFAULT_AUDIO_DIR}/`, '') }); + case 'directory': return request('mpd.play_uri', { uri: `mpd:folder:${path}` }); + case 'file': return request('mpd.play_uri', { uri: `mpd:file:${path}` }); // TODO: Add missing Podcast // TODO: Add missing Stream default: return; @@ -35,8 +38,8 @@ const FolderListItem = ({ const registerItemToCard = () => { switch(type) { - case 'directory': return registerMusicToCard('play_folder', { folder: path, recursive: true }); - case 'file': return registerMusicToCard('play_single', { song_url: path.replace(`${DEFAULT_AUDIO_DIR}/`, '') }); + case 'directory': return registerMusicToCard('mpd.play_uri', { uri: `mpd:folder:${path}` }); + case 'file': return registerMusicToCard('mpd.play_uri', { uri: `mpd:file:${path}` }); // TODO: Add missing Podcast // TODO: Add missing Stream default: return; @@ -50,7 +53,7 @@ const FolderListItem = ({ type === 'directory' ? diff --git a/src/webapp/src/components/Library/lists/folders/folder-list.js b/src/webapp/src/components/Library/lists/folders/folder-list.js index 36a9bcebd..7a8fcfd72 100644 --- a/src/webapp/src/components/Library/lists/folders/folder-list.js +++ b/src/webapp/src/components/Library/lists/folders/folder-list.js @@ -9,28 +9,27 @@ import FolderListItemBack from './folder-list-item-back'; import { ROOT_DIRS } from '../../../../config'; const FolderList = ({ - dir, + path, folders, isSelecting, registerMusicToCard, }) => { - const getParentDir = (dir) => { - // TODO: ROOT_DIRS should be removed after paths are relative - const decodedDir = decodeURIComponent(dir); + const getParent = (path) => { + const decodedPath = decodeURIComponent(path); - if (ROOT_DIRS.includes(decodedDir)) return undefined; + // TODO: ROOT_DIRS should be removed after paths are relative + if (ROOT_DIRS.includes(decodedPath)) return undefined; - const parentDir = dropLast(1, decodedDir.split('/')).join('/'); - return parentDir; + return dropLast(1, decodedPath.split('/')).join('/') || './'; } - const parentDir = getParentDir(dir); + const parent = getParent(path); return ( - {parentDir && + {parent && } {folders.map((folder, key) => diff --git a/src/webapp/src/components/Library/lists/folders/index.js b/src/webapp/src/components/Library/lists/folders/index.js index 32bf47c33..9f075d4c7 100644 --- a/src/webapp/src/components/Library/lists/folders/index.js +++ b/src/webapp/src/components/Library/lists/folders/index.js @@ -16,7 +16,7 @@ const Folders = ({ registerMusicToCard, }) => { const { t } = useTranslation(); - const { dir = './' } = useParams(); + const { path = './' } = useParams(); const [folders, setFolders] = useState([]); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -33,8 +33,8 @@ const Folders = ({ const fetchFolderList = async () => { setIsLoading(true); const { result, error } = await request( - 'folderList', - { folder: decodeURIComponent(dir) } + 'mpd.get_files', + { path: decodeURIComponent(path) } ); setIsLoading(false); @@ -43,7 +43,7 @@ const Folders = ({ } fetchFolderList(); - }, [dir]); + }, [path]); const filteredFolders = folders.filter(search); @@ -56,7 +56,7 @@ const Folders = ({ return ( { return ( {isSelecting && } - + { element={} /> { {/* Skip previous track */} request('previous')} size="large" sx={iconStyles} @@ -89,20 +89,20 @@ const Controls = () => { {/* Play */} - {!playing && + {/* {!playing && */} request('play')} - disabled={!trackid} + // disabled={!trackid} size="large" sx={iconStyles} title={t('player.controls.play')} > - } + {/* } */} {/* Pause */} - {playing && + {/* {playing && */} request('pause')} @@ -112,12 +112,12 @@ const Controls = () => { > - } + {/* } */} {/* Skip next track */} request('next')} size="large" sx={iconStyles} From d47087ea9de6af13ce74818f8d3643bfa8334153 Mon Sep 17 00:00:00 2001 From: ChisSoc <75833833+ChisSoc@users.noreply.github.com> Date: Sat, 19 Feb 2022 06:27:14 +0100 Subject: [PATCH 052/109] Fix album decoding from URI --- .../components/playern/backends/interfacing_mpd.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/jukebox/components/playern/backends/interfacing_mpd.py b/src/jukebox/components/playern/backends/interfacing_mpd.py index b8e605a21..ff95906a0 100644 --- a/src/jukebox/components/playern/backends/interfacing_mpd.py +++ b/src/jukebox/components/playern/backends/interfacing_mpd.py @@ -24,8 +24,8 @@ class MPDBackend: def __init__(self, event_loop): self.client = MPDClient() self.loop = event_loop - self.host = cfg.getn('players', 'mpd', 'host', default='localhost') - self.port = '6600' + self.host = cfg.setndefault('players', 'mpd', 'host', value='localhost') + self.port = cfg.setndefault('players', 'mpd', 'port', value='6600') self._flavors = {'folder': self.get_files, 'file': self.get_track, 'album': self.get_album_from_uri, @@ -100,7 +100,6 @@ def next(self): def prev(self): return self._run_cmd(self.client.previous) - @plugin.tag def play(self, idx=None): """ If idx /= None, start playing song idx from queue @@ -145,7 +144,6 @@ def set_volume(self, value): # ---------------------------------- # Stuff that replaces the current playlist and starts a new playback for URI - @plugin.tag def play_uri(self, uri: str, **kwargs): """Decode URI and forward play call @@ -245,11 +243,11 @@ def get_album_tracks(self, album_artist, album): return self._run_cmd(self.client.find, 'albumartist', album_artist, 'album', album) def get_album_from_uri(self, uri: str): - """Accepts full or partial uri (partial means without leading 'mpd:')""" - p = re.match(r"(mpd:)?album:(.*):albumartist:(.*)", uri) + """Accepts full or partial uri (partial means without leading 'mpd:album:')""" + p = re.match(r"((mpd:)?album:)?(.*):albumartist:(.*)", uri) if not p: raise ValueError(f"Cannot decode album and/or album artist from URI: '{uri}'") - return self.get_album_tracks(album_artist=p.group(3), album=p.group(2)) + return self.get_album_tracks(album_artist=p.group(4), album=p.group(3)) # ---------------------------------- # Get podcasts / livestreams From c094839750b862124683eb1beaeda046bc68d8ce Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 9 Feb 2022 19:51:23 +0100 Subject: [PATCH 053/109] Disable autoplay --- resources/default-settings/spotify.config.toml | 2 +- .../components/playern/backends/{ => mpd}/interfacing_mpd.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/jukebox/components/playern/backends/{ => mpd}/interfacing_mpd.py (100%) diff --git a/resources/default-settings/spotify.config.toml b/resources/default-settings/spotify.config.toml index 29011b016..daf154c35 100644 --- a/resources/default-settings/spotify.config.toml +++ b/resources/default-settings/spotify.config.toml @@ -33,7 +33,7 @@ synchronizationMethod = "NTP" # Time synchronization method (NTP, PING, MELODY, manualCorrection = 0 # Manual time correction in millis [player] ### Player ### -autoplayEnabled = true # Autoplay similar songs when your music ends +autoplayEnabled = false # Autoplay similar songs when your music ends preferredAudioQuality = "NORMAL" # Preferred audio quality (NORMAL, HIGH, VERY_HIGH) enableNormalisation = true # Whether to apply the Spotify loudness normalisation normalisationPregain = +3.0 # Normalisation pregain in decibels (loud at +6, normal at +3, quiet at -5) diff --git a/src/jukebox/components/playern/backends/interfacing_mpd.py b/src/jukebox/components/playern/backends/mpd/interfacing_mpd.py similarity index 100% rename from src/jukebox/components/playern/backends/interfacing_mpd.py rename to src/jukebox/components/playern/backends/mpd/interfacing_mpd.py From ecf11dc26ee87d6a2c27b4f7833e5fdbe2f562d5 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 11 Feb 2022 10:19:58 +0100 Subject: [PATCH 054/109] First attempt Spotify Backend --- .../default-settings/jukebox.default.yaml | 3 +- .../spotify_collection.example.yaml | 9 ++ shared/audio/.gitkeep | 0 .../playern/backends/spotify/http_client.py | 90 ++++++++++++++ .../backends/spotify/interfacing_spotify.py | 91 +++++++++++++++ .../playern/backends/spotify/ws_client.py | 110 ++++++++++++++++++ .../components/playern/core/player_status.py | 39 +++++++ .../components/playern/plugin/__init__.py | 25 +++- 8 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 resources/default-settings/spotify_collection.example.yaml create mode 100644 shared/audio/.gitkeep create mode 100644 src/jukebox/components/playern/backends/spotify/http_client.py create mode 100644 src/jukebox/components/playern/backends/spotify/interfacing_spotify.py create mode 100644 src/jukebox/components/playern/backends/spotify/ws_client.py create mode 100644 src/jukebox/components/playern/core/player_status.py diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index 56cf5b0e0..5f0ed3240 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -13,7 +13,7 @@ modules: jingle.alsawave: jingle.alsawave jingle.jinglemp3: jingle.jinglemp3 player: playern.plugin - spotify: playerspot + # spotify: playerspot cards: rfid.cards rfid: rfid.reader timers: timers @@ -95,6 +95,7 @@ playermpd: playerspot: host: localhost status_file: ../../shared/settings/spotify_player_status.json + collection_file: ../../shared/audio/spotify/spotify_collection.yaml second_swipe_action: # Note: Does not follow the RPC alias convention (yet) # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' diff --git a/resources/default-settings/spotify_collection.example.yaml b/resources/default-settings/spotify_collection.example.yaml new file mode 100644 index 000000000..d662215d6 --- /dev/null +++ b/resources/default-settings/spotify_collection.example.yaml @@ -0,0 +1,9 @@ +playlist: + - name: Gute Nacht! + uri: spotify:playlist:37i9dQZF1DWSVYS2LMyMFg +album: + - name: 001/Die Ritterburg (und 5 weitere Geschichten) + uri: spotify:album:0IakudBXfN2aYXChl5sEK7 +track: + - name: Die Ritterburg - Teil 1 + uri: spotify:track:6Ac7ZQZZF57jeQnW6AVqY8 diff --git a/shared/audio/.gitkeep b/shared/audio/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/jukebox/components/playern/backends/spotify/http_client.py b/src/jukebox/components/playern/backends/spotify/http_client.py new file mode 100644 index 000000000..0ccc7eb05 --- /dev/null +++ b/src/jukebox/components/playern/backends/spotify/http_client.py @@ -0,0 +1,90 @@ +import json +import logging +import requests +from requests.adapters import HTTPAdapter +import urllib +from urllib3.util.retry import Retry + +logger = logging.getLogger('jb.spotify.SpotifyHttpClient') + + +class SpotifyHttpClient: + def __init__(self, host: str, port=24879): + self.protocol = 'http' + self.host = host + self.port = port + self.authority = f'{self.protocol}://{self.host}:{self.port}' + + self.session = requests.Session() + retries = Retry( + total=5, + backoff_factor=5, + status_forcelist=[500, 502, 503, 504] + ) + + self.session.mount( + self.protocol + '://', + HTTPAdapter(max_retries=retries) + ) + self.session.headers.update({'content-type': 'application/json'}) + logger.debug(f'Spotify HTTP Client initialized. Will connect to {self.authority}') + + def close(self): + logger.debug("Exiting Spotify HTTP session") + self._post_request('/instance/close') + + def _request(self, request_func, path: str): + try: + url = urllib.parse.urljoin(self.authority, path) + logger.debug(f'Requesting "{self.authority}"') + + response = request_func(url) + response.raise_for_status() + + except requests.HTTPError as http_error: + response = {} + logger.error(f'HTTPError: {http_error}') + + except Exception as error: + response = {} + logger.error(f'Error {error}') + + return json.loads(response.content) + + def _get_request(self, path: str): + response = self._request(self.session.get, path) + return response + + def _post_request(self, path: str): + response = self._request(self.session.post, path) + return response + + def get_status(self): + # json = self._get_request('/web-api/v1//me/player') + response_json = self._post_request('/player/current') + logger.debug(response_json) + return response_json + + def play_uri(self, uri: str, play: bool = True, shuffle: bool = False): + return self._post_request(f'/player/load?uri={uri}&play={play}&shuffle={shuffle}') + + def play(self): + return self._post_request('/player/resume') + + def pause(self): + return self._post_request('/player/pause') + + def prev(self): + return self._post_request('/player/prev') + + def next(self): + return self._post_request('/player/next') + + def seek(self, new_time: int): + return self._post_request(f'/player/seek?pos={new_time}') + + def shuffle(self, val: bool): + return self._post_request(f'/player/shuffle?val={val}') + + def repeat(self, val: str): + return self._post_request(f'/player/repeat?val={val}') diff --git a/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py b/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py new file mode 100644 index 000000000..3bf5fdae5 --- /dev/null +++ b/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py @@ -0,0 +1,91 @@ +# Copyright: 2022 +# SPDX License Identifier: MIT License + +import logging +import os.path + +from ruamel import yaml + +import jukebox.plugs as plugin +import jukebox.cfghandler +from components.playern.backends.spotify.http_client import SpotifyHttpClient +from components.playern.backends.spotify.ws_client import SpotifyWsClient + +logger = logging.getLogger('jb.spotify') +cfg = jukebox.cfghandler.get_handler('jukebox') + + +def sanitize(path: str): + return os.path.normpath(path).lstrip('./') + + +class SPOTBackend: + def __init__(self, player_status): + host = cfg.getn('playerspot', 'host') + self.player_status = player_status + + self.http_client = SpotifyHttpClient(host) + + self.ws_client = SpotifyWsClient( + host=host, + player_status=self.player_status + ) + self.ws_client.connect() + + self.collection_file_location = cfg.setndefault('playerspot', 'collection_file', + value="../../shared/audio/spotify/spotify_collection.yaml") + self.spotify_collection_data = self._read_data_file() + + def _read_data_file(self) -> dict: + try: + with open(self.collection_file_location, "r") as collection_file: + return yaml.safe_load(collection_file.read()) + except Exception as err: + logger.error(f"Could not open spotify collection file {self.collection_file_location}") + logger.debug(f"Error: {err}") + logger.debug("Continuing with empty dictionary") + return {} + + def play(self): + self.http_client.play() + + def pause(self): + self.http_client.pause() + + def prev(self): + self.http_client.prev() + + def next(self): + self.http_client.next() + + def toggle(self): + pass + + def get_queue(self): + pass + + @plugin.tag + def play_uri(self, uri: str, **kwargs): + """Decode URI and forward play call + + spotify:playlist:0 + --> search in the yaml-file for the type "playlist" and play the first uri + """ + player_type, list_type, index = uri.split(':', 2) + if player_type != 'spotify': + raise KeyError(f"URI prefix must be 'spotify' not '{player_type}") + + self.http_client.play_uri(self.spotify_collection_data.get(list_type)[int(index)].get("uri")) + + # ----------------------------------------------------- + # Queue / URI state (save + restore e.g. random, resume, ...) + + def save_state(self): + """Save the configuration and state of the current URI playback to the URIs state file""" + pass + + def _restore_state(self): + """ + Restore the configuration state and last played status for current active URI + """ + pass diff --git a/src/jukebox/components/playern/backends/spotify/ws_client.py b/src/jukebox/components/playern/backends/spotify/ws_client.py new file mode 100644 index 000000000..3750b93ee --- /dev/null +++ b/src/jukebox/components/playern/backends/spotify/ws_client.py @@ -0,0 +1,110 @@ +import json +import logging +import websocket +import threading + +logger = logging.getLogger("jb.spotify.SpotifyWsClient") + + +class SpotifyWsClient: + def __init__(self, host: str, player_status, port: int = 24879): + self.protocol = 'ws' + self.host = host + self.port = port + self.url = f'{self.protocol}://{self.host}:{self.port}/events' + + self.player_status = player_status + + self.socket = None + self.thread = None + + self.state_callbacks = { + 'playbackPaused': self.playback_paused, + 'playbackResumed': self.playback_resumed, + 'playbackHaltStateChanged': self.playback_halted, + 'trackSeeked': self.track_seeked, + 'metadataAvailable': self.metadata_available, + 'inactiveSession': self.inactive_session, + } + + logger.debug('Spotify WS Client initialized') + + def connect(self): + websocket.enableTrace(True) + self.socket = websocket.WebSocketApp( + self.url, + on_close=self._on_close, + on_error=self._on_error, + on_message=self._on_message + ) + self.thread = threading.Thread(target=self.socket.run_forever) + self.thread.daemon = True + self.thread.start() + + logger.debug(f'Websocket connection established to {self.url}') + + def close(self): + self.socket.close() + + def _on_message(self, socket, message): + logger.debug(f'_on_message: {message}') + data = json.loads(message) + event = data['event'] + + callback = self.state_callbacks.get(event) + if not callback: + raise ValueError(event) + + callback(data) + + def _on_close(self, socket): + logger.debug('Connection with websocket server closed') + + def _on_error(self, socket, error): + logger.error(f'Websocket error: {error}') + + # We only care about seconds, not ms as provided by Spotify + def _round_time_to_seconds(self, time): + return '{:.1f}'.format(time / 1000) + + def metadata_available(self, data: dict): + cover_art = data['track']['album']['coverGroup']['image'][2]['fileId'].lower() + + self.player_status.update( + player='Spotify', # TODO: Should this be done differently? + trackid=data['track']['gid'], + title=data['track']['name'], + artist=data['track']['artist'][0]['name'], + album=data['track']['album']['name'], + albumartist=data['track']['album']['artist'][0]['name'], + duration=self._round_time_to_seconds(data['track']['duration']), + coverArt=cover_art + ) + + def playback_paused(self, data: dict): + self.player_status.update( + playing=False, + elapsed=self._round_time_to_seconds(data['trackTime']) + ) + + def playback_resumed(self, data: dict): + self.player_status.update( + playing=True, + elapsed=self._round_time_to_seconds(data['trackTime']) + ) + + def playback_halted(self, data: dict): + self.player_status.update( + playing=data['halted'], + elapsed=self._round_time_to_seconds(data['trackTime']) + ) + + def track_seeked(self, data: dict): + self.player_status.update( + elapsed=self._round_time_to_seconds(data['trackTime']) + ) + + # When Spotify session is routed to another device, + # the local session goes inactive + def inactive_session(self, data: dict): + self.player_status.update(playing=False) diff --git a/src/jukebox/components/playern/core/player_status.py b/src/jukebox/components/playern/core/player_status.py new file mode 100644 index 000000000..b7fccb3a2 --- /dev/null +++ b/src/jukebox/components/playern/core/player_status.py @@ -0,0 +1,39 @@ +import logging +from jukebox import publishing + +logger = logging.getLogger('jb.player') + + +class PlayerStatus: + STATUS = { + 'album': '', + 'albumartist': '', + 'artist': '', + 'coverArt': '', + 'duration': 0, + 'elapsed': 0, + 'file': '', # required for MPD // check if really is required + 'player': '', # TODO: TBD, Spotify or MPD + 'playing': False, + 'shuffle': False, + 'repeat': 0, + 'title': '', + 'trackid': '', + } + + def __init__(self): + self._player_status = self.STATUS + + def update(self, **kwargs): + for key, value in kwargs.items(): + if key in self.STATUS: + self._player_status[key] = value + + self.publish() + + def publish(self): + logger.debug(f'Published: {self._player_status}') + return publishing.get_publisher().send( + 'player_status', + self._player_status + ) diff --git a/src/jukebox/components/playern/plugin/__init__.py b/src/jukebox/components/playern/plugin/__init__.py index ed04b31e3..2850a7e6f 100644 --- a/src/jukebox/components/playern/plugin/__init__.py +++ b/src/jukebox/components/playern/plugin/__init__.py @@ -8,9 +8,10 @@ import jukebox.plugs as plugin import jukebox.cfghandler -from components.playern.backends.interfacing_mpd import MPDBackend +from components.playern.backends.mpd.interfacing_mpd import MPDBackend +from components.playern.backends.spotify.interfacing_spotify import SPOTBackend from components.playern.core import PlayerCtrl - +from components.playern.core.player_status import PlayerStatus logger = logging.getLogger('jb.player') cfg = jukebox.cfghandler.get_handler('jukebox') @@ -21,8 +22,12 @@ # The top-level player arbiter that acts as the single interface to the outside player_arbiter: PlayerCtrl +# Player status needed for webapp +player_status: PlayerStatus + # The various backends backend_mpd: Optional[MPDBackend] = None +backend_spot: Optional[SPOTBackend] = None def start_event_loop(loop: asyncio.AbstractEventLoop): @@ -46,10 +51,22 @@ def register_mpd(): player_arbiter.register('mpd', backend_mpd) +def register_spotify(): + global backend_spot + global player_arbiter + global player_status + + backend_spot = SPOTBackend(player_status) + # Register with plugin interface to call directly + plugin.register(backend_spot, package='player', name='spotify') + player_arbiter.register('spotify', backend_spot) + + @plugin.initialize def init(): global event_loop global player_arbiter + global player_status # Create the event loop and start it in a background task # the event loop can be shared across different backends (if the backends require a async event loop) event_loop = asyncio.new_event_loop() @@ -58,8 +75,12 @@ def init(): player_arbiter = PlayerCtrl() + player_status = PlayerStatus() + player_status.publish() + # Create and register the players (this is explicit for the moment) register_mpd() + register_spotify() plugin.register(player_arbiter, package='player', name='ctrl') From 839f1ffeb27864654412bd0ae7d19161e71c7255 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 11 Feb 2022 10:25:14 +0100 Subject: [PATCH 055/109] Flake 8 corrections --- src/jukebox/components/players/player_main.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/jukebox/components/players/player_main.py b/src/jukebox/components/players/player_main.py index 0e98a3907..aa5c67882 100644 --- a/src/jukebox/components/players/player_main.py +++ b/src/jukebox/components/players/player_main.py @@ -27,9 +27,9 @@ How it works: -The backends register the flavor(s) with the top-level player control. The WebApp goes through player.play(flavor, uri) to -trigger a playback. Function like next, prev also go through the player control - here the currently active flavor is automatically -selected by the player control. +The backends register the flavor(s) with the top-level player control. The WebApp goes through player.play(flavor, uri) +to trigger a playback. Function like next, prev also go through the player control - here the currently active flavor is +automatically selected by the player control. To get all playlists and/or playlist entries, the WebApp also goes through the player control - it is the same function but takes different arguments depending on player control. And returns different results (possibly in different formats?) @@ -56,7 +56,8 @@ player.get_content(flavor=folder, uri='connie_backt') > [01-song.mp3, 02-intro, ...] - NOTE: list and get_content return not only names of files, but list of tuples which also contain path and filetype (dir, file) + NOTE: list and get_content return not only names of files, but list of tuples which also contain path and + filetype (dir, file) ... @@ -105,7 +106,8 @@ def register(self, flavor: str, backend, play_callable: Callable, get_list_callable: Callable, get_content_callable: Callable): - self._flavors[flavor] = PlayerFlavorEntry(flavor, backend, play_callable, get_list_callable, get_content_callable) + self._flavors[flavor] = PlayerFlavorEntry(flavor, backend, play_callable, get_list_callable, + get_content_callable) def play(self, flavor, check_second_swipe=False, **kwargs): # Save the current state (if something is playing) @@ -241,12 +243,13 @@ def initialize(): mpd = BackendMPD() player.register('album', mpd, - lambda album_artist, album, **ignored_kwargs: mpd.play_album(album_artist=album_artist, album=album), + lambda album_artist, album, **ignored_kwargs: mpd.play_album(album_artist=album_artist, + album=album), lambda **ignored_kwargs: mpd.get_album_list(), - lambda album_artist, album, **ignored_kwargs: mpd.get_album_content(album_artist=album_artist, album=album)) + lambda album_artist, album, **ignored_kwargs: mpd.get_album_content(album_artist=album_artist, + album=album)) player.register('folder', mpd, lambda uri, recursive=False, **ignore_kwargs: mpd.play_folder(uri=uri, recursive=recursive), lambda uri, **ignore_kwargs: mpd.get_folder_list(uri=uri), lambda uri, **ignore_kwargs: mpd.get_folder_content(uri=uri)) - From 5e3bc6db710ae58bfa39cf9a728e6f53d06f5e9c Mon Sep 17 00:00:00 2001 From: Groovylein Date: Thu, 24 Feb 2022 19:51:43 +0100 Subject: [PATCH 056/109] Simplified internal Spotify URI --- .../spotify_collection.example.yaml | 16 +++++++--------- .../backends/spotify/interfacing_spotify.py | 4 ++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/resources/default-settings/spotify_collection.example.yaml b/resources/default-settings/spotify_collection.example.yaml index d662215d6..a2acec798 100644 --- a/resources/default-settings/spotify_collection.example.yaml +++ b/resources/default-settings/spotify_collection.example.yaml @@ -1,9 +1,7 @@ -playlist: - - name: Gute Nacht! - uri: spotify:playlist:37i9dQZF1DWSVYS2LMyMFg -album: - - name: 001/Die Ritterburg (und 5 weitere Geschichten) - uri: spotify:album:0IakudBXfN2aYXChl5sEK7 -track: - - name: Die Ritterburg - Teil 1 - uri: spotify:track:6Ac7ZQZZF57jeQnW6AVqY8 +# You can add your spotify uri's in this yaml file +# please stick to the syntax: +# - name: +# Example: +# - name: Gute Nacht! +# uri: spotify:playlist:37i9dQZF1DWSVYS2LMyMFg diff --git a/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py b/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py index 3bf5fdae5..4db37ab4d 100644 --- a/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py +++ b/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py @@ -71,11 +71,11 @@ def play_uri(self, uri: str, **kwargs): spotify:playlist:0 --> search in the yaml-file for the type "playlist" and play the first uri """ - player_type, list_type, index = uri.split(':', 2) + player_type, index = uri.split(':', 1) if player_type != 'spotify': raise KeyError(f"URI prefix must be 'spotify' not '{player_type}") - self.http_client.play_uri(self.spotify_collection_data.get(list_type)[int(index)].get("uri")) + self.http_client.play_uri(self.spotify_collection_data[int(index)].get("uri")) # ----------------------------------------------------- # Queue / URI state (save + restore e.g. random, resume, ...) From a118f70e1b971e73ba3505717d0b914a8034a4e8 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Thu, 24 Feb 2022 21:15:43 +0100 Subject: [PATCH 057/109] bugfixing connection loss of Spotify websocket --- .../components/playern/backends/spotify/ws_client.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/jukebox/components/playern/backends/spotify/ws_client.py b/src/jukebox/components/playern/backends/spotify/ws_client.py index 3750b93ee..0263cef2c 100644 --- a/src/jukebox/components/playern/backends/spotify/ws_client.py +++ b/src/jukebox/components/playern/backends/spotify/ws_client.py @@ -1,5 +1,7 @@ import json import logging +import time + import websocket import threading @@ -57,8 +59,11 @@ def _on_message(self, socket, message): callback(data) - def _on_close(self, socket): - logger.debug('Connection with websocket server closed') + def _on_close(self, socket, close_status_code, close_message): + logger.debug(f'Connection with websocket server closed with {close_status_code}:{close_message}') + time.sleep(15) + logger.debug("Retrying to connect") + self.connect() def _on_error(self, socket, error): logger.error(f'Websocket error: {error}') From 731c9a90d0b54001a7d97584998b1a4a09ea0b58 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sun, 1 May 2022 20:15:09 +0200 Subject: [PATCH 058/109] Bugfixing: player.ctrl.list_backends show only keys --- src/jukebox/components/playern/core/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/jukebox/components/playern/core/__init__.py b/src/jukebox/components/playern/core/__init__.py index 0180e27b7..5dfc0192a 100644 --- a/src/jukebox/components/playern/core/__init__.py +++ b/src/jukebox/components/playern/core/__init__.py @@ -48,7 +48,8 @@ def get_active(self): @plugin.tag def list_backends(self): - return [b for b in self._backends.items()] + logger.debug(f"Backend list: {self._backends.items()}") + return [b for b in self._backends.keys()] @plugin.tag def play_uri(self, uri, check_second_swipe=False, **kwargs): From 23a0f82a549c34a4fbec8fc32c4c15e8d63edaec Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 23 May 2022 21:21:04 +0200 Subject: [PATCH 059/109] Bugfixing: spotify player --- .../playern/backends/spotify/http_client.py | 9 ++++++++- .../backends/spotify/interfacing_spotify.py | 15 ++++++++++++++- .../playern/backends/spotify/ws_client.py | 8 ++++++++ src/jukebox/components/playern/plugin/__init__.py | 2 +- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/jukebox/components/playern/backends/spotify/http_client.py b/src/jukebox/components/playern/backends/spotify/http_client.py index 0ccc7eb05..1a8ab179a 100644 --- a/src/jukebox/components/playern/backends/spotify/http_client.py +++ b/src/jukebox/components/playern/backends/spotify/http_client.py @@ -49,7 +49,14 @@ def _request(self, request_func, path: str): response = {} logger.error(f'Error {error}') - return json.loads(response.content) + if response.content: + logger.debug(f"Request response.content: {response.content}") + return json.loads(response.content) + else: + logger.debug("Request response.content empty") + return {} + + # no JSON returned def _get_request(self, path: str): response = self._request(self.session.get, path) diff --git a/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py b/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py index 4db37ab4d..56385f09c 100644 --- a/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py +++ b/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py @@ -52,6 +52,15 @@ def play(self): def pause(self): self.http_client.pause() + def stop(self): + try: + is_playing = self.http_client.get_status()['current'] + logger.debug(f"Current player playing status: {is_playing}") + if is_playing: + self.http_client.pause() + except Exception as err: + logger.debug("No status information if Spotify is playing something.") + def prev(self): self.http_client.prev() @@ -75,7 +84,11 @@ def play_uri(self, uri: str, **kwargs): if player_type != 'spotify': raise KeyError(f"URI prefix must be 'spotify' not '{player_type}") - self.http_client.play_uri(self.spotify_collection_data[int(index)].get("uri")) + self.http_client.play_uri(uri) + + @plugin.tag + def get_status(self): + self.http_client.get_status() # ----------------------------------------------------- # Queue / URI state (save + restore e.g. random, resume, ...) diff --git a/src/jukebox/components/playern/backends/spotify/ws_client.py b/src/jukebox/components/playern/backends/spotify/ws_client.py index 0263cef2c..9f46efc9a 100644 --- a/src/jukebox/components/playern/backends/spotify/ws_client.py +++ b/src/jukebox/components/playern/backends/spotify/ws_client.py @@ -24,9 +24,11 @@ def __init__(self, host: str, player_status, port: int = 24879): 'playbackPaused': self.playback_paused, 'playbackResumed': self.playback_resumed, 'playbackHaltStateChanged': self.playback_halted, + 'trackChanged': self.track_changed, 'trackSeeked': self.track_seeked, 'metadataAvailable': self.metadata_available, 'inactiveSession': self.inactive_session, + 'contextChanged': self.context_changed, } logger.debug('Spotify WS Client initialized') @@ -104,11 +106,17 @@ def playback_halted(self, data: dict): elapsed=self._round_time_to_seconds(data['trackTime']) ) + def track_changed(self, data: dict): + pass + def track_seeked(self, data: dict): self.player_status.update( elapsed=self._round_time_to_seconds(data['trackTime']) ) + def context_changed(self, data: dict): + pass + # When Spotify session is routed to another device, # the local session goes inactive def inactive_session(self, data: dict): diff --git a/src/jukebox/components/playern/plugin/__init__.py b/src/jukebox/components/playern/plugin/__init__.py index 2850a7e6f..8a37ef77e 100644 --- a/src/jukebox/components/playern/plugin/__init__.py +++ b/src/jukebox/components/playern/plugin/__init__.py @@ -79,8 +79,8 @@ def init(): player_status.publish() # Create and register the players (this is explicit for the moment) - register_mpd() register_spotify() + register_mpd() plugin.register(player_arbiter, package='player', name='ctrl') From bd8af97baf4f8a52605356298af4100b4784824a Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 27 Jan 2023 21:18:09 +0100 Subject: [PATCH 060/109] prepare Docker dor spotipy --- docker/config/docker.mpd.conf | 4 +- docker/docker-compose.linux.yml | 24 +- docker/docker-compose_spotify.yml | 54 ++++ docker/jukebox_with_spotify.Dockerfile | 40 +++ requirements_with_spotify.txt | 40 +++ src/jukebox/components/players/__init__.py | 123 --------- .../components/players/mpd/__init__.py | 27 -- src/jukebox/components/players/player_main.py | 255 ------------------ .../components/players/player_status.py | 41 --- .../components/players/spotify/__init__.py | 93 ------- .../components/players/spotify/http_client.py | 98 ------- .../components/players/spotify/ws_client.py | 110 -------- 12 files changed, 146 insertions(+), 763 deletions(-) create mode 100644 docker/docker-compose_spotify.yml create mode 100644 docker/jukebox_with_spotify.Dockerfile create mode 100644 requirements_with_spotify.txt delete mode 100644 src/jukebox/components/players/__init__.py delete mode 100644 src/jukebox/components/players/mpd/__init__.py delete mode 100644 src/jukebox/components/players/player_main.py delete mode 100644 src/jukebox/components/players/player_status.py delete mode 100644 src/jukebox/components/players/spotify/__init__.py delete mode 100644 src/jukebox/components/players/spotify/http_client.py delete mode 100644 src/jukebox/components/players/spotify/ws_client.py diff --git a/docker/config/docker.mpd.conf b/docker/config/docker.mpd.conf index 4ec64890e..f42611201 100644 --- a/docker/config/docker.mpd.conf +++ b/docker/config/docker.mpd.conf @@ -83,7 +83,7 @@ user "root" # activation is in use. # # For network -bind_to_address "any" +bind_to_address "0.0.0.0" # # And for Unix Socket #bind_to_address "/run/mpd/socket" @@ -98,7 +98,7 @@ port "6600" # argument is recommended for troubleshooting, though can quickly stretch # available resources on limited hardware storage. # -log_level "default" +log_level "verbose" # # Setting "restore_paused" to "yes" puts MPD into pause mode instead # of starting playback after startup. diff --git a/docker/docker-compose.linux.yml b/docker/docker-compose.linux.yml index c5221e17f..182881be0 100755 --- a/docker/docker-compose.linux.yml +++ b/docker/docker-compose.linux.yml @@ -1,14 +1,10 @@ -version: "3.9" - -services: - mpd: - devices: - - /dev/snd - - spotify: - devices: - - /dev/snd - - jukebox: - devices: - - /dev/snd +version: "3.9" + +services: + mpd: + devices: + - /dev/snd + + jukebox: + devices: + - /dev/snd diff --git a/docker/docker-compose_spotify.yml b/docker/docker-compose_spotify.yml new file mode 100644 index 000000000..c7b5b2361 --- /dev/null +++ b/docker/docker-compose_spotify.yml @@ -0,0 +1,54 @@ +version: "3.9" + +services: + mpd: + build: + context: ../ + dockerfile: ./docker/mpd.Dockerfile + container_name: mpd + ports: + - 6600:6600 + - 8800:8800 + restart: unless-stopped + volumes: + - ../shared/audiofolders:/home/pi/RPi-Jukebox-RFID/shared/audiofolders:rw + - ../shared/playlists:/root/.config/mpd/playlists:rw + - ./config/docker.mpd.conf:/root/.config/mpd/mpd.conf:rw + + jukebox: + build: + context: ../ + dockerfile: ./docker/jukebox_with_spotify.Dockerfile + container_name: jukebox + depends_on: + - mpd + links: + - mpd + ports: + - 5555:5555 + - 5556:5556 + - 5557:5557 + restart: unless-stopped + tty: true + volumes: + - ../src/jukebox:/home/pi/RPi-Jukebox-RFID/src/jukebox + - ../shared:/home/pi/RPi-Jukebox-RFID/shared + - ./config/docker.mpd.conf:/etc/mpd.conf + + webapp: + build: + context: ../ + dockerfile: ./docker/webapp.Dockerfile + container_name: webapp + depends_on: + - jukebox + environment: + - CHOKIDAR_USEPOLLING=true + links: + - jukebox + ports: + - 3001:3000 + restart: unless-stopped + volumes: + - ../src/webapp:/home/node/webapp + - /home/node/webapp/node_modules diff --git a/docker/jukebox_with_spotify.Dockerfile b/docker/jukebox_with_spotify.Dockerfile new file mode 100644 index 000000000..42254d16e --- /dev/null +++ b/docker/jukebox_with_spotify.Dockerfile @@ -0,0 +1,40 @@ +FROM debian:bullseye-slim + +# Prepare Raspberry Pi like environment + +# These are only dependencies that are required to get as close to the +# Raspberry Pi environment as possible. +RUN apt-get update && apt-get install -y \ + alsa-utils \ + libasound2-dev \ + libasound2-plugins \ + pulseaudio \ + pulseaudio-utils \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +RUN usermod -aG audio,pulse,pulse-access root + +ENV HOME /root +ENV INSTALLATION_PATH /home/pi/RPi-Jukebox-RFID + +WORKDIR $INSTALLATION_PATH + +# Jukebox +# Install all Jukebox dependencies +RUN apt-get update && apt-get install -qq -y \ + --allow-downgrades --allow-remove-essential --allow-change-held-packages \ + gcc at wget \ + espeak mpc mpg123 git ffmpeg spi-tools netcat alsa-tools \ + python3 python3-dev python3-pip python3-mutagen python3-gpiozero + +COPY . ${INSTALLATION_PATH} + +RUN pip3 install --no-cache-dir -r ${INSTALLATION_PATH}/requirements_with_spotify.txt +RUN pip3 install pyzmq + +EXPOSE 5555 5556 + +# Run Jukebox +# CMD bash +CMD python3 ${INSTALLATION_PATH}/src/jukebox/run_jukebox.py diff --git a/requirements_with_spotify.txt b/requirements_with_spotify.txt new file mode 100644 index 000000000..d5a259cdc --- /dev/null +++ b/requirements_with_spotify.txt @@ -0,0 +1,40 @@ +# Note: +# sudo apt install libasound2-dev required on some machines + +# Jukebox Core +# For USB inputs (reader, buttons) and bluetooth buttons +evdev +pyalsaaudio +pulsectl +python_mpd2 +ruamel.yaml +# For playlistgenerator +requests +# For music_cover_art +eyed3 +# For the publisher event reactor loop: +tornado +# For spotify +websocket-client +urllib3 +spotipy + +# RPi's GPIO packages: these are installed via APT on the PI +# On regular machines, install them manually if needed for development +# RPi.GPIO +# gpiozero + +# PyZMQ is a special case: +# On the PI, it needs to be compiled with special options to enable Websocket support +# On regular Linux PCs, Websocket is enabled in the Python package +# pyzmq + +# Documentation build flow +sphinx +sphinx_rtd_theme + +# Code quality +flake8 +pytest +mock + diff --git a/src/jukebox/components/players/__init__.py b/src/jukebox/components/players/__init__.py deleted file mode 100644 index 89cd6861c..000000000 --- a/src/jukebox/components/players/__init__.py +++ /dev/null @@ -1,123 +0,0 @@ -import logging -import jukebox.cfghandler -import jukebox.plugs as plugin - -from .player_status import PlayerStatus -# from .mpd import MpdPlayerBuilder -from .spotify import SpotifyPlayerBuilder - -logger = logging.getLogger('jb.players') -cfg = jukebox.cfghandler.get_handler('jukebox') - - -class PlayersFactory: - def __init__(self): - self._builders = {} - - def register_builder(self, key, builder): - self._builders[key] = builder - - def create(self, key, **kwargs): - builder = self._builders.get(key) - if not builder: - raise ValueError(key) - return builder(**kwargs) - - def get(self, player_name, **kwargs): - return self.create(player_name, **kwargs) - - -factory: PlayersFactory - - -@plugin.initialize -def initialize(): - global players - player_status = PlayerStatus() - player_status.publish() - players = PlayersFactory() - players.register_builder('Spotify', SpotifyPlayerBuilder(player_status)) - # players.register_builder('MPD', MpdPlayerBuilder()) - - -@plugin.atexit -def atexit(**ignored_kwargs): - global players - for player in players.keys(): - players.get(player).exit() - - -@plugin.register -def play_single(player: str, uri: str): - """Play a single song""" - players.get(player).play_single(uri) - - -# TODO: Currently not implemented for MPD -@plugin.register -def play_playlist(player: str, uri: str): - """Play a playlist""" - if player == 'Spotify': - players.get(player).play_playlist(uri) - - -@plugin.register -def play_album(player: str, album: str, albumartist: str = None): - """Play an album""" - if player == 'MPD': - if not albumartist: - return logger.error('Missing arguments for MPD operation, skipping operation') - - return players.get(player).play_album(album, albumartist) - - if player == 'Spotify': - return players.get(player).play_album(uri=album) - - -@plugin.register -def play_folder(player: str, folder: str): - """Play a folder""" - if player == 'MPD': - players.get(player).play_folder(folder) - - -@plugin.register -def play(player: str): - """Start playing the current song""" - players.get(player).play() - - -@plugin.register -def pause(player: str): - """Pause playback""" - players.get(player).pause() - - -@plugin.register -def prev(player: str): - """Skip to previous track""" - players.get(player).prev() - - -@plugin.register -def next(player: str): - """Skip to next track""" - players.get(player).next() - - -@plugin.register -def shuffle(player: str, value: int = -1): - """Toggle or set shuffle (-1 toggle, 0 no suffle, 1 shuffle)""" - players.get(player).shuffle(value) - - -@plugin.register -def repeat(player: str, value: int = -1): - """Toggle or set repeat (-1 toggle, 0 no repeat, 1 context, 2 single)""" - players.get(player).repeat(value) - - -@plugin.register -def seek(player: str, pos: int = 0): - """Seek to a position of the current song in ms""" - players.get(player).seek(pos) diff --git a/src/jukebox/components/players/mpd/__init__.py b/src/jukebox/components/players/mpd/__init__.py deleted file mode 100644 index 4a0e2c434..000000000 --- a/src/jukebox/components/players/mpd/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -import logging -import mpd - -logger = logging.getLogger('jb.players.mpd') - - -# MPD Interface -class MpdPlayer: - def __init__(self): - logger.debug('Init MPD') - self.mpd_client = mpd.MPDClient() # This is pseudo code, not functionl yet - - def play_single(self, uri: str): - self.mpd_client.clear() - self.mpd_client.addid(uri) - self.mpd_client.play() - - -class MpdPlayerBuilder: - def __init__(self): - self._instance = None - - def __call__(self, **_ignored): - if not self._instance: - self._instance = MpdPlayer() - - return self._instance diff --git a/src/jukebox/components/players/player_main.py b/src/jukebox/components/players/player_main.py deleted file mode 100644 index aa5c67882..000000000 --- a/src/jukebox/components/players/player_main.py +++ /dev/null @@ -1,255 +0,0 @@ -""" -Backend: - An interface to an player we use for playback, i.e. MDP or Spotify - -Flavor: - An abstraction layer between backend and API, to enable different types of playback contents with the same backend - With MPD, we can playback by album or disk folder. - We also use MPD to play back Podcasts (with a custom decoder int the middle) - This all needs different ways of retrieving and storing playlists, status, etc - But, we still want to use the same MPD instance - -PlayerCtrl: - Top-level player which abstracts the various flavors and backends. All play, stop etc commands controlling - current playback go through this instance. This is mandatory, as we may need to switch between player backends from - one playback to another - -Playlist: - Any kind of concatenated set of songs that somehow belong together and are indexed by a URI. - E.g.the content folder, the songs of an album, the music files of a podcast - -URI: - The link to a playlist. I.e. the path to a directory, the album_artist & album to identify the album - the path to a podcast link file, spotify uri file, ... - -Queue: - The currently active playlist as loaded in the backend - -How it works: - -The backends register the flavor(s) with the top-level player control. The WebApp goes through player.play(flavor, uri) -to trigger a playback. Function like next, prev also go through the player control - here the currently active flavor is -automatically selected by the player control. - -To get all playlists and/or playlist entries, the WebApp also goes through the player control - it is the same function -but takes different arguments depending on player control. And returns different results (possibly in different formats?) - -Displaying what can be played back: - -get_list: List all possible playlists (can be playlists only for a URI prefix - e.g. a folder) - -get_content: List all songs in a playlist - -Examples: - player.play(flavor=folder, uri='conni_backt') - > plays folder content of audiopath/conni_backt/* - - player.play(flavor=spotify, uri=lausemaus/leo_will_nicht_teilen.spotify') - > plays a spotify playlist for which the Spotify URI is in file lausemaus/leo_will_nicht_teilen.spotify - - player.play(flavor=album, album_artist=benjamin, album=ImZirkus) - > plays form MPD database the songs from the album that matches album_artist=benjamin, album=ImZirkus) - - player.get_list(flavor=folder, uri='.') - > [conni_backt, connie_zeltet] - - player.get_content(flavor=folder, uri='connie_backt') - > [01-song.mp3, 02-intro, ...] - - NOTE: list and get_content return not only names of files, but list of tuples which also contain path and - filetype (dir, file) - - ... - -""" - -from typing import Dict, Callable, Optional - - -class PlayerFlavorEntry: - def __init__(self, flavor_name: str, - backend, - play_callable: Callable, - get_list_callable: Callable, - get_content_callable: Callable): - self._backend = backend - self._flavor_name = flavor_name - self._play = play_callable - self._list = get_list_callable - self._content = get_content_callable - - @property - def play(self): - return self._play - - @property - def get_list(self): - return self._list - - @property - def get_content(self): - return self._content - - def __getattr__(self, attr): - """Forward all not specially mapped function calls to the actual backend""" - return getattr(self.backend, attr) - - -class PlayerCtrl: - """The top-level player instance through which all calls go. Arbitrates between the different backends""" - - def __init__(self): - self._flavors: Dict[str, PlayerFlavorEntry] = {} - self._active: Optional[PlayerFlavorEntry] = None - - def register(self, flavor: str, backend, - play_callable: Callable, - get_list_callable: Callable, - get_content_callable: Callable): - self._flavors[flavor] = PlayerFlavorEntry(flavor, backend, play_callable, get_list_callable, - get_content_callable) - - def play(self, flavor, check_second_swipe=False, **kwargs): - # Save the current state (if something is playing) - # Stop the current playback - # Decode card second swipe - # And finally play - self._active = self._flavors[flavor] - self._active.play(**kwargs) - - def stop(self): - # Save current state for resume functionality - self._save_state() - - self._active.stop() - - def next(self): - self._active.next() - - def get_queue(self): - self._active.get_queue() - - def _save_state(self): - # Get the backend to save the state of the current playlist to the URI's config file - self._active.save_queue_state_to_uri() - # Also need to save which backend and URI was currently playing to be able to restore it after reboot - pass - - -class BackendMPD: - """Example Backend for MPD - do the same for other backends""" - - # def play(self, uri): - # # Get URI state - # get_uri_state() - # # Apply last state - # # play - - # ---------------------------------- - # Stuff that replaces the current playlist and starts a new playback for URI - # - - def play_folder(self, uri, recursive=False): - pass - - def play_single(self, uri): - pass - - def play_album(self, album_artist: str, album: str): - pass - - def play_podcast(self, uri): - pass - - # ---------------------------------- - # Get lists of playlists (one for each flavor) - - def get_folder_list(self, uri): - """List folder contents (files and directories)""" - pass - - def get_album_list(self): - """Returns all albums in database""" - pass - - def get_podcast_list(self, uri): - """List all podcasts in directory :attr:`uri`""" - pass - - # ---------------------------------- - # Get all songs of a playlists (one function for each flavor) - - def get_folder_content(self, uri): - """Just to unify the API for all flavors""" - return self.get_folder_list(uri) - - def get_album_content(self, album_artist, album): - """Returns all song of an album""" - pass - - def get_podcast_content(self, uri): - """Lists playlist of one podcast file""" - pass - - # ---------------------------------- - # Stuff that controls current playback (i.e. moves around in the current playlist, termed "the queue") - - def next(self): - pass - - def seek(self, time): - """Seek to position :attr:`time` in current song""" - pass - - def jump(self, position): - """Jump to song at position is in the active playback queue""" - # Play song (id, uri, ##?) that is in current playlist - # but without changing the current playlist (i.e. like going next, next, next instead of play_single(URI) - pass - - # ---------------------------------- - # Stuff that modifies the queue or informs about it - # We do not allow modifying the queue at the moment - - def get_queue(self): - """Displays a list of all songs in the currently active playlist""" - pass - - # ---------------------------------- - # Modifying playback behaviour - - def set_queue_config(self, resume=None, random=None, single=None, loop=None): - """Sets the config for the currently active playback - - These settings will also be saved automatically to URI config!""" - pass - - def save_queue_state_to_uri(self): - """Save the current queue state (resume, random, ...) and current song position to the URI the queue was loaded from""" - # Get state (resume, ..., elapsed, current song) - # Save to database - pass - - # ---------------------------------- - # Modifying playlist's config independent of the current queue - - def set_playlist_config(self, uri, resume=None, random=None, single=None, loop=None): - """Change the config for a specific playlist w/o touching current playback""" - pass - - -def initialize(): - player = PlayerCtrl() - mpd = BackendMPD() - player.register('album', - mpd, - lambda album_artist, album, **ignored_kwargs: mpd.play_album(album_artist=album_artist, - album=album), - lambda **ignored_kwargs: mpd.get_album_list(), - lambda album_artist, album, **ignored_kwargs: mpd.get_album_content(album_artist=album_artist, - album=album)) - player.register('folder', - mpd, - lambda uri, recursive=False, **ignore_kwargs: mpd.play_folder(uri=uri, recursive=recursive), - lambda uri, **ignore_kwargs: mpd.get_folder_list(uri=uri), - lambda uri, **ignore_kwargs: mpd.get_folder_content(uri=uri)) diff --git a/src/jukebox/components/players/player_status.py b/src/jukebox/components/players/player_status.py deleted file mode 100644 index f5b9f476d..000000000 --- a/src/jukebox/components/players/player_status.py +++ /dev/null @@ -1,41 +0,0 @@ -import logging -import jukebox.cfghandler -from jukebox import publishing - -logger = logging.getLogger('jb.players.player_status') -cfg = jukebox.cfghandler.get_handler('jukebox') - - -class PlayerStatus: - STATUS = { - 'album': '', - 'albumartist': '', - 'artist': '', - 'coverArt': '', - 'duration': 0, - 'elapsed': 0, - 'file': '', # required for MPD // check if really is required - 'player': '', # TODO: TBD, Spotify or MPD - 'playing': False, - 'shuffle': False, - 'repeat': 0, - 'title': '', - 'trackid': '', - } - - def __init__(self): - self._player_status = self.STATUS - - def update(self, **kwargs): - for key, value in kwargs.items(): - if key in self.STATUS: - self._player_status[key] = value - - self.publish() - - def publish(self): - logger.debug(f'Published: {self._player_status}') - return publishing.get_publisher().send( - 'player_status', - self._player_status - ) diff --git a/src/jukebox/components/players/spotify/__init__.py b/src/jukebox/components/players/spotify/__init__.py deleted file mode 100644 index 171a9ba88..000000000 --- a/src/jukebox/components/players/spotify/__init__.py +++ /dev/null @@ -1,93 +0,0 @@ -import logging -import jukebox.cfghandler - -from .http_client import SpotifyHttpClient -from .ws_client import SpotifyWsClient - -logger = logging.getLogger('jb.players.spotify') -cfg = jukebox.cfghandler.get_handler('jukebox') - - -# Spotify Interface -class SpotifyPlayer: - def __init__(self, player_status): - logger.debug('Init Spotify') - host = cfg.getn('playerspot', 'host') - self.player_status = player_status - - self.http_client = SpotifyHttpClient(host) - - self.ws_client = SpotifyWsClient( - host=host, - player_status=self.player_status - ) - self.ws_client.connect() - - def exit(self): - logger.debug('Exiting Spotify ...') - self.http_client.close() - self.ws_client.close() - - # TODO: Stop playout after the song - # Spotify would continue automatically - def play_single(self, uri: str): - if not uri.startswith('spotify:track:'): - return logger.error('Provided URI does not match a single track') - - self.http_client.play_uri(uri) - - def play_album(self, uri: str): - if not uri.startswith('spotify:album:'): - return logger.error('Provided ID does not match an album URI') - - self.http_client.play_uri(uri) - - def play_playlist(self, uri: str): - if not uri.startswith('spotify:playlist:'): - return logger.error('Provided URI does not match a playlist') - - self.http_client.play_uri(uri) - - def play(self): - self.http_client.play() - - def pause(self): - self.http_client.pause() - - def prev(self): - self.http_client.prev() - - def next(self): - self.http_client.next() - - def shuffle(self, value: int = -1): - if value > -1: - return self.http_client.shuffle(value) - # TODO: Get status first and determine current shuffle state - # else: - # return self.http_client.shuffle(value) - - def repeat(self, value: int = -1): - # if value == 0: - # state = 'none' - # elif value == 1: - # state = 'context' - # elif value == 2: - # state = 'track' - # else: - # # TODO: Get status first and determine current repeat state - # state = 'none' - - return self.http_client.repeat(value) - - -class SpotifyPlayerBuilder: - def __init__(self, player_status): - self._instance = None - self._player_status = player_status - - def __call__(self, **_ignored): - if not self._instance: - self._instance = SpotifyPlayer(self._player_status) - - return self._instance diff --git a/src/jukebox/components/players/spotify/http_client.py b/src/jukebox/components/players/spotify/http_client.py deleted file mode 100644 index 7b25b1782..000000000 --- a/src/jukebox/components/players/spotify/http_client.py +++ /dev/null @@ -1,98 +0,0 @@ -import json -import logging -import requests -from requests.adapters import HTTPAdapter -import urllib -from urllib3.util.retry import Retry - -logger = logging.getLogger('jb.SpotifyHttpClient') - - -class SpotifyHttpClient: - def __init__(self, host: str, port=24879): - self.protocol = 'http' - self.host = host - self.port = port - self.authority = f'{self.protocol}://{self.host}:{self.port}' - - self.session = requests.Session() - retries = Retry( - total=5, - backoff_factor=5, - status_forcelist=[500, 502, 503, 504] - ) - - self.session.mount( - self.protocol + '://', - HTTPAdapter(max_retries=retries) - ) - self.session.headers.update({'content-type': 'application/json'}) - logger.debug(f'Spotify HTTP Client initialized. Will connect to {self.authority}') - - def close(self): - logger.debug("Exiting Spotify HTTP session") - self._post_request('/instance/close') - - def _request(self, request_func, path: str): - try: - url = urllib.parse.urljoin(self.authority, path) - logger.debug(f'Requesting "{self.authority}"') - - response = request_func(url) - response.raise_for_status() - - except requests.HTTPError as http_error: - response = {} - logger.error(f'HTTPError: {http_error}') - - except Exception as error: - response = {} - logger.error(f'Error {error}') - - return json.loads(response.content) - - def _get_request(self, path: str): - response = self._request(self.session.get, path) - return response - - def _post_request(self, path: str): - response = self._request(self.session.post, path) - return response - - def get_status(self): - # json = self._get_request('/web-api/v1//me/player') - json = self._post_request('/player/current') - logger.debug(json) - return json - - def play_uri(self, uri: str, play: bool = True, shuffle: bool = False): - json = self._post_request(f'/player/load?uri={uri}&play={play}&shuffle={shuffle}') - return json - - def play(self): - json = self._post_request('/player/resume') - return json - - def pause(self): - json = self._post_request('/player/pause') - return json - - def prev(self): - json = self._post_request('/player/prev') - return json - - def next(self): - json = self._post_request('/player/next') - return json - - def seek(self, new_time: int): - json = self._post_request(f'/player/seek?pos={new_time}') - return json - - def shuffle(self, val: bool): - json = self._post_request(f'/player/shuffle?val={val}') - return json - - def repeat(self, val: str): - json = self._post_request(f'/player/repeat?val={val}') - return json diff --git a/src/jukebox/components/players/spotify/ws_client.py b/src/jukebox/components/players/spotify/ws_client.py deleted file mode 100644 index 96e34015b..000000000 --- a/src/jukebox/components/players/spotify/ws_client.py +++ /dev/null @@ -1,110 +0,0 @@ -import json -import logging -import websocket -import threading - -logger = logging.getLogger("jb.SpotifyWsClient") - - -class SpotifyWsClient: - def __init__(self, host: str, player_status, port: int = 24879): - self.protocol = 'ws' - self.host = host - self.port = port - self.url = f'{self.protocol}://{self.host}:{self.port}/events' - - self.player_status = player_status - - self.socket = None - self.thread = None - - self.state_callbacks = { - 'playbackPaused': self.playback_paused, - 'playbackResumed': self.playback_resumed, - 'playbackHaltStateChanged': self.playback_halted, - 'trackSeeked': self.track_seeked, - 'metadataAvailable': self.metadata_available, - 'inactiveSession': self.inactive_session, - } - - logger.debug('Spotify WS Client initialized') - - def connect(self): - websocket.enableTrace(True) - self.socket = websocket.WebSocketApp( - self.url, - on_close=self._on_close, - on_error=self._on_error, - on_message=self._on_message - ) - self.thread = threading.Thread(target=self.socket.run_forever) - self.thread.daemon = True - self.thread.start() - - logger.debug(f'Websocket connection established to {self.url}') - - def close(self): - self.socket.close() - - def _on_message(self, socket, message): - logger.debug(f'_on_message: {message}') - data = json.loads(message) - event = data['event'] - - callback = self.state_callbacks.get(event) - if not callback: - raise ValueError(event) - - callback(data) - - def _on_close(self, socket): - logger.debug('Connection with websocket server closed') - - def _on_error(self, socket, error): - logger.error(f'Websocket error: {error}') - - # We only care about seconds, not ms as provided by Spotify - def _round_time_to_seconds(self, time): - return '{:.1f}'.format(time / 1000) - - def metadata_available(self, data: dict): - cover_art = data['track']['album']['coverGroup']['image'][2]['fileId'].lower() - - self.player_status.update( - player='Spotify', # TODO: Should this be done differently? - trackid=data['track']['gid'], - title=data['track']['name'], - artist=data['track']['artist'][0]['name'], - album=data['track']['album']['name'], - albumartist=data['track']['album']['artist'][0]['name'], - duration=self._round_time_to_seconds(data['track']['duration']), - coverArt=cover_art - ) - - def playback_paused(self, data: dict): - self.player_status.update( - playing=False, - elapsed=self._round_time_to_seconds(data['trackTime']) - ) - - def playback_resumed(self, data: dict): - self.player_status.update( - playing=True, - elapsed=self._round_time_to_seconds(data['trackTime']) - ) - - def playback_halted(self, data: dict): - self.player_status.update( - playing=data['halted'], - elapsed=self._round_time_to_seconds(data['trackTime']) - ) - - def track_seeked(self, data: dict): - self.player_status.update( - elapsed=self._round_time_to_seconds(data['trackTime']) - ) - - # When Spotify session is routed to another device, - # the local session goes inactive - def inactive_session(self, data: dict): - self.player_status.update(playing=False) From aa8443d4b215d22846a943974cc80fcd978a670a Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 27 Jan 2023 21:18:46 +0100 Subject: [PATCH 061/109] first attempt --- .../playern/backends/mpd/interfacing_mpd.py | 4 +- .../backends/spotify/interfacing_spotify.py | 46 ++++++++----------- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/src/jukebox/components/playern/backends/mpd/interfacing_mpd.py b/src/jukebox/components/playern/backends/mpd/interfacing_mpd.py index ff95906a0..b5d364245 100644 --- a/src/jukebox/components/playern/backends/mpd/interfacing_mpd.py +++ b/src/jukebox/components/playern/backends/mpd/interfacing_mpd.py @@ -24,8 +24,8 @@ class MPDBackend: def __init__(self, event_loop): self.client = MPDClient() self.loop = event_loop - self.host = cfg.setndefault('players', 'mpd', 'host', value='localhost') - self.port = cfg.setndefault('players', 'mpd', 'port', value='6600') + self.host = cfg.setndefault('playermpd', 'host', value='localhost') + self.port = cfg.setndefault('playermpd', 'port', value='6600') self._flavors = {'folder': self.get_files, 'file': self.get_track, 'album': self.get_album_from_uri, diff --git a/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py b/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py index 56385f09c..77e51bd72 100644 --- a/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py +++ b/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py @@ -8,8 +8,9 @@ import jukebox.plugs as plugin import jukebox.cfghandler -from components.playern.backends.spotify.http_client import SpotifyHttpClient -from components.playern.backends.spotify.ws_client import SpotifyWsClient + +from spotipy.oauth2 import SpotifyOAuth +import spotipy logger = logging.getLogger('jb.spotify') cfg = jukebox.cfghandler.get_handler('jukebox') @@ -23,18 +24,17 @@ class SPOTBackend: def __init__(self, player_status): host = cfg.getn('playerspot', 'host') self.player_status = player_status + self.client_id = cfg.setndefault('playerspot', 'client_id', value='Phoniebox') + self.client_secret = cfg.setndefault('playerspot', 'client_secret', value='Phoniebox_secret') + self.redirect_uri = cfg.setndefault('playerspot', 'callback_url', + value='https://localhost:8888/callback') + self.auth_manager = SpotifyOAuth(scope="streaming", client_id=self.client_id, client_secret=self.client_id, redirect_uri=self.redirect_uri) - self.http_client = SpotifyHttpClient(host) - - self.ws_client = SpotifyWsClient( - host=host, - player_status=self.player_status - ) - self.ws_client.connect() + self.spot_client = spotipy.Spotify(auth_manager=self.auth_manager) - self.collection_file_location = cfg.setndefault('playerspot', 'collection_file', - value="../../shared/audio/spotify/spotify_collection.yaml") - self.spotify_collection_data = self._read_data_file() + #self.collection_file_location = cfg.setndefault('playerspot', 'collection_file', + # value="../../shared/audio/spotify/spotify_collection.yaml") + #self.spotify_collection_data = self._read_data_file() def _read_data_file(self) -> dict: try: @@ -47,26 +47,19 @@ def _read_data_file(self) -> dict: return {} def play(self): - self.http_client.play() + self.spot_client.start_playback(self.client_id) def pause(self): - self.http_client.pause() + self.spot_client.pause_playback(self.client_id) def stop(self): - try: - is_playing = self.http_client.get_status()['current'] - logger.debug(f"Current player playing status: {is_playing}") - if is_playing: - self.http_client.pause() - except Exception as err: - logger.debug("No status information if Spotify is playing something.") + self.spot_client.pause_playback(self.client_id) def prev(self): - self.http_client.prev() + self.spot_client.previous_track(self.client_id) def next(self): - self.http_client.next() - + self.spot_client.next_track() def toggle(self): pass @@ -84,11 +77,12 @@ def play_uri(self, uri: str, **kwargs): if player_type != 'spotify': raise KeyError(f"URI prefix must be 'spotify' not '{player_type}") - self.http_client.play_uri(uri) + self.spot_client.start_playback(self.client_id, uri) @plugin.tag def get_status(self): - self.http_client.get_status() + logger.debug(self.spot_client.current_playback()) + self.spot_client.current_playback() # ----------------------------------------------------- # Queue / URI state (save + restore e.g. random, resume, ...) From 9d059b4d8c22bc47498ce456eab52226b7203225 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 1 Feb 2023 12:56:09 +0100 Subject: [PATCH 062/109] First successful attempt with cached data --- .../backends/spotify/interfacing_spotify.py | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py b/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py index 77e51bd72..ea23875f5 100644 --- a/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py +++ b/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py @@ -3,8 +3,10 @@ import logging import os.path +import os from ruamel import yaml +from spotipy import CacheFileHandler import jukebox.plugs as plugin import jukebox.cfghandler @@ -24,18 +26,39 @@ class SPOTBackend: def __init__(self, player_status): host = cfg.getn('playerspot', 'host') self.player_status = player_status + self.cache_handler = CacheFileHandler(cache_path='../../shared/spotify/') + self.cache_file = '../../shared/spotify/.spotipyoauthcache' self.client_id = cfg.setndefault('playerspot', 'client_id', value='Phoniebox') self.client_secret = cfg.setndefault('playerspot', 'client_secret', value='Phoniebox_secret') self.redirect_uri = cfg.setndefault('playerspot', 'callback_url', - value='https://localhost:8888/callback') - self.auth_manager = SpotifyOAuth(scope="streaming", client_id=self.client_id, client_secret=self.client_id, redirect_uri=self.redirect_uri) + value='http://localhost:3001') - self.spot_client = spotipy.Spotify(auth_manager=self.auth_manager) + spot_scope = "user-read-playback-state,user-modify-playback-state" + self.auth_manager = SpotifyOAuth(open_browser=False, scope=spot_scope, client_id=self.client_id, client_secret=self.client_id, redirect_uri=self.redirect_uri, cache_path=sanitize(self.cache_file)) + self.auth_uri = self.auth_manager.get_authorize_url() + logger.info(f"Please log in here: {self.auth_uri}") #self.collection_file_location = cfg.setndefault('playerspot', 'collection_file', # value="../../shared/audio/spotify/spotify_collection.yaml") #self.spotify_collection_data = self._read_data_file() + @plugin.tag + def init_spotclient(self, spot_code=None): + token_info = self.auth_manager.get_cached_token() + logger.debug(f"Token Info: {token_info}") + + if token_info: + logger.debug("Found cached token for Spotify Client!") + access_token = token_info['access_token'] + else: + # ToDo: implement this within the web app + token_info = self.auth_manager.get_access_token(spot_code) + access_token = token_info['access_token'] + + if access_token: + self.spot_client = spotipy.Spotify(access_token) + self.auth_code = cfg.setndefault('playerspot', 'auth_code', value=access_token) + def _read_data_file(self) -> dict: try: with open(self.collection_file_location, "r") as collection_file: @@ -47,19 +70,20 @@ def _read_data_file(self) -> dict: return {} def play(self): - self.spot_client.start_playback(self.client_id) + return self.spot_client.start_playback() def pause(self): - self.spot_client.pause_playback(self.client_id) + return self.spot_client.pause_playback() def stop(self): - self.spot_client.pause_playback(self.client_id) + return self.spot_client.pause_playback() def prev(self): - self.spot_client.previous_track(self.client_id) + return self.spot_client.previous_track() def next(self): - self.spot_client.next_track() + return self.spot_client.next_track() + def toggle(self): pass @@ -77,12 +101,11 @@ def play_uri(self, uri: str, **kwargs): if player_type != 'spotify': raise KeyError(f"URI prefix must be 'spotify' not '{player_type}") - self.spot_client.start_playback(self.client_id, uri) + return self.spot_client.start_playback(context_uri=uri) @plugin.tag def get_status(self): - logger.debug(self.spot_client.current_playback()) - self.spot_client.current_playback() + return self.spot_client.current_user() # ----------------------------------------------------- # Queue / URI state (save + restore e.g. random, resume, ...) From 6ec73e25f466eff9b69755ca897b422b5b138b1b Mon Sep 17 00:00:00 2001 From: Kiriakos Antoniadis Date: Wed, 5 Apr 2023 07:13:57 +0000 Subject: [PATCH 063/109] Cleanup spotify player --- docker/docker-compose.mac.yml | 7 - docker/docker-compose.windows.yml | 4 - docker/docker-compose.yml | 13 -- docker/docker-compose_spotify.yml | 54 -------- docker/jukebox_with_spotify.Dockerfile | 40 ------ docker/spotify.Dockerfile | 25 ---- installation/includes/01_default_config.sh | 3 - installation/routines/customize_options.sh | 37 ------ installation/routines/install.sh | 1 - installation/routines/setup_spotify.sh | 49 ------- requirements.txt | 3 - requirements_with_spotify.txt | 40 ------ shared/spotify/.gitkeep | 0 .../playern/backends/spotify/http_client.py | 97 -------------- .../backends/spotify/interfacing_spotify.py | 121 ----------------- .../playern/backends/spotify/ws_client.py | 123 ------------------ 16 files changed, 617 deletions(-) delete mode 100644 docker/docker-compose_spotify.yml delete mode 100644 docker/jukebox_with_spotify.Dockerfile delete mode 100644 docker/spotify.Dockerfile delete mode 100644 installation/routines/setup_spotify.sh delete mode 100644 requirements_with_spotify.txt delete mode 100644 shared/spotify/.gitkeep delete mode 100644 src/jukebox/components/playern/backends/spotify/http_client.py delete mode 100644 src/jukebox/components/playern/backends/spotify/interfacing_spotify.py delete mode 100644 src/jukebox/components/playern/backends/spotify/ws_client.py diff --git a/docker/docker-compose.mac.yml b/docker/docker-compose.mac.yml index 49819d1b9..27163cb93 100644 --- a/docker/docker-compose.mac.yml +++ b/docker/docker-compose.mac.yml @@ -9,13 +9,6 @@ services: - ~/.config/pulse:/root/.config/pulse - /usr/local/Cellar/pulseaudio/14.2/etc/pulse:/etc/pulse - spotify: - environment: - - PULSE_SERVER=tcp:host.docker.internal:4713 - volumes: - - ~/.config/pulse:/root/.config/pulse - - /usr/local/Cellar/pulseaudio/14.2/etc/pulse:/etc/pulse - jukebox: environment: - PULSE_SERVER=tcp:host.docker.internal:4713 diff --git a/docker/docker-compose.windows.yml b/docker/docker-compose.windows.yml index 6bba5aa8d..51134451d 100755 --- a/docker/docker-compose.windows.yml +++ b/docker/docker-compose.windows.yml @@ -7,10 +7,6 @@ services: volumes: - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf:rw - spotify: - environment: - - PULSE_SERVER=tcp:host.docker.internal:4713 - jukebox: environment: - PULSE_SERVER=tcp:host.docker.internal:4713 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7edd6a229..73c462c31 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,18 +15,6 @@ services: - ../shared/playlists:/root/.config/mpd/playlists:rw - ./config/docker.mpd.conf:/root/.config/mpd/mpd.conf:rw - spotify: - build: - context: ../ - dockerfile: ./docker/spotify.Dockerfile - container_name: spotify - ports: - - 12345:12345 - - 24879:24879 - restart: unless-stopped - volumes: - - ./config/docker.spotify.config.toml:/home/pi/librespot-java/config.toml:rw - jukebox: build: context: ../ @@ -34,7 +22,6 @@ services: container_name: jukebox depends_on: - mpd - - spotify links: - mpd ports: diff --git a/docker/docker-compose_spotify.yml b/docker/docker-compose_spotify.yml deleted file mode 100644 index c7b5b2361..000000000 --- a/docker/docker-compose_spotify.yml +++ /dev/null @@ -1,54 +0,0 @@ -version: "3.9" - -services: - mpd: - build: - context: ../ - dockerfile: ./docker/mpd.Dockerfile - container_name: mpd - ports: - - 6600:6600 - - 8800:8800 - restart: unless-stopped - volumes: - - ../shared/audiofolders:/home/pi/RPi-Jukebox-RFID/shared/audiofolders:rw - - ../shared/playlists:/root/.config/mpd/playlists:rw - - ./config/docker.mpd.conf:/root/.config/mpd/mpd.conf:rw - - jukebox: - build: - context: ../ - dockerfile: ./docker/jukebox_with_spotify.Dockerfile - container_name: jukebox - depends_on: - - mpd - links: - - mpd - ports: - - 5555:5555 - - 5556:5556 - - 5557:5557 - restart: unless-stopped - tty: true - volumes: - - ../src/jukebox:/home/pi/RPi-Jukebox-RFID/src/jukebox - - ../shared:/home/pi/RPi-Jukebox-RFID/shared - - ./config/docker.mpd.conf:/etc/mpd.conf - - webapp: - build: - context: ../ - dockerfile: ./docker/webapp.Dockerfile - container_name: webapp - depends_on: - - jukebox - environment: - - CHOKIDAR_USEPOLLING=true - links: - - jukebox - ports: - - 3001:3000 - restart: unless-stopped - volumes: - - ../src/webapp:/home/node/webapp - - /home/node/webapp/node_modules diff --git a/docker/jukebox_with_spotify.Dockerfile b/docker/jukebox_with_spotify.Dockerfile deleted file mode 100644 index 42254d16e..000000000 --- a/docker/jukebox_with_spotify.Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -FROM debian:bullseye-slim - -# Prepare Raspberry Pi like environment - -# These are only dependencies that are required to get as close to the -# Raspberry Pi environment as possible. -RUN apt-get update && apt-get install -y \ - alsa-utils \ - libasound2-dev \ - libasound2-plugins \ - pulseaudio \ - pulseaudio-utils \ - --no-install-recommends \ - && rm -rf /var/lib/apt/lists/* - -RUN usermod -aG audio,pulse,pulse-access root - -ENV HOME /root -ENV INSTALLATION_PATH /home/pi/RPi-Jukebox-RFID - -WORKDIR $INSTALLATION_PATH - -# Jukebox -# Install all Jukebox dependencies -RUN apt-get update && apt-get install -qq -y \ - --allow-downgrades --allow-remove-essential --allow-change-held-packages \ - gcc at wget \ - espeak mpc mpg123 git ffmpeg spi-tools netcat alsa-tools \ - python3 python3-dev python3-pip python3-mutagen python3-gpiozero - -COPY . ${INSTALLATION_PATH} - -RUN pip3 install --no-cache-dir -r ${INSTALLATION_PATH}/requirements_with_spotify.txt -RUN pip3 install pyzmq - -EXPOSE 5555 5556 - -# Run Jukebox -# CMD bash -CMD python3 ${INSTALLATION_PATH}/src/jukebox/run_jukebox.py diff --git a/docker/spotify.Dockerfile b/docker/spotify.Dockerfile deleted file mode 100644 index 59730cb1f..000000000 --- a/docker/spotify.Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM debian:bullseye-slim - -RUN set -eux ; \ - apt-get update && apt-get install -y \ - alsa-utils \ - libasound2-dev \ - libasound2-plugins \ - pulseaudio \ - pulseaudio-utils \ - default-jre - -RUN usermod -aG audio,pulse,pulse-access root - -ENV INSTALLATION_PATH /home/pi/librespot-java -ENV LIBRESPOT_JAVA_VERSION 1.6.2 - -WORKDIR $INSTALLATION_PATH -VOLUME $INSTALLATION_PATH - -ADD https://github.com/librespot-org/librespot-java/releases/download/v${LIBRESPOT_JAVA_VERSION}/librespot-api-${LIBRESPOT_JAVA_VERSION}.jar ${INSTALLATION_PATH} - -EXPOSE 12345 -EXPOSE 24879 - -CMD java -jar librespot-api-${LIBRESPOT_JAVA_VERSION}.jar diff --git a/installation/includes/01_default_config.sh b/installation/includes/01_default_config.sh index 8cf205d25..02401a630 100644 --- a/installation/includes/01_default_config.sh +++ b/installation/includes/01_default_config.sh @@ -11,9 +11,6 @@ DISABLE_SSH_QOS=true DISABLE_BOOT_SCREEN=true DISABLE_BOOT_LOGS_PRINT=true SETUP_MPD=true -SETUP_SPOTIFY=false -SPOTIFY_USERNAME="ANONYMOUS" -SPOTIFY_PASSWORD="PASSWORD" UPDATE_RASPI_OS=false UPDATE_RASPI_OS=${UPDATE_RASPI_OS:-"true"} ENABLE_SAMBA=true diff --git a/installation/routines/customize_options.sh b/installation/routines/customize_options.sh index 41f43aad7..ad7c99817 100644 --- a/installation/routines/customize_options.sh +++ b/installation/routines/customize_options.sh @@ -239,42 +239,6 @@ Do you want to install Node now? [Y/n] " 1>&3 fi } -_option_spotify() { - # SETUP_SPOTIFY - echo "Do you want to enable Spotify? -You need Spotify Premium to use these functionality. -[y/N] " 1>&3 - read -r response - case "$response" in - [yY]) - SETUP_SPOTIFY=true - ;; - *) - ;; - esac - - if [ "$SETUP_SPOTIFY" = true ]; then - while [ "${spotify_username}" == "" ] - do - echo "Please provide your spotify username." 1>&3 - read -r spotify_username - done - SPOTIFY_USERNAME="${spotify_username}" - - while [ "${spotify_password}" == "" ] - do - echo "Please provide your spotify password." 1>&3 - read -r -s spotify_password - done - SPOTIFY_PASSWORD="${spotify_password}" - - echo "SETUP_SPOTIFY=${SETUP_SPOTIFY}" - if [ "$SETUP_SPOTIFY" = true ]; then - echo "SPOTIFY_USERNAME=${SPOTIFY_USERNAME}" - echo "SPOTIFY_PASSWORD=${SPOTIFY_PASSWORD}" - fi - fi -} customize_options() { @@ -287,7 +251,6 @@ customize_options() { _option_disable_onboard_audio _option_samba _option_webapp - _option_spotify _option_build_local_docs if [[ $ENABLE_WEBAPP == true ]] ; then _option_kiosk_mode diff --git a/installation/routines/install.sh b/installation/routines/install.sh index dce15095d..f7db0e1d5 100644 --- a/installation/routines/install.sh +++ b/installation/routines/install.sh @@ -11,7 +11,6 @@ install() { if [ "$ENABLE_SAMBA" = true ] ; then setup_samba; fi; if [ "$ENABLE_WEBAPP" = true ] ; then setup_jukebox_webapp; fi; if [ "$ENABLE_KIOSK_MODE" = true ] ; then setup_kiosk_mode; fi; - if [ "$SETUP_SPOTIFY" = true ] ; then setup_spotify; fi; setup_rfid_reader optimize_boot_time if [ "$ENABLE_AUTOHOTSPOT" = true ] ; then setup_autohotspot; fi; diff --git a/installation/routines/setup_spotify.sh b/installation/routines/setup_spotify.sh deleted file mode 100644 index f183fcca1..000000000 --- a/installation/routines/setup_spotify.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash - -# Constants -LIBRESPOT_JAVA_VERSION="1.6.1" -LIBRESPOT_JAVA_JAR="librespot-api-${LIBRESPOT_JAVA_VERSION}.jar" -LIBRESPOT_JAVA_API_URL="https://github.com/librespot-org/librespot-java/releases/download/v${LIBRESPOT_JAVA_VERSION}/${LIBRESPOT_JAVA_JAR}" - - -_install_packages() { - echo "Installing openjdk-11-jre package to be able to run librespot-java" - sudo apt-get -y install openjdk-11-jre -} - -_download_jar() { - echo "Downloading API jar from github" - wget -O "${SHARED_PATH}/spotify/${LIBRESPOT_JAVA_JAR}" "${LIBRESPOT_JAVA_API_URL}" -} - -_configure_librespot_java() { - echo "Placing config file and inserting username and password" - SPOTIFY_CONFIG_FILE="${SHARED_PATH}/spotify/config.toml" - cp "${INSTALLATION_PATH}"/resources/default-settings/spotify.config.toml "${SPOTIFY_CONFIG_FILE}" - sed -i "s/HERE_USERNAME/${SPOTIFY_USERNAME}/g" "${SPOTIFY_CONFIG_FILE}" - sed -i "s/HERE_PASSWORD/${SPOTIFY_PASSWORD}/g" "${SPOTIFY_CONFIG_FILE}" -} - -_install_service() { - echo "Installing jukebox-spotify service" - SPOTIFY_SERVICE_RESOURCE="${INSTALLATION_PATH}/resources/default-services/jukebox-spotify.service" - sed -i "s#HERE_DIR#${SHARED_PATH}/spotify#g" "${SPOTIFY_SERVICE_RESOURCE}" - sed -i "s#HERE_JAR_FILE#${SHARED_PATH}/spotify/${LIBRESPOT_JAVA_JAR}#g" "${SPOTIFY_SERVICE_RESOURCE}" - - sudo cp -f "${SPOTIFY_SERVICE_RESOURCE}" "${SYSTEMD_PATH}" - sudo chmod 644 "${SYSTEMD_PATH}"/jukebox-spotify.service - - sudo systemctl enable jukebox-spotify.service - sudo systemctl daemon-reload -} - -setup_spotify() { - echo "Install Spotify functionality" | tee /dev/fd/3 - - _install_packages - _download_jar - _configure_librespot_java - _install_service - - echo "DONE: setup_spotify" -} diff --git a/requirements.txt b/requirements.txt index 68dbe5dde..f96de684c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,9 +14,6 @@ requests eyed3 # For the publisher event reactor loop: tornado -# For spotify -websocket-client -urllib3 # RPi's GPIO packages: these are installed via APT on the PI # On regular machines, install them manually if needed for development diff --git a/requirements_with_spotify.txt b/requirements_with_spotify.txt deleted file mode 100644 index d5a259cdc..000000000 --- a/requirements_with_spotify.txt +++ /dev/null @@ -1,40 +0,0 @@ -# Note: -# sudo apt install libasound2-dev required on some machines - -# Jukebox Core -# For USB inputs (reader, buttons) and bluetooth buttons -evdev -pyalsaaudio -pulsectl -python_mpd2 -ruamel.yaml -# For playlistgenerator -requests -# For music_cover_art -eyed3 -# For the publisher event reactor loop: -tornado -# For spotify -websocket-client -urllib3 -spotipy - -# RPi's GPIO packages: these are installed via APT on the PI -# On regular machines, install them manually if needed for development -# RPi.GPIO -# gpiozero - -# PyZMQ is a special case: -# On the PI, it needs to be compiled with special options to enable Websocket support -# On regular Linux PCs, Websocket is enabled in the Python package -# pyzmq - -# Documentation build flow -sphinx -sphinx_rtd_theme - -# Code quality -flake8 -pytest -mock - diff --git a/shared/spotify/.gitkeep b/shared/spotify/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/jukebox/components/playern/backends/spotify/http_client.py b/src/jukebox/components/playern/backends/spotify/http_client.py deleted file mode 100644 index 1a8ab179a..000000000 --- a/src/jukebox/components/playern/backends/spotify/http_client.py +++ /dev/null @@ -1,97 +0,0 @@ -import json -import logging -import requests -from requests.adapters import HTTPAdapter -import urllib -from urllib3.util.retry import Retry - -logger = logging.getLogger('jb.spotify.SpotifyHttpClient') - - -class SpotifyHttpClient: - def __init__(self, host: str, port=24879): - self.protocol = 'http' - self.host = host - self.port = port - self.authority = f'{self.protocol}://{self.host}:{self.port}' - - self.session = requests.Session() - retries = Retry( - total=5, - backoff_factor=5, - status_forcelist=[500, 502, 503, 504] - ) - - self.session.mount( - self.protocol + '://', - HTTPAdapter(max_retries=retries) - ) - self.session.headers.update({'content-type': 'application/json'}) - logger.debug(f'Spotify HTTP Client initialized. Will connect to {self.authority}') - - def close(self): - logger.debug("Exiting Spotify HTTP session") - self._post_request('/instance/close') - - def _request(self, request_func, path: str): - try: - url = urllib.parse.urljoin(self.authority, path) - logger.debug(f'Requesting "{self.authority}"') - - response = request_func(url) - response.raise_for_status() - - except requests.HTTPError as http_error: - response = {} - logger.error(f'HTTPError: {http_error}') - - except Exception as error: - response = {} - logger.error(f'Error {error}') - - if response.content: - logger.debug(f"Request response.content: {response.content}") - return json.loads(response.content) - else: - logger.debug("Request response.content empty") - return {} - - # no JSON returned - - def _get_request(self, path: str): - response = self._request(self.session.get, path) - return response - - def _post_request(self, path: str): - response = self._request(self.session.post, path) - return response - - def get_status(self): - # json = self._get_request('/web-api/v1//me/player') - response_json = self._post_request('/player/current') - logger.debug(response_json) - return response_json - - def play_uri(self, uri: str, play: bool = True, shuffle: bool = False): - return self._post_request(f'/player/load?uri={uri}&play={play}&shuffle={shuffle}') - - def play(self): - return self._post_request('/player/resume') - - def pause(self): - return self._post_request('/player/pause') - - def prev(self): - return self._post_request('/player/prev') - - def next(self): - return self._post_request('/player/next') - - def seek(self, new_time: int): - return self._post_request(f'/player/seek?pos={new_time}') - - def shuffle(self, val: bool): - return self._post_request(f'/player/shuffle?val={val}') - - def repeat(self, val: str): - return self._post_request(f'/player/repeat?val={val}') diff --git a/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py b/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py deleted file mode 100644 index ea23875f5..000000000 --- a/src/jukebox/components/playern/backends/spotify/interfacing_spotify.py +++ /dev/null @@ -1,121 +0,0 @@ -# Copyright: 2022 -# SPDX License Identifier: MIT License - -import logging -import os.path -import os - -from ruamel import yaml -from spotipy import CacheFileHandler - -import jukebox.plugs as plugin -import jukebox.cfghandler - -from spotipy.oauth2 import SpotifyOAuth -import spotipy - -logger = logging.getLogger('jb.spotify') -cfg = jukebox.cfghandler.get_handler('jukebox') - - -def sanitize(path: str): - return os.path.normpath(path).lstrip('./') - - -class SPOTBackend: - def __init__(self, player_status): - host = cfg.getn('playerspot', 'host') - self.player_status = player_status - self.cache_handler = CacheFileHandler(cache_path='../../shared/spotify/') - self.cache_file = '../../shared/spotify/.spotipyoauthcache' - self.client_id = cfg.setndefault('playerspot', 'client_id', value='Phoniebox') - self.client_secret = cfg.setndefault('playerspot', 'client_secret', value='Phoniebox_secret') - self.redirect_uri = cfg.setndefault('playerspot', 'callback_url', - value='http://localhost:3001') - - spot_scope = "user-read-playback-state,user-modify-playback-state" - self.auth_manager = SpotifyOAuth(open_browser=False, scope=spot_scope, client_id=self.client_id, client_secret=self.client_id, redirect_uri=self.redirect_uri, cache_path=sanitize(self.cache_file)) - self.auth_uri = self.auth_manager.get_authorize_url() - logger.info(f"Please log in here: {self.auth_uri}") - - #self.collection_file_location = cfg.setndefault('playerspot', 'collection_file', - # value="../../shared/audio/spotify/spotify_collection.yaml") - #self.spotify_collection_data = self._read_data_file() - - @plugin.tag - def init_spotclient(self, spot_code=None): - token_info = self.auth_manager.get_cached_token() - logger.debug(f"Token Info: {token_info}") - - if token_info: - logger.debug("Found cached token for Spotify Client!") - access_token = token_info['access_token'] - else: - # ToDo: implement this within the web app - token_info = self.auth_manager.get_access_token(spot_code) - access_token = token_info['access_token'] - - if access_token: - self.spot_client = spotipy.Spotify(access_token) - self.auth_code = cfg.setndefault('playerspot', 'auth_code', value=access_token) - - def _read_data_file(self) -> dict: - try: - with open(self.collection_file_location, "r") as collection_file: - return yaml.safe_load(collection_file.read()) - except Exception as err: - logger.error(f"Could not open spotify collection file {self.collection_file_location}") - logger.debug(f"Error: {err}") - logger.debug("Continuing with empty dictionary") - return {} - - def play(self): - return self.spot_client.start_playback() - - def pause(self): - return self.spot_client.pause_playback() - - def stop(self): - return self.spot_client.pause_playback() - - def prev(self): - return self.spot_client.previous_track() - - def next(self): - return self.spot_client.next_track() - - def toggle(self): - pass - - def get_queue(self): - pass - - @plugin.tag - def play_uri(self, uri: str, **kwargs): - """Decode URI and forward play call - - spotify:playlist:0 - --> search in the yaml-file for the type "playlist" and play the first uri - """ - player_type, index = uri.split(':', 1) - if player_type != 'spotify': - raise KeyError(f"URI prefix must be 'spotify' not '{player_type}") - - return self.spot_client.start_playback(context_uri=uri) - - @plugin.tag - def get_status(self): - return self.spot_client.current_user() - - # ----------------------------------------------------- - # Queue / URI state (save + restore e.g. random, resume, ...) - - def save_state(self): - """Save the configuration and state of the current URI playback to the URIs state file""" - pass - - def _restore_state(self): - """ - Restore the configuration state and last played status for current active URI - """ - pass diff --git a/src/jukebox/components/playern/backends/spotify/ws_client.py b/src/jukebox/components/playern/backends/spotify/ws_client.py deleted file mode 100644 index 9f46efc9a..000000000 --- a/src/jukebox/components/playern/backends/spotify/ws_client.py +++ /dev/null @@ -1,123 +0,0 @@ -import json -import logging -import time - -import websocket -import threading - -logger = logging.getLogger("jb.spotify.SpotifyWsClient") - - -class SpotifyWsClient: - def __init__(self, host: str, player_status, port: int = 24879): - self.protocol = 'ws' - self.host = host - self.port = port - self.url = f'{self.protocol}://{self.host}:{self.port}/events' - - self.player_status = player_status - - self.socket = None - self.thread = None - - self.state_callbacks = { - 'playbackPaused': self.playback_paused, - 'playbackResumed': self.playback_resumed, - 'playbackHaltStateChanged': self.playback_halted, - 'trackChanged': self.track_changed, - 'trackSeeked': self.track_seeked, - 'metadataAvailable': self.metadata_available, - 'inactiveSession': self.inactive_session, - 'contextChanged': self.context_changed, - } - - logger.debug('Spotify WS Client initialized') - - def connect(self): - websocket.enableTrace(True) - self.socket = websocket.WebSocketApp( - self.url, - on_close=self._on_close, - on_error=self._on_error, - on_message=self._on_message - ) - self.thread = threading.Thread(target=self.socket.run_forever) - self.thread.daemon = True - self.thread.start() - - logger.debug(f'Websocket connection established to {self.url}') - - def close(self): - self.socket.close() - - def _on_message(self, socket, message): - logger.debug(f'_on_message: {message}') - data = json.loads(message) - event = data['event'] - - callback = self.state_callbacks.get(event) - if not callback: - raise ValueError(event) - - callback(data) - - def _on_close(self, socket, close_status_code, close_message): - logger.debug(f'Connection with websocket server closed with {close_status_code}:{close_message}') - time.sleep(15) - logger.debug("Retrying to connect") - self.connect() - - def _on_error(self, socket, error): - logger.error(f'Websocket error: {error}') - - # We only care about seconds, not ms as provided by Spotify - def _round_time_to_seconds(self, time): - return '{:.1f}'.format(time / 1000) - - def metadata_available(self, data: dict): - cover_art = data['track']['album']['coverGroup']['image'][2]['fileId'].lower() - - self.player_status.update( - player='Spotify', # TODO: Should this be done differently? - trackid=data['track']['gid'], - title=data['track']['name'], - artist=data['track']['artist'][0]['name'], - album=data['track']['album']['name'], - albumartist=data['track']['album']['artist'][0]['name'], - duration=self._round_time_to_seconds(data['track']['duration']), - coverArt=cover_art - ) - - def playback_paused(self, data: dict): - self.player_status.update( - playing=False, - elapsed=self._round_time_to_seconds(data['trackTime']) - ) - - def playback_resumed(self, data: dict): - self.player_status.update( - playing=True, - elapsed=self._round_time_to_seconds(data['trackTime']) - ) - - def playback_halted(self, data: dict): - self.player_status.update( - playing=data['halted'], - elapsed=self._round_time_to_seconds(data['trackTime']) - ) - - def track_changed(self, data: dict): - pass - - def track_seeked(self, data: dict): - self.player_status.update( - elapsed=self._round_time_to_seconds(data['trackTime']) - ) - - def context_changed(self, data: dict): - pass - - # When Spotify session is routed to another device, - # the local session goes inactive - def inactive_session(self, data: dict): - self.player_status.update(playing=False) From c7a3eaba6ba875eb8c7157e42a72551417320c11 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 4 Aug 2023 11:45:27 +0200 Subject: [PATCH 064/109] Removed spotify code from playern --- src/jukebox/components/playern/plugin/__init__.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/jukebox/components/playern/plugin/__init__.py b/src/jukebox/components/playern/plugin/__init__.py index 8a37ef77e..52e9d57a4 100644 --- a/src/jukebox/components/playern/plugin/__init__.py +++ b/src/jukebox/components/playern/plugin/__init__.py @@ -9,7 +9,6 @@ import jukebox.plugs as plugin import jukebox.cfghandler from components.playern.backends.mpd.interfacing_mpd import MPDBackend -from components.playern.backends.spotify.interfacing_spotify import SPOTBackend from components.playern.core import PlayerCtrl from components.playern.core.player_status import PlayerStatus @@ -27,8 +26,6 @@ # The various backends backend_mpd: Optional[MPDBackend] = None -backend_spot: Optional[SPOTBackend] = None - def start_event_loop(loop: asyncio.AbstractEventLoop): # https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.shutdown_asyncgens @@ -51,17 +48,6 @@ def register_mpd(): player_arbiter.register('mpd', backend_mpd) -def register_spotify(): - global backend_spot - global player_arbiter - global player_status - - backend_spot = SPOTBackend(player_status) - # Register with plugin interface to call directly - plugin.register(backend_spot, package='player', name='spotify') - player_arbiter.register('spotify', backend_spot) - - @plugin.initialize def init(): global event_loop @@ -79,7 +65,6 @@ def init(): player_status.publish() # Create and register the players (this is explicit for the moment) - register_spotify() register_mpd() plugin.register(player_arbiter, package='player', name='ctrl') From 20d72843e1df740d44e6631cf18fe4c6a3a08111 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 6 Nov 2023 17:15:38 +0100 Subject: [PATCH 065/109] fist player content core --- requirements.txt | 2 ++ .../components/playern/core/player_content.py | 34 +++++++++++++++++++ .../components/playern/plugin/__init__.py | 3 ++ 3 files changed, 39 insertions(+) create mode 100644 src/jukebox/components/playern/core/player_content.py diff --git a/requirements.txt b/requirements.txt index f96de684c..ae9e055b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,8 @@ requests eyed3 # For the publisher event reactor loop: tornado +# for collecting audiofiles +pyyaml # RPi's GPIO packages: these are installed via APT on the PI # On regular machines, install them manually if needed for development diff --git a/src/jukebox/components/playern/core/player_content.py b/src/jukebox/components/playern/core/player_content.py new file mode 100644 index 000000000..9a70543db --- /dev/null +++ b/src/jukebox/components/playern/core/player_content.py @@ -0,0 +1,34 @@ +import logging +import yaml + +import jukebox.plugs as plugin +import jukebox.cfghandler + +logger = logging.getLogger('jb.player_content') +cfg = jukebox.cfghandler.get_handler('jukebox') + + +class PlayerData: + + def __init__(self): + self.audiofile = cfg.setndefault('players', 'content', 'audiofile', value='../../shared/audio/audiofiles.yaml') + self._database = {'audio': [], + 'podcasts': [], + 'livestreams': []} + self._fill_database(self.audiofile) + + def _fill_database(self, yaml_file): + with open(yaml_file, 'r') as stream: + try: + self._database = yaml.safe_load(stream) + logger.debug("audiofiles database read") + except yaml.YAMLError as err: + logger.error(f"Error occured while reading {yaml_file}: {err}") + + @plugin.tag + def read_player_content(self, content_type): + return self._database.get(content_type, "empty") + + @plugin.tag + def get_location_of_file(self, filename): + [v.get('x', None) for v in self._database.values()] diff --git a/src/jukebox/components/playern/plugin/__init__.py b/src/jukebox/components/playern/plugin/__init__.py index 52e9d57a4..f6959115e 100644 --- a/src/jukebox/components/playern/plugin/__init__.py +++ b/src/jukebox/components/playern/plugin/__init__.py @@ -10,6 +10,7 @@ import jukebox.cfghandler from components.playern.backends.mpd.interfacing_mpd import MPDBackend from components.playern.core import PlayerCtrl +from components.playern.core.player_content import PlayerData from components.playern.core.player_status import PlayerStatus logger = logging.getLogger('jb.player') @@ -64,6 +65,8 @@ def init(): player_status = PlayerStatus() player_status.publish() + player_content = PlayerData() + # Create and register the players (this is explicit for the moment) register_mpd() From 6b54f47bca1ccaebc3e10a4ac2af0dd995c555e3 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sat, 11 Nov 2023 10:49:36 +0100 Subject: [PATCH 066/109] populate content manager --- src/jukebox/components/playern/core/player_content.py | 4 ++++ src/jukebox/components/playern/plugin/__init__.py | 1 + 2 files changed, 5 insertions(+) diff --git a/src/jukebox/components/playern/core/player_content.py b/src/jukebox/components/playern/core/player_content.py index 9a70543db..6433bdcab 100644 --- a/src/jukebox/components/playern/core/player_content.py +++ b/src/jukebox/components/playern/core/player_content.py @@ -32,3 +32,7 @@ def read_player_content(self, content_type): @plugin.tag def get_location_of_file(self, filename): [v.get('x', None) for v in self._database.values()] + + @plugin.tag + def list_content(self): + return self._database diff --git a/src/jukebox/components/playern/plugin/__init__.py b/src/jukebox/components/playern/plugin/__init__.py index f6959115e..e584be594 100644 --- a/src/jukebox/components/playern/plugin/__init__.py +++ b/src/jukebox/components/playern/plugin/__init__.py @@ -71,6 +71,7 @@ def init(): register_mpd() plugin.register(player_arbiter, package='player', name='ctrl') + plugin.register(player_content, package='player', name='content') @plugin.atexit From ef92b71059684b6760abc3d0723df4e84bd1f8dc Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 13 Nov 2023 20:44:07 +0100 Subject: [PATCH 067/109] get_location for content, mpd fixes --- resources/default-settings/jukebox.default.yaml | 2 +- .../playern/backends/mpd/interfacing_mpd.py | 5 +++++ .../components/playern/core/player_content.py | 16 ++++++++++------ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index eabacf7c0..8a96ca6f5 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -94,7 +94,7 @@ playermpd: playerspot: host: localhost status_file: ../../shared/settings/spotify_player_status.json - collection_file: ../../shared/audio/spotify/spotify_collection.yaml + collection_file: ../../shared/audiofolders/spotify/spotify_collection.yaml second_swipe_action: # Note: Does not follow the RPC alias convention (yet) # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' diff --git a/src/jukebox/components/playern/backends/mpd/interfacing_mpd.py b/src/jukebox/components/playern/backends/mpd/interfacing_mpd.py index b5d364245..9ffd31445 100644 --- a/src/jukebox/components/playern/backends/mpd/interfacing_mpd.py +++ b/src/jukebox/components/playern/backends/mpd/interfacing_mpd.py @@ -210,6 +210,7 @@ def get_files(self, path, recursive=False): :returns: List of file(s) and directories including meta data """ path = sanitize(path) + self._run_cmd(self.client.update, path) if os.path.isfile(path): files = self._run_cmd(self.client.find, 'file', path) elif not recursive: @@ -220,6 +221,8 @@ def get_files(self, path, recursive=False): @plugin.tag def get_track(self, path): + path = sanitize(path) + self._run_cmd(self.client.update, path) playlist = self._run_cmd(self.client.find, 'file', path) if len(playlist) != 1: raise ValueError(f"Path decodes to more than one file: '{path}'") @@ -265,6 +268,7 @@ def get_podcast(self, path): * file: List podcast playlist """ + path = sanitize(path) pass def _get_livestream_items(self, path): @@ -280,6 +284,7 @@ def get_livestream(self, path): * file: List livestream playlist """ + path = sanitize(path) pass # ----------------------------------------------------- diff --git a/src/jukebox/components/playern/core/player_content.py b/src/jukebox/components/playern/core/player_content.py index 6433bdcab..55ab4ce6e 100644 --- a/src/jukebox/components/playern/core/player_content.py +++ b/src/jukebox/components/playern/core/player_content.py @@ -1,4 +1,6 @@ import logging +import os.path + import yaml import jukebox.plugs as plugin @@ -11,10 +13,10 @@ class PlayerData: def __init__(self): - self.audiofile = cfg.setndefault('players', 'content', 'audiofile', value='../../shared/audio/audiofiles.yaml') - self._database = {'audio': [], - 'podcasts': [], - 'livestreams': []} + self.audiofile = cfg.setndefault('players', 'content', 'audiofile', value='../../shared/audiofolders/audiofiles.yaml') + self._database = {'music': [{}], + 'podcasts': [{}], + 'livestreams': [{}]} self._fill_database(self.audiofile) def _fill_database(self, yaml_file): @@ -30,8 +32,10 @@ def read_player_content(self, content_type): return self._database.get(content_type, "empty") @plugin.tag - def get_location_of_file(self, filename): - [v.get('x', None) for v in self._database.values()] + def get_location(self, titlename): + for key, value in self._database.items(): + for elem in value: + return f"mpd:{key}:{elem['location']}" if elem['name'] == titlename else None @plugin.tag def list_content(self): From c8bc8c031653231639e415a19fae507ae49a9e4d Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 13 Nov 2023 21:57:33 +0100 Subject: [PATCH 068/109] cleanup old player --- .../default-settings/jukebox.default.yaml | 4 +- shared/audio/.gitkeep | 0 src/jukebox/components/player/__init__.py | 55 -- src/jukebox/components/playermpd/__init__.py | 619 ------------------ .../components/playern/core/player_content.py | 1 + .../components/playern/plugin/__init__.py | 2 +- src/jukebox/components/volume/__init__.py | 4 +- 7 files changed, 6 insertions(+), 679 deletions(-) delete mode 100644 shared/audio/.gitkeep delete mode 100644 src/jukebox/components/player/__init__.py delete mode 100644 src/jukebox/components/playermpd/__init__.py diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index 8a96ca6f5..347234487 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -78,8 +78,8 @@ alsawave: # Config of the Wave through ALSA Jingle Service device: default players: - mpd: - host: localhost + content: + audiofile: /home/pi/RPi-Jukebox-RFID/shared/audiofolders/audiofiles.yaml playermpd: host: localhost status_file: ../../shared/settings/music_player_status.json diff --git a/shared/audio/.gitkeep b/shared/audio/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/jukebox/components/player/__init__.py b/src/jukebox/components/player/__init__.py deleted file mode 100644 index ecff33449..000000000 --- a/src/jukebox/components/player/__init__.py +++ /dev/null @@ -1,55 +0,0 @@ -import os -import re -import logging -import jukebox.cfghandler -from typing import Optional - - -logger = logging.getLogger('jb.player') -cfg = jukebox.cfghandler.get_handler('jukebox') - - -def _get_music_library_path(conf_file): - """Parse the music directory from the mpd.conf file""" - pattern = re.compile(r'^\s*music_directory\s*"(.*)"', re.I) - directory = None - with open(conf_file, 'r') as f: - for line in f: - res = pattern.match(line) - if res: - directory = res.group(1) - break - else: - logger.error(f"Could not find music library path in {conf_file}") - logger.debug(f"MPD music lib path = {directory}; from {conf_file}") - return directory - - -class MusicLibPath: - """Extract the music directory from the mpd.conf file""" - def __init__(self): - self._music_library_path = None - mpd_conf_file = cfg.setndefault('playermpd', 'mpd_conf', value='~/.config/mpd/mpd.conf') - try: - self._music_library_path = _get_music_library_path(os.path.expanduser(mpd_conf_file)) - except Exception as e: - logger.error(f"Could not determine music library directory from '{mpd_conf_file}'") - logger.error(f"Reason: {e.__class__.__name__}: {e}") - - @property - def music_library_path(self): - return self._music_library_path - - -# --------------------------------------------------------------------------- - - -_MUSIC_LIBRARY_PATH: Optional[MusicLibPath] = None - - -def get_music_library_path(): - """Get the music library path""" - global _MUSIC_LIBRARY_PATH - if _MUSIC_LIBRARY_PATH is None: - _MUSIC_LIBRARY_PATH = MusicLibPath() - return _MUSIC_LIBRARY_PATH.music_library_path diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py deleted file mode 100644 index 04f0643c1..000000000 --- a/src/jukebox/components/playermpd/__init__.py +++ /dev/null @@ -1,619 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Package for interfacing with the MPD Music Player Daemon - -Status information in three topics -1) Player Status: published only on change - This is a subset of the MPD status (and not the full MPD status) ?? - - folder - - song - - volume (volume is published only via player status, and not separatly to avoid too many Threads) - - ... -2) Elapsed time: published every 250 ms, unless constant - - elapsed -3) Folder Config: published only on change - This belongs to the folder being played - Publish: - - random, resume, single, loop - On save store this information: - Contains the information for resume functionality of each folder - - random, resume, single, loop - - if resume: - - current song, elapsed - - what is PLAYSTATUS for? - When to save - - on stop - Angstsave: - - on pause (only if box get turned off without proper shutdown - else stop gets implicitly called) - - on status change of random, resume, single, loop (for resume omit current status if currently playing- this has now meaning) - Load checks: - - if resume, but no song, elapsed -> log error and start from the beginning - -Status storing: - - Folder config for each folder (see above) - - Information to restart last folder playback, which is: - - last_folder -> folder_on_close - - song, elapsed - - random, resume, single, loop - - if resume is enabled, after start we need to set last_played_folder, such that card swipe is detected as second swipe?! - on the other hand: if resume is enabled, this is also saved to folder.config -> and that is checked by play card - -Internal status - - last played folder: Needed to detect second swipe - - -Saving {'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'}, -'audio_folder_status': -{'TraumfaengerStarkeLieder': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'stop', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}, -'Giraffenaffen': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'play', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}}} - -References: -https://github.com/Mic92/python-mpd2 -https://python-mpd2.readthedocs.io/en/latest/topics/commands.html -https://mpd.readthedocs.io/en/latest/protocol.html - -sudo -u mpd speaker-test -t wav -c 2 -""" # noqa: E501 -# Warum ist "Second Swipe" im Player und nicht im RFID Reader? -# Second swipe ist abhängig vom Player State - nicht vom RFID state. -# Beispiel: RFID triggered Folder1, Webapp triggered Folder2, RFID Folder1: Dann muss das 2. Mal Folder1 auch als "first swipe" -# gewertet werden. Wenn der RFID das basierend auf IDs macht, kann der nicht unterscheiden und glaubt es ist 2. Swipe. -# Beispiel 2: Jemand hat RFID Reader (oder 1x RFID und 1x Barcode Scanner oder so) angeschlossen. Liest zuerst Karte mit -# Reader 1 und dann mit Reader 2: Reader 2 weiß nicht, was bei Reader 1 passiert ist und denkt es ist 1. swipe. -# Beispiel 3: RFID trigered Folder1, Playlist läuft durch und hat schon gestoppt, dann wird die Karte wieder vorgehalten. -# Dann muss das als 1. Swipe gewertet werden -# Beispiel 4: RFID triggered "Folder1", dann wird Karte "Volume Up" aufgelegt, dann wieder Karte "Folder1": Auch das ist -# aus Sicht ders Playbacks 2nd Swipe -# 2nd Swipe ist keine im Reader festgelegte Funktion extra fur den Player. -# -# In der aktuellen Implementierung weiß der Player (der second "swipe" dekodiert) überhaupt nichts vom RFID. -# Im Prinzip gibt es zwei "Play" Funktionen: (1) play always from start und (2) play with toggle action. -# Die Webapp ruft immer (1) auf und die RFID immer (2). Jetzt kann man sogar für einige Karten sagen -# immer (1) - also kein Second Swipe und für andere (2). -# Sollte der Reader das Swcond swipe dekodieren, muss aber der Reader den Status des Player kennen. -# Das ist allerdings ein Problem. In Version 2 ist das nicht aufgefallen, -# weil alles uber File I/Os lief - Thread safe ist das nicht! -# -# Beispiel: Second swipe bei anderen Funktionen, hier: WiFi on/off. -# Was die Karte Action tut ist ein Toggle. Der Toggle hängt vom Wifi State ab, den der RFID Kartenleser nicht kennt. -# Den kann der Leser auch nicht tracken. Der State kann ja auch über die WebApp oder Kommandozeile geändert werden. -# Toggle (und 2nd Swipe generell) ist immer vom Status des Zielsystems abhängig und kann damit nur vom Zielsystem geändert -# werden. Bei Wifi also braucht man 3 Funktionen: on / off / toggle. Toggle ist dann first swipe / second swipe - -import mpd -import threading -import logging -import time -import functools -import components.player -import jukebox.cfghandler -import jukebox.utils as utils -import jukebox.plugs as plugs -import jukebox.multitimer as multitimer -import jukebox.publishing as publishing -import jukebox.playlistgenerator as playlistgenerator -import misc - -from jukebox.NvManager import nv_manager - - -logger = logging.getLogger('jb.PlayerMPD') -cfg = jukebox.cfghandler.get_handler('jukebox') - - -class MpdLock: - def __init__(self, client: mpd.MPDClient, host: str, port: int): - self._lock = threading.RLock() - self.client = client - self.host = host - self.port = port - - def _try_connect(self): - try: - self.client.connect(self.host, self.port) - except mpd.base.ConnectionError: - pass - - def __enter__(self): - self._lock.acquire() - self._try_connect() - return self - - def __exit__(self, exc_type, exc_value, traceback): - self._lock.release() - - def acquire(self, blocking: bool = True, timeout: float = -1) -> bool: - locked = self._lock.acquire(blocking, timeout) - if locked: - self._try_connect() - return locked - - def release(self): - self._lock.release() - - def locked(self): - return self._lock.locked() - - -class PlayerMPD: - """Interface to MPD Music Player Daemon""" - - def __init__(self): - self.nvm = nv_manager() - self.mpd_host = cfg.getn('playermpd', 'host') - self.music_player_status = self.nvm.load(cfg.getn('playermpd', 'status_file')) - - self.second_swipe_action_dict = {'toggle': self.toggle, - 'play': self.play, - 'skip': self.next, - 'rewind': self.rewind, - 'replay': self.replay, - 'replay_if_stopped': self.replay_if_stopped} - self.second_swipe_action = None - self.decode_2nd_swipe_option() - - self.mpd_client = mpd.MPDClient() - # The timeout refer to the low-level socket time-out - # If these are too short and the response is not fast enough (due to the PI being busy), - # the current MPC command times out. Leave these at blocking calls, since we do not react on a timed out socket - # in any relevant matter anyway - self.mpd_client.timeout = None # network timeout in seconds (floats allowed), default: None - self.mpd_client.idletimeout = None # timeout for fetching the result of the idle command - self.connect() - logger.info(f"Connected to MPD Version: {self.mpd_client.mpd_version}") - - self.current_folder_status = {} - if not self.music_player_status: - self.music_player_status['player_status'] = {} - self.music_player_status['audio_folder_status'] = {} - self.music_player_status.save_to_json() - self.current_folder_status = {} - self.music_player_status['player_status']['last_played_folder'] = '' - else: - last_played_folder = self.music_player_status['player_status'].get('last_played_folder') - if last_played_folder: - # current_folder_status is a dict, but last_played_folder a str - self.current_folder_status = self.music_player_status['audio_folder_status'][last_played_folder] - # Restore the playlist status in mpd - # But what about playback position? - self.mpd_client.clear() - # This could fail and cause load fail of entire package: - # self.mpd_client.add(last_played_folder) - logger.info(f"Last Played Folder: {last_played_folder}") - - # Clear last folder played, as we actually did not play any folder yet - # Needed for second swipe detection - # TODO: This will loose the last_played_folder information is the box is started and closed with playing anything... - # Change this to last_played_folder and shutdown_state (for restoring) - self.music_player_status['player_status']['last_played_folder'] = '' - - self.old_song = None - self.mpd_status = {} - self.mpd_status_poll_interval = 0.25 - self.mpd_lock = MpdLock(self.mpd_client, self.mpd_host, 6600) - self.status_is_closing = False - # self.status_thread = threading.Timer(self.mpd_status_poll_interval, self._mpd_status_poll).start() - - self.status_thread = multitimer.GenericEndlessTimerClass('mpd.timer_status', - self.mpd_status_poll_interval, self._mpd_status_poll) - self.status_thread.start() - - def exit(self): - logger.debug("Exit routine of playermpd started") - self.status_is_closing = True - self.status_thread.cancel() - self.mpd_client.disconnect() - self.nvm.save_all() - return self.status_thread.timer_thread - - def connect(self): - self.mpd_client.connect(self.mpd_host, 6600) - - def decode_2nd_swipe_option(self): - cfg_2nd_swipe_action = cfg.setndefault('playermpd', 'second_swipe_action', 'alias', value='none').lower() - if cfg_2nd_swipe_action not in [*self.second_swipe_action_dict.keys(), 'none', 'custom']: - logger.error(f"Config mpd.second_swipe_action must be one of " - f"{[*self.second_swipe_action_dict.keys(), 'none', 'custom']}. Ignore setting.") - if cfg_2nd_swipe_action in self.second_swipe_action_dict.keys(): - self.second_swipe_action = self.second_swipe_action_dict[cfg_2nd_swipe_action] - if cfg_2nd_swipe_action == 'custom': - custom_action = utils.decode_rpc_call(cfg.getn('playermpd', 'second_swipe_action', default=None)) - self.second_swipe_action = functools.partial(plugs.call_ignore_errors, - custom_action['package'], - custom_action['plugin'], - custom_action['method'], - custom_action['args'], - custom_action['kwargs']) - - def mpd_retry_with_mutex(self, mpd_cmd, *args): - """ - This method adds thread saftey for acceses to mpd via a mutex lock, - it shall be used for each access to mpd to ensure thread safety - In case of a communication error the connection will be reestablished and the pending command will be repeated 2 times - - I think this should be refactored to a decorator - """ - with self.mpd_lock: - try: - value = mpd_cmd(*args) - except Exception as e: - logger.error(f"{e.__class__.__qualname__}: {e}") - value = None - return value - - def _mpd_status_poll(self): - """ - this method polls the status from mpd and stores the important inforamtion in the music_player_status, - it will repeat itself in the intervall specified by self.mpd_status_poll_interval - """ - self.mpd_status.update(self.mpd_retry_with_mutex(self.mpd_client.status)) - self.mpd_status.update(self.mpd_retry_with_mutex(self.mpd_client.currentsong)) - - if self.mpd_status.get('elapsed') is not None: - self.current_folder_status["ELAPSED"] = self.mpd_status['elapsed'] - self.music_player_status['player_status']["CURRENTSONGPOS"] = self.mpd_status['song'] - self.music_player_status['player_status']["CURRENTFILENAME"] = self.mpd_status['file'] - - if self.mpd_status.get('file') is not None: - self.current_folder_status["CURRENTFILENAME"] = self.mpd_status['file'] - self.current_folder_status["CURRENTSONGPOS"] = self.mpd_status['song'] - self.current_folder_status["ELAPSED"] = self.mpd_status.get('elapsed', '0.0') - self.current_folder_status["PLAYSTATUS"] = self.mpd_status['state'] - self.current_folder_status["RESUME"] = "OFF" - self.current_folder_status["SHUFFLE"] = "OFF" - self.current_folder_status["LOOP"] = "OFF" - self.current_folder_status["SINGLE"] = "OFF" - - # Delete the volume key to avoid confusion - # Volume is published via the 'volume' component! - try: - del self.mpd_status['volume'] - except KeyError: - pass - publishing.get_publisher().send('playerstatus', self.mpd_status) - - @plugs.tag - def get_player_type_and_version(self): - with self.mpd_lock: - value = self.mpd_client.mpd_version() - return value - - @plugs.tag - def update(self): - with self.mpd_lock: - state = self.mpd_client.update() - return state - - @plugs.tag - def play(self): - with self.mpd_lock: - self.mpd_client.play() - - @plugs.tag - def stop(self): - with self.mpd_lock: - self.mpd_client.stop() - - @plugs.tag - def pause(self, state: int = 1): - """Enforce pause to state (1: pause, 0: resume) - - This is what you want as card removal action: pause the playback, so it can be resumed when card is placed - on the reader again. What happens on re-placement depends on configured second swipe option - """ - with self.mpd_lock: - self.mpd_client.pause(state) - - @plugs.tag - def prev(self): - logger.debug("Prev") - with self.mpd_lock: - self.mpd_client.previous() - - @plugs.tag - def next(self): - """Play next track in current playlist""" - logger.debug("Next") - with self.mpd_lock: - self.mpd_client.next() - - @plugs.tag - def seek(self, new_time): - with self.mpd_lock: - self.mpd_client.seekcur(new_time) - - @plugs.tag - def shuffle(self, random): - # As long as we don't work with waiting lists (aka playlist), this implementation is ok! - self.mpd_retry_with_mutex(self.mpd_client.random, 1 if random else 0) - - @plugs.tag - def rewind(self): - """ - Re-start current playlist from first track - - Note: Will not re-read folder config, but leave settings untouched""" - logger.debug("Rewind") - with self.mpd_lock: - self.mpd_client.play(1) - - @plugs.tag - def replay(self): - """ - Re-start playing the last-played folder - - Will reset settings to folder config""" - logger.debug("Replay") - with self.mpd_lock: - self.play_folder(self.music_player_status['player_status']['last_played_folder']) - - @plugs.tag - def toggle(self): - """Toggle pause state, i.e. do a pause / resume depending on current state""" - logger.debug("Toggle") - with self.mpd_lock: - self.mpd_client.pause() - - @plugs.tag - def replay_if_stopped(self): - """ - Re-start playing the last-played folder unless playlist is still playing - - .. note:: To me this seems much like the behaviour of play, - but we keep it as it is specifically implemented in box 2.X""" - with self.mpd_lock: - if self.mpd_status['state'] == 'stop': - self.play_folder(self.music_player_status['player_status']['last_played_folder']) - - @plugs.tag - def repeatmode(self, mode): - if mode == 'repeat': - repeat = 1 - single = 0 - elif mode == 'single': - repeat = 1 - single = 1 - else: - repeat = 0 - single = 0 - - with self.mpd_lock: - self.mpd_client.repeat(repeat) - self.mpd_client.single(single) - - @plugs.tag - def get_current_song(self, param): - return self.mpd_status - - @plugs.tag - def map_filename_to_playlist_pos(self, filename): - # self.mpd_client.playlistfind() - raise NotImplementedError - - @plugs.tag - def remove(self): - raise NotImplementedError - - @plugs.tag - def move(self): - # song_id = param.get("song_id") - # step = param.get("step") - # MPDClient.playlistmove(name, from, to) - # MPDClient.swapid(song1, song2) - raise NotImplementedError - - @plugs.tag - def play_single(self, song_url): - with self.mpd_lock: - self.mpd_client.clear() - self.mpd_client.addid(song_url) - self.mpd_client.play() - - @plugs.tag - def resume(self): - with self.mpd_lock: - songpos = self.current_folder_status["CURRENTSONGPOS"] - elapsed = self.current_folder_status["ELAPSED"] - self.mpd_client.seek(songpos, elapsed) - self.mpd_client.play() - - @plugs.tag - def play_card(self, folder: str, recursive: bool = False): - """ - Main entry point for trigger music playing from RFID reader. Decodes second swipe options before playing folder content - - Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action - accordingly. - - :param folder: Folder path relative to music library path - :param recursive: Add folder recursively - """ - # Developers notes: - # - # * 2nd swipe trigger may also happen, if playlist has already stopped playing - # --> Generally, treat as first swipe - # * 2nd swipe of same Card ID may also happen if a different song has been played in between from WebUI - # --> Treat as first swipe - # * With place-not-swipe: Card is placed on reader until playlist expieres. Music stop. Card is removed and - # placed again on the reader: Should be like first swipe - # * TODO: last_played_folder is restored after box start, so first swipe of last played card may look like - # second swipe - # - logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}") - with self.mpd_lock: - is_second_swipe = self.music_player_status['player_status']['last_played_folder'] == folder - if self.second_swipe_action is not None and is_second_swipe: - logger.debug('Calling second swipe action') - self.second_swipe_action() - else: - logger.debug('Calling first swipe action') - self.play_folder(folder, recursive) - - @plugs.tag - def get_folder_content(self, folder: str): - """ - Get the folder content as content list with meta-information. Depth is always 1. - - Call repeatedly to descend in hierarchy - - :param folder: Folder path relative to music library path - """ - plc = playlistgenerator.PlaylistCollector(components.player.get_music_library_path()) - plc.get_directory_content(folder) - return plc.playlist - - @plugs.tag - def play_folder(self, folder: str, recursive: bool = False) -> None: - """ - Playback a music folder. - - Folder content is added to the playlist as described by :mod:`jukebox.playlistgenerator`. - The playlist is cleared first. - - :param folder: Folder path relative to music library path - :param recursive: Add folder recursively - """ - # TODO: This changes the current state -> Need to save last state - with self.mpd_lock: - logger.info(f"Play folder: '{folder}'") - self.mpd_client.clear() - - plc = playlistgenerator.PlaylistCollector(components.player.get_music_library_path()) - plc.parse(folder, recursive) - uri = '--unset--' - try: - for uri in plc: - self.mpd_client.addid(uri) - except mpd.base.CommandError as e: - logger.error(f"{e.__class__.__qualname__}: {e} at uri {uri}") - except Exception as e: - logger.error(f"{e.__class__.__qualname__}: {e} at uri {uri}") - - self.music_player_status['player_status']['last_played_folder'] = folder - - self.current_folder_status = self.music_player_status['audio_folder_status'].get(folder) - if self.current_folder_status is None: - self.current_folder_status = self.music_player_status['audio_folder_status'][folder] = {} - - self.mpd_client.play() - - @plugs.tag - def play_album(self, albumartist: str, album: str): - """ - Playback a album found in MPD database. - - All album songs are added to the playlist - The playlist is cleared first. - - :param albumartist: Artist of the Album provided by MPD database - :param album: Album name provided by MPD database - """ - with self.mpd_lock: - logger.info(f"Play album: '{album}' by '{albumartist}") - self.mpd_client.clear() - self.mpd_retry_with_mutex(self.mpd_client.findadd, 'albumartist', albumartist, 'album', album) - self.mpd_client.play() - - @plugs.tag - def queue_load(self, folder): - # There was something playing before -> stop and save state - # Clear the queue - # Check / Create the playlist - # - not needed if same folder is played again? Buf what if files have been added a mpc update has been run? - # - and this a re-trigger to start the new playlist - # If we must update the playlists everytime anyway why write them to file and not just keep them in the queue? - # Load the playlist - # Get folder config and apply settings - pass - - @plugs.tag - def playerstatus(self): - return self.mpd_status - - @plugs.tag - def playlistinfo(self): - with self.mpd_lock: - value = self.mpd_client.playlistinfo() - return value - - # Attention: MPD.listal will consume a lot of memory with large libs.. should be refactored at some point - @plugs.tag - def list_all_dirs(self): - with self.mpd_lock: - result = self.mpd_client.listall() - # list = [entry for entry in list if 'directory' in entry] - return result - - @plugs.tag - def list_albums(self): - with self.mpd_lock: - albums = self.mpd_retry_with_mutex(self.mpd_client.list, 'album', 'group', 'albumartist') - - return albums - - @plugs.tag - def list_song_by_artist_and_album(self, albumartist, album): - with self.mpd_lock: - albums = self.mpd_retry_with_mutex(self.mpd_client.find, 'albumartist', albumartist, 'album', album) - - return albums - - @plugs.tag - def get_song_by_url(self, song_url): - with self.mpd_lock: - song = self.mpd_retry_with_mutex(self.mpd_client.find, 'file', song_url) - - return song - - def get_volume(self): - """ - Get the current volume - - For volume control do not use directly, but use through the plugin 'volume', - as the user may have configured a volume control manager other than MPD""" - with self.mpd_lock: - volume = self.mpd_client.status().get('volume') - return int(volume) - - def set_volume(self, volume): - """ - Set the volume - - For volume control do not use directly, but use through the plugin 'volume', - as the user may have configured a volume control manager other than MPD""" - with self.mpd_lock: - self.mpd_client.setvol(volume) - return self.get_volume() - - -# --------------------------------------------------------------------------- -# Plugin Initializer / Finalizer -# --------------------------------------------------------------------------- - -player_ctrl: PlayerMPD - - -@plugs.initialize -def initialize(): - global player_ctrl - player_ctrl = PlayerMPD() - plugs.register(player_ctrl, name='ctrl') - - # Update mpc library - library_update = cfg.setndefault('playermpd', 'library', 'update_on_startup', value=True) - if library_update: - player_ctrl.update() - - # Check user rights on music library - library_check_user_rights = cfg.setndefault('playermpd', 'library', 'check_user_rights', value=True) - if library_check_user_rights is True: - music_library_path = components.player.get_music_library_path() - if music_library_path is not None: - logger.info(f"Change user rights for {music_library_path}") - misc.recursive_chmod(music_library_path, mode_files=0o666, mode_dirs=0o777) - - -@plugs.atexit -def atexit(**ignored_kwargs): - global player_ctrl - return player_ctrl.exit() diff --git a/src/jukebox/components/playern/core/player_content.py b/src/jukebox/components/playern/core/player_content.py index 55ab4ce6e..73ef315d1 100644 --- a/src/jukebox/components/playern/core/player_content.py +++ b/src/jukebox/components/playern/core/player_content.py @@ -29,6 +29,7 @@ def _fill_database(self, yaml_file): @plugin.tag def read_player_content(self, content_type): + self._fill_database(self.audiofile) return self._database.get(content_type, "empty") @plugin.tag diff --git a/src/jukebox/components/playern/plugin/__init__.py b/src/jukebox/components/playern/plugin/__init__.py index e584be594..1e24d0901 100644 --- a/src/jukebox/components/playern/plugin/__init__.py +++ b/src/jukebox/components/playern/plugin/__init__.py @@ -50,7 +50,7 @@ def register_mpd(): @plugin.initialize -def init(): +def initialize(): global event_loop global player_arbiter global player_status diff --git a/src/jukebox/components/volume/__init__.py b/src/jukebox/components/volume/__init__.py index 6653baa77..b4fd8a35a 100644 --- a/src/jukebox/components/volume/__init__.py +++ b/src/jukebox/components/volume/__init__.py @@ -629,6 +629,8 @@ def initialize(): pulse_monitor.start() pulse_control = PulseVolumeControl(parse_config()) + plugin.register(pulse_control, package="volume", name="ctrl", replace=True) + plugin.register(pulse_monitor, package="volume", name="mon", replace=True) @plugin.finalize @@ -646,8 +648,6 @@ def finalize(): pulse_control.set_volume(startup_volume) else: pulse_control.publish_volume() - plugin.register(pulse_control, package="volume", name="ctrl", replace=True) - @plugin.atexit def atexit(**ignored_kwargs): From d0e851f67c6db76bd7df45d26e1b099a44e0d66c Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sat, 18 Nov 2023 15:37:06 +0100 Subject: [PATCH 069/109] Update docker env --- .dockerignore | 3 +- docker/config/docker.pulse.mpd.conf | 4 +-- docker/config/jukebox.overrides.yaml | 5 --- docker/docker-compose.linux.yml | 27 +++++++++++++--- docker/docker-compose.yml | 32 +++++++++++-------- docker/jukebox.Dockerfile | 22 +++++++------ docker/mpd.Dockerfile | 21 ++++++------ .../components/playern/core/player_content.py | 3 +- 8 files changed, 67 insertions(+), 50 deletions(-) diff --git a/.dockerignore b/.dockerignore index fed16790c..55fe62a70 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,6 @@ .git +.githooks +.github .dockerignore .DS_Store @@ -6,7 +8,6 @@ docker docs installation -src shared # webapp diff --git a/docker/config/docker.pulse.mpd.conf b/docker/config/docker.pulse.mpd.conf index 96f27347e..0be3b7128 100644 --- a/docker/config/docker.pulse.mpd.conf +++ b/docker/config/docker.pulse.mpd.conf @@ -11,7 +11,7 @@ # be disabled and audio files will only be accepted over ipc socket (using # file:// protocol) or streaming files over an accepted protocol. # -music_directory "/home/pi/RPi-Jukebox-RFID/shared/audiofolders" +music_directory "~/RPi-Jukebox-RFID/shared/audiofolders" # # This setting sets the MPD internal playlist directory. The purpose of this # directory is storage for playlists created by MPD. The server will use @@ -67,7 +67,7 @@ sticker_file "~/.config/mpd/sticker.sql" # initialization. This setting is disabled by default and MPD is run as the # current user. # -user "root" +#user "root" # # This setting specifies the group that MPD will run as. If not specified # primary group of user specified with "user" setting will be used (if set). diff --git a/docker/config/jukebox.overrides.yaml b/docker/config/jukebox.overrides.yaml index 0bd6fad07..ca79486d4 100644 --- a/docker/config/jukebox.overrides.yaml +++ b/docker/config/jukebox.overrides.yaml @@ -1,7 +1,2 @@ playermpd: host: mpd - mpd_conf: /etc/mpd.conf -pulse: - outputs: - primary: - pulse_sink_name: Channel_1__Channel_2.2 \ No newline at end of file diff --git a/docker/docker-compose.linux.yml b/docker/docker-compose.linux.yml index 182881be0..9609dc18d 100755 --- a/docker/docker-compose.linux.yml +++ b/docker/docker-compose.linux.yml @@ -2,9 +2,28 @@ version: "3.9" services: mpd: - devices: - - /dev/snd + build: + args: + - UID=${UID:-1000} + - USER=pi + - HOME=/home/pi + environment: + - PULSE_SERVER=unix:/tmp/pulse-sock + volumes: + - ../shared/audiofolders:/home/pi/RPi-Jukebox-RFID/shared/audiofolders + - ../shared/playlists:/home/pi/.config/mpd/playlists + - ./config/docker.pulse.mpd.conf:/home/pi/.config/mpd/mpd.conf + - $XDG_RUNTIME_DIR/pulse/native:/tmp/pulse-sock jukebox: - devices: - - /dev/snd + build: + args: + - UID=${UID:-1000} + - USER=pi + - HOME=/home/pi + environment: + - PULSE_SERVER=unix:/tmp/pulse-sock + volumes: + - ../shared:/home/pi/RPi-Jukebox-RFID/shared + - ./config/docker.pulse.mpd.conf:/home/pi/.config/mpd/mpd.conf + - $XDG_RUNTIME_DIR/pulse/native:/tmp/pulse-sock diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 73c462c31..68bb5c351 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -3,27 +3,34 @@ version: "3.9" services: mpd: build: + args: + - UID=0 + - USER=root + - HOME=/root context: ../ dockerfile: ./docker/mpd.Dockerfile container_name: mpd - ports: - - 6600:6600 - - 8800:8800 + environment: + - PULSE_SERVER=tcp:host.docker.internal:4713 restart: unless-stopped volumes: - - ../shared/audiofolders:/home/pi/RPi-Jukebox-RFID/shared/audiofolders:rw - - ../shared/playlists:/root/.config/mpd/playlists:rw - - ./config/docker.mpd.conf:/root/.config/mpd/mpd.conf:rw + - ../shared/audiofolders:/root/RPi-Jukebox-RFID/shared/audiofolders + - ../shared/playlists:/root/.config/mpd/playlists + - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf jukebox: build: + args: + - UID=0 + - USER=root + - HOME=/root context: ../ dockerfile: ./docker/jukebox.Dockerfile container_name: jukebox depends_on: - mpd - links: - - mpd + environment: + - PULSE_SERVER=tcp:host.docker.internal:4713 ports: - 5555:5555 - 5556:5556 @@ -31,9 +38,8 @@ services: restart: unless-stopped tty: true volumes: - - ../src/jukebox:/home/pi/RPi-Jukebox-RFID/src/jukebox - - ../shared:/home/pi/RPi-Jukebox-RFID/shared - - ./config/docker.mpd.conf:/etc/mpd.conf + - ../shared:/root/RPi-Jukebox-RFID/shared + - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf webapp: build: @@ -44,10 +50,8 @@ services: - jukebox environment: - CHOKIDAR_USEPOLLING=true - links: - - jukebox ports: - - 3001:3000 + - 3000:3000 restart: unless-stopped volumes: - ../src/webapp:/home/node/webapp diff --git a/docker/jukebox.Dockerfile b/docker/jukebox.Dockerfile index f1ac07141..2c7566e37 100644 --- a/docker/jukebox.Dockerfile +++ b/docker/jukebox.Dockerfile @@ -5,36 +5,38 @@ FROM debian:bullseye-slim # These are only dependencies that are required to get as close to the # Raspberry Pi environment as possible. RUN apt-get update && apt-get install -y \ - alsa-utils \ libasound2-dev \ - libasound2-plugins \ pulseaudio \ pulseaudio-utils \ --no-install-recommends \ && rm -rf /var/lib/apt/lists/* -RUN usermod -aG audio,pulse,pulse-access root +ARG UID +ARG USER +ARG HOME +ENV INSTALLATION_PATH ${HOME}/RPi-Jukebox-RFID -ENV HOME /root -ENV INSTALLATION_PATH /home/pi/RPi-Jukebox-RFID - -WORKDIR $INSTALLATION_PATH +RUN test ${UID} -gt 0 && useradd -m -u ${UID} ${USER} || continue +RUN usermod -aG pulse ${USER} # Jukebox # Install all Jukebox dependencies RUN apt-get update && apt-get install -qq -y \ --allow-downgrades --allow-remove-essential --allow-change-held-packages \ gcc at wget \ - espeak mpc mpg123 git ffmpeg spi-tools netcat alsa-tools \ + espeak mpc mpg123 git ffmpeg spi-tools netcat \ python3 python3-dev python3-pip python3-mutagen python3-gpiozero -COPY . ${INSTALLATION_PATH} +USER ${USER} +WORKDIR ${INSTALLATION_PATH} +COPY --chown=${USER}:${USER} . ${INSTALLATION_PATH}/ RUN pip3 install --no-cache-dir -r ${INSTALLATION_PATH}/requirements.txt RUN pip3 install pyzmq EXPOSE 5555 5556 +WORKDIR ${INSTALLATION_PATH}/src/jukebox + # Run Jukebox -# CMD bash CMD python3 ${INSTALLATION_PATH}/src/jukebox/run_jukebox.py diff --git a/docker/mpd.Dockerfile b/docker/mpd.Dockerfile index f77b65dc3..e13d7f472 100644 --- a/docker/mpd.Dockerfile +++ b/docker/mpd.Dockerfile @@ -2,9 +2,6 @@ FROM debian:bullseye-slim RUN set -eux ; \ apt-get update && apt-get install -y \ - alsa-utils \ - libasound2-dev \ - libasound2-plugins \ pulseaudio \ pulseaudio-utils \ mpd mpc \ @@ -12,16 +9,16 @@ RUN set -eux ; \ ; \ rm -rf /var/lib/apt/lists/* -ENV HOME /root -RUN mkdir ${HOME}/.config ${HOME}/.config/mpd ; \ - touch ${HOME}/.config/mpd/state -RUN mkdir -p /home/pi/RPi-Jukebox-RFID/shared/audiofolders - -RUN usermod -aG audio,pulse,pulse-access root +ARG UID +ARG USER +ARG HOME -VOLUME ${HOME}/.config/mpd +RUN useradd -m -u ${UID} ${USER} || continue +RUN usermod -aG pulse ${USER} -EXPOSE 6600 +USER ${USER} +RUN mkdir -p ${HOME}/.config/mpd ; \ + touch ${HOME}/.config/mpd/state -CMD [ ! -s ~/.config/mpd/pid ] && mpd --stdout --no-daemon ${HOME}/.config/mpd/mpd.conf +CMD mpd --stdout --no-daemon ${HOME}/.config/mpd/mpd.conf diff --git a/src/jukebox/components/playern/core/player_content.py b/src/jukebox/components/playern/core/player_content.py index 73ef315d1..601d13e1d 100644 --- a/src/jukebox/components/playern/core/player_content.py +++ b/src/jukebox/components/playern/core/player_content.py @@ -1,5 +1,4 @@ import logging -import os.path import yaml @@ -14,7 +13,7 @@ class PlayerData: def __init__(self): self.audiofile = cfg.setndefault('players', 'content', 'audiofile', value='../../shared/audiofolders/audiofiles.yaml') - self._database = {'music': [{}], + self._database = {'file': [{}], 'podcasts': [{}], 'livestreams': [{}]} self._fill_database(self.audiofile) From aaa935a48cd2aeb28fc11791870d400ea90d4970 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sat, 18 Nov 2023 15:56:38 +0100 Subject: [PATCH 070/109] update from upstream --- .../default-services/jukebox-spotify.service | 17 - .../default-settings/spotify.config.toml | 84 --- .../spotify_collection.example.yaml | 7 - .../backends/mpd/interfacing_mpd.py | 0 .../{playern => player}/core/__init__.py | 0 .../core/player_content.py | 0 .../{playern => player}/core/player_status.py | 0 .../{playern => player}/plugin/__init__.py | 8 +- src/jukebox/components/playermpd/__init__.py | 657 ------------------ .../playermpd/playcontentcallback.py | 37 - src/webapp/src/commands/index.js | 49 +- .../components/Library/lists/albums/index.js | 2 +- .../Library/lists/albums/song-list/index.js | 32 +- .../albums/song-list/song-list-controls.js | 12 +- .../albums/song-list/song-list-headline.js | 2 +- .../lists/albums/song-list/song-list-item.js | 12 +- .../Library/lists/folders/folder-link.js | 4 +- .../lists/folders/folder-list-item-back.js | 4 +- .../Library/lists/folders/folder-list-item.js | 15 +- .../Library/lists/folders/folder-list.js | 19 +- .../components/Library/lists/folders/index.js | 10 +- .../src/components/Library/lists/index.js | 5 +- src/webapp/src/components/Player/controls.js | 85 ++- src/webapp/src/components/Player/cover.js | 2 +- src/webapp/src/components/Player/display.js | 14 +- src/webapp/src/components/Player/index.js | 29 +- src/webapp/src/components/Player/seekbar.js | 35 +- .../src/components/Settings/timers/timer.js | 4 +- .../src/components/general/Countdown.js | 31 + src/webapp/src/components/general/Counter.js | 48 -- src/webapp/src/components/general/index.js | 4 +- src/webapp/src/config.js | 1 - 32 files changed, 214 insertions(+), 1015 deletions(-) delete mode 100644 resources/default-services/jukebox-spotify.service delete mode 100644 resources/default-settings/spotify.config.toml delete mode 100644 resources/default-settings/spotify_collection.example.yaml rename src/jukebox/components/{playern => player}/backends/mpd/interfacing_mpd.py (100%) rename src/jukebox/components/{playern => player}/core/__init__.py (100%) rename src/jukebox/components/{playern => player}/core/player_content.py (100%) rename src/jukebox/components/{playern => player}/core/player_status.py (100%) rename src/jukebox/components/{playern => player}/plugin/__init__.py (90%) delete mode 100644 src/jukebox/components/playermpd/__init__.py delete mode 100644 src/jukebox/components/playermpd/playcontentcallback.py create mode 100644 src/webapp/src/components/general/Countdown.js delete mode 100644 src/webapp/src/components/general/Counter.js diff --git a/resources/default-services/jukebox-spotify.service b/resources/default-services/jukebox-spotify.service deleted file mode 100644 index b2f0cc962..000000000 --- a/resources/default-services/jukebox-spotify.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=Junkebox-Spotify -Wants=network-online.target -After=network.target network-online.target -Requires=network-online.target - -[Service] -Type=simple -Restart=always -RestartSec=10 -PermissionsStartOnly=true -WorkingDirectory=HERE_DIR -ExecStartPre=/bin/sh -c 'until ping -c1 spotify.com; do sleep 5; done;' -ExecStart=/usr/bin/java -jar HERE_JAR_FILE - -[Install] -WantedBy=multi-user.target diff --git a/resources/default-settings/spotify.config.toml b/resources/default-settings/spotify.config.toml deleted file mode 100644 index daf154c35..000000000 --- a/resources/default-settings/spotify.config.toml +++ /dev/null @@ -1,84 +0,0 @@ -deviceId = "" ### Device ID (40 chars, leave empty for random) ### -deviceName = "Phoniebox" ### Device name ### -deviceType = "SPEAKER" ### Device type (COMPUTER, TABLET, SMARTPHONE, SPEAKER, TV, AVR, STB, AUDIO_DONGLE, GAME_CONSOLE, CAST_VIDEO, CAST_AUDIO, AUTOMOBILE, WEARABLE, UNKNOWN_SPOTIFY, CAR_THING, UNKNOWN) ### -preferredLocale = "de" ### Preferred locale ### -logLevel = "TRACE" ### Log level (OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE, ALL) ### - -[auth] ### Authentication ### -strategy = "USER_PASS" # Strategy (USER_PASS, ZEROCONF, BLOB, FACEBOOK, STORED) -username = "HERE_USERNAME" # Spotify username (BLOB, USER_PASS only) -password = "HERE_PASSWORD" # Spotify password (USER_PASS only) -blob = "" # Spotify authentication blob Base64-encoded (BLOB only) -storeCredentials = false # Whether to store reusable credentials on disk (not a plain password) -credentialsFile = "credentials.json" # Credentials file (JSON) - -[zeroconf] ### Zeroconf ### -listenPort = 12345 # Listen on this TCP port (`-1` for random) -listenAll = true # Listen on all interfaces (overrides `zeroconf.interfaces`) -interfaces = "" # Listen on these interfaces (comma separated list of names) - -[cache] ### Cache ### -enabled = false # Cache enabled -dir = "./cache/" -doCleanUp = true - -[network] ### Network ### -connectionTimeout = 10 # If ping isn't received within this amount of seconds, reconnect - -[preload] ### Preload ### -enabled = true # Preload enabled - -[time] ### Time correction ### -synchronizationMethod = "NTP" # Time synchronization method (NTP, PING, MELODY, MANUAL) -manualCorrection = 0 # Manual time correction in millis - -[player] ### Player ### -autoplayEnabled = false # Autoplay similar songs when your music ends -preferredAudioQuality = "NORMAL" # Preferred audio quality (NORMAL, HIGH, VERY_HIGH) -enableNormalisation = true # Whether to apply the Spotify loudness normalisation -normalisationPregain = +3.0 # Normalisation pregain in decibels (loud at +6, normal at +3, quiet at -5) -initialVolume = 65536 # Initial volume (0-65536) -volumeSteps = 64 # Number of volume notches -logAvailableMixers = true # Log available mixers -mixerSearchKeywords = "" # Mixer/backend search keywords (semicolon separated) -crossfadeDuration = 0 # Crossfade overlap time (in milliseconds) -output = "MIXER" # Audio output device (MIXER, PIPE, STDOUT, CUSTOM) -outputClass = "" # Audio output Java class name -releaseLineDelay = 20 # Release mixer line after set delay (in seconds) -pipe = "" # Output raw (signed) PCM to this file (`player.output` must be PIPE) -retryOnChunkError = true # Whether the player should retry fetching a chuck if it fails -metadataPipe = "" # Output metadata in Shairport Sync format (https://github.com/mikebrady/shairport-sync-metadata-reader) -bypassSinkVolume = false # Whether librespot-java should ignore volume events, sink volume is set to the max -localFilesPath = "" # Where librespot-java should search for local files - -[api] ### API ### -port = 24879 # API port (`api` module only) -host = "0.0.0.0" # API listen interface (`api` module only) - -[proxy] ### Proxy ### -enabled = false # Whether the proxy is enabled -type = "HTTP" # The proxy type (HTTP, SOCKS) -ssl = false # Connect to proxy using SSL (HTTP only) -address = "" # The proxy hostname -port = 0 # The proxy port -auth = false # Whether authentication is enabled on the server -username = "" # Basic auth username -password = "" # Basic auth password - -[shell] ### Shell ### -enabled = false # Shell events enabled -executeWithBash = false # Execute the command with `bash -c` -onContextChanged = "" -onTrackChanged = "" -onPlaybackEnded = "" -onPlaybackPaused = "" -onPlaybackResumed = "" -onTrackSeeked = "" -onMetadataAvailable = "" -onVolumeChanged = "" -onInactiveSession = "" -onPanicState = "" -onConnectionDropped = "" -onConnectionEstablished = "" -onStartedLoading = "" -onFinishedLoading = "" diff --git a/resources/default-settings/spotify_collection.example.yaml b/resources/default-settings/spotify_collection.example.yaml deleted file mode 100644 index a2acec798..000000000 --- a/resources/default-settings/spotify_collection.example.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# You can add your spotify uri's in this yaml file -# please stick to the syntax: -# - name: -# Example: -# - name: Gute Nacht! -# uri: spotify:playlist:37i9dQZF1DWSVYS2LMyMFg diff --git a/src/jukebox/components/playern/backends/mpd/interfacing_mpd.py b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py similarity index 100% rename from src/jukebox/components/playern/backends/mpd/interfacing_mpd.py rename to src/jukebox/components/player/backends/mpd/interfacing_mpd.py diff --git a/src/jukebox/components/playern/core/__init__.py b/src/jukebox/components/player/core/__init__.py similarity index 100% rename from src/jukebox/components/playern/core/__init__.py rename to src/jukebox/components/player/core/__init__.py diff --git a/src/jukebox/components/playern/core/player_content.py b/src/jukebox/components/player/core/player_content.py similarity index 100% rename from src/jukebox/components/playern/core/player_content.py rename to src/jukebox/components/player/core/player_content.py diff --git a/src/jukebox/components/playern/core/player_status.py b/src/jukebox/components/player/core/player_status.py similarity index 100% rename from src/jukebox/components/playern/core/player_status.py rename to src/jukebox/components/player/core/player_status.py diff --git a/src/jukebox/components/playern/plugin/__init__.py b/src/jukebox/components/player/plugin/__init__.py similarity index 90% rename from src/jukebox/components/playern/plugin/__init__.py rename to src/jukebox/components/player/plugin/__init__.py index 1e24d0901..ed5c3c7d6 100644 --- a/src/jukebox/components/playern/plugin/__init__.py +++ b/src/jukebox/components/player/plugin/__init__.py @@ -8,10 +8,10 @@ import jukebox.plugs as plugin import jukebox.cfghandler -from components.playern.backends.mpd.interfacing_mpd import MPDBackend -from components.playern.core import PlayerCtrl -from components.playern.core.player_content import PlayerData -from components.playern.core.player_status import PlayerStatus +from components.player.backends.mpd.interfacing_mpd import MPDBackend +from components.player.core import PlayerCtrl +from components.player.core.player_content import PlayerData +from components.player.core.player_status import PlayerStatus logger = logging.getLogger('jb.player') cfg = jukebox.cfghandler.get_handler('jukebox') diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py deleted file mode 100644 index ecac65ab8..000000000 --- a/src/jukebox/components/playermpd/__init__.py +++ /dev/null @@ -1,657 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Package for interfacing with the MPD Music Player Daemon - -Status information in three topics -1) Player Status: published only on change - This is a subset of the MPD status (and not the full MPD status) ?? - - folder - - song - - volume (volume is published only via player status, and not separatly to avoid too many Threads) - - ... -2) Elapsed time: published every 250 ms, unless constant - - elapsed -3) Folder Config: published only on change - This belongs to the folder being played - Publish: - - random, resume, single, loop - On save store this information: - Contains the information for resume functionality of each folder - - random, resume, single, loop - - if resume: - - current song, elapsed - - what is PLAYSTATUS for? - When to save - - on stop - Angstsave: - - on pause (only if box get turned off without proper shutdown - else stop gets implicitly called) - - on status change of random, resume, single, loop (for resume omit current status if currently playing- this has now meaning) - Load checks: - - if resume, but no song, elapsed -> log error and start from the beginning - -Status storing: - - Folder config for each folder (see above) - - Information to restart last folder playback, which is: - - last_folder -> folder_on_close - - song, elapsed - - random, resume, single, loop - - if resume is enabled, after start we need to set last_played_folder, such that card swipe is detected as second swipe?! - on the other hand: if resume is enabled, this is also saved to folder.config -> and that is checked by play card - -Internal status - - last played folder: Needed to detect second swipe - - -Saving {'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'}, -'audio_folder_status': -{'TraumfaengerStarkeLieder': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'stop', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}, -'Giraffenaffen': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'play', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}}} - -References: -https://github.com/Mic92/python-mpd2 -https://python-mpd2.readthedocs.io/en/latest/topics/commands.html -https://mpd.readthedocs.io/en/latest/protocol.html - -sudo -u mpd speaker-test -t wav -c 2 -""" # noqa: E501 -# Warum ist "Second Swipe" im Player und nicht im RFID Reader? -# Second swipe ist abhängig vom Player State - nicht vom RFID state. -# Beispiel: RFID triggered Folder1, Webapp triggered Folder2, RFID Folder1: Dann muss das 2. Mal Folder1 auch als "first swipe" -# gewertet werden. Wenn der RFID das basierend auf IDs macht, kann der nicht unterscheiden und glaubt es ist 2. Swipe. -# Beispiel 2: Jemand hat RFID Reader (oder 1x RFID und 1x Barcode Scanner oder so) angeschlossen. Liest zuerst Karte mit -# Reader 1 und dann mit Reader 2: Reader 2 weiß nicht, was bei Reader 1 passiert ist und denkt es ist 1. swipe. -# Beispiel 3: RFID trigered Folder1, Playlist läuft durch und hat schon gestoppt, dann wird die Karte wieder vorgehalten. -# Dann muss das als 1. Swipe gewertet werden -# Beispiel 4: RFID triggered "Folder1", dann wird Karte "Volume Up" aufgelegt, dann wieder Karte "Folder1": Auch das ist -# aus Sicht ders Playbacks 2nd Swipe -# 2nd Swipe ist keine im Reader festgelegte Funktion extra fur den Player. -# -# In der aktuellen Implementierung weiß der Player (der second "swipe" dekodiert) überhaupt nichts vom RFID. -# Im Prinzip gibt es zwei "Play" Funktionen: (1) play always from start und (2) play with toggle action. -# Die Webapp ruft immer (1) auf und die RFID immer (2). Jetzt kann man sogar für einige Karten sagen -# immer (1) - also kein Second Swipe und für andere (2). -# Sollte der Reader das Swcond swipe dekodieren, muss aber der Reader den Status des Player kennen. -# Das ist allerdings ein Problem. In Version 2 ist das nicht aufgefallen, -# weil alles uber File I/Os lief - Thread safe ist das nicht! -# -# Beispiel: Second swipe bei anderen Funktionen, hier: WiFi on/off. -# Was die Karte Action tut ist ein Toggle. Der Toggle hängt vom Wifi State ab, den der RFID Kartenleser nicht kennt. -# Den kann der Leser auch nicht tracken. Der State kann ja auch über die WebApp oder Kommandozeile geändert werden. -# Toggle (und 2nd Swipe generell) ist immer vom Status des Zielsystems abhängig und kann damit nur vom Zielsystem geändert -# werden. Bei Wifi also braucht man 3 Funktionen: on / off / toggle. Toggle ist dann first swipe / second swipe - -import mpd -import threading -import logging -import time -import functools -import components.player -import jukebox.cfghandler -import jukebox.utils as utils -import jukebox.plugs as plugs -import jukebox.multitimer as multitimer -import jukebox.publishing as publishing -import jukebox.playlistgenerator as playlistgenerator -import misc - -from jukebox.NvManager import nv_manager -from .playcontentcallback import PlayContentCallbacks, PlayCardState - -logger = logging.getLogger('jb.PlayerMPD') -cfg = jukebox.cfghandler.get_handler('jukebox') - - -class MpdLock: - def __init__(self, client: mpd.MPDClient, host: str, port: int): - self._lock = threading.RLock() - self.client = client - self.host = host - self.port = port - - def _try_connect(self): - try: - self.client.connect(self.host, self.port) - except mpd.base.ConnectionError: - pass - - def __enter__(self): - self._lock.acquire() - self._try_connect() - return self - - def __exit__(self, exc_type, exc_value, traceback): - self._lock.release() - - def acquire(self, blocking: bool = True, timeout: float = -1) -> bool: - locked = self._lock.acquire(blocking, timeout) - if locked: - self._try_connect() - return locked - - def release(self): - self._lock.release() - - def locked(self): - return self._lock.locked() - - -class PlayerMPD: - """Interface to MPD Music Player Daemon""" - - def __init__(self): - self.nvm = nv_manager() - self.mpd_host = cfg.getn('playermpd', 'host') - self.music_player_status = self.nvm.load(cfg.getn('playermpd', 'status_file')) - - self.second_swipe_action_dict = {'toggle': self.toggle, - 'play': self.play, - 'skip': self.next, - 'rewind': self.rewind, - 'replay': self.replay, - 'replay_if_stopped': self.replay_if_stopped} - self.second_swipe_action = None - self.decode_2nd_swipe_option() - - self.mpd_client = mpd.MPDClient() - # The timeout refer to the low-level socket time-out - # If these are too short and the response is not fast enough (due to the PI being busy), - # the current MPC command times out. Leave these at blocking calls, since we do not react on a timed out socket - # in any relevant matter anyway - self.mpd_client.timeout = None # network timeout in seconds (floats allowed), default: None - self.mpd_client.idletimeout = None # timeout for fetching the result of the idle command - self.connect() - logger.info(f"Connected to MPD Version: {self.mpd_client.mpd_version}") - - self.current_folder_status = {} - if not self.music_player_status: - self.music_player_status['player_status'] = {} - self.music_player_status['audio_folder_status'] = {} - self.music_player_status.save_to_json() - self.current_folder_status = {} - self.music_player_status['player_status']['last_played_folder'] = '' - else: - last_played_folder = self.music_player_status['player_status'].get('last_played_folder') - if last_played_folder: - # current_folder_status is a dict, but last_played_folder a str - self.current_folder_status = self.music_player_status['audio_folder_status'][last_played_folder] - # Restore the playlist status in mpd - # But what about playback position? - self.mpd_client.clear() - # This could fail and cause load fail of entire package: - # self.mpd_client.add(last_played_folder) - logger.info(f"Last Played Folder: {last_played_folder}") - - # Clear last folder played, as we actually did not play any folder yet - # Needed for second swipe detection - # TODO: This will loose the last_played_folder information is the box is started and closed with playing anything... - # Change this to last_played_folder and shutdown_state (for restoring) - self.music_player_status['player_status']['last_played_folder'] = '' - - self.old_song = None - self.mpd_status = {} - self.mpd_status_poll_interval = 0.25 - self.mpd_lock = MpdLock(self.mpd_client, self.mpd_host, 6600) - self.status_is_closing = False - # self.status_thread = threading.Timer(self.mpd_status_poll_interval, self._mpd_status_poll).start() - - self.status_thread = multitimer.GenericEndlessTimerClass('mpd.timer_status', - self.mpd_status_poll_interval, self._mpd_status_poll) - self.status_thread.start() - - def exit(self): - logger.debug("Exit routine of playermpd started") - self.status_is_closing = True - self.status_thread.cancel() - self.mpd_client.disconnect() - self.nvm.save_all() - return self.status_thread.timer_thread - - def connect(self): - self.mpd_client.connect(self.mpd_host, 6600) - - def decode_2nd_swipe_option(self): - cfg_2nd_swipe_action = cfg.setndefault('playermpd', 'second_swipe_action', 'alias', value='none').lower() - if cfg_2nd_swipe_action not in [*self.second_swipe_action_dict.keys(), 'none', 'custom']: - logger.error(f"Config mpd.second_swipe_action must be one of " - f"{[*self.second_swipe_action_dict.keys(), 'none', 'custom']}. Ignore setting.") - if cfg_2nd_swipe_action in self.second_swipe_action_dict.keys(): - self.second_swipe_action = self.second_swipe_action_dict[cfg_2nd_swipe_action] - if cfg_2nd_swipe_action == 'custom': - custom_action = utils.decode_rpc_call(cfg.getn('playermpd', 'second_swipe_action', default=None)) - self.second_swipe_action = functools.partial(plugs.call_ignore_errors, - custom_action['package'], - custom_action['plugin'], - custom_action['method'], - custom_action['args'], - custom_action['kwargs']) - - def mpd_retry_with_mutex(self, mpd_cmd, *args): - """ - This method adds thread saftey for acceses to mpd via a mutex lock, - it shall be used for each access to mpd to ensure thread safety - In case of a communication error the connection will be reestablished and the pending command will be repeated 2 times - - I think this should be refactored to a decorator - """ - with self.mpd_lock: - try: - value = mpd_cmd(*args) - except Exception as e: - logger.error(f"{e.__class__.__qualname__}: {e}") - value = None - return value - - def _mpd_status_poll(self): - """ - this method polls the status from mpd and stores the important inforamtion in the music_player_status, - it will repeat itself in the intervall specified by self.mpd_status_poll_interval - """ - self.mpd_status.update(self.mpd_retry_with_mutex(self.mpd_client.status)) - self.mpd_status.update(self.mpd_retry_with_mutex(self.mpd_client.currentsong)) - - if self.mpd_status.get('elapsed') is not None: - self.current_folder_status["ELAPSED"] = self.mpd_status['elapsed'] - self.music_player_status['player_status']["CURRENTSONGPOS"] = self.mpd_status['song'] - self.music_player_status['player_status']["CURRENTFILENAME"] = self.mpd_status['file'] - - if self.mpd_status.get('file') is not None: - self.current_folder_status["CURRENTFILENAME"] = self.mpd_status['file'] - self.current_folder_status["CURRENTSONGPOS"] = self.mpd_status['song'] - self.current_folder_status["ELAPSED"] = self.mpd_status.get('elapsed', '0.0') - self.current_folder_status["PLAYSTATUS"] = self.mpd_status['state'] - self.current_folder_status["RESUME"] = "OFF" - self.current_folder_status["SHUFFLE"] = "OFF" - self.current_folder_status["LOOP"] = "OFF" - self.current_folder_status["SINGLE"] = "OFF" - - # Delete the volume key to avoid confusion - # Volume is published via the 'volume' component! - try: - del self.mpd_status['volume'] - except KeyError: - pass - publishing.get_publisher().send('playerstatus', self.mpd_status) - - @plugs.tag - def get_player_type_and_version(self): - with self.mpd_lock: - value = self.mpd_client.mpd_version() - return value - - @plugs.tag - def update(self): - with self.mpd_lock: - state = self.mpd_client.update() - return state - - @plugs.tag - def update_wait(self): - state = self.update() - self._db_wait_for_update(state) - return state - - @plugs.tag - def play(self): - with self.mpd_lock: - self.mpd_client.play() - - @plugs.tag - def stop(self): - with self.mpd_lock: - self.mpd_client.stop() - - @plugs.tag - def pause(self, state: int = 1): - """Enforce pause to state (1: pause, 0: resume) - - This is what you want as card removal action: pause the playback, so it can be resumed when card is placed - on the reader again. What happens on re-placement depends on configured second swipe option - """ - with self.mpd_lock: - self.mpd_client.pause(state) - - @plugs.tag - def prev(self): - logger.debug("Prev") - with self.mpd_lock: - self.mpd_client.previous() - - @plugs.tag - def next(self): - """Play next track in current playlist""" - logger.debug("Next") - with self.mpd_lock: - self.mpd_client.next() - - @plugs.tag - def seek(self, new_time): - with self.mpd_lock: - self.mpd_client.seekcur(new_time) - - @plugs.tag - def shuffle(self, random): - # As long as we don't work with waiting lists (aka playlist), this implementation is ok! - self.mpd_retry_with_mutex(self.mpd_client.random, 1 if random else 0) - - @plugs.tag - def rewind(self): - """ - Re-start current playlist from first track - - Note: Will not re-read folder config, but leave settings untouched""" - logger.debug("Rewind") - with self.mpd_lock: - self.mpd_client.play(1) - - @plugs.tag - def replay(self): - """ - Re-start playing the last-played folder - - Will reset settings to folder config""" - logger.debug("Replay") - with self.mpd_lock: - self.play_folder(self.music_player_status['player_status']['last_played_folder']) - - @plugs.tag - def toggle(self): - """Toggle pause state, i.e. do a pause / resume depending on current state""" - logger.debug("Toggle") - with self.mpd_lock: - self.mpd_client.pause() - - @plugs.tag - def replay_if_stopped(self): - """ - Re-start playing the last-played folder unless playlist is still playing - - .. note:: To me this seems much like the behaviour of play, - but we keep it as it is specifically implemented in box 2.X""" - with self.mpd_lock: - if self.mpd_status['state'] == 'stop': - self.play_folder(self.music_player_status['player_status']['last_played_folder']) - - @plugs.tag - def repeatmode(self, mode): - if mode == 'repeat': - repeat = 1 - single = 0 - elif mode == 'single': - repeat = 1 - single = 1 - else: - repeat = 0 - single = 0 - - with self.mpd_lock: - self.mpd_client.repeat(repeat) - self.mpd_client.single(single) - - @plugs.tag - def get_current_song(self, param): - return self.mpd_status - - @plugs.tag - def map_filename_to_playlist_pos(self, filename): - # self.mpd_client.playlistfind() - raise NotImplementedError - - @plugs.tag - def remove(self): - raise NotImplementedError - - @plugs.tag - def move(self): - # song_id = param.get("song_id") - # step = param.get("step") - # MPDClient.playlistmove(name, from, to) - # MPDClient.swapid(song1, song2) - raise NotImplementedError - - @plugs.tag - def play_single(self, song_url): - with self.mpd_lock: - self.mpd_client.clear() - self.mpd_client.addid(song_url) - self.mpd_client.play() - - @plugs.tag - def resume(self): - with self.mpd_lock: - songpos = self.current_folder_status["CURRENTSONGPOS"] - elapsed = self.current_folder_status["ELAPSED"] - self.mpd_client.seek(songpos, elapsed) - self.mpd_client.play() - - @plugs.tag - def play_card(self, folder: str, recursive: bool = False): - """ - Main entry point for trigger music playing from RFID reader. Decodes second swipe options before playing folder content - - Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action - accordingly. - - :param folder: Folder path relative to music library path - :param recursive: Add folder recursively - """ - # Developers notes: - # - # * 2nd swipe trigger may also happen, if playlist has already stopped playing - # --> Generally, treat as first swipe - # * 2nd swipe of same Card ID may also happen if a different song has been played in between from WebUI - # --> Treat as first swipe - # * With place-not-swipe: Card is placed on reader until playlist expieres. Music stop. Card is removed and - # placed again on the reader: Should be like first swipe - # * TODO: last_played_folder is restored after box start, so first swipe of last played card may look like - # second swipe - # - logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}") - with self.mpd_lock: - is_second_swipe = self.music_player_status['player_status']['last_played_folder'] == folder - if self.second_swipe_action is not None and is_second_swipe: - logger.debug('Calling second swipe action') - - # run callbacks before second_swipe_action is invoked - play_card_callbacks.run_callbacks(folder, PlayCardState.secondSwipe) - - self.second_swipe_action() - else: - logger.debug('Calling first swipe action') - - # run callbacks before play_folder is invoked - play_card_callbacks.run_callbacks(folder, PlayCardState.firstSwipe) - - self.play_folder(folder, recursive) - - @plugs.tag - def get_folder_content(self, folder: str): - """ - Get the folder content as content list with meta-information. Depth is always 1. - - Call repeatedly to descend in hierarchy - - :param folder: Folder path relative to music library path - """ - plc = playlistgenerator.PlaylistCollector(components.player.get_music_library_path()) - plc.get_directory_content(folder) - return plc.playlist - - @plugs.tag - def play_folder(self, folder: str, recursive: bool = False) -> None: - """ - Playback a music folder. - - Folder content is added to the playlist as described by :mod:`jukebox.playlistgenerator`. - The playlist is cleared first. - - :param folder: Folder path relative to music library path - :param recursive: Add folder recursively - """ - # TODO: This changes the current state -> Need to save last state - with self.mpd_lock: - logger.info(f"Play folder: '{folder}'") - self.mpd_client.clear() - - plc = playlistgenerator.PlaylistCollector(components.player.get_music_library_path()) - plc.parse(folder, recursive) - uri = '--unset--' - try: - for uri in plc: - self.mpd_client.addid(uri) - except mpd.base.CommandError as e: - logger.error(f"{e.__class__.__qualname__}: {e} at uri {uri}") - except Exception as e: - logger.error(f"{e.__class__.__qualname__}: {e} at uri {uri}") - - self.music_player_status['player_status']['last_played_folder'] = folder - - self.current_folder_status = self.music_player_status['audio_folder_status'].get(folder) - if self.current_folder_status is None: - self.current_folder_status = self.music_player_status['audio_folder_status'][folder] = {} - - self.mpd_client.play() - - @plugs.tag - def play_album(self, albumartist: str, album: str): - """ - Playback a album found in MPD database. - - All album songs are added to the playlist - The playlist is cleared first. - - :param albumartist: Artist of the Album provided by MPD database - :param album: Album name provided by MPD database - """ - with self.mpd_lock: - logger.info(f"Play album: '{album}' by '{albumartist}") - self.mpd_client.clear() - self.mpd_retry_with_mutex(self.mpd_client.findadd, 'albumartist', albumartist, 'album', album) - self.mpd_client.play() - - @plugs.tag - def queue_load(self, folder): - # There was something playing before -> stop and save state - # Clear the queue - # Check / Create the playlist - # - not needed if same folder is played again? Buf what if files have been added a mpc update has been run? - # - and this a re-trigger to start the new playlist - # If we must update the playlists everytime anyway why write them to file and not just keep them in the queue? - # Load the playlist - # Get folder config and apply settings - pass - - @plugs.tag - def playerstatus(self): - return self.mpd_status - - @plugs.tag - def playlistinfo(self): - with self.mpd_lock: - value = self.mpd_client.playlistinfo() - return value - - # Attention: MPD.listal will consume a lot of memory with large libs.. should be refactored at some point - @plugs.tag - def list_all_dirs(self): - with self.mpd_lock: - result = self.mpd_client.listall() - # list = [entry for entry in list if 'directory' in entry] - return result - - @plugs.tag - def list_albums(self): - with self.mpd_lock: - albums = self.mpd_retry_with_mutex(self.mpd_client.list, 'album', 'group', 'albumartist') - - return albums - - @plugs.tag - def list_song_by_artist_and_album(self, albumartist, album): - with self.mpd_lock: - albums = self.mpd_retry_with_mutex(self.mpd_client.find, 'albumartist', albumartist, 'album', album) - - return albums - - @plugs.tag - def get_song_by_url(self, song_url): - with self.mpd_lock: - song = self.mpd_retry_with_mutex(self.mpd_client.find, 'file', song_url) - - return song - - def get_volume(self): - """ - Get the current volume - - For volume control do not use directly, but use through the plugin 'volume', - as the user may have configured a volume control manager other than MPD""" - with self.mpd_lock: - volume = self.mpd_client.status().get('volume') - return int(volume) - - def set_volume(self, volume): - """ - Set the volume - - For volume control do not use directly, but use through the plugin 'volume', - as the user may have configured a volume control manager other than MPD""" - with self.mpd_lock: - self.mpd_client.setvol(volume) - return self.get_volume() - - def _db_wait_for_update(self, update_id: int): - logger.debug("Waiting for update to finish") - while self._db_is_updating(update_id): - # a little throttling - time.sleep(0.1) - - def _db_is_updating(self, update_id: int): - with self.mpd_lock: - _status = self.mpd_client.status() - _cur_update_id = _status.get('updating_db') - if _cur_update_id is not None and int(_cur_update_id) <= int(update_id): - return True - else: - return False - - -# --------------------------------------------------------------------------- -# Plugin Initializer / Finalizer -# --------------------------------------------------------------------------- - -player_ctrl: PlayerMPD -#: Callback handler instance for play_card events. -#: - is executed when play_card function is called -#: States: -#: - See :class:`PlayCardState` -#: See :class:`PlayContentCallbacks` -play_card_callbacks: PlayContentCallbacks[PlayCardState] - - -@plugs.initialize -def initialize(): - global player_ctrl - player_ctrl = PlayerMPD() - plugs.register(player_ctrl, name='ctrl') - - global play_card_callbacks - play_card_callbacks = PlayContentCallbacks[PlayCardState]('play_card_callbacks', logger, context=player_ctrl.mpd_lock) - - # Update mpc library - library_update = cfg.setndefault('playermpd', 'library', 'update_on_startup', value=True) - if library_update: - player_ctrl.update() - - # Check user rights on music library - library_check_user_rights = cfg.setndefault('playermpd', 'library', 'check_user_rights', value=True) - if library_check_user_rights is True: - music_library_path = components.player.get_music_library_path() - if music_library_path is not None: - logger.info(f"Change user rights for {music_library_path}") - misc.recursive_chmod(music_library_path, mode_files=0o666, mode_dirs=0o777) - - -@plugs.atexit -def atexit(**ignored_kwargs): - global player_ctrl - return player_ctrl.exit() diff --git a/src/jukebox/components/playermpd/playcontentcallback.py b/src/jukebox/components/playermpd/playcontentcallback.py deleted file mode 100644 index a60452a23..000000000 --- a/src/jukebox/components/playermpd/playcontentcallback.py +++ /dev/null @@ -1,37 +0,0 @@ - -from enum import Enum -from typing import Callable, Generic, TypeVar - -from jukebox.callingback import CallbackHandler - - -class PlayCardState(Enum): - firstSwipe = 0, - secondSwipe = 1 - - -STATE = TypeVar('STATE', bound=Enum) - - -class PlayContentCallbacks(Generic[STATE], CallbackHandler): - """ - Callbacks are executed in various play functions - """ - - def register(self, func: Callable[[str, STATE], None]): - """ - Add a new callback function :attr:`func`. - - Callback signature is - - .. py:function:: func(folder: str, state: STATE) - :noindex: - - :param folder: relativ path to folder to play - :param state: indicator of the state inside the calling - """ - super().register(func) - - def run_callbacks(self, folder: str, state: STATE): - """:meta private:""" - super().run_callbacks(folder, state) diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index dd87ddf48..bd8fd782e 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -9,16 +9,15 @@ const commands = { plugin: 'ctrl', method: 'list_all_dirs', }, - 'mpd.get_albums': { + albumList: { _package: 'player', - plugin: 'mpd', - method: 'get_albums', + plugin: 'ctrl', + method: 'list_albums', }, - 'mpd.get_album_tracks': { + songList: { _package: 'player', - plugin: 'mpd', - method: 'get_album_tracks', - argKeys: ['album_artist', 'album'] + plugin: 'ctrl', + method: 'list_song_by_artist_and_album', }, getSongByUrl: { _package: 'player', @@ -26,11 +25,10 @@ const commands = { method: 'get_song_by_url', argKeys: ['song_url'] }, - 'mpd.get_files': { + folderList: { _package: 'player', - plugin: 'mpd', - method: 'get_files', - argKeys: ['path'] + plugin: 'ctrl', + method: 'get_folder_content', }, cardsList: { _package: 'cards', @@ -56,6 +54,24 @@ const commands = { plugin: 'ctrl', method: 'play', }, + play_single: { + _package: 'player', + plugin: 'ctrl', + method: 'play_single', + argKeys: ['song_url'] + }, + play_folder: { + _package: 'player', + plugin: 'ctrl', + method: 'play_folder', + argKeys: ['folder'] + }, + play_album: { + _package: 'player', + plugin: 'ctrl', + method: 'play_album', + argKeys: ['albumartist', 'album'] + }, pause: { _package: 'player', plugin: 'ctrl', @@ -79,17 +95,12 @@ const commands = { repeat: { _package: 'player', plugin: 'ctrl', - method: 'repeat', + method: 'repeatmode', }, seek: { - _package: 'players', - plugin: 'seek', - }, - 'mpd.play_uri': { _package: 'player', - plugin: 'mpd', - method: 'play_uri', - argKeys: ['uri'] + plugin: 'ctrl', + method: 'seek', }, // Volume diff --git a/src/webapp/src/components/Library/lists/albums/index.js b/src/webapp/src/components/Library/lists/albums/index.js index 92b1caedd..3e915a9fa 100644 --- a/src/webapp/src/components/Library/lists/albums/index.js +++ b/src/webapp/src/components/Library/lists/albums/index.js @@ -30,7 +30,7 @@ const Albums = ({ musicFilter }) => { useEffect(() => { const fetchAlbumList = async () => { setIsLoading(true); - const { result, error } = await request('mpd.get_albums'); + const { result, error } = await request('albumList'); setIsLoading(false); if(result) setAlbums(result.reduce(flatByAlbum, [])); diff --git a/src/webapp/src/components/Library/lists/albums/song-list/index.js b/src/webapp/src/components/Library/lists/albums/song-list/index.js index 707964e2b..006ab791b 100644 --- a/src/webapp/src/components/Library/lists/albums/song-list/index.js +++ b/src/webapp/src/components/Library/lists/albums/song-list/index.js @@ -30,10 +30,10 @@ const SongList = ({ const getSongList = async () => { setIsLoading(true); const { result, error } = await request( - 'mpd.get_album_tracks', + 'songList', { - album_artist: decodeURIComponent(artist), album: decodeURIComponent(album), + albumartist: decodeURIComponent(artist), } ); setIsLoading(false); @@ -70,23 +70,21 @@ const SongList = ({ marginTop: '0' }} > - {isLoading && } - {!isLoading && !error && - - {songs.map(song => - - )} - + {isLoading + ? + : + {songs.map(song => + + )} + } {error && - - {`${t('library.albums.no-songs-in-album')} 🤔`} - + {`${t('library.albums.no-songs-in-album')} 🤔`} } diff --git a/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js b/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js index 4423c5896..b2391819e 100644 --- a/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js +++ b/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js @@ -18,20 +18,14 @@ const SongListControls = ({ isSelecting }) => { const { t } = useTranslation(); - - const command = 'mpd.play_uri'; - const uri = [ - 'mpd', - 'album', encodeURI(album), - 'albumartist', encodeURI(albumartist) - ].join(':'); + const command = 'play_album'; const playAlbum = () => ( - request(command, { uri }) + request(command, { albumartist, album }) ); const registerAlbumToCard = () => ( - registerMusicToCard(command, { uri }) + registerMusicToCard(command, { albumartist, album }) ); return ( diff --git a/src/webapp/src/components/Library/lists/albums/song-list/song-list-headline.js b/src/webapp/src/components/Library/lists/albums/song-list/song-list-headline.js index 7cf33153d..55f1ec9b7 100644 --- a/src/webapp/src/components/Library/lists/albums/song-list/song-list-headline.js +++ b/src/webapp/src/components/Library/lists/albums/song-list/song-list-headline.js @@ -6,7 +6,7 @@ import { } from '@mui/material'; const SongListHeadline = ({ artist, album }) => ( - + {album} diff --git a/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js b/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js index a367aa3c4..0f22d2df3 100644 --- a/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js +++ b/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js @@ -17,7 +17,7 @@ const SongListItem = ({ }) => { const { t } = useTranslation(); - const command = 'mpd.play_uri'; + const command = 'play_single'; const { artist, duration, @@ -25,14 +25,12 @@ const SongListItem = ({ title, } = song; - const uri = `mpd:file:${file}`; - - const playSingle = () => ( - request(command, { uri }) - ); + const playSingle = () => { + request(command, { song_url: file }) + } const registerSongToCard = () => ( - registerMusicToCard(command, { uri }) + registerMusicToCard(command, { song_url: file }) ); return ( diff --git a/src/webapp/src/components/Library/lists/folders/folder-link.js b/src/webapp/src/components/Library/lists/folders/folder-link.js index 95d05a33f..1f55415c3 100644 --- a/src/webapp/src/components/Library/lists/folders/folder-link.js +++ b/src/webapp/src/components/Library/lists/folders/folder-link.js @@ -7,10 +7,10 @@ import { const FolderLink = forwardRef((props, ref) => { const { search: urlSearch } = useLocation(); const { data } = props; - const path = encodeURIComponent(data?.path); + const dir = encodeURIComponent(data?.dir); // TODO: Introduce fallback incase artist or album are undefined - const location = `/library/folders/${path}${urlSearch}`; + const location = `/library/folders/${dir}${urlSearch}`; return }); diff --git a/src/webapp/src/components/Library/lists/folders/folder-list-item-back.js b/src/webapp/src/components/Library/lists/folders/folder-list-item-back.js index d406359fa..2ff14667b 100644 --- a/src/webapp/src/components/Library/lists/folders/folder-list-item-back.js +++ b/src/webapp/src/components/Library/lists/folders/folder-list-item-back.js @@ -11,14 +11,14 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import FolderLink from './folder-link'; -const FolderListItemBack = ({ path }) => { +const FolderListItemBack = ({ dir }) => { const { t } = useTranslation(); return ( diff --git a/src/webapp/src/components/Library/lists/folders/folder-list-item.js b/src/webapp/src/components/Library/lists/folders/folder-list-item.js index c6b37f58f..755feef15 100644 --- a/src/webapp/src/components/Library/lists/folders/folder-list-item.js +++ b/src/webapp/src/components/Library/lists/folders/folder-list-item.js @@ -21,15 +21,12 @@ const FolderListItem = ({ registerMusicToCard, }) => { const { t } = useTranslation(); - const { directory, file } = folder; - const type = directory ? 'directory' : 'file'; - const path = directory || file; - const name = directory || folder.title; + const { type, name, path } = folder; const playItem = () => { switch(type) { - case 'directory': return request('mpd.play_uri', { uri: `mpd:folder:${path}` }); - case 'file': return request('mpd.play_uri', { uri: `mpd:file:${path}` }); + case 'directory': return request('play_folder', { folder: path, recursive: true }); + case 'file': return request('play_single', { song_url: path.replace(`${DEFAULT_AUDIO_DIR}/`, '') }); // TODO: Add missing Podcast // TODO: Add missing Stream default: return; @@ -38,8 +35,8 @@ const FolderListItem = ({ const registerItemToCard = () => { switch(type) { - case 'directory': return registerMusicToCard('mpd.play_uri', { uri: `mpd:folder:${path}` }); - case 'file': return registerMusicToCard('mpd.play_uri', { uri: `mpd:file:${path}` }); + case 'directory': return registerMusicToCard('play_folder', { folder: path, recursive: true }); + case 'file': return registerMusicToCard('play_single', { song_url: path.replace(`${DEFAULT_AUDIO_DIR}/`, '') }); // TODO: Add missing Podcast // TODO: Add missing Stream default: return; @@ -53,7 +50,7 @@ const FolderListItem = ({ type === 'directory' ? diff --git a/src/webapp/src/components/Library/lists/folders/folder-list.js b/src/webapp/src/components/Library/lists/folders/folder-list.js index 7a8fcfd72..36a9bcebd 100644 --- a/src/webapp/src/components/Library/lists/folders/folder-list.js +++ b/src/webapp/src/components/Library/lists/folders/folder-list.js @@ -9,27 +9,28 @@ import FolderListItemBack from './folder-list-item-back'; import { ROOT_DIRS } from '../../../../config'; const FolderList = ({ - path, + dir, folders, isSelecting, registerMusicToCard, }) => { - const getParent = (path) => { - const decodedPath = decodeURIComponent(path); - + const getParentDir = (dir) => { // TODO: ROOT_DIRS should be removed after paths are relative - if (ROOT_DIRS.includes(decodedPath)) return undefined; + const decodedDir = decodeURIComponent(dir); + + if (ROOT_DIRS.includes(decodedDir)) return undefined; - return dropLast(1, decodedPath.split('/')).join('/') || './'; + const parentDir = dropLast(1, decodedDir.split('/')).join('/'); + return parentDir; } - const parent = getParent(path); + const parentDir = getParentDir(dir); return ( - {parent && + {parentDir && } {folders.map((folder, key) => diff --git a/src/webapp/src/components/Library/lists/folders/index.js b/src/webapp/src/components/Library/lists/folders/index.js index 9f075d4c7..32bf47c33 100644 --- a/src/webapp/src/components/Library/lists/folders/index.js +++ b/src/webapp/src/components/Library/lists/folders/index.js @@ -16,7 +16,7 @@ const Folders = ({ registerMusicToCard, }) => { const { t } = useTranslation(); - const { path = './' } = useParams(); + const { dir = './' } = useParams(); const [folders, setFolders] = useState([]); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -33,8 +33,8 @@ const Folders = ({ const fetchFolderList = async () => { setIsLoading(true); const { result, error } = await request( - 'mpd.get_files', - { path: decodeURIComponent(path) } + 'folderList', + { folder: decodeURIComponent(dir) } ); setIsLoading(false); @@ -43,7 +43,7 @@ const Folders = ({ } fetchFolderList(); - }, [path]); + }, [dir]); const filteredFolders = folders.filter(search); @@ -56,7 +56,7 @@ const Folders = ({ return ( { return ( {isSelecting && } - + { element={} /> { const { t } = useTranslation(); - const { state: { player_status } } = useContext(PubSubContext); + const { + state, + setState, + } = useContext(PlayerContext); const { - playing, - shuffle, - repeat, - trackid, - } = player_status; + isPlaying, + playerstatus, + isShuffle, + isRepeat, + isSingle, + songIsScheduled + } = state; const toggleShuffle = () => { - request('shuffle', { val: !shuffle }); + request('shuffle', { random: !isShuffle }); } const toggleRepeat = () => { - let mode = repeat + 1; - if (mode > 2) mode = -1; - request('repeat', { val: mode }); + let mode = null; + if (!isRepeat && !isSingle) mode = 'repeat'; + if (isRepeat && !isSingle) mode = 'single'; + + request('repeat', { mode }); } + useEffect(() => { + setState({ + ...state, + isPlaying: playerstatus?.state === 'play' ? true : false, + songIsScheduled: playerstatus?.songid ? true : false, + isShuffle: playerstatus?.random === '1' ? true : false, + isRepeat: playerstatus?.repeat === '1' ? true : false, + isSingle: playerstatus?.single === '1' ? true : false, + }); + }, [playerstatus]); + const iconStyles = { padding: '7px' }; const labelShuffle = () => ( - shuffle + isShuffle ? t('player.controls.shuffle.deactivate') : t('player.controls.shuffle.activate') ); - // Toggle or set repeat (-1 toggle, 0 no repeat, 1 context, 2 single) const labelRepeat = () => { - const labels = [ - t('player.controls.repeat.activate'), - t('player.controls.repeat.activate-single'), - t('player.controls.repeat.deactivate'), - ]; - - return labels[repeat]; + if (!isRepeat) return t('player.controls.repeat.activate'); + if (isRepeat && !isSingle) return t('player.controls.repeat.activate-single'); + if (isRepeat && isSingle) return t('player.controls.repeat.deactivate'); }; return ( @@ -67,7 +80,7 @@ const Controls = () => { {/* Shuffle */} { {/* Skip previous track */} request('previous')} + disabled={!songIsScheduled} + onClick={e => request('previous')} size="large" sx={iconStyles} title={t('player.controls.skip')} @@ -89,36 +102,36 @@ const Controls = () => { {/* Play */} - {/* {!playing && */} + {!isPlaying && request('play')} - // disabled={!trackid} + onClick={e => request('play')} + disabled={!songIsScheduled} size="large" sx={iconStyles} title={t('player.controls.play')} > - {/* } */} + } {/* Pause */} - {/* {playing && */} + {isPlaying && request('pause')} + onClick={e => request('pause')} size="large" sx={iconStyles} title={t('player.controls.pause')} > - {/* } */} + } {/* Skip next track */} request('next')} + disabled={!songIsScheduled} + onClick={e => request('next')} size="large" sx={iconStyles} title={t('player.controls.next')} @@ -129,18 +142,18 @@ const Controls = () => { {/* Repeat */} 0 ? 'primary' : undefined} + color={isRepeat ? 'primary' : undefined} onClick={toggleRepeat} size="large" sx={iconStyles} title={labelRepeat()} > { - repeat < 2 && + !isSingle && } { - repeat === 2 && + isSingle && } diff --git a/src/webapp/src/components/Player/cover.js b/src/webapp/src/components/Player/cover.js index d20133acb..e44e38e6b 100644 --- a/src/webapp/src/components/Player/cover.js +++ b/src/webapp/src/components/Player/cover.js @@ -32,7 +32,7 @@ const Cover = ({ coverImage }) => { {coverImage && {t('player.cover.title')}} {!coverImage && diff --git a/src/webapp/src/components/Player/display.js b/src/webapp/src/components/Player/display.js index 6c1acff16..446eba349 100644 --- a/src/webapp/src/components/Player/display.js +++ b/src/webapp/src/components/Player/display.js @@ -1,14 +1,14 @@ import React, { useContext } from 'react'; import { useTranslation } from 'react-i18next'; -import PubSubContext from '../../context/pubsub/context'; +import PlayerContext from '../../context/player/context'; import Grid from '@mui/material/Grid'; import Typography from '@mui/material/Typography'; const Display = () => { const { t } = useTranslation(); - const { state: { player_status } } = useContext(PubSubContext); + const { state: { playerstatus } } = useContext(PlayerContext); const dontBreak = { whiteSpace: 'nowrap', @@ -20,15 +20,15 @@ const Display = () => { return ( - {player_status.trackid - ? (player_status.title || t('player.display.unknown-title')) + {playerstatus?.songid + ? (playerstatus?.title || t('player.display.unknown-title')) : t('player.display.no-song-in-queue') } - {player_status.trackid && (player_status.artist || t('player.display.unknown-artist')) } - - {player_status.trackid && (player_status.album || player_status.file) } + {playerstatus?.songid && (playerstatus?.artist || t('player.display.unknown-artist')) } + + {playerstatus?.songid && (playerstatus?.album || playerstatus?.file) } ); diff --git a/src/webapp/src/components/Player/index.js b/src/webapp/src/components/Player/index.js index 2a3a0f3c8..5efa5e10a 100644 --- a/src/webapp/src/components/Player/index.js +++ b/src/webapp/src/components/Player/index.js @@ -8,24 +8,35 @@ import Display from './display'; import SeekBar from './seekbar'; import Volume from './volume'; +import PlayerContext from '../../context/player/context'; import PubSubContext from '../../context/pubsub/context'; +import request from '../../utils/request'; +import { pluginIsLoaded } from '../../utils/utils'; const Player = () => { - const { state: { player_status } } = useContext(PubSubContext); + const { state: { playerstatus } } = useContext(PlayerContext); + const { state: { 'core.plugins.loaded': plugins } } = useContext(PubSubContext); + const { file } = playerstatus || {}; const [coverImage, setCoverImage] = useState(undefined); const [backgroundImage, setBackgroundImage] = useState('none'); useEffect(() => { - if (player_status?.coverArt) { - const coverImageSrc = `https://i.scdn.co/image/${player_status.coverArt}`; - setCoverImage(coverImageSrc); - setBackgroundImage([ - 'linear-gradient(to bottom, rgba(18, 18, 18, 0.7), rgba(18, 18, 18, 1))', - `url(${coverImageSrc})` - ].join(',')); + const getMusicCover = async () => { + const { result } = await request('musicCoverByFilenameAsBase64', { audio_src: file }); + if (result) { + setCoverImage(result); + setBackgroundImage([ + 'linear-gradient(to bottom, rgba(18, 18, 18, 0.7), rgba(18, 18, 18, 1))', + `url(data:image/jpeg;base64,${result})` + ].join(',')); + }; } - }, [player_status]); + + if (pluginIsLoaded(plugins, 'music_cover_art') && file) { + getMusicCover(); + } + }, [file, plugins]); return ( { const { t } = useTranslation(); - const { state: { player_status } } = useContext(PubSubContext); + const { state } = useContext(PlayerContext); + const { playerstatus } = state; - const [isRunning, setIsRunning] = useState(player_status?.playing); const [isSeeking, setIsSeeking] = useState(false); const [progress, setProgress] = useState(0); - const [timeElapsed, setTimeElapsed] = useState(parseFloat(player_status?.elapsed) || 0); - const timeTotal = parseFloat(player_status?.duration) || 0; + const [timeElapsed, setTimeElapsed] = useState(parseFloat(playerstatus?.elapsed) || 0); + const timeTotal = parseFloat(playerstatus?.duration) || 0; const updateTimeAndProgress = (newTime) => { setTimeElapsed(newTime); @@ -35,7 +35,7 @@ const SeekBar = () => { updateTimeAndProgress(progressToTime(timeTotal, newPosition)); }; - // Only send command to backend when user committed to new position + // Only send commend to backend when user committed to new position // We don't send it while seeking (too many useless requests) const playFromNewTime = () => { request('seek', { new_time: timeElapsed.toFixed(3) }); @@ -46,16 +46,16 @@ const SeekBar = () => { // Avoid updating time and progress when user is seeking to new // song position if (!isSeeking) { - updateTimeAndProgress(player_status?.elapsed); + updateTimeAndProgress(playerstatus?.elapsed); } - }, [player_status]); + }, [playerstatus]); return <> { > - + {toHHMMSS(parseInt(timeElapsed))} diff --git a/src/webapp/src/components/Settings/timers/timer.js b/src/webapp/src/components/Settings/timers/timer.js index c6323fd4b..20b990edc 100644 --- a/src/webapp/src/components/Settings/timers/timer.js +++ b/src/webapp/src/components/Settings/timers/timer.js @@ -11,7 +11,7 @@ import { useTheme } from '@mui/material/styles'; import request from '../../../utils/request'; import { - Counter, + Countdown, SliderTimer } from '../../general'; @@ -86,7 +86,7 @@ const Timer = ({ type }) => { marginLeft: '0', }}> {status?.enabled && - setEnabled(false)} stringEnded={t('settings.timers.ended')} diff --git a/src/webapp/src/components/general/Countdown.js b/src/webapp/src/components/general/Countdown.js new file mode 100644 index 000000000..84e91cb88 --- /dev/null +++ b/src/webapp/src/components/general/Countdown.js @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { toHHMMSS } from '../../utils/utils'; + +const Countdown = ({ onEnd, seconds, stringEnded = undefined }) => { + // This is required to avoid async updates on unmounted compomemts + // https://github.com/facebook/react/issues/14227 + const isMounted = useRef(null); + const [time, setTime] = useState(seconds); + + const onEndCallback = useCallback(() => onEnd(), [onEnd]); + + useEffect(() => { + isMounted.current = true; + + if (time === 0) return onEndCallback(); + setTimeout(() => { + if (isMounted.current) setTime(time - 1) + }, 1000); + + return () => { + isMounted.current = false; + } + }, [onEndCallback, time]); + + if (time) return toHHMMSS(time); + if (stringEnded) return stringEnded; + return toHHMMSS(0); +} + +export default Countdown; diff --git a/src/webapp/src/components/general/Counter.js b/src/webapp/src/components/general/Counter.js deleted file mode 100644 index 26c5884c7..000000000 --- a/src/webapp/src/components/general/Counter.js +++ /dev/null @@ -1,48 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { toHHMMSS } from '../../utils/utils'; - -const Counter = ({ - seconds, - direction = 'down', - end = 0, - paused = false, - onEnd = () => {}, - stringEnded = undefined -}) => { - // This is required to avoid async updates on unmounted components - // https://github.com/facebook/react/issues/14227 - const isMounted = useRef(null); - const [time, setTime] = useState(parseInt(seconds)); - - const onEndCallback = useCallback(() => onEnd(), [onEnd]); - - useEffect(() => { - isMounted.current = true; - - const summand = direction === 'down' ? -1 : 1; - - if (!paused) { - if (time >= end) return onEndCallback(); - setTimeout(() => { - if (isMounted.current) setTime(time + summand) - }, 1000); - } - - return () => { - isMounted.current = false; - } - }, [ - direction, - end, - onEndCallback, - paused, - time, - ]); - - if (time) return toHHMMSS(time); - if (stringEnded) return stringEnded; - return toHHMMSS(0); -} - -export default Counter; diff --git a/src/webapp/src/components/general/index.js b/src/webapp/src/components/general/index.js index e4aedf07e..a97998977 100644 --- a/src/webapp/src/components/general/index.js +++ b/src/webapp/src/components/general/index.js @@ -1,9 +1,9 @@ -import Counter from "./Counter" +import Countdown from "./Countdown" import SliderTimer from "./SliderTimer" import SwitchWithLoader from "./SwitchWithLoader" export { - Counter, + Countdown, SliderTimer, SwitchWithLoader, }; diff --git a/src/webapp/src/config.js b/src/webapp/src/config.js index a241add74..4383a7897 100644 --- a/src/webapp/src/config.js +++ b/src/webapp/src/config.js @@ -13,7 +13,6 @@ const SUBSCRIPTIONS = [ 'host.timer.cputemp', 'host.temperature.cpu', 'playerstatus', - 'player_status', 'rfid.card_id', 'volume.level', ]; From b910210c9decd2da8cdd7899e7dee19eaaec4cff Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sat, 18 Nov 2023 15:58:24 +0100 Subject: [PATCH 071/109] further update from upstream --- docker/config/docker.mpd.conf | 2 +- installation/includes/01_default_config.sh | 1 - installation/routines/customize_options.sh | 1 - resources/default-settings/jukebox.default.yaml | 12 +----------- 4 files changed, 2 insertions(+), 14 deletions(-) diff --git a/docker/config/docker.mpd.conf b/docker/config/docker.mpd.conf index f42611201..d8a91a780 100644 --- a/docker/config/docker.mpd.conf +++ b/docker/config/docker.mpd.conf @@ -83,7 +83,7 @@ user "root" # activation is in use. # # For network -bind_to_address "0.0.0.0" +bind_to_address "any" # # And for Unix Socket #bind_to_address "/run/mpd/socket" diff --git a/installation/includes/01_default_config.sh b/installation/includes/01_default_config.sh index 02401a630..eb6e45a91 100644 --- a/installation/includes/01_default_config.sh +++ b/installation/includes/01_default_config.sh @@ -11,7 +11,6 @@ DISABLE_SSH_QOS=true DISABLE_BOOT_SCREEN=true DISABLE_BOOT_LOGS_PRINT=true SETUP_MPD=true -UPDATE_RASPI_OS=false UPDATE_RASPI_OS=${UPDATE_RASPI_OS:-"true"} ENABLE_SAMBA=true ENABLE_WEBAPP=true diff --git a/installation/routines/customize_options.sh b/installation/routines/customize_options.sh index 87b102be0..d5ac7c547 100644 --- a/installation/routines/customize_options.sh +++ b/installation/routines/customize_options.sh @@ -269,7 +269,6 @@ Do you want to install Node? [Y/n]" 1>&3 fi } - customize_options() { echo "Customize Options starts" diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index 6330bc3f1..7120a95b4 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -11,8 +11,7 @@ modules: jingle: jingle jingle.alsawave: jingle.alsawave jingle.jinglemp3: jingle.jinglemp3 - player: playern.plugin - # spotify: playerspot + player: player.plugin cards: rfid.cards rfid: rfid.reader timers: timers @@ -92,15 +91,6 @@ playermpd: update_on_startup: true check_user_rights: true mpd_conf: ~/.config/mpd/mpd.conf -playerspot: - host: localhost - status_file: ../../shared/settings/spotify_player_status.json - collection_file: ../../shared/audiofolders/spotify/spotify_collection.yaml - second_swipe_action: - # Note: Does not follow the RPC alias convention (yet) - # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' - alias: toggle - spot_conf: ../../shared/spotify/config.toml" rpc: tcp_port: 5555 websocket_port: 5556 From 86e734ee356f7e6eacdacb70696730ea094023b0 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sat, 18 Nov 2023 16:10:33 +0100 Subject: [PATCH 072/109] correct flake8 --- src/jukebox/components/player/plugin/__init__.py | 1 + src/jukebox/components/volume/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/jukebox/components/player/plugin/__init__.py b/src/jukebox/components/player/plugin/__init__.py index ed5c3c7d6..ca0e24781 100644 --- a/src/jukebox/components/player/plugin/__init__.py +++ b/src/jukebox/components/player/plugin/__init__.py @@ -28,6 +28,7 @@ # The various backends backend_mpd: Optional[MPDBackend] = None + def start_event_loop(loop: asyncio.AbstractEventLoop): # https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.shutdown_asyncgens logger.debug("Start player AsyncIO Background Event Loop") diff --git a/src/jukebox/components/volume/__init__.py b/src/jukebox/components/volume/__init__.py index b4fd8a35a..ae7e4229e 100644 --- a/src/jukebox/components/volume/__init__.py +++ b/src/jukebox/components/volume/__init__.py @@ -62,7 +62,6 @@ import logging import threading import time -import traceback import pulsectl import jukebox.cfghandler @@ -649,6 +648,7 @@ def finalize(): else: pulse_control.publish_volume() + @plugin.atexit def atexit(**ignored_kwargs): global pulse_monitor From 36bfd79922b4f9413b58cdc089f22c58fe749bf3 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sat, 18 Nov 2023 16:35:24 +0100 Subject: [PATCH 073/109] Fix docker; delete copy-paste-error --- docker/jukebox.Dockerfile | 4 +++- src/webapp/src/components/Library/lists/albums/index.js | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docker/jukebox.Dockerfile b/docker/jukebox.Dockerfile index 5e7a08915..2eb025592 100644 --- a/docker/jukebox.Dockerfile +++ b/docker/jukebox.Dockerfile @@ -16,6 +16,7 @@ ARG USER ARG HOME ENV INSTALLATION_PATH ${HOME}/RPi-Jukebox-RFID +USER root RUN test ${UID} -gt 0 && useradd -m -u ${UID} ${USER} || continue RUN usermod -aG pulse ${USER} @@ -35,7 +36,8 @@ USER ${USER} WORKDIR ${HOME} COPY --chown=${USER}:${USER} . ${INSTALLATION_PATH}/ -RUN pip install --no-cache-dir -r ${INSTALLATION_PATH}/requirements.txt +USER root +RUN pip install --no-cache-dir --upgrade -r ${INSTALLATION_PATH}/requirements.txt RUN pip install pyzmq EXPOSE 5555 5556 diff --git a/src/webapp/src/components/Library/lists/albums/index.js b/src/webapp/src/components/Library/lists/albums/index.js index 3e915a9fa..b8027a154 100644 --- a/src/webapp/src/components/Library/lists/albums/index.js +++ b/src/webapp/src/components/Library/lists/albums/index.js @@ -30,7 +30,7 @@ const Albums = ({ musicFilter }) => { useEffect(() => { const fetchAlbumList = async () => { setIsLoading(true); - const { result, error } = await request('albumList'); + const { result, error } = await request('albumList') setIsLoading(false); if(result) setAlbums(result.reduce(flatByAlbum, [])); @@ -44,7 +44,7 @@ const Albums = ({ musicFilter }) => { <> {isLoading ? - : } From b8fd417a0e4b28771ad4ccfc81d889996ad730a6 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sat, 18 Nov 2023 16:36:11 +0100 Subject: [PATCH 074/109] Syntax error --- src/webapp/src/components/Library/lists/albums/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webapp/src/components/Library/lists/albums/index.js b/src/webapp/src/components/Library/lists/albums/index.js index b8027a154..b8d61b089 100644 --- a/src/webapp/src/components/Library/lists/albums/index.js +++ b/src/webapp/src/components/Library/lists/albums/index.js @@ -30,7 +30,7 @@ const Albums = ({ musicFilter }) => { useEffect(() => { const fetchAlbumList = async () => { setIsLoading(true); - const { result, error } = await request('albumList') + const { result, error } = await request('albumList'); setIsLoading(false); if(result) setAlbums(result.reduce(flatByAlbum, [])); From 9006d6fca72247f56cd268e6e415075aa81d56e0 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sat, 18 Nov 2023 20:21:50 +0100 Subject: [PATCH 075/109] Added inheritance class for players --- .../{audio => audiofolders}/shutdownsound.mp3 | Bin .../{audio => audiofolders}/shutdownsound.wav | Bin .../{audio => audiofolders}/startupsound.mp3 | Bin .../{audio => audiofolders}/startupsound.wav | Bin .../components/player/backends/__init__.py | 36 ++++++++++++++++++ .../player/backends/mpd/interfacing_mpd.py | 3 +- .../components/player/core/__init__.py | 12 ++++++ src/webapp/src/commands/index.js | 7 ++++ .../components/Library/lists/albums/index.js | 2 +- 9 files changed, 58 insertions(+), 2 deletions(-) rename resources/{audio => audiofolders}/shutdownsound.mp3 (100%) rename resources/{audio => audiofolders}/shutdownsound.wav (100%) rename resources/{audio => audiofolders}/startupsound.mp3 (100%) rename resources/{audio => audiofolders}/startupsound.wav (100%) create mode 100644 src/jukebox/components/player/backends/__init__.py diff --git a/resources/audio/shutdownsound.mp3 b/resources/audiofolders/shutdownsound.mp3 similarity index 100% rename from resources/audio/shutdownsound.mp3 rename to resources/audiofolders/shutdownsound.mp3 diff --git a/resources/audio/shutdownsound.wav b/resources/audiofolders/shutdownsound.wav similarity index 100% rename from resources/audio/shutdownsound.wav rename to resources/audiofolders/shutdownsound.wav diff --git a/resources/audio/startupsound.mp3 b/resources/audiofolders/startupsound.mp3 similarity index 100% rename from resources/audio/startupsound.mp3 rename to resources/audiofolders/startupsound.mp3 diff --git a/resources/audio/startupsound.wav b/resources/audiofolders/startupsound.wav similarity index 100% rename from resources/audio/startupsound.wav rename to resources/audiofolders/startupsound.wav diff --git a/src/jukebox/components/player/backends/__init__.py b/src/jukebox/components/player/backends/__init__.py new file mode 100644 index 000000000..64607751d --- /dev/null +++ b/src/jukebox/components/player/backends/__init__.py @@ -0,0 +1,36 @@ +class BackendPlayer: + """ + Class to inherit, so that you can build a proper new Player + """ + def __init__(self): + raise NotImplementedError + + def next(self): + raise NotImplementedError + + def prev(self): + raise NotImplementedError + + def play(self, idx=None): + raise NotImplementedError + + def toggle(self): + raise NotImplementedError + + def pause(self): + raise NotImplementedError + + def stop(self): + raise NotImplementedError + + def get_queue(self): + raise NotImplementedError + + def play_uri(self, uri): + raise NotImplementedError + + def repeatmode(self): + raise NotImplementedError + + def seek(self): + raise NotImplementedError diff --git a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py index 9ffd31445..9e5f8b0d8 100644 --- a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py +++ b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py @@ -10,6 +10,7 @@ import jukebox.cfghandler from mpd.asyncio import MPDClient +from components.player.backends import BackendPlayer logger = logging.getLogger('jb.mpd') cfg = jukebox.cfghandler.get_handler('jukebox') @@ -19,7 +20,7 @@ def sanitize(path: str): return os.path.normpath(path).lstrip('./') -class MPDBackend: +class MPDBackend(BackendPlayer): def __init__(self, event_loop): self.client = MPDClient() diff --git a/src/jukebox/components/player/core/__init__.py b/src/jukebox/components/player/core/__init__.py index 5dfc0192a..b474ec009 100644 --- a/src/jukebox/components/player/core/__init__.py +++ b/src/jukebox/components/player/core/__init__.py @@ -108,6 +108,10 @@ def prev(self): def play(self): self._active.play() + @plugin.tag + def play_single(self, uri): + self.play_uri(uri) + @plugin.tag def toggle(self): self._active.toggle() @@ -126,6 +130,14 @@ def stop(self): def get_queue(self): self._active.get_queue() + @plugin.tag + def repeatmode(self): + self._active.repeatmode() + + @plugin.tag + def seek(self): + self._active.seek() + def _save_state(self): # Get the backend to save the state of the current playlist to the URI's config file self._active.save_state() diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index bd8fd782e..f8ab46bec 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -4,27 +4,32 @@ const commands = { plugin: 'ctrl', method: 'get_by_filename_as_base64', }, + // ToDo: Implement directoryTreeOfAudiofolder: { _package: 'player', plugin: 'ctrl', method: 'list_all_dirs', }, + // ToDo: Implement new player albumList: { _package: 'player', plugin: 'ctrl', method: 'list_albums', }, + // ToDo: Implement songList: { _package: 'player', plugin: 'ctrl', method: 'list_song_by_artist_and_album', }, + // ToDo: Implement getSongByUrl: { _package: 'player', plugin: 'ctrl', method: 'get_song_by_url', argKeys: ['song_url'] }, + // ToDo: Implement folderList: { _package: 'player', plugin: 'ctrl', @@ -60,12 +65,14 @@ const commands = { method: 'play_single', argKeys: ['song_url'] }, + // ToDo: verify if this is really needed? play_folder: { _package: 'player', plugin: 'ctrl', method: 'play_folder', argKeys: ['folder'] }, + // ToDo: Implement play_album: { _package: 'player', plugin: 'ctrl', diff --git a/src/webapp/src/components/Library/lists/albums/index.js b/src/webapp/src/components/Library/lists/albums/index.js index b8d61b089..3e915a9fa 100644 --- a/src/webapp/src/components/Library/lists/albums/index.js +++ b/src/webapp/src/components/Library/lists/albums/index.js @@ -44,7 +44,7 @@ const Albums = ({ musicFilter }) => { <> {isLoading ? - : } From 5784f8de90ad1a36a3863c0ae639c9a7c19bb470 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sat, 18 Nov 2023 20:59:00 +0100 Subject: [PATCH 076/109] Added missing functions for webapp --- .../components/player/backends/__init__.py | 17 +++++++++++++++++ src/jukebox/components/player/core/__init__.py | 17 +++++++++++++++++ src/webapp/src/commands/index.js | 4 +--- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/jukebox/components/player/backends/__init__.py b/src/jukebox/components/player/backends/__init__.py index 64607751d..fd6fd560a 100644 --- a/src/jukebox/components/player/backends/__init__.py +++ b/src/jukebox/components/player/backends/__init__.py @@ -1,3 +1,6 @@ +import jukebox.plugs as plugin + + class BackendPlayer: """ Class to inherit, so that you can build a proper new Player @@ -5,32 +8,46 @@ class BackendPlayer: def __init__(self): raise NotImplementedError + @plugin.tag def next(self): raise NotImplementedError + @plugin.tag def prev(self): raise NotImplementedError + @plugin.tag def play(self, idx=None): raise NotImplementedError + @plugin.tag def toggle(self): raise NotImplementedError + @plugin.tag def pause(self): raise NotImplementedError + @plugin.tag def stop(self): raise NotImplementedError + @plugin.tag def get_queue(self): raise NotImplementedError + @plugin.tag def play_uri(self, uri): raise NotImplementedError + @plugin.tag def repeatmode(self): raise NotImplementedError + @plugin.tag def seek(self): raise NotImplementedError + + @plugin.tag + def get_albums(self): + raise NotImplementedError diff --git a/src/jukebox/components/player/core/__init__.py b/src/jukebox/components/player/core/__init__.py index b474ec009..b245e7bcc 100644 --- a/src/jukebox/components/player/core/__init__.py +++ b/src/jukebox/components/player/core/__init__.py @@ -138,6 +138,23 @@ def repeatmode(self): def seek(self): self._active.seek() + @plugin.tag + def list_albums(self): + """ + Coolects from every backend the albums and albumartists + """ + album_list = [] + for name, bkend in self._backends.items(): + album_list.append(bkend.get_albums()) + + return album_list + + @plugin.tag + def list_song_by_artist_and_album(self, artist, albumname): + for name, bkend in self._backends.items(): + s_item = filter(lambda album: album['artist'] == artist and album['albumname'] == albumname, bkend.get_albums()) + return s_item if s_item else None + def _save_state(self): # Get the backend to save the state of the current playlist to the URI's config file self._active.save_state() diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index f8ab46bec..46b0939f1 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -4,19 +4,17 @@ const commands = { plugin: 'ctrl', method: 'get_by_filename_as_base64', }, - // ToDo: Implement + // ToDo: Do we need that? directoryTreeOfAudiofolder: { _package: 'player', plugin: 'ctrl', method: 'list_all_dirs', }, - // ToDo: Implement new player albumList: { _package: 'player', plugin: 'ctrl', method: 'list_albums', }, - // ToDo: Implement songList: { _package: 'player', plugin: 'ctrl', From 3b4f448adb73af35ca93947ed54ab085a623724c Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 18 Dec 2023 09:26:32 +0100 Subject: [PATCH 077/109] first attempt for get_folder_content --- .../components/player/core/player_content.py | 17 ++++++++++++++++- src/jukebox/jukebox/playlistgenerator.py | 19 +++++++++++-------- src/webapp/src/commands/index.js | 2 +- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/jukebox/components/player/core/player_content.py b/src/jukebox/components/player/core/player_content.py index 601d13e1d..93b3372b8 100644 --- a/src/jukebox/components/player/core/player_content.py +++ b/src/jukebox/components/player/core/player_content.py @@ -4,6 +4,7 @@ import jukebox.plugs as plugin import jukebox.cfghandler +from jukebox import playlistgenerator logger = logging.getLogger('jb.player_content') cfg = jukebox.cfghandler.get_handler('jukebox') @@ -13,6 +14,7 @@ class PlayerData: def __init__(self): self.audiofile = cfg.setndefault('players', 'content', 'audiofile', value='../../shared/audiofolders/audiofiles.yaml') + self.audiofile_basedir = cfg.setndefault('players', 'content', 'audiofile_basedir', value='../../shared/audiofolders') self._database = {'file': [{}], 'podcasts': [{}], 'livestreams': [{}]} @@ -32,7 +34,7 @@ def read_player_content(self, content_type): return self._database.get(content_type, "empty") @plugin.tag - def get_location(self, titlename): + def get_uri(self, titlename): for key, value in self._database.items(): for elem in value: return f"mpd:{key}:{elem['location']}" if elem['name'] == titlename else None @@ -40,3 +42,16 @@ def get_location(self, titlename): @plugin.tag def list_content(self): return self._database + + @plugin.tag + def get_folder_content(self, folder: str): + """ + Get the folder content as content list with meta-information. Depth is always 1. + + Call repeatedly to descend in hierarchy + + :param folder: Folder path relative to music library path + """ + plc = playlistgenerator.PlaylistCollector(self.audiofile_basedir) + plc.get_directory_content(folder) + return plc.playlist diff --git a/src/jukebox/jukebox/playlistgenerator.py b/src/jukebox/jukebox/playlistgenerator.py index db64d3eff..e0e463d70 100755 --- a/src/jukebox/jukebox/playlistgenerator.py +++ b/src/jukebox/jukebox/playlistgenerator.py @@ -55,6 +55,8 @@ from typing import (List) +from components.player.core import player_content + logger = logging.getLogger('jb.plgen') # From .xml podcasts, need to parse out these strings: @@ -71,23 +73,23 @@ class PlaylistEntry: - def __init__(self, filetype: int, name: str, path: str): + def __init__(self, filetype: int, name: str, uri: str = None): self._type = filetype self._name = name - self._path = path + self._uri = uri @property def name(self): return self._name - @property - def path(self): - return self._path - @property def filetype(self): return self._type + @property + def uri(self): + return self._uri + def decode_podcast_core(url, playlist): # Example url: @@ -212,7 +214,7 @@ def _is_valid(cls, direntry: os.DirEntry) -> bool: Check if filename is valid """ return direntry.is_file() and not direntry.name.startswith('.') \ - and PlaylistCollector._exclude_re.match(direntry.name) is None and direntry.name.find('.') >= 0 + and PlaylistCollector._exclude_re.match(direntry.name) is None and direntry.name.find('.') >= 0 @classmethod def set_exclusion_endings(cls, endings: List[str]): @@ -276,8 +278,9 @@ def get_directory_content(self, path='.'): except FileNotFoundError as e: logger.error(f" {e.__class__.__name__}: {e}") else: + logger.debug(f"Playlist Content: {content}") for m in content: - self.playlist.append({'type': TYPE_DECODE[m.filetype], 'name': m.name, 'path': m.path}) + self.playlist.append({'type': TYPE_DECODE[m.filetype], 'name': m.name, 'path': m.path, 'uri': m.uri}) def _parse_nonrecusive(self, path='.'): return [x.path for x in self._get_directory_content(path) if x.filetype != TYPE_DIR] diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index 46b0939f1..34e4e5a24 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -30,7 +30,7 @@ const commands = { // ToDo: Implement folderList: { _package: 'player', - plugin: 'ctrl', + plugin: 'content', method: 'get_folder_content', }, cardsList: { From e85b2944008e738157fa753579bb406f1d42f16f Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 18 Dec 2023 19:03:25 +0100 Subject: [PATCH 078/109] Bring back spotify backend --- docker/config/docker.spotify.conf | 13 ++ requirements.txt | 5 + .../default-settings/jukebox.default.yaml | 9 ++ .../default-settings/spotify.config.toml | 84 ++++++++++++ .../player/backends/spotify/http_client.py | 97 ++++++++++++++ .../backends/spotify/interfacing_spotify.py | 121 +++++++++++++++++ .../player/backends/spotify/ws_client.py | 123 ++++++++++++++++++ .../components/player/plugin/__init__.py | 18 ++- 8 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 docker/config/docker.spotify.conf create mode 100644 resources/default-settings/spotify.config.toml create mode 100644 src/jukebox/components/player/backends/spotify/http_client.py create mode 100644 src/jukebox/components/player/backends/spotify/interfacing_spotify.py create mode 100644 src/jukebox/components/player/backends/spotify/ws_client.py diff --git a/docker/config/docker.spotify.conf b/docker/config/docker.spotify.conf new file mode 100644 index 000000000..cfbcedef6 --- /dev/null +++ b/docker/config/docker.spotify.conf @@ -0,0 +1,13 @@ +[global] +username = "Groovylein" +password = "G!sm0Ges4" +backend = "alsa" +device = "pulse" # Given by `aplay -L` +mixer = "PCM" +volume-control = "alsa" # or alsa_linear, or softvol +#onevent = command_run_on_playback_event +device_name = "phoniebox" # Cannot contain spaces +bitrate = 160 +cache_path = "cache_directory" +volume-normalisation = true +normalisation-pregain = -10 diff --git a/requirements.txt b/requirements.txt index a04c8c80b..c157c224f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,3 +35,8 @@ flake8>=4.0.0 pytest pytest-cov mock + +# For Spotify +spotipy +urllib3 +websocket diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index 5d0a7a62d..aca6c78d7 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -91,6 +91,15 @@ playermpd: update_on_startup: true check_user_rights: true mpd_conf: ~/.config/mpd/mpd.conf +playerspot: + host: localhost + status_file: ../../shared/settings/spotify_player_status.json + collection_file: ../../shared/audio/spotify/spotify_collection.yaml + second_swipe_action: + # Note: Does not follow the RPC alias convention (yet) + # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' + alias: toggle + spot_conf: ../../shared/spotify/config.toml" rpc: tcp_port: 5555 websocket_port: 5556 diff --git a/resources/default-settings/spotify.config.toml b/resources/default-settings/spotify.config.toml new file mode 100644 index 000000000..daf154c35 --- /dev/null +++ b/resources/default-settings/spotify.config.toml @@ -0,0 +1,84 @@ +deviceId = "" ### Device ID (40 chars, leave empty for random) ### +deviceName = "Phoniebox" ### Device name ### +deviceType = "SPEAKER" ### Device type (COMPUTER, TABLET, SMARTPHONE, SPEAKER, TV, AVR, STB, AUDIO_DONGLE, GAME_CONSOLE, CAST_VIDEO, CAST_AUDIO, AUTOMOBILE, WEARABLE, UNKNOWN_SPOTIFY, CAR_THING, UNKNOWN) ### +preferredLocale = "de" ### Preferred locale ### +logLevel = "TRACE" ### Log level (OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE, ALL) ### + +[auth] ### Authentication ### +strategy = "USER_PASS" # Strategy (USER_PASS, ZEROCONF, BLOB, FACEBOOK, STORED) +username = "HERE_USERNAME" # Spotify username (BLOB, USER_PASS only) +password = "HERE_PASSWORD" # Spotify password (USER_PASS only) +blob = "" # Spotify authentication blob Base64-encoded (BLOB only) +storeCredentials = false # Whether to store reusable credentials on disk (not a plain password) +credentialsFile = "credentials.json" # Credentials file (JSON) + +[zeroconf] ### Zeroconf ### +listenPort = 12345 # Listen on this TCP port (`-1` for random) +listenAll = true # Listen on all interfaces (overrides `zeroconf.interfaces`) +interfaces = "" # Listen on these interfaces (comma separated list of names) + +[cache] ### Cache ### +enabled = false # Cache enabled +dir = "./cache/" +doCleanUp = true + +[network] ### Network ### +connectionTimeout = 10 # If ping isn't received within this amount of seconds, reconnect + +[preload] ### Preload ### +enabled = true # Preload enabled + +[time] ### Time correction ### +synchronizationMethod = "NTP" # Time synchronization method (NTP, PING, MELODY, MANUAL) +manualCorrection = 0 # Manual time correction in millis + +[player] ### Player ### +autoplayEnabled = false # Autoplay similar songs when your music ends +preferredAudioQuality = "NORMAL" # Preferred audio quality (NORMAL, HIGH, VERY_HIGH) +enableNormalisation = true # Whether to apply the Spotify loudness normalisation +normalisationPregain = +3.0 # Normalisation pregain in decibels (loud at +6, normal at +3, quiet at -5) +initialVolume = 65536 # Initial volume (0-65536) +volumeSteps = 64 # Number of volume notches +logAvailableMixers = true # Log available mixers +mixerSearchKeywords = "" # Mixer/backend search keywords (semicolon separated) +crossfadeDuration = 0 # Crossfade overlap time (in milliseconds) +output = "MIXER" # Audio output device (MIXER, PIPE, STDOUT, CUSTOM) +outputClass = "" # Audio output Java class name +releaseLineDelay = 20 # Release mixer line after set delay (in seconds) +pipe = "" # Output raw (signed) PCM to this file (`player.output` must be PIPE) +retryOnChunkError = true # Whether the player should retry fetching a chuck if it fails +metadataPipe = "" # Output metadata in Shairport Sync format (https://github.com/mikebrady/shairport-sync-metadata-reader) +bypassSinkVolume = false # Whether librespot-java should ignore volume events, sink volume is set to the max +localFilesPath = "" # Where librespot-java should search for local files + +[api] ### API ### +port = 24879 # API port (`api` module only) +host = "0.0.0.0" # API listen interface (`api` module only) + +[proxy] ### Proxy ### +enabled = false # Whether the proxy is enabled +type = "HTTP" # The proxy type (HTTP, SOCKS) +ssl = false # Connect to proxy using SSL (HTTP only) +address = "" # The proxy hostname +port = 0 # The proxy port +auth = false # Whether authentication is enabled on the server +username = "" # Basic auth username +password = "" # Basic auth password + +[shell] ### Shell ### +enabled = false # Shell events enabled +executeWithBash = false # Execute the command with `bash -c` +onContextChanged = "" +onTrackChanged = "" +onPlaybackEnded = "" +onPlaybackPaused = "" +onPlaybackResumed = "" +onTrackSeeked = "" +onMetadataAvailable = "" +onVolumeChanged = "" +onInactiveSession = "" +onPanicState = "" +onConnectionDropped = "" +onConnectionEstablished = "" +onStartedLoading = "" +onFinishedLoading = "" diff --git a/src/jukebox/components/player/backends/spotify/http_client.py b/src/jukebox/components/player/backends/spotify/http_client.py new file mode 100644 index 000000000..1a8ab179a --- /dev/null +++ b/src/jukebox/components/player/backends/spotify/http_client.py @@ -0,0 +1,97 @@ +import json +import logging +import requests +from requests.adapters import HTTPAdapter +import urllib +from urllib3.util.retry import Retry + +logger = logging.getLogger('jb.spotify.SpotifyHttpClient') + + +class SpotifyHttpClient: + def __init__(self, host: str, port=24879): + self.protocol = 'http' + self.host = host + self.port = port + self.authority = f'{self.protocol}://{self.host}:{self.port}' + + self.session = requests.Session() + retries = Retry( + total=5, + backoff_factor=5, + status_forcelist=[500, 502, 503, 504] + ) + + self.session.mount( + self.protocol + '://', + HTTPAdapter(max_retries=retries) + ) + self.session.headers.update({'content-type': 'application/json'}) + logger.debug(f'Spotify HTTP Client initialized. Will connect to {self.authority}') + + def close(self): + logger.debug("Exiting Spotify HTTP session") + self._post_request('/instance/close') + + def _request(self, request_func, path: str): + try: + url = urllib.parse.urljoin(self.authority, path) + logger.debug(f'Requesting "{self.authority}"') + + response = request_func(url) + response.raise_for_status() + + except requests.HTTPError as http_error: + response = {} + logger.error(f'HTTPError: {http_error}') + + except Exception as error: + response = {} + logger.error(f'Error {error}') + + if response.content: + logger.debug(f"Request response.content: {response.content}") + return json.loads(response.content) + else: + logger.debug("Request response.content empty") + return {} + + # no JSON returned + + def _get_request(self, path: str): + response = self._request(self.session.get, path) + return response + + def _post_request(self, path: str): + response = self._request(self.session.post, path) + return response + + def get_status(self): + # json = self._get_request('/web-api/v1//me/player') + response_json = self._post_request('/player/current') + logger.debug(response_json) + return response_json + + def play_uri(self, uri: str, play: bool = True, shuffle: bool = False): + return self._post_request(f'/player/load?uri={uri}&play={play}&shuffle={shuffle}') + + def play(self): + return self._post_request('/player/resume') + + def pause(self): + return self._post_request('/player/pause') + + def prev(self): + return self._post_request('/player/prev') + + def next(self): + return self._post_request('/player/next') + + def seek(self, new_time: int): + return self._post_request(f'/player/seek?pos={new_time}') + + def shuffle(self, val: bool): + return self._post_request(f'/player/shuffle?val={val}') + + def repeat(self, val: str): + return self._post_request(f'/player/repeat?val={val}') diff --git a/src/jukebox/components/player/backends/spotify/interfacing_spotify.py b/src/jukebox/components/player/backends/spotify/interfacing_spotify.py new file mode 100644 index 000000000..ea23875f5 --- /dev/null +++ b/src/jukebox/components/player/backends/spotify/interfacing_spotify.py @@ -0,0 +1,121 @@ +# Copyright: 2022 +# SPDX License Identifier: MIT License + +import logging +import os.path +import os + +from ruamel import yaml +from spotipy import CacheFileHandler + +import jukebox.plugs as plugin +import jukebox.cfghandler + +from spotipy.oauth2 import SpotifyOAuth +import spotipy + +logger = logging.getLogger('jb.spotify') +cfg = jukebox.cfghandler.get_handler('jukebox') + + +def sanitize(path: str): + return os.path.normpath(path).lstrip('./') + + +class SPOTBackend: + def __init__(self, player_status): + host = cfg.getn('playerspot', 'host') + self.player_status = player_status + self.cache_handler = CacheFileHandler(cache_path='../../shared/spotify/') + self.cache_file = '../../shared/spotify/.spotipyoauthcache' + self.client_id = cfg.setndefault('playerspot', 'client_id', value='Phoniebox') + self.client_secret = cfg.setndefault('playerspot', 'client_secret', value='Phoniebox_secret') + self.redirect_uri = cfg.setndefault('playerspot', 'callback_url', + value='http://localhost:3001') + + spot_scope = "user-read-playback-state,user-modify-playback-state" + self.auth_manager = SpotifyOAuth(open_browser=False, scope=spot_scope, client_id=self.client_id, client_secret=self.client_id, redirect_uri=self.redirect_uri, cache_path=sanitize(self.cache_file)) + self.auth_uri = self.auth_manager.get_authorize_url() + logger.info(f"Please log in here: {self.auth_uri}") + + #self.collection_file_location = cfg.setndefault('playerspot', 'collection_file', + # value="../../shared/audio/spotify/spotify_collection.yaml") + #self.spotify_collection_data = self._read_data_file() + + @plugin.tag + def init_spotclient(self, spot_code=None): + token_info = self.auth_manager.get_cached_token() + logger.debug(f"Token Info: {token_info}") + + if token_info: + logger.debug("Found cached token for Spotify Client!") + access_token = token_info['access_token'] + else: + # ToDo: implement this within the web app + token_info = self.auth_manager.get_access_token(spot_code) + access_token = token_info['access_token'] + + if access_token: + self.spot_client = spotipy.Spotify(access_token) + self.auth_code = cfg.setndefault('playerspot', 'auth_code', value=access_token) + + def _read_data_file(self) -> dict: + try: + with open(self.collection_file_location, "r") as collection_file: + return yaml.safe_load(collection_file.read()) + except Exception as err: + logger.error(f"Could not open spotify collection file {self.collection_file_location}") + logger.debug(f"Error: {err}") + logger.debug("Continuing with empty dictionary") + return {} + + def play(self): + return self.spot_client.start_playback() + + def pause(self): + return self.spot_client.pause_playback() + + def stop(self): + return self.spot_client.pause_playback() + + def prev(self): + return self.spot_client.previous_track() + + def next(self): + return self.spot_client.next_track() + + def toggle(self): + pass + + def get_queue(self): + pass + + @plugin.tag + def play_uri(self, uri: str, **kwargs): + """Decode URI and forward play call + + spotify:playlist:0 + --> search in the yaml-file for the type "playlist" and play the first uri + """ + player_type, index = uri.split(':', 1) + if player_type != 'spotify': + raise KeyError(f"URI prefix must be 'spotify' not '{player_type}") + + return self.spot_client.start_playback(context_uri=uri) + + @plugin.tag + def get_status(self): + return self.spot_client.current_user() + + # ----------------------------------------------------- + # Queue / URI state (save + restore e.g. random, resume, ...) + + def save_state(self): + """Save the configuration and state of the current URI playback to the URIs state file""" + pass + + def _restore_state(self): + """ + Restore the configuration state and last played status for current active URI + """ + pass diff --git a/src/jukebox/components/player/backends/spotify/ws_client.py b/src/jukebox/components/player/backends/spotify/ws_client.py new file mode 100644 index 000000000..9f46efc9a --- /dev/null +++ b/src/jukebox/components/player/backends/spotify/ws_client.py @@ -0,0 +1,123 @@ +import json +import logging +import time + +import websocket +import threading + +logger = logging.getLogger("jb.spotify.SpotifyWsClient") + + +class SpotifyWsClient: + def __init__(self, host: str, player_status, port: int = 24879): + self.protocol = 'ws' + self.host = host + self.port = port + self.url = f'{self.protocol}://{self.host}:{self.port}/events' + + self.player_status = player_status + + self.socket = None + self.thread = None + + self.state_callbacks = { + 'playbackPaused': self.playback_paused, + 'playbackResumed': self.playback_resumed, + 'playbackHaltStateChanged': self.playback_halted, + 'trackChanged': self.track_changed, + 'trackSeeked': self.track_seeked, + 'metadataAvailable': self.metadata_available, + 'inactiveSession': self.inactive_session, + 'contextChanged': self.context_changed, + } + + logger.debug('Spotify WS Client initialized') + + def connect(self): + websocket.enableTrace(True) + self.socket = websocket.WebSocketApp( + self.url, + on_close=self._on_close, + on_error=self._on_error, + on_message=self._on_message + ) + self.thread = threading.Thread(target=self.socket.run_forever) + self.thread.daemon = True + self.thread.start() + + logger.debug(f'Websocket connection established to {self.url}') + + def close(self): + self.socket.close() + + def _on_message(self, socket, message): + logger.debug(f'_on_message: {message}') + data = json.loads(message) + event = data['event'] + + callback = self.state_callbacks.get(event) + if not callback: + raise ValueError(event) + + callback(data) + + def _on_close(self, socket, close_status_code, close_message): + logger.debug(f'Connection with websocket server closed with {close_status_code}:{close_message}') + time.sleep(15) + logger.debug("Retrying to connect") + self.connect() + + def _on_error(self, socket, error): + logger.error(f'Websocket error: {error}') + + # We only care about seconds, not ms as provided by Spotify + def _round_time_to_seconds(self, time): + return '{:.1f}'.format(time / 1000) + + def metadata_available(self, data: dict): + cover_art = data['track']['album']['coverGroup']['image'][2]['fileId'].lower() + + self.player_status.update( + player='Spotify', # TODO: Should this be done differently? + trackid=data['track']['gid'], + title=data['track']['name'], + artist=data['track']['artist'][0]['name'], + album=data['track']['album']['name'], + albumartist=data['track']['album']['artist'][0]['name'], + duration=self._round_time_to_seconds(data['track']['duration']), + coverArt=cover_art + ) + + def playback_paused(self, data: dict): + self.player_status.update( + playing=False, + elapsed=self._round_time_to_seconds(data['trackTime']) + ) + + def playback_resumed(self, data: dict): + self.player_status.update( + playing=True, + elapsed=self._round_time_to_seconds(data['trackTime']) + ) + + def playback_halted(self, data: dict): + self.player_status.update( + playing=data['halted'], + elapsed=self._round_time_to_seconds(data['trackTime']) + ) + + def track_changed(self, data: dict): + pass + + def track_seeked(self, data: dict): + self.player_status.update( + elapsed=self._round_time_to_seconds(data['trackTime']) + ) + + def context_changed(self, data: dict): + pass + + # When Spotify session is routed to another device, + # the local session goes inactive + def inactive_session(self, data: dict): + self.player_status.update(playing=False) diff --git a/src/jukebox/components/player/plugin/__init__.py b/src/jukebox/components/player/plugin/__init__.py index ca0e24781..8ee2b754b 100644 --- a/src/jukebox/components/player/plugin/__init__.py +++ b/src/jukebox/components/player/plugin/__init__.py @@ -9,10 +9,13 @@ import jukebox.plugs as plugin import jukebox.cfghandler from components.player.backends.mpd.interfacing_mpd import MPDBackend +from components.player.backends.spotify.interfacing_spotify import SPOTBackend from components.player.core import PlayerCtrl -from components.player.core.player_content import PlayerData from components.player.core.player_status import PlayerStatus +from components.player.core.player_content import PlayerData + + logger = logging.getLogger('jb.player') cfg = jukebox.cfghandler.get_handler('jukebox') @@ -27,6 +30,7 @@ # The various backends backend_mpd: Optional[MPDBackend] = None +backend_spot: Optional[SPOTBackend] = None def start_event_loop(loop: asyncio.AbstractEventLoop): @@ -50,6 +54,17 @@ def register_mpd(): player_arbiter.register('mpd', backend_mpd) +def register_spotify(): + global backend_spot + global player_arbiter + global player_status + + backend_spot = SPOTBackend(player_status) + # Register with plugin interface to call directly + plugin.register(backend_spot, package='player', name='spotify') + player_arbiter.register('spotify', backend_spot) + + @plugin.initialize def initialize(): global event_loop @@ -69,6 +84,7 @@ def initialize(): player_content = PlayerData() # Create and register the players (this is explicit for the moment) + register_spotify() register_mpd() plugin.register(player_arbiter, package='player', name='ctrl') From 7a0d17053fa9facf8924fa8016cfc9f32af931a3 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 18 Dec 2023 19:22:12 +0100 Subject: [PATCH 079/109] remove unnecessary files --- docker/config/docker.spotify.conf | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 docker/config/docker.spotify.conf diff --git a/docker/config/docker.spotify.conf b/docker/config/docker.spotify.conf deleted file mode 100644 index cfbcedef6..000000000 --- a/docker/config/docker.spotify.conf +++ /dev/null @@ -1,13 +0,0 @@ -[global] -username = "Groovylein" -password = "G!sm0Ges4" -backend = "alsa" -device = "pulse" # Given by `aplay -L` -mixer = "PCM" -volume-control = "alsa" # or alsa_linear, or softvol -#onevent = command_run_on_playback_event -device_name = "phoniebox" # Cannot contain spaces -bitrate = 160 -cache_path = "cache_directory" -volume-normalisation = true -normalisation-pregain = -10 From 257d177e54664ed63faa7383fb44c98cc7c0695f Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 18 Dec 2023 19:33:16 +0100 Subject: [PATCH 080/109] fix auth error during pip install in docker --- docker/jukebox.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/jukebox.Dockerfile b/docker/jukebox.Dockerfile index 253c476c7..eccb5dc4e 100644 --- a/docker/jukebox.Dockerfile +++ b/docker/jukebox.Dockerfile @@ -26,11 +26,11 @@ RUN apt-get update && apt-get install -qq -y \ espeak mpc mpg123 git ffmpeg spi-tools netcat \ python3 python3-venv python3-dev python3-mutagen +USER ${USER} ENV VIRTUAL_ENV=${INSTALLATION_PATH}/.venv RUN python3 -m venv $VIRTUAL_ENV ENV PATH="$VIRTUAL_ENV/bin:$PATH" -USER ${USER} WORKDIR ${HOME} COPY --chown=${USER}:${USER} . ${INSTALLATION_PATH}/ From 90cc33b98563803c668632aa899f44fde08531c1 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 18 Dec 2023 20:01:30 +0100 Subject: [PATCH 081/109] Docker bugfixing --- docker/jukebox.Dockerfile | 2 +- resources/{audiofolders => audio}/shutdownsound.mp3 | Bin resources/{audiofolders => audio}/shutdownsound.wav | Bin resources/{audiofolders => audio}/startupsound.mp3 | Bin resources/{audiofolders => audio}/startupsound.wav | Bin .../player/backends/spotify/interfacing_spotify.py | 9 ++++++--- 6 files changed, 7 insertions(+), 4 deletions(-) rename resources/{audiofolders => audio}/shutdownsound.mp3 (100%) rename resources/{audiofolders => audio}/shutdownsound.wav (100%) rename resources/{audiofolders => audio}/startupsound.mp3 (100%) rename resources/{audiofolders => audio}/startupsound.wav (100%) diff --git a/docker/jukebox.Dockerfile b/docker/jukebox.Dockerfile index eccb5dc4e..50a52fc23 100644 --- a/docker/jukebox.Dockerfile +++ b/docker/jukebox.Dockerfile @@ -22,7 +22,7 @@ RUN usermod -aG pulse ${USER} # Install all Jukebox dependencies RUN apt-get update && apt-get install -qq -y \ --allow-downgrades --allow-remove-essential --allow-change-held-packages \ - gcc at wget \ + gcc g++ at wget \ espeak mpc mpg123 git ffmpeg spi-tools netcat \ python3 python3-venv python3-dev python3-mutagen diff --git a/resources/audiofolders/shutdownsound.mp3 b/resources/audio/shutdownsound.mp3 similarity index 100% rename from resources/audiofolders/shutdownsound.mp3 rename to resources/audio/shutdownsound.mp3 diff --git a/resources/audiofolders/shutdownsound.wav b/resources/audio/shutdownsound.wav similarity index 100% rename from resources/audiofolders/shutdownsound.wav rename to resources/audio/shutdownsound.wav diff --git a/resources/audiofolders/startupsound.mp3 b/resources/audio/startupsound.mp3 similarity index 100% rename from resources/audiofolders/startupsound.mp3 rename to resources/audio/startupsound.mp3 diff --git a/resources/audiofolders/startupsound.wav b/resources/audio/startupsound.wav similarity index 100% rename from resources/audiofolders/startupsound.wav rename to resources/audio/startupsound.wav diff --git a/src/jukebox/components/player/backends/spotify/interfacing_spotify.py b/src/jukebox/components/player/backends/spotify/interfacing_spotify.py index ea23875f5..41d6198d6 100644 --- a/src/jukebox/components/player/backends/spotify/interfacing_spotify.py +++ b/src/jukebox/components/player/backends/spotify/interfacing_spotify.py @@ -34,9 +34,12 @@ def __init__(self, player_status): value='http://localhost:3001') spot_scope = "user-read-playback-state,user-modify-playback-state" - self.auth_manager = SpotifyOAuth(open_browser=False, scope=spot_scope, client_id=self.client_id, client_secret=self.client_id, redirect_uri=self.redirect_uri, cache_path=sanitize(self.cache_file)) - self.auth_uri = self.auth_manager.get_authorize_url() - logger.info(f"Please log in here: {self.auth_uri}") + try: + self.auth_manager = SpotifyOAuth(open_browser=False, scope=spot_scope, client_id=self.client_id, client_secret=self.client_id, redirect_uri=self.redirect_uri, cache_path=sanitize(self.cache_file)) + self.auth_uri = self.auth_manager.get_authorize_url() + logger.info(f"Please log in here: {self.auth_uri}") + except Exception as err: + logger.error(err) #self.collection_file_location = cfg.setndefault('playerspot', 'collection_file', # value="../../shared/audio/spotify/spotify_collection.yaml") From d43c6febc15f3ed8ea21f2045a5373dc3df7438d Mon Sep 17 00:00:00 2001 From: Groovylein Date: Thu, 21 Dec 2023 21:45:47 +0100 Subject: [PATCH 082/109] implement Spotipy authentication via bottle --- docker/config/docker.mpd.conf | 2 +- docker/docker-compose.linux.yml | 10 +-- docker/docker-compose.yml | 13 +-- docker/jukebox.Dockerfile | 10 ++- requirements.txt | 1 + .../default-settings/jukebox.default.yaml | 2 +- .../default-settings/spotify.config.toml | 84 ------------------- .../backends/spotify/interfacing_spotify.py | 46 +++++----- .../player/backends/spotify/oauth.py | 41 +++++++++ .../components/player/plugin/__init__.py | 3 +- 10 files changed, 85 insertions(+), 127 deletions(-) delete mode 100644 resources/default-settings/spotify.config.toml create mode 100644 src/jukebox/components/player/backends/spotify/oauth.py diff --git a/docker/config/docker.mpd.conf b/docker/config/docker.mpd.conf index d8a91a780..a34910d84 100644 --- a/docker/config/docker.mpd.conf +++ b/docker/config/docker.mpd.conf @@ -11,7 +11,7 @@ # be disabled and audio files will only be accepted over ipc socket (using # file:// protocol) or streaming files over an accepted protocol. # -music_directory "/home/pi/RPi-Jukebox-RFID/shared/audiofolders" +music_directory "~/RPi-Jukebox-RFID/shared/audiofolders" # # This setting sets the MPD internal playlist directory. The purpose of this # directory is storage for playlists created by MPD. The server will use diff --git a/docker/docker-compose.linux.yml b/docker/docker-compose.linux.yml index 9609dc18d..6c8ca391f 100755 --- a/docker/docker-compose.linux.yml +++ b/docker/docker-compose.linux.yml @@ -10,9 +10,9 @@ services: environment: - PULSE_SERVER=unix:/tmp/pulse-sock volumes: - - ../shared/audiofolders:/home/pi/RPi-Jukebox-RFID/shared/audiofolders - - ../shared/playlists:/home/pi/.config/mpd/playlists - - ./config/docker.pulse.mpd.conf:/home/pi/.config/mpd/mpd.conf + - ../shared/audiofolders:${HOME}/RPi-Jukebox-RFID/shared/audiofolders + - ../shared/playlists:${HOME}/.config/mpd/playlists + - ./config/docker.pulse.mpd.conf:${HOME}/.config/mpd/mpd.conf - $XDG_RUNTIME_DIR/pulse/native:/tmp/pulse-sock jukebox: @@ -24,6 +24,6 @@ services: environment: - PULSE_SERVER=unix:/tmp/pulse-sock volumes: - - ../shared:/home/pi/RPi-Jukebox-RFID/shared - - ./config/docker.pulse.mpd.conf:/home/pi/.config/mpd/mpd.conf + - ../shared:${HOME}/RPi-Jukebox-RFID/shared + - ./config/docker.pulse.mpd.conf:${HOME}/.config/mpd/mpd.conf - $XDG_RUNTIME_DIR/pulse/native:/tmp/pulse-sock diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1679bbeb5..c491ca29a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,9 +15,9 @@ services: - PULSE_SERVER=tcp:host.docker.internal:4713 restart: unless-stopped volumes: - - ../shared/audiofolders:/root/RPi-Jukebox-RFID/shared/audiofolders - - ../shared/playlists:/root/.config/mpd/playlists - - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf + - ../shared/audiofolders:/home/pi/RPi-Jukebox-RFID/shared/audiofolders + - ../shared/playlists:/home/pi/.config/mpd/playlists + - ./config/docker.pulse.mpd.conf://home/pi/.config/mpd/mpd.conf jukebox: build: @@ -37,12 +37,13 @@ services: - 5555:5555 - 5556:5556 - 5557:5557 + - 3001:3001 restart: unless-stopped tty: true volumes: - - ../src/jukebox:/root/RPi-Jukebox-RFID/src/jukebox - - ../shared:/root/RPi-Jukebox-RFID/shared - - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf + - ../src/jukebox:/home/pi/RPi-Jukebox-RFID/src/jukebox + - ../shared:/home/pi/RPi-Jukebox-RFID/shared + - ./config/docker.pulse.mpd.conf:/home/pi/.config/mpd/mpd.conf command: python run_jukebox.py webapp: diff --git a/docker/jukebox.Dockerfile b/docker/jukebox.Dockerfile index 50a52fc23..5ffb5f096 100644 --- a/docker/jukebox.Dockerfile +++ b/docker/jukebox.Dockerfile @@ -4,6 +4,7 @@ FROM debian:bullseye-slim # Raspberry Pi environment as possible. RUN apt-get update && apt-get install -y \ libasound2-dev \ + libzmq3-dev \ pulseaudio \ pulseaudio-utils \ --no-install-recommends \ @@ -14,7 +15,6 @@ ARG USER ARG HOME ENV INSTALLATION_PATH ${HOME}/RPi-Jukebox-RFID -USER root RUN test ${UID} -gt 0 && useradd -m -u ${UID} ${USER} || continue RUN usermod -aG pulse ${USER} @@ -22,16 +22,17 @@ RUN usermod -aG pulse ${USER} # Install all Jukebox dependencies RUN apt-get update && apt-get install -qq -y \ --allow-downgrades --allow-remove-essential --allow-change-held-packages \ - gcc g++ at wget \ + g++ at wget \ espeak mpc mpg123 git ffmpeg spi-tools netcat \ python3 python3-venv python3-dev python3-mutagen USER ${USER} +WORKDIR ${HOME} + ENV VIRTUAL_ENV=${INSTALLATION_PATH}/.venv RUN python3 -m venv $VIRTUAL_ENV ENV PATH="$VIRTUAL_ENV/bin:$PATH" -WORKDIR ${HOME} COPY --chown=${USER}:${USER} . ${INSTALLATION_PATH}/ RUN pip install --no-cache-dir -r ${INSTALLATION_PATH}/requirements.txt @@ -46,7 +47,8 @@ RUN [ "$(uname -m)" = "aarch64" ] && ARCH="arm64" || ARCH="$(uname -m)"; \ rm -f libzmq.tar.gz; RUN export ZMQ_PREFIX=${PREFIX} && export ZMQ_DRAFT_API=1 -RUN pip install -v --no-binary pyzmq --pre pyzmq +#RUN pip install -v --no-binary pyzmq --pre pyzmq +RUN pip install -v --no-binary=:all: pyzmq --pre pyzmq EXPOSE 5555 5556 diff --git a/requirements.txt b/requirements.txt index c157c224f..3cdbcf703 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,3 +40,4 @@ mock spotipy urllib3 websocket +bottle diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index aca6c78d7..03adf6fc8 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -94,7 +94,7 @@ playermpd: playerspot: host: localhost status_file: ../../shared/settings/spotify_player_status.json - collection_file: ../../shared/audio/spotify/spotify_collection.yaml + oauthcache: ../../shared/audio/spotify/.spotipyoauthcache second_swipe_action: # Note: Does not follow the RPC alias convention (yet) # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' diff --git a/resources/default-settings/spotify.config.toml b/resources/default-settings/spotify.config.toml deleted file mode 100644 index daf154c35..000000000 --- a/resources/default-settings/spotify.config.toml +++ /dev/null @@ -1,84 +0,0 @@ -deviceId = "" ### Device ID (40 chars, leave empty for random) ### -deviceName = "Phoniebox" ### Device name ### -deviceType = "SPEAKER" ### Device type (COMPUTER, TABLET, SMARTPHONE, SPEAKER, TV, AVR, STB, AUDIO_DONGLE, GAME_CONSOLE, CAST_VIDEO, CAST_AUDIO, AUTOMOBILE, WEARABLE, UNKNOWN_SPOTIFY, CAR_THING, UNKNOWN) ### -preferredLocale = "de" ### Preferred locale ### -logLevel = "TRACE" ### Log level (OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE, ALL) ### - -[auth] ### Authentication ### -strategy = "USER_PASS" # Strategy (USER_PASS, ZEROCONF, BLOB, FACEBOOK, STORED) -username = "HERE_USERNAME" # Spotify username (BLOB, USER_PASS only) -password = "HERE_PASSWORD" # Spotify password (USER_PASS only) -blob = "" # Spotify authentication blob Base64-encoded (BLOB only) -storeCredentials = false # Whether to store reusable credentials on disk (not a plain password) -credentialsFile = "credentials.json" # Credentials file (JSON) - -[zeroconf] ### Zeroconf ### -listenPort = 12345 # Listen on this TCP port (`-1` for random) -listenAll = true # Listen on all interfaces (overrides `zeroconf.interfaces`) -interfaces = "" # Listen on these interfaces (comma separated list of names) - -[cache] ### Cache ### -enabled = false # Cache enabled -dir = "./cache/" -doCleanUp = true - -[network] ### Network ### -connectionTimeout = 10 # If ping isn't received within this amount of seconds, reconnect - -[preload] ### Preload ### -enabled = true # Preload enabled - -[time] ### Time correction ### -synchronizationMethod = "NTP" # Time synchronization method (NTP, PING, MELODY, MANUAL) -manualCorrection = 0 # Manual time correction in millis - -[player] ### Player ### -autoplayEnabled = false # Autoplay similar songs when your music ends -preferredAudioQuality = "NORMAL" # Preferred audio quality (NORMAL, HIGH, VERY_HIGH) -enableNormalisation = true # Whether to apply the Spotify loudness normalisation -normalisationPregain = +3.0 # Normalisation pregain in decibels (loud at +6, normal at +3, quiet at -5) -initialVolume = 65536 # Initial volume (0-65536) -volumeSteps = 64 # Number of volume notches -logAvailableMixers = true # Log available mixers -mixerSearchKeywords = "" # Mixer/backend search keywords (semicolon separated) -crossfadeDuration = 0 # Crossfade overlap time (in milliseconds) -output = "MIXER" # Audio output device (MIXER, PIPE, STDOUT, CUSTOM) -outputClass = "" # Audio output Java class name -releaseLineDelay = 20 # Release mixer line after set delay (in seconds) -pipe = "" # Output raw (signed) PCM to this file (`player.output` must be PIPE) -retryOnChunkError = true # Whether the player should retry fetching a chuck if it fails -metadataPipe = "" # Output metadata in Shairport Sync format (https://github.com/mikebrady/shairport-sync-metadata-reader) -bypassSinkVolume = false # Whether librespot-java should ignore volume events, sink volume is set to the max -localFilesPath = "" # Where librespot-java should search for local files - -[api] ### API ### -port = 24879 # API port (`api` module only) -host = "0.0.0.0" # API listen interface (`api` module only) - -[proxy] ### Proxy ### -enabled = false # Whether the proxy is enabled -type = "HTTP" # The proxy type (HTTP, SOCKS) -ssl = false # Connect to proxy using SSL (HTTP only) -address = "" # The proxy hostname -port = 0 # The proxy port -auth = false # Whether authentication is enabled on the server -username = "" # Basic auth username -password = "" # Basic auth password - -[shell] ### Shell ### -enabled = false # Shell events enabled -executeWithBash = false # Execute the command with `bash -c` -onContextChanged = "" -onTrackChanged = "" -onPlaybackEnded = "" -onPlaybackPaused = "" -onPlaybackResumed = "" -onTrackSeeked = "" -onMetadataAvailable = "" -onVolumeChanged = "" -onInactiveSession = "" -onPanicState = "" -onConnectionDropped = "" -onConnectionEstablished = "" -onStartedLoading = "" -onFinishedLoading = "" diff --git a/src/jukebox/components/player/backends/spotify/interfacing_spotify.py b/src/jukebox/components/player/backends/spotify/interfacing_spotify.py index 41d6198d6..ae6a65ed6 100644 --- a/src/jukebox/components/player/backends/spotify/interfacing_spotify.py +++ b/src/jukebox/components/player/backends/spotify/interfacing_spotify.py @@ -1,12 +1,11 @@ # Copyright: 2022 # SPDX License Identifier: MIT License - import logging import os.path import os +import threading from ruamel import yaml -from spotipy import CacheFileHandler import jukebox.plugs as plugin import jukebox.cfghandler @@ -14,6 +13,8 @@ from spotipy.oauth2 import SpotifyOAuth import spotipy +from components.player.backends.spotify.oauth import create_oauth_website + logger = logging.getLogger('jb.spotify') cfg = jukebox.cfghandler.get_handler('jukebox') @@ -23,44 +24,39 @@ def sanitize(path: str): class SPOTBackend: - def __init__(self, player_status): - host = cfg.getn('playerspot', 'host') + def __init__(self, player_status, event_loop): + self.loop = event_loop self.player_status = player_status - self.cache_handler = CacheFileHandler(cache_path='../../shared/spotify/') - self.cache_file = '../../shared/spotify/.spotipyoauthcache' + self.cache_file = cfg.setndefault('playerspot', 'oauthcache', value='../../shared/spotify/.spotipyoauthcache') self.client_id = cfg.setndefault('playerspot', 'client_id', value='Phoniebox') self.client_secret = cfg.setndefault('playerspot', 'client_secret', value='Phoniebox_secret') self.redirect_uri = cfg.setndefault('playerspot', 'callback_url', value='http://localhost:3001') - spot_scope = "user-read-playback-state,user-modify-playback-state" - try: - self.auth_manager = SpotifyOAuth(open_browser=False, scope=spot_scope, client_id=self.client_id, client_secret=self.client_id, redirect_uri=self.redirect_uri, cache_path=sanitize(self.cache_file)) - self.auth_uri = self.auth_manager.get_authorize_url() - logger.info(f"Please log in here: {self.auth_uri}") - except Exception as err: - logger.error(err) - - #self.collection_file_location = cfg.setndefault('playerspot', 'collection_file', - # value="../../shared/audio/spotify/spotify_collection.yaml") - #self.spotify_collection_data = self._read_data_file() + spot_scope = "user-read-playback-state,user-modify-playback-state,streaming" + self.auth_manager = SpotifyOAuth(open_browser=False, scope=spot_scope, client_id=self.client_id, client_secret=self.client_secret, redirect_uri=self.redirect_uri, cache_path=os.path.abspath(self.cache_file)) + self.access_token = "" + self.spot_client = None @plugin.tag def init_spotclient(self, spot_code=None): token_info = self.auth_manager.get_cached_token() + token_info = self.auth_manager.validate_token(token_info) logger.debug(f"Token Info: {token_info}") if token_info: logger.debug("Found cached token for Spotify Client!") - access_token = token_info['access_token'] + self.access_token = token_info['access_token'] else: - # ToDo: implement this within the web app - token_info = self.auth_manager.get_access_token(spot_code) - access_token = token_info['access_token'] - - if access_token: - self.spot_client = spotipy.Spotify(access_token) - self.auth_code = cfg.setndefault('playerspot', 'auth_code', value=access_token) + spotify_oauth_website = create_oauth_website(self.auth_manager) + self.thread = threading.Thread(target=spotify_oauth_website.run, kwargs={'host': '', 'port': 3001, 'debug': True}) + self.thread.daemon = True + self.thread.start() + + if self.access_token: + logger.debug("Creating Spotify Client") + self.spot_client = spotipy.Spotify(self.access_token) + return self.spot_client.devices() def _read_data_file(self) -> dict: try: diff --git a/src/jukebox/components/player/backends/spotify/oauth.py b/src/jukebox/components/player/backends/spotify/oauth.py new file mode 100644 index 000000000..76be55fee --- /dev/null +++ b/src/jukebox/components/player/backends/spotify/oauth.py @@ -0,0 +1,41 @@ +import bottle + + +def create_oauth_website(auth_manager): + app = bottle.Bottle() + + @app.route('/') + def index(): + access_token = "" + + token_info = auth_manager.validate_token(auth_manager.cache_handler.get_cached_token()) + + if token_info: + print("Found cached token!") + access_token = token_info['access_token'] + else: + url = bottle.request.url + code = auth_manager.parse_response_code(url) + if code != url: + print("Found Spotify auth code in Request URL! Trying to get valid access token...") + print(f"code: {code}") + token_info = auth_manager.get_access_token(code) + access_token = token_info['access_token'] + + if access_token: + print("Access token available! Trying to get user information...") + return access_token + + else: + return get_login_button() + + def get_login_button(): + auth_url = get_auth_url() + login_button = "Login to Spotify" + return login_button + + def get_auth_url(): + auth_url = auth_manager.get_authorize_url() + return auth_url + return app + diff --git a/src/jukebox/components/player/plugin/__init__.py b/src/jukebox/components/player/plugin/__init__.py index 8ee2b754b..662d1077b 100644 --- a/src/jukebox/components/player/plugin/__init__.py +++ b/src/jukebox/components/player/plugin/__init__.py @@ -55,11 +55,12 @@ def register_mpd(): def register_spotify(): + global event_loop global backend_spot global player_arbiter global player_status - backend_spot = SPOTBackend(player_status) + backend_spot = SPOTBackend(player_status, event_loop) # Register with plugin interface to call directly plugin.register(backend_spot, package='player', name='spotify') player_arbiter.register('spotify', backend_spot) From e3ea953ece6a9bfb6aad42e9ba27e5d7030dceb5 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Thu, 28 Dec 2023 20:51:17 +0100 Subject: [PATCH 083/109] Docker container for spotifyd with pulseaudio; Still problems --- docker/config/docker.spotifyd.conf | 13 ++++++++++ docker/docker-compose.yml | 15 +++++++++++ docker/spotifyd.Dockerfile | 40 ++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 docker/config/docker.spotifyd.conf create mode 100644 docker/spotifyd.Dockerfile diff --git a/docker/config/docker.spotifyd.conf b/docker/config/docker.spotifyd.conf new file mode 100644 index 000000000..b2ab6e5e6 --- /dev/null +++ b/docker/config/docker.spotifyd.conf @@ -0,0 +1,13 @@ +[global] +username = USER +password = PASS +backend = pulse +device = default # Given by `aplay -L` +mixer = PCM +volume-control = alsa # or alsa_linear, or softvol +#onevent = command_run_on_playback_event +device_name = phoniebox_docker # Cannot contain spaces +bitrate = 160 +cache_path = cache_directory +volume-normalisation = true +normalisation-pregain = -10 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c491ca29a..3fdd5a88e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -62,3 +62,18 @@ services: volumes: - ../src/webapp:/home/node/webapp - /home/node/webapp/node_modules + + spotifyd: + build: + context: ../ + dockerfile: ./docker/spotifyd.Dockerfile + container_name: spotifyd + ports: + - 1234:1234 + restart: unless-stopped + environment: + - PULSE_SERVER=unix:/tmp/pulseaudio.socket + - PULSE_COOKIE=/tmp/pulseaudio.cookie + volumes: + - ../shared/spotify/docker.spotifyd.conf:/etc/spotifyd.conf + - $XDG_RUNTIME_DIR/pulse/native:/tmp/pulseaudio.socket diff --git a/docker/spotifyd.Dockerfile b/docker/spotifyd.Dockerfile new file mode 100644 index 000000000..d9d6adc1d --- /dev/null +++ b/docker/spotifyd.Dockerfile @@ -0,0 +1,40 @@ +#An extra layer to get around this bug https://github.com/docker/buildx/issues/395 +#It's there simply to download add required libraries for cargo build +FROM --platform=$BUILDPLATFORM rust:bullseye AS rust_fix + +ENV USER=root +ENV V_spotifyd=v0.3.5 + +WORKDIR /usr/src/spotifyd +RUN apt-get -y update && \ + apt-get install --no-install-recommends -y apt-transport-https ca-certificates git && \ + git clone --depth 1 --branch=${V_spotifyd} https://github.com/Spotifyd/spotifyd.git . + +# Don't do `cargo init` or --> error: `cargo init` cannot be run on existing Cargo packages +# RUN cargo init +RUN mkdir -p .cargo \ + && cargo vendor > .cargo/config + +FROM rust:bullseye as build + +RUN apt-get -y update && \ + apt-get install --no-install-recommends -y libasound2-dev build-essential pulseaudio libpulse-dev libdbus-1-dev + +COPY --from=rust_fix /usr/src/spotifyd /usr/src/spotifyd +WORKDIR /usr/src/spotifyd + +RUN cargo build -j 2 --release --features pulseaudio_backend,dbus_mpris --offline + +FROM debian:bullseye-slim as release + +CMD ["dbus-run-session", "/usr/bin/spotifyd", "--no-daemon"] + +RUN apt-get update && \ + apt-get install -yqq --no-install-recommends libasound2 pulseaudio dbus libssl1.1 && \ + rm -rf /var/lib/apt/lists/* && \ + groupadd -r spotify && \ + useradd --no-log-init -r -g spotify -G audio spotify + +COPY --from=build /usr/src/spotifyd/target/release/spotifyd /usr/bin/ + +USER spotify From e0faa44ca594f47652fe5b5993595a39e880e62b Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 3 Jan 2024 10:07:39 +0100 Subject: [PATCH 084/109] Installation of spotifyd --- installation/includes/01_default_config.sh | 3 + installation/routines/customize_options.sh | 37 +++++++++ installation/routines/install.sh | 1 + installation/routines/setup_spotify.sh | 62 ++++++++++++++ resources/default-services/spotifyd.service | 15 ++++ .../default-settings/spotifyd.default.conf | 81 +++++++++++++++++++ 6 files changed, 199 insertions(+) create mode 100644 installation/routines/setup_spotify.sh create mode 100644 resources/default-services/spotifyd.service create mode 100755 resources/default-settings/spotifyd.default.conf diff --git a/installation/includes/01_default_config.sh b/installation/includes/01_default_config.sh index fa1bafb61..6aeffc0dd 100644 --- a/installation/includes/01_default_config.sh +++ b/installation/includes/01_default_config.sh @@ -11,6 +11,9 @@ DISABLE_SSH_QOS=true DISABLE_BOOT_SCREEN=true DISABLE_BOOT_LOGS_PRINT=true SETUP_MPD=true +ENABLE_SPOTIFY=false +SPOT_USERNAME=NONE +SPOT_PASSWORD=NONE ENABLE_MPD_OVERWRITE_INSTALL=true UPDATE_RASPI_OS=${UPDATE_RASPI_OS:-"true"} ENABLE_RFID_READER=true diff --git a/installation/routines/customize_options.sh b/installation/routines/customize_options.sh index 4007b88f5..d332527c2 100644 --- a/installation/routines/customize_options.sh +++ b/installation/routines/customize_options.sh @@ -142,6 +142,42 @@ Would you like to overwrite your configuration? [Y/n]" fi } +_option_spotify() { + # ENABLE_SPOTIFY + clear_c + print_c "---------------------- SPOTIFY ---------------------- + +Installs an additional player, so that you can +play music from spotify. + +Note: You need Spotify Premium to run this service! + +Do you want to enable the spotify player? [y/N]" + read -r response + case "$response" in + [yY][eE][sS]|[yY]) + ENABLE_SPOTIFY=true + ;; + *) + ;; + esac + + if [ "$ENABLE_SPOTIFY" = true ]; then + print_c "Please provide the Spotify username" + read -r response_username + SPOT_USERNAME="${response_username}" + + print_c "Please provide the Spotify password" + read -r response_password + SPOT_PASSWORD="${response_password}" + fi + + log "ENABLE_SPOTIFY=${ENABLE_SPOTIFY}" + if [ "$ENABLE_SPOTIFY" = true ]; then + log "Spotify service will be enabled with username ${SPOT_USERNAME}" + fi +} + _option_rfid_reader() { # ENABLE_RFID_READER clear_c @@ -328,6 +364,7 @@ _run_customize_options() { _option_bluetooth _option_disable_onboard_audio _option_mpd + _option_spotify _option_rfid_reader _option_samba _option_webapp diff --git a/installation/routines/install.sh b/installation/routines/install.sh index 62d602f17..64be7bd63 100644 --- a/installation/routines/install.sh +++ b/installation/routines/install.sh @@ -8,6 +8,7 @@ install() { init_git_repo_from_tardir setup_jukebox_core setup_mpd + setup_spotifyd setup_samba setup_jukebox_webapp setup_kiosk_mode diff --git a/installation/routines/setup_spotify.sh b/installation/routines/setup_spotify.sh new file mode 100644 index 000000000..0965dc91e --- /dev/null +++ b/installation/routines/setup_spotify.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +# documentation of spotifyd +# https://docs.spotifyd.rs/installation/Raspberry-Pi.html + +SPOTIFYD_SERVICE_FILE="/etc/systemd/user/spotifyd.service" +SPOTIFYD_TAR="https://github.com/Spotifyd/spotifyd/releases/latest/download/spotifyd-linux-armhf-default.tar.gz" +SPOTIFYD_TAR_FILE_NAME="spotifyd.tar.gz" + +SPOTIFYD_TARGET_PATH="/usr/bin/" +SPOTIFYD_TARGET_FILE="/usr/bin/spotifyd" +SPOTIFYD_CONFIG_TARGET_FILE="/usr/spotifyd.conf" + + +_install_spotifyd_script() { + wget "${SPOTIFYD_TAR}" --output-document "${SPOTIFYD_TAR_FILE_NAME}" + sudo tar xzf "${SPOTIFYD_TAR_FILE_NAME}" -C "${SPOTIFYD_TARGET_PATH}" + + # Cleanup + # rm -f "${SPOTIFYD_TAR_FILE_NAME}" +} + + +_install_spotifyd_service() { + sudo cp "${INSTALLATION_PATH}"/resources/default-services/spotifyd.service /etc/systemd/system/spotifyd.service + sudo systemctl enable spotifyd.service +} + +_configure_spotifyd() { + local SPOTIFYD_CUSTOM_FILE="${INSTALLATION_PATH}"/resources/default-settings/spotifyd.default.conf + + sed -i "s/%%SPOT_USERNAME%%/${SPOT_USERNAME}/g" "${SPOTIFYD_CUSTOM_FILE}" + sed -i "s/%%SPOT_PASSWORD%%/${SPOT_PASSWORD}/g" "${SPOTIFYD_CUSTOM_FILE}" + sudo cp "${SPOTIFYD_CUSTOM_FILE}" "${SPOTIFYD_CONFIG_TARGET_FILE}" + +} + + +_spotifyd_check() { + print_verify_installation + + verify_service_enablement spotifyd.service enabled + + verify_files_exists "${SPOTIFYD_TARGET_FILE}" + + verify_file_contains_string "${SPOT_USERNAME}" "${SPOTIFYD_CONFIG_TARGET_FILE}" + verify_file_contains_string "${SPOT_PASSWORD}" "${SPOTIFYD_CONFIG_TARGET_FILE}" + +} + +_run_setup_spotifyd() { + _install_spotifyd_script + _install_spotifyd_service + _configure_spotifyd + _spotifyd_check +} + +setup_spotifyd() { + if [ "$ENABLE_SPOTIFY" == true ] ; then + run_with_log_frame _run_setup_spotifyd "Install Spotifyd" + fi +} diff --git a/resources/default-services/spotifyd.service b/resources/default-services/spotifyd.service new file mode 100644 index 000000000..5857b899a --- /dev/null +++ b/resources/default-services/spotifyd.service @@ -0,0 +1,15 @@ +[Unit] +Description=A spotify playing daemon +Documentation=https://github.com/Spotifyd/spotifyd +Wants=sound.target +After=sound.target +Wants=network-online.target +After=network-online.target + +[Service] +ExecStart=/usr/bin/spotifyd --no-daemon +Restart=always +RestartSec=12 + +[Install] +WantedBy=default.target diff --git a/resources/default-settings/spotifyd.default.conf b/resources/default-settings/spotifyd.default.conf new file mode 100755 index 000000000..f0771f793 --- /dev/null +++ b/resources/default-settings/spotifyd.default.conf @@ -0,0 +1,81 @@ +[global] +# Your Spotify account name. +username = "%%SPOT_USERNAME%%" + +# Your Spotify account password. +password = "%%SPOT_PASSWORD%%" + +# The audio backend used to play music. To get +# a list of possible backends, run `spotifyd --help`. +backend = "pulse" + +# The alsa audio device to stream audio. To get a +# list of valid devices, run `aplay -L`, +device = "default" + +# The PCM sample format to use. Possible values +# are F32, S32, S24, S24_3, S16. +# Change this value if you encounter errors like +# "Alsa error PCM open ALSA function 'snd_pcm_hw_params_set_format' failed with error 'EINVAL: Invalid argument'" +audio_format = "S16" + +# The alsa control device. By default this is the same +# name as the `device` field. +control = "default" + +# The alsa mixer used by `spotifyd`. +mixer = "PCM" + +# The volume controller. Each one behaves different to +# volume increases. For possible values, run +# `spotifyd --help`. +volume_controller = "alsa" # use softvol for macOS + +# A command that gets executed in your shell after each song changes. +on_song_change_hook = "command_to_run_on_playback_events" + +# The name that gets displayed under the connect tab on +# official clients. +device_name = "phoniebox" + +# The audio bitrate. 96, 160 or 320 kbit/s +bitrate = 160 + +# The directory used to cache audio data. This setting can save +# a lot of bandwidth when activated, as it will avoid re-downloading +# audio files when replaying them. +# +# Note: The file path does not get expanded. Environment variables and +# shell placeholders like $HOME or ~ don't work! +cache_path = "/tmp" + +# The maximal size of the cache directory in bytes +# The example value corresponds to ~ 1GB +max_cache_size = 1000000000 + +# If set to true, audio data does NOT get cached. +no_audio_cache = false + +# Volume on startup between 0 and 100 +# NOTE: This variable's type will change in v0.4, to a number (instead of string) +initial_volume = "50" + +# If set to true, enables volume normalisation between songs. +volume_normalisation = true + +# The normalisation pregain that is applied for each song. +normalisation_pregain = -10 + +# After the music playback has ended, start playing similar songs based on the previous tracks. +autoplay = false + +# The port at which `spotifyd` is going to offer its service over the network (TCP). +# If not set, a random port > 1024 is used. For the service to be discoverable on the +# local network via mDNS, both the mDNS port (5353 UDP) and the random or fixed +# zeroconf port need to be allowed through any active firewall. +zeroconf_port = 1234 + +# The displayed device type in Spotify clients. +# Can be unknown, computer, tablet, smartphone, speaker, t_v, +# a_v_r (Audio/Video Receiver), s_t_b (Set-Top Box), and audio_dongle. +device_type = "speaker" From 3d7195d1ef92485a45a1adace778970c759aa9dd Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 3 Jan 2024 10:58:21 +0100 Subject: [PATCH 085/109] correct path of spotifyd config --- installation/routines/setup_spotify.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installation/routines/setup_spotify.sh b/installation/routines/setup_spotify.sh index 0965dc91e..7258d3f55 100644 --- a/installation/routines/setup_spotify.sh +++ b/installation/routines/setup_spotify.sh @@ -9,7 +9,7 @@ SPOTIFYD_TAR_FILE_NAME="spotifyd.tar.gz" SPOTIFYD_TARGET_PATH="/usr/bin/" SPOTIFYD_TARGET_FILE="/usr/bin/spotifyd" -SPOTIFYD_CONFIG_TARGET_FILE="/usr/spotifyd.conf" +SPOTIFYD_CONFIG_TARGET_FILE="/etc/spotifyd.conf" _install_spotifyd_script() { From 0deda1e589c9f2aa6cb78480a9192fd05d269413 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 3 Jan 2024 11:47:07 +0100 Subject: [PATCH 086/109] corrected relative path in jukebox yaml --- resources/default-settings/jukebox.default.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index 03adf6fc8..511b45b8a 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -79,7 +79,7 @@ alsawave: device: default players: content: - audiofile: /home/pi/RPi-Jukebox-RFID/shared/audiofolders/audiofiles.yaml + audiofile: /../../shared/audiofolders/audiofiles.yaml playermpd: host: localhost status_file: ../../shared/settings/music_player_status.json From 9a506f0c63cbad9dfdcb481c4a0c7f46380cd00a Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 3 Jan 2024 12:29:59 +0100 Subject: [PATCH 087/109] prepare for client_id, client_secret --- resources/default-settings/jukebox.default.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index 511b45b8a..aadfdd3de 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -94,12 +94,14 @@ playermpd: playerspot: host: localhost status_file: ../../shared/settings/spotify_player_status.json - oauthcache: ../../shared/audio/spotify/.spotipyoauthcache + oauthcache: ../../shared/spotify/.spotipyoauthcache second_swipe_action: # Note: Does not follow the RPC alias convention (yet) # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' - alias: toggle - spot_conf: ../../shared/spotify/config.toml" + alias: toggle + client_id: client_id + client_secret: client_secret + callback_url: http://localhost:3001 rpc: tcp_port: 5555 websocket_port: 5556 From af3b28178079bc587418881805d12a7aedbad0b0 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 5 Jan 2024 19:44:57 +0100 Subject: [PATCH 088/109] extract player config into new file --- .../default-settings/jukebox.default.yaml | 25 ----------------- .../default-settings/player.default.yaml | 28 +++++++++++++++++++ 2 files changed, 28 insertions(+), 25 deletions(-) create mode 100644 resources/default-settings/player.default.yaml diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index aadfdd3de..483f187c9 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -77,31 +77,6 @@ jinglemp3: alsawave: # Config of the Wave through ALSA Jingle Service device: default -players: - content: - audiofile: /../../shared/audiofolders/audiofiles.yaml -playermpd: - host: localhost - status_file: ../../shared/settings/music_player_status.json - second_swipe_action: - # Note: Does not follow the RPC alias convention (yet) - # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' - alias: toggle - library: - update_on_startup: true - check_user_rights: true - mpd_conf: ~/.config/mpd/mpd.conf -playerspot: - host: localhost - status_file: ../../shared/settings/spotify_player_status.json - oauthcache: ../../shared/spotify/.spotipyoauthcache - second_swipe_action: - # Note: Does not follow the RPC alias convention (yet) - # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' - alias: toggle - client_id: client_id - client_secret: client_secret - callback_url: http://localhost:3001 rpc: tcp_port: 5555 websocket_port: 5556 diff --git a/resources/default-settings/player.default.yaml b/resources/default-settings/player.default.yaml new file mode 100644 index 000000000..d7c8817e8 --- /dev/null +++ b/resources/default-settings/player.default.yaml @@ -0,0 +1,28 @@ +# IMPORTANT: +# Always use relative path from settingsfile `../../`, but do not use relative paths with `~/`. +# Sole (!) exception is in playermpd.mpd_conf +players: + content: + audiofile: /../../shared/audiofolders/audiofiles.yaml +playermpd: + host: localhost + status_file: ../../shared/settings/music_player_status.json + second_swipe_action: + # Note: Does not follow the RPC alias convention (yet) + # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' + alias: toggle + library: + update_on_startup: true + check_user_rights: true + mpd_conf: ~/.config/mpd/mpd.conf +playerspot: + host: localhost + status_file: ../../shared/settings/spotify_player_status.json + oauthcache: ../../shared/spotify/.spotipyoauthcache + second_swipe_action: + # Note: Does not follow the RPC alias convention (yet) + # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' + alias: toggle + client_id: %%SPOT_CLIENT_ID%% + client_secret: %%SPOT_CLIENT_SECRET%% + callback_url: http://localhost:3001 From 3e0c94a7e9b30a85e31a308929e053306c977bb9 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 5 Jan 2024 19:47:04 +0100 Subject: [PATCH 089/109] make use of the new player.yaml --- installation/routines/setup_jukebox_core.sh | 1 + src/jukebox/components/player/backends/mpd/interfacing_mpd.py | 2 +- .../components/player/backends/spotify/interfacing_spotify.py | 2 +- src/jukebox/components/player/core/player_content.py | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/installation/routines/setup_jukebox_core.sh b/installation/routines/setup_jukebox_core.sh index a7d0f29b6..52dfe4626 100644 --- a/installation/routines/setup_jukebox_core.sh +++ b/installation/routines/setup_jukebox_core.sh @@ -108,6 +108,7 @@ _jukebox_core_build_and_install_pyzmq() { _jukebox_core_install_settings() { print_lc " Register Jukebox settings" cp -f "${INSTALLATION_PATH}/resources/default-settings/jukebox.default.yaml" "${SETTINGS_PATH}/jukebox.yaml" + cp -f "${INSTALLATION_PATH}/resources/default-settings/player.default.yaml" "${SETTINGS_PATH}/player.yaml" cp -f "${INSTALLATION_PATH}/resources/default-settings/logger.default.yaml" "${SETTINGS_PATH}/logger.yaml" } diff --git a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py index 9e5f8b0d8..b3c108a3a 100644 --- a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py +++ b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py @@ -13,7 +13,7 @@ from components.player.backends import BackendPlayer logger = logging.getLogger('jb.mpd') -cfg = jukebox.cfghandler.get_handler('jukebox') +cfg = jukebox.cfghandler.get_handler('player') def sanitize(path: str): diff --git a/src/jukebox/components/player/backends/spotify/interfacing_spotify.py b/src/jukebox/components/player/backends/spotify/interfacing_spotify.py index ae6a65ed6..20105650d 100644 --- a/src/jukebox/components/player/backends/spotify/interfacing_spotify.py +++ b/src/jukebox/components/player/backends/spotify/interfacing_spotify.py @@ -16,7 +16,7 @@ from components.player.backends.spotify.oauth import create_oauth_website logger = logging.getLogger('jb.spotify') -cfg = jukebox.cfghandler.get_handler('jukebox') +cfg = jukebox.cfghandler.get_handler('player') def sanitize(path: str): diff --git a/src/jukebox/components/player/core/player_content.py b/src/jukebox/components/player/core/player_content.py index 93b3372b8..7149f2a9e 100644 --- a/src/jukebox/components/player/core/player_content.py +++ b/src/jukebox/components/player/core/player_content.py @@ -7,7 +7,7 @@ from jukebox import playlistgenerator logger = logging.getLogger('jb.player_content') -cfg = jukebox.cfghandler.get_handler('jukebox') +cfg = jukebox.cfghandler.get_handler('player') class PlayerData: From 8d65be36753c2f5d1912e528bb53110c218d24df Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 5 Jan 2024 20:09:20 +0100 Subject: [PATCH 090/109] create first basic documentation how to obtain client_id/client_secret --- documentation/builders/README.md | 1 + documentation/builders/spotify.md | 39 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 documentation/builders/spotify.md diff --git a/documentation/builders/README.md b/documentation/builders/README.md index 3d70bca15..7f4532d35 100644 --- a/documentation/builders/README.md +++ b/documentation/builders/README.md @@ -9,6 +9,7 @@ ## Configuration * [Audio](./audio.md) +* [Spotify](./spotify.md) * [RFID](./rfid.md) * [Card Database](./card-database.md) * [Troubleshooting](./troubleshooting.md) diff --git a/documentation/builders/spotify.md b/documentation/builders/spotify.md new file mode 100644 index 000000000..3b48cad6c --- /dev/null +++ b/documentation/builders/spotify.md @@ -0,0 +1,39 @@ +# Spotify Integration + +The Spotify integration allows to play music directly from Spotify. + +> [!IMPORTANT] +> You need a Spotify Premium subscription to use this functionality + +## Needed credentials + +For the spotifyd daemon, you need to provide username and password of your Spotify account +during installation. This is used to enable the Phoniebox to play music from Spotify. + +To control the playback (spotipy), you need to create an app as Spotify developer and provide the +`client_id` and `client_secret` therefore follow the steps: + +1. Access [Developers Dashboard](https://developer.spotify.com/dashboard) +2. Create an App via the button on the top right +3. Fill in following fields: + - App name: `your_desired_name` + - Redirect URI: `http://localhost:3001` +4. Save the `client_id` and `client_secret` for the installation + +## Post installation configuration +### spotifyd + +The spotifyd daemon is using the username and password of your Spotify account. + +If the credentials changed, you can update them in the config file located at `/etc/spotifyd.conf` + +### spotipy + +Spotipy is using `client_id` and `client_secret` to controll the playback of the Phoniebox. + +To update the credentials, please modify the file located at `~/RPi-Jukebox-RFID/shared/settings/player.yaml` + +## Resources + +- [spotifyd](https://spotifyd.rs/) +- [spotipy](https://spotipy.readthedocs.io) From bb24d8ad5c74d06dac5cac9b81c5c3e3e2fdd6de Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 5 Jan 2024 20:22:41 +0100 Subject: [PATCH 091/109] enhance installation method with client_id and client_secret --- installation/includes/01_default_config.sh | 2 ++ installation/routines/customize_options.sh | 18 +++++++++++++++++- installation/routines/setup_spotify.sh | 9 +++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/installation/includes/01_default_config.sh b/installation/includes/01_default_config.sh index 6aeffc0dd..0f4576200 100644 --- a/installation/includes/01_default_config.sh +++ b/installation/includes/01_default_config.sh @@ -14,6 +14,8 @@ SETUP_MPD=true ENABLE_SPOTIFY=false SPOT_USERNAME=NONE SPOT_PASSWORD=NONE +SPOT_CLIENT_ID=NONE +SPOT_CLIENT_SECRET=NONE ENABLE_MPD_OVERWRITE_INSTALL=true UPDATE_RASPI_OS=${UPDATE_RASPI_OS:-"true"} ENABLE_RFID_READER=true diff --git a/installation/routines/customize_options.sh b/installation/routines/customize_options.sh index d332527c2..c95a30bb9 100644 --- a/installation/routines/customize_options.sh +++ b/installation/routines/customize_options.sh @@ -152,6 +152,9 @@ play music from spotify. Note: You need Spotify Premium to run this service! +For more information see documentation: +https://github.com/MiczFlor/RPi-Jukebox-RFID/blob/future3/develop/documentation/builders/spotify.md + Do you want to enable the spotify player? [y/N]" read -r response case "$response" in @@ -163,6 +166,11 @@ Do you want to enable the spotify player? [y/N]" esac if [ "$ENABLE_SPOTIFY" = true ]; then + print_c "To configure Spotify properly, you need to create +an App at https://developer.spotify.com/dashboard and provide the +client_id and client_secret in the following prompts." + print_c "" + print_c "" print_c "Please provide the Spotify username" read -r response_username SPOT_USERNAME="${response_username}" @@ -170,11 +178,19 @@ Do you want to enable the spotify player? [y/N]" print_c "Please provide the Spotify password" read -r response_password SPOT_PASSWORD="${response_password}" + + print_c "Please provide the client_id of the Spotify App" + read -r response_client_id + SPOT_CLIENT_ID="${response_client_id}" + + print_c "Please provide the client_secret of the Spotify App" + read -r response_client_secret + SPOT_CLIENT_SECRET="${response_client_secret}" fi log "ENABLE_SPOTIFY=${ENABLE_SPOTIFY}" if [ "$ENABLE_SPOTIFY" = true ]; then - log "Spotify service will be enabled with username ${SPOT_USERNAME}" + log "Spotify service will be enabled with username ${SPOT_USERNAME} and client_id ${SPOT_CLIENT_ID}" fi } diff --git a/installation/routines/setup_spotify.sh b/installation/routines/setup_spotify.sh index 7258d3f55..6c364bb4d 100644 --- a/installation/routines/setup_spotify.sh +++ b/installation/routines/setup_spotify.sh @@ -35,6 +35,14 @@ _configure_spotifyd() { } +_configure_spotipy() { + local PLAYER_YAML_FILE="${SETTINGS_PATH}/player.yaml" + + sed -i "s/%%SPOT_CLIENT_ID%%/${SPOT_CLIENT_ID}/g" "${PLAYER_YAML_FILE}" + sed -i "s/%%SPOT_CLIENT_SECRET%%/${SPOT_CLIENT_SECRET}/g" "${PLAYER_YAML_FILE}" + +} + _spotifyd_check() { print_verify_installation @@ -52,6 +60,7 @@ _run_setup_spotifyd() { _install_spotifyd_script _install_spotifyd_service _configure_spotifyd + _configure_spotipy _spotifyd_check } From f34815dcbff502f175cc19300630415ab59c5835 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Fri, 5 Jan 2024 20:26:53 +0100 Subject: [PATCH 092/109] Adjust CI to handle spotify installation --- ci/installation/run_install_common.sh | 2 ++ ci/installation/run_install_faststartup.sh | 2 ++ 2 files changed, 4 insertions(+) diff --git a/ci/installation/run_install_common.sh b/ci/installation/run_install_common.sh index 102c71aa4..0f70f68fe 100644 --- a/ci/installation/run_install_common.sh +++ b/ci/installation/run_install_common.sh @@ -21,6 +21,7 @@ export ENABLE_WEBAPP_PROD_DOWNLOAD=true # n - deactivate bluetooth # n - disable on-chip audio # - - mpd overwrite config (only with existing installation) +# n - setup Spotify # n - setup rfid reader # y - setup samba # y - setup webapp @@ -36,6 +37,7 @@ n n n n +n y y n diff --git a/ci/installation/run_install_faststartup.sh b/ci/installation/run_install_faststartup.sh index 46cda25ec..1a845c0ab 100644 --- a/ci/installation/run_install_faststartup.sh +++ b/ci/installation/run_install_faststartup.sh @@ -19,6 +19,7 @@ LOCAL_INSTALL_SCRIPT_PATH="${LOCAL_INSTALL_SCRIPT_PATH%/}" # y - deactivate bluetooth # y - disable on-chip audio # - - mpd overwrite config (only with existing installation) +# n - setup Spotify # n - setup rfid reader # n - setup samba # n - setup webapp @@ -36,4 +37,5 @@ n n n n +n ' From d24e16a3fb9f49fb2af2edaae5ec342078543ec4 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sat, 6 Jan 2024 21:06:59 +0100 Subject: [PATCH 093/109] bugfixing spotifyd daemon --- .../default-settings/spotifyd.default.conf | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/resources/default-settings/spotifyd.default.conf b/resources/default-settings/spotifyd.default.conf index f0771f793..c7f49ec20 100755 --- a/resources/default-settings/spotifyd.default.conf +++ b/resources/default-settings/spotifyd.default.conf @@ -5,9 +5,23 @@ username = "%%SPOT_USERNAME%%" # Your Spotify account password. password = "%%SPOT_PASSWORD%%" +# If set to true, `spotifyd` tries to bind to dbus (default is the session bus) +# and expose MPRIS controls. When running headless, without the session bus, +# you should set this to false, to avoid errors. If you still want to use MPRIS, +# have a look at the `dbus_type` option. +use_mpris = false + +# The bus to bind to with the MPRIS interface. +# Possible values: "session", "system" +# The system bus can be used if no graphical session is available +# (e.g. on headless systems) but you still want to be able to use MPRIS. +# NOTE: You might need to add appropriate policies to allow spotifyd to +# own the name. +dbus_type = "system" + # The audio backend used to play music. To get # a list of possible backends, run `spotifyd --help`. -backend = "pulse" +backend = "alsa" # The alsa audio device to stream audio. To get a # list of valid devices, run `aplay -L`, @@ -32,7 +46,7 @@ mixer = "PCM" volume_controller = "alsa" # use softvol for macOS # A command that gets executed in your shell after each song changes. -on_song_change_hook = "command_to_run_on_playback_events" +# on_song_change_hook = "command_to_run_on_playback_events" # The name that gets displayed under the connect tab on # official clients. From 190aaf765c2d1655255dcb9c9c9c23b12b137347 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 8 Jan 2024 14:46:29 +0100 Subject: [PATCH 094/109] Review changes --- docker/docker-compose.yml | 12 ++++++------ documentation/builders/spotify.md | 4 ++-- installation/routines/customize_options.sh | 8 ++++---- installation/routines/setup_spotify.sh | 1 - .../player/backends/mpd/interfacing_mpd.py | 5 ++++- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 3fdd5a88e..b0a9044db 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,9 +15,9 @@ services: - PULSE_SERVER=tcp:host.docker.internal:4713 restart: unless-stopped volumes: - - ../shared/audiofolders:/home/pi/RPi-Jukebox-RFID/shared/audiofolders - - ../shared/playlists:/home/pi/.config/mpd/playlists - - ./config/docker.pulse.mpd.conf://home/pi/.config/mpd/mpd.conf + - ../shared/audiofolders:/root/RPi-Jukebox-RFID/shared/audiofolders + - ../shared/playlists:/root/.config/mpd/playlists + - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf jukebox: build: @@ -41,9 +41,9 @@ services: restart: unless-stopped tty: true volumes: - - ../src/jukebox:/home/pi/RPi-Jukebox-RFID/src/jukebox - - ../shared:/home/pi/RPi-Jukebox-RFID/shared - - ./config/docker.pulse.mpd.conf:/home/pi/.config/mpd/mpd.conf + - ../src/jukebox:/root/RPi-Jukebox-RFID/src/jukebox + - ../shared:/root/RPi-Jukebox-RFID/shared + - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf command: python run_jukebox.py webapp: diff --git a/documentation/builders/spotify.md b/documentation/builders/spotify.md index 3b48cad6c..8a36550a6 100644 --- a/documentation/builders/spotify.md +++ b/documentation/builders/spotify.md @@ -25,11 +25,11 @@ To control the playback (spotipy), you need to create an app as Spotify develope The spotifyd daemon is using the username and password of your Spotify account. -If the credentials changed, you can update them in the config file located at `/etc/spotifyd.conf` +If the credentials have changed, you can update them in the config file located at `/etc/spotifyd.conf` ### spotipy -Spotipy is using `client_id` and `client_secret` to controll the playback of the Phoniebox. +Spotipy is using `client_id` and `client_secret` to control the playback of the Phoniebox. To update the credentials, please modify the file located at `~/RPi-Jukebox-RFID/shared/settings/player.yaml` diff --git a/installation/routines/customize_options.sh b/installation/routines/customize_options.sh index c95a30bb9..fb8d15fcd 100644 --- a/installation/routines/customize_options.sh +++ b/installation/routines/customize_options.sh @@ -148,12 +148,12 @@ _option_spotify() { print_c "---------------------- SPOTIFY ---------------------- Installs an additional player, so that you can -play music from spotify. +play music from spotify. You will need to create +an app in the Spotify developer dashboard to use +Spotify properly -Note: You need Spotify Premium to run this service! +Note: Spotify Premium is needed to operate player! -For more information see documentation: -https://github.com/MiczFlor/RPi-Jukebox-RFID/blob/future3/develop/documentation/builders/spotify.md Do you want to enable the spotify player? [y/N]" read -r response diff --git a/installation/routines/setup_spotify.sh b/installation/routines/setup_spotify.sh index 6c364bb4d..c6a34ae2b 100644 --- a/installation/routines/setup_spotify.sh +++ b/installation/routines/setup_spotify.sh @@ -52,7 +52,6 @@ _spotifyd_check() { verify_files_exists "${SPOTIFYD_TARGET_FILE}" verify_file_contains_string "${SPOT_USERNAME}" "${SPOTIFYD_CONFIG_TARGET_FILE}" - verify_file_contains_string "${SPOT_PASSWORD}" "${SPOTIFYD_CONFIG_TARGET_FILE}" } diff --git a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py index b3c108a3a..895515f80 100644 --- a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py +++ b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py @@ -63,7 +63,10 @@ async def _connect(self): return await self.client.connect(self.host, self.port) def connect(self): - # May raise: mpd.base.ConnectionError: Can not send command to disconnected client + """ + Connect to the MPD backend + :raises: mpd.base.ConnectionError + """ result = asyncio.run_coroutine_threadsafe(self._connect(), self.loop).result() logger.debug(f"Connected to MPD version {self.client.mpd_version} @ {self.host}:{self.port}") return result From 2eccf39aa178d704efaf2bb61ac950b4d85cf2d4 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 8 Jan 2024 14:54:05 +0100 Subject: [PATCH 095/109] Review changes #2 --- .../player/backends/spotify/interfacing_spotify.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/jukebox/components/player/backends/spotify/interfacing_spotify.py b/src/jukebox/components/player/backends/spotify/interfacing_spotify.py index 20105650d..3348fdc21 100644 --- a/src/jukebox/components/player/backends/spotify/interfacing_spotify.py +++ b/src/jukebox/components/player/backends/spotify/interfacing_spotify.py @@ -13,6 +13,7 @@ from spotipy.oauth2 import SpotifyOAuth import spotipy +from components.player.backends import BackendPlayer from components.player.backends.spotify.oauth import create_oauth_website logger = logging.getLogger('jb.spotify') @@ -23,7 +24,7 @@ def sanitize(path: str): return os.path.normpath(path).lstrip('./') -class SPOTBackend: +class SPOTBackend(BackendPlayer): def __init__(self, player_status, event_loop): self.loop = event_loop self.player_status = player_status @@ -84,9 +85,11 @@ def next(self): return self.spot_client.next_track() def toggle(self): + # ToDo: Implement as soon as spotify is working pass def get_queue(self): + # ToDo: Implement as soon as spotify is working pass @plugin.tag From 35100854a9588b8660f44fd5082915c424d6c8c4 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 8 Jan 2024 15:09:29 +0100 Subject: [PATCH 096/109] flake 8 fixes --- .../player/backends/spotify/interfacing_spotify.py | 7 +++++-- src/jukebox/components/player/backends/spotify/oauth.py | 1 - src/jukebox/jukebox/playlistgenerator.py | 2 -- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/jukebox/components/player/backends/spotify/interfacing_spotify.py b/src/jukebox/components/player/backends/spotify/interfacing_spotify.py index 3348fdc21..dfa5b0cc4 100644 --- a/src/jukebox/components/player/backends/spotify/interfacing_spotify.py +++ b/src/jukebox/components/player/backends/spotify/interfacing_spotify.py @@ -35,7 +35,9 @@ def __init__(self, player_status, event_loop): value='http://localhost:3001') spot_scope = "user-read-playback-state,user-modify-playback-state,streaming" - self.auth_manager = SpotifyOAuth(open_browser=False, scope=spot_scope, client_id=self.client_id, client_secret=self.client_secret, redirect_uri=self.redirect_uri, cache_path=os.path.abspath(self.cache_file)) + self.auth_manager = SpotifyOAuth(open_browser=False, scope=spot_scope, client_id=self.client_id, + client_secret=self.client_secret, redirect_uri=self.redirect_uri, + cache_path=os.path.abspath(self.cache_file)) self.access_token = "" self.spot_client = None @@ -50,7 +52,8 @@ def init_spotclient(self, spot_code=None): self.access_token = token_info['access_token'] else: spotify_oauth_website = create_oauth_website(self.auth_manager) - self.thread = threading.Thread(target=spotify_oauth_website.run, kwargs={'host': '', 'port': 3001, 'debug': True}) + self.thread = threading.Thread(target=spotify_oauth_website.run, + kwargs={'host': '', 'port': 3001, 'debug': True}) self.thread.daemon = True self.thread.start() diff --git a/src/jukebox/components/player/backends/spotify/oauth.py b/src/jukebox/components/player/backends/spotify/oauth.py index 76be55fee..dcf16ac4a 100644 --- a/src/jukebox/components/player/backends/spotify/oauth.py +++ b/src/jukebox/components/player/backends/spotify/oauth.py @@ -38,4 +38,3 @@ def get_auth_url(): auth_url = auth_manager.get_authorize_url() return auth_url return app - diff --git a/src/jukebox/jukebox/playlistgenerator.py b/src/jukebox/jukebox/playlistgenerator.py index e0e463d70..c304a76ae 100755 --- a/src/jukebox/jukebox/playlistgenerator.py +++ b/src/jukebox/jukebox/playlistgenerator.py @@ -55,8 +55,6 @@ from typing import (List) -from components.player.core import player_content - logger = logging.getLogger('jb.plgen') # From .xml podcasts, need to parse out these strings: From 3aedd9cd880891736271b06e533a229142f5e289 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 10 Jan 2024 10:20:02 +0100 Subject: [PATCH 097/109] reverted all spotify code; focus on multi-player --- ci/installation/run_install_common.sh | 2 - ci/installation/run_install_faststartup.sh | 2 - docker/config/docker.spotifyd.conf | 13 -- docker/docker-compose.yml | 15 --- docker/spotifyd.Dockerfile | 40 ------ documentation/builders/README.md | 1 - documentation/builders/spotify.md | 39 ------ documentation/developers/status.md | 1 - installation/includes/01_default_config.sh | 5 - installation/routines/customize_options.sh | 53 -------- installation/routines/install.sh | 1 - installation/routines/setup_spotify.sh | 70 ---------- requirements.txt | 6 - resources/default-services/spotifyd.service | 15 --- .../default-settings/player.default.yaml | 11 -- .../default-settings/spotifyd.default.conf | 95 ------------- .../player/backends/spotify/http_client.py | 97 -------------- .../backends/spotify/interfacing_spotify.py | 126 ------------------ .../player/backends/spotify/oauth.py | 40 ------ .../player/backends/spotify/ws_client.py | 123 ----------------- .../components/player/core/player_status.py | 2 +- .../components/player/plugin/__init__.py | 15 --- 22 files changed, 1 insertion(+), 771 deletions(-) delete mode 100644 docker/config/docker.spotifyd.conf delete mode 100644 docker/spotifyd.Dockerfile delete mode 100644 documentation/builders/spotify.md delete mode 100644 installation/routines/setup_spotify.sh delete mode 100644 resources/default-services/spotifyd.service delete mode 100755 resources/default-settings/spotifyd.default.conf delete mode 100644 src/jukebox/components/player/backends/spotify/http_client.py delete mode 100644 src/jukebox/components/player/backends/spotify/interfacing_spotify.py delete mode 100644 src/jukebox/components/player/backends/spotify/oauth.py delete mode 100644 src/jukebox/components/player/backends/spotify/ws_client.py diff --git a/ci/installation/run_install_common.sh b/ci/installation/run_install_common.sh index 90e6b1ea9..3d4c59778 100644 --- a/ci/installation/run_install_common.sh +++ b/ci/installation/run_install_common.sh @@ -21,7 +21,6 @@ export ENABLE_WEBAPP_PROD_DOWNLOAD=true # n - deactivate bluetooth # n - disable on-chip audio # - - mpd overwrite config (only with existing installation) -# n - setup Spotify # n - setup rfid reader # y - setup samba # y - setup webapp @@ -37,7 +36,6 @@ n n n n -n y y n diff --git a/ci/installation/run_install_faststartup.sh b/ci/installation/run_install_faststartup.sh index 27210d093..249d78ffc 100644 --- a/ci/installation/run_install_faststartup.sh +++ b/ci/installation/run_install_faststartup.sh @@ -19,7 +19,6 @@ LOCAL_INSTALL_SCRIPT_PATH="${LOCAL_INSTALL_SCRIPT_PATH%/}" # y - deactivate bluetooth # y - disable on-chip audio # - - mpd overwrite config (only with existing installation) -# n - setup Spotify # n - setup rfid reader # n - setup samba # n - setup webapp @@ -37,5 +36,4 @@ n n n n -n ' diff --git a/docker/config/docker.spotifyd.conf b/docker/config/docker.spotifyd.conf deleted file mode 100644 index b2ab6e5e6..000000000 --- a/docker/config/docker.spotifyd.conf +++ /dev/null @@ -1,13 +0,0 @@ -[global] -username = USER -password = PASS -backend = pulse -device = default # Given by `aplay -L` -mixer = PCM -volume-control = alsa # or alsa_linear, or softvol -#onevent = command_run_on_playback_event -device_name = phoniebox_docker # Cannot contain spaces -bitrate = 160 -cache_path = cache_directory -volume-normalisation = true -normalisation-pregain = -10 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index dde8223f7..f1b88cdf6 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -63,18 +63,3 @@ services: volumes: - ../src/webapp:/home/node/webapp - /home/node/webapp/node_modules - - spotifyd: - build: - context: ../ - dockerfile: ./docker/spotifyd.Dockerfile - container_name: spotifyd - ports: - - 1234:1234 - restart: unless-stopped - environment: - - PULSE_SERVER=unix:/tmp/pulseaudio.socket - - PULSE_COOKIE=/tmp/pulseaudio.cookie - volumes: - - ../shared/spotify/docker.spotifyd.conf:/etc/spotifyd.conf - - $XDG_RUNTIME_DIR/pulse/native:/tmp/pulseaudio.socket diff --git a/docker/spotifyd.Dockerfile b/docker/spotifyd.Dockerfile deleted file mode 100644 index d9d6adc1d..000000000 --- a/docker/spotifyd.Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -#An extra layer to get around this bug https://github.com/docker/buildx/issues/395 -#It's there simply to download add required libraries for cargo build -FROM --platform=$BUILDPLATFORM rust:bullseye AS rust_fix - -ENV USER=root -ENV V_spotifyd=v0.3.5 - -WORKDIR /usr/src/spotifyd -RUN apt-get -y update && \ - apt-get install --no-install-recommends -y apt-transport-https ca-certificates git && \ - git clone --depth 1 --branch=${V_spotifyd} https://github.com/Spotifyd/spotifyd.git . - -# Don't do `cargo init` or --> error: `cargo init` cannot be run on existing Cargo packages -# RUN cargo init -RUN mkdir -p .cargo \ - && cargo vendor > .cargo/config - -FROM rust:bullseye as build - -RUN apt-get -y update && \ - apt-get install --no-install-recommends -y libasound2-dev build-essential pulseaudio libpulse-dev libdbus-1-dev - -COPY --from=rust_fix /usr/src/spotifyd /usr/src/spotifyd -WORKDIR /usr/src/spotifyd - -RUN cargo build -j 2 --release --features pulseaudio_backend,dbus_mpris --offline - -FROM debian:bullseye-slim as release - -CMD ["dbus-run-session", "/usr/bin/spotifyd", "--no-daemon"] - -RUN apt-get update && \ - apt-get install -yqq --no-install-recommends libasound2 pulseaudio dbus libssl1.1 && \ - rm -rf /var/lib/apt/lists/* && \ - groupadd -r spotify && \ - useradd --no-log-init -r -g spotify -G audio spotify - -COPY --from=build /usr/src/spotifyd/target/release/spotifyd /usr/bin/ - -USER spotify diff --git a/documentation/builders/README.md b/documentation/builders/README.md index c71122bbd..f9fef397e 100644 --- a/documentation/builders/README.md +++ b/documentation/builders/README.md @@ -9,7 +9,6 @@ ## Configuration * [Audio](./audio.md) -* [Spotify](./spotify.md) * [RFID](./rfid.md) * [GPIO Recipes](./gpio.md) * [Card Database](./card-database.md) diff --git a/documentation/builders/spotify.md b/documentation/builders/spotify.md deleted file mode 100644 index 8a36550a6..000000000 --- a/documentation/builders/spotify.md +++ /dev/null @@ -1,39 +0,0 @@ -# Spotify Integration - -The Spotify integration allows to play music directly from Spotify. - -> [!IMPORTANT] -> You need a Spotify Premium subscription to use this functionality - -## Needed credentials - -For the spotifyd daemon, you need to provide username and password of your Spotify account -during installation. This is used to enable the Phoniebox to play music from Spotify. - -To control the playback (spotipy), you need to create an app as Spotify developer and provide the -`client_id` and `client_secret` therefore follow the steps: - -1. Access [Developers Dashboard](https://developer.spotify.com/dashboard) -2. Create an App via the button on the top right -3. Fill in following fields: - - App name: `your_desired_name` - - Redirect URI: `http://localhost:3001` -4. Save the `client_id` and `client_secret` for the installation - -## Post installation configuration -### spotifyd - -The spotifyd daemon is using the username and password of your Spotify account. - -If the credentials have changed, you can update them in the config file located at `/etc/spotifyd.conf` - -### spotipy - -Spotipy is using `client_id` and `client_secret` to control the playback of the Phoniebox. - -To update the credentials, please modify the file located at `~/RPi-Jukebox-RFID/shared/settings/player.yaml` - -## Resources - -- [spotifyd](https://spotifyd.rs/) -- [spotipy](https://spotipy.readthedocs.io) diff --git a/documentation/developers/status.md b/documentation/developers/status.md index 48c2b6c3b..8def56c4b 100644 --- a/documentation/developers/status.md +++ b/documentation/developers/status.md @@ -27,7 +27,6 @@ Topics marked _in progress_ are already in the process of implementation by comm - [Volume](#volume) - [GPIO](#gpio) - [WLAN](#wlan) - - [Spotify](#spotify) - [Others](#others) - [Start-up stuff](#start-up-stuff) - [Debug Tools](#debug-tools) diff --git a/installation/includes/01_default_config.sh b/installation/includes/01_default_config.sh index 0f4576200..fa1bafb61 100644 --- a/installation/includes/01_default_config.sh +++ b/installation/includes/01_default_config.sh @@ -11,11 +11,6 @@ DISABLE_SSH_QOS=true DISABLE_BOOT_SCREEN=true DISABLE_BOOT_LOGS_PRINT=true SETUP_MPD=true -ENABLE_SPOTIFY=false -SPOT_USERNAME=NONE -SPOT_PASSWORD=NONE -SPOT_CLIENT_ID=NONE -SPOT_CLIENT_SECRET=NONE ENABLE_MPD_OVERWRITE_INSTALL=true UPDATE_RASPI_OS=${UPDATE_RASPI_OS:-"true"} ENABLE_RFID_READER=true diff --git a/installation/routines/customize_options.sh b/installation/routines/customize_options.sh index c64032b04..7409a6e07 100644 --- a/installation/routines/customize_options.sh +++ b/installation/routines/customize_options.sh @@ -142,58 +142,6 @@ Would you like to overwrite your configuration? [Y/n]" fi } -_option_spotify() { - # ENABLE_SPOTIFY - clear_c - print_c "---------------------- SPOTIFY ---------------------- - -Installs an additional player, so that you can -play music from spotify. You will need to create -an app in the Spotify developer dashboard to use -Spotify properly - -Note: Spotify Premium is needed to operate player! - - -Do you want to enable the spotify player? [y/N]" - read -r response - case "$response" in - [yY][eE][sS]|[yY]) - ENABLE_SPOTIFY=true - ;; - *) - ;; - esac - - if [ "$ENABLE_SPOTIFY" = true ]; then - print_c "To configure Spotify properly, you need to create -an App at https://developer.spotify.com/dashboard and provide the -client_id and client_secret in the following prompts." - print_c "" - print_c "" - print_c "Please provide the Spotify username" - read -r response_username - SPOT_USERNAME="${response_username}" - - print_c "Please provide the Spotify password" - read -r response_password - SPOT_PASSWORD="${response_password}" - - print_c "Please provide the client_id of the Spotify App" - read -r response_client_id - SPOT_CLIENT_ID="${response_client_id}" - - print_c "Please provide the client_secret of the Spotify App" - read -r response_client_secret - SPOT_CLIENT_SECRET="${response_client_secret}" - fi - - log "ENABLE_SPOTIFY=${ENABLE_SPOTIFY}" - if [ "$ENABLE_SPOTIFY" = true ]; then - log "Spotify service will be enabled with username ${SPOT_USERNAME} and client_id ${SPOT_CLIENT_ID}" - fi -} - _option_rfid_reader() { # ENABLE_RFID_READER clear_c @@ -385,7 +333,6 @@ _run_customize_options() { _option_bluetooth _option_disable_onboard_audio _option_mpd - _option_spotify _option_rfid_reader _option_samba _option_webapp diff --git a/installation/routines/install.sh b/installation/routines/install.sh index 64be7bd63..62d602f17 100644 --- a/installation/routines/install.sh +++ b/installation/routines/install.sh @@ -8,7 +8,6 @@ install() { init_git_repo_from_tardir setup_jukebox_core setup_mpd - setup_spotifyd setup_samba setup_jukebox_webapp setup_kiosk_mode diff --git a/installation/routines/setup_spotify.sh b/installation/routines/setup_spotify.sh deleted file mode 100644 index c6a34ae2b..000000000 --- a/installation/routines/setup_spotify.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash - -# documentation of spotifyd -# https://docs.spotifyd.rs/installation/Raspberry-Pi.html - -SPOTIFYD_SERVICE_FILE="/etc/systemd/user/spotifyd.service" -SPOTIFYD_TAR="https://github.com/Spotifyd/spotifyd/releases/latest/download/spotifyd-linux-armhf-default.tar.gz" -SPOTIFYD_TAR_FILE_NAME="spotifyd.tar.gz" - -SPOTIFYD_TARGET_PATH="/usr/bin/" -SPOTIFYD_TARGET_FILE="/usr/bin/spotifyd" -SPOTIFYD_CONFIG_TARGET_FILE="/etc/spotifyd.conf" - - -_install_spotifyd_script() { - wget "${SPOTIFYD_TAR}" --output-document "${SPOTIFYD_TAR_FILE_NAME}" - sudo tar xzf "${SPOTIFYD_TAR_FILE_NAME}" -C "${SPOTIFYD_TARGET_PATH}" - - # Cleanup - # rm -f "${SPOTIFYD_TAR_FILE_NAME}" -} - - -_install_spotifyd_service() { - sudo cp "${INSTALLATION_PATH}"/resources/default-services/spotifyd.service /etc/systemd/system/spotifyd.service - sudo systemctl enable spotifyd.service -} - -_configure_spotifyd() { - local SPOTIFYD_CUSTOM_FILE="${INSTALLATION_PATH}"/resources/default-settings/spotifyd.default.conf - - sed -i "s/%%SPOT_USERNAME%%/${SPOT_USERNAME}/g" "${SPOTIFYD_CUSTOM_FILE}" - sed -i "s/%%SPOT_PASSWORD%%/${SPOT_PASSWORD}/g" "${SPOTIFYD_CUSTOM_FILE}" - sudo cp "${SPOTIFYD_CUSTOM_FILE}" "${SPOTIFYD_CONFIG_TARGET_FILE}" - -} - -_configure_spotipy() { - local PLAYER_YAML_FILE="${SETTINGS_PATH}/player.yaml" - - sed -i "s/%%SPOT_CLIENT_ID%%/${SPOT_CLIENT_ID}/g" "${PLAYER_YAML_FILE}" - sed -i "s/%%SPOT_CLIENT_SECRET%%/${SPOT_CLIENT_SECRET}/g" "${PLAYER_YAML_FILE}" - -} - - -_spotifyd_check() { - print_verify_installation - - verify_service_enablement spotifyd.service enabled - - verify_files_exists "${SPOTIFYD_TARGET_FILE}" - - verify_file_contains_string "${SPOT_USERNAME}" "${SPOTIFYD_CONFIG_TARGET_FILE}" - -} - -_run_setup_spotifyd() { - _install_spotifyd_script - _install_spotifyd_service - _configure_spotifyd - _configure_spotipy - _spotifyd_check -} - -setup_spotifyd() { - if [ "$ENABLE_SPOTIFY" == true ] ; then - run_with_log_frame _run_setup_spotifyd "Install Spotifyd" - fi -} diff --git a/requirements.txt b/requirements.txt index f1b85aa4d..c519935e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,9 +36,3 @@ flake8>=4.0.0 pytest pytest-cov mock - -# For Spotify -spotipy -urllib3 -websocket -bottle diff --git a/resources/default-services/spotifyd.service b/resources/default-services/spotifyd.service deleted file mode 100644 index 5857b899a..000000000 --- a/resources/default-services/spotifyd.service +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=A spotify playing daemon -Documentation=https://github.com/Spotifyd/spotifyd -Wants=sound.target -After=sound.target -Wants=network-online.target -After=network-online.target - -[Service] -ExecStart=/usr/bin/spotifyd --no-daemon -Restart=always -RestartSec=12 - -[Install] -WantedBy=default.target diff --git a/resources/default-settings/player.default.yaml b/resources/default-settings/player.default.yaml index d7c8817e8..96e18f192 100644 --- a/resources/default-settings/player.default.yaml +++ b/resources/default-settings/player.default.yaml @@ -15,14 +15,3 @@ playermpd: update_on_startup: true check_user_rights: true mpd_conf: ~/.config/mpd/mpd.conf -playerspot: - host: localhost - status_file: ../../shared/settings/spotify_player_status.json - oauthcache: ../../shared/spotify/.spotipyoauthcache - second_swipe_action: - # Note: Does not follow the RPC alias convention (yet) - # Must be one of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'none' - alias: toggle - client_id: %%SPOT_CLIENT_ID%% - client_secret: %%SPOT_CLIENT_SECRET%% - callback_url: http://localhost:3001 diff --git a/resources/default-settings/spotifyd.default.conf b/resources/default-settings/spotifyd.default.conf deleted file mode 100755 index c7f49ec20..000000000 --- a/resources/default-settings/spotifyd.default.conf +++ /dev/null @@ -1,95 +0,0 @@ -[global] -# Your Spotify account name. -username = "%%SPOT_USERNAME%%" - -# Your Spotify account password. -password = "%%SPOT_PASSWORD%%" - -# If set to true, `spotifyd` tries to bind to dbus (default is the session bus) -# and expose MPRIS controls. When running headless, without the session bus, -# you should set this to false, to avoid errors. If you still want to use MPRIS, -# have a look at the `dbus_type` option. -use_mpris = false - -# The bus to bind to with the MPRIS interface. -# Possible values: "session", "system" -# The system bus can be used if no graphical session is available -# (e.g. on headless systems) but you still want to be able to use MPRIS. -# NOTE: You might need to add appropriate policies to allow spotifyd to -# own the name. -dbus_type = "system" - -# The audio backend used to play music. To get -# a list of possible backends, run `spotifyd --help`. -backend = "alsa" - -# The alsa audio device to stream audio. To get a -# list of valid devices, run `aplay -L`, -device = "default" - -# The PCM sample format to use. Possible values -# are F32, S32, S24, S24_3, S16. -# Change this value if you encounter errors like -# "Alsa error PCM open ALSA function 'snd_pcm_hw_params_set_format' failed with error 'EINVAL: Invalid argument'" -audio_format = "S16" - -# The alsa control device. By default this is the same -# name as the `device` field. -control = "default" - -# The alsa mixer used by `spotifyd`. -mixer = "PCM" - -# The volume controller. Each one behaves different to -# volume increases. For possible values, run -# `spotifyd --help`. -volume_controller = "alsa" # use softvol for macOS - -# A command that gets executed in your shell after each song changes. -# on_song_change_hook = "command_to_run_on_playback_events" - -# The name that gets displayed under the connect tab on -# official clients. -device_name = "phoniebox" - -# The audio bitrate. 96, 160 or 320 kbit/s -bitrate = 160 - -# The directory used to cache audio data. This setting can save -# a lot of bandwidth when activated, as it will avoid re-downloading -# audio files when replaying them. -# -# Note: The file path does not get expanded. Environment variables and -# shell placeholders like $HOME or ~ don't work! -cache_path = "/tmp" - -# The maximal size of the cache directory in bytes -# The example value corresponds to ~ 1GB -max_cache_size = 1000000000 - -# If set to true, audio data does NOT get cached. -no_audio_cache = false - -# Volume on startup between 0 and 100 -# NOTE: This variable's type will change in v0.4, to a number (instead of string) -initial_volume = "50" - -# If set to true, enables volume normalisation between songs. -volume_normalisation = true - -# The normalisation pregain that is applied for each song. -normalisation_pregain = -10 - -# After the music playback has ended, start playing similar songs based on the previous tracks. -autoplay = false - -# The port at which `spotifyd` is going to offer its service over the network (TCP). -# If not set, a random port > 1024 is used. For the service to be discoverable on the -# local network via mDNS, both the mDNS port (5353 UDP) and the random or fixed -# zeroconf port need to be allowed through any active firewall. -zeroconf_port = 1234 - -# The displayed device type in Spotify clients. -# Can be unknown, computer, tablet, smartphone, speaker, t_v, -# a_v_r (Audio/Video Receiver), s_t_b (Set-Top Box), and audio_dongle. -device_type = "speaker" diff --git a/src/jukebox/components/player/backends/spotify/http_client.py b/src/jukebox/components/player/backends/spotify/http_client.py deleted file mode 100644 index 1a8ab179a..000000000 --- a/src/jukebox/components/player/backends/spotify/http_client.py +++ /dev/null @@ -1,97 +0,0 @@ -import json -import logging -import requests -from requests.adapters import HTTPAdapter -import urllib -from urllib3.util.retry import Retry - -logger = logging.getLogger('jb.spotify.SpotifyHttpClient') - - -class SpotifyHttpClient: - def __init__(self, host: str, port=24879): - self.protocol = 'http' - self.host = host - self.port = port - self.authority = f'{self.protocol}://{self.host}:{self.port}' - - self.session = requests.Session() - retries = Retry( - total=5, - backoff_factor=5, - status_forcelist=[500, 502, 503, 504] - ) - - self.session.mount( - self.protocol + '://', - HTTPAdapter(max_retries=retries) - ) - self.session.headers.update({'content-type': 'application/json'}) - logger.debug(f'Spotify HTTP Client initialized. Will connect to {self.authority}') - - def close(self): - logger.debug("Exiting Spotify HTTP session") - self._post_request('/instance/close') - - def _request(self, request_func, path: str): - try: - url = urllib.parse.urljoin(self.authority, path) - logger.debug(f'Requesting "{self.authority}"') - - response = request_func(url) - response.raise_for_status() - - except requests.HTTPError as http_error: - response = {} - logger.error(f'HTTPError: {http_error}') - - except Exception as error: - response = {} - logger.error(f'Error {error}') - - if response.content: - logger.debug(f"Request response.content: {response.content}") - return json.loads(response.content) - else: - logger.debug("Request response.content empty") - return {} - - # no JSON returned - - def _get_request(self, path: str): - response = self._request(self.session.get, path) - return response - - def _post_request(self, path: str): - response = self._request(self.session.post, path) - return response - - def get_status(self): - # json = self._get_request('/web-api/v1//me/player') - response_json = self._post_request('/player/current') - logger.debug(response_json) - return response_json - - def play_uri(self, uri: str, play: bool = True, shuffle: bool = False): - return self._post_request(f'/player/load?uri={uri}&play={play}&shuffle={shuffle}') - - def play(self): - return self._post_request('/player/resume') - - def pause(self): - return self._post_request('/player/pause') - - def prev(self): - return self._post_request('/player/prev') - - def next(self): - return self._post_request('/player/next') - - def seek(self, new_time: int): - return self._post_request(f'/player/seek?pos={new_time}') - - def shuffle(self, val: bool): - return self._post_request(f'/player/shuffle?val={val}') - - def repeat(self, val: str): - return self._post_request(f'/player/repeat?val={val}') diff --git a/src/jukebox/components/player/backends/spotify/interfacing_spotify.py b/src/jukebox/components/player/backends/spotify/interfacing_spotify.py deleted file mode 100644 index dfa5b0cc4..000000000 --- a/src/jukebox/components/player/backends/spotify/interfacing_spotify.py +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright: 2022 -# SPDX License Identifier: MIT License -import logging -import os.path -import os -import threading - -from ruamel import yaml - -import jukebox.plugs as plugin -import jukebox.cfghandler - -from spotipy.oauth2 import SpotifyOAuth -import spotipy - -from components.player.backends import BackendPlayer -from components.player.backends.spotify.oauth import create_oauth_website - -logger = logging.getLogger('jb.spotify') -cfg = jukebox.cfghandler.get_handler('player') - - -def sanitize(path: str): - return os.path.normpath(path).lstrip('./') - - -class SPOTBackend(BackendPlayer): - def __init__(self, player_status, event_loop): - self.loop = event_loop - self.player_status = player_status - self.cache_file = cfg.setndefault('playerspot', 'oauthcache', value='../../shared/spotify/.spotipyoauthcache') - self.client_id = cfg.setndefault('playerspot', 'client_id', value='Phoniebox') - self.client_secret = cfg.setndefault('playerspot', 'client_secret', value='Phoniebox_secret') - self.redirect_uri = cfg.setndefault('playerspot', 'callback_url', - value='http://localhost:3001') - - spot_scope = "user-read-playback-state,user-modify-playback-state,streaming" - self.auth_manager = SpotifyOAuth(open_browser=False, scope=spot_scope, client_id=self.client_id, - client_secret=self.client_secret, redirect_uri=self.redirect_uri, - cache_path=os.path.abspath(self.cache_file)) - self.access_token = "" - self.spot_client = None - - @plugin.tag - def init_spotclient(self, spot_code=None): - token_info = self.auth_manager.get_cached_token() - token_info = self.auth_manager.validate_token(token_info) - logger.debug(f"Token Info: {token_info}") - - if token_info: - logger.debug("Found cached token for Spotify Client!") - self.access_token = token_info['access_token'] - else: - spotify_oauth_website = create_oauth_website(self.auth_manager) - self.thread = threading.Thread(target=spotify_oauth_website.run, - kwargs={'host': '', 'port': 3001, 'debug': True}) - self.thread.daemon = True - self.thread.start() - - if self.access_token: - logger.debug("Creating Spotify Client") - self.spot_client = spotipy.Spotify(self.access_token) - return self.spot_client.devices() - - def _read_data_file(self) -> dict: - try: - with open(self.collection_file_location, "r") as collection_file: - return yaml.safe_load(collection_file.read()) - except Exception as err: - logger.error(f"Could not open spotify collection file {self.collection_file_location}") - logger.debug(f"Error: {err}") - logger.debug("Continuing with empty dictionary") - return {} - - def play(self): - return self.spot_client.start_playback() - - def pause(self): - return self.spot_client.pause_playback() - - def stop(self): - return self.spot_client.pause_playback() - - def prev(self): - return self.spot_client.previous_track() - - def next(self): - return self.spot_client.next_track() - - def toggle(self): - # ToDo: Implement as soon as spotify is working - pass - - def get_queue(self): - # ToDo: Implement as soon as spotify is working - pass - - @plugin.tag - def play_uri(self, uri: str, **kwargs): - """Decode URI and forward play call - - spotify:playlist:0 - --> search in the yaml-file for the type "playlist" and play the first uri - """ - player_type, index = uri.split(':', 1) - if player_type != 'spotify': - raise KeyError(f"URI prefix must be 'spotify' not '{player_type}") - - return self.spot_client.start_playback(context_uri=uri) - - @plugin.tag - def get_status(self): - return self.spot_client.current_user() - - # ----------------------------------------------------- - # Queue / URI state (save + restore e.g. random, resume, ...) - - def save_state(self): - """Save the configuration and state of the current URI playback to the URIs state file""" - pass - - def _restore_state(self): - """ - Restore the configuration state and last played status for current active URI - """ - pass diff --git a/src/jukebox/components/player/backends/spotify/oauth.py b/src/jukebox/components/player/backends/spotify/oauth.py deleted file mode 100644 index dcf16ac4a..000000000 --- a/src/jukebox/components/player/backends/spotify/oauth.py +++ /dev/null @@ -1,40 +0,0 @@ -import bottle - - -def create_oauth_website(auth_manager): - app = bottle.Bottle() - - @app.route('/') - def index(): - access_token = "" - - token_info = auth_manager.validate_token(auth_manager.cache_handler.get_cached_token()) - - if token_info: - print("Found cached token!") - access_token = token_info['access_token'] - else: - url = bottle.request.url - code = auth_manager.parse_response_code(url) - if code != url: - print("Found Spotify auth code in Request URL! Trying to get valid access token...") - print(f"code: {code}") - token_info = auth_manager.get_access_token(code) - access_token = token_info['access_token'] - - if access_token: - print("Access token available! Trying to get user information...") - return access_token - - else: - return get_login_button() - - def get_login_button(): - auth_url = get_auth_url() - login_button = "Login to Spotify" - return login_button - - def get_auth_url(): - auth_url = auth_manager.get_authorize_url() - return auth_url - return app diff --git a/src/jukebox/components/player/backends/spotify/ws_client.py b/src/jukebox/components/player/backends/spotify/ws_client.py deleted file mode 100644 index 9f46efc9a..000000000 --- a/src/jukebox/components/player/backends/spotify/ws_client.py +++ /dev/null @@ -1,123 +0,0 @@ -import json -import logging -import time - -import websocket -import threading - -logger = logging.getLogger("jb.spotify.SpotifyWsClient") - - -class SpotifyWsClient: - def __init__(self, host: str, player_status, port: int = 24879): - self.protocol = 'ws' - self.host = host - self.port = port - self.url = f'{self.protocol}://{self.host}:{self.port}/events' - - self.player_status = player_status - - self.socket = None - self.thread = None - - self.state_callbacks = { - 'playbackPaused': self.playback_paused, - 'playbackResumed': self.playback_resumed, - 'playbackHaltStateChanged': self.playback_halted, - 'trackChanged': self.track_changed, - 'trackSeeked': self.track_seeked, - 'metadataAvailable': self.metadata_available, - 'inactiveSession': self.inactive_session, - 'contextChanged': self.context_changed, - } - - logger.debug('Spotify WS Client initialized') - - def connect(self): - websocket.enableTrace(True) - self.socket = websocket.WebSocketApp( - self.url, - on_close=self._on_close, - on_error=self._on_error, - on_message=self._on_message - ) - self.thread = threading.Thread(target=self.socket.run_forever) - self.thread.daemon = True - self.thread.start() - - logger.debug(f'Websocket connection established to {self.url}') - - def close(self): - self.socket.close() - - def _on_message(self, socket, message): - logger.debug(f'_on_message: {message}') - data = json.loads(message) - event = data['event'] - - callback = self.state_callbacks.get(event) - if not callback: - raise ValueError(event) - - callback(data) - - def _on_close(self, socket, close_status_code, close_message): - logger.debug(f'Connection with websocket server closed with {close_status_code}:{close_message}') - time.sleep(15) - logger.debug("Retrying to connect") - self.connect() - - def _on_error(self, socket, error): - logger.error(f'Websocket error: {error}') - - # We only care about seconds, not ms as provided by Spotify - def _round_time_to_seconds(self, time): - return '{:.1f}'.format(time / 1000) - - def metadata_available(self, data: dict): - cover_art = data['track']['album']['coverGroup']['image'][2]['fileId'].lower() - - self.player_status.update( - player='Spotify', # TODO: Should this be done differently? - trackid=data['track']['gid'], - title=data['track']['name'], - artist=data['track']['artist'][0]['name'], - album=data['track']['album']['name'], - albumartist=data['track']['album']['artist'][0]['name'], - duration=self._round_time_to_seconds(data['track']['duration']), - coverArt=cover_art - ) - - def playback_paused(self, data: dict): - self.player_status.update( - playing=False, - elapsed=self._round_time_to_seconds(data['trackTime']) - ) - - def playback_resumed(self, data: dict): - self.player_status.update( - playing=True, - elapsed=self._round_time_to_seconds(data['trackTime']) - ) - - def playback_halted(self, data: dict): - self.player_status.update( - playing=data['halted'], - elapsed=self._round_time_to_seconds(data['trackTime']) - ) - - def track_changed(self, data: dict): - pass - - def track_seeked(self, data: dict): - self.player_status.update( - elapsed=self._round_time_to_seconds(data['trackTime']) - ) - - def context_changed(self, data: dict): - pass - - # When Spotify session is routed to another device, - # the local session goes inactive - def inactive_session(self, data: dict): - self.player_status.update(playing=False) diff --git a/src/jukebox/components/player/core/player_status.py b/src/jukebox/components/player/core/player_status.py index b7fccb3a2..b3ed20b01 100644 --- a/src/jukebox/components/player/core/player_status.py +++ b/src/jukebox/components/player/core/player_status.py @@ -13,7 +13,7 @@ class PlayerStatus: 'duration': 0, 'elapsed': 0, 'file': '', # required for MPD // check if really is required - 'player': '', # TODO: TBD, Spotify or MPD + 'player': '', 'playing': False, 'shuffle': False, 'repeat': 0, diff --git a/src/jukebox/components/player/plugin/__init__.py b/src/jukebox/components/player/plugin/__init__.py index 662d1077b..56149f557 100644 --- a/src/jukebox/components/player/plugin/__init__.py +++ b/src/jukebox/components/player/plugin/__init__.py @@ -9,7 +9,6 @@ import jukebox.plugs as plugin import jukebox.cfghandler from components.player.backends.mpd.interfacing_mpd import MPDBackend -from components.player.backends.spotify.interfacing_spotify import SPOTBackend from components.player.core import PlayerCtrl from components.player.core.player_status import PlayerStatus @@ -30,7 +29,6 @@ # The various backends backend_mpd: Optional[MPDBackend] = None -backend_spot: Optional[SPOTBackend] = None def start_event_loop(loop: asyncio.AbstractEventLoop): @@ -54,18 +52,6 @@ def register_mpd(): player_arbiter.register('mpd', backend_mpd) -def register_spotify(): - global event_loop - global backend_spot - global player_arbiter - global player_status - - backend_spot = SPOTBackend(player_status, event_loop) - # Register with plugin interface to call directly - plugin.register(backend_spot, package='player', name='spotify') - player_arbiter.register('spotify', backend_spot) - - @plugin.initialize def initialize(): global event_loop @@ -85,7 +71,6 @@ def initialize(): player_content = PlayerData() # Create and register the players (this is explicit for the moment) - register_spotify() register_mpd() plugin.register(player_arbiter, package='player', name='ctrl') From 8cb99d197d531d94d265940a5a98e055d262ccfe Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sat, 13 Jan 2024 10:45:57 +0100 Subject: [PATCH 098/109] added missing functions to ABC --- .../components/player/backends/__init__.py | 88 ++++++++++++------- .../components/player/core/__init__.py | 41 ++++++++- .../components/player/core/player_status.py | 6 ++ src/webapp/src/commands/index.js | 10 +-- 4 files changed, 104 insertions(+), 41 deletions(-) diff --git a/src/jukebox/components/player/backends/__init__.py b/src/jukebox/components/player/backends/__init__.py index fd6fd560a..1feb5c0ab 100644 --- a/src/jukebox/components/player/backends/__init__.py +++ b/src/jukebox/components/player/backends/__init__.py @@ -1,53 +1,77 @@ -import jukebox.plugs as plugin +from abc import ABC, abstractmethod - -class BackendPlayer: +class BackendPlayer(ABC): """ - Class to inherit, so that you can build a proper new Player + Abstract Class to inherit, so that you can build a proper new Player """ - def __init__(self): - raise NotImplementedError - @plugin.tag + @abstractmethod def next(self): - raise NotImplementedError + pass - @plugin.tag + @abstractmethod def prev(self): - raise NotImplementedError + pass + + @abstractmethod + def play(self): + pass - @plugin.tag - def play(self, idx=None): - raise NotImplementedError + @abstractmethod + def play_single(self, uri): + pass - @plugin.tag + @abstractmethod + def play_album(self, albumartist, album): + pass + @abstractmethod def toggle(self): - raise NotImplementedError + pass + + @abstractmethod + def shuffle(self): + pass - @plugin.tag + @abstractmethod def pause(self): - raise NotImplementedError + pass - @plugin.tag + @abstractmethod def stop(self): - raise NotImplementedError + pass - @plugin.tag + @abstractmethod def get_queue(self): - raise NotImplementedError + pass - @plugin.tag - def play_uri(self, uri): - raise NotImplementedError + @abstractmethod + def repeat(self): + pass - @plugin.tag - def repeatmode(self): - raise NotImplementedError - - @plugin.tag + @abstractmethod def seek(self): - raise NotImplementedError + pass - @plugin.tag + @abstractmethod def get_albums(self): - raise NotImplementedError + pass + + @abstractmethod + def get_single_coverart(self, song_url): + pass + + @abstractmethod + def get_album_coverart(self): + pass + + @abstractmethod + def list_dirs(self): + pass + + @abstractmethod + def get_song_by_url(self, song_url): + pass + + @abstractmethod + def get_folder_content(self): + pass diff --git a/src/jukebox/components/player/core/__init__.py b/src/jukebox/components/player/core/__init__.py index b245e7bcc..0a607ce04 100644 --- a/src/jukebox/components/player/core/__init__.py +++ b/src/jukebox/components/player/core/__init__.py @@ -112,10 +112,21 @@ def play(self): def play_single(self, uri): self.play_uri(uri) + @plugin.tag + def play_album(self, albumartist, album): + self._active.play_album(albumartist, album) + @plugin.tag def toggle(self): self._active.toggle() + @plugin.tag + def shuffle(self, option='toggle'): + """ + Force the player to toggle the shuffle option of the current playing list/album + """ + self._active.shuffle(option) + @plugin.tag def pause(self): self._active.pause() @@ -131,13 +142,30 @@ def get_queue(self): self._active.get_queue() @plugin.tag - def repeatmode(self): - self._active.repeatmode() + def repeat(self): + self._active.repeat() @plugin.tag def seek(self): self._active.seek() + @plugin.tag + def get_single_coverart(self, song_url): + self._active.get_single_coverart(song_url) + + @plugin.tag + def get_album_coverart(self): + self._active.get_album_coverart() + + @plugin.tag + def list_all_dirs(self): + list_of_all_dirs = [] + for name, bkend in self._backends.items(): + list_of_all_dirs.append(bkend.list_dirs()) + return list_of_all_dirs + + + @plugin.tag def list_albums(self): """ @@ -155,6 +183,15 @@ def list_song_by_artist_and_album(self, artist, albumname): s_item = filter(lambda album: album['artist'] == artist and album['albumname'] == albumname, bkend.get_albums()) return s_item if s_item else None + @plugin.tag + def get_song_by_url(self, song_url): + return self._active.get_song_by_url(song_url) + + @plugin.tag + def get_folder_content(self): + return self._active.get_folder_content() + + def _save_state(self): # Get the backend to save the state of the current playlist to the URI's config file self._active.save_state() diff --git a/src/jukebox/components/player/core/player_status.py b/src/jukebox/components/player/core/player_status.py index b3ed20b01..f3efcc2f7 100644 --- a/src/jukebox/components/player/core/player_status.py +++ b/src/jukebox/components/player/core/player_status.py @@ -1,4 +1,6 @@ import logging + +import jukebox.plugs as plugin from jukebox import publishing logger = logging.getLogger('jb.player') @@ -37,3 +39,7 @@ def publish(self): 'player_status', self._player_status ) + + @plugin.tag + def status(self): + return self._player_status diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index e4eeedca9..ac9796a5a 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -9,7 +9,6 @@ const commands = { plugin: 'ctrl', method: 'get_album_coverart', }, - // ToDo: Do we need that? directoryTreeOfAudiofolder: { _package: 'player', plugin: 'ctrl', @@ -25,17 +24,15 @@ const commands = { plugin: 'ctrl', method: 'list_songs_by_artist_and_album', }, - // ToDo: Implement getSongByUrl: { _package: 'player', plugin: 'ctrl', method: 'get_song_by_url', argKeys: ['song_url'] }, - // ToDo: Implement folderList: { _package: 'player', - plugin: 'content', + plugin: 'ctrl', method: 'get_folder_content', }, cardsList: { @@ -52,8 +49,8 @@ const commands = { }, playerstatus: { _package: 'player', - plugin: 'ctrl', - method: 'playerstatus' + plugin: 'playerstatus', + method: 'status' }, // Player Actions @@ -75,7 +72,6 @@ const commands = { method: 'play_folder', argKeys: ['folder'] }, - // ToDo: Implement play_album: { _package: 'player', plugin: 'ctrl', From afcafd165c0cc3d54b03112ee7591e72055fbdf3 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sun, 14 Jan 2024 14:46:06 +0100 Subject: [PATCH 099/109] cleanup old player calls for better development --- .../components/player/backends/__init__.py | 1 + .../player/backends/mpd/interfacing_mpd.py | 11 ++++++ .../components/player/plugin/__init__.py | 5 ++- .../playermpd/playcontentcallback.py | 37 +++++++++++++++++++ .../synchronisation/rfidcards/__init__.py | 12 +++--- 5 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 src/jukebox/components/playermpd/playcontentcallback.py diff --git a/src/jukebox/components/player/backends/__init__.py b/src/jukebox/components/player/backends/__init__.py index 1feb5c0ab..14e0bd4c3 100644 --- a/src/jukebox/components/player/backends/__init__.py +++ b/src/jukebox/components/player/backends/__init__.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod + class BackendPlayer(ABC): """ Abstract Class to inherit, so that you can build a proper new Player diff --git a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py index 895515f80..200a6d5fd 100644 --- a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py +++ b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py @@ -116,6 +116,17 @@ def play(self, idx=None): else: return self._run_cmd(self.client.play, idx) + @plugin.tag + def play_folder(self, folder: str, recursive: bool = False): + """ + Playback a music folder. + + :param folder: Folder path relative to music library path + :param recursive: Add folder recursively + """ + self._run_cmd(self.client.clear) + + def toggle(self): """Toggle between playback / pause""" return self._run_cmd(self.client.pause) diff --git a/src/jukebox/components/player/plugin/__init__.py b/src/jukebox/components/player/plugin/__init__.py index 56149f557..ac9a08101 100644 --- a/src/jukebox/components/player/plugin/__init__.py +++ b/src/jukebox/components/player/plugin/__init__.py @@ -68,13 +68,14 @@ def initialize(): player_status = PlayerStatus() player_status.publish() - player_content = PlayerData() + # ToDo: remove player_content + # player_content = PlayerData() # Create and register the players (this is explicit for the moment) register_mpd() plugin.register(player_arbiter, package='player', name='ctrl') - plugin.register(player_content, package='player', name='content') + # plugin.register(player_content, package='player', name='content') @plugin.atexit diff --git a/src/jukebox/components/playermpd/playcontentcallback.py b/src/jukebox/components/playermpd/playcontentcallback.py new file mode 100644 index 000000000..a60452a23 --- /dev/null +++ b/src/jukebox/components/playermpd/playcontentcallback.py @@ -0,0 +1,37 @@ + +from enum import Enum +from typing import Callable, Generic, TypeVar + +from jukebox.callingback import CallbackHandler + + +class PlayCardState(Enum): + firstSwipe = 0, + secondSwipe = 1 + + +STATE = TypeVar('STATE', bound=Enum) + + +class PlayContentCallbacks(Generic[STATE], CallbackHandler): + """ + Callbacks are executed in various play functions + """ + + def register(self, func: Callable[[str, STATE], None]): + """ + Add a new callback function :attr:`func`. + + Callback signature is + + .. py:function:: func(folder: str, state: STATE) + :noindex: + + :param folder: relativ path to folder to play + :param state: indicator of the state inside the calling + """ + super().register(func) + + def run_callbacks(self, folder: str, state: STATE): + """:meta private:""" + super().run_callbacks(folder, state) diff --git a/src/jukebox/components/synchronisation/rfidcards/__init__.py b/src/jukebox/components/synchronisation/rfidcards/__init__.py index 0fa0969a9..3295a214f 100644 --- a/src/jukebox/components/synchronisation/rfidcards/__init__.py +++ b/src/jukebox/components/synchronisation/rfidcards/__init__.py @@ -19,7 +19,6 @@ import logging import subprocess import components.player -import components.playermpd import components.rfid.reader import components.synchronisation.syncutils as syncutils import jukebox.cfghandler @@ -29,7 +28,7 @@ import shutil from components.rfid.reader import RfidCardDetectState -from components.playermpd.playcontentcallback import PlayCardState +# from components.playermpd.playcontentcallback import PlayCardState logger = logging.getLogger('jb.sync_rfidcards') @@ -73,7 +72,8 @@ def __init__(self): self._sync_remote_ssh_user = cfg_sync_rfidcards.getn('sync_rfidcards', 'credentials', 'username') components.rfid.reader.rfid_card_detect_callbacks.register(self._rfid_callback) - components.playermpd.play_card_callbacks.register(self._play_card_callback) + # ToDo multi-player: check if this is needed with the new player + # components.playermpd.play_card_callbacks.register(self._play_card_callback) else: logger.info("Sync RFID cards deactivated") @@ -84,9 +84,9 @@ def _rfid_callback(self, card_id: str, state: RfidCardDetectState): if state == RfidCardDetectState.received: self.sync_card_database(card_id) - def _play_card_callback(self, folder: str, state: PlayCardState): - if state == PlayCardState.firstSwipe: - self.sync_folder(folder) + # def _play_card_callback(self, folder: str, state: PlayCardState): + # if state == PlayCardState.firstSwipe: + # self.sync_folder(folder) @plugs.tag def sync_change_on_rfid_scan(self, option: str = 'toggle') -> None: From 8be8adea9952f383c8e24ac1bfcd59028c946c5b Mon Sep 17 00:00:00 2001 From: Groovylein Date: Sun, 14 Jan 2024 14:59:21 +0100 Subject: [PATCH 100/109] introduce necessary methods from abstract class --- .../player/backends/mpd/interfacing_mpd.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py index 200a6d5fd..22eeb7c30 100644 --- a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py +++ b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py @@ -126,11 +126,26 @@ def play_folder(self, folder: str, recursive: bool = False): """ self._run_cmd(self.client.clear) + def play_single(self, uri): + pass + + def play_album(self, albumartist, album): + pass def toggle(self): """Toggle between playback / pause""" return self._run_cmd(self.client.pause) + def shuffle(self): + pass + + def repeat(self): + pass + + def seek(self): + pass + + def pause(self): """Pause playback if playing @@ -267,6 +282,21 @@ def get_album_from_uri(self, uri: str): raise ValueError(f"Cannot decode album and/or album artist from URI: '{uri}'") return self.get_album_tracks(album_artist=p.group(4), album=p.group(3)) + def get_single_coverart(self, song_url): + pass + + def get_album_coverart(self): + pass + + def list_dirs(self): + pass + + def get_song_by_url(self, song_url): + pass + + def get_folder_content(self): + pass + # ---------------------------------- # Get podcasts / livestreams From e5ae21430e76af6ce1ed74abb86cb8807874c309 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 22 Jan 2024 21:34:14 +0100 Subject: [PATCH 101/109] enable read from new yaml file player.yaml --- docker/docker-compose.yml | 1 - src/jukebox/components/player/plugin/__init__.py | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f1b88cdf6..03191dbd5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -37,7 +37,6 @@ services: - 5555:5555 - 5556:5556 - 5557:5557 - - 3001:3001 restart: unless-stopped tty: true volumes: diff --git a/src/jukebox/components/player/plugin/__init__.py b/src/jukebox/components/player/plugin/__init__.py index ac9a08101..b70cfce9b 100644 --- a/src/jukebox/components/player/plugin/__init__.py +++ b/src/jukebox/components/player/plugin/__init__.py @@ -17,6 +17,7 @@ logger = logging.getLogger('jb.player') cfg = jukebox.cfghandler.get_handler('jukebox') +cfg_player = jukebox.cfghandler.get_handler('player') # Background event loop in a separate thread to be used by backends as needed for asyncio tasks event_loop: asyncio.AbstractEventLoop @@ -57,6 +58,8 @@ def initialize(): global event_loop global player_arbiter global player_status + + jukebox.cfghandler.load_yaml(cfg_player, '../../shared/settings/player.yaml') # Create the event loop and start it in a background task # the event loop can be shared across different backends (if the backends require a async event loop) event_loop = asyncio.new_event_loop() From 3edfa67eef0fa003f9fa1687c12e008c0ee98781 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 31 Jan 2024 17:22:54 +0100 Subject: [PATCH 102/109] display folder content in webapp --- .../components/player/backends/__init__.py | 9 ++- .../player/backends/mpd/interfacing_mpd.py | 57 ++++++++++++++++++- .../components/player/core/__init__.py | 4 +- src/jukebox/jukebox/playlistgenerator.py | 5 +- 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/jukebox/components/player/backends/__init__.py b/src/jukebox/components/player/backends/__init__.py index 14e0bd4c3..cf3169f14 100644 --- a/src/jukebox/components/player/backends/__init__.py +++ b/src/jukebox/components/player/backends/__init__.py @@ -74,5 +74,12 @@ def get_song_by_url(self, song_url): pass @abstractmethod - def get_folder_content(self): + def get_folder_content(self, folder): + """ + Get the folder content as content list with meta-information. Depth is always 1. + + Call repeatedly to descend in hierarchy + + :param folder: Folder path relative to music library path + """ pass diff --git a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py index 22eeb7c30..fa2e816fc 100644 --- a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py +++ b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py @@ -5,13 +5,16 @@ import logging import os.path import re +from typing import Optional import jukebox.plugs as plugin import jukebox.cfghandler +import jukebox.playlistgenerator as playlistgenerator from mpd.asyncio import MPDClient from components.player.backends import BackendPlayer + logger = logging.getLogger('jb.mpd') cfg = jukebox.cfghandler.get_handler('player') @@ -294,8 +297,11 @@ def list_dirs(self): def get_song_by_url(self, song_url): pass - def get_folder_content(self): - pass + def get_folder_content(self, folder): + logger.debug(f"get_folder_content param: {folder}") + plc = playlistgenerator.PlaylistCollector(get_music_library_path()) + plc.get_directory_content(folder) + return plc.playlist # ---------------------------------- # Get podcasts / livestreams @@ -344,3 +350,50 @@ def _restore_state(self): Restore the configuration state and last played status for current active URI """ pass + + +#ToDo: refactor code +def _get_music_library_path(conf_file): + """Parse the music directory from the mpd.conf file""" + pattern = re.compile(r'^\s*music_directory\s*"(.*)"', re.I) + directory = None + with open(conf_file, 'r') as f: + for line in f: + res = pattern.match(line) + if res: + directory = res.group(1) + break + else: + logger.error(f"Could not find music library path in {conf_file}") + logger.debug(f"MPD music lib path = {directory}; from {conf_file}") + return directory + + +class MusicLibPath: + """Extract the music directory from the mpd.conf file""" + def __init__(self): + self._music_library_path = None + mpd_conf_file = cfg.setndefault('playermpd', 'mpd_conf', value='~/.config/mpd/mpd.conf') + try: + self._music_library_path = _get_music_library_path(os.path.expanduser(mpd_conf_file)) + except Exception as e: + logger.error(f"Could not determine music library directory from '{mpd_conf_file}'") + logger.error(f"Reason: {e.__class__.__name__}: {e}") + + @property + def music_library_path(self): + return self._music_library_path + + +# --------------------------------------------------------------------------- + + +_MUSIC_LIBRARY_PATH: Optional[MusicLibPath] = None + + +def get_music_library_path(): + """Get the music library path""" + global _MUSIC_LIBRARY_PATH + if _MUSIC_LIBRARY_PATH is None: + _MUSIC_LIBRARY_PATH = MusicLibPath() + return _MUSIC_LIBRARY_PATH.music_library_path diff --git a/src/jukebox/components/player/core/__init__.py b/src/jukebox/components/player/core/__init__.py index 0a607ce04..c314eeec2 100644 --- a/src/jukebox/components/player/core/__init__.py +++ b/src/jukebox/components/player/core/__init__.py @@ -188,8 +188,8 @@ def get_song_by_url(self, song_url): return self._active.get_song_by_url(song_url) @plugin.tag - def get_folder_content(self): - return self._active.get_folder_content() + def get_folder_content(self, folder): + return self._active.get_folder_content(folder) def _save_state(self): diff --git a/src/jukebox/jukebox/playlistgenerator.py b/src/jukebox/jukebox/playlistgenerator.py index c304a76ae..e61fd5e9f 100755 --- a/src/jukebox/jukebox/playlistgenerator.py +++ b/src/jukebox/jukebox/playlistgenerator.py @@ -269,6 +269,7 @@ def get_directory_content(self, path='.'): """ self.playlist = [] self._folder = os.path.abspath(os.path.join(self._music_library_base_path, path)) + logger.debug(self._folder) try: content = self._get_directory_content(self._folder) except NotADirectoryError as e: @@ -278,10 +279,10 @@ def get_directory_content(self, path='.'): else: logger.debug(f"Playlist Content: {content}") for m in content: - self.playlist.append({'type': TYPE_DECODE[m.filetype], 'name': m.name, 'path': m.path, 'uri': m.uri}) + self.playlist.append({'type': TYPE_DECODE[m.filetype], 'name': m.name, 'path': m.uri}) def _parse_nonrecusive(self, path='.'): - return [x.path for x in self._get_directory_content(path) if x.filetype != TYPE_DIR] + return [x.uri for x in self._get_directory_content(path) if x.filetype != TYPE_DIR] def _parse_recursive(self, path='.'): # This can certainly be optimized, as os.walk is called on all From c8f809bb087732506a9f1ed4e01a3bca61378725 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Wed, 31 Jan 2024 22:00:10 +0100 Subject: [PATCH 103/109] enable playing from folder view --- src/jukebox/components/player/backends/__init__.py | 11 +++++++++++ .../player/backends/mpd/interfacing_mpd.py | 14 ++++++++++---- src/jukebox/components/player/core/__init__.py | 8 +++++--- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/jukebox/components/player/backends/__init__.py b/src/jukebox/components/player/backends/__init__.py index cf3169f14..8daf7ba37 100644 --- a/src/jukebox/components/player/backends/__init__.py +++ b/src/jukebox/components/player/backends/__init__.py @@ -25,6 +25,17 @@ def play_single(self, uri): @abstractmethod def play_album(self, albumartist, album): pass + + @abstractmethod + def play_folder(self, folder: str, recursive: bool): + """ + Playback a music folder. + + :param folder: Folder path relative to music library path + :param recursive: Add folder recursively + """ + pass + @abstractmethod def toggle(self): pass diff --git a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py index fa2e816fc..f12628f28 100644 --- a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py +++ b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py @@ -20,7 +20,13 @@ def sanitize(path: str): - return os.path.normpath(path).lstrip('./') + """ + Trim path to enable MPD to search in database + + :param path: File or folder path + """ + _music_library_path_absolute = os.path.expanduser(get_music_library_path()) + return os.path.normpath(path).lstrip('./').replace(f'{_music_library_path_absolute}/', '') class MPDBackend(BackendPlayer): @@ -57,6 +63,7 @@ async def _run_cmd_async(self, afunc, *args, **kwargs): return await afunc(*args, **kwargs) def _run_cmd(self, afunc, *args, **kwargs): + logger.debug(f"executing command {afunc.__name__} with params {args} {kwargs}") return asyncio.run_coroutine_threadsafe(self._run_cmd_async(afunc, *args, **kwargs), self.loop).result() # ----------------------------------------------------- @@ -127,7 +134,7 @@ def play_folder(self, folder: str, recursive: bool = False): :param folder: Folder path relative to music library path :param recursive: Add folder recursively """ - self._run_cmd(self.client.clear) + self.play_uri(f"mpd:folder:{folder}", recursive=recursive) def play_single(self, uri): pass @@ -148,7 +155,6 @@ def repeat(self): def seek(self): pass - def pause(self): """Pause playback if playing @@ -352,7 +358,7 @@ def _restore_state(self): pass -#ToDo: refactor code +# ToDo: refactor code def _get_music_library_path(conf_file): """Parse the music directory from the mpd.conf file""" pattern = re.compile(r'^\s*music_directory\s*"(.*)"', re.I) diff --git a/src/jukebox/components/player/core/__init__.py b/src/jukebox/components/player/core/__init__.py index c314eeec2..a779732c5 100644 --- a/src/jukebox/components/player/core/__init__.py +++ b/src/jukebox/components/player/core/__init__.py @@ -116,6 +116,10 @@ def play_single(self, uri): def play_album(self, albumartist, album): self._active.play_album(albumartist, album) + @plugin.tag + def play_folder(self, folder, recursive): + self._active.play_folder(folder, recursive) + @plugin.tag def toggle(self): self._active.toggle() @@ -164,8 +168,6 @@ def list_all_dirs(self): list_of_all_dirs.append(bkend.list_dirs()) return list_of_all_dirs - - @plugin.tag def list_albums(self): """ @@ -187,11 +189,11 @@ def list_song_by_artist_and_album(self, artist, albumname): def get_song_by_url(self, song_url): return self._active.get_song_by_url(song_url) + # ToDo: make it iterate through all player, so that the whole content is displayed @plugin.tag def get_folder_content(self, folder): return self._active.get_folder_content(folder) - def _save_state(self): # Get the backend to save the state of the current playlist to the URI's config file self._active.save_state() From fcf2ced2f1d46710a445e0a6fae40204e8f3b285 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Tue, 27 Feb 2024 16:41:22 +0100 Subject: [PATCH 104/109] Make library available on webapp --- src/jukebox/components/player/core/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/jukebox/components/player/core/__init__.py b/src/jukebox/components/player/core/__init__.py index a779732c5..90e095fe4 100644 --- a/src/jukebox/components/player/core/__init__.py +++ b/src/jukebox/components/player/core/__init__.py @@ -15,7 +15,6 @@ player.mpd.get_...(...) """ - import logging from typing import Dict, Callable, Optional, Any @@ -175,15 +174,17 @@ def list_albums(self): """ album_list = [] for name, bkend in self._backends.items(): - album_list.append(bkend.get_albums()) + album_list.extend(bkend.get_albums()) return album_list @plugin.tag - def list_song_by_artist_and_album(self, artist, albumname): + def list_songs_by_artist_and_album(self, albumartist, album): for name, bkend in self._backends.items(): - s_item = filter(lambda album: album['artist'] == artist and album['albumname'] == albumname, bkend.get_albums()) - return s_item if s_item else None + t_list = bkend.get_album_tracks(albumartist, album) + if t_list: + return t_list + return None @plugin.tag def get_song_by_url(self, song_url): From d38dc0c6930165d9ed82089ca5a0ceea93d15403 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Mon, 11 Mar 2024 15:56:20 +0100 Subject: [PATCH 105/109] Publish playerstatus, so that buttons work on the player --- .../components/player/backends/mpd/interfacing_mpd.py | 7 +++++-- src/jukebox/components/player/plugin/__init__.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py index f12628f28..38e09349c 100644 --- a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py +++ b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py @@ -13,7 +13,7 @@ from mpd.asyncio import MPDClient from components.player.backends import BackendPlayer - +from jukebox import publishing logger = logging.getLogger('jb.mpd') cfg = jukebox.cfghandler.get_handler('player') @@ -26,7 +26,7 @@ def sanitize(path: str): :param path: File or folder path """ _music_library_path_absolute = os.path.expanduser(get_music_library_path()) - return os.path.normpath(path).lstrip('./').replace(f'{_music_library_path_absolute}/', '') + return os.path.normpath(path).replace(f'{_music_library_path_absolute}/', '') class MPDBackend(BackendPlayer): @@ -94,6 +94,7 @@ async def _status_listener(self): # logger.debug(f"MPD: New Status: {s.result()}") print(f"MPD: New Status: {type(s)} // {s}") # Now, do something with it ... + publishing.get_publisher().send('playerstatus', s) async def _status(self): return await self.client.status() @@ -104,6 +105,8 @@ def status(self): f = asyncio.run_coroutine_threadsafe(self._status(), self.loop).result() print(f"Status: {f}") # Put it into unified structure and notify global player control + # ToDo: propagate to core player + publishing.get_publisher().send('playerstatus', f) # ----------------------------------------------------- # Stuff that controls current playback (i.e. moves around in the current playlist, termed "the queue") diff --git a/src/jukebox/components/player/plugin/__init__.py b/src/jukebox/components/player/plugin/__init__.py index b70cfce9b..3824c4138 100644 --- a/src/jukebox/components/player/plugin/__init__.py +++ b/src/jukebox/components/player/plugin/__init__.py @@ -78,6 +78,7 @@ def initialize(): register_mpd() plugin.register(player_arbiter, package='player', name='ctrl') + plugin.register(player_status, package='player', name='playerstatus') # plugin.register(player_content, package='player', name='content') From ba72a186158a65d19200bf24ff17d3d9009cb3c3 Mon Sep 17 00:00:00 2001 From: Kiriakos Antoniadis Date: Tue, 16 Apr 2024 06:38:21 +0000 Subject: [PATCH 106/109] Revert some changes --- docker/config/docker.mpd.conf | 2 +- src/jukebox/components/volume/__init__.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docker/config/docker.mpd.conf b/docker/config/docker.mpd.conf index ccc6449eb..ad7713d0d 100644 --- a/docker/config/docker.mpd.conf +++ b/docker/config/docker.mpd.conf @@ -98,7 +98,7 @@ port "6600" # argument is recommended for troubleshooting, though can quickly stretch # available resources on limited hardware storage. # -log_level "verbose" +log_level "default" # # Setting "restore_paused" to "yes" puts MPD into pause mode instead # of starting playback after startup. diff --git a/src/jukebox/components/volume/__init__.py b/src/jukebox/components/volume/__init__.py index 6e47389ae..efffec0a5 100644 --- a/src/jukebox/components/volume/__init__.py +++ b/src/jukebox/components/volume/__init__.py @@ -62,7 +62,7 @@ import logging import threading import time - +import traceback import pulsectl import jukebox.cfghandler import jukebox.plugs as plugin @@ -627,8 +627,6 @@ def initialize(): pulse_monitor.start() pulse_control = PulseVolumeControl(parse_config()) - plugin.register(pulse_control, package="volume", name="ctrl", replace=True) - plugin.register(pulse_monitor, package="volume", name="mon", replace=True) @plugin.finalize @@ -646,6 +644,7 @@ def finalize(): pulse_control.set_volume(startup_volume) else: pulse_control.publish_volume() + plugin.register(pulse_control, package="volume", name="ctrl", replace=True) @plugin.atexit From 81695921181358c04ca1dd871e0a4953c1c25d85 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Tue, 23 Apr 2024 20:40:20 +0200 Subject: [PATCH 107/109] transfer coverart_cache_manager --- .../player/backends/mpd/interfacing_mpd.py | 8 +- .../player/core/coverart_cache_manager.py | 90 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/jukebox/components/player/core/coverart_cache_manager.py diff --git a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py index 38e09349c..33a8d0835 100644 --- a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py +++ b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py @@ -5,6 +5,7 @@ import logging import os.path import re +from pathlib import Path from typing import Optional import jukebox.plugs as plugin @@ -13,6 +14,7 @@ from mpd.asyncio import MPDClient from components.player.backends import BackendPlayer +from components.player.core.coverart_cache_manager import CoverartCacheManager from jukebox import publishing logger = logging.getLogger('jb.mpd') @@ -36,6 +38,7 @@ def __init__(self, event_loop): self.loop = event_loop self.host = cfg.setndefault('playermpd', 'host', value='localhost') self.port = cfg.setndefault('playermpd', 'port', value='6600') + self.coverart_cache_manager = CoverartCacheManager() self._flavors = {'folder': self.get_files, 'file': self.get_track, 'album': self.get_album_from_uri, @@ -295,7 +298,10 @@ def get_album_from_uri(self, uri: str): return self.get_album_tracks(album_artist=p.group(4), album=p.group(3)) def get_single_coverart(self, song_url): - pass + mp3_file_path = Path(get_music_library_path(), song_url).expanduser() + cache_filename = self.coverart_cache_manager.get_cache_filename(mp3_file_path) + + return cache_filename def get_album_coverart(self): pass diff --git a/src/jukebox/components/player/core/coverart_cache_manager.py b/src/jukebox/components/player/core/coverart_cache_manager.py new file mode 100644 index 000000000..bb2346497 --- /dev/null +++ b/src/jukebox/components/player/core/coverart_cache_manager.py @@ -0,0 +1,90 @@ +from mutagen.mp3 import MP3 +from mutagen.id3 import ID3, APIC +from pathlib import Path +import hashlib +import logging +from queue import Queue +from threading import Thread +import jukebox.cfghandler + +COVER_PREFIX = 'cover' +NO_COVER_ART_EXTENSION = 'no-art' +NO_CACHE = '' +CACHE_PENDING = 'CACHE_PENDING' + +logger = logging.getLogger('jb.CoverartCacheManager') +cfg = jukebox.cfghandler.get_handler('jukebox') + + +class CoverartCacheManager: + def __init__(self): + coverart_cache_path = cfg.setndefault('webapp', 'coverart_cache_path', value='../../src/webapp/build/cover-cache') + self.cache_folder_path = Path(coverart_cache_path).expanduser() + self.write_queue = Queue() + self.worker_thread = Thread(target=self.process_write_requests) + self.worker_thread.daemon = True # Ensure the thread closes with the program + self.worker_thread.start() + + def generate_cache_key(self, base_filename: str) -> str: + return f"{COVER_PREFIX}-{hashlib.sha256(base_filename.encode()).hexdigest()}" + + def get_cache_filename(self, mp3_file_path: str) -> str: + base_filename = Path(mp3_file_path).stem + cache_key = self.generate_cache_key(base_filename) + + for path in self.cache_folder_path.iterdir(): + if path.stem == cache_key: + if path.suffix == f".{NO_COVER_ART_EXTENSION}": + return NO_CACHE + return path.name + + self.save_to_cache(mp3_file_path) + return CACHE_PENDING + + def save_to_cache(self, mp3_file_path: str): + self.write_queue.put(mp3_file_path) + + def _save_to_cache(self, mp3_file_path: str): + base_filename = Path(mp3_file_path).stem + cache_key = self.generate_cache_key(base_filename) + file_extension, data = self._extract_album_art(mp3_file_path) + + cache_filename = f"{cache_key}.{file_extension}" + full_path = self.cache_folder_path / cache_filename # Works due to Pathlib + + with full_path.open('wb') as file: + file.write(data) + logger.debug(f"Created file: {cache_filename}") + + return cache_filename + + def _extract_album_art(self, mp3_file_path: str) -> tuple: + try: + audio_file = MP3(mp3_file_path, ID3=ID3) + except Exception as e: + logger.error(f"Error reading MP3 file {mp3_file_path}: {e}") + return (NO_COVER_ART_EXTENSION, b'') + + for tag in audio_file.tags.values(): + if isinstance(tag, APIC): + mime_type = tag.mime + file_extension = 'jpg' if mime_type == 'image/jpeg' else mime_type.split('/')[-1] + return (file_extension, tag.data) + + return (NO_COVER_ART_EXTENSION, b'') + + def process_write_requests(self): + while True: + mp3_file_path = self.write_queue.get() + try: + self._save_to_cache(mp3_file_path) + except Exception as e: + logger.error(f"Error processing write request: {e}") + self.write_queue.task_done() + + def flush_cache(self): + for path in self.cache_folder_path.iterdir(): + if path.is_file(): + path.unlink() + logger.debug(f"Deleted cached file: {path.name}") + logger.info("Cache flushed successfully.") From b4ff177c3467282b734e051e31d9e67819c24d2b Mon Sep 17 00:00:00 2001 From: Groovylein Date: Tue, 23 Apr 2024 22:02:33 +0200 Subject: [PATCH 108/109] Status publishing via multitimer --- .../player/backends/mpd/interfacing_mpd.py | 15 ++++++++---- .../components/player/core/__init__.py | 24 +++++++++++++++++++ .../components/player/core/player_status.py | 5 +++- .../components/player/plugin/__init__.py | 2 +- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py index 33a8d0835..23a65621b 100644 --- a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py +++ b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py @@ -48,7 +48,7 @@ def __init__(self, event_loop): # TODO: If connect fails on first try this is non recoverable self.connect() # Start the status listener in an endless loop in the event loop - asyncio.run_coroutine_threadsafe(self._status_listener(), self.loop) + # asyncio.run_coroutine_threadsafe(self._status_listener(), self.loop) # ------------------------------------------------------------------------------------------------------ # Bring calls to client functions from the synchronous part into the async domain @@ -95,21 +95,28 @@ async def _status_listener(self): # logger.debug("MPD: Idle change in", subsystem) s = await self.client.status() # logger.debug(f"MPD: New Status: {s.result()}") - print(f"MPD: New Status: {type(s)} // {s}") + #print(f"MPD: New Status: {type(s)} // {s}") # Now, do something with it ... publishing.get_publisher().send('playerstatus', s) + async def _status(self): return await self.client.status() @plugin.tag def status(self): """Refresh the current MPD status (by a manual, sync trigger)""" + # Example + # Status: {'volume': '40', 'repeat': '0', 'random': '0', 'single': '0', 'consume': '0', 'partition': 'default', + # 'playlist': '94', 'playlistlength': '22', 'mixrampdb': '0.000000', 'state': 'play', 'song': '0', + # 'songid': '71', 'time': '1:126', 'elapsed': '1.108', 'bitrate': '96', 'duration': '125.988', + # 'audio': '44100:24:2', 'nextsong': '1', 'nextsongid': '72'} f = asyncio.run_coroutine_threadsafe(self._status(), self.loop).result() - print(f"Status: {f}") + # print(f"Status: {f}") # Put it into unified structure and notify global player control # ToDo: propagate to core player - publishing.get_publisher().send('playerstatus', f) + # publishing.get_publisher().send('playerstatus', f) + return f # ----------------------------------------------------- # Stuff that controls current playback (i.e. moves around in the current playlist, termed "the queue") diff --git a/src/jukebox/components/player/core/__init__.py b/src/jukebox/components/player/core/__init__.py index 90e095fe4..92484e192 100644 --- a/src/jukebox/components/player/core/__init__.py +++ b/src/jukebox/components/player/core/__init__.py @@ -19,6 +19,8 @@ from typing import Dict, Callable, Optional, Any import jukebox.plugs as plugin +from components.player.core.player_status import PlayerStatus +from jukebox import multitimer logger = logging.getLogger('jb.player') @@ -29,12 +31,23 @@ class PlayerCtrl: def __init__(self): self._backends: Dict[str, Any] = {} self._active = None + self.player_status = None + self.status_poll_interval = 0.25 + self.status_thread = multitimer.GenericEndlessTimerClass('player.timer_status', + self.status_poll_interval, self._status_poll) + self.status_thread.start() + + def _status_poll(self): + ret_status = self._active.status() + if ret_status.get('state') == 'play': + self.player_status.update(playing=True, elapsed=ret_status.get('elapsed', '0.0'), duration=ret_status.get('duration', '0.0') ) def register(self, name: str, backend): self._backends[name] = backend # For now simply default to first registered backend if self._active is None: self._active = self._backends.values().__iter__().__next__() + self.player_status.update(player=name) @plugin.tag def get_active(self): @@ -64,6 +77,7 @@ def play_uri(self, uri, check_second_swipe=False, **kwargs): if inst is None: raise KeyError(f"URI player type unknown: '{player_type}'. Available backends are: {self._backends.keys()}.") self._active = self._backends.get(player_type) + self.player_status.update(player=player_type) self._active.play_uri(uri, **kwargs) def _is_second_swipe(self): @@ -106,22 +120,30 @@ def prev(self): @plugin.tag def play(self): self._active.play() + self.player_status.update(playing=True) @plugin.tag def play_single(self, uri): self.play_uri(uri) + self.player_status.update(playing=True) @plugin.tag def play_album(self, albumartist, album): self._active.play_album(albumartist, album) + self.player_status.update(playing=True) @plugin.tag def play_folder(self, folder, recursive): self._active.play_folder(folder, recursive) + self.player_status.update(playing=True) @plugin.tag def toggle(self): self._active.toggle() + if self.player_status.get_value('playing') is False: + self.player_status.update(playing=True) + else: + self.player_status.update(playing=False) @plugin.tag def shuffle(self, option='toggle'): @@ -133,12 +155,14 @@ def shuffle(self, option='toggle'): @plugin.tag def pause(self): self._active.pause() + self.player_status.update(playing=False) @plugin.tag def stop(self): # Save current state for resume functionality self._save_state() self._active.stop() + self.player_status.update(playing=False) @plugin.tag def get_queue(self): diff --git a/src/jukebox/components/player/core/player_status.py b/src/jukebox/components/player/core/player_status.py index f3efcc2f7..71c9621d6 100644 --- a/src/jukebox/components/player/core/player_status.py +++ b/src/jukebox/components/player/core/player_status.py @@ -1,7 +1,7 @@ import logging import jukebox.plugs as plugin -from jukebox import publishing +from jukebox import publishing, multitimer logger = logging.getLogger('jb.player') @@ -33,6 +33,9 @@ def update(self, **kwargs): self.publish() + def get_value(self, key): + return self.STATUS.get(key) + def publish(self): logger.debug(f'Published: {self._player_status}') return publishing.get_publisher().send( diff --git a/src/jukebox/components/player/plugin/__init__.py b/src/jukebox/components/player/plugin/__init__.py index 3824c4138..4e2f159d7 100644 --- a/src/jukebox/components/player/plugin/__init__.py +++ b/src/jukebox/components/player/plugin/__init__.py @@ -69,7 +69,7 @@ def initialize(): player_arbiter = PlayerCtrl() player_status = PlayerStatus() - player_status.publish() + player_arbiter.player_status = player_status # ToDo: remove player_content # player_content = PlayerData() From 83692f1a33e7fab551e35b3a80acbef28f4fc050 Mon Sep 17 00:00:00 2001 From: Groovylein Date: Thu, 2 May 2024 14:33:40 +0200 Subject: [PATCH 109/109] Correct status call in webapp --- src/jukebox/components/player/backends/mpd/interfacing_mpd.py | 3 +-- src/jukebox/components/player/core/__init__.py | 3 ++- src/jukebox/components/player/core/player_status.py | 2 +- src/webapp/src/commands/index.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py index 23a65621b..1cdd6503b 100644 --- a/src/jukebox/components/player/backends/mpd/interfacing_mpd.py +++ b/src/jukebox/components/player/backends/mpd/interfacing_mpd.py @@ -95,11 +95,10 @@ async def _status_listener(self): # logger.debug("MPD: Idle change in", subsystem) s = await self.client.status() # logger.debug(f"MPD: New Status: {s.result()}") - #print(f"MPD: New Status: {type(s)} // {s}") + # print(f"MPD: New Status: {type(s)} // {s}") # Now, do something with it ... publishing.get_publisher().send('playerstatus', s) - async def _status(self): return await self.client.status() diff --git a/src/jukebox/components/player/core/__init__.py b/src/jukebox/components/player/core/__init__.py index 92484e192..c68232ccb 100644 --- a/src/jukebox/components/player/core/__init__.py +++ b/src/jukebox/components/player/core/__init__.py @@ -40,7 +40,8 @@ def __init__(self): def _status_poll(self): ret_status = self._active.status() if ret_status.get('state') == 'play': - self.player_status.update(playing=True, elapsed=ret_status.get('elapsed', '0.0'), duration=ret_status.get('duration', '0.0') ) + self.player_status.update(playing=True, elapsed=ret_status.get('elapsed', '0.0'), + duration=ret_status.get('duration', '0.0')) def register(self, name: str, backend): self._backends[name] = backend diff --git a/src/jukebox/components/player/core/player_status.py b/src/jukebox/components/player/core/player_status.py index 71c9621d6..57401ccc6 100644 --- a/src/jukebox/components/player/core/player_status.py +++ b/src/jukebox/components/player/core/player_status.py @@ -1,7 +1,7 @@ import logging import jukebox.plugs as plugin -from jukebox import publishing, multitimer +from jukebox import publishing logger = logging.getLogger('jb.player') diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index ac9796a5a..fb17fbf17 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -49,7 +49,7 @@ const commands = { }, playerstatus: { _package: 'player', - plugin: 'playerstatus', + plugin: 'player_status', method: 'status' },