Skip to content

Commit d0efe21

Browse files
committed
feat: Add minimal local file support
1 parent bde9d9c commit d0efe21

File tree

15 files changed

+551
-32
lines changed

15 files changed

+551
-32
lines changed

CHANGELOG.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- [core] Add `SpotifyUri` type to represent more types of URI than `SpotifyId` can
13-
13+
- [playback] Local files can now be played with the following caveats:
14+
- They must be sampled at 44,100 Hz
15+
- They cannot be played from a Connect device using the dedicated 'Local Files' playlist; they must be added to another playlist first
16+
- [playback] `--local-file-dir` / `-l` option added to binary to specify local file directories to pull from
17+
- [playback] `local_file_directories` field added to `PlayerConfig` struct (breaking)
18+
- [metadata] `Local` variant added to `UniqueFields` enum (breaking)
19+
1420
### Changed
1521

1622
- [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
1925
- [player] `preload` function changed from accepting a `SpotifyId` to accepting a `SpotifyUri` (breaking)
2026
- [spclient] `get_radio_for_track` function changed from accepting a `SpotifyId` to accepting a `SpotifyUri` (breaking)
2127

22-
2328
### Removed
2429

2530
- [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
7075
- [connect] Add `pause` parameter to `Spirc::disconnect` method (breaking)
7176
- [connect] Add `volume_steps` to `ConnectConfig` (breaking)
7277
- [connect] Add and enforce rustdoc
73-
- [connect] Add `audio/local` to the `supported_types` field of the device capabilities.
7478
- [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking)
7579
- [playback] Add `PlayerEvent::PositionChanged` event to notify about the current playback position
7680
- [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient`
7781
- [core] Add `try_get_urls` to `CdnUrl`
7882
- [oauth] Add `OAuthClient` and `OAuthClientBuilder` structs to achieve a more customizable login process
7983

80-
8184
### Fixed
8285

8386
- [test] Missing bindgen breaks crossbuild on recent runners. Now installing latest bindgen in addition.

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

audio/src/fetch/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,14 @@ impl StreamLoaderController {
300300
// terminate stream loading and don't load any more data for this file.
301301
self.send_stream_loader_command(StreamLoaderCommand::Close);
302302
}
303+
304+
pub fn from_local_file(file_size: u64) -> Result<Self, Error> {
305+
Ok(Self {
306+
channel_tx: None,
307+
stream_shared: None,
308+
file_size: file_size as usize,
309+
})
310+
}
303311
}
304312

305313
pub struct AudioFileStreaming {

connect/src/spirc.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,13 @@ impl SpircTask {
531531
// finish after we received our last item of a type
532532
next_context = async {
533533
self.context_resolver.get_next_context(|| {
534+
// Sending local file URIs to this endpoint results in a Bad Request status.
535+
// It's likely appropriate to filter them out anyway; Spotify's backend
536+
// has no knowledge about these tracks and so can't do anything with them.
534537
self.connect_state.recent_track_uris()
538+
.into_iter()
539+
.filter(|t| !t.starts_with("spotify:local"))
540+
.collect::<Vec<_>>()
535541
}).await
536542
}, if allow_context_resolving && self.context_resolver.has_next() => {
537543
let update_state = self.handle_next_context(next_context);

connect/src/state/context.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -446,9 +446,6 @@ impl ConnectState {
446446
provider: Option<Provider>,
447447
) -> Result<ProvidedTrack, Error> {
448448
let id = match (ctx_track.uri.as_ref(), ctx_track.gid.as_ref()) {
449-
(Some(uri), _) if uri.contains(['?', '%']) => {
450-
Err(StateError::InvalidTrackUri(Some(uri.clone())))?
451-
}
452449
(Some(uri), _) if !uri.is_empty() => SpotifyUri::from_uri(uri)?,
453450
(_, Some(gid)) if !gid.is_empty() => SpotifyUri::Track {
454451
id: SpotifyId::from_raw(gid)?,

core/src/spotify_uri.rs

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::{Error, SpotifyId};
2-
use std::{borrow::Cow, fmt};
2+
use std::{borrow::Cow, fmt, str::FromStr, time::Duration};
33
use thiserror::Error;
44

55
use librespot_protocol as protocol;
@@ -65,7 +65,10 @@ pub enum SpotifyUri {
6565
impl SpotifyUri {
6666
/// Returns whether this `SpotifyUri` is for a playable audio item, if known.
6767
pub fn is_playable(&self) -> bool {
68-
matches!(self, SpotifyUri::Episode { .. } | SpotifyUri::Track { .. })
68+
matches!(
69+
self,
70+
SpotifyUri::Episode { .. } | SpotifyUri::Track { .. } | SpotifyUri::Local { .. }
71+
)
6972
}
7073

7174
/// Gets the item type of this URI as a static string
@@ -147,6 +150,7 @@ impl SpotifyUri {
147150
};
148151

149152
let name = parts.next().ok_or(SpotifyUriError::InvalidFormat)?;
153+
150154
match item_type {
151155
SPOTIFY_ITEM_TYPE_ALBUM => Ok(Self::Album {
152156
id: SpotifyId::from_base62(name)?,
@@ -167,12 +171,23 @@ impl SpotifyUri {
167171
SPOTIFY_ITEM_TYPE_TRACK => Ok(Self::Track {
168172
id: SpotifyId::from_base62(name)?,
169173
}),
170-
SPOTIFY_ITEM_TYPE_LOCAL => Ok(Self::Local {
171-
artist: "unimplemented".to_owned(),
172-
album_title: "unimplemented".to_owned(),
173-
track_title: "unimplemented".to_owned(),
174-
duration: Default::default(),
175-
}),
174+
SPOTIFY_ITEM_TYPE_LOCAL => {
175+
let artist = name;
176+
let album_title = parts.next().unwrap_or_default();
177+
// enforce track_title exists; spotify:local:::<duration> is a silly URI
178+
let track_title = parts.next().ok_or(SpotifyUriError::InvalidFormat)?;
179+
let duration_secs = parts
180+
.next()
181+
.and_then(|f| u64::from_str(f).ok())
182+
.ok_or(SpotifyUriError::InvalidFormat)?;
183+
184+
Ok(Self::Local {
185+
artist: artist.to_owned(),
186+
album_title: album_title.to_owned(),
187+
track_title: track_title.to_owned(),
188+
duration: Duration::from_secs(duration_secs),
189+
})
190+
}
176191
_ => Ok(Self::Unknown {
177192
kind: item_type.to_owned().into(),
178193
id: name.to_owned(),
@@ -533,15 +548,33 @@ mod tests {
533548

534549
#[test]
535550
fn from_local_uri() {
536-
let actual = SpotifyUri::from_uri("spotify:local:xyz:123").unwrap();
551+
let actual = SpotifyUri::from_uri(
552+
"spotify:local:David+Wise:Donkey+Kong+Country%3A+Tropical+Freeze:Snomads+Island:127",
553+
)
554+
.unwrap();
555+
556+
assert_eq!(
557+
actual,
558+
SpotifyUri::Local {
559+
artist: "David+Wise".to_owned(),
560+
album_title: "Donkey+Kong+Country%3A+Tropical+Freeze".to_owned(),
561+
track_title: "Snomads+Island".to_owned(),
562+
duration: Duration::from_secs(127),
563+
}
564+
);
565+
}
566+
567+
#[test]
568+
fn from_local_uri_missing_fields() {
569+
let actual = SpotifyUri::from_uri("spotify:local:::Snomads+Island:127").unwrap();
537570

538571
assert_eq!(
539572
actual,
540573
SpotifyUri::Local {
541-
artist: "unimplemented".to_owned(),
542-
album_title: "unimplemented".to_owned(),
543-
track_title: "unimplemented".to_owned(),
544-
duration: Default::default(),
574+
artist: "".to_owned(),
575+
album_title: "".to_owned(),
576+
track_title: "Snomads+Island".to_owned(),
577+
duration: Duration::from_secs(127),
545578
}
546579
);
547580
}

metadata/src/audio/item.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::fmt::Debug;
1+
use std::{fmt::Debug, path::PathBuf};
22

33
use crate::{
44
Metadata,
@@ -50,6 +50,16 @@ pub enum UniqueFields {
5050
number: u32,
5151
disc_number: u32,
5252
},
53+
Local {
54+
// artists / album_artists can't be a Vec here, they are retrieved from metadata as a String,
55+
// and we cannot make any assumptions about them being e.g. comma-separated
56+
artists: String,
57+
album: String,
58+
album_artists: String,
59+
number: u32,
60+
disc_number: u32,
61+
path: PathBuf,
62+
},
5363
Episode {
5464
description: String,
5565
publish_time: Date,

playback/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,4 @@ ogg = { version = "0.9", optional = true }
9393
# Dithering
9494
rand = { version = "0.9", default-features = false, features = ["small_rng"] }
9595
rand_distr = "0.5"
96+
regex = "1.11.2"

playback/src/config.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::{mem, str::FromStr, time::Duration};
1+
use std::{mem, path::PathBuf, str::FromStr, time::Duration};
22

33
pub use crate::dither::{DithererBuilder, TriangularDitherer, mk_ditherer};
44
use crate::{convert::i24, player::duration_to_coefficient};
@@ -136,6 +136,8 @@ pub struct PlayerConfig {
136136
pub normalisation_release_cf: f64,
137137
pub normalisation_knee_db: f64,
138138

139+
pub local_file_directories: Vec<PathBuf>,
140+
139141
// pass function pointers so they can be lazily instantiated *after* spawning a thread
140142
// (thereby circumventing Send bounds that they might not satisfy)
141143
pub ditherer: Option<DithererBuilder>,
@@ -160,6 +162,7 @@ impl Default for PlayerConfig {
160162
passthrough: false,
161163
ditherer: Some(mk_ditherer::<TriangularDitherer>),
162164
position_update_interval: None,
165+
local_file_directories: Vec::new(),
163166
}
164167
}
165168
}

0 commit comments

Comments
 (0)