Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8b0f69f
fix: fix clippy needless-borrow on discovery lib
paulfariello Sep 23, 2025
fc6d797
Allow cloning SPIRC
wisp3rwind Sep 17, 2024
52c371f
add release date to AudioItem
wisp3rwind Sep 17, 2024
64f0606
add Spirc.seek_offset command
wisp3rwind Sep 17, 2024
194cd70
add initial MPRIS support using zbus
wisp3rwind Oct 1, 2024
14c06c7
feat(mpris): serve identity based on configured name
paulfariello Sep 23, 2025
8f98a34
feat(mpris): Add set_volume handler
paulfariello Sep 23, 2025
c32bc88
feat(mpris): Retry with pid specific name on NameTaken error
paulfariello Sep 23, 2025
e9987c9
feat(player): Send current state of player for all new player listeners
paulfariello Sep 23, 2025
fdc4e99
feat(mpris): Notify when volume changed
paulfariello Sep 23, 2025
0e933f6
fix(mpris): Remove done todo
paulfariello Sep 23, 2025
121b4fd
fix(mpris): Remove duplicated and commented function
paulfariello Sep 23, 2025
29cd843
fix(mpris): Add comment concerning non-support of setting playback rate
paulfariello Sep 23, 2025
605553a
feat(mpris): Store metadata unserialized
paulfariello Sep 25, 2025
6748e45
feat(mpris): Add debug logging
paulfariello Sep 25, 2025
8297fdd
feat(mpris): Send biggest art url
paulfariello Sep 25, 2025
90cd456
feat(mpris): Update track id on EndOfTrack
paulfariello Sep 25, 2025
8e15bef
feat(player): Add position update option
paulfariello Sep 30, 2025
afd6e8c
feat(mpris): Get position from player and provide it to MPRIS
paulfariello Sep 30, 2025
da6ede9
feat(mpris): Check track_id when setting position
paulfariello Sep 30, 2025
f583f10
chore(mpris): Remove useless comment
paulfariello Sep 30, 2025
1e4dd30
feat(mpris): Add support for desktop entry
paulfariello Sep 30, 2025
d500285
feat(mpris): Return error when trying to play/pause in wrong context
paulfariello Sep 30, 2025
b60fb08
feat(mpris): Signal when position changed
paulfariello Sep 30, 2025
c6f096b
chore(mpris): alias zbus::fdo::{Error, Result} for readability
paulfariello Oct 1, 2025
d50b902
feat(player): Allow for stopped event without track_id
paulfariello Oct 1, 2025
043e82c
feat(player): Rename position update interval option
paulfariello Oct 1, 2025
0950bd5
feat(mpris): Upgrade to zbus 5
paulfariello Oct 2, 2025
17891cf
feat(player): Add position_ms in Loading event
paulfariello Oct 2, 2025
32ffb1a
feat(changelog): Add mpris changelog
paulfariello Oct 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- [core] Add `SpotifyUri` type to represent more types of URI than `SpotifyId` can
- [dbus/mpris] Add dbus/mpris support to allow controlling player

### Changed

Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ repository = "https://github.com/librespot-org/librespot"
edition = "2024"

[features]
default = ["native-tls", "rodio-backend", "with-libmdns"]
default = ["native-tls", "rodio-backend", "with-libmdns", "with-mpris"]

# TLS backends (mutually exclusive - compile-time checks in oauth/src/lib.rs)
# Note: Feature validation is in oauth crate since it's compiled first in the dependency tree.
Expand Down Expand Up @@ -133,6 +133,10 @@ with-dns-sd = ["librespot-discovery/with-dns-sd"]
# data.
passthrough-decoder = ["librespot-playback/passthrough-decoder"]

# MPRIS: Allow external tool to have access to playback
# status, metadata and to control the player.
with-mpris = ["dep:zbus", "dep:zvariant"]

