feat(qt): move a playlist to a folder from its editor, delete one from any context menu

The editor gains a Folder row — the same folders the sidebar and the
manager file playlists into, "No folder" for the root — seeded from the
playlist's current folder and applied on Save through the manager's own
move verb, so an existing playlist no longer needs the sidebar's row menu
to be filed (#785). "Delete playlist" joins the sidebar row menu (own and
local playlists; sidebar entries now carry isOwner) and the playlist card
menu (own playlists), behind a shell-level "Delete playlist?" confirmation
that runs the same delete the editor's button runs and leaves the detail
page only when it was the deleted one (#776). Eight catalogs updated.

cc
This commit is contained in:
vicrodh committed 2026-09-13 14:47:12 -06:00
1 parent 83c12c62c5
commit 1ba578a1d8
17 files changed
+445 -35

No files matched your search

@@ -7626,3 +7626,15 @@ msgstr "In diesem Netzwerk"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Koppeln"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist"
msgstr "Playlist löschen"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist?"
msgstr "Playlist löschen?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "„{}“ wird gelöscht. Das lässt sich nicht rückgängig machen."
@@ -3265,3 +3265,27 @@ msgstr "On this network"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Pair"
#: qbz-qt/qml/controls/PlaylistEditModal.qml
msgid "Folder"
msgstr "Folder"
#: qbz-qt/qml/controls/PlaylistEditModal.qml
msgid "No folder"
msgstr "No folder"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist"
msgstr "Delete playlist"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist?"
msgstr "Delete playlist?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "“{}” will be deleted. This cannot be undone."
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete"
msgstr "Delete"
@@ -7632,3 +7632,15 @@ msgstr "En esta red"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Vincular"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist"
msgstr "Eliminar lista de reproducción"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist?"
msgstr "¿Eliminar la lista de reproducción?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "«{}» se eliminará. Esto no se puede deshacer."
@@ -7624,3 +7624,15 @@ msgstr "Sur ce réseau"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Associer"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist"
msgstr "Supprimer la playlist"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist?"
msgstr "Supprimer la playlist ?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "« {} » sera supprimée. Cette action est irréversible."
@@ -7607,3 +7607,15 @@ msgstr "このネットワーク上"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "ペアリング"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist"
msgstr "プレイリストを削除"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist?"
msgstr "プレイリストを削除しますか?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "「{}」を削除します。この操作は取り消せません。"
@@ -7614,3 +7614,15 @@ msgstr "In dit netwerk"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Koppelen"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist"
msgstr "Afspeellijst verwijderen"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist?"
msgstr "Afspeellijst verwijderen?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "“{}” wordt verwijderd. Dit kan niet ongedaan worden gemaakt."
@@ -7624,3 +7624,15 @@ msgstr "Nesta rede"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Parear"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist"
msgstr "Excluir playlist"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist?"
msgstr "Excluir a playlist?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "“{}” será excluída. Isso não pode ser desfeito."
@@ -7645,3 +7645,15 @@ msgstr "В этой сети"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Подключить"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist"
msgstr "Удалить плейлист"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "Delete playlist?"
msgstr "Удалить плейлист?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "«{}» будет удалён. Это действие нельзя отменить."
+1
View File
@@ -570,6 +570,7 @@ fn main() {
"qml/controls/MyQbzModals.qml",
"qml/controls/PlaylistCreateModal.qml",
"qml/controls/PlaylistEditModal.qml",
"qml/controls/PlaylistDeleteConfirm.qml",
"qml/controls/PlaylistImportModal.qml",
"qml/controls/PlaylistPickerModal.qml",
"qml/controls/PmFolderIcon.qml",
+6
View File
@@ -392,6 +392,11 @@ Rectangle {
m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" })
if (root.hasCopyByIdSeam && !owned && root.item.playlistCopied !== true)
m.push({ "label": t("Copy to your library", r), "icon": "copy", "action": "copy" })
// Delete (2026-09-13): the fast path from search / library / browse
// cards, behind the shell-level confirmation. Owned playlists only —
// a followed one has Unfollow above.
if (owned)
m.push({ "label": t("Delete playlist", r), "icon": "trash-2", "action": "delete", "danger": true })
return m
}
@@ -415,5 +420,6 @@ Rectangle {
}]))
}
else if (a === "cache") QbzOffline.cachePlaylist(String(root.item.id))
else if (a === "delete") QbzPlaylistEdit.askDelete(String(root.item.id), String(root.item.title || ""))
}
}
@@ -0,0 +1,42 @@
// The "Delete playlist?" confirmation summoned from context menus — the
// sidebar row menu and the playlist card (2026-09-13). Self-gates on
// QbzPlaylistEdit.deleteJson ({open, id, name, isLocal}) the way FolderModals
// gates on editJson, and lives at the AppShell level for the same reason: the
// sidebar clips to width 0 when collapsed, so a modal parented into it would
// vanish with it. Delete / Cancel go back through the bridge; Rust runs the
// same delete the editor's own button runs and refreshes the surfaces.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
readonly property var doc: {
try { return JSON.parse(QbzPlaylistEdit.deleteJson) } catch (e) { return ({}) }
}
readonly property bool askOpen: root.doc.open === true
onAskOpenChanged: {
if (root.askOpen)
confirm.open()
else if (confirm.opened)
confirm.close()
}
QbzConfirmModal {
id: confirm
anchors.fill: parent
danger: true
title: root.t("Delete playlist?")
body: root.t("\u201c{}\u201d will be deleted. This cannot be undone.")
.replace("{}", root.doc.name || "")
confirmLabel: root.t("Delete")
onConfirmed: QbzPlaylistEdit.confirmDelete()
onCancelled: QbzPlaylistEdit.cancelDelete()
}
}
@@ -98,6 +98,25 @@ Item {
property string draftName: ""
property string draftDescription: ""
property bool draftOfflineOnly: false
/// The folder the playlist will be filed in on Save ("" = root). Seeded
/// from `doc.folderId`; the move rides QbzPlaylistManager.moveToFolder,
/// the same verb the sidebar row menu uses (2026-09-13).
property string draftFolderId: ""
readonly property var folders: {
try {
var f = JSON.parse(QbzPlaylistManager.foldersJson || "[]")
return Array.isArray(f) ? f : []
} catch (e) {
return []
}
}
readonly property var folderOptions: [root.t("No folder")].concat(
root.folders.map(function (f) { return String(f.name) }))
readonly property int folderIndex: {
for (var i = 0; i < root.folders.length; i++)
if (String(root.folders[i].id) === root.draftFolderId) return i + 1
return 0
}
readonly property bool canSave: root.draftName.trim() !== "" && !root.busy
@@ -115,6 +134,7 @@ Item {
root.draftName = root.doc.name || ""
root.draftDescription = root.doc.description || ""
root.draftOfflineOnly = root.doc.offlineOnly === true
root.draftFolderId = root.doc.folderId || ""
scope.forceActiveFocus()
nameField.focusField()
}
@@ -138,9 +158,14 @@ Item {
}
function submit() {
if (root.canSave)
QbzPlaylistEdit.save(root.draftName, root.draftDescription,
root.draftOfflineOnly)
if (!root.canSave)
return
// The folder is a separate write (the sidebar's own move verb, with
// its optimistic patch); only when it changed.
if (root.draftFolderId !== (root.doc.folderId || ""))
QbzPlaylistManager.moveToFolder(String(root.doc.id), root.draftFolderId)
QbzPlaylistEdit.save(root.draftName, root.draftDescription,
root.draftOfflineOnly)
}
FocusScope {
@@ -323,6 +348,27 @@ Item {
// from its tallest child, so anchoring a child to
// `parent.verticalCenter` inside one is a binding loop. Same
// shape FolderEditPanel.qml's hidden row uses.
// Folder (2026-09-13): the same folders the sidebar and the
// manager file playlists into; "No folder" = the root.
Column {
width: parent.width
spacing: 6
Text {
text: root.t("Folder")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
QbzSelect {
menuWidth: 260
options: root.folderOptions
currentIndex: root.folderIndex
enabled: !root.busy
onSelected: function (i) {
root.draftFolderId = i <= 0 ? "" : String(root.folders[i - 1].id)
}
}
}
Item {
width: parent.width
height: 20
+7
View File
@@ -950,6 +950,13 @@ Rectangle {
PlaylistEditModal {
anchors.fill: parent
}
// PlaylistDeleteConfirm — the "Delete playlist?" confirmation the sidebar
// row menu and the playlist cards summon (QbzPlaylistEdit.askDelete).
// Self-gates on QbzPlaylistEdit.deleteJson; invisible and inert while
// closed. Out here for the same reason as FolderModals above.
PlaylistDeleteConfirm {
anchors.fill: parent
}
// PlaylistCreateModal — "New playlist" (name · description · folder ·
// public · offline-only), raised by the sidebar's "+" and, like its
// neighbour, self-gating on its own document (QbzPlaylistEdit.createJson).
@@ -89,6 +89,10 @@ QbzContextMenu {
readonly property bool isFolder: root.entry !== null && root.entry.kind === "folder"
readonly property bool isLocal: root.entry !== null && root.entry.isLocal === true
/// The signed-in user owns the Qobuz playlist (sidebar_qt stamps it);
/// a local playlist is always the user's own.
readonly property bool isOwner: root.entry !== null
&& (root.entry.isLocal === true || root.entry.isOwner === true)
readonly property string entryId: root.entry !== null ? String(root.entry.id) : ""
readonly property string entryFolderId:
(root.entry !== null && root.entry.folderId) ? String(root.entry.folderId) : ""
@@ -261,6 +265,19 @@ QbzContextMenu {
}
}
// Divider before the move-to-folder section — Qobuz only.
// Delete (2026-09-13): the fast path the editor's danger button already
// offers, one click from the row, behind the shell-level confirmation
// (PlaylistDeleteConfirm.qml). Owned Qobuz playlists and local ones only;
// a followed playlist keeps its Unfollow elsewhere.
MenuRow {
visible: !root.isFolder && root.isOwner
icon: "trash-2"
label: QbzSession.tr("Delete playlist", QbzSession.trRev)
onActivated: {
root.close()
QbzPlaylistEdit.askDelete(root.entryId, String(root.entry.name))
}
}
Item {
visible: !root.isFolder && !root.isLocal
width: parent ? parent.width : 0
+28
View File
@@ -75,6 +75,9 @@ pub mod qbz_playlist_edit_bridge {
// descLoaded / isLocal / offlineOnly / busy. Parseable default so a
// binding reading `doc.open` on the pre-publish frame cannot throw.
#[qproperty(QString, edit_json)]
/// The "Delete playlist?" confirmation summoned from context menus:
/// `{open, id, name, isLocal}` (`playlist_edit_qt::DeleteDoc`).
#[qproperty(QString, delete_json)]
/// The "New playlist" document (`playlist_create_qt`): open / busy /
/// offlineLocked / folders. Same parseable-default rule as `edit_json`.
///
@@ -138,6 +141,16 @@ pub mod qbz_playlist_edit_bridge {
#[qinvokable]
fn delete_playlist(self: Pin<&mut QbzPlaylistEdit>);
/// Context menus: open the shell-level "Delete playlist?"
/// confirmation for `id` (Qobuz id or local ref), showing `name`.
#[qinvokable]
fn ask_delete(self: Pin<&mut QbzPlaylistEdit>, id: QString, name: QString);
/// The confirmation's Delete / Cancel.
#[qinvokable]
fn confirm_delete(self: Pin<&mut QbzPlaylistEdit>);
#[qinvokable]
fn cancel_delete(self: Pin<&mut QbzPlaylistEdit>);
/// Dismiss. Refused while a save or delete is in flight (D22).
#[qinvokable]
fn close(self: Pin<&mut QbzPlaylistEdit>);
@@ -180,6 +193,7 @@ use qbz_playlist_edit_bridge::QbzPlaylistEdit;
pub struct QbzPlaylistEditRust {
edit_json: QString,
create_json: QString,
delete_json: QString,
}
impl Default for QbzPlaylistEditRust {
@@ -189,6 +203,7 @@ impl Default for QbzPlaylistEditRust {
// and no binding in the modal throws before the first publish.
edit_json: QString::from("{\"open\":false}"),
create_json: QString::from("{\"open\":false}"),
delete_json: QString::from("{\"open\":false}"),
}
}
}
@@ -216,6 +231,7 @@ impl qbz_playlist_edit_bridge::QbzPlaylistEdit {
// full shape rather than the terser Default literal.
crate::playlist_edit_qt::publish();
crate::playlist_create_qt::publish();
crate::playlist_edit_qt::publish_delete_ask();
}
pub fn open(self: Pin<&mut Self>, id: QString) {
@@ -238,6 +254,18 @@ impl qbz_playlist_edit_bridge::QbzPlaylistEdit {
crate::playlist_edit_qt::delete_playlist();
}
pub fn ask_delete(self: Pin<&mut Self>, id: QString, name: QString) {
crate::playlist_edit_qt::ask_delete(&id.to_string(), &name.to_string());
}
pub fn confirm_delete(self: Pin<&mut Self>) {
crate::playlist_edit_qt::confirm_delete();
}
pub fn cancel_delete(self: Pin<&mut Self>) {
crate::playlist_edit_qt::cancel_delete();
}
pub fn close(self: Pin<&mut Self>) {
crate::playlist_edit_qt::close();
}
+172 -32
View File
@@ -85,10 +85,128 @@ struct EditState {
is_local: bool,
offline_only: bool,
busy: bool,
/// The folder the playlist is filed in ("" = root) — the editor's
/// "Folder" row (2026-09-13).
folder_id: String,
}
static EDIT: LazyLock<Mutex<EditState>> = LazyLock::new(|| Mutex::new(EditState::default()));
/// The "Delete playlist?" confirmation summoned from a context menu (sidebar
/// row, playlist card) — the same delete the editor's button runs, minus the
/// editor. Published as `QbzPlaylistEdit.deleteJson`; the shell-level modal
/// self-gates on `open` (2026-09-13).
#[derive(Clone, Debug, Default)]
struct DeleteAsk {
open: bool,
id: String,
name: String,
is_local: bool,
}
static DELETE_ASK: LazyLock<Mutex<DeleteAsk>> =
LazyLock::new(|| Mutex::new(DeleteAsk::default()));
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DeleteDoc {
open: bool,
id: String,
name: String,
is_local: bool,
}
pub(crate) fn publish_delete_ask() {
let ask = DELETE_ASK.lock().unwrap_or_else(|e| e.into_inner()).clone();
let json = serde_json::to_string(&DeleteDoc {
open: ask.open,
id: ask.id,
name: ask.name,
is_local: ask.is_local,
})
.unwrap_or_else(|_| "{\"open\":false}".to_string());
crate::playlist_edit_bridge::ui(move |mut b| {
b.as_mut().set_delete_json(QString::from(json.as_str()));
});
}
/// Open the confirmation for `id` (a Qobuz id or a local ref), showing `name`.
pub(crate) fn ask_delete(id: &str, name: &str) {
let id = id.trim().to_string();
if id.is_empty() {
return;
}
let is_local = matches!(
crate::local_playlist_qt::PlaylistRef::parse(&id),
Some(crate::local_playlist_qt::PlaylistRef::Local(_))
);
{
let mut ask = DELETE_ASK.lock().unwrap_or_else(|e| e.into_inner());
*ask = DeleteAsk {
open: true,
id,
name: name.to_string(),
is_local,
};
}
publish_delete_ask();
}
pub(crate) fn cancel_delete() {
DELETE_ASK.lock().unwrap_or_else(|e| e.into_inner()).open = false;
publish_delete_ask();
}
/// The modal's Delete: run the same delete the editor runs, refresh the
/// surfaces, and leave the detail page only if it was the deleted one.
pub(crate) fn confirm_delete() {
let (id, is_local) = {
let mut ask = DELETE_ASK.lock().unwrap_or_else(|e| e.into_inner());
if !ask.open || ask.id.is_empty() {
return;
}
ask.open = false;
(std::mem::take(&mut ask.id), ask.is_local)
};
publish_delete_ask();
crate::spawn(async move {
let ok = delete_ref(&id, is_local).await;
if ok {
crate::playlist_qt::back_if_showing(&id);
after_write();
} else {
log::warn!("[qbz-qt] playlist delete (from menu) failed for {id}");
crate::toast_qt::error(qbz_i18n::t("Failed to delete playlist"));
}
});
}
/// Delete a playlist by ref: the local repo for a local one, the catalog
/// (delete when owned, unsubscribe otherwise) for a Qobuz one.
async fn delete_ref(id: &str, is_local: bool) -> bool {
if is_local {
let target = id.to_string();
return tokio::task::spawn_blocking(move || {
crate::local_playlist_qt::delete_blocking(&target)
})
.await
.unwrap_or(false);
}
match id.parse::<u64>() {
Ok(pid) => {
let runtime = crate::app();
match crate::playlist_qt::delete_by_id(&runtime, pid).await {
Ok(()) => true,
Err(e) => {
log::error!("[qbz-qt] {e}");
false
}
}
}
Err(_) => false,
}
}
fn state() -> std::sync::MutexGuard<'static, EditState> {
EDIT.lock().unwrap_or_else(|e| e.into_inner())
}
@@ -108,6 +226,9 @@ struct EditDoc {
is_local: bool,
offline_only: bool,
busy: bool,
/// The folder the playlist is filed in ("" = root), so the editor can
/// offer "Folder" next to the name (2026-09-13).
folder_id: String,
}
/// Serialize a snapshot. Split from [`edit_doc`] so the shape is testable
@@ -123,6 +244,7 @@ fn edit_doc_of(st: EditState) -> String {
is_local: st.is_local,
offline_only: st.offline_only,
busy: st.busy,
folder_id: st.folder_id,
})
.unwrap_or_else(|_| "{\"open\":false}".to_string())
}
@@ -171,6 +293,27 @@ struct Seed {
desc_loaded: bool,
is_local: bool,
offline_only: bool,
folder_id: String,
}
/// The folder a playlist is filed in, read off the same tables the sidebar
/// reads: `local_playlists.folder_id` for a local one, the per-playlist
/// settings for a Qobuz one. Blocking (SQLite); call off the UI thread.
fn folder_of_blocking(id: &str) -> String {
match crate::local_playlist_qt::PlaylistRef::parse(id) {
Some(crate::local_playlist_qt::PlaylistRef::Local(local)) => {
crate::local_playlist_qt::get_blocking(&local)
.and_then(|p| p.folder_id)
.unwrap_or_default()
}
Some(crate::local_playlist_qt::PlaylistRef::Qobuz(pid)) => {
crate::folders_qt::playlist_settings_map()
.get(&pid)
.and_then(|s| s.folder_id.clone())
.unwrap_or_default()
}
None => String::new(),
}
}
/// Open the editor on `id`.
@@ -218,6 +361,7 @@ fn open_local(id: String) {
seat(
id,
Seed {
folder_id: p.folder_id.clone().unwrap_or_default(),
name: p.name,
description: p.description.unwrap_or_default(),
// A local read either answered or we returned above.
@@ -233,20 +377,32 @@ fn open_qobuz(id: String, pid: u64) {
// Warm manager cache first: it is free, it is already merged, and it
// carries the description precisely so this lookup does not need a fetch.
if let Some((name, description)) = crate::playlist_manager_qt::cached_playlist_seed(pid) {
seat(
id,
Seed {
name,
description: description.unwrap_or_default(),
desc_loaded: true,
is_local: false,
offline_only: false,
},
);
// The folder is one SQLite read; keep it off the UI thread.
crate::spawn(async move {
let lookup = id.clone();
let folder_id = tokio::task::spawn_blocking(move || folder_of_blocking(&lookup))
.await
.unwrap_or_default();
seat(
id,
Seed {
name,
description: description.unwrap_or_default(),
desc_loaded: true,
is_local: false,
offline_only: false,
folder_id,
},
);
});
return;
}
let runtime = crate::app();
crate::spawn(async move {
let lookup = id.clone();
let folder_id = tokio::task::spawn_blocking(move || folder_of_blocking(&lookup))
.await
.unwrap_or_default();
let seed = match runtime.core().get_playlist(pid).await {
Ok(p) => Seed {
name: p.name,
@@ -254,6 +410,7 @@ fn open_qobuz(id: String, pid: u64) {
desc_loaded: true,
is_local: false,
offline_only: false,
folder_id: folder_id.clone(),
},
Err(e) => {
// Offline, or the playlist is gone. The editor still opens —
@@ -268,6 +425,7 @@ fn open_qobuz(id: String, pid: u64) {
desc_loaded: false,
is_local: false,
offline_only: false,
folder_id,
}
}
};
@@ -288,6 +446,7 @@ fn seat(id: String, seed: Seed) {
is_local: seed.is_local,
offline_only: seed.offline_only,
busy: false,
folder_id: seed.folder_id,
};
}
publish();
@@ -421,28 +580,7 @@ pub(crate) fn delete_playlist() {
publish();
crate::spawn(async move {
let ok = if is_local {
let target = id.clone();
tokio::task::spawn_blocking(move || crate::local_playlist_qt::delete_blocking(&target))
.await
.unwrap_or(false)
} else {
match id.parse::<u64>() {
Ok(pid) => {
let runtime = crate::app();
// The ownership re-derivation and the unsubscribe branch
// live in there (§5.1); it NEVER navigates.
match crate::playlist_qt::delete_by_id(&runtime, pid).await {
Ok(()) => true,
Err(e) => {
log::error!("[qbz-qt] {e}");
false
}
}
}
Err(_) => false,
}
};
let ok = delete_ref(&id, is_local).await;
if ok {
// CONDITIONAL (§5.1): only when the user is actually standing on
@@ -529,10 +667,12 @@ mod tests {
is_local: true,
offline_only: true,
busy: false,
folder_id: "folder-1".into(),
});
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["id"], serde_json::Value::String("local:abc-def".into()));
assert_eq!(v["isLocal"], serde_json::Value::Bool(true));
assert_eq!(v["folderId"], serde_json::Value::String("folder-1".into()));
assert_eq!(v["offlineOnly"], serde_json::Value::Bool(true));
assert_eq!(v["descLoaded"], serde_json::Value::Bool(true));
assert!(v.get("is_local").is_none());
+15
View File
@@ -132,6 +132,10 @@ pub struct SidebarEntry {
skip_serializing_if = "std::ops::Not::not"
)]
pub is_local: bool,
/// The signed-in user owns it (a local playlist always does): the row
/// menu offers Delete only then (2026-09-13).
#[serde(default, rename = "isOwner")]
pub is_owner: bool,
}
/// One LOCAL playlist listed in the sidebar (library.db, ids `local:<uuid>`).
@@ -155,6 +159,8 @@ struct SidebarPlaylist {
tracks_count: u32,
cover_urls: Vec<String>,
position: i32,
/// `owner.id == the signed-in user` at load; false when unknown.
is_owner: bool,
}
#[derive(Default, Clone)]
@@ -292,6 +298,7 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
out
};
SidebarPlaylist {
is_owner: crate::playlist_qt::current_user_id().is_some_and(|uid| uid == p.owner.id),
id: p.id,
name: p.name,
tracks_count: p.tracks_count,
@@ -357,6 +364,8 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
tracks_count,
cover_urls: Vec::new(),
position: 0,
// An offline header row: ownership unknown, no Delete offered.
is_owner: false,
});
}
}
@@ -508,6 +517,7 @@ pub fn rebuild() -> Vec<SidebarEntry> {
|p: &SidebarPlaylist| !searching || p.name.to_lowercase().contains(query.as_str());
let entry_for = |p: &SidebarPlaylist, indent: bool, folder_id: &str| SidebarEntry {
is_owner: p.is_owner,
kind: "playlist".into(),
id: p.id.to_string(),
name: p.name.clone(),
@@ -519,6 +529,7 @@ pub fn rebuild() -> Vec<SidebarEntry> {
is_local: false,
};
let local_entry_for = |p: &SidebarLocal, indent: bool, folder_id: &str| SidebarEntry {
is_owner: true,
kind: "playlist".into(),
id: p.id.clone(),
name: p.name.clone(),
@@ -570,6 +581,7 @@ pub fn rebuild() -> Vec<SidebarEntry> {
// When searching, force-expand so matches inside are visible.
let is_exp = searching || expanded.contains(fid);
entries.push(SidebarEntry {
is_owner: false,
kind: "folder".into(),
id: fid.clone(),
name: fname.clone(),
@@ -777,6 +789,8 @@ pub fn insert_qobuz_entry(id: u64, name: &str, tracks_count: u32, covers: &[Stri
tracks_count,
cover_urls: covers.iter().take(4).cloned().collect(),
position: 0,
// Just created by this user.
is_owner: true,
},
);
data.recent_qobuz.insert(id, std::time::Instant::now());
@@ -951,6 +965,7 @@ mod creation_regressions {
let now = std::time::Instant::now();
let mut previous = SidebarData::default();
previous.playlists.push(SidebarPlaylist {
is_owner: true,
id: 42,
name: "Created".into(),
tracks_count: 0,