Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
Loading