feat(qbz-library): network-folder filter on folder-tree primitives

list_folder_children, list_folder_tracks, list_folder_tracks_recursive
now apply the same network-folder exclusion that flat-mode search
honors, so the tree rail and recursive playback see the same source
of truth. Tests cover the on/off cases.

cc
This commit is contained in:
vicrodh committed 2026-05-08 16:58:59 -06:00
1 parent 632accbe40
commit 1d68f7516e
1 file changed
+153 -13
+153 -13
View File
@@ -1447,9 +1447,24 @@ impl LibraryDatabase {
pub fn list_folder_children(
&self,
parent_path: &str,
exclude_network_folders: bool,
) -> Result<Vec<FolderTreeEntry>, LibraryError> {
let escaped_prefix = escape_like_pattern(parent_path);
// Network folder filter: exclude tracks whose file_path starts
// with any registered network-mount folder path. Mirrors the
// mechanism used by `get_albums_with_full_filter` so tree rail
// visibility matches flat-mode + recursive playback.
let network_filter = if exclude_network_folders {
"AND NOT EXISTS ( \
SELECT 1 FROM library_folders nf \
WHERE nf.is_network = 1 \
AND local_tracks.file_path LIKE nf.path || '%' \
)"
} else {
""
};
// SQL strategy (CTE form for readability; SQLite uses
// idx_tracks_file_path on the LIKE prefix in the candidates step):
// suffix = file_path with the parent prefix + '/' stripped
@@ -1459,7 +1474,8 @@ impl LibraryDatabase {
// descendant tracks; track rows are 1:1 with their file. Include
// MIN(file_path) so we can recover the absolute path for tracks
// (folders ignore it and reconstruct path from parent + segment).
let sql = "WITH candidates AS ( \
let sql = format!(
"WITH candidates AS ( \
SELECT \
substr(file_path, length(?1) + 2) AS suffix, \
file_path, \
@@ -1467,6 +1483,7 @@ impl LibraryDatabase {
FROM local_tracks \
WHERE file_path LIKE ?2 || '/%' ESCAPE '\\' \
AND COALESCE(source, 'user') = 'user' \
{network_filter} \
), \
classified AS ( \
SELECT \
@@ -1488,11 +1505,13 @@ impl LibraryDatabase {
MAX(artwork_path) AS artwork, \
MIN(file_path) AS one_file_path \
FROM classified \
GROUP BY child_segment, kind";
GROUP BY child_segment, kind",
network_filter = network_filter,
);
let mut stmt = self
.conn
.prepare(sql)
.prepare(&sql)
.map_err(|e| LibraryError::Database(e.to_string()))?;
// ?1 bound with the unescaped path (used in length() arithmetic
@@ -1568,18 +1587,34 @@ impl LibraryDatabase {
pub fn list_folder_tracks(
&self,
folder_path: &str,
exclude_network_folders: bool,
) -> Result<Vec<LocalTrack>, LibraryError> {
let escaped_prefix = escape_like_pattern(folder_path);
// See `list_folder_children` for the rationale on the network
// filter — same EXISTS subquery so the tree rail and direct-
// children listing reflect the same visible-track set.
let network_filter = if exclude_network_folders {
"AND NOT EXISTS ( \
SELECT 1 FROM library_folders nf \
WHERE nf.is_network = 1 \
AND local_tracks.file_path LIKE nf.path || '%' \
)"
} else {
""
};
let sql = format!(
"SELECT {cols} FROM local_tracks \
WHERE file_path LIKE ?1 || '/%' ESCAPE '\\' \
AND substr(file_path, length(?2) + 2) NOT LIKE '%/%' \
AND COALESCE(source, 'user') = 'user' \
{network_filter} \
ORDER BY disc_number ASC NULLS LAST, \
track_number ASC NULLS LAST, \
title COLLATE NOCASE ASC",
cols = Self::TRACK_COLUMNS,
network_filter = network_filter,
);
let mut stmt = self
@@ -1621,15 +1656,32 @@ impl LibraryDatabase {
pub fn list_folder_tracks_recursive(
&self,
folder_path: &str,
exclude_network_folders: bool,
) -> Result<Vec<LocalTrack>, LibraryError> {
let escaped_prefix = escape_like_pattern(folder_path);
// Network filter mirrors the flat-mode `v2_library_search` /
// `get_albums_with_full_filter` predicate so the recursive
// multi-select boundary matches what the tree rail and the
// playback path see.
let network_filter = if exclude_network_folders {
"AND NOT EXISTS ( \
SELECT 1 FROM library_folders nf \
WHERE nf.is_network = 1 \
AND local_tracks.file_path LIKE nf.path || '%' \
)"
} else {
""
};
let sql = format!(
"SELECT {cols} FROM local_tracks \
WHERE file_path LIKE ?1 || '/%' ESCAPE '\\' \
AND COALESCE(source, 'user') = 'user' \
{network_filter} \
ORDER BY file_path ASC",
cols = Self::TRACK_COLUMNS,
network_filter = network_filter,
);
let mut stmt = self
@@ -4929,7 +4981,7 @@ mod folder_tree_tests {
// /m/A/album1 has: subfolder "Disc 1", tracks "t1.flac" + "t2.flac".
// Expected order: folder first, then tracks alphabetical.
let children = db.list_folder_children("/m/A/album1").unwrap();
let children = db.list_folder_children("/m/A/album1", false).unwrap();
assert_eq!(children.len(), 3, "one folder + two tracks expected");
match &children[0] {
@@ -4965,7 +5017,7 @@ mod folder_tree_tests {
let (_tmp, db) = fresh_db();
seed_standard_fixture(&db);
let children = db.list_folder_children("/m/A/album1").unwrap();
let children = db.list_folder_children("/m/A/album1", false).unwrap();
// qcache.flac (qobuz_download) must not appear, even though it
// shares the same parent folder as t1.flac/t2.flac.
for entry in &children {
@@ -5010,7 +5062,7 @@ mod folder_tree_tests {
"Two Hundred",
);
let children = db.list_folder_children("/m/percent_test").unwrap();
let children = db.list_folder_children("/m/percent_test", false).unwrap();
assert_eq!(children.len(), 1, "only the local 100% file matches");
match &children[0] {
FolderTreeEntry::Track { segment, path } => {
@@ -5025,7 +5077,7 @@ mod folder_tree_tests {
// any single character. If escape_like_pattern is missing, a
// sibling like /m/percentXtest/foo.flac would also match.
insert_at(&db, "/m/percentXtest/decoy.flac", Some(1), Some(1), "Decoy");
let children = db.list_folder_children("/m/percent_test").unwrap();
let children = db.list_folder_children("/m/percent_test", false).unwrap();
assert_eq!(
children.len(),
1,
@@ -5041,7 +5093,7 @@ mod folder_tree_tests {
// /m/A/album1 has direct tracks t1.flac + t2.flac, plus one
// track in a subfolder (Disc 1/t3.flac). The latter must NOT
// appear in list_folder_tracks output.
let tracks = db.list_folder_tracks("/m/A/album1").unwrap();
let tracks = db.list_folder_tracks("/m/A/album1", false).unwrap();
assert_eq!(tracks.len(), 2, "subfolder tracks must be excluded");
let titles: Vec<_> = tracks.iter().map(|t| t.title.as_str()).collect();
assert!(titles.contains(&"Alpha"));
@@ -5074,7 +5126,7 @@ mod folder_tree_tests {
"ant", // lowercase — NOCASE collation should sort ant < Bee
);
let tracks = db.list_folder_tracks("/m/order").unwrap();
let tracks = db.list_folder_tracks("/m/order", false).unwrap();
let titles: Vec<_> = tracks.iter().map(|track| track.title.clone()).collect();
assert_eq!(titles, vec!["ant", "Bee", "D1T2", "D2T1"]);
}
@@ -5087,7 +5139,7 @@ mod folder_tree_tests {
// /m/A/album1 has direct tracks t1.flac, t2.flac AND a deeper
// file at /m/A/album1/Disc 1/t3.flac. The recursive listing
// must return all three.
let tracks = db.list_folder_tracks_recursive("/m/A/album1").unwrap();
let tracks = db.list_folder_tracks_recursive("/m/A/album1", false).unwrap();
let titles: Vec<_> = tracks.iter().map(|track| track.title.clone()).collect();
assert_eq!(tracks.len(), 3, "recursive listing must include subfolder tracks");
assert!(titles.contains(&"Alpha".to_string()));
@@ -5110,7 +5162,7 @@ mod folder_tree_tests {
insert_at(&db, "/m/r/alpha.flac", Some(1), Some(1), "A");
insert_at(&db, "/m/r/sub/middle.flac", Some(1), Some(1), "M");
let tracks = db.list_folder_tracks_recursive("/m/r").unwrap();
let tracks = db.list_folder_tracks_recursive("/m/r", false).unwrap();
let paths: Vec<_> = tracks.iter().map(|track| track.file_path.clone()).collect();
assert_eq!(
paths,
@@ -5132,7 +5184,7 @@ mod folder_tree_tests {
insert_at(&db, "/m/percent_test/inner/200.flac", Some(1), Some(1), "TwoHundred");
insert_at(&db, "/m/percentXtest/decoy.flac", Some(1), Some(1), "Decoy");
let tracks = db.list_folder_tracks_recursive("/m/percent_test").unwrap();
let tracks = db.list_folder_tracks_recursive("/m/percent_test", false).unwrap();
let titles: Vec<_> = tracks.iter().map(|track| track.title.clone()).collect();
assert_eq!(tracks.len(), 2, "underscore in parent path must be escaped");
assert!(titles.contains(&"Hundred".to_string()));
@@ -5146,7 +5198,95 @@ mod folder_tree_tests {
// Vec rather than an error — frontend treats empty as "nothing
// to play/queue" and skips the toast.
let (_tmp, db) = fresh_db();
let tracks = db.list_folder_tracks_recursive("/m/does/not/exist").unwrap();
let tracks = db.list_folder_tracks_recursive("/m/does/not/exist", false).unwrap();
assert!(tracks.is_empty());
}
/// Mirrors the offline / "exclude network folders" toggle: tracks
/// living under a `library_folders` row marked `is_network = 1`
/// must be filtered out of every tree-mode listing primitive when
/// `exclude_network_folders = true`, and present when `false`.
/// Matches the predicate used by `get_albums_with_full_filter`
/// and `v2_library_search` so flat mode and tree mode see the same
/// source of truth.
#[test]
fn list_folder_primitives_honor_network_exclude() {
let (_tmp, db) = fresh_db();
// Register two scan roots: one local, one flagged as network.
db.add_folder_with_network_info("/m/local", false, None)
.unwrap();
db.add_folder_with_network_info("/m/net", true, Some("nfs"))
.unwrap();
// Seed user tracks under each root. The folder structure is
// similar enough that the only thing distinguishing them is the
// network-mount flag on the parent library_folders row.
insert_at(&db, "/m/local/album/local1.flac", Some(1), Some(1), "L1");
insert_at(&db, "/m/local/album/local2.flac", Some(1), Some(2), "L2");
insert_at(&db, "/m/net/album/net1.flac", Some(1), Some(1), "N1");
insert_at(&db, "/m/net/album/sub/net2.flac", Some(1), Some(1), "N2");
// --- list_folder_children -----------------------------------
// Without filter: both roots appear under '/m'.
let all_children = db.list_folder_children("/m", false).unwrap();
let segments: Vec<_> = all_children
.iter()
.filter_map(|e| match e {
FolderTreeEntry::Folder { segment, .. } => Some(segment.as_str()),
_ => None,
})
.collect();
assert!(segments.contains(&"local"));
assert!(segments.contains(&"net"));
// With filter: the network root collapses out (no descendant
// tracks survive the EXISTS predicate, so it stops aggregating).
let filtered = db.list_folder_children("/m", true).unwrap();
let segments: Vec<_> = filtered
.iter()
.filter_map(|e| match e {
FolderTreeEntry::Folder { segment, .. } => Some(segment.as_str()),
_ => None,
})
.collect();
assert!(segments.contains(&"local"));
assert!(
!segments.contains(&"net"),
"network folder leaked into tree rail when exclude=true"
);
// --- list_folder_tracks (direct children) ------------------
let direct_all = db.list_folder_tracks("/m/net/album", false).unwrap();
assert_eq!(direct_all.len(), 1, "net1.flac must appear when exclude=false");
let direct_filtered = db.list_folder_tracks("/m/net/album", true).unwrap();
assert!(
direct_filtered.is_empty(),
"network track leaked into direct-children listing when exclude=true"
);
// Local folder is unaffected by the toggle.
let local_filtered = db.list_folder_tracks("/m/local/album", true).unwrap();
assert_eq!(local_filtered.len(), 2);
// --- list_folder_tracks_recursive --------------------------
let recursive_all = db.list_folder_tracks_recursive("/m/net", false).unwrap();
assert_eq!(recursive_all.len(), 2, "both net tracks visible when exclude=false");
let recursive_filtered = db
.list_folder_tracks_recursive("/m/net", true)
.unwrap();
assert!(
recursive_filtered.is_empty(),
"network tracks leaked into recursive listing when exclude=true"
);
// Recursive listing on a non-network root still returns its
// tracks even when exclude=true.
let recursive_local = db
.list_folder_tracks_recursive("/m/local", true)
.unwrap();
assert_eq!(recursive_local.len(), 2);
}
}