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:
parent
bde9d9c46a
commit
d0efe21c55
15 changed files with 553 additions and 34 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
@ -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
1
Cargo.lock
generated
|
@ -2040,6 +2040,7 @@ dependencies = [
|
|||
"portaudio-rs",
|
||||
"rand 0.9.2",
|
||||
"rand_distr",
|
||||
"regex",
|
||||
"rodio",
|
||||
"sdl2",
|
||||
"shell-words",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)?,
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
182
playback/src/local_file.rs
Normal 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 doesn’t 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())
|
||||
}
|
|
@ -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();
|
||||
|
|
14
src/main.rs
14
src/main.rs
|
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue