Skip to content

Commit 9f8be59

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

File tree

15 files changed

+551
-31
lines changed

15 files changed

+551
-31
lines changed

CHANGELOG.md

Lines changed: 7 additions & 3 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
@@ -77,7 +82,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7782
- [core] Add `try_get_urls` to `CdnUrl`
7883
- [oauth] Add `OAuthClient` and `OAuthClientBuilder` structs to achieve a more customizable login process
7984

80-
8185
### Fixed
8286

8387
- [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
}

playback/src/decoder/symphonia_decoder.rs

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use std::{io, time::Duration};
22

33
use symphonia::{
4+
core::meta::{Metadata, MetadataOptions},
5+
core::probe::{Hint, ProbedMetadata},
46
core::{
57
audio::SampleBuffer,
68
codecs::{Decoder, DecoderOptions},
@@ -27,6 +29,18 @@ pub struct SymphoniaDecoder {
2729
format: Box<dyn FormatReader>,
2830
decoder: Box<dyn Decoder>,
2931
sample_buffer: Option<SampleBuffer<f64>>,
32+
probed_metadata: Option<ProbedMetadata>,
33+
}
34+
35+
#[derive(Default)]
36+
pub(crate) struct LocalFileMetadata {
37+
pub name: String,
38+
pub language: String,
39+
pub album: String,
40+
pub artists: String,
41+
pub album_artists: String,
42+
pub number: u32,
43+
pub disc_number: u32,
3044
}
3145

3246
impl SymphoniaDecoder {
@@ -94,20 +108,62 @@ impl SymphoniaDecoder {
94108
// We set the sample buffer when decoding the first full packet,
95109
// whose duration is also the ideal sample buffer size.
96110
sample_buffer: None,
111+
112+
probed_metadata: None,
97113
})
98114
}
99115

100-
pub fn normalisation_data(&mut self) -> Option<NormalisationData> {
101-
let mut metadata = self.format.metadata();
116+
pub(crate) fn new_with_probe<R>(src: R, extension: Option<&str>) -> DecoderResult<Self>
117+
where
118+
R: MediaSource + 'static,
119+
{
120+
let mss = MediaSourceStream::new(Box::new(src), Default::default());
102121

103-
// Advance to the latest metadata revision.
104-
// None means we hit the latest.
105-
loop {
106-
if metadata.pop().is_none() {
107-
break;
108-
}
122+
let mut hint = Hint::new();
123+
124+
if let Some(extension) = extension {
125+
hint.with_extension(extension);
126+
}
127+
128+
let format_opts: FormatOptions = Default::default();
129+
let metadata_opts: MetadataOptions = Default::default();
130+
let decoder_opts: DecoderOptions = Default::default();
131+
132+
let probed =
133+
symphonia::default::get_probe().format(&hint, mss, &format_opts, &metadata_opts)?;
134+
135+
let format = probed.format;
136+
137+
let track = format.default_track().ok_or_else(|| {
138+
DecoderError::SymphoniaDecoder("Could not retrieve default track".into())
139+
})?;
140+
141+
let decoder = symphonia::default::get_codecs().make(&track.codec_params, &decoder_opts)?;
142+
143+
let rate = decoder.codec_params().sample_rate.ok_or_else(|| {
144+
DecoderError::SymphoniaDecoder("Could not retrieve sample rate".into())
145+
})?;
146+
147+
// TODO: The official client supports local files with sample rates other than 44,100 kHz.
148+
// To play these accurately, we need to either resample the input audio, or introduce a way
149+
// to change the player's current sample rate (likely by closing and re-opening the sink
150+
// with new parameters).
151+
if rate != SAMPLE_RATE {
152+
return Err(DecoderError::SymphoniaDecoder(format!(
153+
"Unsupported sample rate: {rate}. Local files must have a sample rate of {SAMPLE_RATE} Hz."
154+
)));
109155
}
110156

157+
Ok(Self {
158+
format,
159+
decoder,
160+
sample_buffer: None,
161+
probed_metadata: Some(probed.metadata),
162+
})
163+
}
164+
165+
pub fn normalisation_data(&mut self) -> Option<NormalisationData> {
166+
let metadata = self.metadata()?;
111167
let tags = metadata.current()?.tags();
112168

113169
if tags.is_empty() {
@@ -131,6 +187,70 @@ impl SymphoniaDecoder {
131187
}
132188
}
133189

190+
pub(crate) fn local_file_metadata(&mut self) -> Option<LocalFileMetadata> {
191+
let metadata = self.metadata()?;
192+
let tags = metadata.current()?.tags();
193+
let mut metadata = LocalFileMetadata::default();
194+
195+
for tag in tags {
196+
if let Value::String(value) = &tag.value {
197+
match tag.std_key {
198+
// We could possibly use mem::take here to avoid cloning, but that risks leaving
199+
// the audio item metadata in a bad state.
200+
Some(StandardTagKey::TrackTitle) => metadata.name = value.clone(),
201+
Some(StandardTagKey::Language) => metadata.language = value.clone(),
202+
Some(StandardTagKey::Artist) => metadata.artists = value.clone(),
203+
Some(StandardTagKey::AlbumArtist) => metadata.album_artists = value.clone(),
204+
Some(StandardTagKey::Album) => metadata.album = value.clone(),
205+
Some(StandardTagKey::TrackNumber) => {
206+
metadata.number = value.parse::<u32>().unwrap_or_default()
207+
}
208+
Some(StandardTagKey::DiscNumber) => {
209+
metadata.disc_number = value.parse::<u32>().unwrap_or_default()
210+
}
211+
_ => (),
212+
}
213+
} else if let Value::UnsignedInt(value) = &tag.value {
214+
match tag.std_key {
215+
Some(StandardTagKey::TrackNumber) => metadata.number = *value as u32,
216+
Some(StandardTagKey::DiscNumber) => metadata.disc_number = *value as u32,
217+
_ => (),
218+
}
219+
} else if let Value::SignedInt(value) = &tag.value {
220+
match tag.std_key {
221+
Some(StandardTagKey::TrackNumber) => metadata.number = *value as u32,
222+
Some(StandardTagKey::DiscNumber) => metadata.disc_number = *value as u32,
223+
_ => (),
224+
}
225+
}
226+
}
227+
228+
Some(metadata)
229+
}
230+
231+
fn metadata(&mut self) -> Option<Metadata> {
232+
let mut metadata = self.format.metadata();
233+
234+
// If we can't get metadata from the container, fall back to other tags found by probing.
235+
// Note that this is only relevant for local files.
236+
if metadata.current().is_none()
237+
&& let Some(ref mut probe_metadata) = self.probed_metadata
238+
&& let Some(inner_probe_metadata) = probe_metadata.get()
239+
{
240+
metadata = inner_probe_metadata;
241+
}
242+
243+
// Advance to the latest metadata revision.
244+
// None means we hit the latest.
245+
loop {
246+
if metadata.pop().is_none() {
247+
break;
248+
}
249+
}
250+
251+
Some(metadata)
252+
}
253+
134254
#[inline]
135255
fn ts_to_ms(&self, ts: u64) -> u32 {
136256
match self.decoder.codec_params().time_base {

0 commit comments

Comments
 (0)