aede_core/lyrics.rs
1//! Lyrics that are already there: in the tags, or in an `.lrc` beside the file.
2//!
3//! Nothing here touches the network. Reading what a library already holds is
4//! the first half of the job and the cheap one — the parsers walk past `USLT`,
5//! `LYRICS` and `©lyr` today, and a `.lrc` sitting next to a track is a text
6//! file anyone can open. Fetching what is *missing* is another matter
7//! entirely: it needs a network, a source whose terms allow it, and an explicit
8//! choice, because lyrics are the composition's copyright and owning a FLAC
9//! grants no rights in it. See `docs/design/lyrics.md`.
10//!
11//! **Where they live follows the rule the rest of the catalog follows.** Tag
12//! lyrics are already in it, because raw tags are kept per file; a sidecar is
13//! not in the file, so pretending it were a tag would make the catalog lie
14//! about what the file says. The catalog therefore stores the sidecar's
15//! **path**, and the text is read from it when somebody asks — it is one small
16//! file, sitting right next to the music it belongs to.
17//!
18//! [`Timeline`] indexes the parsed timings for terminal and graphical players.
19//! The player supplies its decoded-track position; this module does not run a
20//! playback clock or attach lyrics to audio packets.
21
22use std::io::Read;
23use std::path::{Path, PathBuf};
24
25#[path = "lyrics_source.rs"]
26mod source;
27pub use source::{CurrentTrack, ReadError, has_source, read_current, read_local};
28
29#[path = "lyrics_timeline.rs"]
30mod timeline;
31pub use timeline::{Cue, Timeline};
32
33/// Extension of a lyrics file beside a track.
34pub const EXTENSION: &str = "lrc";
35
36/// At most this much of a lyrics file is read.
37///
38/// Lyrics are a page of text. A ten-megabyte `.lrc` is not a song, and reading
39/// it whole to show a track page would be paying for somebody's mistake.
40const LIMIT: usize = 256 * 1024;
41/// Text storage after repeating lines for their timestamps.
42const MIN_TEXT_BUDGET: usize = 1024 * 1024;
43
44/// One line, with the moment it is sung when the file says so.
45#[derive(Debug, Clone, PartialEq)]
46pub struct Line {
47 /// Milliseconds from the start of the track, or `None` on a plain line.
48 ///
49 /// Offsets in LRC input have already been applied. A player uses its
50 /// decoded-track position, including a seek offset, to follow these times.
51 pub at_ms: Option<u64>,
52 /// The line as written, without its timestamp when it could be expanded safely.
53 /// Invalid or excessive timestamps remain literal text on an untimed line.
54 pub text: String,
55}
56
57/// Where a track's lyrics were found.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Source {
60 /// A tag inside the audio file itself.
61 Tag,
62 /// A `.lrc` file beside it.
63 Sidecar,
64}
65
66/// The lyrics of one track, as read.
67#[derive(Debug, Clone, PartialEq)]
68pub struct Lyrics {
69 /// Where they came from, which the page names so the reader knows.
70 pub source: Source,
71 /// The file they were read from: the audio itself, or the sidecar.
72 pub origin: String,
73 /// Every line, in order.
74 pub lines: Vec<Line>,
75}
76
77impl Lyrics {
78 /// `true` when at least one line carries a time.
79 ///
80 /// Not "every line": real `.lrc` files carry untimed headers, blank lines
81 /// and the occasional stray, and a file that is 95% timed is a synced file.
82 pub fn synced(&self) -> bool {
83 self.lines.iter().any(|line| line.at_ms.is_some())
84 }
85
86 /// The whole text, timestamps dropped — what a search looks through.
87 pub fn text(&self) -> String {
88 self.lines
89 .iter()
90 .map(|line| line.text.as_str())
91 .collect::<Vec<_>>()
92 .join("\n")
93 }
94}
95
96/// Where the sidecar of an audio file would be: the same name, `.lrc`.
97pub fn sidecar_of(audio: &Path) -> PathBuf {
98 audio.with_extension(EXTENSION)
99}
100
101/// `true` when a file name is a lyrics sidecar, whatever its case.
102pub fn is_sidecar(name: &str) -> bool {
103 name.to_ascii_lowercase().ends_with(".lrc")
104}
105
106/// Reads a sidecar, bounded, and never fatally.
107///
108/// Invalid UTF-8 is replaced rather than refused. A `.lrc` written on a Windows
109/// machine in 2003 is Latin-1 as often as not, and refusing to show a song
110/// because of one accent would be the wrong trade. Reads at most 256 KiB;
111/// expanded line text stays within 1 MiB, including after lossy decoding.
112pub fn read(path: &Path) -> Option<Lyrics> {
113 // A sidecar may have been replaced since the scan. Refuse special files
114 // before an open could wait for a FIFO writer; ordinary links still work.
115 if !std::fs::metadata(path).ok()?.is_file() {
116 return None;
117 }
118 let file = std::fs::File::open(path).ok()?;
119 if !file.metadata().ok()?.is_file() {
120 return None;
121 }
122 let text = read_text(file).ok()?;
123 let lines = parse_with_budget(&text, MIN_TEXT_BUDGET);
124 match lines.is_empty() {
125 true => None,
126 false => Some(Lyrics {
127 source: Source::Sidecar,
128 origin: path.to_string_lossy().to_string(),
129 lines,
130 }),
131 }
132}
133
134fn read_text(reader: impl Read) -> std::io::Result<String> {
135 let mut raw = Vec::new();
136 reader.take(LIMIT as u64).read_to_end(&mut raw)?;
137 Ok(String::from_utf8_lossy(&raw).into_owned())
138}
139
140/// The lyrics a tag carries, if it carries any.
141pub fn from_tag(origin: &str, tag: &str) -> Option<Lyrics> {
142 let lines = parse(tag);
143 match lines.is_empty() {
144 true => None,
145 false => Some(Lyrics {
146 source: Source::Tag,
147 origin: origin.to_string(),
148 lines,
149 }),
150 }
151}
152
153/// Reads lyrics text, timed or not.
154///
155/// One parser for both, because a tag can perfectly well hold LRC — plenty of
156/// taggers write the synced text straight into `LYRICS`, and a reader that only
157/// understood plain text would show a page of `[00:12.34]` to the user.
158///
159/// The `.lrc` metadata headers (`[ar:…]`, `[ti:…]`, `[by:…]`) are dropped: they
160/// repeat what the tags already say, and this file is not where the artist's
161/// name is settled. `[offset:…]` is applied, since it exists precisely to shift
162/// a file that was timed against another encoding. An unrepresentable timestamp
163/// remains literal, untimed text rather than hiding the line or overflowing.
164/// Timed blank lines are kept, including at the end, so a player can clear the
165/// preceding verse. Only untimed blank edges and an initial UTF-8 BOM are removed.
166///
167/// Repeated timestamps share a text budget of `max(1 MiB, 4 * text.len())`
168/// bytes across the entire result. A line that cannot fit its expanded copies
169/// remains one untimed literal line, preserving its words and timestamps.
170pub fn parse(text: &str) -> Vec<Line> {
171 parse_with_budget(text, MIN_TEXT_BUDGET.max(text.len().saturating_mul(4)))
172}
173
174/// A complete lyrics result would exceed the permitted UTF-8 text budget.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub struct ParseLimitExceeded;
177
178impl std::fmt::Display for ParseLimitExceeded {
179 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 formatter.write_str("complete lyrics exceed the text budget")
181 }
182}
183
184impl std::error::Error for ParseLimitExceeded {}
185
186/// Parses lyrics completely, refusing excessive timestamp expansion.
187///
188/// Unlike [`parse`], this does not turn an over-budget timed chorus into
189/// literal untimed text. This distinction lets an API promise complete timing
190/// rather than silently downgrade it. Malformed timestamps remain literal
191/// text under the same rules as the ordinary parser.
192///
193/// `text_budget` limits the combined UTF-8 bytes in all returned line texts,
194/// including every expanded repetition; it does not include vector storage,
195/// line separators or a transport's serialization overhead. Callers reading
196/// untrusted sources must also bound their input and encoded response size.
197pub fn parse_complete(text: &str, text_budget: usize) -> Result<Vec<Line>, ParseLimitExceeded> {
198 let (lines, exceeded) = parse_bounded(text, text_budget, true);
199 if exceeded {
200 Err(ParseLimitExceeded)
201 } else {
202 Ok(lines)
203 }
204}
205
206fn parse_with_budget(text: &str, budget: usize) -> Vec<Line> {
207 parse_bounded(text, budget, false).0
208}
209
210fn parse_bounded(text: &str, budget: usize, complete: bool) -> (Vec<Line>, bool) {
211 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
212 let mut offset_ms: i64 = 0;
213 let mut lines = Vec::new();
214 let mut used_text = 0usize;
215 // Reserve the raw spelling of later lines, so an accepted chorus cannot
216 // consume the space needed to retain subsequent words and timestamps.
217 let mut remaining_raw = text
218 .lines()
219 .fold(0usize, |bytes, line| bytes.saturating_add(line.len()));
220 for raw in text.lines() {
221 remaining_raw = remaining_raw.saturating_sub(raw.len());
222 let raw = raw.trim_end_matches('\r');
223 if let Some(value) = header(raw, "offset") {
224 offset_ms = value.trim().parse().unwrap_or(0);
225 continue;
226 }
227 if is_header(raw) {
228 continue;
229 }
230 let (times, rest) = timestamps(raw);
231 if times.is_empty() {
232 let text = rest.trim_end().to_string();
233 // A blank line inside lyrics is a verse break and worth keeping;
234 // one at the very start is just the file's shape.
235 if !text.is_empty() || !lines.is_empty() {
236 used_text = used_text.saturating_add(text.len());
237 if complete && used_text > budget {
238 return (Vec::new(), true);
239 }
240 lines.push(Line { at_ms: None, text });
241 }
242 continue;
243 }
244 // `[00:12.00][01:44.00] the chorus again` is one line sung twice, and
245 // the file means both: a chorus that only appeared once would leave a
246 // player silent at its second turn.
247 let text = rest.trim();
248 let expanded = text.len().saturating_mul(times.len());
249 let available = match complete {
250 true => budget.saturating_sub(used_text),
251 false => budget
252 .saturating_sub(used_text)
253 .saturating_sub(remaining_raw),
254 };
255 if expanded > available {
256 if complete {
257 return (Vec::new(), true);
258 }
259 used_text = used_text.saturating_add(raw.len());
260 lines.push(Line {
261 at_ms: None,
262 text: raw.to_owned(),
263 });
264 continue;
265 }
266 used_text = used_text.saturating_add(expanded);
267 for at in times {
268 lines.push(Line {
269 at_ms: Some(at.saturating_add(offset_ms).max(0) as u64),
270 text: text.to_owned(),
271 });
272 }
273 }
274 while lines
275 .last()
276 .is_some_and(|line| line.at_ms.is_none() && line.text.is_empty())
277 {
278 lines.pop();
279 }
280 (lines, false)
281}
282
283/// The value of an `.lrc` header such as `[offset:+250]`, if this is one.
284fn header<'a>(line: &'a str, name: &str) -> Option<&'a str> {
285 let inner = line.trim().strip_prefix('[')?.strip_suffix(']')?;
286 let (key, value) = inner.split_once(':')?;
287 (key.trim().eq_ignore_ascii_case(name)).then_some(value)
288}
289
290/// `true` for the metadata lines of an `.lrc`: `[ar:…]`, `[ti:…]`, `[length:…]`.
291fn is_header(line: &str) -> bool {
292 let Some(inner) = line
293 .trim()
294 .strip_prefix('[')
295 .and_then(|rest| rest.strip_suffix(']'))
296 else {
297 return false;
298 };
299 let Some((key, _)) = inner.split_once(':') else {
300 return false;
301 };
302 // A timestamp is `[mm:ss…]`, so a key of digits is not a header.
303 !key.is_empty() && key.chars().all(|c| c.is_ascii_alphabetic())
304}
305
306/// Every timestamp at the head of a line, and what follows them.
307fn timestamps(line: &str) -> (Vec<i64>, &str) {
308 let mut rest = line;
309 let mut found = Vec::new();
310 loop {
311 let trimmed = rest.trim_start();
312 let Some(close) = trimmed.find(']') else {
313 break;
314 };
315 if !trimmed.starts_with('[') {
316 break;
317 }
318 let Some(at) = moment(&trimmed[1..close]) else {
319 break;
320 };
321 found.push(at);
322 rest = &trimmed[close + 1..];
323 }
324 (found, rest)
325}
326
327/// `mm:ss`, `mm:ss.cc` or `mm:ss.mmm`, in milliseconds.
328fn moment(inner: &str) -> Option<i64> {
329 let (minutes, rest) = inner.split_once(':')?;
330 let minutes: i64 = minutes.trim().parse().ok()?;
331 let (seconds, fraction) = match rest.split_once(['.', ':']) {
332 Some((seconds, fraction)) => (seconds, fraction),
333 None => (rest, ""),
334 };
335 let seconds: i64 = seconds.trim().parse().ok()?;
336 // Two digits are hundredths, three are milliseconds — both are written.
337 let digits: String = fraction
338 .chars()
339 .take_while(|c| c.is_ascii_digit())
340 .collect();
341 let fraction: i64 = match digits.len() {
342 0 => 0,
343 1 => digits.parse::<i64>().ok()? * 100,
344 2 => digits.parse::<i64>().ok()? * 10,
345 _ => digits[..3].parse().ok()?,
346 };
347 minutes
348 .checked_mul(60_000)?
349 .checked_add(seconds.checked_mul(1_000)?)?
350 .checked_add(fraction)
351}
352
353#[cfg(test)]
354#[path = "lyrics_tests.rs"]
355mod tests;