[lib]
name = "librespot"
path = "src/lib.rs"
Expand Down Expand Up @@ -181,7 +185,10 @@ tokio = { version = "1", features = [
"sync",
"process",
] }
time = { version = "0.3", features = ["formatting"] }
url = "2.2"
zbus = { version = "5", default-features = false, features = ["tokio"], optional = true }
zvariant = { version = "5", default-features = false, optional = true }

[package.metadata.deb]
maintainer = "Librespot Organization <[email protected]>"
Expand Down
29 changes: 29 additions & 0 deletions connect/src/spirc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ enum SpircCommand {
RepeatTrack(bool),
Disconnect { pause: bool },
SetPosition(u32),
SeekOffset(i32),
SetVolume(u16),
Activate,
Load(LoadRequest),
Expand All @@ -139,6 +140,7 @@ const VOLUME_UPDATE_DELAY: Duration = Duration::from_millis(500);
const UPDATE_STATE_DELAY: Duration = Duration::from_millis(200);

/// The spotify connect handle
#[derive(Clone)]
pub struct Spirc {
commands: mpsc::UnboundedSender<SpircCommand>,
}
Expand Down Expand Up @@ -384,6 +386,13 @@ impl Spirc {
Ok(self.commands.send(SpircCommand::Load(command))?)
}

/// Seek to given offset.
///
/// Does nothing if we are not the active device.
pub fn seek_offset(&self, offset_ms: i32) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::SeekOffset(offset_ms))?)
}

/// Disconnects the current device and pauses the playback according the value.
///
/// Does nothing if we are not the active device.
Expand Down Expand Up @@ -650,6 +659,7 @@ impl SpircTask {
SpircCommand::Repeat(repeat) => self.handle_repeat_context(repeat)?,
SpircCommand::RepeatTrack(repeat) => self.handle_repeat_track(repeat),
SpircCommand::SetPosition(position) => self.handle_seek(position),
SpircCommand::SeekOffset(offset) => self.handle_seek_offset(offset),
SpircCommand::SetVolume(volume) => self.set_volume(volume),
SpircCommand::Load(command) => self.handle_load(command, None).await?,
};
Expand Down Expand Up @@ -1460,6 +1470,25 @@ impl SpircTask {
};
}

fn handle_seek_offset(&mut self, offset_ms: i32) {
let position_ms = match self.play_status {
SpircPlayStatus::Stopped => return,
SpircPlayStatus::LoadingPause { position_ms }
| SpircPlayStatus::LoadingPlay { position_ms }
| SpircPlayStatus::Paused { position_ms, .. } => position_ms,
SpircPlayStatus::Playing {
nominal_start_time, ..
} => {
let now = self.now_ms();
(now - nominal_start_time) as u32
}
};

let position_ms = ((position_ms as i32) + offset_ms).max(0) as u32;

self.handle_seek(position_ms);
}

