fix(qt): saving the queue as a playlist works with local rows, and offline

queue_qt::save_as_playlist skipped every local and ephemeral row and,
with no catalog ids left, returned without a word — a queue of Local
Library tracks opened nothing from either the queue panel or the Listen
list, online or offline. The module's own note said the picker's local
mode existed and only the row-to-ref resolver was missing.

local_playlist_qt::local_picker_ref_for_queue_track now builds that ref
from a QueueTrack by source (a file its row id, Plex its rating key,
Jellyfin/Subsonic the server item id, an offline copy its row id from
the hint slot; an ephemeral row nothing), and the queue carries ids and
refs together: only local rows open the picker in local mode, a mix
carries the new Payload::Mixed, which the picker routes as the two halves
would be on their own — both writes onto a local playlist, the sidecar
plus the API (duplicate check included) onto a Qobuz one, a local
playlist on create while offline. The per-row "Add to playlist" entry
shows for local rows on both queue surfaces too.

cc
This commit is contained in:
vicrodh committed 2026-09-14 01:54:57 -06:00
1 parent 47b5c14d32
commit 1c45c7f2b9
5 files changed
+218 -68

No files matched your search

+5 -12
View File
@@ -596,18 +596,11 @@ Rectangle {
if (root.rowCanGoArtist(row)) if (root.rowCanGoArtist(row))
items.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" }) items.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
if (row.isEphemeral !== true) { if (row.isEphemeral !== true) {
// LOCAL/Plex rows drop it too: the queue's // Local/Plex/media rows too (2026-09-14):
// `add_to_playlist` refuses them // the queue resolves them to picker refs
// (queue_qt.rs — a queue row's id is a // by source (local_playlist_qt::
// library.db / Plex synthetic id and this // local_picker_ref_for_queue_track).
// panel has no source-aware resolver to items.push({ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-plus", "action": "add-to-playlist" })
// 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" })
items.push({ "label": QbzSession.tr("Track info", QbzSession.trRev), "icon": "info", "action": "track-info" }) 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" }) 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" })
} }
+4 -3
View File
@@ -319,9 +319,10 @@ Rectangle {
items.push({ "label": t("Go to artist", r), "icon": "user", items.push({ "label": t("Go to artist", r), "icon": "user",
"action": "go-artist", "external": true }) "action": "go-artist", "external": true })
if (row.isEphemeral !== true) { if (row.isEphemeral !== true) {
if (row.isLocal !== true) // Local rows too (2026-09-14): the queue resolves them to picker
items.push({ "label": t("Add to playlist", r), "icon": "list-plus", // refs by source (queue_qt::picker_payload_for).
"action": "add-to-playlist", "external": true }) 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" }) items.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" })
if (row.isLocal !== true) if (row.isLocal !== true)
items.push({ "label": row.isFavorite === true items.push({ "label": row.isFavorite === true
+18 -20
View File
@@ -711,30 +711,30 @@ pub fn set_open_mixed_snapshot(
pub fn local_picker_ref_for_row(id: &str) -> Option<String> { pub fn local_picker_ref_for_row(id: &str) -> Option<String> {
let queue = CURRENT_QUEUE.lock().ok()?; let queue = CURRENT_QUEUE.lock().ok()?;
let q = queue.iter().find(|q| q.id.to_string() == id)?; 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<String> {
match q.source.as_deref() { match q.source.as_deref() {
Some("plex") => q Some("plex") => q
.source_item_id_hint .source_item_id_hint
.as_ref() .as_ref()
.map(|key| format!("plex:{key}")), .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 Some(source @ ("jellyfin" | "subsonic")) => q
.source_item_id_hint .source_item_id_hint
.as_ref() .as_ref()
.map(|item| format!("{source}:{item}")), .map(|item| format!("{source}:{item}")),
Some("local") => Some(q.id.to_string()), 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(), Some("qobuz_download") => q.source_item_id_hint.clone(),
_ => None, _ => None,
} }
@@ -876,11 +876,9 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueT
// opaque token `cached_path` could not classify — which is why // opaque token `cached_path` could not classify — which is why
// media rows were art-less in the TrackRow while the queue and NPB, // media rows were art-less in the TrackRow while the queue and NPB,
// going through this same ref, showed the cover fine. // going through this same ref, showed the cover fine.
let art_ref = crate::local_rows::portable_artwork_ref( let art_ref =
t, crate::local_rows::portable_artwork_ref(t, crate::local_rows::ArtworkScope::Track)
crate::local_rows::ArtworkScope::Track, .unwrap_or_default();
)
.unwrap_or_default();
let row = PlaylistTrackRow { let row = PlaylistTrackRow {
position: 0, position: 0,
featured: Vec::new(), featured: Vec::new(),
@@ -934,7 +932,7 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueT
.unwrap_or_else(|| path.clone()); .unwrap_or_else(|| path.clone());
( (
PlaylistTrackRow { PlaylistTrackRow {
position: 0, position: 0,
featured: Vec::new(), featured: Vec::new(),
id: path.clone(), id: path.clone(),
title: name, title: name,
+90 -5
View File
@@ -76,6 +76,11 @@ pub enum Payload {
/// source-aware at insert time to a local path / an offline-copy Qobuz id) /// source-aware at insert time to a local path / an offline-copy Qobuz id)
/// or `"plex:<rating key>"` Plex rows. /// or `"plex:<rating key>"` Plex rows.
LocalRefs(Vec<String>), LocalRefs(Vec<String>),
/// 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<u64>, local: Vec<String> },
} }
impl Default for Payload { impl Default for Payload {
@@ -89,6 +94,7 @@ impl Payload {
match self { match self {
Self::Qobuz(v) => v.len(), Self::Qobuz(v) => v.len(),
Self::LocalRefs(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<String>) {
open_payload(runtime, Payload::LocalRefs(refs)); 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<String>, refs: Vec<String>) {
let qobuz: Vec<u64> = ids.iter().filter_map(|s| s.parse::<u64>().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) { fn open_payload(runtime: &Runtime, payload: Payload) {
if payload.is_empty() { if payload.is_empty() {
return; return;
@@ -426,6 +444,14 @@ async fn refresh_containment(epoch: u64) {
Payload::LocalRefs(strings) => { Payload::LocalRefs(strings) => {
crate::local_playlist_qt::membership_refs_blocking(strings) crate::local_playlist_qt::membership_refs_blocking(strings)
} }
Payload::Mixed { qobuz, local } => {
let mut refs: Vec<PlaylistTrackRef> = 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 // Only the Qobuz snapshot can be incomplete; the sidecar and local
// tables are locally authoritative (QBZ is their only writer). // tables are locally authoritative (QBZ is their only writer).
@@ -648,6 +674,11 @@ pub fn pick(playlist_id: &str) {
Payload::Qobuz(ids) => { Payload::Qobuz(ids) => {
crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &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 .await
.unwrap_or(0); .unwrap_or(0);
@@ -663,6 +694,26 @@ pub fn pick(playlist_id: &str) {
}; };
match payload { 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 // Case 3 — local refs onto a QOBUZ playlist. They attach through the
// library.db sidecar tables (the same tables the merged detail // library.db sidecar tables (the same tables the merged detail
// renders), never through the API: a library row id posted to Qobuz // 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) => { Payload::Qobuz(ids) => {
crate::local_playlist_qt::add_qobuz_tracks_blocking(&new_id, &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 .await
@@ -887,6 +942,20 @@ pub fn create_and_add(name: &str) {
toast_added(written, &name); toast_added(written, &name);
crate::playlist_qt::refresh_after_membership_change(&runtime, pid).await; 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(); close();
@@ -926,6 +995,10 @@ pub async fn add_dropped_payload_to_target(runtime: &Runtime, target_id: &str, p
Payload::Qobuz(ids) => { Payload::Qobuz(ids) => {
crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &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 .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; refresh_local_target(runtime, &target).await;
} }
Some(crate::local_playlist_qt::PlaylistRef::Qobuz(pid)) => { Some(crate::local_playlist_qt::PlaylistRef::Qobuz(pid)) => {
let Payload::LocalRefs(refs) = payload else { let (ids, refs) = match payload {
return; 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 qobuz_count = crate::sidebar_qt::playlist_track_count(pid).unwrap_or(0);
let written = write_sidecar_refs(pid, qobuz_count, refs).await; let written = if refs.is_empty() {
set_last_used(&pid.to_string(), written); 0
toast_added(written, ""); } 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; crate::playlist_qt::refresh_after_membership_change(runtime, pid).await;
} }
None => {} None => {}
+101 -28
View File
@@ -1493,37 +1493,62 @@ pub async fn panel_opened(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
/// (or a synthetic ephemeral id), and the picker's Qobuz arm would add an /// (or a synthetic ephemeral id), and the picker's Qobuz arm would add an
/// unrelated catalog track under that number. /// unrelated catalog track under that number.
/// ///
/// The local-mode ARM now exists (`playlist_picker_qt::open_for_local_refs`), /// Every row the picker can file rides along, by SOURCE: a catalog row its
/// so the remaining blocker is narrower than the old note claimed: what this /// id, a local row (file, Plex, Jellyfin/Subsonic, offline copy) the ref
/// module lacks is a source-aware row -> ref RESOLVER. A `QueueTrack`'s /// `local_playlist_qt::local_picker_ref_for_queue_track` builds, an
/// `source_item_id_hint` is overloaded (`local_playback` documents it as the /// ephemeral row nothing. A queue of only local rows opens the picker in
/// album id on the non-mixtape enqueue paths), so it is not a safe Plex rating /// local mode (its list is the local playlists, which is the whole list
/// key outside the local queue builder's own rows. PARITY-DEBT, recorded /// offline); a queue of both kinds carries a MIXED payload the picker
/// rather than silently mixing the id spaces. /// 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<AppRuntime<LoggingAdapter>>) { pub async fn save_as_playlist(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let state = runtime.core().get_queue_state_full().await; 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<Item = &'a QueueTrack>,
) -> (Vec<String>, Vec<String>, usize) {
let mut ids: Vec<String> = Vec::new(); let mut ids: Vec<String> = Vec::new();
let mut seen: HashSet<u64> = HashSet::new(); let mut refs: Vec<String> = Vec::new();
let mut seen_ids: HashSet<u64> = HashSet::new();
let mut seen_refs: HashSet<String> = HashSet::new();
let mut skipped = 0usize; let mut skipped = 0usize;
for track in state.current_track.iter().chain(state.upcoming.iter()) { for track in rows {
if track.is_local || crate::local_ephemeral::is_ephemeral_id(track.id as i64) { if crate::local_ephemeral::is_ephemeral_id(track.id as i64) {
skipped += 1; skipped += 1;
continue; 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()); ids.push(track.id.to_string());
} }
} }
if skipped > 0 { (ids, refs, skipped)
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);
} }
/// Open the picker seeded with ONE upcoming row (page-local `page_index`) — /// Open the picker seeded with ONE upcoming row (page-local `page_index`) —
@@ -1541,17 +1566,22 @@ pub async fn add_to_playlist(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_ind
log::warn!("[qbz-qt] queue: add_to_playlist {page_index} -> no upcoming track"); log::warn!("[qbz-qt] queue: add_to_playlist {page_index} -> no upcoming track");
return; return;
}; };
if track.is_local || crate::local_ephemeral::is_ephemeral_id(track.id as i64) { if 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`.
log::warn!( log::warn!(
"[qbz-qt] queue: add_to_playlist {page_index} is a local/ephemeral row — \ "[qbz-qt] queue: add_to_playlist {page_index} is an ephemeral row — nothing to file"
no source-aware ref resolver on the queue"
); );
return; 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()]); 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 /// §4.4 JSON shape: {index, tracks:[{id,title,artist,artUrl}]} over the
/// FULL flat queue, history OLDEST-first. /// FULL flat queue, history OLDEST-first.
#[test] #[test]