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:
1 parent
47b5c14d32
commit
1c45c7f2b9
5 files changed
+218
-68
No files matched your search
@@ -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" })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -711,30 +711,30 @@ pub fn set_open_mixed_snapshot(
|
||||
pub fn local_picker_ref_for_row(id: &str) -> Option<String> {
|
||||
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<String> {
|
||||
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<QueueT
|
||||
// opaque token `cached_path` could not classify — which is why
|
||||
// media rows were art-less in the TrackRow while the queue and NPB,
|
||||
// going through this same ref, showed the cover fine.
|
||||
let art_ref = crate::local_rows::portable_artwork_ref(
|
||||
t,
|
||||
crate::local_rows::ArtworkScope::Track,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let art_ref =
|
||||
crate::local_rows::portable_artwork_ref(t, crate::local_rows::ArtworkScope::Track)
|
||||
.unwrap_or_default();
|
||||
let row = PlaylistTrackRow {
|
||||
position: 0,
|
||||
featured: Vec::new(),
|
||||
@@ -934,7 +932,7 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueT
|
||||
.unwrap_or_else(|| path.clone());
|
||||
(
|
||||
PlaylistTrackRow {
|
||||
position: 0,
|
||||
position: 0,
|
||||
featured: Vec::new(),
|
||||
id: path.clone(),
|
||||
title: name,
|
||||
|
||||
@@ -76,6 +76,11 @@ pub enum Payload {
|
||||
/// source-aware at insert time to a local path / an offline-copy Qobuz id)
|
||||
/// or `"plex:<rating key>"` Plex rows.
|
||||
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 {
|
||||
@@ -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<String>) {
|
||||
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) {
|
||||
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<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
|
||||
// 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 => {}
|
||||
|
||||
+101
-28
@@ -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
|
||||
/// 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<AppRuntime<LoggingAdapter>>) {
|
||||
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 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;
|
||||
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<AppRuntime<LoggingAdapter>>, 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]
|
||||
|
||||
Reference in new issue
Block a user