Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/features/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<p begin>` (`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 `<p begin>` (`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.
Expand Down
2 changes: 1 addition & 1 deletion docs/features/smart-playlists.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
130 changes: 125 additions & 5 deletions src-tauri/crates/app/src/commands/lyrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1838,16 +1838,16 @@ 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
/// `<p>` 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.
/// Apple serves lyrics it has no timing for as TTML too, with
/// `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),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
LyricsFormat::Plain => false,
LyricsFormat::Ttml => ttml_has_timed_line(content),
}
Expand All @@ -1859,8 +1859,85 @@ static LRC_LINE_STAMP: std::sync::LazyLock<regex::Regex> = 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<u64> = 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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let mut per_time: std::collections::HashMap<u64, usize> = 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, `<mm:ss.xx>`, same fields as a line stamp.
static LRC_WORD_STAMP: std::sync::LazyLock<regex::Regex> = 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 `<mm:ss.xx>` 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: &regex::Captures) -> u64 {
let number = |i: usize| {
stamp
.get(i)
.map_or(0, |m| m.as_str().parse::<u64>().unwrap_or(0))
};
let fraction = stamp.get(3).map_or(0, |m| {
let digits = m.as_str();
digits.parse::<u64>().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 {
Expand Down Expand Up @@ -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"));
Expand All @@ -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"));
}

Expand Down
23 changes: 23 additions & 0 deletions src-tauri/crates/app/src/commands/playlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64> = 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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions src-tauri/crates/app/src/commands/web_radio_catalogue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
7 changes: 5 additions & 2 deletions src/components/common/SelectionActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);

Expand Down Expand Up @@ -196,12 +199,12 @@ export function SelectionActionBar({
{t("selection.addToPlaylist")}
</div>
<div className="px-2 max-h-64 overflow-y-auto">
{playlists.length === 0 ? (
{editablePlaylists.length === 0 ? (
<div className="px-2 py-3 text-xs text-zinc-400 text-center">
{t("trackActions.noPlaylists")}
</div>
) : (
playlists.map((pl) => {
editablePlaylists.map((pl) => {
const color = resolvePlaylistColor(pl.color_id);
return (
<button
Expand Down
7 changes: 5 additions & 2 deletions src/components/common/TrackContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ export function TrackContextMenu({
}: TrackContextMenuProps) {
const { t } = useTranslation();
const artists = parseArtistList(track.artist_ids, track.artist_name);
// A smart playlist is rebuilt from its rules, so a track added by hand
// would vanish at the next regeneration. The backend refuses it too.
const editablePlaylists = playlists.filter((pl) => pl.is_smart !== 1);
// Round POPM 0-255 to the nearest integer star (0-5) for the
// current-rating chip on the submenu trigger. Sub-star precision
// (half stars) is supported via the modal's slider — the context
Expand Down Expand Up @@ -142,12 +145,12 @@ export function TrackContextMenu({
{/* `overscroll-contain`: reaching the end of the list must not
scroll the page behind it, which closes the menu. */}
<div className="max-h-64 overflow-y-auto overscroll-contain">
{playlists.length === 0 ? (
{editablePlaylists.length === 0 ? (
<div className="px-3 py-2 text-xs text-zinc-400">
{t("trackActions.noPlaylists")}
</div>
) : (
playlists.map((pl) => {
editablePlaylists.map((pl) => {
const color = resolvePlaylistColor(pl.color_id);
return (
<button
Expand Down
2 changes: 1 addition & 1 deletion src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export function Sidebar({
libraryId = libraries[0].id;
selectLibrary(libraryId);
} else {
const lib = await createLibrary({ name: "Ma musique" });
const lib = await createLibrary({ name: t("onboarding.defaultLibraryName") });
libraryId = lib.id;
selectLibrary(libraryId);
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/views/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ export function HomeView({
libId = libraries[0].id;
selectLibrary(libId);
} else {
const lib = await createLibrary({ name: "Ma musique" });
const lib = await createLibrary({ name: t("onboarding.defaultLibraryName") });
libId = lib.id;
selectLibrary(libId);
}
Expand Down
Loading
Loading