Skip to content

Commit cc818da

Browse files
feat: Basic local file support (#1595)
* 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/<id>` 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. * feat: Add minimal local file support * fix: Fix "q".parse
1 parent 84a3302 commit cc818da

18 files changed

Lines changed: 565 additions & 76 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- [core] Add method `transfer` to `SpClient`
1414
- [core] Add `SpotifyUri` type to represent more types of URI than `SpotifyId` can
1515
- [discovery] Add support for [device aliases](https://developer.spotify.com/documentation/commercial-hardware/implementation/guides/zeroconf#device-aliases)
16+
- [main] `--local-file-dir` / `-l` option added to binary to specify local file directories to pull from
17+
- [metadata] `Local` variant added to `UniqueFields` enum (breaking)
18+
- [playback] Local files can now be played with the following caveats:
19+
- They must be sampled at 44,100 Hz
20+
- They cannot be played from a Connect device using the dedicated 'Local Files' playlist; they must be added to another playlist first
21+
- [playback] `local_file_directories` field added to `PlayerConfig` struct (breaking)
22+
1623

1724
### Changed
1825

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) -> Self {
305+
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
@@ -544,7 +544,13 @@ impl SpircTask {
544544
// finish after we received our last item of a type
545545
next_context = async {
546546
self.context_resolver.get_next_context(|| {
547+
// Sending local file URIs to this endpoint results in a Bad Request status.
548+
// It's likely appropriate to filter them out anyway; Spotify's backend
549+
// has no knowledge about these tracks and so can't do anything with them.
547550
self.connect_state.recent_track_uris()
551+
.into_iter()
552+
.filter(|t| !t.starts_with("spotify:local"))
553+
.collect::<Vec<_>>()
548554
}).await
549555
}, if allow_context_resolving && self.context_resolver.has_next() => {
550556
let update_state = self.handle_next_context(next_context);

connect/src/state.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,11 @@ impl ConnectState {
161161
supports_gzip_pushes: true,
162162
// todo: enable after logout handling is implemented, see spirc logout_request
163163
supports_logout: false,
164-
supported_types: vec!["audio/episode".into(), "audio/track".into()],
164+
supported_types: vec![
165+
"audio/episode".into(),
166+
"audio/track".into(),
167+
"audio/local".into(),
168+
],
165169
supports_playlist_v2: true,
166170
supports_transfer_command: true,
167171
supports_command_request: true,

connect/src/state/context.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ 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(['?', '%']) => {
449+
(Some(uri), _) if uri.contains(['?']) => {
450450
Err(StateError::InvalidTrackUri(Some(uri.clone())))?
451451
}
452452
(Some(uri), _) if !uri.is_empty() => SpotifyUri::from_uri(uri)?,

core/src/spotify_uri.rs

Lines changed: 45 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,22 @@ 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().ok_or(SpotifyUriError::InvalidFormat)?;
177+
let track_title = parts.next().ok_or(SpotifyUriError::InvalidFormat)?;
178+
let duration_secs = parts
179+
.next()
180+
.and_then(|f| u64::from_str(f).ok())
181+
.ok_or(SpotifyUriError::InvalidFormat)?;
182+
183+
Ok(Self::Local {
184+
artist: artist.to_owned(),
185+
album_title: album_title.to_owned(),
186+
track_title: track_title.to_owned(),
187+
duration: Duration::from_secs(duration_secs),
188+
})
189+
}
176190
_ => Ok(Self::Unknown {
177191
kind: item_type.to_owned().into(),
178192
id: name.to_owned(),
@@ -533,15 +547,33 @@ mod tests {
533547

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

538570
assert_eq!(
539571
actual,
540572
SpotifyUri::Local {
541-
artist: "unimplemented".to_owned(),
542-
album_title: "unimplemented".to_owned(),
543-
track_title: "unimplemented".to_owned(),
544-
duration: Default::default(),
573+
artist: "".to_owned(),
574+
album_title: "".to_owned(),
575+
track_title: "Snomads+Island".to_owned(),
576+
duration: Duration::from_secs(127),
545577
}
546578
);
547579
}

metadata/src/audio/file.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ impl AudioFiles {
103103
pub fn is_flac(format: AudioFileFormat) -> bool {
104104
matches!(format, AudioFileFormat::FLAC_FLAC)
105105
}
106+
107+
pub fn mime_type(format: AudioFileFormat) -> Option<&'static str> {
108+
if Self::is_ogg_vorbis(format) {
109+
Some("audio/ogg")
110+
} else if Self::is_mp3(format) {
111+
Some("audio/mpeg")
112+
} else if Self::is_flac(format) {
113+
Some("audio/flac")
114+
} else {
115+
None
116+
}
117+
}
106118
}
107119

108120
impl From<&[AudioFileMessage]> for AudioFiles {

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: Option<String>,
57+
album: Option<String>,
58+
album_artists: Option<String>,
59+
number: Option<u32>,
60+
disc_number: Option<u32>,
61+
path: PathBuf,
62+
},
5363
Episode {
5464
description: String,
5565
publish_time: Date,

playback/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,6 @@ ogg = { version = "0.9", optional = true }
9494
# Dithering
9595
rand = { version = "0.9", default-features = false, features = ["small_rng"] }
9696
rand_distr = "0.5"
97+
98+
# Local file handling
99+
form_urlencoded = "1.2.2"

0 commit comments

Comments
 (0)