aede_core/
lyrics_source.rs

1//! Complete local lyrics reads for clients that cannot accept a truncated result.
2
3use 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
13/// Current catalog evidence for the audio to which lyrics belong.
14///
15/// Paths come from the catalog, never from an HTTP caller. A sidecar must have
16/// the audio file's basename and parent, with a case-insensitive `.lrc` suffix.
17/// Precise modification time is required: callers must rescan legacy catalogs
18/// before publishing lyrics associated with an unverified audio identity.
19pub struct CurrentTrack<'a> {
20    /// Catalogued audio path.
21    pub path: &'a Path,
22    /// Catalogued audio byte length.
23    pub size: u64,
24    /// Catalogued modification time in whole seconds.
25    pub mtime: u64,
26    /// Nanosecond fraction of the catalogued modification time.
27    pub mtime_subseconds: u32,
28    /// First lyrics tag, when present; nonempty tag lyrics precede a sidecar.
29    pub tag: Option<&'a str>,
30    /// Catalogued lyrics sidecar, when present.
31    pub sidecar: Option<&'a Path>,
32}
33
34/// Why a complete, current lyrics answer cannot be supplied.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ReadError {
37    /// The catalogued audio is missing or cannot be opened.
38    SourceUnavailable,
39    /// Audio identity changed, its precise timestamp is unavailable, or its
40    /// path is a link or non-regular file.
41    SourceChanged,
42    /// The catalogued sidecar is missing or cannot be opened.
43    SidecarUnavailable,
44    /// Sidecar identity, type or confinement cannot be confirmed.
45    SidecarChanged,
46    /// Input exceeds 256 KiB or complete decoded/expanded text exceeds 1 MiB.
47    TooLarge,
48    /// The complete sidecar could not be read.
49    ReadFailed,
50    /// Local audio tags could not be read for an uncatalogued track.
51    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        // Stable descriptor IDs are not available through the supported
83        // Windows standard-library API. Retain precise metadata checks.
84        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    // Unknown and pre-epoch dates cannot become a valid zero file identity.
106    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
164/// Lightweight presence hint for catalog presentation, without opening audio or sidecar text.
165///
166/// Bounded cached tag words or a nonempty regular adjacent LRC are sufficient.
167/// Sidecar content and current audio identity are validated only by `read_current`;
168/// a hint may become stale or refer to metadata-only text. No acquisition is performed.
169/// A catalogued sidecar takes precedence over discovering a newly downloaded one.
170pub 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
247/// Read a complete local lyrics answer after confirming its current audio identity.
248///
249/// Tag lyrics take precedence; an empty tag falls back to the catalogued `.lrc`.
250/// With neither source, the answer is `Ok(None)`. Empty/metadata-only lyrics
251/// also return `None`. This function never fetches or writes anything.
252///
253/// Input text is limited to 256 KiB, complete decoded/expanded text to 1 MiB.
254/// Unlike [`super::read`], excessive input and timestamp expansion are explicit
255/// errors rather than a prefix or an unsynchronized fallback. Invalid UTF-8 is
256/// replaced consistently with the ordinary sidecar reader. Audio and sidecar
257/// descriptors, paths and precise timestamps are checked before publication;
258/// this detects ordinary replacement races but cannot eliminate every concurrent
259/// ancestor-directory change through pathname-based standard-library APIs.
260pub 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
279/// Read complete lyrics for a local audio path without catalog evidence.
280///
281/// Existing tag parsers read the audio once; its descriptor and precise identity
282/// are checked before and after that read. A nonempty lyrics tag wins over the
283/// standard lowercase `.lrc` sidecar. This operation never scans, fetches or
284/// writes. For a catalogued track, prefer [`read_current`] so stale stored tags
285/// cannot be published against an audio file that changed since its scan.
286pub 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;