From a3abb075791456d572ee15da1c5a59542b8088ac Mon Sep 17 00:00:00 2001 From: Jay Malhotra Date: Sun, 10 Aug 2025 13:25:54 +0100 Subject: [PATCH 01/11] feat: Add audio/local to device capabilities Spotify Connect does not allow you to move playback of a local file to the librespot device as it says that it "can't play this track". Note that this is slightly inconsistent as Spotify allows you to switch to a local file if librespot is already playing a non-local file, which currently fails with an error. However, it is possible for the desktop and iOS client to accept playback of local files. In looking at the PUT request sent to `connect-state/v1/devices/` from the iOS client, it can be seen that it includes `audio/local` as an entry in the `supported_types` capability field. This commit introduces this field to the capabilities that librespot sends. For now, it is a complete lie as we do not support local file playback, but it will make the ongoing development of this feature easier, as we will not have to queue up a non-local track and attempt to switch to a local one. Testing shows that with this flag the "can't play this track" message disappears and allows librespot to (attempt) to play a local file before erroring out. --- CHANGELOG.md | 2 ++ connect/src/state.rs | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a11d932e4..96a3b7bef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,12 +70,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [connect] Add `pause` parameter to `Spirc::disconnect` method (breaking) - [connect] Add `volume_steps` to `ConnectConfig` (breaking) - [connect] Add and enforce rustdoc +- [connect] Add `audio/local` to the `supported_types` field of the device capabilities. - [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking) - [playback] Add `PlayerEvent::PositionChanged` event to notify about the current playback position - [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient` - [core] Add `try_get_urls` to `CdnUrl` - [oauth] Add `OAuthClient` and `OAuthClientBuilder` structs to achieve a more customizable login process + ### Fixed - [test] Missing bindgen breaks crossbuild on recent runners. Now installing latest bindgen in addition. diff --git a/connect/src/state.rs b/connect/src/state.rs index a84de2346..b4ee61b2f 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -161,7 +161,11 @@ impl ConnectState { supports_gzip_pushes: true, // todo: enable after logout handling is implemented, see spirc logout_request supports_logout: false, - supported_types: vec!["audio/episode".into(), "audio/track".into()], + supported_types: vec![ + "audio/episode".into(), + "audio/track".into(), + "audio/local".into(), + ], supports_playlist_v2: true, supports_transfer_command: true, supports_command_request: true, From f32a597c32f8fc8f0fec9abf0a0c53f8573e0bfa Mon Sep 17 00:00:00 2001 From: Jay Malhotra Date: Tue, 9 Sep 2025 21:34:52 +0100 Subject: [PATCH 02/11] feat: Add minimal local file support --- CHANGELOG.md | 11 +- Cargo.lock | 1 + audio/src/fetch/mod.rs | 8 + connect/src/spirc.rs | 6 + connect/src/state/context.rs | 3 - core/src/spotify_uri.rs | 59 +++++-- metadata/src/audio/item.rs | 12 +- playback/Cargo.toml | 1 + playback/src/config.rs | 5 +- playback/src/decoder/symphonia_decoder.rs | 136 +++++++++++++++- playback/src/lib.rs | 1 + playback/src/local_file.rs | 182 ++++++++++++++++++++++ playback/src/player.rs | 122 ++++++++++++++- src/main.rs | 14 ++ src/player_event_handler.rs | 22 +++ 15 files changed, 551 insertions(+), 32 deletions(-) create mode 100644 playback/src/local_file.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a3b7bef..c056fe4b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,13 @@ 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 - +- [playback] Local files can now be played with the following caveats: + - They must be sampled at 44,100 Hz + - They cannot be played from a Connect device using the dedicated 'Local Files' playlist; they must be added to another playlist first +- [playback] `--local-file-dir` / `-l` option added to binary to specify local file directories to pull from +- [playback] `local_file_directories` field added to `PlayerConfig` struct (breaking) +- [metadata] `Local` variant added to `UniqueFields` enum (breaking) + ### Changed - [playback] Changed type of `SpotifyId` fields in `PlayerEvent` members to `SpotifyUri` (breaking) @@ -19,7 +25,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [player] `preload` function changed from accepting a `SpotifyId` to accepting a `SpotifyUri` (breaking) - [spclient] `get_radio_for_track` function changed from accepting a `SpotifyId` to accepting a `SpotifyUri` (breaking) - ### Removed - [core] Removed `SpotifyItemType` enum; the new `SpotifyUri` is an enum over all item types and so which variant it is @@ -70,14 +75,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [connect] Add `pause` parameter to `Spirc::disconnect` method (breaking) - [connect] Add `volume_steps` to `ConnectConfig` (breaking) - [connect] Add and enforce rustdoc -- [connect] Add `audio/local` to the `supported_types` field of the device capabilities. - [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking) - [playback] Add `PlayerEvent::PositionChanged` event to notify about the current playback position - [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient` - [core] Add `try_get_urls` to `CdnUrl` - [oauth] Add `OAuthClient` and `OAuthClientBuilder` structs to achieve a more customizable login process - ### Fixed - [test] Missing bindgen breaks crossbuild on recent runners. Now installing latest bindgen in addition. diff --git a/Cargo.lock b/Cargo.lock index 7e2744964..43b54e8cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2040,6 +2040,7 @@ dependencies = [ "portaudio-rs", "rand 0.9.2", "rand_distr", + "regex", "rodio", "sdl2", "shell-words", diff --git a/audio/src/fetch/mod.rs b/audio/src/fetch/mod.rs index 6a6379b96..5b87d2459 100644 --- a/audio/src/fetch/mod.rs +++ b/audio/src/fetch/mod.rs @@ -300,6 +300,14 @@ impl StreamLoaderController { // terminate stream loading and don't load any more data for this file. self.send_stream_loader_command(StreamLoaderCommand::Close); } + + pub fn from_local_file(file_size: u64) -> Result { + Ok(Self { + channel_tx: None, + stream_shared: None, + file_size: file_size as usize, + }) + } } pub struct AudioFileStreaming { diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 43702d8a4..2e7326ad0 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -531,7 +531,13 @@ impl SpircTask { // finish after we received our last item of a type next_context = async { self.context_resolver.get_next_context(|| { + // Sending local file URIs to this endpoint results in a Bad Request status. + // It's likely appropriate to filter them out anyway; Spotify's backend + // has no knowledge about these tracks and so can't do anything with them. self.connect_state.recent_track_uris() + .into_iter() + .filter(|t| !t.starts_with("spotify:local")) + .collect::>() }).await }, if allow_context_resolving && self.context_resolver.has_next() => { let update_state = self.handle_next_context(next_context); diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index e2b78720e..379b8d9db 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -446,9 +446,6 @@ impl ConnectState { provider: Option, ) -> Result { let id = match (ctx_track.uri.as_ref(), ctx_track.gid.as_ref()) { - (Some(uri), _) if uri.contains(['?', '%']) => { - Err(StateError::InvalidTrackUri(Some(uri.clone())))? - } (Some(uri), _) if !uri.is_empty() => SpotifyUri::from_uri(uri)?, (_, Some(gid)) if !gid.is_empty() => SpotifyUri::Track { id: SpotifyId::from_raw(gid)?, diff --git a/core/src/spotify_uri.rs b/core/src/spotify_uri.rs index 647ec652b..de7cb6b35 100644 --- a/core/src/spotify_uri.rs +++ b/core/src/spotify_uri.rs @@ -1,5 +1,5 @@ use crate::{Error, SpotifyId}; -use std::{borrow::Cow, fmt}; +use std::{borrow::Cow, fmt, str::FromStr, time::Duration}; use thiserror::Error; use librespot_protocol as protocol; @@ -65,7 +65,10 @@ pub enum SpotifyUri { impl SpotifyUri { /// Returns whether this `SpotifyUri` is for a playable audio item, if known. pub fn is_playable(&self) -> bool { - matches!(self, SpotifyUri::Episode { .. } | SpotifyUri::Track { .. }) + matches!( + self, + SpotifyUri::Episode { .. } | SpotifyUri::Track { .. } | SpotifyUri::Local { .. } + ) } /// Gets the item type of this URI as a static string @@ -147,6 +150,7 @@ impl SpotifyUri { }; let name = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; + match item_type { SPOTIFY_ITEM_TYPE_ALBUM => Ok(Self::Album { id: SpotifyId::from_base62(name)?, @@ -167,12 +171,23 @@ impl SpotifyUri { SPOTIFY_ITEM_TYPE_TRACK => Ok(Self::Track { id: SpotifyId::from_base62(name)?, }), - SPOTIFY_ITEM_TYPE_LOCAL => Ok(Self::Local { - artist: "unimplemented".to_owned(), - album_title: "unimplemented".to_owned(), - track_title: "unimplemented".to_owned(), - duration: Default::default(), - }), + SPOTIFY_ITEM_TYPE_LOCAL => { + let artist = name; + let album_title = parts.next().unwrap_or_default(); + // enforce track_title exists; spotify:local::: is a silly URI + let track_title = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; + let duration_secs = parts + .next() + .and_then(|f| u64::from_str(f).ok()) + .ok_or(SpotifyUriError::InvalidFormat)?; + + Ok(Self::Local { + artist: artist.to_owned(), + album_title: album_title.to_owned(), + track_title: track_title.to_owned(), + duration: Duration::from_secs(duration_secs), + }) + } _ => Ok(Self::Unknown { kind: item_type.to_owned().into(), id: name.to_owned(), @@ -533,15 +548,33 @@ mod tests { #[test] fn from_local_uri() { - let actual = SpotifyUri::from_uri("spotify:local:xyz:123").unwrap(); + let actual = SpotifyUri::from_uri( + "spotify:local:David+Wise:Donkey+Kong+Country%3A+Tropical+Freeze:Snomads+Island:127", + ) + .unwrap(); + + assert_eq!( + actual, + SpotifyUri::Local { + artist: "David+Wise".to_owned(), + album_title: "Donkey+Kong+Country%3A+Tropical+Freeze".to_owned(), + track_title: "Snomads+Island".to_owned(), + duration: Duration::from_secs(127), + } + ); + } + + #[test] + fn from_local_uri_missing_fields() { + let actual = SpotifyUri::from_uri("spotify:local:::Snomads+Island:127").unwrap(); assert_eq!( actual, SpotifyUri::Local { - artist: "unimplemented".to_owned(), - album_title: "unimplemented".to_owned(), - track_title: "unimplemented".to_owned(), - duration: Default::default(), + artist: "".to_owned(), + album_title: "".to_owned(), + track_title: "Snomads+Island".to_owned(), + duration: Duration::from_secs(127), } ); } diff --git a/metadata/src/audio/item.rs b/metadata/src/audio/item.rs index 3df63d9e8..2a13d8b74 100644 --- a/metadata/src/audio/item.rs +++ b/metadata/src/audio/item.rs @@ -1,4 +1,4 @@ -use std::fmt::Debug; +use std::{fmt::Debug, path::PathBuf}; use crate::{ Metadata, @@ -50,6 +50,16 @@ pub enum UniqueFields { number: u32, disc_number: u32, }, + Local { + // artists / album_artists can't be a Vec here, they are retrieved from metadata as a String, + // and we cannot make any assumptions about them being e.g. comma-separated + artists: String, + album: String, + album_artists: String, + number: u32, + disc_number: u32, + path: PathBuf, + }, Episode { description: String, publish_time: Date, diff --git a/playback/Cargo.toml b/playback/Cargo.toml index d8414a817..7d8cf6cc8 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -94,3 +94,4 @@ ogg = { version = "0.9", optional = true } # Dithering rand = { version = "0.9", default-features = false, features = ["small_rng"] } rand_distr = "0.5" +regex = "1.11.2" diff --git a/playback/src/config.rs b/playback/src/config.rs index a747ce38d..f919df190 100644 --- a/playback/src/config.rs +++ b/playback/src/config.rs @@ -1,4 +1,4 @@ -use std::{mem, str::FromStr, time::Duration}; +use std::{mem, path::PathBuf, str::FromStr, time::Duration}; pub use crate::dither::{DithererBuilder, TriangularDitherer, mk_ditherer}; use crate::{convert::i24, player::duration_to_coefficient}; @@ -136,6 +136,8 @@ pub struct PlayerConfig { pub normalisation_release_cf: f64, pub normalisation_knee_db: f64, + pub local_file_directories: Vec, + // pass function pointers so they can be lazily instantiated *after* spawning a thread // (thereby circumventing Send bounds that they might not satisfy) pub ditherer: Option, @@ -160,6 +162,7 @@ impl Default for PlayerConfig { passthrough: false, ditherer: Some(mk_ditherer::), position_update_interval: None, + local_file_directories: Vec::new(), } } } diff --git a/playback/src/decoder/symphonia_decoder.rs b/playback/src/decoder/symphonia_decoder.rs index 2aa2a3e08..8222752e0 100644 --- a/playback/src/decoder/symphonia_decoder.rs +++ b/playback/src/decoder/symphonia_decoder.rs @@ -1,6 +1,8 @@ use std::{io, time::Duration}; use symphonia::{ + core::meta::{Metadata, MetadataOptions}, + core::probe::{Hint, ProbedMetadata}, core::{ audio::SampleBuffer, codecs::{Decoder, DecoderOptions}, @@ -27,6 +29,18 @@ pub struct SymphoniaDecoder { format: Box, decoder: Box, sample_buffer: Option>, + probed_metadata: Option, +} + +#[derive(Default)] +pub(crate) struct LocalFileMetadata { + pub name: String, + pub language: String, + pub album: String, + pub artists: String, + pub album_artists: String, + pub number: u32, + pub disc_number: u32, } impl SymphoniaDecoder { @@ -98,20 +112,62 @@ impl SymphoniaDecoder { // We set the sample buffer when decoding the first full packet, // whose duration is also the ideal sample buffer size. sample_buffer: None, + + probed_metadata: None, }) } - pub fn normalisation_data(&mut self) -> Option { - let mut metadata = self.format.metadata(); + pub(crate) fn new_with_probe(src: R, extension: Option<&str>) -> DecoderResult + where + R: MediaSource + 'static, + { + let mss = MediaSourceStream::new(Box::new(src), Default::default()); - // Advance to the latest metadata revision. - // None means we hit the latest. - loop { - if metadata.pop().is_none() { - break; - } + let mut hint = Hint::new(); + + if let Some(extension) = extension { + hint.with_extension(extension); + } + + let format_opts: FormatOptions = Default::default(); + let metadata_opts: MetadataOptions = Default::default(); + let decoder_opts: DecoderOptions = Default::default(); + + let probed = + symphonia::default::get_probe().format(&hint, mss, &format_opts, &metadata_opts)?; + + let format = probed.format; + + let track = format.default_track().ok_or_else(|| { + DecoderError::SymphoniaDecoder("Could not retrieve default track".into()) + })?; + + let decoder = symphonia::default::get_codecs().make(&track.codec_params, &decoder_opts)?; + + let rate = decoder.codec_params().sample_rate.ok_or_else(|| { + DecoderError::SymphoniaDecoder("Could not retrieve sample rate".into()) + })?; + + // TODO: The official client supports local files with sample rates other than 44,100 kHz. + // To play these accurately, we need to either resample the input audio, or introduce a way + // to change the player's current sample rate (likely by closing and re-opening the sink + // with new parameters). + if rate != SAMPLE_RATE { + return Err(DecoderError::SymphoniaDecoder(format!( + "Unsupported sample rate: {rate}. Local files must have a sample rate of {SAMPLE_RATE} Hz." + ))); } + Ok(Self { + format, + decoder, + sample_buffer: None, + probed_metadata: Some(probed.metadata), + }) + } + + pub fn normalisation_data(&mut self) -> Option { + let metadata = self.metadata()?; let tags = metadata.current()?.tags(); if tags.is_empty() { @@ -135,6 +191,70 @@ impl SymphoniaDecoder { } } + pub(crate) fn local_file_metadata(&mut self) -> Option { + let metadata = self.metadata()?; + let tags = metadata.current()?.tags(); + let mut metadata = LocalFileMetadata::default(); + + for tag in tags { + if let Value::String(value) = &tag.value { + match tag.std_key { + // We could possibly use mem::take here to avoid cloning, but that risks leaving + // the audio item metadata in a bad state. + Some(StandardTagKey::TrackTitle) => metadata.name = value.clone(), + Some(StandardTagKey::Language) => metadata.language = value.clone(), + Some(StandardTagKey::Artist) => metadata.artists = value.clone(), + Some(StandardTagKey::AlbumArtist) => metadata.album_artists = value.clone(), + Some(StandardTagKey::Album) => metadata.album = value.clone(), + Some(StandardTagKey::TrackNumber) => { + metadata.number = value.parse::().unwrap_or_default() + } + Some(StandardTagKey::DiscNumber) => { + metadata.disc_number = value.parse::().unwrap_or_default() + } + _ => (), + } + } else if let Value::UnsignedInt(value) = &tag.value { + match tag.std_key { + Some(StandardTagKey::TrackNumber) => metadata.number = *value as u32, + Some(StandardTagKey::DiscNumber) => metadata.disc_number = *value as u32, + _ => (), + } + } else if let Value::SignedInt(value) = &tag.value { + match tag.std_key { + Some(StandardTagKey::TrackNumber) => metadata.number = *value as u32, + Some(StandardTagKey::DiscNumber) => metadata.disc_number = *value as u32, + _ => (), + } + } + } + + Some(metadata) + } + + fn metadata(&mut self) -> Option { + let mut metadata = self.format.metadata(); + + // If we can't get metadata from the container, fall back to other tags found by probing. + // Note that this is only relevant for local files. + if metadata.current().is_none() + && let Some(ref mut probe_metadata) = self.probed_metadata + && let Some(inner_probe_metadata) = probe_metadata.get() + { + metadata = inner_probe_metadata; + } + + // Advance to the latest metadata revision. + // None means we hit the latest. + loop { + if metadata.pop().is_none() { + break; + } + } + + Some(metadata) + } + #[inline] fn ts_to_ms(&self, ts: u64) -> u32 { match self.decoder.codec_params().time_base { diff --git a/playback/src/lib.rs b/playback/src/lib.rs index 43a5b4f0c..c05cc2f9e 100644 --- a/playback/src/lib.rs +++ b/playback/src/lib.rs @@ -10,6 +10,7 @@ pub mod config; pub mod convert; pub mod decoder; pub mod dither; +mod local_file; pub mod mixer; pub mod player; diff --git a/playback/src/local_file.rs b/playback/src/local_file.rs new file mode 100644 index 000000000..9d961e326 --- /dev/null +++ b/playback/src/local_file.rs @@ -0,0 +1,182 @@ +use librespot_core::{Error, SpotifyUri}; +use regex::{Captures, Regex}; +use std::sync::LazyLock; +use std::{ + collections::HashMap, + fs, + fs::File, + io, + path::{Path, PathBuf}, + time::Duration, +}; +use symphonia::{ + core::formats::FormatOptions, + core::io::MediaSourceStream, + core::meta::{MetadataOptions, StandardTagKey, Tag}, + core::probe::{Hint, ProbeResult}, +}; + +// "Spotify supports .mp3, .mp4, and .m4p files. It doesn’t support .mp4 files that contain video, +// or the iTunes lossless format (M4A)." +// https://community.spotify.com/t5/FAQs/Local-Files/ta-p/5186118 +// +// There are some indications online that FLAC is supported, so check for this as well. +const SUPPORTED_FILE_EXTENSIONS: &[&str; 4] = &["mp3", "mp4", "m4p", "flac"]; + +#[derive(Default)] +pub struct LocalFileLookup(HashMap); + +impl LocalFileLookup { + pub fn get(&self, uri: &SpotifyUri) -> Option<&Path> { + self.0.get(uri).map(|p| p.as_path()) + } +} + +pub fn create_local_file_lookup(directories: &[PathBuf]) -> LocalFileLookup { + let mut lookup = LocalFileLookup(HashMap::new()); + + for path in directories { + if !path.is_dir() { + warn!( + "Ignoring local file source {}: not a directory", + path.display() + ); + continue; + } + + if let Err(e) = visit_dir(path, &mut lookup) { + warn!( + "Failed to load entries from local file source {}: {}", + path.display(), + e + ); + } + } + + lookup +} + +fn visit_dir(dir: &Path, accumulator: &mut LocalFileLookup) -> io::Result<()> { + for entry in fs::read_dir(dir)? { + let path = entry?.path(); + if path.is_dir() { + visit_dir(&path, accumulator)?; + } else { + let Some(extension) = path.extension().and_then(|e| e.to_str()) else { + continue; + }; + + let lowercase_extension = extension.to_lowercase(); + + if SUPPORTED_FILE_EXTENSIONS.contains(&lowercase_extension.as_str()) { + let uri = match get_uri_from_file(path.as_path(), extension) { + Ok(uri) => uri, + Err(e) => { + warn!( + "Failed to determine URI of local file {}: {}", + path.display(), + e + ); + continue; + } + }; + + accumulator.0.insert(uri, path); + } + } + } + + Ok(()) +} + +fn get_uri_from_file(audio_path: &Path, extension: &str) -> Result { + let src = File::open(audio_path)?; + let mss = MediaSourceStream::new(Box::new(src), Default::default()); + + let mut hint = Hint::new(); + hint.with_extension(extension); + + let meta_opts: MetadataOptions = Default::default(); + let fmt_opts: FormatOptions = Default::default(); + + let mut probed = symphonia::default::get_probe() + .format(&hint, mss, &fmt_opts, &meta_opts) + .map_err(|_| Error::internal("Failed to probe file"))?; + + let mut artist: Option = None; + let mut album_title: Option = None; + let mut track_title: Option = None; + + let tags = get_tags(&mut probed).ok_or(Error::internal("Failed to probe audio tags"))?; + + for tag in tags { + if let Some(std_key) = tag.std_key { + match std_key { + StandardTagKey::Album => { + album_title.replace(tag.value.to_string()); + } + StandardTagKey::Artist => { + artist.replace(tag.value.to_string()); + } + StandardTagKey::TrackTitle => { + track_title.replace(tag.value.to_string()); + } + _ => { + continue; + } + } + } + } + + let first_track = probed + .format + .default_track() + .ok_or(Error::internal("Failed to find an audio track"))?; + + let time_base = first_track + .codec_params + .time_base + .ok_or(Error::internal("Failed to calculate track duration"))?; + + let num_frames = first_track + .codec_params + .n_frames + .ok_or(Error::internal("Failed to calculate track duration"))?; + + let time = time_base.calc_time(num_frames); + + Ok(SpotifyUri::Local { + artist: format_uri_component(artist), + album_title: format_uri_component(album_title), + track_title: format_uri_component(track_title), + duration: Duration::from_secs(time.seconds), + }) +} + +fn get_tags(probed: &mut ProbeResult) -> Option> { + if let Some(metadata_rev) = probed.format.metadata().current() { + return Some(metadata_rev.tags().to_vec()); + } + + if let Some(metadata_rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) { + return Some(metadata_rev.tags().to_vec()); + } + + None +} + +fn url_encode(input: &str) -> String { + static ENCODE_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"[#$&'()*+,/:;=?@\[\]\s]").unwrap()); + + ENCODE_REGEX + .replace_all(input, |caps: &Captures| match &caps[0] { + " " => "+".to_owned(), + _ => format!("%{:X}", &caps[0].as_bytes()[0]), + }) + .into_owned() +} + +fn format_uri_component(input: Option) -> String { + input.as_deref().map(url_encode).unwrap_or("".to_owned()) +} diff --git a/playback/src/player.rs b/playback/src/player.rs index a4a03ca3f..f2a9798d6 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -1,6 +1,7 @@ use std::{ collections::HashMap, - fmt, + fmt, fs, + fs::File, future::Future, io::{self, Read, Seek, SeekFrom}, mem, @@ -25,6 +26,7 @@ use crate::{ convert::Converter, core::{Error, Session, SpotifyId, SpotifyUri, util::SeqGenerator}, decoder::{AudioDecoder, AudioPacket, AudioPacketPosition, SymphoniaDecoder}, + local_file::{LocalFileLookup, create_local_file_lookup}, metadata::audio::{AudioFileFormat, AudioFiles, AudioItem}, mixer::VolumeGetter, }; @@ -32,7 +34,8 @@ use futures_util::{ StreamExt, TryFutureExt, future, future::FusedFuture, stream::futures_unordered::FuturesUnordered, }; -use librespot_metadata::track::Tracks; +use librespot_metadata::{audio::UniqueFields, track::Tracks}; + use symphonia::core::io::MediaSource; use tokio::sync::{mpsc, oneshot}; @@ -89,6 +92,8 @@ struct PlayerInternal { player_id: usize, play_request_id_generator: SeqGenerator, last_progress_update: Instant, + + local_file_lookup: Arc, } static PLAYER_COUNTER: AtomicUsize = AtomicUsize::new(0); @@ -471,6 +476,12 @@ impl Player { let converter = Converter::new(config.ditherer); let normalisation_knee_factor = 1.0 / (8.0 * config.normalisation_knee_db); + // TODO: it would be neat if we could watch for added or modified files in the + // specified directories, and dynamically update the lookup. Currently, a new player + // must be created for any new local files to be playable. + let local_file_lookup = + create_local_file_lookup(config.local_file_directories.as_slice()); + let internal = PlayerInternal { session, config, @@ -496,6 +507,8 @@ impl Player { player_id, play_request_id_generator: SeqGenerator::new(0), last_progress_update: Instant::now(), + + local_file_lookup: Arc::new(local_file_lookup), }; // While PlayerInternal is written as a future, it still contains blocking code. @@ -885,6 +898,7 @@ impl PlayerState { struct PlayerTrackLoader { session: Session, config: PlayerConfig, + local_file_lookup: Arc, } impl PlayerTrackLoader { @@ -948,6 +962,7 @@ impl PlayerTrackLoader { SpotifyUri::Track { .. } | SpotifyUri::Episode { .. } => { self.load_remote_track(track_uri, position_ms).await } + SpotifyUri::Local { .. } => self.load_local_track(track_uri, position_ms).await, _ => { error!("Cannot handle load of track with URI: <{track_uri}>",); None @@ -1191,6 +1206,108 @@ impl PlayerTrackLoader { }); } } + + async fn load_local_track( + &self, + track_uri: SpotifyUri, + position_ms: u32, + ) -> Option { + let entry = self.local_file_lookup.get(&track_uri); + + let Some(path) = entry else { + error!("Unable to find file path for local file <{track_uri}>"); + return None; + }; + + debug!( + "Located file path '{}' for local file <{track_uri}>", + path.display() + ); + + let src = match File::open(path) { + Ok(src) => src, + Err(e) => { + error!("Failed to open local file: {e}"); + return None; + } + }; + + let decoder = match SymphoniaDecoder::new_with_probe( + src, + path.extension().and_then(|e| e.to_str()), + ) { + Ok(decoder) => decoder, + Err(e) => { + error!("Error decoding local file: {e}"); + return None; + } + }; + + let mut decoder = Box::new(decoder); + let normalisation_data = decoder.normalisation_data().unwrap_or_else(|| { + warn!("Unable to get normalisation data, continuing with defaults."); + NormalisationData::default() + }); + + let local_file_metadata = decoder.local_file_metadata().unwrap_or_default(); + + let stream_position_ms = match decoder.seek(position_ms) { + Ok(new_position_ms) => new_position_ms, + Err(e) => { + error!( + "PlayerTrackLoader::load_local_track error seeking to starting position {position_ms}: {e}" + ); + return None; + } + }; + + let SpotifyUri::Local { duration, .. } = track_uri else { + error!("Unable to determine track duration for local file: not a local file URI"); + return None; + }; + + let file_size = fs::metadata(path).ok()?.len(); + let bytes_per_second = (file_size / duration.as_secs()) as usize; + + let stream_loader_controller = match StreamLoaderController::from_local_file(file_size) { + Ok(c) => c, + Err(e) => { + error!("Unable to create local StreamLoaderController: {e}"); + return None; + } + }; + + Some(PlayerLoadedTrackData { + decoder, + normalisation_data, + stream_loader_controller, + bytes_per_second, + duration_ms: duration.as_millis() as u32, + stream_position_ms, + is_explicit: false, + audio_item: AudioItem { + duration_ms: duration.as_millis() as u32, + uri: track_uri.to_uri().unwrap_or_default(), + track_id: track_uri, + files: Default::default(), + name: local_file_metadata.name, + // We can't get a CoverImage.URL for the track image, applications will have to parse the file metadata themselves using unique_fields.path + covers: vec![], + language: vec![local_file_metadata.language], + is_explicit: false, + availability: Ok(()), + alternatives: None, + unique_fields: UniqueFields::Local { + artists: local_file_metadata.artists, + album: local_file_metadata.album, + album_artists: local_file_metadata.album_artists, + number: local_file_metadata.number, + disc_number: local_file_metadata.disc_number, + path: path.to_path_buf(), + }, + }, + }) + } } impl Future for PlayerInternal { @@ -2270,6 +2387,7 @@ impl PlayerInternal { let loader = PlayerTrackLoader { session: self.session.clone(), config: self.config.clone(), + local_file_lookup: self.local_file_lookup.clone(), }; let (result_tx, result_rx) = oneshot::channel(); diff --git a/src/main.rs b/src/main.rs index b824ea948..fb85803a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -283,6 +283,7 @@ async fn get_setup() -> Setup { const ZEROCONF_PORT: &str = "zeroconf-port"; const ZEROCONF_INTERFACE: &str = "zeroconf-interface"; const ZEROCONF_BACKEND: &str = "zeroconf-backend"; + const LOCAL_FILE_DIR: &str = "local-file-dir"; // Mostly arbitrary. const AP_PORT_SHORT: &str = "a"; @@ -335,6 +336,7 @@ async fn get_setup() -> Setup { const NORMALISATION_THRESHOLD_SHORT: &str = "Z"; const ZEROCONF_PORT_SHORT: &str = "z"; const ZEROCONF_BACKEND_SHORT: &str = ""; // no short flag + const LOCAL_FILE_DIR_SHORT: &str = "l"; // Options that have different descriptions // depending on what backends were enabled at build time. @@ -660,6 +662,11 @@ async fn get_setup() -> Setup { ZEROCONF_BACKEND, "Zeroconf (MDNS/DNS-SD) backend to use. Valid values are 'avahi', 'dns-sd' and 'libmdns', if librespot is compiled with the corresponding feature flags.", "BACKEND" + ).optmulti( + LOCAL_FILE_DIR_SHORT, + LOCAL_FILE_DIR, + "Directory to search for local file playback. Can be specified multiple times to add multiple search directories", + "DIRECTORY" ); #[cfg(feature = "passthrough-decoder")] @@ -1380,6 +1387,12 @@ async fn get_setup() -> Setup { }) }); + let local_file_directories = matches + .opt_strs(LOCAL_FILE_DIR) + .into_iter() + .map(PathBuf::from) + .collect::>(); + let connect_config = { let connect_default_config = ConnectConfig::default(); @@ -1819,6 +1832,7 @@ async fn get_setup() -> Setup { normalisation_knee_db, ditherer, position_update_interval: None, + local_file_directories, } }; diff --git a/src/player_event_handler.rs b/src/player_event_handler.rs index 51495932a..ffd61aa72 100644 --- a/src/player_event_handler.rs +++ b/src/player_event_handler.rs @@ -87,6 +87,28 @@ impl EventHandler { env_vars .insert("DISC_NUMBER", disc_number.to_string()); } + UniqueFields::Local { + artists, + album, + album_artists, + number, + disc_number, + path, + } => { + env_vars.insert("ITEM_TYPE", "Track".to_string()); + env_vars.insert("ARTISTS", artists); + env_vars.insert("ALBUM_ARTISTS", album_artists); + env_vars.insert("ALBUM", album); + env_vars.insert("NUMBER", number.to_string()); + env_vars + .insert("DISC_NUMBER", disc_number.to_string()); + env_vars.insert( + "LOCAL_FILE_PATH", + path.into_os_string() + .into_string() + .unwrap_or_default(), + ); + } UniqueFields::Episode { description, publish_time, From 76dafc925f7178af8858da3791d8f2ad3f97760c Mon Sep 17 00:00:00 2001 From: Jay Malhotra Date: Mon, 22 Sep 2025 21:13:37 +0100 Subject: [PATCH 03/11] chore: Fix build warning --- playback/src/decoder/symphonia_decoder.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playback/src/decoder/symphonia_decoder.rs b/playback/src/decoder/symphonia_decoder.rs index 8222752e0..8e03cce2b 100644 --- a/playback/src/decoder/symphonia_decoder.rs +++ b/playback/src/decoder/symphonia_decoder.rs @@ -232,7 +232,7 @@ impl SymphoniaDecoder { Some(metadata) } - fn metadata(&mut self) -> Option { + fn metadata(&mut self) -> Option> { let mut metadata = self.format.metadata(); // If we can't get metadata from the container, fall back to other tags found by probing. From 33a8b777183930796219a44f9b0bbaa26933278e Mon Sep 17 00:00:00 2001 From: Jay Malhotra Date: Mon, 22 Sep 2025 21:19:04 +0100 Subject: [PATCH 04/11] chore: fix rust 1.85 build --- playback/src/decoder/symphonia_decoder.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/playback/src/decoder/symphonia_decoder.rs b/playback/src/decoder/symphonia_decoder.rs index 8e03cce2b..be8f7627a 100644 --- a/playback/src/decoder/symphonia_decoder.rs +++ b/playback/src/decoder/symphonia_decoder.rs @@ -237,11 +237,12 @@ impl SymphoniaDecoder { // If we can't get metadata from the container, fall back to other tags found by probing. // Note that this is only relevant for local files. - if metadata.current().is_none() - && let Some(ref mut probe_metadata) = self.probed_metadata - && let Some(inner_probe_metadata) = probe_metadata.get() - { - metadata = inner_probe_metadata; + if metadata.current().is_none() { + if let Some(ref mut probe_metadata) = self.probed_metadata { + if let Some(inner_probe_metadata) = probe_metadata.get() { + metadata = inner_probe_metadata; + } + } } // Advance to the latest metadata revision. From 776a4581ec77c46467d703b5653be161866b331d Mon Sep 17 00:00:00 2001 From: Jay Malhotra Date: Mon, 22 Sep 2025 21:32:01 +0100 Subject: [PATCH 05/11] chore: Improve logging while loading local files --- playback/src/player.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/playback/src/player.rs b/playback/src/player.rs index f2a9798d6..7f62ee782 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -1212,6 +1212,8 @@ impl PlayerTrackLoader { track_uri: SpotifyUri, position_ms: u32, ) -> Option { + info!("Loading local file with Spotify URI <{}>", track_uri); + let entry = self.local_file_lookup.get(&track_uri); let Some(path) = entry else { @@ -1219,11 +1221,6 @@ impl PlayerTrackLoader { return None; }; - debug!( - "Located file path '{}' for local file <{track_uri}>", - path.display() - ); - let src = match File::open(path) { Ok(src) => src, Err(e) => { @@ -1277,6 +1274,12 @@ impl PlayerTrackLoader { } }; + info!( + "Loaded <{}> from path <{}>", + local_file_metadata.name, + path.display() + ); + Some(PlayerLoadedTrackData { decoder, normalisation_data, From 961e2f4850ee86b21364ab80429944c84a79b543 Mon Sep 17 00:00:00 2001 From: Jay Malhotra Date: Mon, 6 Oct 2025 20:46:08 +0100 Subject: [PATCH 06/11] chore: Respond to review feedback --- CHANGELOG.md | 4 +- audio/src/fetch/mod.rs | 6 +- connect/src/state/context.rs | 3 + metadata/src/audio/file.rs | 12 +++ metadata/src/audio/item.rs | 4 +- playback/src/decoder/symphonia_decoder.rs | 113 +++++----------------- playback/src/local_file.rs | 38 ++++---- playback/src/player.rs | 38 ++++---- src/player_event_handler.rs | 15 ++- 9 files changed, 96 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c056fe4b1..e371a9816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,12 @@ 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 +- [main] `--local-file-dir` / `-l` option added to binary to specify local file directories to pull from +- [metadata] `Local` variant added to `UniqueFields` enum (breaking)ss - [playback] Local files can now be played with the following caveats: - They must be sampled at 44,100 Hz - They cannot be played from a Connect device using the dedicated 'Local Files' playlist; they must be added to another playlist first -- [playback] `--local-file-dir` / `-l` option added to binary to specify local file directories to pull from - [playback] `local_file_directories` field added to `PlayerConfig` struct (breaking) -- [metadata] `Local` variant added to `UniqueFields` enum (breaking) ### Changed diff --git a/audio/src/fetch/mod.rs b/audio/src/fetch/mod.rs index 5b87d2459..ab440f641 100644 --- a/audio/src/fetch/mod.rs +++ b/audio/src/fetch/mod.rs @@ -301,12 +301,12 @@ impl StreamLoaderController { self.send_stream_loader_command(StreamLoaderCommand::Close); } - pub fn from_local_file(file_size: u64) -> Result { - Ok(Self { + pub fn from_local_file(file_size: u64) -> Self { + Self { channel_tx: None, stream_shared: None, file_size: file_size as usize, - }) + } } } diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 379b8d9db..838b10485 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -446,6 +446,9 @@ impl ConnectState { provider: Option, ) -> Result { let id = match (ctx_track.uri.as_ref(), ctx_track.gid.as_ref()) { + (Some(uri), _) if uri.contains(['?']) => { + Err(StateError::InvalidTrackUri(Some(uri.clone())))? + } (Some(uri), _) if !uri.is_empty() => SpotifyUri::from_uri(uri)?, (_, Some(gid)) if !gid.is_empty() => SpotifyUri::Track { id: SpotifyId::from_raw(gid)?, diff --git a/metadata/src/audio/file.rs b/metadata/src/audio/file.rs index cf70a88d6..126993216 100644 --- a/metadata/src/audio/file.rs +++ b/metadata/src/audio/file.rs @@ -103,6 +103,18 @@ impl AudioFiles { pub fn is_flac(format: AudioFileFormat) -> bool { matches!(format, AudioFileFormat::FLAC_FLAC) } + + pub fn mime_type(format: AudioFileFormat) -> Option<&'static str> { + if Self::is_ogg_vorbis(format) { + Some("audio/ogg") + } else if Self::is_mp3(format) { + Some("audio/mpeg") + } else if Self::is_flac(format) { + Some("audio/flac") + } else { + None + } + } } impl From<&[AudioFileMessage]> for AudioFiles { diff --git a/metadata/src/audio/item.rs b/metadata/src/audio/item.rs index 2a13d8b74..5f045fdfc 100644 --- a/metadata/src/audio/item.rs +++ b/metadata/src/audio/item.rs @@ -56,8 +56,8 @@ pub enum UniqueFields { artists: String, album: String, album_artists: String, - number: u32, - disc_number: u32, + number: Option, + disc_number: Option, path: PathBuf, }, Episode { diff --git a/playback/src/decoder/symphonia_decoder.rs b/playback/src/decoder/symphonia_decoder.rs index be8f7627a..75dfc1271 100644 --- a/playback/src/decoder/symphonia_decoder.rs +++ b/playback/src/decoder/symphonia_decoder.rs @@ -11,19 +11,11 @@ use symphonia::{ io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}, meta::{StandardTagKey, Value}, }, - default::{ - codecs::{FlacDecoder, MpaDecoder, VorbisDecoder}, - formats::{FlacReader, MpaReader, OggReader}, - }, }; use super::{AudioDecoder, AudioPacket, AudioPacketPosition, DecoderError, DecoderResult}; -use crate::{ - NUM_CHANNELS, PAGES_PER_MS, SAMPLE_RATE, - metadata::audio::{AudioFileFormat, AudioFiles}, - player::NormalisationData, -}; +use crate::{NUM_CHANNELS, PAGES_PER_MS, SAMPLE_RATE, player::NormalisationData}; pub struct SymphoniaDecoder { format: Box, @@ -39,12 +31,12 @@ pub(crate) struct LocalFileMetadata { pub album: String, pub artists: String, pub album_artists: String, - pub number: u32, - pub disc_number: u32, + pub number: Option, + pub disc_number: Option, } impl SymphoniaDecoder { - pub fn new(input: R, file_format: AudioFileFormat) -> DecoderResult + pub fn new(input: R, hint: Hint) -> DecoderResult where R: MediaSource + 'static, { @@ -57,39 +49,29 @@ impl SymphoniaDecoder { enable_gapless: true, ..Default::default() }; + let metadata_opts: MetadataOptions = Default::default(); - let format: Box = if AudioFiles::is_ogg_vorbis(file_format) { - Box::new(OggReader::try_new(mss, &format_opts)?) - } else if AudioFiles::is_mp3(file_format) { - Box::new(MpaReader::try_new(mss, &format_opts)?) - } else if AudioFiles::is_flac(file_format) { - Box::new(FlacReader::try_new(mss, &format_opts)?) - } else { - return Err(DecoderError::SymphoniaDecoder(format!( - "Unsupported format: {file_format:?}" - ))); - }; + let probed = + symphonia::default::get_probe().format(&hint, mss, &format_opts, &metadata_opts)?; + + let format = probed.format; let track = format.default_track().ok_or_else(|| { DecoderError::SymphoniaDecoder("Could not retrieve default track".into()) })?; let decoder_opts: DecoderOptions = Default::default(); - let decoder: Box = if AudioFiles::is_ogg_vorbis(file_format) { - Box::new(VorbisDecoder::try_new(&track.codec_params, &decoder_opts)?) - } else if AudioFiles::is_mp3(file_format) { - Box::new(MpaDecoder::try_new(&track.codec_params, &decoder_opts)?) - } else if AudioFiles::is_flac(file_format) { - Box::new(FlacDecoder::try_new(&track.codec_params, &decoder_opts)?) - } else { - return Err(DecoderError::SymphoniaDecoder(format!( - "Unsupported decoder: {file_format:?}" - ))); - }; + + let decoder = symphonia::default::get_codecs().make(&track.codec_params, &decoder_opts)?; let rate = decoder.codec_params().sample_rate.ok_or_else(|| { DecoderError::SymphoniaDecoder("Could not retrieve sample rate".into()) })?; + + // TODO: The official client supports local files with sample rates other than 44,100 kHz. + // To play these accurately, we need to either resample the input audio, or introduce a way + // to change the player's current sample rate (likely by closing and re-opening the sink + // with new parameters). if rate != SAMPLE_RATE { return Err(DecoderError::SymphoniaDecoder(format!( "Unsupported sample rate: {rate}" @@ -108,60 +90,9 @@ impl SymphoniaDecoder { Ok(Self { format, decoder, - // We set the sample buffer when decoding the first full packet, // whose duration is also the ideal sample buffer size. sample_buffer: None, - - probed_metadata: None, - }) - } - - pub(crate) fn new_with_probe(src: R, extension: Option<&str>) -> DecoderResult - where - R: MediaSource + 'static, - { - let mss = MediaSourceStream::new(Box::new(src), Default::default()); - - let mut hint = Hint::new(); - - if let Some(extension) = extension { - hint.with_extension(extension); - } - - let format_opts: FormatOptions = Default::default(); - let metadata_opts: MetadataOptions = Default::default(); - let decoder_opts: DecoderOptions = Default::default(); - - let probed = - symphonia::default::get_probe().format(&hint, mss, &format_opts, &metadata_opts)?; - - let format = probed.format; - - let track = format.default_track().ok_or_else(|| { - DecoderError::SymphoniaDecoder("Could not retrieve default track".into()) - })?; - - let decoder = symphonia::default::get_codecs().make(&track.codec_params, &decoder_opts)?; - - let rate = decoder.codec_params().sample_rate.ok_or_else(|| { - DecoderError::SymphoniaDecoder("Could not retrieve sample rate".into()) - })?; - - // TODO: The official client supports local files with sample rates other than 44,100 kHz. - // To play these accurately, we need to either resample the input audio, or introduce a way - // to change the player's current sample rate (likely by closing and re-opening the sink - // with new parameters). - if rate != SAMPLE_RATE { - return Err(DecoderError::SymphoniaDecoder(format!( - "Unsupported sample rate: {rate}. Local files must have a sample rate of {SAMPLE_RATE} Hz." - ))); - } - - Ok(Self { - format, - decoder, - sample_buffer: None, probed_metadata: Some(probed.metadata), }) } @@ -207,23 +138,23 @@ impl SymphoniaDecoder { Some(StandardTagKey::AlbumArtist) => metadata.album_artists = value.clone(), Some(StandardTagKey::Album) => metadata.album = value.clone(), Some(StandardTagKey::TrackNumber) => { - metadata.number = value.parse::().unwrap_or_default() + metadata.number = value.parse::().ok() } Some(StandardTagKey::DiscNumber) => { - metadata.disc_number = value.parse::().unwrap_or_default() + metadata.disc_number = value.parse::().ok() } _ => (), } } else if let Value::UnsignedInt(value) = &tag.value { match tag.std_key { - Some(StandardTagKey::TrackNumber) => metadata.number = *value as u32, - Some(StandardTagKey::DiscNumber) => metadata.disc_number = *value as u32, + Some(StandardTagKey::TrackNumber) => metadata.number = Some(*value as u32), + Some(StandardTagKey::DiscNumber) => metadata.disc_number = Some(*value as u32), _ => (), } } else if let Value::SignedInt(value) = &tag.value { match tag.std_key { - Some(StandardTagKey::TrackNumber) => metadata.number = *value as u32, - Some(StandardTagKey::DiscNumber) => metadata.disc_number = *value as u32, + Some(StandardTagKey::TrackNumber) => metadata.number = Some(*value as u32), + Some(StandardTagKey::DiscNumber) => metadata.disc_number = Some(*value as u32), _ => (), } } diff --git a/playback/src/local_file.rs b/playback/src/local_file.rs index 9d961e326..d00ded939 100644 --- a/playback/src/local_file.rs +++ b/playback/src/local_file.rs @@ -145,10 +145,26 @@ fn get_uri_from_file(audio_path: &Path, extension: &str) -> Result String { + static ENCODE_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"[#$&'()*+,/:;=?@\[\]\s]").unwrap()); + + ENCODE_REGEX + .replace_all(input, |caps: &Captures| match &caps[0] { + " " => "+".to_owned(), + _ => format!("%{:X}", &caps[0].as_bytes()[0]), + }) + .into_owned() + } + + fn format_uri_part(input: Option) -> String { + input.as_deref().map(url_encode).unwrap_or("".to_owned()) + } + Ok(SpotifyUri::Local { - artist: format_uri_component(artist), - album_title: format_uri_component(album_title), - track_title: format_uri_component(track_title), + artist: format_uri_part(artist), + album_title: format_uri_part(album_title), + track_title: format_uri_part(track_title), duration: Duration::from_secs(time.seconds), }) } @@ -164,19 +180,3 @@ fn get_tags(probed: &mut ProbeResult) -> Option> { None } - -fn url_encode(input: &str) -> String { - static ENCODE_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r"[#$&'()*+,/:;=?@\[\]\s]").unwrap()); - - ENCODE_REGEX - .replace_all(input, |caps: &Captures| match &caps[0] { - " " => "+".to_owned(), - _ => format!("%{:X}", &caps[0].as_bytes()[0]), - }) - .into_owned() -} - -fn format_uri_component(input: Option) -> String { - input.as_deref().map(url_encode).unwrap_or("".to_owned()) -} diff --git a/playback/src/player.rs b/playback/src/player.rs index 7f62ee782..14cd080c1 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -37,6 +37,7 @@ use futures_util::{ use librespot_metadata::{audio::UniqueFields, track::Tracks}; use symphonia::core::io::MediaSource; +use symphonia::core::probe::Hint; use tokio::sync::{mpsc, oneshot}; use crate::SAMPLES_PER_SECOND; @@ -1126,7 +1127,14 @@ impl PlayerTrackLoader { }; #[cfg(not(feature = "passthrough-decoder"))] - let decoder_type = symphonia_decoder(audio_file, format); + let decoder_type = { + let mut hint = Hint::new(); + if let Some(mime_type) = AudioFiles::mime_type(format) { + hint.mime_type(mime_type); + } + + symphonia_decoder(audio_file, hint) + }; let normalisation_data = normalisation_data.unwrap_or_else(|| { warn!("Unable to get normalisation data, continuing with defaults."); @@ -1214,6 +1222,11 @@ impl PlayerTrackLoader { ) -> Option { info!("Loading local file with Spotify URI <{}>", track_uri); + let SpotifyUri::Local { duration, .. } = track_uri else { + error!("Unable to determine track duration for local file: not a local file URI"); + return None; + }; + let entry = self.local_file_lookup.get(&track_uri); let Some(path) = entry else { @@ -1229,10 +1242,12 @@ impl PlayerTrackLoader { } }; - let decoder = match SymphoniaDecoder::new_with_probe( - src, - path.extension().and_then(|e| e.to_str()), - ) { + let mut hint = Hint::new(); + if let Some(file_extension) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(file_extension); + } + + let decoder = match SymphoniaDecoder::new(src, hint) { Ok(decoder) => decoder, Err(e) => { error!("Error decoding local file: {e}"); @@ -1258,21 +1273,10 @@ impl PlayerTrackLoader { } }; - let SpotifyUri::Local { duration, .. } = track_uri else { - error!("Unable to determine track duration for local file: not a local file URI"); - return None; - }; - let file_size = fs::metadata(path).ok()?.len(); let bytes_per_second = (file_size / duration.as_secs()) as usize; - let stream_loader_controller = match StreamLoaderController::from_local_file(file_size) { - Ok(c) => c, - Err(e) => { - error!("Unable to create local StreamLoaderController: {e}"); - return None; - } - }; + let stream_loader_controller = StreamLoaderController::from_local_file(file_size); info!( "Loaded <{}> from path <{}>", diff --git a/src/player_event_handler.rs b/src/player_event_handler.rs index ffd61aa72..7c45e9d13 100644 --- a/src/player_event_handler.rs +++ b/src/player_event_handler.rs @@ -99,9 +99,18 @@ impl EventHandler { env_vars.insert("ARTISTS", artists); env_vars.insert("ALBUM_ARTISTS", album_artists); env_vars.insert("ALBUM", album); - env_vars.insert("NUMBER", number.to_string()); - env_vars - .insert("DISC_NUMBER", disc_number.to_string()); + env_vars.insert( + "NUMBER", + number + .map(|n: u32| n.to_string()) + .unwrap_or_default(), + ); + env_vars.insert( + "DISC_NUMBER", + disc_number + .map(|n: u32| n.to_string()) + .unwrap_or_default(), + ); env_vars.insert( "LOCAL_FILE_PATH", path.into_os_string() From 9222e2046871798b5db41f5db257113ce3e93ecc Mon Sep 17 00:00:00 2001 From: Jay Malhotra Date: Mon, 6 Oct 2025 21:40:38 +0100 Subject: [PATCH 07/11] chore: Respond to missed review feedback --- playback/src/local_file.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/playback/src/local_file.rs b/playback/src/local_file.rs index d00ded939..a02f493ef 100644 --- a/playback/src/local_file.rs +++ b/playback/src/local_file.rs @@ -62,14 +62,14 @@ fn visit_dir(dir: &Path, accumulator: &mut LocalFileLookup) -> io::Result<()> { if path.is_dir() { visit_dir(&path, accumulator)?; } else { - let Some(extension) = path.extension().and_then(|e| e.to_str()) else { + let Some(file_extension) = path.extension().and_then(|e| e.to_str()) else { continue; }; - let lowercase_extension = extension.to_lowercase(); + let lowercase_extension = file_extension.to_lowercase(); if SUPPORTED_FILE_EXTENSIONS.contains(&lowercase_extension.as_str()) { - let uri = match get_uri_from_file(path.as_path(), extension) { + let uri = match get_uri_from_file(path.as_path(), file_extension) { Ok(uri) => uri, Err(e) => { warn!( @@ -89,12 +89,12 @@ fn visit_dir(dir: &Path, accumulator: &mut LocalFileLookup) -> io::Result<()> { Ok(()) } -fn get_uri_from_file(audio_path: &Path, extension: &str) -> Result { +fn get_uri_from_file(audio_path: &Path, file_extension: &str) -> Result { let src = File::open(audio_path)?; let mss = MediaSourceStream::new(Box::new(src), Default::default()); let mut hint = Hint::new(); - hint.with_extension(extension); + hint.with_extension(file_extension); let meta_opts: MetadataOptions = Default::default(); let fmt_opts: FormatOptions = Default::default(); From 0d01247ca2a460fa7eb13996b95243394b54c61e Mon Sep 17 00:00:00 2001 From: Jay Malhotra Date: Mon, 6 Oct 2025 21:58:39 +0100 Subject: [PATCH 08/11] refactor: Use crate for URL encoding --- Cargo.lock | 2 +- playback/Cargo.toml | 4 +++- playback/src/local_file.rs | 22 +++++++--------------- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43b54e8cd..b28a3d0b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,6 +2024,7 @@ version = "0.7.1" dependencies = [ "alsa 0.10.0", "cpal", + "form_urlencoded", "futures-util", "gstreamer", "gstreamer-app", @@ -2040,7 +2041,6 @@ dependencies = [ "portaudio-rs", "rand 0.9.2", "rand_distr", - "regex", "rodio", "sdl2", "shell-words", diff --git a/playback/Cargo.toml b/playback/Cargo.toml index 7d8cf6cc8..f4150aebd 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -94,4 +94,6 @@ ogg = { version = "0.9", optional = true } # Dithering rand = { version = "0.9", default-features = false, features = ["small_rng"] } rand_distr = "0.5" -regex = "1.11.2" + +# Local file handling +form_urlencoded = "1.2.2" diff --git a/playback/src/local_file.rs b/playback/src/local_file.rs index a02f493ef..79f2aacd0 100644 --- a/playback/src/local_file.rs +++ b/playback/src/local_file.rs @@ -1,6 +1,4 @@ use librespot_core::{Error, SpotifyUri}; -use regex::{Captures, Regex}; -use std::sync::LazyLock; use std::{ collections::HashMap, fs, @@ -145,20 +143,14 @@ fn get_uri_from_file(audio_path: &Path, file_extension: &str) -> Result String { - static ENCODE_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r"[#$&'()*+,/:;=?@\[\]\s]").unwrap()); - - ENCODE_REGEX - .replace_all(input, |caps: &Captures| match &caps[0] { - " " => "+".to_owned(), - _ => format!("%{:X}", &caps[0].as_bytes()[0]), - }) - .into_owned() - } - fn format_uri_part(input: Option) -> String { - input.as_deref().map(url_encode).unwrap_or("".to_owned()) + input + .map(|s| { + let bytes = s.into_bytes(); + let encoded = form_urlencoded::byte_serialize(bytes.as_slice()); + encoded.collect::() + }) + .unwrap_or("".to_owned()) } Ok(SpotifyUri::Local { From 25d83c1b7c98a358945d18ec6b04ad80e4cc5acc Mon Sep 17 00:00:00 2001 From: Jay Malhotra Date: Tue, 7 Oct 2025 20:57:07 +0100 Subject: [PATCH 09/11] refactor: Review feedback & remove duplication --- CHANGELOG.md | 2 +- metadata/src/audio/item.rs | 6 +- playback/src/decoder/symphonia_decoder.rs | 85 ++++++++--------------- playback/src/lib.rs | 1 + playback/src/local_file.rs | 31 ++++----- playback/src/player.rs | 15 ++-- playback/src/symphonia_util.rs | 24 +++++++ src/player_event_handler.rs | 10 ++- 8 files changed, 85 insertions(+), 89 deletions(-) create mode 100644 playback/src/symphonia_util.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e371a9816..53d2f303e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [core] Add `SpotifyUri` type to represent more types of URI than `SpotifyId` can - [main] `--local-file-dir` / `-l` option added to binary to specify local file directories to pull from -- [metadata] `Local` variant added to `UniqueFields` enum (breaking)ss +- [metadata] `Local` variant added to `UniqueFields` enum (breaking) - [playback] Local files can now be played with the following caveats: - They must be sampled at 44,100 Hz - They cannot be played from a Connect device using the dedicated 'Local Files' playlist; they must be added to another playlist first diff --git a/metadata/src/audio/item.rs b/metadata/src/audio/item.rs index 5f045fdfc..f2cab9462 100644 --- a/metadata/src/audio/item.rs +++ b/metadata/src/audio/item.rs @@ -53,9 +53,9 @@ pub enum UniqueFields { Local { // artists / album_artists can't be a Vec here, they are retrieved from metadata as a String, // and we cannot make any assumptions about them being e.g. comma-separated - artists: String, - album: String, - album_artists: String, + artists: Option, + album: Option, + album_artists: Option, number: Option, disc_number: Option, path: PathBuf, diff --git a/playback/src/decoder/symphonia_decoder.rs b/playback/src/decoder/symphonia_decoder.rs index 75dfc1271..21032cb9f 100644 --- a/playback/src/decoder/symphonia_decoder.rs +++ b/playback/src/decoder/symphonia_decoder.rs @@ -1,36 +1,32 @@ use std::{io, time::Duration}; -use symphonia::{ - core::meta::{Metadata, MetadataOptions}, - core::probe::{Hint, ProbedMetadata}, - core::{ - audio::SampleBuffer, - codecs::{Decoder, DecoderOptions}, - errors::Error, - formats::{FormatOptions, FormatReader, SeekMode, SeekTo}, - io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}, - meta::{StandardTagKey, Value}, - }, +use symphonia::core::{ + audio::SampleBuffer, + codecs::{Decoder, DecoderOptions}, + errors::Error, + formats::{FormatOptions, SeekMode, SeekTo}, + io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}, + meta::{MetadataOptions, StandardTagKey, Value}, + probe::{Hint, ProbeResult}, }; use super::{AudioDecoder, AudioPacket, AudioPacketPosition, DecoderError, DecoderResult}; -use crate::{NUM_CHANNELS, PAGES_PER_MS, SAMPLE_RATE, player::NormalisationData}; +use crate::{NUM_CHANNELS, PAGES_PER_MS, SAMPLE_RATE, player::NormalisationData, symphonia_util}; pub struct SymphoniaDecoder { - format: Box, + probe_result: ProbeResult, decoder: Box, sample_buffer: Option>, - probed_metadata: Option, } #[derive(Default)] pub(crate) struct LocalFileMetadata { - pub name: String, - pub language: String, - pub album: String, - pub artists: String, - pub album_artists: String, + pub name: Option, + pub language: Option, + pub album: Option, + pub artists: Option, + pub album_artists: Option, pub number: Option, pub disc_number: Option, } @@ -51,10 +47,10 @@ impl SymphoniaDecoder { }; let metadata_opts: MetadataOptions = Default::default(); - let probed = + let probe_result = symphonia::default::get_probe().format(&hint, mss, &format_opts, &metadata_opts)?; - let format = probed.format; + let format = &probe_result.format; let track = format.default_track().ok_or_else(|| { DecoderError::SymphoniaDecoder("Could not retrieve default track".into()) @@ -88,17 +84,16 @@ impl SymphoniaDecoder { } Ok(Self { - format, + probe_result, decoder, // We set the sample buffer when decoding the first full packet, // whose duration is also the ideal sample buffer size. sample_buffer: None, - probed_metadata: Some(probed.metadata), }) } pub fn normalisation_data(&mut self) -> Option { - let metadata = self.metadata()?; + let metadata = symphonia_util::get_latest_metadata(&mut self.probe_result)?; let tags = metadata.current()?.tags(); if tags.is_empty() { @@ -123,7 +118,7 @@ impl SymphoniaDecoder { } pub(crate) fn local_file_metadata(&mut self) -> Option { - let metadata = self.metadata()?; + let metadata = symphonia_util::get_latest_metadata(&mut self.probe_result)?; let tags = metadata.current()?.tags(); let mut metadata = LocalFileMetadata::default(); @@ -132,11 +127,13 @@ impl SymphoniaDecoder { match tag.std_key { // We could possibly use mem::take here to avoid cloning, but that risks leaving // the audio item metadata in a bad state. - Some(StandardTagKey::TrackTitle) => metadata.name = value.clone(), - Some(StandardTagKey::Language) => metadata.language = value.clone(), - Some(StandardTagKey::Artist) => metadata.artists = value.clone(), - Some(StandardTagKey::AlbumArtist) => metadata.album_artists = value.clone(), - Some(StandardTagKey::Album) => metadata.album = value.clone(), + Some(StandardTagKey::TrackTitle) => metadata.name = Some(value.clone()), + Some(StandardTagKey::Language) => metadata.language = Some(value.clone()), + Some(StandardTagKey::Artist) => metadata.artists = Some(value.clone()), + Some(StandardTagKey::AlbumArtist) => { + metadata.album_artists = Some(value.clone()) + } + Some(StandardTagKey::Album) => metadata.album = Some(value.clone()), Some(StandardTagKey::TrackNumber) => { metadata.number = value.parse::().ok() } @@ -163,30 +160,6 @@ impl SymphoniaDecoder { Some(metadata) } - fn metadata(&mut self) -> Option> { - let mut metadata = self.format.metadata(); - - // If we can't get metadata from the container, fall back to other tags found by probing. - // Note that this is only relevant for local files. - if metadata.current().is_none() { - if let Some(ref mut probe_metadata) = self.probed_metadata { - if let Some(inner_probe_metadata) = probe_metadata.get() { - metadata = inner_probe_metadata; - } - } - } - - // Advance to the latest metadata revision. - // None means we hit the latest. - loop { - if metadata.pop().is_none() { - break; - } - } - - Some(metadata) - } - #[inline] fn ts_to_ms(&self, ts: u64) -> u32 { match self.decoder.codec_params().time_base { @@ -213,7 +186,7 @@ impl AudioDecoder for SymphoniaDecoder { } // `track_id: None` implies the default track ID (of the container, not of Spotify). - let seeked_to_ts = self.format.seek( + let seeked_to_ts = self.probe_result.format.seek( SeekMode::Accurate, SeekTo::Time { time: target.into(), @@ -232,7 +205,7 @@ impl AudioDecoder for SymphoniaDecoder { let mut skipped = false; loop { - let packet = match self.format.next_packet() { + let packet = match self.probe_result.format.next_packet() { Ok(packet) => packet, Err(Error::IoError(err)) => { if err.kind() == io::ErrorKind::UnexpectedEof { diff --git a/playback/src/lib.rs b/playback/src/lib.rs index c05cc2f9e..a637f00cf 100644 --- a/playback/src/lib.rs +++ b/playback/src/lib.rs @@ -13,6 +13,7 @@ pub mod dither; mod local_file; pub mod mixer; pub mod player; +mod symphonia_util; pub const SAMPLE_RATE: u32 = 44100; pub const NUM_CHANNELS: u8 = 2; diff --git a/playback/src/local_file.rs b/playback/src/local_file.rs index 79f2aacd0..f25c65829 100644 --- a/playback/src/local_file.rs +++ b/playback/src/local_file.rs @@ -1,3 +1,4 @@ +use crate::symphonia_util; use librespot_core::{Error, SpotifyUri}; use std::{ collections::HashMap, @@ -7,11 +8,11 @@ use std::{ path::{Path, PathBuf}, time::Duration, }; -use symphonia::{ - core::formats::FormatOptions, - core::io::MediaSourceStream, - core::meta::{MetadataOptions, StandardTagKey, Tag}, - core::probe::{Hint, ProbeResult}, +use symphonia::core::{ + formats::FormatOptions, + io::MediaSourceStream, + meta::{MetadataOptions, StandardTagKey, Tag}, + probe::{Hint, ProbeResult}, }; // "Spotify supports .mp3, .mp4, and .m4p files. It doesn’t support .mp4 files that contain video, @@ -105,9 +106,13 @@ fn get_uri_from_file(audio_path: &Path, file_extension: &str) -> Result = None; let mut track_title: Option = None; - let tags = get_tags(&mut probed).ok_or(Error::internal("Failed to probe audio tags"))?; + fn get_tags(probed: &mut ProbeResult) -> Option> { + let metadata = symphonia_util::get_latest_metadata(probed)?; + let metadata_rev = metadata.current()?; + Some(metadata_rev.tags().to_vec()) + } - for tag in tags { + for tag in get_tags(&mut probed).ok_or(Error::internal("Failed to probe audio tags"))? { if let Some(std_key) = tag.std_key { match std_key { StandardTagKey::Album => { @@ -160,15 +165,3 @@ fn get_uri_from_file(audio_path: &Path, file_extension: &str) -> Result Option> { - if let Some(metadata_rev) = probed.format.metadata().current() { - return Some(metadata_rev.tags().to_vec()); - } - - if let Some(metadata_rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) { - return Some(metadata_rev.tags().to_vec()); - } - - None -} diff --git a/playback/src/player.rs b/playback/src/player.rs index 14cd080c1..223e8c8ca 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -1278,11 +1278,9 @@ impl PlayerTrackLoader { let stream_loader_controller = StreamLoaderController::from_local_file(file_size); - info!( - "Loaded <{}> from path <{}>", - local_file_metadata.name, - path.display() - ); + let name = local_file_metadata.name.unwrap_or_default(); + + info!("Loaded <{name}> from path <{}>", path.display()); Some(PlayerLoadedTrackData { decoder, @@ -1297,10 +1295,13 @@ impl PlayerTrackLoader { uri: track_uri.to_uri().unwrap_or_default(), track_id: track_uri, files: Default::default(), - name: local_file_metadata.name, + name, // We can't get a CoverImage.URL for the track image, applications will have to parse the file metadata themselves using unique_fields.path covers: vec![], - language: vec![local_file_metadata.language], + language: local_file_metadata + .language + .map(|val| vec![val]) + .unwrap_or_default(), is_explicit: false, availability: Ok(()), alternatives: None, diff --git a/playback/src/symphonia_util.rs b/playback/src/symphonia_util.rs new file mode 100644 index 000000000..db3868d5a --- /dev/null +++ b/playback/src/symphonia_util.rs @@ -0,0 +1,24 @@ +use symphonia::core::meta::Metadata; +use symphonia::core::probe::ProbeResult; + +pub fn get_latest_metadata(probe_result: &mut ProbeResult) -> Option { + let mut metadata = probe_result.format.metadata(); + + // If we can't get metadata from the container, fall back to other tags found by probing. + // Note that this is only relevant for local files. + if metadata.current().is_none() { + if let Some(inner_probe_metadata) = probe_result.metadata.get() { + metadata = inner_probe_metadata; + } + } + + // Advance to the latest metadata revision. + // None means we hit the latest. + loop { + if metadata.pop().is_none() { + break; + } + } + + Some(metadata) +} diff --git a/src/player_event_handler.rs b/src/player_event_handler.rs index 7c45e9d13..2cdac5c60 100644 --- a/src/player_event_handler.rs +++ b/src/player_event_handler.rs @@ -96,9 +96,13 @@ impl EventHandler { path, } => { env_vars.insert("ITEM_TYPE", "Track".to_string()); - env_vars.insert("ARTISTS", artists); - env_vars.insert("ALBUM_ARTISTS", album_artists); - env_vars.insert("ALBUM", album); + env_vars + .insert("ARTISTS", artists.unwrap_or_default()); + env_vars.insert( + "ALBUM_ARTISTS", + album_artists.unwrap_or_default(), + ); + env_vars.insert("ALBUM", album.unwrap_or_default()); env_vars.insert( "NUMBER", number From 0aa6dacd600b2c1af7435c34bcc99e96f189b5b7 Mon Sep 17 00:00:00 2001 From: Jay Malhotra Date: Tue, 7 Oct 2025 21:01:37 +0100 Subject: [PATCH 10/11] chore: Remove incorrect comment --- core/src/spotify_uri.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/src/spotify_uri.rs b/core/src/spotify_uri.rs index de7cb6b35..f92f2d3bd 100644 --- a/core/src/spotify_uri.rs +++ b/core/src/spotify_uri.rs @@ -173,8 +173,7 @@ impl SpotifyUri { }), SPOTIFY_ITEM_TYPE_LOCAL => { let artist = name; - let album_title = parts.next().unwrap_or_default(); - // enforce track_title exists; spotify:local::: is a silly URI + let album_title = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; let track_title = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; let duration_secs = parts .next() From 731595ed6f4c1afa9049b8fe66834e1194a3b717 Mon Sep 17 00:00:00 2001 From: Jay Malhotra Date: Tue, 7 Oct 2025 21:02:40 +0100 Subject: [PATCH 11/11] chore: Fix CI build error --- playback/src/symphonia_util.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playback/src/symphonia_util.rs b/playback/src/symphonia_util.rs index db3868d5a..9cabc60ee 100644 --- a/playback/src/symphonia_util.rs +++ b/playback/src/symphonia_util.rs @@ -1,7 +1,7 @@ use symphonia::core::meta::Metadata; use symphonia::core::probe::ProbeResult; -pub fn get_latest_metadata(probe_result: &mut ProbeResult) -> Option { +pub fn get_latest_metadata(probe_result: &mut ProbeResult) -> Option> { let mut metadata = probe_result.format.metadata(); // If we can't get metadata from the container, fall back to other tags found by probing.