diff --git a/CHANGELOG.md b/CHANGELOG.md index a11d932e4..53d2f303e 100644 --- a/CHANGELOG.md +++ b/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 - +- [main] `--local-file-dir` / `-l` option added to binary to specify local file directories to pull from +- [metadata] `Local` variant added to `UniqueFields` enum (breaking) +- [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_directories` field added to `PlayerConfig` struct (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 diff --git a/Cargo.lock b/Cargo.lock index 7e2744964..b28a3d0b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,6 +2024,7 @@ version = "0.7.1" dependencies = [ "alsa 0.10.0", "cpal", + "form_urlencoded", "futures-util", "gstreamer", "gstreamer-app", diff --git a/audio/src/fetch/mod.rs b/audio/src/fetch/mod.rs index 6a6379b96..ab440f641 100644 --- a/audio/src/fetch/mod.rs +++ b/audio/src/fetch/mod.rs @@ -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) -> Self { + Self { + channel_tx: None, + stream_shared: None, + file_size: file_size as usize, + } + } } pub struct AudioFileStreaming { diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 43702d8a4..2e7326ad0 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -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::>() }).await }, if allow_context_resolving && self.context_resolver.has_next() => { let update_state = self.handle_next_context(next_context); diff --git a/connect/src/state.rs b/connect/src/state.rs index a84de2346..b4ee61b2f 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -161,7 +161,11 @@ impl ConnectState { supports_gzip_pushes: true, // todo: enable after logout handling is implemented, see spirc logout_request supports_logout: false, - supported_types: vec!["audio/episode".into(), "audio/track".into()], + supported_types: vec![ + "audio/episode".into(), + "audio/track".into(), + "audio/local".into(), + ], supports_playlist_v2: true, supports_transfer_command: true, supports_command_request: true, diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index e2b78720e..838b10485 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -446,7 +446,7 @@ impl ConnectState { provider: Option, ) -> Result { let id = match (ctx_track.uri.as_ref(), ctx_track.gid.as_ref()) { - (Some(uri), _) if uri.contains(['?', '%']) => { + (Some(uri), _) if uri.contains(['?']) => { Err(StateError::InvalidTrackUri(Some(uri.clone())))? } (Some(uri), _) if !uri.is_empty() => SpotifyUri::from_uri(uri)?, diff --git a/core/src/spotify_uri.rs b/core/src/spotify_uri.rs index 647ec652b..f92f2d3bd 100644 --- a/core/src/spotify_uri.rs +++ b/core/src/spotify_uri.rs @@ -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,22 @@ 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().ok_or(SpotifyUriError::InvalidFormat)?; + 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 +547,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: "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: "unimplemented".to_owned(), - album_title: "unimplemented".to_owned(), - track_title: "unimplemented".to_owned(), - duration: Default::default(), + artist: "".to_owned(), + album_title: "".to_owned(), + track_title: "Snomads+Island".to_owned(), + duration: Duration::from_secs(127), } ); } diff --git a/metadata/src/audio/file.rs b/metadata/src/audio/file.rs index cf70a88d6..126993216 100644 --- a/metadata/src/audio/file.rs +++ b/metadata/src/audio/file.rs @@ -103,6 +103,18 @@ impl AudioFiles { pub fn is_flac(format: AudioFileFormat) -> bool { matches!(format, AudioFileFormat::FLAC_FLAC) } + + pub fn mime_type(format: AudioFileFormat) -> Option<&'static str> { + if Self::is_ogg_vorbis(format) { + Some("audio/ogg") + } else if Self::is_mp3(format) { + Some("audio/mpeg") + } else if Self::is_flac(format) { + Some("audio/flac") + } else { + None + } + } } impl From<&[AudioFileMessage]> for AudioFiles { diff --git a/metadata/src/audio/item.rs b/metadata/src/audio/item.rs index 3df63d9e8..f2cab9462 100644 --- a/metadata/src/audio/item.rs +++ b/metadata/src/audio/item.rs @@ -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: Option, + album: Option, + album_artists: Option, + number: Option, + disc_number: Option, + path: PathBuf, + }, Episode { description: String, publish_time: Date, diff --git a/playback/Cargo.toml b/playback/Cargo.toml index d8414a817..f4150aebd 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -94,3 +94,6 @@ ogg = { version = "0.9", optional = true } # Dithering rand = { version = "0.9", default-features = false, features = ["small_rng"] } rand_distr = "0.5" + +# Local file handling +form_urlencoded = "1.2.2" diff --git a/playback/src/config.rs b/playback/src/config.rs index a747ce38d..f919df190 100644 --- a/playback/src/config.rs +++ b/playback/src/config.rs @@ -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, + // 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, @@ -160,6 +162,7 @@ impl Default for PlayerConfig { passthrough: false, ditherer: Some(mk_ditherer::), position_update_interval: None, + local_file_directories: Vec::new(), } } } diff --git a/playback/src/decoder/symphonia_decoder.rs b/playback/src/decoder/symphonia_decoder.rs index 2aa2a3e08..21032cb9f 100644 --- a/playback/src/decoder/symphonia_decoder.rs +++ b/playback/src/decoder/symphonia_decoder.rs @@ -1,36 +1,38 @@ use std::{io, time::Duration}; -use symphonia::{ - core::{ - audio::SampleBuffer, - codecs::{Decoder, DecoderOptions}, - errors::Error, - formats::{FormatOptions, FormatReader, SeekMode, SeekTo}, - io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}, - meta::{StandardTagKey, Value}, - }, - default::{ - codecs::{FlacDecoder, MpaDecoder, VorbisDecoder}, - formats::{FlacReader, MpaReader, OggReader}, - }, +use symphonia::core::{ + audio::SampleBuffer, + codecs::{Decoder, DecoderOptions}, + errors::Error, + formats::{FormatOptions, SeekMode, SeekTo}, + io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}, + meta::{MetadataOptions, StandardTagKey, Value}, + probe::{Hint, ProbeResult}, }; use super::{AudioDecoder, AudioPacket, AudioPacketPosition, DecoderError, DecoderResult}; -use crate::{ - NUM_CHANNELS, PAGES_PER_MS, SAMPLE_RATE, - metadata::audio::{AudioFileFormat, AudioFiles}, - player::NormalisationData, -}; +use crate::{NUM_CHANNELS, PAGES_PER_MS, SAMPLE_RATE, player::NormalisationData, symphonia_util}; pub struct SymphoniaDecoder { - format: Box, + probe_result: ProbeResult, decoder: Box, sample_buffer: Option>, } +#[derive(Default)] +pub(crate) struct LocalFileMetadata { + pub name: Option, + pub language: Option, + pub album: Option, + pub artists: Option, + pub album_artists: Option, + pub number: Option, + pub disc_number: Option, +} + impl SymphoniaDecoder { - pub fn new(input: R, file_format: AudioFileFormat) -> DecoderResult + pub fn new(input: R, hint: Hint) -> DecoderResult where R: MediaSource + 'static, { @@ -43,39 +45,29 @@ impl SymphoniaDecoder { enable_gapless: true, ..Default::default() }; + let metadata_opts: MetadataOptions = Default::default(); - let format: Box = if AudioFiles::is_ogg_vorbis(file_format) { - Box::new(OggReader::try_new(mss, &format_opts)?) - } else if AudioFiles::is_mp3(file_format) { - Box::new(MpaReader::try_new(mss, &format_opts)?) - } else if AudioFiles::is_flac(file_format) { - Box::new(FlacReader::try_new(mss, &format_opts)?) - } else { - return Err(DecoderError::SymphoniaDecoder(format!( - "Unsupported format: {file_format:?}" - ))); - }; + let probe_result = + symphonia::default::get_probe().format(&hint, mss, &format_opts, &metadata_opts)?; + + let format = &probe_result.format; let track = format.default_track().ok_or_else(|| { DecoderError::SymphoniaDecoder("Could not retrieve default track".into()) })?; let decoder_opts: DecoderOptions = Default::default(); - let decoder: Box = if AudioFiles::is_ogg_vorbis(file_format) { - Box::new(VorbisDecoder::try_new(&track.codec_params, &decoder_opts)?) - } else if AudioFiles::is_mp3(file_format) { - Box::new(MpaDecoder::try_new(&track.codec_params, &decoder_opts)?) - } else if AudioFiles::is_flac(file_format) { - Box::new(FlacDecoder::try_new(&track.codec_params, &decoder_opts)?) - } else { - return Err(DecoderError::SymphoniaDecoder(format!( - "Unsupported decoder: {file_format:?}" - ))); - }; + + 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}" @@ -92,9 +84,8 @@ impl SymphoniaDecoder { } Ok(Self { - format, + probe_result, decoder, - // We set the sample buffer when decoding the first full packet, // whose duration is also the ideal sample buffer size. sample_buffer: None, @@ -102,16 +93,7 @@ impl SymphoniaDecoder { } pub fn normalisation_data(&mut self) -> Option { - 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 = symphonia_util::get_latest_metadata(&mut self.probe_result)?; let tags = metadata.current()?.tags(); if tags.is_empty() { @@ -135,6 +117,49 @@ impl SymphoniaDecoder { } } + pub(crate) fn local_file_metadata(&mut self) -> Option { + let metadata = symphonia_util::get_latest_metadata(&mut self.probe_result)?; + 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 = Some(value.clone()), + Some(StandardTagKey::Language) => metadata.language = Some(value.clone()), + Some(StandardTagKey::Artist) => metadata.artists = Some(value.clone()), + Some(StandardTagKey::AlbumArtist) => { + metadata.album_artists = Some(value.clone()) + } + Some(StandardTagKey::Album) => metadata.album = Some(value.clone()), + Some(StandardTagKey::TrackNumber) => { + metadata.number = value.parse::().ok() + } + Some(StandardTagKey::DiscNumber) => { + metadata.disc_number = value.parse::().ok() + } + _ => (), + } + } else if let Value::UnsignedInt(value) = &tag.value { + match tag.std_key { + Some(StandardTagKey::TrackNumber) => metadata.number = Some(*value as u32), + Some(StandardTagKey::DiscNumber) => metadata.disc_number = Some(*value as u32), + _ => (), + } + } else if let Value::SignedInt(value) = &tag.value { + match tag.std_key { + Some(StandardTagKey::TrackNumber) => metadata.number = Some(*value as u32), + Some(StandardTagKey::DiscNumber) => metadata.disc_number = Some(*value as u32), + _ => (), + } + } + } + + Some(metadata) + } + #[inline] fn ts_to_ms(&self, ts: u64) -> u32 { match self.decoder.codec_params().time_base { @@ -161,7 +186,7 @@ impl AudioDecoder for SymphoniaDecoder { } // `track_id: None` implies the default track ID (of the container, not of Spotify). - let seeked_to_ts = self.format.seek( + let seeked_to_ts = self.probe_result.format.seek( SeekMode::Accurate, SeekTo::Time { time: target.into(), @@ -180,7 +205,7 @@ impl AudioDecoder for SymphoniaDecoder { let mut skipped = false; loop { - let packet = match self.format.next_packet() { + let packet = match self.probe_result.format.next_packet() { Ok(packet) => packet, Err(Error::IoError(err)) => { if err.kind() == io::ErrorKind::UnexpectedEof { diff --git a/playback/src/lib.rs b/playback/src/lib.rs index 43a5b4f0c..a637f00cf 100644 --- a/playback/src/lib.rs +++ b/playback/src/lib.rs @@ -10,8 +10,10 @@ pub mod config; pub mod convert; pub mod decoder; pub mod dither; +mod local_file; pub mod mixer; pub mod player; +mod symphonia_util; pub const SAMPLE_RATE: u32 = 44100; pub const NUM_CHANNELS: u8 = 2; diff --git a/playback/src/local_file.rs b/playback/src/local_file.rs new file mode 100644 index 000000000..f25c65829 --- /dev/null +++ b/playback/src/local_file.rs @@ -0,0 +1,167 @@ +use crate::symphonia_util; +use librespot_core::{Error, SpotifyUri}; +use std::{ + collections::HashMap, + fs, + fs::File, + io, + path::{Path, PathBuf}, + time::Duration, +}; +use symphonia::core::{ + formats::FormatOptions, + io::MediaSourceStream, + meta::{MetadataOptions, StandardTagKey, Tag}, + 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); + +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(file_extension) = path.extension().and_then(|e| e.to_str()) else { + continue; + }; + + let lowercase_extension = file_extension.to_lowercase(); + + if SUPPORTED_FILE_EXTENSIONS.contains(&lowercase_extension.as_str()) { + let uri = match get_uri_from_file(path.as_path(), file_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, file_extension: &str) -> Result { + let src = File::open(audio_path)?; + let mss = MediaSourceStream::new(Box::new(src), Default::default()); + + let mut hint = Hint::new(); + hint.with_extension(file_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 = None; + let mut album_title: Option = None; + let mut track_title: Option = None; + + fn get_tags(probed: &mut ProbeResult) -> Option> { + let metadata = symphonia_util::get_latest_metadata(probed)?; + let metadata_rev = metadata.current()?; + Some(metadata_rev.tags().to_vec()) + } + + for tag in get_tags(&mut probed).ok_or(Error::internal("Failed to probe audio 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); + + fn format_uri_part(input: Option) -> String { + input + .map(|s| { + let bytes = s.into_bytes(); + let encoded = form_urlencoded::byte_serialize(bytes.as_slice()); + encoded.collect::() + }) + .unwrap_or("".to_owned()) + } + + Ok(SpotifyUri::Local { + artist: format_uri_part(artist), + album_title: format_uri_part(album_title), + track_title: format_uri_part(track_title), + duration: Duration::from_secs(time.seconds), + }) +} diff --git a/playback/src/player.rs b/playback/src/player.rs index a4a03ca3f..223e8c8ca 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -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,8 +34,10 @@ 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 symphonia::core::probe::Hint; use tokio::sync::{mpsc, oneshot}; use crate::SAMPLES_PER_SECOND; @@ -89,6 +93,8 @@ struct PlayerInternal { player_id: usize, play_request_id_generator: SeqGenerator, last_progress_update: Instant, + + local_file_lookup: Arc, } static PLAYER_COUNTER: AtomicUsize = AtomicUsize::new(0); @@ -471,6 +477,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 +508,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 +899,7 @@ impl PlayerState { struct PlayerTrackLoader { session: Session, config: PlayerConfig, + local_file_lookup: Arc, } impl PlayerTrackLoader { @@ -948,6 +963,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 @@ -1111,7 +1127,14 @@ impl PlayerTrackLoader { }; #[cfg(not(feature = "passthrough-decoder"))] - let decoder_type = symphonia_decoder(audio_file, format); + let decoder_type = { + let mut hint = Hint::new(); + if let Some(mime_type) = AudioFiles::mime_type(format) { + hint.mime_type(mime_type); + } + + symphonia_decoder(audio_file, hint) + }; let normalisation_data = normalisation_data.unwrap_or_else(|| { warn!("Unable to get normalisation data, continuing with defaults."); @@ -1191,6 +1214,108 @@ impl PlayerTrackLoader { }); } } + + async fn load_local_track( + &self, + track_uri: SpotifyUri, + position_ms: u32, + ) -> Option { + info!("Loading local file with Spotify URI <{}>", track_uri); + + let SpotifyUri::Local { duration, .. } = track_uri else { + error!("Unable to determine track duration for local file: not a local file URI"); + return None; + }; + + 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; + }; + + let src = match File::open(path) { + Ok(src) => src, + Err(e) => { + error!("Failed to open local file: {e}"); + return None; + } + }; + + let mut hint = Hint::new(); + if let Some(file_extension) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(file_extension); + } + + let decoder = match SymphoniaDecoder::new(src, hint) { + 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 file_size = fs::metadata(path).ok()?.len(); + let bytes_per_second = (file_size / duration.as_secs()) as usize; + + let stream_loader_controller = StreamLoaderController::from_local_file(file_size); + + let name = local_file_metadata.name.unwrap_or_default(); + + info!("Loaded <{name}> from path <{}>", path.display()); + + 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, + // 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: local_file_metadata + .language + .map(|val| vec![val]) + .unwrap_or_default(), + 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 +2395,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(); diff --git a/playback/src/symphonia_util.rs b/playback/src/symphonia_util.rs new file mode 100644 index 000000000..9cabc60ee --- /dev/null +++ b/playback/src/symphonia_util.rs @@ -0,0 +1,24 @@ +use symphonia::core::meta::Metadata; +use symphonia::core::probe::ProbeResult; + +pub fn get_latest_metadata(probe_result: &mut ProbeResult) -> Option> { + let mut metadata = probe_result.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() { + if let Some(inner_probe_metadata) = probe_result.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) +} diff --git a/src/main.rs b/src/main.rs index b824ea948..fb85803a9 100644 --- a/src/main.rs +++ b/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::>(); + 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, } }; diff --git a/src/player_event_handler.rs b/src/player_event_handler.rs index 51495932a..2cdac5c60 100644 --- a/src/player_event_handler.rs +++ b/src/player_event_handler.rs @@ -87,6 +87,41 @@ 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.unwrap_or_default()); + env_vars.insert( + "ALBUM_ARTISTS", + album_artists.unwrap_or_default(), + ); + env_vars.insert("ALBUM", album.unwrap_or_default()); + env_vars.insert( + "NUMBER", + number + .map(|n: u32| n.to_string()) + .unwrap_or_default(), + ); + env_vars.insert( + "DISC_NUMBER", + disc_number + .map(|n: u32| n.to_string()) + .unwrap_or_default(), + ); + env_vars.insert( + "LOCAL_FILE_PATH", + path.into_os_string() + .into_string() + .unwrap_or_default(), + ); + } UniqueFields::Episode { description, publish_time,