aede_core/
lrclib.rs

1//! Asking LRCLIB for the words, and reading what it answers.
2//!
3//! No socket here, exactly as in [`crate::musicbrainz`]: this builds addresses
4//! and reads answers. Waiting between requests belongs to the optional HTTP
5//! client, available with the `fetch` feature. This module can therefore be
6//! tested against canned text without network access.
7//!
8//! # Why this service and not another
9//!
10//! Lyrics are the **composition's** copyright, and owning a FLAC grants no
11//! rights in it — they are legally a different object from the recording. Of
12//! the free sources, LRCLIB is the only one an open-source program can query
13//! without breaking an API's own terms: Genius forbids the scraping its lyrics
14//! require, and Musixmatch's free tier is non-commercial and truncated. The
15//! reasoning in full is in `docs/design/lyrics.md`.
16//!
17//! It is also the gentlest network path this program has. No key, no account, a
18//! JSON document of a few fields, and a failure that costs nothing: no lyrics
19//! is not a broken catalog, where a bad MusicBrainz match attaches the wrong
20//! record to an album.
21//!
22//! # Identify yourself
23//!
24//! LRCLIB drops connections from agents it has come to distrust — Jellyfin's
25//! server agent is refused outright, with no status code at all, the connection
26//! simply closing. This program sends the same honest `User-Agent` it sends
27//! MusicBrainz, and shares its throttle: being slower than a free service
28//! requires is never the wrong mistake.
29
30use crate::json::Json;
31use crate::url::encode_component;
32
33/// Where the service lives.
34pub const WEB_SERVICE: &str = "https://lrclib.net/api";
35
36/// What the service had for one track.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum Found {
39    /// Words, timed or not.
40    Words(Words),
41    /// The service knows this recording and says it has no words at all.
42    ///
43    /// **Not the same as not knowing**, and the distinction is the reason this
44    /// is a variant rather than an empty [`Words`]: an instrumental is a
45    /// finished question, and telling a reader "no lyrics found" about one
46    /// would send them looking for a fault that is not there.
47    Instrumental,
48    /// The service has nothing for this track.
49    Nothing,
50}
51
52/// The words themselves.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Words {
55    /// The text, in the form it will be written to disk.
56    pub text: String,
57    /// Whether the text carries timings.
58    ///
59    /// The service holds both forms and they are two different things to a
60    /// reader: one is a page to read, the other is a file a player can follow.
61    /// The synced form is preferred where there is one, and this says which was
62    /// taken so the run can report it rather than leave it to be discovered.
63    pub synced: bool,
64}
65
66/// The address that asks about one track.
67///
68/// **Four criteria, and only two of them are the service's requirement.** A
69/// live answer to `artist_name` and `track_name` alone came back `200`, so the
70/// album and the length are *this program's* insistence rather than LRCLIB's:
71/// a song has a studio take, a live one and three covers, they share a title,
72/// and the length is what tells them apart. Both are things this catalog holds
73/// exactly — the duration is measured on the stream rather than read from a
74/// tag — so asking the narrow question costs nothing and answers about this
75/// recording.
76///
77/// An empty album is **left out of the address** rather than sent empty. The
78/// two are different questions: an omitted parameter asks with one criterion
79/// fewer, where `album_name=` states that the album is called nothing. Only the
80/// first has been seen to work, and inventing the behaviour of the second is
81/// exactly what a checked answer is for.
82pub fn get_url(artist: &str, title: &str, album: &str, duration_secs: u64) -> String {
83    let mut url = format!(
84        "{WEB_SERVICE}/get?artist_name={}&track_name={}",
85        encode_component(artist),
86        encode_component(title)
87    );
88    if !album.trim().is_empty() {
89        url.push_str(&format!("&album_name={}", encode_component(album)));
90    }
91    url.push_str(&format!("&duration={duration_secs}"));
92    url
93}
94
95/// Reads what the service answered.
96///
97/// The synced form wins where there is one, because it is the richer of the
98/// two: [`crate::lyrics::parse`] reads it into lines that carry their timings,
99/// and a reader who only wants the words gets them from the same lines with the
100/// timings dropped. Taking the plain form when a timed one exists would throw
101/// away something that cannot be recovered.
102///
103/// A non-instrumental answer whose text is empty or only LRC metadata is
104/// [`Found::Nothing`]: a record with no words in it is not an answer about the
105/// words. Use [`read_checked`] at the network boundary to distinguish that from
106/// a malformed success body.
107pub fn read(value: &Json) -> Found {
108    // Asked before the text, because it is an answer *about* the absence of
109    // text and would otherwise be read as a service that failed to have any.
110    if value.field_optional_bool("instrumental") == Some(true) {
111        return Found::Instrumental;
112    }
113    for (field, synced) in [("syncedLyrics", true), ("plainLyrics", false)] {
114        if let Some(text) = value
115            .field_str(field)
116            .filter(|text| !crate::lyrics::parse(text).is_empty())
117        {
118            return Found::Words(Words { text, synced });
119        }
120    }
121    Found::Nothing
122}
123
124/// Reads a service response, refusing missing or wrongly typed answer fields.
125///
126/// A successful LRCLIB body carries a boolean `instrumental` and both lyric
127/// fields, each either a string or null. An empty but correctly typed answer
128/// is [`Found::Nothing`]; an error object is refused rather than counted as an
129/// ordinary miss. HTTP 404 must be handled by the caller before this parser.
130pub fn read_checked(value: &Json) -> Result<Found, String> {
131    if value.field_optional_bool("instrumental").is_none() {
132        return Err("LRCLIB returned no valid instrumental flag".to_string());
133    }
134    for field in ["plainLyrics", "syncedLyrics"] {
135        if !matches!(value.get(field), Some(Json::Str(_) | Json::Null)) {
136            return Err(format!("LRCLIB returned no valid {field} field"));
137        }
138    }
139    Ok(read(value))
140}
141
142#[cfg(test)]
143#[path = "lrclib_tests.rs"]
144mod tests;