lavende_core/sources/
local.rs1pub 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}