fn handle_shuffle(&mut self, shuffle: bool) -> Result<(), Error> {
self.player.emit_shuffle_changed_event(shuffle);
self.connect_state.handle_shuffle(shuffle)
Expand Down
2 changes: 1 addition & 1 deletion discovery/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ fn launch_libmdns(
}
.map_err(|e| DiscoveryError::DnsSdError(Box::new(e)))?;

let svc = responder.register(&DNS_SD_SERVICE_NAME, &name, port, &TXT_RECORD);
let svc = responder.register(DNS_SD_SERVICE_NAME, &name, port, &TXT_RECORD);

let _ = shutdown_rx.blocking_recv();

Expand Down
4 changes: 4 additions & 0 deletions metadata/src/audio/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pub enum UniqueFields {
Track {
artists: ArtistsWithRole,
album: String,
album_date: Date,
album_artists: Vec<String>,
popularity: u8,
number: u32,
Expand Down Expand Up @@ -80,6 +81,8 @@ impl AudioItem {
let uri_string = uri.to_uri()?;
let album = track.album.name;

let album_date = track.album.date;

let album_artists = track
.album
.artists
Expand Down Expand Up @@ -113,6 +116,7 @@ impl AudioItem {
let unique_fields = UniqueFields::Track {
artists: track.artists_with_role,
album,
album_date,
album_artists,
popularity,
number,
Expand Down
66 changes: 60 additions & 6 deletions playback/src/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ pub enum PlayerEvent {
},
// Fired when the player is stopped (e.g. by issuing a "stop" command to the player).
Stopped {
play_request_id: u64,
track_id: SpotifyUri,
play_request_id: Option<u64>,
track_id: Option<SpotifyUri>,
},
// The player is delayed by loading a track.
Loading {
Expand Down Expand Up @@ -267,7 +267,8 @@ impl PlayerEvent {
play_request_id, ..
}
| Stopped {
play_request_id, ..
play_request_id: Some(play_request_id),
..
}
| PositionCorrection {
play_request_id, ..
Expand Down Expand Up @@ -679,6 +680,7 @@ enum PlayerState {
play_request_id: u64,
start_playback: bool,
loader: Pin<Box<dyn FusedFuture<Output = Result<PlayerLoadedTrackData, ()>> + Send>>,
position_ms: u32,
},
Paused {
track_id: SpotifyUri,
Expand Down Expand Up @@ -1226,6 +1228,7 @@ impl Future for PlayerInternal {
ref track_id,
start_playback,
play_request_id,
..
} = self.state
{
// The loader may be terminated if we are trying to load the same track
Expand Down Expand Up @@ -1541,8 +1544,8 @@ impl PlayerInternal {

self.ensure_sink_stopped(false);
self.send_event(PlayerEvent::Stopped {
track_id,
play_request_id,
track_id: Some(track_id),
play_request_id: Some(play_request_id),
});
self.state = PlayerState::Stopped;
}
Expand Down Expand Up @@ -2020,6 +2023,7 @@ impl PlayerInternal {
play_request_id,
start_playback: play,
loader,
position_ms,
};

Ok(())
Expand Down Expand Up @@ -2163,7 +2167,57 @@ impl PlayerInternal {

PlayerCommand::SetSession(session) => self.session = session,

PlayerCommand::AddEventSender(sender) => self.event_senders.push(sender),
PlayerCommand::AddEventSender(sender) => {
// Send current player state to new event listener
match self.state {
PlayerState::Loading {
ref track_id,
play_request_id,
position_ms,
..
} => {
let _ = sender.send(PlayerEvent::Loading {
play_request_id,
track_id: track_id.clone(),
position_ms,
});
}
PlayerState::Paused {
ref track_id,
play_request_id,
stream_position_ms,
..
} => {
let _ = sender.send(PlayerEvent::Paused {
play_request_id,
track_id: track_id.clone(),
position_ms: stream_position_ms,
});
}
PlayerState::Playing { ref audio_item, .. } => {
let audio_item = Box::new(audio_item.clone());
let _ = sender.send(PlayerEvent::TrackChanged { audio_item });
}
PlayerState::EndOfTrack {
play_request_id,
ref track_id,
..
} => {
let _ = sender.send(PlayerEvent::EndOfTrack {
play_request_id,
track_id: track_id.clone(),
});
}
PlayerState::Invalid | PlayerState::Stopped => {
let _ = sender.send(PlayerEvent::Stopped {
play_request_id: None,
track_id: None,
});
}
}

self.event_senders.push(sender);
}

PlayerCommand::SetSinkEventCallback(callback) => self.sink_event_callback = callback,

Expand Down
46 changes: 45 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ use url::Url;
mod player_event_handler;
use player_event_handler::{EventHandler, run_program_on_sink_events};

#[cfg(feature = "with-mpris")]
mod mpris_event_handler;
#[cfg(feature = "with-mpris")]
use mpris_event_handler::MprisEventHandler;

fn device_id(name: &str) -> String {
HEXLOWER.encode(&Sha1::digest(name.as_bytes()))
}
Expand Down Expand Up @@ -270,6 +275,7 @@ async fn get_setup() -> Setup {
#[cfg(feature = "passthrough-decoder")]
const PASSTHROUGH: &str = "passthrough";
const PASSWORD: &str = "password";
const POSITION_UPDATE_INTERVAL: &str = "position-update-interval";
const PROXY: &str = "proxy";
const QUIET: &str = "quiet";
const SYSTEM_CACHE: &str = "system-cache";
Expand Down Expand Up @@ -315,6 +321,7 @@ async fn get_setup() -> Setup {
#[cfg(feature = "passthrough-decoder")]
const PASSTHROUGH_SHORT: &str = "P";
const PASSWORD_SHORT: &str = "p";
const POSITION_UPDATE_INTERVAL_SHORT: &str = ""; // no short flag
const EMIT_SINK_EVENTS_SHORT: &str = "Q";
const QUIET_SHORT: &str = "q";
const INITIAL_VOLUME_SHORT: &str = "R";
Expand Down Expand Up @@ -625,6 +632,12 @@ async fn get_setup() -> Setup {
"Knee width (dB) of the dynamic limiter from 0.0 to 10.0. Defaults to 5.0.",
"KNEE",
)
.optopt(
POSITION_UPDATE_INTERVAL_SHORT,
POSITION_UPDATE_INTERVAL,
"Maximum interval in ms for player to send a position event. Defaults to no forced position update.",
"POSITION_UPDATE",
)
.optopt(
ZEROCONF_PORT_SHORT,
ZEROCONF_PORT,
Expand Down Expand Up @@ -1800,6 +1813,22 @@ async fn get_setup() -> Setup {
},
};

let position_update_interval = opt_str(POSITION_UPDATE_INTERVAL).as_deref().map(
|position_update| match position_update.parse::<u64>() {
Ok(value) => Duration::from_millis(value),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe also add a lower bound here? Either by returning an error if lower, or maybe better by simply taking the value.min(MIN_INTERVAL)? (Not sure what value should be, a few ms at least?) Or at least require it to be nonzero?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any idea, what sensible value should we use? 100ms seems quite sensible lower bound.

_ => {
invalid_error_msg(
POSITION_UPDATE_INTERVAL,
POSITION_UPDATE_INTERVAL_SHORT,
position_update,
"Integer value in ms",
"None",
);
exit(1);
}
},
);

#[cfg(feature = "passthrough-decoder")]
let passthrough = opt_present(PASSTHROUGH);
#[cfg(not(feature = "passthrough-decoder"))]
Expand All @@ -1818,7 +1847,7 @@ async fn get_setup() -> Setup {
normalisation_release_cf,
normalisation_knee_db,
ditherer,
position_update_interval: None,
position_update_interval,
}
};

Expand Down Expand Up @@ -1991,6 +2020,14 @@ async fn main() {
}
}

#[cfg(feature = "with-mpris")]
let mpris = MprisEventHandler::spawn(player.clone(), &setup.connect_config.name, None)
.await
.unwrap_or_else(|e| {
error!("could not initialize MPRIS: {e}");
exit(1);
});

loop {
tokio::select! {
credentials = async {
Expand Down Expand Up @@ -2044,6 +2081,10 @@ async fn main() {
exit(1);
}
};

#[cfg(feature = "with-mpris")]
mpris.set_spirc(spirc_.clone());

spirc = Some(spirc_);
spirc_task = Some(Box::pin(spirc_task_));

Expand Down Expand Up @@ -2089,6 +2130,9 @@ async fn main() {

let mut shutdown_tasks = tokio::task::JoinSet::new();

#[cfg(feature = "with-mpris")]
shutdown_tasks.spawn(mpris.quit_and_join());

// Shutdown spirc if necessary
if let Some(spirc) = spirc {
if let Err(e) = spirc.shutdown() {
Expand Down
Loading