diff --git a/docs/features/integrations.md b/docs/features/integrations.md index 8c02c899..4edc68c6 100644 --- a/docs/features/integrations.md +++ b/docs/features/integrations.md @@ -164,7 +164,7 @@ Triggered from [`emit_track_changed`](../../src-tauri/crates/app/src/commands/pl repairs it and falls through to the sidecar, with nothing for the listener to do. -**Plugins** (`waveflow:metadata/v2` and `/v3`) — between tiers 4 and 5, ahead of every network provider: a plugin is there because the user installed it, and it is the only source that returns translations and a pronunciation with the lyrics. A v2 plugin is asked about the artist and the title; a v3 one about the track — the same two, plus the album, the file's length and the ISRC it is tagged with (read from `track_tag` only when a v3 plugin is enabled) — so a provider can tell a single from an album cut instead of matching on a name. All enabled lyrics plugins are asked at once, and the first answer that validates is cached as a bundle ([plugins.md](plugins.md)). **Only a synced answer ends the waterfall here** (#668). An unsynced one — Apple serves lyrics it has no timing for as TTML with `itunes:timing="None"` and no `begin` on its lines — is held while tiers 5 to 7 look for synced lyrics; if they find none (plain text, an instrumental verdict, a miss, a network failure), the plugin's answer is cached again and returned, since those tiers write their own result over it. So the plugin still wins a tie, and karaoke beats static text. "Synced" follows the renderer: LRC, Enhanced LRC, or a well-formed TTML document with at least one `

` (`lyrics_are_synced`). Picking the plugin by name in the provider picker (`refetch_lyrics` with its id) returns its answer as it is: the panel lists every enabled v2 and v3 plugin below the built-in providers ([`useLyricsPlugins`](../../src/hooks/usePluginNames.ts)), and opens on any library track — embedded, sidecar or no lyrics at all included — so a plugin can be asked about one track and seen to answer or not. +**Plugins** (`waveflow:metadata/v2` and `/v3`) — between tiers 4 and 5, ahead of every network provider: a plugin is there because the user installed it, and it is the only source that returns translations and a pronunciation with the lyrics. A v2 plugin is asked about the artist and the title; a v3 one about the track — the same two, plus the album, the file's length and the ISRC it is tagged with (read from `track_tag` only when a v3 plugin is enabled) — so a provider can tell a single from an album cut instead of matching on a name. All enabled lyrics plugins are asked at once, and the first answer that validates is cached as a bundle ([plugins.md](plugins.md)). **Only a synced answer ends the waterfall here** (#668). An unsynced one — Apple serves lyrics it has no timing for as TTML with `itunes:timing="None"` and no `begin` on its lines — is held while tiers 5 to 7 look for synced lyrics; if they find none (plain text, an instrumental verdict, a miss, a network failure), the plugin's answer is cached again and returned, since those tiers write their own result over it. So the plugin still wins a tie, and karaoke beats static text. "Synced" follows the renderer: LRC or Enhanced LRC whose lines carry real timing, or a well-formed TTML document with at least one `

` (`lyrics_are_synced`). Stamps alone are not timing: some sources stamp every line `[00:00.00]`, so LRC where more than half of the sung lines share one time counts as unsynced (`lrc_has_timing`), and the renderer shows it as plain text (`isUntimedLrc`) instead of lighting only its last line. Enhanced LRC word stamps at two or more different times count as timing however the lines are stamped, so word-timed lyrics never lose their karaoke to this rule. Picking the plugin by name in the provider picker (`refetch_lyrics` with its id) returns its answer as it is: the panel lists every enabled v2 and v3 plugin below the built-in providers ([`useLyricsPlugins`](../../src/hooks/usePluginNames.ts)), and opens on any library track — embedded, sidecar or no lyrics at all included — so a plugin can be asked about one track and seen to answer or not. 5. **Musixmatch Enhanced** — asks for word-level karaoke first. It only wins early when the result is actually Enhanced LRC; regular line-level LRC from Musixmatch falls through so LRCLIB's stricter metadata match can still win. 6. **LRCLIB** — synced lyrics first, falls back to plain text. Result cached as a new row. diff --git a/docs/features/smart-playlists.md b/docs/features/smart-playlists.md index aab6121f..eb100ad2 100644 --- a/docs/features/smart-playlists.md +++ b/docs/features/smart-playlists.md @@ -161,7 +161,7 @@ The cross-DB lookup in `top_artists_with_bpm` opens a short-lived secondary conn ## Manual edits -A user adding or removing a track from a Daily Mix **will lose the change on the next regen**: `upsert_smart_playlist` deletes every `playlist_track` row for the playlist before re-inserting the freshly shuffled set. If durable curation is needed, the right move is to "Save as new playlist", which copies the tracks into a fresh `is_smart = 0` row. +A user adding or removing a track from a Daily Mix **will lose the change on the next regen**: `upsert_smart_playlist` deletes every `playlist_track` row for the playlist before re-inserting the freshly shuffled set. **Adding is refused**: the "Add to playlist" menus list only regular playlists, and `add_track_to_playlist`, `add_tracks_to_playlist` and `add_source_to_playlist` reject a smart one, since the track would be accepted and then vanish. **Removing is still allowed** ("Remove from playlist" on the track and in the selection bar) and lasts until the next regeneration: it hides a track for now, which is still useful. There is no way yet to freeze a mix into a regular playlist (planned for 1.9); until then, durable curation means building a regular playlist by hand. ## Custom smart playlists (recursive boolean rule tree) diff --git a/src-tauri/crates/app/src/commands/lyrics.rs b/src-tauri/crates/app/src/commands/lyrics.rs index 807072e9..d3180de5 100644 --- a/src-tauri/crates/app/src/commands/lyrics.rs +++ b/src-tauri/crates/app/src/commands/lyrics.rs @@ -1838,8 +1838,8 @@ struct PluginAnswer { plugin_id: String, } -/// Lyrics the renderer can follow: LRC or Enhanced LRC with at least one -/// complete line stamp, or a well-formed TTML document with at least one +/// Lyrics the renderer can follow: LRC or Enhanced LRC whose lines carry +/// real timing (see [`lrc_has_timing`]), or a well-formed TTML document with at least one /// `

` whose `begin` it can read. Each rule mirrors the frontend parser, /// because an answer judged synced here ends the waterfall, and one the /// renderer then cannot follow is static text that beat synced lyrics. @@ -1847,7 +1847,7 @@ struct PluginAnswer { /// `itunes:timing="None"` and bare lines. fn lyrics_are_synced(format: &LyricsFormat, content: &str) -> bool { match format { - LyricsFormat::Lrc | LyricsFormat::EnhancedLrc => lrc_has_line_stamp(content), + LyricsFormat::Lrc | LyricsFormat::EnhancedLrc => lrc_has_timing(content), LyricsFormat::Plain => false, LyricsFormat::Ttml => ttml_has_timed_line(content), } @@ -1859,8 +1859,85 @@ static LRC_LINE_STAMP: std::sync::LazyLock = std::sync::LazyLock:: regex::Regex::new(r"\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]").expect("static pattern") }); -fn lrc_has_line_stamp(content: &str) -> bool { - LRC_LINE_STAMP.is_match(content) +/// Whether stamped LRC is actually timed. A complete line stamp is not +/// enough: some sources stamp every line `[00:00.00]`, which the renderer +/// shows as plain text (`isUntimedLrc` in `src/lib/tauri/lyrics.ts`), so +/// an answer like that must not end the search ahead of real timing. The +/// rule is the frontend's: untimed when more than half of the sung lines +/// (a stamp followed by text) share one time. Enhanced LRC word stamps at +/// two or more different times are timing however the lines are stamped: +/// those words still carry the karaoke. Only a stamp followed by text +/// counts; the one closing a line marks where it ends, not a word. Text +/// before a line's first word stamp is a word sung at the line's own time, +/// as `parseEnhancedLrc` reads it. +fn lrc_has_timing(content: &str) -> bool { + let mut word_times = std::collections::HashSet::new(); + for line in content.lines() { + // Read like the frontend: lines without a line stamp are skipped, + // and the words are found in what remains once those are removed. + let line_times: Vec = LRC_LINE_STAMP + .captures_iter(line) + .map(|stamp| lrc_stamp_ms(&stamp)) + .collect(); + if line_times.is_empty() { + continue; + } + let body = LRC_LINE_STAMP.replace_all(line, ""); + let stamps: Vec<_> = LRC_WORD_STAMP.captures_iter(&body).collect(); + let Some(first) = stamps.first().and_then(|stamp| stamp.get(0)) else { + continue; + }; + if !body[..first.start()].trim().is_empty() { + word_times.extend(line_times.iter().copied()); + } + for (i, stamp) in stamps.iter().enumerate() { + let from = stamp.get(0).map_or(body.len(), |m| m.end()); + let to = stamps + .get(i + 1) + .and_then(|next| next.get(0)) + .map_or(body.len(), |m| m.start()); + if !body[from..to].trim().is_empty() { + word_times.insert(lrc_stamp_ms(stamp)); + } + } + } + if word_times.len() >= 2 { + return true; + } + let mut per_time: std::collections::HashMap = std::collections::HashMap::new(); + let mut sung = 0usize; + for line in content.lines() { + if LRC_LINE_STAMP.replace_all(line, "").trim().is_empty() { + continue; + } + for stamp in LRC_LINE_STAMP.captures_iter(line) { + *per_time.entry(lrc_stamp_ms(&stamp)).or_default() += 1; + sung += 1; + } + } + let top = per_time.values().copied().max().unwrap_or(0); + sung > 0 && !(sung >= 2 && top * 2 > sung) +} + +/// An Enhanced LRC word stamp, ``, same fields as a line stamp. +static LRC_WORD_STAMP: std::sync::LazyLock = std::sync::LazyLock::new(|| { + regex::Regex::new(r"<(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?>").expect("static pattern") +}); + +/// A captured `[mm:ss.xx]` or `` stamp in milliseconds. Only compared for +/// equality, but normalised the way the frontend reads it: a fraction of +/// one, two or three digits is tenths, hundredths or thousandths. +fn lrc_stamp_ms(stamp: ®ex::Captures) -> u64 { + let number = |i: usize| { + stamp + .get(i) + .map_or(0, |m| m.as_str().parse::().unwrap_or(0)) + }; + let fraction = stamp.get(3).map_or(0, |m| { + let digits = m.as_str(); + digits.parse::().unwrap_or(0) * 10u64.pow(3 - digits.len().min(3) as u32) + }); + number(1) * 60_000 + number(2) * 1000 + fraction } fn ttml_has_timed_line(content: &str) -> bool { @@ -5144,6 +5221,44 @@ mod tests { assert!(!lyrics_are_synced(&LyricsFormat::Ttml, elsewhere)); } + #[test] + fn lrc_with_every_line_on_one_time_is_untimed() { + let all_zero = "[00:00.00]first\n[00:00.00]second\n[00:00.00]third"; + assert!(!lyrics_are_synced(&LyricsFormat::Lrc, all_zero)); + // A title card at 0 and real timing after it stays synced. + let mostly_timed = + "[00:00.00]Title\n[00:00.00]Artist\n[00:12.40]one\n[00:15.10]two\n[00:18.00]three"; + assert!(lyrics_are_synced(&LyricsFormat::Lrc, mostly_timed)); + // More than half on one time is untimed, whichever time it is. + let piled = "[00:30.00]a\n[00:30.00]b\n[00:30.00]c\n[00:31.00]d"; + assert!(!lyrics_are_synced(&LyricsFormat::Lrc, piled)); + // The same instant written with different precision is one time. + let same_instant = "[00:01.5]a\n[00:01.50]b\n[00:01.500]c"; + assert!(!lyrics_are_synced(&LyricsFormat::Lrc, same_instant)); + // Word stamps carry timing even when every line sits on 0. + let words = "[00:00.00]<00:01.00>a <00:01.50>b\n[00:00.00]<00:03.00>c <00:03.40>d"; + assert!(lyrics_are_synced(&LyricsFormat::EnhancedLrc, words)); + // A stamp closing a line times no word: words piled on one time + // stay untimed whatever their end markers say. + let closers = "[00:00.00]<00:00.00>a <00:00.00>b <00:05.00>\n[00:00.00]<00:00.00>c <00:00.00>d <00:06.00>"; + assert!(!lyrics_are_synced(&LyricsFormat::EnhancedLrc, closers)); + // …while real word timing with the same end markers stays synced. + let timed_with_closers = "[00:00.00]<00:01.00>a <00:01.50>b <00:02.00>\n[00:00.00]<00:03.00>c <00:03.40>d <00:04.00>"; + assert!(lyrics_are_synced( + &LyricsFormat::EnhancedLrc, + timed_with_closers + )); + // Text before the first word stamp is sung at the line's time, as + // the frontend reads it: lines on 0 with words at 5 s are timed. + let prefixed = "[00:00.00]a <00:05.00>b\n[00:00.00]c <00:05.00>d"; + assert!(lyrics_are_synced(&LyricsFormat::EnhancedLrc, prefixed)); + // Exactly half is not more than half. + assert!(lyrics_are_synced( + &LyricsFormat::Lrc, + "[00:01.00]a\n[00:01.00]b\n[00:02.00]c\n[00:03.00]d" + )); + } + #[test] fn lrc_needs_a_complete_line_stamp() { assert!(lyrics_are_synced(&LyricsFormat::Lrc, "[00:01.00]x")); @@ -5157,6 +5272,11 @@ mod tests { )); assert!(!lyrics_are_synced(&LyricsFormat::Lrc, "[00:01x\nline")); assert!(!lyrics_are_synced(&LyricsFormat::Lrc, "[ar:Someone]\nline")); + // Stamps with no words after them time nothing. + assert!(!lyrics_are_synced( + &LyricsFormat::Lrc, + "[00:01.00]\n[00:02.00]" + )); assert!(!lyrics_are_synced(&LyricsFormat::Plain, "x")); } diff --git a/src-tauri/crates/app/src/commands/playlist.rs b/src-tauri/crates/app/src/commands/playlist.rs index 029c816f..9b9fbc4e 100644 --- a/src-tauri/crates/app/src/commands/playlist.rs +++ b/src-tauri/crates/app/src/commands/playlist.rs @@ -400,6 +400,26 @@ pub async fn list_playlists_containing_track( .await?) } +/// Refuse to add tracks to a smart playlist. Its tracks are rebuilt from +/// its rules on every regeneration, so a hand-added track would be +/// accepted, shown, and then silently dropped. The menus no longer offer +/// smart playlists; this is the guard for every other way in. +async fn refuse_smart_playlist( + conn: &mut sqlx::SqliteConnection, + playlist_id: i64, +) -> AppResult<()> { + let is_smart: Option = sqlx::query_scalar("SELECT is_smart FROM playlist WHERE id = ?") + .bind(playlist_id) + .fetch_optional(&mut *conn) + .await?; + if is_smart == Some(1) { + return Err(AppError::Other( + "tracks cannot be added to a smart playlist: it is rebuilt from its rules".into(), + )); + } + Ok(()) +} + /// Append a single track to the end of a playlist. Idempotent — if the track /// is already in the playlist the existing row is preserved and `updated_at` /// is still bumped so the UI reflects the user's intent. @@ -414,6 +434,7 @@ pub async fn add_track_to_playlist( let now = now_millis(); let mut tx = pool.begin().await?; + refuse_smart_playlist(&mut tx, playlist_id).await?; append_track_conn(&mut tx, playlist_id, track_id, now).await?; let entity_id = crate::sync::canonical::ensure_local_playlist(&mut tx, playlist_id).await?; // Phase 1.j.b — fold per-track snapshots into the outbound @@ -462,6 +483,7 @@ pub async fn add_tracks_to_playlist( let now = now_millis(); let mut tx = pool.begin().await?; + refuse_smart_playlist(&mut tx, playlist_id).await?; let inserted = append_tracks_conn(&mut tx, playlist_id, &track_ids, now).await?; let entity_id = crate::sync::canonical::ensure_local_playlist(&mut tx, playlist_id).await?; // Phase 1.j.b — per-track snapshots for the public share @@ -625,6 +647,7 @@ pub async fn add_source_to_playlist( .await?; let mut tx = pool.begin().await?; + refuse_smart_playlist(&mut tx, playlist_id).await?; let inserted = append_tracks_conn(&mut tx, playlist_id, &track_ids, now_millis()).await?; let entity_id = crate::sync::canonical::ensure_local_playlist(&mut tx, playlist_id).await?; // Phase 1.j.b — per-track snapshots for the public share diff --git a/src-tauri/crates/app/src/commands/web_radio_catalogue.rs b/src-tauri/crates/app/src/commands/web_radio_catalogue.rs index 8f2384c0..b5fb7f02 100644 --- a/src-tauri/crates/app/src/commands/web_radio_catalogue.rs +++ b/src-tauri/crates/app/src/commands/web_radio_catalogue.rs @@ -487,14 +487,17 @@ pub async fn resolve_radio_catalogue( .await? } else if let Some(tag) = q.strip_prefix("tag:") { // Substring match on the comma-joined tag list. Broader than the - // live `bytag` exact match, but fine for offline browsing. + // live `bytag` exact match, but fine for offline browsing. The tag + // is escaped, so a `%` or `_` in it is a character, not a wildcard. let tag = tag.trim().to_lowercase(); sqlx::query_as( "SELECT stream_url, name, country, tags, favicon, bitrate - FROM radio_station WHERE lower(tags) LIKE '%' || ? || '%' + FROM radio_station WHERE lower(tags) LIKE ? ESCAPE '\\' ORDER BY votes DESC LIMIT ?", ) - .bind(tag) + .bind(waveflow_core::repository::sqlite::search::like_pattern( + &tag, + )) .bind(limit) .fetch_all(pool) .await? diff --git a/src/components/common/SelectionActionBar.tsx b/src/components/common/SelectionActionBar.tsx index 4c8c3211..3788b6b1 100644 --- a/src/components/common/SelectionActionBar.tsx +++ b/src/components/common/SelectionActionBar.tsx @@ -38,6 +38,9 @@ export function SelectionActionBar({ }: SelectionActionBarProps) { const { t } = useTranslation(); const { playlists } = usePlaylist(); + // Smart playlists are rebuilt from their rules: a hand-added track would + // vanish at the next regeneration. The backend refuses it too. + const editablePlaylists = playlists.filter((pl) => pl.is_smart !== 1); const [isAddOpen, setIsAddOpen] = useState(false); const addRef = useRef(null); @@ -196,12 +199,12 @@ export function SelectionActionBar({ {t("selection.addToPlaylist")}

- {playlists.length === 0 ? ( + {editablePlaylists.length === 0 ? (
{t("trackActions.noPlaylists")}
) : ( - playlists.map((pl) => { + editablePlaylists.map((pl) => { const color = resolvePlaylistColor(pl.color_id); return (