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")}