Skip to content
Open
9 changes: 7 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

- [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)
- [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_directories` field added to `PlayerConfig` struct (breaking)

### Changed

- [playback] Changed type of `SpotifyId` fields in `PlayerEvent` members to `SpotifyUri` (breaking)
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

8 changes: 8 additions & 0 deletions audio/src/fetch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) -> Self {
Self {
channel_tx: None,
stream_shared: None,
file_size: file_size as usize,
}
}
}

pub struct AudioFileStreaming {
Expand Down
6 changes: 6 additions & 0 deletions connect/src/spirc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
}).await
}, if allow_context_resolving && self.context_resolver.has_next() => {
let update_state = self.handle_next_context(next_context);
Expand Down
6 changes: 5 additions & 1 deletion connect/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion connect/src/state/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ impl ConnectState {
provider: Option<Provider>,
) -> Result<ProvidedTrack, Error> {
let id = match (ctx_track.uri.as_ref(), ctx_track.gid.as_ref()) {
(Some(uri), _) if uri.contains(['?', '%']) => {
(Some(uri), _) if uri.contains(['?']) => {
Err(StateError::InvalidTrackUri(Some(uri.clone())))?
}
(Some(uri), _) if !uri.is_empty() => SpotifyUri::from_uri(uri)?,
Expand Down
58 changes: 45 additions & 13 deletions core/src/spotify_uri.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)?,
Expand All @@ -167,12 +171,22 @@ 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().ok_or(SpotifyUriError::InvalidFormat)?;
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(),
Expand Down Expand Up @@ -533,15 +547,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),
}
);
}
Expand Down
12 changes: 12 additions & 0 deletions metadata/src/audio/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 11 additions & 1 deletion metadata/src/audio/item.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::fmt::Debug;
use std::{fmt::Debug, path::PathBuf};

use crate::{
Metadata,
Expand Down Expand Up @@ -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: Option<String>,
album: Option<String>,
album_artists: Option<String>,
number: Option<u32>,
disc_number: Option<u32>,
path: PathBuf,
},
Episode {
description: String,
publish_time: Date,
Expand Down
3 changes: 3 additions & 0 deletions playback/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,6 @@ ogg = { version = "0.9", optional = true }
# Dithering
rand = { version = "0.9", default-features = false, features = ["small_rng"] }
rand_distr = "0.5"

# Local file handling
form_urlencoded = "1.2.2"
5 changes: 4 additions & 1 deletion playback/src/config.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -136,6 +136,8 @@ pub struct PlayerConfig {
pub normalisation_release_cf: f64,
pub normalisation_knee_db: f64,

pub local_file_directories: Vec<PathBuf>,

// 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<DithererBuilder>,
Expand All @@ -160,6 +162,7 @@ impl Default for PlayerConfig {
passthrough: false,
ditherer: Some(mk_ditherer::<TriangularDitherer>),
position_update_interval: None,
local_file_directories: Vec::new(),
}
}
}
Expand Down
Loading