Skip to main content

lavende_core/sources/
local.rs

1pub mod track {
2    use crate::{
3        common::AudioFormat,
4        sources::playable_track::{PlayableTrack, ResolvedTrack},
5    };
6    use async_trait::async_trait;
7    use std::{
8        io::{Read, Seek, SeekFrom},
9        path::Path,
10    };
11    use tracing::error;
12    pub struct LocalTrack {
13        pub path: String,
14    }
15    struct LocalFileSource {
16        file: std::fs::File,
17        len: u64,
18    }
19    impl LocalFileSource {
20        fn open(path: &str) -> std::io::Result<Self> {
21            let file = std::fs::File::open(path)?;
22            let len = file.metadata()?.len();
23            Ok(Self { file, len })
24        }
25    }
26    impl Read for LocalFileSource {
27        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
28            self.file.read(buf)
29        }
30    }
31    impl Seek for LocalFileSource {
32        fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
33            self.file.seek(pos)
34        }
35    }
36    impl symphonia::core::io::MediaSource for LocalFileSource {
37        fn is_seekable(&self) -> bool {
38            true
39        }
40        fn byte_len(&self) -> Option<u64> {
41            Some(self.len)
42        }
43    }
44    #[async_trait]
45    impl PlayableTrack for LocalTrack {
46        fn supports_seek(&self) -> bool {
47            true
48        }
49        async fn resolve(&self) -> Result<ResolvedTrack, String> {
50            let path = self.path.clone();
51            let hint = Path::new(&path)
52                .extension()
53                .and_then(|e| e.to_str())
54                .map(AudioFormat::from_ext);
55            let reader = tokio::task::spawn_blocking(move || {
56                LocalFileSource::open(&path)
57                    .map(|s| Box::new(s) as Box<dyn symphonia::core::io::MediaSource>)
58                    .map_err(|e| {
59                        error!("LocalTrack: failed to open '{path}': {e}");
60                        format!("Failed to open file: {e}")
61                    })
62            })
63            .await
64            .map_err(|e| format!("spawn_blocking failed: {e}"))??;
65            Ok(ResolvedTrack::new(reader, hint))
66        }
67    }
68}
69use crate::{
70    common::Severity,
71    protocol::tracks::{LoadError, LoadResult, Track, TrackInfo},
72    sources::{SourcePlugin, playable_track::BoxedTrack},
73};
74use async_trait::async_trait;
75use std::{path::Path, sync::Arc};
76use symphonia::core::{
77    codecs::CODEC_TYPE_NULL,
78    formats::FormatOptions,
79    io::MediaSourceStream,
80    meta::{MetadataOptions, StandardTagKey},
81    probe::Hint,
82};
83use tracing::{debug, error, warn};
84pub use track::LocalTrack;
85pub struct LocalSource;
86impl Default for LocalSource {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91impl LocalSource {
92    pub fn new() -> Self {
93        Self
94    }
95    fn probe_file(path: &str) -> Result<TrackInfo, Box<dyn std::error::Error + Send + Sync>> {
96        let file = std::fs::File::open(path)?;
97        let path_obj = Path::new(path);
98        let ext = path_obj
99            .extension()
100            .and_then(|e| e.to_str())
101            .map(|s| s.to_lowercase());
102        let mut hint = Hint::new();
103        if let Some(ref e) = ext {
104            hint.with_extension(e);
105        }
106        let mss = MediaSourceStream::new(Box::new(file), Default::default());
107        let probed = symphonia::default::get_probe().format(
108            &hint,
109            mss,
110            &FormatOptions::default(),
111            &MetadataOptions::default(),
112        )?;
113        let mut format = probed.format;
114        let track = format
115            .tracks()
116            .iter()
117            .find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
118            .ok_or("no audio track found")?;
119        let duration = track
120            .codec_params
121            .n_frames
122            .and_then(|n| {
123                track
124                    .codec_params
125                    .sample_rate
126                    .map(|r| (n as f64 / r as f64 * 1000.0) as u64)
127            })
128            .unwrap_or(0);
129        let mut title = String::new();
130        let mut author = String::new();
131        if let Some(meta) = format.metadata().current() {
132            for tag in meta.tags() {
133                match tag.std_key {
134                    Some(StandardTagKey::TrackTitle) => title = tag.value.to_string(),
135                    Some(StandardTagKey::Artist) | Some(StandardTagKey::AlbumArtist)
136                        if author.is_empty() =>
137                    {
138                        author = tag.value.to_string();
139                    }
140                    _ => {}
141                }
142            }
143        }
144        if title.is_empty() {
145            title = path_obj
146                .file_stem()
147                .and_then(|s| s.to_str())
148                .unwrap_or("Unknown")
149                .to_owned();
150        }
151        if author.is_empty() {
152            author = "Unknown Artist".to_owned();
153        }
154        Ok(TrackInfo {
155            identifier: path.to_owned(),
156            is_seekable: true,
157            author,
158            length: duration,
159            is_stream: false,
160            position: 0,
161            title,
162            uri: Some(format!("file://{path}")),
163            source_name: "local".to_owned(),
164            artwork_url: None,
165            isrc: None,
166        })
167    }
168}
169#[async_trait]
170impl SourcePlugin for LocalSource {
171    fn name(&self) -> &str {
172        "local"
173    }
174    fn can_handle(&self, identifier: &str) -> bool {
175        let path = identifier.strip_prefix("file://").unwrap_or(identifier);
176        Path::new(path).is_file()
177    }
178    async fn load(
179        &self,
180        identifier: &str,
181        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
182    ) -> LoadResult {
183        let path = identifier
184            .strip_prefix("file://")
185            .unwrap_or(identifier)
186            .to_owned();
187        debug!("Local source probing file: {path}");
188        let path_clone = path.clone();
189        let result =
190            tokio::task::spawn_blocking(move || LocalSource::probe_file(&path_clone)).await;
191        match result {
192            Ok(Ok(info)) => LoadResult::Track(Track::new(info)),
193            Ok(Err(e)) => {
194                warn!("Local source: failed to probe '{path}': {e}");
195                LoadResult::Error(LoadError {
196                    message: Some(format!("Failed to load local file: {e}")),
197                    severity: Severity::Suspicious,
198                    cause: e.to_string(),
199                    cause_stack_trace: None,
200                })
201            }
202            Err(e) => {
203                error!("Local source: task join error: {e}");
204                LoadResult::Error(LoadError {
205                    message: Some("Internal error reading local file".to_owned()),
206                    severity: Severity::Fault,
207                    cause: e.to_string(),
208                    cause_stack_trace: None,
209                })
210            }
211        }
212    }
213    async fn get_track(
214        &self,
215        identifier: &str,
216        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
217    ) -> Option<BoxedTrack> {
218        let path = identifier
219            .strip_prefix("file://")
220            .unwrap_or(identifier)
221            .to_owned();
222        if !Path::new(&path).is_file() {
223            return None;
224        }
225        Some(Arc::new(LocalTrack { path }))
226    }
227}