diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml index aaf431c78..08fb4ff12 100644 --- a/crates/qbz-qt/qml/shell/QueuePanel.qml +++ b/crates/qbz-qt/qml/shell/QueuePanel.qml @@ -596,18 +596,11 @@ Rectangle { if (root.rowCanGoArtist(row)) items.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" }) if (row.isEphemeral !== true) { - // LOCAL/Plex rows drop it too: the queue's - // `add_to_playlist` refuses them - // (queue_qt.rs — a queue row's id is a - // library.db / Plex synthetic id and this - // panel has no source-aware resolver to - // turn it into a local-mode ref), so the - // entry would render and no-op. Absent - // beats dead; the LOCAL surfaces that DO - // have the resolver offer it instead - // (local/LocalTrackRow.qml). - if (row.isLocal !== true) - items.push({ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-plus", "action": "add-to-playlist" }) + // Local/Plex/media rows too (2026-09-14): + // the queue resolves them to picker refs + // by source (local_playlist_qt:: + // local_picker_ref_for_queue_track). + items.push({ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-plus", "action": "add-to-playlist" }) items.push({ "label": QbzSession.tr("Track info", QbzSession.trRev), "icon": "info", "action": "track-info" }) items.push({ "label": root.favState(row) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.favState(row) ? "heart-filled" : "heart", "action": "favorite" }) } diff --git a/crates/qbz-qt/qml/views/QueueView.qml b/crates/qbz-qt/qml/views/QueueView.qml index 03d5d59cb..f45e674a4 100644 --- a/crates/qbz-qt/qml/views/QueueView.qml +++ b/crates/qbz-qt/qml/views/QueueView.qml @@ -319,9 +319,10 @@ Rectangle { items.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist", "external": true }) if (row.isEphemeral !== true) { - if (row.isLocal !== true) - items.push({ "label": t("Add to playlist", r), "icon": "list-plus", - "action": "add-to-playlist", "external": true }) + // Local rows too (2026-09-14): the queue resolves them to picker + // refs by source (queue_qt::picker_payload_for). + items.push({ "label": t("Add to playlist", r), "icon": "list-plus", + "action": "add-to-playlist", "external": true }) items.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" }) if (row.isLocal !== true) items.push({ "label": row.isFavorite === true diff --git a/crates/qbz-qt/src/local_playlist_qt.rs b/crates/qbz-qt/src/local_playlist_qt.rs index fb6fdc81c..0ed121c29 100644 --- a/crates/qbz-qt/src/local_playlist_qt.rs +++ b/crates/qbz-qt/src/local_playlist_qt.rs @@ -711,30 +711,30 @@ pub fn set_open_mixed_snapshot( pub fn local_picker_ref_for_row(id: &str) -> Option { let queue = CURRENT_QUEUE.lock().ok()?; let q = queue.iter().find(|q| q.id.to_string() == id)?; + local_picker_ref_for_queue_track(q) +} + +/// The picker ref for a QUEUE row, source-aware like +/// `local_picker_ref_for_track`: Plex and the media servers ride the key +/// their row keeps in the hint slot (the rating key / the server item id — +/// `local_playback::local_queue_track`, `media_servers_qt::cached_to_local_track`), +/// an offline copy its library row id (ALSO in the hint: its queue id is the +/// catalog id when the copy has one, which is what `local_row_input` needs to +/// re-derive the right input kind), a local file its row id. `None` = a +/// local row this port cannot file (a media row that lost its key). The +/// queue's "save as playlist" and per-row "Add to playlist" build their local +/// refs here — never by hand (the module header's rule). +pub fn local_picker_ref_for_queue_track(q: &QueueTrack) -> Option { match q.source.as_deref() { Some("plex") => q .source_item_id_hint .as_ref() .map(|key| format!("plex:{key}")), - // Remote rows carry the server item id in the hint slot, same as - // Plex carries the rating key (media_servers_qt::cached_to_local_track). Some(source @ ("jellyfin" | "subsonic")) => q .source_item_id_hint .as_ref() .map(|item| format!("{source}:{item}")), Some("local") => Some(q.id.to_string()), - // An OFFLINE COPY row (`local_playback::local_queue_track` tags these - // "qobuz_download"). It reaches this function because `row_to_display` - // publishes every non-Plex `RowItem::Local` with `source: "local"`, so - // TrackRow.qml offers the entry — returning `None` here would render it - // and no-op, which is the defect class this round is closing. - // - // Its `q.id` is NOT a library row id: `local_queue_track` sets the - // queue id to `qobuz_track_id` when the row has one. The library row id - // is carried in `source_item_id_hint` for exactly this reason, and it - // is what `local_row_input` needs to re-derive the right input kind - // (`Qobuz(qid)` when the copy has a catalog id, the file path when it - // does not). Some("qobuz_download") => q.source_item_id_hint.clone(), _ => None, } @@ -876,11 +876,9 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option (PlaylistTrackRow, Option"` Plex rows. LocalRefs(Vec), + /// Both at once — the QUEUE saved as a playlist while it holds catalog + /// and local rows (queue_qt::save_as_playlist). Routed as the two + /// halves would be on their own; the confusion the enum exists to make + /// unrepresentable stays so, each half in its own typed slot. + Mixed { qobuz: Vec, local: Vec }, } impl Default for Payload { @@ -89,6 +94,7 @@ impl Payload { match self { Self::Qobuz(v) => v.len(), Self::LocalRefs(v) => v.len(), + Self::Mixed { qobuz, local } => qobuz.len() + local.len(), } } @@ -360,6 +366,18 @@ pub fn open_for_local_refs(runtime: &Runtime, refs: Vec) { open_payload(runtime, Payload::LocalRefs(refs)); } +/// Open the picker with catalog ids AND local refs — the queue saved as a +/// playlist. Either half may be empty, in which case this is the matching +/// single-kind open. +pub fn open_for_mixed(runtime: &Runtime, ids: Vec, refs: Vec) { + let qobuz: Vec = ids.iter().filter_map(|s| s.parse::().ok()).collect(); + match (qobuz.is_empty(), refs.is_empty()) { + (true, _) => open_payload(runtime, Payload::LocalRefs(refs)), + (false, true) => open_payload(runtime, Payload::Qobuz(qobuz)), + (false, false) => open_payload(runtime, Payload::Mixed { qobuz, local: refs }), + } +} + fn open_payload(runtime: &Runtime, payload: Payload) { if payload.is_empty() { return; @@ -426,6 +444,14 @@ async fn refresh_containment(epoch: u64) { Payload::LocalRefs(strings) => { crate::local_playlist_qt::membership_refs_blocking(strings) } + Payload::Mixed { qobuz, local } => { + let mut refs: Vec = qobuz + .iter() + .map(|&id| PlaylistTrackRef::Qobuz(id)) + .collect(); + refs.extend(crate::local_playlist_qt::membership_refs_blocking(local)); + refs + } }; // Only the Qobuz snapshot can be incomplete; the sidecar and local // tables are locally authoritative (QBZ is their only writer). @@ -648,6 +674,11 @@ pub fn pick(playlist_id: &str) { Payload::Qobuz(ids) => { crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &ids) } + // Cases 1 + 2 at once: the queue's mixed payload. + Payload::Mixed { qobuz, local } => { + crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &qobuz) + + crate::local_playlist_qt::add_local_refs_blocking(&target, &local) + } }) .await .unwrap_or(0); @@ -663,6 +694,26 @@ pub fn pick(playlist_id: &str) { }; match payload { + // Cases 3 + 4 at once — the queue's MIXED payload: the local refs + // ride the sidecar right away, and the catalog ids take the Qobuz arm + // below (its duplicate check included) through one more pass of this + // function with only them carried. + Payload::Mixed { qobuz, local } => { + let qobuz_count = crate::sidebar_qt::playlist_track_count(pid).unwrap_or(0); + let sidecar_runtime = runtime.clone(); + crate::spawn(async move { + let written = write_sidecar_refs(pid, qobuz_count, local).await; + log::info!( + "[qbz-qt] playlist picker: {written} local row(s) filed on the sidecar of {pid}" + ); + crate::playlist_qt::refresh_after_membership_change(&sidecar_runtime, pid).await; + }); + { + let mut st = STATE.lock().unwrap(); + st.payload = Payload::Qobuz(qobuz); + } + pick(playlist_id); + } // Case 3 — local refs onto a QOBUZ playlist. They attach through the // library.db sidecar tables (the same tables the merged detail // renders), never through the API: a library row id posted to Qobuz @@ -820,6 +871,10 @@ pub fn create_and_add(name: &str) { Payload::Qobuz(ids) => { crate::local_playlist_qt::add_qobuz_tracks_blocking(&new_id, &ids) } + Payload::Mixed { qobuz, local } => { + crate::local_playlist_qt::add_qobuz_tracks_blocking(&new_id, &qobuz) + + crate::local_playlist_qt::add_local_refs_blocking(&new_id, &local) + } } }) .await @@ -887,6 +942,20 @@ pub fn create_and_add(name: &str) { toast_added(written, &name); crate::playlist_qt::refresh_after_membership_change(&runtime, pid).await; } + // The queue's mixed payload: local refs on the sidecar, + // catalog ids through the API, one toast for the ids. + Payload::Mixed { qobuz, local } => { + if !local.is_empty() { + let written = write_sidecar_refs(pid, 0, local).await; + log::info!( + "[qbz-qt] playlist picker: {written} local row(s) filed on the new playlist {pid}" + ); + } + if !qobuz.is_empty() { + add_and_toast(&runtime, pid, qobuz, name).await; + } + crate::playlist_qt::refresh_after_membership_change(&runtime, pid).await; + } _ => {} } close(); @@ -926,6 +995,10 @@ pub async fn add_dropped_payload_to_target(runtime: &Runtime, target_id: &str, p Payload::Qobuz(ids) => { crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &ids) } + Payload::Mixed { qobuz, local } => { + crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &qobuz) + + crate::local_playlist_qt::add_local_refs_blocking(&target, &local) + } } }) .await @@ -935,13 +1008,25 @@ pub async fn add_dropped_payload_to_target(runtime: &Runtime, target_id: &str, p refresh_local_target(runtime, &target).await; } Some(crate::local_playlist_qt::PlaylistRef::Qobuz(pid)) => { - let Payload::LocalRefs(refs) = payload else { - return; + let (ids, refs) = match payload { + Payload::LocalRefs(refs) => (Vec::new(), refs), + Payload::Mixed { qobuz, local } => (qobuz, local), + Payload::Qobuz(_) => return, }; + let had_ids = !ids.is_empty(); let qobuz_count = crate::sidebar_qt::playlist_track_count(pid).unwrap_or(0); - let written = write_sidecar_refs(pid, qobuz_count, refs).await; - set_last_used(&pid.to_string(), written); - toast_added(written, ""); + let written = if refs.is_empty() { + 0 + } else { + write_sidecar_refs(pid, qobuz_count, refs).await + }; + if had_ids { + add_and_toast(runtime, pid, ids, String::new()).await; + } + set_last_used(&pid.to_string(), written.max(1)); + if written > 0 || !had_ids { + toast_added(written, ""); + } crate::playlist_qt::refresh_after_membership_change(runtime, pid).await; } None => {} diff --git a/crates/qbz-qt/src/queue_qt.rs b/crates/qbz-qt/src/queue_qt.rs index 85bf01593..dec430927 100644 --- a/crates/qbz-qt/src/queue_qt.rs +++ b/crates/qbz-qt/src/queue_qt.rs @@ -1493,37 +1493,62 @@ pub async fn panel_opened(runtime: &Arc>) { /// (or a synthetic ephemeral id), and the picker's Qobuz arm would add an /// unrelated catalog track under that number. /// -/// The local-mode ARM now exists (`playlist_picker_qt::open_for_local_refs`), -/// so the remaining blocker is narrower than the old note claimed: what this -/// module lacks is a source-aware row -> ref RESOLVER. A `QueueTrack`'s -/// `source_item_id_hint` is overloaded (`local_playback` documents it as the -/// album id on the non-mixtape enqueue paths), so it is not a safe Plex rating -/// key outside the local queue builder's own rows. PARITY-DEBT, recorded -/// rather than silently mixing the id spaces. +/// Every row the picker can file rides along, by SOURCE: a catalog row its +/// id, a local row (file, Plex, Jellyfin/Subsonic, offline copy) the ref +/// `local_playlist_qt::local_picker_ref_for_queue_track` builds, an +/// ephemeral row nothing. A queue of only local rows opens the picker in +/// local mode (its list is the local playlists, which is the whole list +/// offline); a queue of both kinds carries a MIXED payload the picker +/// routes as the two halves would be on their own. Until 2026-09-14 the +/// local rows were skipped, so an all-local queue opened nothing at all. pub async fn save_as_playlist(runtime: &Arc>) { let state = runtime.core().get_queue_state_full().await; + let (ids, refs, skipped) = + picker_payload_for(state.current_track.iter().chain(state.upcoming.iter())); + if skipped > 0 { + log::info!( + "[qbz-qt] queue save-as-playlist: skipped {skipped} row(s) with nothing to file \ + (ephemeral, or a local row without its key)" + ); + } + if ids.is_empty() && refs.is_empty() { + log::warn!("[qbz-qt] queue save-as-playlist: nothing to file"); + return; + } + crate::playlist_picker_qt::open_for_mixed(runtime, ids, refs); +} + +/// The picker payload for queue rows: catalog ids and local refs, each +/// de-duplicated, plus the count of rows that could not be filed. +fn picker_payload_for<'a>( + rows: impl Iterator, +) -> (Vec, Vec, usize) { let mut ids: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); + let mut refs: Vec = Vec::new(); + let mut seen_ids: HashSet = HashSet::new(); + let mut seen_refs: HashSet = HashSet::new(); let mut skipped = 0usize; - for track in state.current_track.iter().chain(state.upcoming.iter()) { - if track.is_local || crate::local_ephemeral::is_ephemeral_id(track.id as i64) { + for track in rows { + if crate::local_ephemeral::is_ephemeral_id(track.id as i64) { skipped += 1; continue; } - if seen.insert(track.id) { + if track.is_local { + match crate::local_playlist_qt::local_picker_ref_for_queue_track(track) { + Some(r) => { + if seen_refs.insert(r.clone()) { + refs.push(r); + } + } + None => skipped += 1, + } + continue; + } + if seen_ids.insert(track.id) { ids.push(track.id.to_string()); } } - if skipped > 0 { - log::info!( - "[qbz-qt] queue save-as-playlist: skipped {skipped} local/ephemeral row(s) — \ - no local-mode picker in this port" - ); - } - if ids.is_empty() { - return; - } - crate::playlist_picker_qt::open_for_ids(runtime, ids); + (ids, refs, skipped) } /// Open the picker seeded with ONE upcoming row (page-local `page_index`) — @@ -1541,17 +1566,22 @@ pub async fn add_to_playlist(runtime: &Arc>, page_ind log::warn!("[qbz-qt] queue: add_to_playlist {page_index} -> no upcoming track"); return; }; - if track.is_local || crate::local_ephemeral::is_ephemeral_id(track.id as i64) { - // Defensive: QueuePanel.qml drops the entry for `isLocal` and - // `isEphemeral` rows, so this is only reachable from a stale page - // index. Not "no local-mode picker" (that arm exists) — no - // source-aware row -> ref resolver here; see `save_as_playlist`. + if crate::local_ephemeral::is_ephemeral_id(track.id as i64) { log::warn!( - "[qbz-qt] queue: add_to_playlist {page_index} is a local/ephemeral row — \ - no source-aware ref resolver on the queue" + "[qbz-qt] queue: add_to_playlist {page_index} is an ephemeral row — nothing to file" ); return; } + if track.is_local { + // By source, the way the local surfaces do it (2026-09-14). + match crate::local_playlist_qt::local_picker_ref_for_queue_track(track) { + Some(r) => crate::playlist_picker_qt::open_for_local_refs(runtime, vec![r]), + None => log::warn!( + "[qbz-qt] queue: add_to_playlist {page_index} is a local row without a picker key" + ), + } + return; + } crate::playlist_picker_qt::open_for_ids(runtime, vec![track.id.to_string()]); } @@ -1624,6 +1654,49 @@ mod tests { } } + #[test] + fn save_as_playlist_files_every_source_it_can() { + let mut local = track(41, "file"); + local.is_local = true; + local.source = Some("local".into()); + let mut plex = track(4200, "plex"); + plex.is_local = true; + plex.source = Some("plex".into()); + plex.source_item_id_hint = Some("12345".into()); + let mut jelly = track(77, "jf"); + jelly.is_local = true; + jelly.source = Some("jellyfin".into()); + jelly.source_item_id_hint = Some("abc".into()); + let mut copy = track(900, "copy"); + copy.is_local = true; + copy.source = Some("qobuz_download".into()); + copy.source_item_id_hint = Some("58".into()); + let mut keyless = track(78, "lost"); + keyless.is_local = true; + keyless.source = Some("subsonic".into()); + let rows = [ + track(555, "q"), + local, + plex, + jelly, + copy, + keyless, + track(555, "q again"), + ]; + let (ids, refs, skipped) = picker_payload_for(rows.iter()); + assert_eq!(ids, vec!["555".to_string()]); + assert_eq!( + refs, + vec![ + "41".to_string(), + "plex:12345".to_string(), + "jellyfin:abc".to_string(), + "58".to_string() + ] + ); + assert_eq!(skipped, 1); + } + /// §4.4 JSON shape: {index, tracks:[{id,title,artist,artUrl}]} over the /// FULL flat queue, history OLDEST-first. #[test]