Repository: aome510/spotify-player (language: rust)

Issue:
feature suggestions/requests
~~1. download the current playing song's album cover into a file or into a cache for another script to use; my usecase for this is to display the album cover with an image viewer, as i missed this feature from playing music with `mpv.`~~
~~2. display the album cover as braille; my usecase for this is with [st boxdraw](https://st.suckless.org/patches/boxdraw/), as it can render braille characters as block. (might be replaced by sixel)~~ viuer backend request, not spotify-player
1. support querying of status, like in spotify-tui, with the ability for custom format:
```
λ spt playback
🔀 ▶ Warakumbla - Hypothermia
```
2. support running a command on new song or supply current playing song, data, albumcover file, volume on music event to an external command via configuration, or a really over-engineered fifo.


Discussion / hints:
2 - https://github.com/librespot-org/librespot/wiki/Events
```
   Compiling spotify_player v0.10.0 (/tmp/kiss/14173/build/spotify-player/spotify_player)
warning: function `get_track_album_image_url` is never used
  --> spotify_player/src/utils.rs:63:8
   |
63 | pub fn get_track_album_image_url(track: &rspotify::model::FullTrack) -> Option<&str> {
   |        ^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(dead_code)]` on by default

```
this can fulfill feature request 1!
> support running a command on new song or supply current playing song, data, albumcover file, volume on music event to an external command via configuration, or a really over-engineered fifo.

a workaround to this would be to wait for a new file in the album cover cache directory i guess?

Fix patch:
diff --git a/docs/config.md b/docs/config.md
index 13d404c7..2838f407 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -4,6 +4,8 @@
 
 - [General](#general)
   - [Notes](#notes)
+  - [Media control](#media-control)
+  - [Player event hook command](#player-event-hook-command)
   - [Device configurations](#device-configurations)
 - [Themes](#themes)
   - [Use script to add theme](#use-script-to-add-theme)
@@ -27,6 +29,7 @@ All configuration files should be placed inside the application's configuration
 | `playback_format`                    | the format of the text in the playback's window                               | `{track} • {artists}\n{album}\n{metadata}`                 |
 | `notify_format`                      | the format of a notification (`notify` feature only)                          | `{ summary = "{track} • {artists}", body = "{album}" }`    |
 | `copy_command`                       | the command used to execute a copy-to-clipboard action                        | `xclip -sel c` (Linux), `pbcopy` (MacOS), `clip` (Windows) |
+| `player_event_hook_command`          | the hook command executed when there is a new player event                    | `None`                                                     |
 | `ap_port`                            | the application's Spotify session connection port                             | `None`                                                     |
 | `proxy`                              | the application's Spotify session connection proxy                            | `None`                                                     |
 | `theme`                              | the application's theme                                                       | `default`                                                  |
@@ -36,12 +39,12 @@ All configuration files should be placed inside the application's configuration
 | `page_size_in_rows`                  | a page's size expressed as a number of rows (for page-navigation commands)    | `20`                                                       |
 | `track_table_item_max_len`           | the maximum length of a column in a track table                               | `32`                                                       |
 | `enable_media_control`               | enable application media control support (`media-control` feature only)       | `true` (Linux), `false` (Windows and MacOS)                |
-| `enable_streaming`                   | create a device for streaming (streaming feature only)                        | `Always`                                                     |
+| `enable_streaming`                   | create a device for streaming (streaming feature only)                        | `Always`                                                   |
 | `enable_cover_image_cache`           | store album's cover images in the cache folder                                | `true`                                                     |
 | `default_device`                     | the default device to connect to on startup if no playing device found        | `spotify-player`                                           |
-| `play_icon`                          | the icon to indicate playing state of a Spotify item                          | `▶`                                                        |
+| `play_icon`                          | the icon to indicate playing state of a Spotify item                          | `▶`                                                       |
 | `pause_icon`                         | the icon to indicate pause state of a Spotify item                            | `▌▌`                                                       |
-| `liked_icon`                         | the icon to indicate the liked state of a song                                | `♥`                                                        |
+| `liked_icon`                         | the icon to indicate the liked state of a song                                | `♥`                                                       |
 | `border_type`                        | the type of the application's borders                                         | `Plain`                                                    |
 | `progress_bar_type`                  | the type of the playback progress bar                                         | `Rectangle`                                                |
 | `playback_window_position`           | the position of the playback window                                           | `Top`                                                      |
@@ -83,6 +86,30 @@ Media control support (`enable_media_control` option) is enabled by default on L
 
 MacOS and Windows require **an open window** to listen to OS media event. As a result, `spotify_player` needs to spawn an invisible window on startup, which may steal focus from the running terminal. To interact with `spotify_player`, which is run on the terminal, user will need to re-focus the terminal. Because of this extra re-focus step, the media control support is disabled by default on MacOS and Windows to avoid possible confusion for first-time users.
 
+### Player event hook command
+
+Similar to `copy_command`, if specified, `player_event_hook_command` should be a struct with two fields `command` and `args`. Each time `spotify_player` receives a new player event, `player_event_hook_command` is executed with the event as the **standard input**.
+
+A player event is a `json` string with either of the following values:
+
+- `{ Changed: { old_track_id: String, new_track_id: String } }`
+- `{ Playing: { track_id: String, position_ms: Number, duration_ms: Number } }`
+- `{ Paused: { track_id: String, position_ms: Number, duration_ms: Number } }`
+- `{ EndOfTrack: { track_id: String } }`
+
+Example `player_event_hook_command` script, which reads the event from **stdin**, parses the event as a `json` string, and writes the parsed event into a file:
+
+```python
+#!/usr/bin/python3
+
+import json
+
+player_event = json.loads(input())
+
+with open("/tmp/spotify_player_events.txt", "a") as f:
+    f.write(f"{player_event}\n")
+```
+
 ### Device configurations
 
 The configuration options for the [Librespot](https://github.com/librespot-org/librespot) integrated device are specified under the `[device]` section in the `app.toml` file:
diff --git a/examples/app.toml b/examples/app.toml
index 2d4115f3..369e613c 100644
--- a/examples/app.toml
+++ b/examples/app.toml
@@ -5,7 +5,7 @@ tracks_playback_limit = 50
 playback_format = "{track} • {artists}\n{album}\n{metadata}"
 notify_format = { summary = "{track} • {artists}", body = "{album}" }
 # the default `copy_command` is based on the OS
-copy_command = { command = "pbcopy", args = [] } # macos
+# copy_command = { command = "pbcopy", args = [] } # macos
 # copy_command = { command = "xclip", args = ["-sel", "c"] } # linux
 # copy_command = { command = "clip", args = [] } # windows
 app_refresh_duration_in_ms = 32
diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs
index bdd35691..5096efd6 100644
--- a/spotify_player/src/client/mod.rs
+++ b/spotify_player/src/client/mod.rs
@@ -82,6 +82,7 @@ impl Client {
             session,
             state.app_config.device.clone(),
             self.client_pub.clone(),
+            state.app_config.player_event_hook_command.clone(),
         );
 
         let mut stream_conn = self.stream_conn.lock();
diff --git a/spotify_player/src/config/mod.rs b/spotify_player/src/config/mod.rs
index 9f9c432d..80dd8152 100644
--- a/spotify_player/src/config/mod.rs
+++ b/spotify_player/src/config/mod.rs
@@ -26,6 +26,7 @@ pub struct AppConfig {
     pub client_port: u16,
 
     pub copy_command: Command,
+    pub player_event_hook_command: Option<Command>,
 
     pub playback_format: String,
     #[cfg(feature = "notify")]
@@ -197,6 +198,8 @@ impl Default for AppConfig {
                 args: vec![],
             },
 
+            player_event_hook_command: None,
+
             proxy: None,
             ap_port: None,
             app_refresh_duration_in_ms: 32,
diff --git a/spotify_player/src/streaming.rs b/spotify_player/src/streaming.rs
index c785885c..6e7dcadb 100644
--- a/spotify_player/src/streaming.rs
+++ b/spotify_player/src/streaming.rs
@@ -1,27 +1,119 @@
+use std::io::Write;
+
 use crate::{config, event::ClientRequest};
+use anyhow::Context;
 use librespot_connect::spirc::Spirc;
 use librespot_core::{
     config::{ConnectConfig, DeviceType},
     session::Session,
+    spotify_id,
 };
 use librespot_playback::mixer::MixerConfig;
 use librespot_playback::{
     audio_backend,
     config::{AudioFormat, Bitrate, PlayerConfig},
     mixer::{self, Mixer},
-    player::Player,
+    player,
 };
+use rspotify::model::TrackId;
+use serde::Serialize;
+
+#[derive(Debug, Serialize)]
+enum PlayerEvent {
+    Changed {
+        old_track_id: TrackId<'static>,
+        new_track_id: TrackId<'static>,
+    },
+    Playing {
+        track_id: TrackId<'static>,
+        position_ms: u32,
+        duration_ms: u32,
+    },
+    Paused {
+        track_id: TrackId<'static>,
+        position_ms: u32,
+        duration_ms: u32,
+    },
+    EndOfTrack {
+        track_id: TrackId<'static>,
+    },
+}
+
+fn spotify_id_to_track_id(id: spotify_id::SpotifyId) -> anyhow::Result<TrackId<'static>> {
+    let uri = id.to_uri()?;
+    Ok(TrackId::from_uri(&uri)?.into_static())
+}
+
+impl PlayerEvent {
+    pub fn from_librespot_player_event(e: player::PlayerEvent) -> anyhow::Result<Option<Self>> {
+        Ok(match e {
+            player::PlayerEvent::Changed {
+                old_track_id,
+                new_track_id,
+            } => Some(PlayerEvent::Changed {
+                old_track_id: spotify_id_to_track_id(old_track_id)?,
+                new_track_id: spotify_id_to_track_id(new_track_id)?,
+            }),
+            player::PlayerEvent::Playing {
+                track_id,
+                position_ms,
+                duration_ms,
+                ..
+            } => Some(PlayerEvent::Playing {
+                track_id: spotify_id_to_track_id(track_id)?,
+                position_ms,
+                duration_ms,
+            }),
+            player::PlayerEvent::Paused {
+                track_id,
+                position_ms,
+                duration_ms,
+                ..
+            } => Some(PlayerEvent::Paused {
+                track_id: spotify_id_to_track_id(track_id)?,
+                position_ms,
+                duration_ms,
+            }),
+            player::PlayerEvent::EndOfTrack { track_id, .. } => Some(PlayerEvent::EndOfTrack {
+                track_id: spotify_id_to_track_id(track_id)?,
+            }),
+            _ => None,
+        })
+    }
+}
+
+fn execute_player_event_hook_command(
+    cmd: &config::Command,
+    event: PlayerEvent,
+) -> anyhow::Result<()> {
+    let data = serde_json::to_vec(&event).context("serialize player event into json")?;
+
+    let mut child = std::process::Command::new(&cmd.command)
+        .args(&cmd.args)
+        .stdin(std::process::Stdio::piped())
+        .stdout(std::process::Stdio::piped())
+        .spawn()?;
+
+    let mut stdin = match child.stdin.take() {
+        Some(stdin) => stdin,
+        None => anyhow::bail!("no stdin found in the child command"),
+    };
+
+    stdin.write_all(&data)?;
+    Ok(())
+}
 
 /// Create a new streaming connection
 pub fn new_connection(
     session: Session,
     device: config::DeviceConfig,
     client_pub: flume::Sender<ClientRequest>,
+    player_event_hook_command: Option<config::Command>,
 ) -> Spirc {
-    // librespot volume is a u16 number ranging from 0 to 65535,
+    // `librespot` volume is a u16 number ranging from 0 to 65535,
     // while a percentage volume value (from 0 to 100) is used for the device configuration.
     // So we need to convert from one format to another
-    let volume = (std::cmp::min(device.volume, 100_u8) as f64 / 100.0 * 65535_f64).round() as u16;
+    let volume = (std::cmp::min(device.volume, 100_u8) as f64 / 100.0 * 65535.0).round() as u16;
 
     let connect_config = ConnectConfig {
         name: device.name,
@@ -55,7 +147,7 @@ pub fn new_connection(
         session.device_id()
     );
 
-    let (player, mut channel) = Player::new(
+    let (player, mut channel) = player::Player::new(
         player_config,
         session.clone(),
         mixer.get_soft_volume(),
@@ -65,11 +157,30 @@ pub fn new_connection(
     let player_event_task = tokio::task::spawn({
         async move {
             while let Some(event) = channel.recv().await {
-                tracing::info!("Got an event from the integrated player: {:?}", event);
-                client_pub
-                    .send_async(ClientRequest::GetCurrentPlayback)
-                    .await
-                    .unwrap_or_default();
+                match PlayerEvent::from_librespot_player_event(event) {
+                    Err(err) => {
+                        tracing::warn!("Failed to convert a `librespot` player event into `spotify_player` player event: {err:#}");
+                    }
+                    Ok(Some(event)) => {
+                        tracing::info!("Got a new player event: {event:?}");
+
+                        // execute a player event hook command
+                        if let Some(ref cmd) = player_event_hook_command {
+                            if let Err(err) = execute_player_event_hook_command(cmd, event) {
+                                tracing::warn!(
+                                    "Failed to execute player event hook command: {err:#}"
+                                );
+                            }
+                        }
+
+                        // notify the application about the new player event by making playback update request
+                        client_pub
+                            .send_async(ClientRequest::GetCurrentPlayback)
+                            .await
+                            .unwrap_or_default();
+                    }
+                    Ok(None) => {}
+                }
             }
         }
     });


Test patch: