1
0
Fork 0
mirror of https://github.com/librespot-org/librespot.git synced 2025-10-05 19:42:03 +02:00

feat: Add minimal local file support

This commit is contained in:
Jay Malhotra 2025-09-09 21:34:52 +01:00
parent bde9d9c46a
commit d0efe21c55
15 changed files with 553 additions and 34 deletions

View file

@ -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
- [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-dir` / `-l` option added to binary to specify local file directories to pull from
- [playback] `local_file_directories` field added to `PlayerConfig` struct (breaking)
- [metadata] `Local` variant added to `UniqueFields` enum (breaking)
### Changed
- [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
- [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
@ -70,14 +75,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [connect] Add `pause` parameter to `Spirc::disconnect` method (breaking)
- [connect] Add `volume_steps` to `ConnectConfig` (breaking)
- [connect] Add and enforce rustdoc
- [connect] Add `audio/local` to the `supported_types` field of the device capabilities.
- [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking)
- [playback] Add `PlayerEvent::PositionChanged` event to notify about the current playback position
- [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient`
- [core] Add `try_get_urls` to `CdnUrl`
- [oauth] Add `OAuthClient` and `OAuthClientBuilder` structs to achieve a more customizable login process
### Fixed
- [test] Missing bindgen breaks crossbuild on recent runners. Now installing latest bindgen in addition.

1
Cargo.lock generated
View file

@ -2040,6 +2040,7 @@ dependencies = [
"portaudio-rs",
"rand 0.9.2",
"rand_distr",
"regex",
"rodio",
"sdl2",
"shell-words",

View file

@ -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) -> Result<Self, Error> {
Ok(Self {
channel_tx: None,
stream_shared: None,
file_size: file_size as usize,
})
}
}
pub struct AudioFileStreaming {

View file

@ -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);

View file

@ -446,9 +446,6 @@ 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(['?', '%']) => {
Err(StateError::InvalidTrackUri(Some(uri.clone())))?
}
(Some(uri), _) if !uri.is_empty() => SpotifyUri::from_uri(uri)?,
(_, Some(gid)) if !gid.is_empty() => SpotifyUri::Track {
id: SpotifyId::from_raw(gid)?,

View file

@ -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;
@ -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
@ -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)?,
@ -167,12 +171,23 @@ 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().unwrap_or_default();
// enforce track_title exists; spotify:local:::<duration> is a silly URI
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(),
@ -533,15 +548,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: "unimplemented".to_owned(),
album_title: "unimplemented".to_owned(),
track_title: "unimplemented".to_owned(),
duration: Default::default(),
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: "".to_owned(),
album_title: "".to_owned(),
track_title: "Snomads+Island".to_owned(),
duration: Duration::from_secs(127),
}
);
}

View file

@ -1,4 +1,4 @@
use std::fmt::Debug;
use std::{fmt::Debug, path::PathBuf};
use crate::{
Metadata,
@ -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: String,
album: String,
album_artists: String,
number: u32,
disc_number: u32,
path: PathBuf,
},
Episode {
description: String,
publish_time: Date,

View file

@ -93,3 +93,4 @@ ogg = { version = "0.9", optional = true }
# Dithering
rand = { version = "0.9", default-features = false, features = ["small_rng"] }
rand_distr = "0.5"
regex = "1.11.2"

View file

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

View file

@ -1,6 +1,8 @@
use std::{io, time::Duration};
use symphonia::{
core::meta::{Metadata, MetadataOptions},
core::probe::{Hint, ProbedMetadata},
core::{
audio::SampleBuffer,
codecs::{Decoder, DecoderOptions},
@ -27,6 +29,18 @@ pub struct SymphoniaDecoder {
format: Box<dyn FormatReader>,
decoder: Box<dyn Decoder>,
sample_buffer: Option<SampleBuffer<f64>>,
probed_metadata: Option<ProbedMetadata>,
}
#[derive(Default)]
pub(crate) struct LocalFileMetadata {
pub name: String,
pub language: String,
pub album: String,
pub artists: String,
pub album_artists: String,
pub number: u32,
pub disc_number: u32,
}
impl SymphoniaDecoder {
@ -94,20 +108,62 @@ impl SymphoniaDecoder {
// We set the sample buffer when decoding the first full packet,
// whose duration is also the ideal sample buffer size.
sample_buffer: None,
probed_metadata: None,
})
}
pub(crate) fn new_with_probe<R>(src: R, extension: Option<&str>) -> DecoderResult<Self>
where
R: MediaSource + 'static,
{
let mss = MediaSourceStream::new(Box::new(src), Default::default());
let mut hint = Hint::new();
if let Some(extension) = extension {
hint.with_extension(extension);
}
let format_opts: FormatOptions = Default::default();
let metadata_opts: MetadataOptions = Default::default();
let decoder_opts: DecoderOptions = Default::default();
let probed =
symphonia::default::get_probe().format(&hint, mss, &format_opts, &metadata_opts)?;
let format = probed.format;
let track = format.default_track().ok_or_else(|| {
DecoderError::SymphoniaDecoder("Could not retrieve default track".into())
})?;
let decoder = symphonia::default::get_codecs().make(&track.codec_params, &decoder_opts)?;
let rate = decoder.codec_params().sample_rate.ok_or_else(|| {
DecoderError::SymphoniaDecoder("Could not retrieve sample rate".into())
})?;
// TODO: The official client supports local files with sample rates other than 44,100 kHz.
// To play these accurately, we need to either resample the input audio, or introduce a way
// to change the player's current sample rate (likely by closing and re-opening the sink
// with new parameters).
if rate != SAMPLE_RATE {
return Err(DecoderError::SymphoniaDecoder(format!(
"Unsupported sample rate: {rate}. Local files must have a sample rate of {SAMPLE_RATE} Hz."
)));
}
Ok(Self {
format,
decoder,
sample_buffer: None,
probed_metadata: Some(probed.metadata),
})
}
pub fn normalisation_data(&mut self) -> Option<NormalisationData> {
let mut metadata = self.format.metadata();
// Advance to the latest metadata revision.
// None means we hit the latest.
loop {
if metadata.pop().is_none() {
break;
}
}
let metadata = self.metadata()?;
let tags = metadata.current()?.tags();
if tags.is_empty() {
@ -131,6 +187,70 @@ impl SymphoniaDecoder {
}
}
pub(crate) fn local_file_metadata(&mut self) -> Option<LocalFileMetadata> {
let metadata = self.metadata()?;
let tags = metadata.current()?.tags();
let mut metadata = LocalFileMetadata::default();
for tag in tags {
if let Value::String(value) = &tag.value {
match tag.std_key {
// We could possibly use mem::take here to avoid cloning, but that risks leaving
// the audio item metadata in a bad state.
Some(StandardTagKey::TrackTitle) => metadata.name = value.clone(),
Some(StandardTagKey::Language) => metadata.language = value.clone(),
Some(StandardTagKey::Artist) => metadata.artists = value.clone(),
Some(StandardTagKey::AlbumArtist) => metadata.album_artists = value.clone(),
Some(StandardTagKey::Album) => metadata.album = value.clone(),
Some(StandardTagKey::TrackNumber) => {
metadata.number = value.parse::<u32>().unwrap_or_default()
}
Some(StandardTagKey::DiscNumber) => {
metadata.disc_number = value.parse::<u32>().unwrap_or_default()
}
_ => (),
}
} else if let Value::UnsignedInt(value) = &tag.value {
match tag.std_key {
Some(StandardTagKey::TrackNumber) => metadata.number = *value as u32,
Some(StandardTagKey::DiscNumber) => metadata.disc_number = *value as u32,
_ => (),
}
} else if let Value::SignedInt(value) = &tag.value {
match tag.std_key {
Some(StandardTagKey::TrackNumber) => metadata.number = *value as u32,
Some(StandardTagKey::DiscNumber) => metadata.disc_number = *value as u32,
_ => (),
}
}
}
Some(metadata)
}
fn metadata(&mut self) -> Option<Metadata> {
let mut metadata = self.format.metadata();
// If we can't get metadata from the container, fall back to other tags found by probing.
// Note that this is only relevant for local files.
if metadata.current().is_none()
&& let Some(ref mut probe_metadata) = self.probed_metadata
&& let Some(inner_probe_metadata) = probe_metadata.get()
{
metadata = inner_probe_metadata;
}
// Advance to the latest metadata revision.
// None means we hit the latest.
loop {
if metadata.pop().is_none() {
break;
}
}
Some(metadata)
}
#[inline]
fn ts_to_ms(&self, ts: u64) -> u32 {
match self.decoder.codec_params().time_base {

View file

@ -10,6 +10,7 @@ pub mod config;
pub mod convert;
pub mod decoder;
pub mod dither;
mod local_file;
pub mod mixer;
pub mod player;

182
playback/src/local_file.rs Normal file
View file

@ -0,0 +1,182 @@
use librespot_core::{Error, SpotifyUri};
use regex::{Captures, Regex};
use std::sync::LazyLock;
use std::{
collections::HashMap,
fs,
fs::File,
io,
path::{Path, PathBuf},
time::Duration,
};
use symphonia::{
core::formats::FormatOptions,
core::io::MediaSourceStream,
core::meta::{MetadataOptions, StandardTagKey, Tag},
core::probe::{Hint, ProbeResult},
};
// "Spotify supports .mp3, .mp4, and .m4p files. It doesnt support .mp4 files that contain video,
// or the iTunes lossless format (M4A)."
// https://community.spotify.com/t5/FAQs/Local-Files/ta-p/5186118
//
// There are some indications online that FLAC is supported, so check for this as well.
const SUPPORTED_FILE_EXTENSIONS: &[&str; 4] = &["mp3", "mp4", "m4p", "flac"];
#[derive(Default)]
pub struct LocalFileLookup(HashMap<SpotifyUri, PathBuf>);
impl LocalFileLookup {
pub fn get(&self, uri: &SpotifyUri) -> Option<&Path> {
self.0.get(uri).map(|p| p.as_path())
}
}
pub fn create_local_file_lookup(directories: &[PathBuf]) -> LocalFileLookup {
let mut lookup = LocalFileLookup(HashMap::new());
for path in directories {
if !path.is_dir() {
warn!(
"Ignoring local file source {}: not a directory",
path.display()
);
continue;
}
if let Err(e) = visit_dir(path, &mut lookup) {
warn!(
"Failed to load entries from local file source {}: {}",
path.display(),
e
);
}
}
lookup
}
fn visit_dir(dir: &Path, accumulator: &mut LocalFileLookup) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
visit_dir(&path, accumulator)?;
} else {
let Some(extension) = path.extension().and_then(|e| e.to_str()) else {
continue;
};
let lowercase_extension = extension.to_lowercase();
if SUPPORTED_FILE_EXTENSIONS.contains(&lowercase_extension.as_str()) {
let uri = match get_uri_from_file(path.as_path(), extension) {
Ok(uri) => uri,
Err(e) => {
warn!(
"Failed to determine URI of local file {}: {}",
path.display(),
e
);
continue;
}
};
accumulator.0.insert(uri, path);
}
}
}
Ok(())
}
fn get_uri_from_file(audio_path: &Path, extension: &str) -> Result<SpotifyUri, Error> {
let src = File::open(audio_path)?;
let mss = MediaSourceStream::new(Box::new(src), Default::default());
let mut hint = Hint::new();
hint.with_extension(extension);
let meta_opts: MetadataOptions = Default::default();
let fmt_opts: FormatOptions = Default::default();
let mut probed = symphonia::default::get_probe()
.format(&hint, mss, &fmt_opts, &meta_opts)
.map_err(|_| Error::internal("Failed to probe file"))?;
let mut artist: Option<String> = None;
let mut album_title: Option<String> = None;
let mut track_title: Option<String> = None;
let tags = get_tags(&mut probed).ok_or(Error::internal("Failed to probe audio tags"))?;
for tag in tags {
if let Some(std_key) = tag.std_key {
match std_key {
StandardTagKey::Album => {
album_title.replace(tag.value.to_string());
}
StandardTagKey::Artist => {
artist.replace(tag.value.to_string());
}
StandardTagKey::TrackTitle => {
track_title.replace(tag.value.to_string());
}
_ => {
continue;
}
}
}
}
let first_track = probed
.format
.default_track()
.ok_or(Error::internal("Failed to find an audio track"))?;
let time_base = first_track
.codec_params
.time_base
.ok_or(Error::internal("Failed to calculate track duration"))?;
let num_frames = first_track
.codec_params
.n_frames
.ok_or(Error::internal("Failed to calculate track duration"))?;
let time = time_base.calc_time(num_frames);
Ok(SpotifyUri::Local {
artist: format_uri_component(artist),
album_title: format_uri_component(album_title),
track_title: format_uri_component(track_title),
duration: Duration::from_secs(time.seconds),
})
}
fn get_tags(probed: &mut ProbeResult) -> Option<Vec<Tag>> {
if let Some(metadata_rev) = probed.format.metadata().current() {
return Some(metadata_rev.tags().to_vec());
}
if let Some(metadata_rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) {
return Some(metadata_rev.tags().to_vec());
}
None
}
fn url_encode(input: &str) -> String {
static ENCODE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[#$&'()*+,/:;=?@\[\]\s]").unwrap());
ENCODE_REGEX
.replace_all(input, |caps: &Captures| match &caps[0] {
" " => "+".to_owned(),
_ => format!("%{:X}", &caps[0].as_bytes()[0]),
})
.into_owned()
}
fn format_uri_component(input: Option<String>) -> String {
input.as_deref().map(url_encode).unwrap_or("".to_owned())
}

View file

@ -1,6 +1,7 @@
use std::{
collections::HashMap,
fmt,
fmt, fs,
fs::File,
future::Future,
io::{self, Read, Seek, SeekFrom},
mem,
@ -25,6 +26,7 @@ use crate::{
convert::Converter,
core::{Error, Session, SpotifyId, SpotifyUri, util::SeqGenerator},
decoder::{AudioDecoder, AudioPacket, AudioPacketPosition, SymphoniaDecoder},
local_file::{LocalFileLookup, create_local_file_lookup},
metadata::audio::{AudioFileFormat, AudioFiles, AudioItem},
mixer::VolumeGetter,
};
@ -32,7 +34,8 @@ use futures_util::{
StreamExt, TryFutureExt, future, future::FusedFuture,
stream::futures_unordered::FuturesUnordered,
};
use librespot_metadata::track::Tracks;
use librespot_metadata::{audio::UniqueFields, track::Tracks};
use symphonia::core::io::MediaSource;
use tokio::sync::{mpsc, oneshot};
@ -89,6 +92,8 @@ struct PlayerInternal {
player_id: usize,
play_request_id_generator: SeqGenerator<u64>,
last_progress_update: Instant,
local_file_lookup: Arc<LocalFileLookup>,
}
static PLAYER_COUNTER: AtomicUsize = AtomicUsize::new(0);
@ -471,6 +476,12 @@ impl Player {
let converter = Converter::new(config.ditherer);
let normalisation_knee_factor = 1.0 / (8.0 * config.normalisation_knee_db);
// TODO: it would be neat if we could watch for added or modified files in the
// specified directories, and dynamically update the lookup. Currently, a new player
// must be created for any new local files to be playable.
let local_file_lookup =
create_local_file_lookup(config.local_file_directories.as_slice());
let internal = PlayerInternal {
session,
config,
@ -496,6 +507,8 @@ impl Player {
player_id,
play_request_id_generator: SeqGenerator::new(0),
last_progress_update: Instant::now(),
local_file_lookup: Arc::new(local_file_lookup),
};
// While PlayerInternal is written as a future, it still contains blocking code.
@ -885,6 +898,7 @@ impl PlayerState {
struct PlayerTrackLoader {
session: Session,
config: PlayerConfig,
local_file_lookup: Arc<LocalFileLookup>,
}
impl PlayerTrackLoader {
@ -948,6 +962,7 @@ impl PlayerTrackLoader {
SpotifyUri::Track { .. } | SpotifyUri::Episode { .. } => {
self.load_remote_track(track_uri, position_ms).await
}
SpotifyUri::Local { .. } => self.load_local_track(track_uri, position_ms).await,
_ => {
error!("Cannot handle load of track with URI: <{track_uri}>",);
None
@ -1191,6 +1206,108 @@ impl PlayerTrackLoader {
});
}
}
async fn load_local_track(
&self,
track_uri: SpotifyUri,
position_ms: u32,
) -> Option<PlayerLoadedTrackData> {
let entry = self.local_file_lookup.get(&track_uri);
let Some(path) = entry else {
error!("Unable to find file path for local file <{track_uri}>");
return None;
};
debug!(
"Located file path '{}' for local file <{track_uri}>",
path.display()
);
let src = match File::open(path) {
Ok(src) => src,
Err(e) => {
error!("Failed to open local file: {e}");
return None;
}
};
let decoder = match SymphoniaDecoder::new_with_probe(
src,
path.extension().and_then(|e| e.to_str()),
) {
Ok(decoder) => decoder,
Err(e) => {
error!("Error decoding local file: {e}");
return None;
}
};
let mut decoder = Box::new(decoder);
let normalisation_data = decoder.normalisation_data().unwrap_or_else(|| {
warn!("Unable to get normalisation data, continuing with defaults.");
NormalisationData::default()
});
let local_file_metadata = decoder.local_file_metadata().unwrap_or_default();
let stream_position_ms = match decoder.seek(position_ms) {
Ok(new_position_ms) => new_position_ms,
Err(e) => {
error!(
"PlayerTrackLoader::load_local_track error seeking to starting position {position_ms}: {e}"
);
return None;
}
};
let SpotifyUri::Local { duration, .. } = track_uri else {
error!("Unable to determine track duration for local file: not a local file URI");
return None;
};
let file_size = fs::metadata(path).ok()?.len();
let bytes_per_second = (file_size / duration.as_secs()) as usize;
let stream_loader_controller = match StreamLoaderController::from_local_file(file_size) {
Ok(c) => c,
Err(e) => {
error!("Unable to create local StreamLoaderController: {e}");
return None;
}
};
Some(PlayerLoadedTrackData {
decoder,
normalisation_data,
stream_loader_controller,
bytes_per_second,
duration_ms: duration.as_millis() as u32,
stream_position_ms,
is_explicit: false,
audio_item: AudioItem {
duration_ms: duration.as_millis() as u32,
uri: track_uri.to_uri().unwrap_or_default(),
track_id: track_uri,
files: Default::default(),
name: local_file_metadata.name,
// We can't get a CoverImage.URL for the track image, applications will have to parse the file metadata themselves using unique_fields.path
covers: vec![],
language: vec![local_file_metadata.language],
is_explicit: false,
availability: Ok(()),
alternatives: None,
unique_fields: UniqueFields::Local {
artists: local_file_metadata.artists,
album: local_file_metadata.album,
album_artists: local_file_metadata.album_artists,
number: local_file_metadata.number,
disc_number: local_file_metadata.disc_number,
path: path.to_path_buf(),
},
},
})
}
}
impl Future for PlayerInternal {
@ -2270,6 +2387,7 @@ impl PlayerInternal {
let loader = PlayerTrackLoader {
session: self.session.clone(),
config: self.config.clone(),
local_file_lookup: self.local_file_lookup.clone(),
};
let (result_tx, result_rx) = oneshot::channel();

View file

@ -283,6 +283,7 @@ async fn get_setup() -> Setup {
const ZEROCONF_PORT: &str = "zeroconf-port";
const ZEROCONF_INTERFACE: &str = "zeroconf-interface";
const ZEROCONF_BACKEND: &str = "zeroconf-backend";
const LOCAL_FILE_DIR: &str = "local-file-dir";
// Mostly arbitrary.
const AP_PORT_SHORT: &str = "a";
@ -335,6 +336,7 @@ async fn get_setup() -> Setup {
const NORMALISATION_THRESHOLD_SHORT: &str = "Z";
const ZEROCONF_PORT_SHORT: &str = "z";
const ZEROCONF_BACKEND_SHORT: &str = ""; // no short flag
const LOCAL_FILE_DIR_SHORT: &str = "l";
// Options that have different descriptions
// depending on what backends were enabled at build time.
@ -660,6 +662,11 @@ async fn get_setup() -> Setup {
ZEROCONF_BACKEND,
"Zeroconf (MDNS/DNS-SD) backend to use. Valid values are 'avahi', 'dns-sd' and 'libmdns', if librespot is compiled with the corresponding feature flags.",
"BACKEND"
).optmulti(
LOCAL_FILE_DIR_SHORT,
LOCAL_FILE_DIR,
"Directory to search for local file playback. Can be specified multiple times to add multiple search directories",
"DIRECTORY"
);
#[cfg(feature = "passthrough-decoder")]
@ -1380,6 +1387,12 @@ async fn get_setup() -> Setup {
})
});
let local_file_directories = matches
.opt_strs(LOCAL_FILE_DIR)
.into_iter()
.map(PathBuf::from)
.collect::<Vec<_>>();
let connect_config = {
let connect_default_config = ConnectConfig::default();
@ -1819,6 +1832,7 @@ async fn get_setup() -> Setup {
normalisation_knee_db,
ditherer,
position_update_interval: None,
local_file_directories,
}
};

View file

@ -87,6 +87,28 @@ impl EventHandler {
env_vars
.insert("DISC_NUMBER", disc_number.to_string());
}
UniqueFields::Local {
artists,
album,
album_artists,
number,
disc_number,
path,
} => {
env_vars.insert("ITEM_TYPE", "Track".to_string());
env_vars.insert("ARTISTS", artists);
env_vars.insert("ALBUM_ARTISTS", album_artists);
env_vars.insert("ALBUM", album);
env_vars.insert("NUMBER", number.to_string());
env_vars
.insert("DISC_NUMBER", disc_number.to_string());
env_vars.insert(
"LOCAL_FILE_PATH",
path.into_os_string()
.into_string()
.unwrap_or_default(),
);
}
UniqueFields::Episode {
description,
publish_time,