From e6c7d6a670a374561834e848eefa3c5414496c2e Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 5 Oct 2026 08:28:36 +0200 Subject: [PATCH 1/8] fix(playlists): keep smart playlists out of add to playlist A smart playlist is rebuilt from its rules, so a track added to it by hand showed up and then vanished at the next regeneration. The four add-to-playlist lists now offer regular playlists only, and the three add commands refuse a smart one. --- docs/features/smart-playlists.md | 2 +- src-tauri/crates/app/src/commands/playlist.rs | 23 +++++++++++++++++++ src/components/common/SelectionActionBar.tsx | 7 ++++-- src/components/common/TrackContextMenu.tsx | 7 ++++-- src/components/views/LibraryView.tsx | 10 ++++++-- 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/docs/features/smart-playlists.md b/docs/features/smart-playlists.md index aab6121f..e4d056a1 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. So the app does not offer it: the "Add to playlist" menus list only regular playlists, and `add_track_to_playlist`, `add_tracks_to_playlist` and `add_source_to_playlist` refuse a smart one. 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/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/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 (