diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b1cc73f..c15f5c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). word, `⌘⌫` and `⌘⌦` clear to either end of the field, `⌘↑`/`⌘↓` jump to the ends, and the Emacs control keys (`⌃A`, `⌃E`, `⌃B`, `⌃F`, `⌃D`, `⌃H`, `⌃K`) do what they do everywhere else on a Mac. The menu bar gains Edit and Window menus and the usual Settings, Hide and Show All items. +- Local tracks can load same-name `.lrc` lyrics files beside the audio file. ### Fixed diff --git a/assets/i18n/en-US/main.ftl b/assets/i18n/en-US/main.ftl index 2f269337..230b0b0a 100644 --- a/assets/i18n/en-US/main.ftl +++ b/assets/i18n/en-US/main.ftl @@ -497,8 +497,8 @@ settings-panel-lyrics-size-detail = Size of the lyrics text in the side panel, o settings-fullscreen-lyrics-size = Lyrics size (fullscreen) settings-fullscreen-lyrics-size-detail = Size of the lyrics text on the fullscreen player, on top of the base font size settings-lyrics-size-value = { $size }% -settings-lyrics-for-local-files = Lyrics for local files -settings-lyrics-for-local-files-detail = Use metadata from local files to fetch lyrics from the internet +settings-lyrics-for-local-files = Online lyrics for local files +settings-lyrics-for-local-files-detail = If local lyrics aren't available, use track metadata to fetch lyrics from the internet settings-karaoke-lyrics = Karaoke lyrics settings-karaoke-lyrics-detail = Highlight lyrics word by word when timing is available settings-blur-lyrics = Blur inactive lyrics diff --git a/crates/music/src/local/client.rs b/crates/music/src/local/client.rs index 0bd3f717..0749c84e 100644 --- a/crates/music/src/local/client.rs +++ b/crates/music/src/local/client.rs @@ -7,13 +7,13 @@ use async_trait::async_trait; use storage::Database; use crate::{ - Album, AlbumDetail, Artist, ArtistProfile, MediaKind, MusicApi, Playlist, PlaylistDetail, - SavedArtist, Track, TrackTags, UserProfile, distinct_covers, + Album, AlbumDetail, Artist, ArtistProfile, Lyrics, MediaKind, MusicApi, Playlist, + PlaylistDetail, SavedArtist, Track, TrackTags, UserProfile, distinct_covers, }; use super::scan::Scanned; use super::store::Store; -use super::{tags, wire}; +use super::{lyrics, tags, wire}; const COVERS: usize = 4; const NOT_SUPPORTED: &str = "local playlists are not shared"; @@ -223,6 +223,10 @@ impl MusicApi for LocalClient { Ok(None) } + async fn track_lyrics(&self, track_id: &str) -> Result> { + lyrics::read(track_id).await + } + async fn playlists(&self, limit: u32) -> Result> { Ok(self .store diff --git a/crates/music/src/local/lyrics.rs b/crates/music/src/local/lyrics.rs new file mode 100644 index 00000000..59cabf39 --- /dev/null +++ b/crates/music/src/local/lyrics.rs @@ -0,0 +1,236 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result, anyhow, bail}; + +use crate::{Lyrics, lyrics::lrc}; + +use super::wire; + +pub async fn read(track_id: &str) -> Result> { + let path = lyrics_path(track_id)?; + tokio::task::spawn_blocking(move || read_file(&path)) + .await + .context("local lyrics task panicked")? +} + +fn lyrics_path(track_id: &str) -> Result { + let track = wire::path_from_track_id(track_id) + .ok_or_else(|| anyhow!("{track_id} is not a local track id"))?; + Ok(track.with_extension("lrc")) +} + +fn read_file(path: &Path) -> Result> { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error) + .with_context(|| format!("cannot read local lyrics {}", path.display())); + } + }; + + let lines = lrc::parse(text.trim_start_matches('\u{feff}')); + if lines.is_empty() { + bail!("{} does not contain valid LRC lyrics", path.display()); + } + + Ok(Some(Lyrics::Synced { + lines: lines.into(), + })) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + + fn scratch(name: &str) -> PathBuf { + static NEXT: AtomicU64 = AtomicU64::new(0); + + let path = std::env::temp_dir().join(format!( + "sonora-local-lyrics-{}-{name}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + + fs::create_dir_all(&path).expect("scratch directory is created"); + path + } + + #[test] + fn derives_lrc_path_from_track_path() { + let track = Path::new("Music").join("Album").join("Song.flac"); + let id = wire::track_id(&track); + + assert_eq!( + lyrics_path(&id).unwrap(), + Path::new("Music").join("Album").join("Song.lrc") + ); + } + + #[tokio::test] + async fn loads_same_name_lrc_file() { + let root = scratch("valid"); + let track = root.join("Song.flac"); + let id = wire::track_id(&track); + + fs::write( + track.with_extension("lrc"), + "[00:01.00]First test line\n[00:02.50]Second test line\n", + ) + .expect("lyrics file is written"); + + let found = read(&id) + .await + .expect("local lyrics lookup succeeds") + .expect("local lyrics are found"); + + let Lyrics::Synced { lines } = found else { + panic!("LRC file produces synced lyrics"); + }; + + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].text, "First test line"); + assert_eq!(lines[1].text, "Second test line"); + + fs::remove_dir_all(root).expect("scratch directory is removed"); + } + + #[tokio::test] + async fn loads_utf8_bom_lrc_file() { + let root = scratch("bom"); + let track = root.join("Song.flac"); + let id = wire::track_id(&track); + + fs::write(track.with_extension("lrc"), "\u{feff}[00:01.00]BOM test\n") + .expect("lyrics file is written"); + + let found = read(&id) + .await + .expect("local lyrics lookup succeeds") + .expect("local lyrics are found"); + + let Lyrics::Synced { lines } = found else { + panic!("LRC file produces synced lyrics"); + }; + + assert_eq!(lines[0].text, "BOM test"); + + fs::remove_dir_all(root).expect("scratch directory is removed"); + } + + #[tokio::test] + async fn missing_lrc_file_returns_none() { + let root = scratch("missing"); + let track = root.join("Song.flac"); + let id = wire::track_id(&track); + + let found = read(&id) + .await + .expect("missing local lyrics are not an error"); + + assert!(found.is_none()); + + fs::remove_dir_all(root).expect("scratch directory is removed"); + } + + #[tokio::test] + async fn empty_lrc_file_is_rejected() { + let root = scratch("empty"); + let track = root.join("Song.flac"); + let id = wire::track_id(&track); + + fs::write(track.with_extension("lrc"), "").expect("lyrics file is written"); + + let error = read(&id) + .await + .expect_err("empty local lyrics are rejected"); + + assert!( + error + .to_string() + .contains("does not contain valid LRC lyrics") + ); + + fs::remove_dir_all(root).expect("scratch directory is removed"); + } + + #[tokio::test] + async fn lrc_file_without_valid_lines_is_rejected() { + let root = scratch("malformed"); + let track = root.join("Song.flac"); + let id = wire::track_id(&track); + + fs::write( + track.with_extension("lrc"), + "[not-a-time]This is not valid timed LRC\n", + ) + .expect("lyrics file is written"); + + let error = read(&id) + .await + .expect_err("malformed local lyrics are rejected"); + + assert!( + error + .to_string() + .contains("does not contain valid LRC lyrics") + ); + + fs::remove_dir_all(root).expect("scratch directory is removed"); + } + + #[tokio::test] + async fn unreadable_lrc_file_is_rejected() { + let root = scratch("unreadable"); + let track = root.join("Song.flac"); + let id = wire::track_id(&track); + + fs::create_dir(track.with_extension("lrc")).expect("lyrics path is created as a directory"); + + let error = read(&id) + .await + .expect_err("unreadable local lyrics are rejected"); + + assert!(error.to_string().contains("cannot read local lyrics")); + + fs::remove_dir_all(root).expect("scratch directory is removed"); + } + + #[tokio::test] + async fn updated_lrc_file_is_read_again() { + let root = scratch("updated"); + let track = root.join("Song.flac"); + let lyrics = track.with_extension("lrc"); + let id = wire::track_id(&track); + + fs::write(&lyrics, "[00:01.00]Before\n").expect("lyrics file is written"); + + let first = read(&id) + .await + .expect("first lookup succeeds") + .expect("first lyrics file exists"); + + fs::write(&lyrics, "[00:01.00]After\n").expect("lyrics file is updated"); + + let second = read(&id) + .await + .expect("second lookup succeeds") + .expect("second lyrics file exists"); + + let Lyrics::Synced { lines: first } = first else { + panic!("LRC file produces synced lyrics"); + }; + let Lyrics::Synced { lines: second } = second else { + panic!("LRC file produces synced lyrics"); + }; + + assert_eq!(first[0].text, "Before"); + assert_eq!(second[0].text, "After"); + + fs::remove_dir_all(root).expect("scratch directory is removed"); + } +} diff --git a/crates/music/src/local/mod.rs b/crates/music/src/local/mod.rs index ce2b571c..1e375dba 100644 --- a/crates/music/src/local/mod.rs +++ b/crates/music/src/local/mod.rs @@ -1,4 +1,5 @@ mod client; +mod lyrics; mod playback; mod scan; mod store; diff --git a/crates/state/src/lyrics.rs b/crates/state/src/lyrics.rs index 44df8047..c4d2d70c 100644 --- a/crates/state/src/lyrics.rs +++ b/crates/state/src/lyrics.rs @@ -148,7 +148,9 @@ impl Lyrics { self.settled = false; self.revision = self.revision.wrapping_add(1); - if let Some(found) = self.remembered(&id, cx) { + if !music::is_local_id(&id) + && let Some(found) = self.remembered(&id, cx) + { self.task = None; self.settled = true; self.hits = found.hits; @@ -157,6 +159,7 @@ impl Lyrics { self.prefetch(cx); return; } + self.load(id, track, cx); } @@ -212,12 +215,40 @@ impl Lyrics { }) } + fn local_native(&self, id: &str, cx: &mut Context) -> Option { + if !music::is_local_id(id) { + return None; + } + + let session = self.session.read(cx); + Some(Native { + api: session.local_client()?, + source: session.local_name(), + id: id.to_owned(), + }) + } + + fn finish(&mut self, id: &str) -> bool { + let current = self.following.as_deref() == Some(id); + + if current { + self.task = None; + } + + if self.ahead_of.as_deref() == Some(id) { + self.ahead_of = None; + } + + current + } + fn load(&mut self, id: String, track: Track, cx: &mut Context) { - if self.providers.is_empty() { + if self.providers.is_empty() && !music::is_local_id(&id) { self.state = LyricsState::Missing; cx.notify(); return; } + self.hits.clear(); self.state = LyricsState::Loading; cx.notify(); @@ -227,36 +258,36 @@ impl Lyrics { self.ahead_of = None; return; } + self.task = Some(self.fetch(id, track, cx)); } fn prefetch(&mut self, cx: &mut Context) { - if self.providers.is_empty() || self.task.is_some() { + if self.task.is_some() { return; } + let next = self.queue.read(cx).upcoming().next().cloned(); let Some((track, id)) = next.and_then(|track| Some((track.clone(), track.id?))) else { return; }; + + let local = music::is_local_id(&id); + if self.ahead_of.as_deref() == Some(id.as_str()) - || self.cache.contains_key(&id) - || self.store.holds(&self.key(&id, cx)) + || (!local && self.providers.is_empty()) + || (!local && (self.cache.contains_key(&id) || self.store.holds(&self.key(&id, cx)))) { return; } + self.ahead_of = Some(id.clone()); self.ahead = Some(self.fetch(id, track, cx)); } fn fetch(&mut self, id: String, track: Track, cx: &mut Context) -> Task<()> { - if !self.settings.read(cx).lyrics_for_local_files() && music::is_local_id(&id) { - log::info!( - "lyrics: local files are disabled, skipping {:?}", - track.name - ); - self.state = LyricsState::Missing; - return Task::ready(()); - } + let local = music::is_local_id(&id); + let online_for_local = self.settings.read(cx).lyrics_for_local_files(); let key = self .session @@ -266,14 +297,94 @@ impl Lyrics { provider, id: id.clone(), }); + let query = query_for(&track, key); let providers = self.providers.clone(); + let local_native = self.local_native(&id, cx); let native = self.native(&id, cx); let io = self.io.clone(); + cx.spawn(async move |this, cx| { + if local { + if let Some(local_native) = local_native { + let local_query = query.clone(); + let worker = io.spawn(async move { own(local_native, local_query).await }); + + match worker.await { + Ok(hits) if !hits.is_empty() => { + log::debug!("lyrics: using local LRC for {:?}", track.name); + + this.update(cx, |this, cx| { + let current = this.finish(&id); + + if current { + this.settled = true; + this.pin(hits, None); + this.state = LyricsState::Ready; + cx.notify(); + this.prefetch(cx); + } + }) + .ok(); + + return; + } + Ok(_) => {} + Err(error) => { + log::warn!("lyrics: local lyrics task failed: {error}"); + } + } + } + + let remembered = this + .update(cx, |this, cx| { + let Some(found) = this.remembered(&id, cx) else { + return false; + }; + + let current = this.finish(&id); + + if current { + this.settled = true; + this.hits = found.hits; + this.state = state_for(&this.hits, found.instrumental); + cx.notify(); + this.prefetch(cx); + } + + true + }) + .unwrap_or(false); + + if remembered { + return; + } + + if !online_for_local { + log::info!( + "lyrics: online lyrics for local files are disabled, skipping {:?}", + track.name + ); + + this.update(cx, |this, cx| { + if this.finish(&id) { + this.settled = true; + this.hits.clear(); + this.state = LyricsState::Missing; + cx.notify(); + this.prefetch(cx); + } + }) + .ok(); + + return; + } + } + let (sender, mut incoming) = tokio::sync::mpsc::unbounded_channel(); let ranking = query.clone(); let worker = io.spawn(async move { gather(providers, native, query, sender).await }); + let mut hits = Vec::new(); let mut displayed: Option = None; let mut shown: Option = None; @@ -281,19 +392,24 @@ impl Lyrics { while let Some(mut found) = incoming.recv().await { hits.append(&mut found); let ranked = ordered(&ranking, hits.clone()); + let Some(best) = ranked.first().cloned() else { continue; }; + let step = depth(&best.lyrics); if shown.is_none_or(|shown| step >= shown) { shown = Some(step); displayed = Some(best); } + let anchor = displayed.clone(); + this.update(cx, |this, cx| { if this.following.as_deref() != Some(id.as_str()) { return; } + this.paint(ranked, anchor.as_ref(), cx); }) .ok(); @@ -302,21 +418,18 @@ impl Lyrics { let found = join(worker).await; this.update(cx, |this, cx| { - let current = this.following.as_deref() == Some(id.as_str()); - if current { - this.task = None; - } - if this.ahead_of.as_deref() == Some(id.as_str()) { - this.ahead_of = None; - } + let current = this.finish(&id); + match found { Ok(()) => { let instrumental = music::lyrics::instrumental(&ranking, &hits); let ranked = ordered(&ranking, hits); + this.remember(id, ranked, displayed.as_ref(), instrumental, current, cx); } Err(error) => { log::warn!("lyrics: cannot look up {}: {error:#}", track.name); + if current { this.state = LyricsState::Failed(format!("{error:#}")); cx.notify(); diff --git a/crates/state/src/session.rs b/crates/state/src/session.rs index f146994a..516a87d6 100644 --- a/crates/state/src/session.rs +++ b/crates/state/src/session.rs @@ -249,6 +249,10 @@ impl Session { Some(provider.slug()) } + pub fn local_name(&self) -> &'static str { + self.local_provider.name() + } + pub fn local_slug(&self) -> &'static str { self.local_provider.slug() }