1use std::fmt;
4use std::fs::{self, File, Metadata};
5use std::io::{self, Read};
6use std::path::{Component, Path};
7
8use super::{Lyrics, Source, parse_complete};
9
10const MAX_INPUT_BYTES: u64 = 256 * 1024;
11const MAX_TEXT_BYTES: usize = 1024 * 1024;
12
13pub struct CurrentTrack<'a> {
20 pub path: &'a Path,
22 pub size: u64,
24 pub mtime: u64,
26 pub mtime_subseconds: u32,
28 pub tag: Option<&'a str>,
30 pub sidecar: Option<&'a Path>,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ReadError {
37 SourceUnavailable,
39 SourceChanged,
42 SidecarUnavailable,
44 SidecarChanged,
46 TooLarge,
48 ReadFailed,
50 TagsUnavailable,
52}
53
54impl fmt::Display for ReadError {
55 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56 formatter.write_str(match self {
57 Self::SourceUnavailable => "the catalogued audio source is unavailable",
58 Self::SourceChanged => "the catalogued audio source changed; rescan it",
59 Self::SidecarUnavailable => "the catalogued lyrics sidecar is unavailable",
60 Self::SidecarChanged => "the lyrics sidecar changed or is not a regular adjacent file",
61 Self::TooLarge => "complete lyrics exceed the supported text size",
62 Self::ReadFailed => "the complete lyrics sidecar could not be read",
63 Self::TagsUnavailable => "audio tags could not be read to select local lyrics",
64 })
65 }
66}
67
68impl std::error::Error for ReadError {}
69
70fn regular(metadata: &Metadata) -> bool {
71 metadata.is_file() && !metadata.file_type().is_symlink()
72}
73
74fn same_file(left: &Metadata, right: &Metadata) -> bool {
75 #[cfg(unix)]
76 {
77 use std::os::unix::fs::MetadataExt as _;
78 left.dev() == right.dev() && left.ino() == right.ino()
79 }
80 #[cfg(not(unix))]
81 {
82 left.len() == right.len()
85 && matches!((left.modified(), right.modified()), (Ok(left), Ok(right)) if left == right)
86 }
87}
88
89fn unchanged(before: &Metadata, after: &Metadata) -> bool {
90 regular(after)
91 && same_file(before, after)
92 && before.len() == after.len()
93 && matches!((before.modified(), after.modified()), (Ok(before), Ok(after)) if before == after)
94}
95
96fn audio_matches(source: &CurrentTrack<'_>, metadata: &Metadata) -> bool {
97 regular(metadata)
98 && metadata.len() == source.size
99 && modification(metadata).is_some_and(|modified| {
100 modified.as_secs() == source.mtime && modified.subsec_nanos() == source.mtime_subseconds
101 })
102}
103
104fn modification(metadata: &Metadata) -> Option<std::time::Duration> {
105 metadata
107 .modified()
108 .ok()?
109 .duration_since(std::time::UNIX_EPOCH)
110 .ok()
111}
112
113fn open_audio(source: &CurrentTrack<'_>) -> Result<(File, Metadata), ReadError> {
114 if !source.path.is_absolute()
115 || source
116 .path
117 .components()
118 .any(|part| matches!(part, Component::ParentDir))
119 {
120 return Err(ReadError::SourceChanged);
121 }
122 let before = fs::symlink_metadata(source.path).map_err(|_| ReadError::SourceUnavailable)?;
123 if !audio_matches(source, &before) {
124 return Err(ReadError::SourceChanged);
125 }
126 let file = File::open(source.path).map_err(|_| ReadError::SourceUnavailable)?;
127 let opened = file.metadata().map_err(|_| ReadError::SourceUnavailable)?;
128 if !audio_matches(source, &opened) || !unchanged(&before, &opened) {
129 return Err(ReadError::SourceChanged);
130 }
131 Ok((file, opened))
132}
133
134fn confined(audio: &Path, sidecar: &Path) -> Result<(), ReadError> {
135 if !audio.is_absolute()
136 || !sidecar.is_absolute()
137 || audio.parent() != sidecar.parent()
138 || audio.file_stem() != sidecar.file_stem()
139 || sidecar
140 .extension()
141 .and_then(|suffix| suffix.to_str())
142 .is_none_or(|suffix| !suffix.eq_ignore_ascii_case(super::EXTENSION))
143 || [audio, sidecar].iter().any(|path| {
144 path.components()
145 .any(|part| matches!(part, Component::ParentDir))
146 })
147 {
148 return Err(ReadError::SidecarChanged);
149 }
150 let folder = audio.parent().ok_or(ReadError::SidecarChanged)?;
151 let folder_metadata = fs::symlink_metadata(folder).map_err(|_| ReadError::SidecarChanged)?;
152 if !folder_metadata.is_dir() || folder_metadata.file_type().is_symlink() {
153 return Err(ReadError::SidecarChanged);
154 }
155 let folder = fs::canonicalize(folder).map_err(|_| ReadError::SidecarChanged)?;
156 let audio = fs::canonicalize(audio).map_err(|_| ReadError::SourceChanged)?;
157 let sidecar = fs::canonicalize(sidecar).map_err(|_| ReadError::SidecarUnavailable)?;
158 if audio.parent() != Some(folder.as_path()) || sidecar.parent() != Some(folder.as_path()) {
159 return Err(ReadError::SidecarChanged);
160 }
161 Ok(())
162}
163
164pub fn has_source(path: &Path, tag: Option<&str>, sidecar: Option<&Path>) -> bool {
171 if tag.is_some_and(|text| {
172 text.len() as u64 <= MAX_INPUT_BYTES
173 && super::from_tag(&path.to_string_lossy(), text).is_some()
174 }) {
175 return true;
176 }
177 let adjacent = super::sidecar_of(path);
178 let sidecar = sidecar.unwrap_or(&adjacent);
179 fs::symlink_metadata(sidecar).is_ok_and(|metadata| {
180 metadata.is_file()
181 && !metadata.file_type().is_symlink()
182 && metadata.len() > 0
183 && metadata.len() <= MAX_INPUT_BYTES
184 && confined(path, sidecar).is_ok()
185 })
186}
187
188fn complete(origin: &Path, text: &str, source: Source) -> Result<Option<Lyrics>, ReadError> {
189 if text.len() > MAX_TEXT_BYTES {
190 return Err(ReadError::TooLarge);
191 }
192 let lines = parse_complete(text, MAX_TEXT_BYTES).map_err(|_| ReadError::TooLarge)?;
193 Ok((!lines.is_empty()).then(|| Lyrics {
194 source,
195 origin: origin.to_string_lossy().into_owned(),
196 lines,
197 }))
198}
199
200fn tag_lyrics(path: &Path, tag: Option<&str>) -> Result<Option<Lyrics>, ReadError> {
201 match tag {
202 Some(text) if text.len() as u64 > MAX_INPUT_BYTES => Err(ReadError::TooLarge),
203 Some(text) => complete(path, text, Source::Tag),
204 None => Ok(None),
205 }
206}
207
208fn read_sidecar(audio: &Path, path: &Path) -> Result<Option<Lyrics>, ReadError> {
209 confined(audio, path)?;
210 let before = fs::symlink_metadata(path).map_err(|_| ReadError::SidecarUnavailable)?;
211 if !regular(&before) {
212 return Err(ReadError::SidecarChanged);
213 }
214 if before.len() > MAX_INPUT_BYTES {
215 return Err(ReadError::TooLarge);
216 }
217 let mut file = File::open(path).map_err(|_| ReadError::SidecarUnavailable)?;
218 let opened = file.metadata().map_err(|_| ReadError::SidecarUnavailable)?;
219 if !unchanged(&before, &opened) {
220 return Err(ReadError::SidecarChanged);
221 }
222 let mut bytes = Vec::new();
223 (&mut file)
224 .take(MAX_INPUT_BYTES + 1)
225 .read_to_end(&mut bytes)
226 .map_err(|_| ReadError::ReadFailed)?;
227 if bytes.len() as u64 > MAX_INPUT_BYTES {
228 return Err(ReadError::TooLarge);
229 }
230 let lyrics = complete(path, &String::from_utf8_lossy(&bytes), Source::Sidecar)?;
231 if bytes.len() as u64 != opened.len()
232 || !unchanged(
233 &opened,
234 &file.metadata().map_err(|_| ReadError::SidecarChanged)?,
235 )
236 || !unchanged(
237 &opened,
238 &fs::symlink_metadata(path).map_err(|_| ReadError::SidecarChanged)?,
239 )
240 {
241 return Err(ReadError::SidecarChanged);
242 }
243 confined(audio, path)?;
244 Ok(lyrics)
245}
246
247pub fn read_current(source: CurrentTrack<'_>) -> Result<Option<Lyrics>, ReadError> {
261 let (audio, opened) = open_audio(&source)?;
262 let lyrics = tag_lyrics(source.path, source.tag)?;
263 let lyrics = match (lyrics, source.sidecar) {
264 (None, Some(path)) => read_sidecar(source.path, path)?,
265 (lyrics, _) => lyrics,
266 };
267 if !audio_matches(
268 &source,
269 &audio.metadata().map_err(|_| ReadError::SourceChanged)?,
270 ) || !unchanged(
271 &opened,
272 &fs::symlink_metadata(source.path).map_err(|_| ReadError::SourceChanged)?,
273 ) {
274 return Err(ReadError::SourceChanged);
275 }
276 Ok(lyrics)
277}
278
279pub fn read_local(path: &Path) -> Result<Option<Lyrics>, ReadError> {
287 let before = fs::symlink_metadata(path).map_err(|_| ReadError::SourceUnavailable)?;
288 if !regular(&before) {
289 return Err(ReadError::SourceChanged);
290 }
291 let modified = modification(&before).ok_or(ReadError::SourceChanged)?;
292 let absolute = fs::canonicalize(path).map_err(|_| ReadError::SourceUnavailable)?;
293 let source = CurrentTrack {
294 path: &absolute,
295 size: before.len(),
296 mtime: modified.as_secs(),
297 mtime_subseconds: modified.subsec_nanos(),
298 tag: None,
299 sidecar: None,
300 };
301 let (audio, opened) = open_audio(&source)?;
302 if !unchanged(&before, &opened) {
303 return Err(ReadError::SourceChanged);
304 }
305 let tags = crate::tags::read(&absolute).map_err(|_| ReadError::TagsUnavailable)?;
306 if !unchanged(
307 &opened,
308 &audio.metadata().map_err(|_| ReadError::SourceChanged)?,
309 ) || !unchanged(
310 &opened,
311 &fs::symlink_metadata(path).map_err(|_| ReadError::SourceChanged)?,
312 ) {
313 return Err(ReadError::SourceChanged);
314 }
315 let lyrics = match tag_lyrics(&absolute, tags.first("lyrics"))? {
316 Some(lyrics) => Some(lyrics),
317 None => {
318 let sidecar = super::sidecar_of(&absolute);
319 match fs::symlink_metadata(&sidecar) {
320 Ok(_) => read_sidecar(&absolute, &sidecar)?,
321 Err(failure) if failure.kind() == io::ErrorKind::NotFound => None,
322 Err(_) => return Err(ReadError::SidecarUnavailable),
323 }
324 }
325 };
326 if !unchanged(
327 &opened,
328 &audio.metadata().map_err(|_| ReadError::SourceChanged)?,
329 ) || !unchanged(
330 &opened,
331 &fs::symlink_metadata(path).map_err(|_| ReadError::SourceChanged)?,
332 ) {
333 return Err(ReadError::SourceChanged);
334 }
335 Ok(lyrics)
336}
337
338#[cfg(test)]
339#[path = "lyrics_source_tests.rs"]
340mod tests;