feat(library): detect network-mounted tracks at index time

Adds an is_network_mount flag to local_tracks. Populated by reading
/proc/mounts (falling back to /run/host/proc/mounts for Flatpak
sandboxes) and matching the file path's longest-prefix mount against
a list of network filesystems — nfs/nfs4, cifs/smb/smb3, fuse.sshfs,
fuse.rclone, fuse.gvfs, davfs/webdav, 9p, ceph, glusterfs, afs, afp.

Motivation: the earlier offline-awareness commit used a frontend
heuristic that matched /mnt, /media, /run/media and Windows UNC. That
catches the classic cases but misses the common sandbox case — a user
on Flathub or Snap mounts their NAS via GVFS at /run/user/1000/gvfs/…
or bind-mounts it into /home/user/music, and neither of those
triggers the frontend check. The backend sees the mount type
directly, so flagging the row at index time gives the UI ground truth.

Changes:
  * new crates/qbz-library/src/mount_info.rs — is_network_path(&Path)
    helper with /proc/mounts parsing, longest-prefix mount matching,
    and a NETWORK_FS_PREFIXES list. Guarded with cfg(target_os =
    "linux"); stubs return false on macOS / Windows (frontend keeps
    its string-match heuristic there).
  * LocalTrack gains is_network_mount: bool (serialized camelCase).
  * local_tracks table: new is_network_mount INTEGER NOT NULL DEFAULT
    0 column, both in the initial CREATE TABLE and a migration for
    existing DBs.
  * insert_track calls mount_info::is_network_path(&track.file_path)
    and writes the flag. Qobuz cached-download inserts rely on the
    column default (always 0) since those land inside the app's own
    data dir.
  * TRACK_COLUMNS + row_to_track pick up the new column. The two
    inline LocalTrack builders in get_playlist_local_tracks and
    get_playlist_local_tracks_with_position are updated the same way,
    plus the playlist_position index shifts by one.

cc
This commit is contained in:
vicrodh committed 2026-04-21 13:27:31 -06:00
1 parent 54a7179627
commit 1e13447921
6 files changed
+227 -7

No files matched your search

+1
View File
@@ -272,6 +272,7 @@ pub fn cue_to_tracks(
indexed_at: now,
source: None,
qobuz_track_id: None,
is_network_mount: false,
});
}
+47 -7
View File
@@ -88,6 +88,7 @@ impl LibraryDatabase {
indexed_at INTEGER NOT NULL,
album_group_key TEXT,
album_group_title TEXT,
is_network_mount INTEGER NOT NULL DEFAULT 0,
UNIQUE(file_path, cue_start_secs)
);
@@ -656,6 +657,27 @@ impl LibraryDatabase {
log::info!("Migration completed: sample_rate is now REAL");
}
// Migration: Add is_network_mount flag to local_tracks. Default
// 0; callers can re-scan folders to populate real values for
// existing rows.
let has_network_mount: bool = self.conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('local_tracks') WHERE name = 'is_network_mount'",
[],
|row| row.get::<_, i32>(0),
)
.map(|count| count > 0)
.unwrap_or(false);
if !has_network_mount {
log::info!("Running migration: adding is_network_mount to local_tracks");
self.conn
.execute_batch(
"ALTER TABLE local_tracks ADD COLUMN is_network_mount INTEGER NOT NULL DEFAULT 0;",
)
.map_err(|e| LibraryError::Database(format!("Migration failed: {}", e)))?;
}
// Migration: Add canonical_name column to artist_images for artist name normalization
let has_canonical_name: bool = self.conn
.query_row(
@@ -1049,6 +1071,15 @@ impl LibraryDatabase {
"user"
};
// Detect whether the audio file sits on a network-backed
// filesystem. Done per-insert instead of per-scan-start because
// mount topology can change between folder scans; the cost is
// negligible (one /proc/mounts read, cached by the kernel
// page cache).
let is_network_mount = crate::mount_info::is_network_path(
std::path::Path::new(&track.file_path),
);
self.conn
.execute(
r#"INSERT OR REPLACE INTO local_tracks
@@ -1056,8 +1087,8 @@ impl LibraryDatabase {
disc_number, year, genre, catalog_number, duration_secs, format, bit_depth,
sample_rate, channels, file_size_bytes, cue_file_path,
cue_start_secs, cue_end_secs, artwork_path, last_modified, indexed_at,
album_group_key, album_group_title, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
album_group_key, album_group_title, source, is_network_mount)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
params![
track.file_path,
track.title,
@@ -1083,7 +1114,8 @@ impl LibraryDatabase {
track.indexed_at,
track.album_group_key,
track.album_group_title,
source
source,
is_network_mount as i64,
],
)
.map_err(|e| LibraryError::Database(e.to_string()))?;
@@ -1816,7 +1848,7 @@ impl LibraryDatabase {
bit_depth, sample_rate, channels, file_size_bytes, \
cue_file_path, cue_start_secs, cue_end_secs, artwork_path, \
last_modified, indexed_at, album_group_key, album_group_title, \
source, qobuz_track_id, catalog_number";
source, qobuz_track_id, catalog_number, is_network_mount";
fn row_to_track(row: &rusqlite::Row) -> rusqlite::Result<LocalTrack> {
Ok(LocalTrack {
@@ -1847,6 +1879,12 @@ impl LibraryDatabase {
source: row.get(24).ok().flatten(), // source
qobuz_track_id: row.get(25).ok().flatten(), // qobuz_track_id
catalog_number: row.get(26).ok().flatten(), // catalog_number
is_network_mount: row
.get::<_, Option<i64>>(27)
.ok()
.flatten()
.map(|v| v != 0)
.unwrap_or(false),
})
}
@@ -2914,7 +2952,7 @@ impl LibraryDatabase {
t.year, t.genre, t.duration_secs, t.format, t.bit_depth, t.sample_rate,
t.channels, t.file_size_bytes, t.cue_file_path, t.cue_start_secs,
t.cue_end_secs, t.artwork_path, t.last_modified, t.indexed_at, t.source,
t.qobuz_track_id, plt.position
t.qobuz_track_id, t.is_network_mount, plt.position
FROM playlist_local_tracks plt
JOIN local_tracks t ON plt.local_track_id = t.id
WHERE plt.qobuz_playlist_id = ?1
@@ -2952,6 +2990,7 @@ impl LibraryDatabase {
indexed_at: row.get(23)?,
source: row.get(24)?,
qobuz_track_id: row.get(25)?,
is_network_mount: row.get::<_, i64>(26)? != 0,
})
})
.map_err(|e| {
@@ -2976,7 +3015,7 @@ impl LibraryDatabase {
t.year, t.genre, t.duration_secs, t.format, t.bit_depth, t.sample_rate,
t.channels, t.file_size_bytes, t.cue_file_path, t.cue_start_secs,
t.cue_end_secs, t.artwork_path, t.last_modified, t.indexed_at, t.source,
t.qobuz_track_id, plt.position
t.qobuz_track_id, t.is_network_mount, plt.position
FROM playlist_local_tracks plt
JOIN local_tracks t ON plt.local_track_id = t.id
WHERE plt.qobuz_playlist_id = ?1
@@ -3015,8 +3054,9 @@ impl LibraryDatabase {
indexed_at: row.get(23)?,
source: row.get(24)?,
qobuz_track_id: row.get(25)?,
is_network_mount: row.get::<_, i64>(26)? != 0,
},
playlist_position: row.get(26)?,
playlist_position: row.get(27)?,
})
})
.map_err(|e| {
+2
View File
@@ -33,6 +33,7 @@ mod database;
mod errors;
mod metadata;
mod models;
mod mount_info;
mod scanner;
mod tag_sidecar;
mod thumbnails;
@@ -46,6 +47,7 @@ pub use database::{
pub use errors::LibraryError;
pub use metadata::MetadataExtractor;
pub use models::*;
pub use mount_info::is_network_path;
pub use scanner::{LibraryScanner, ScanResult};
pub use thumbnails::{
clear_thumbnails, generate_thumbnail, generate_thumbnail_from_bytes, get_cache_size,
+2
View File
@@ -487,6 +487,7 @@ impl MetadataExtractor {
.unwrap_or(0),
source: None,
qobuz_track_id: None,
is_network_mount: false,
}
} else {
// No tag found, use defaults
@@ -527,6 +528,7 @@ impl MetadataExtractor {
.unwrap_or(0),
source: None,
qobuz_track_id: None,
is_network_mount: false,
}
};
+10
View File
@@ -76,6 +76,15 @@ pub struct LocalTrack {
// Download tracking
pub source: Option<String>,
pub qobuz_track_id: Option<i64>,
/// True when the file lives on a network-backed filesystem (NFS,
/// CIFS/SMB, SSHFS, etc.). Detected at index time by inspecting
/// /proc/mounts. Consumed by the UI to mark the track as
/// unreachable under forced offline mode — cable unplugged means
/// the mount is gone even if the path string still says /home/…,
/// which is common under Flatpak / Snap sandboxes.
#[serde(default)]
pub is_network_mount: bool,
}
impl Default for LocalTrack {
@@ -108,6 +117,7 @@ impl Default for LocalTrack {
indexed_at: 0,
source: None,
qobuz_track_id: None,
is_network_mount: false,
}
}
}
+165
View File
@@ -0,0 +1,165 @@
//! Network-mount detection for local library paths.
//!
//! On Linux, reads /proc/mounts (or /run/host/proc/mounts as a fallback
//! for sandboxed apps like Flatpak / Snap) and classifies a given
//! filesystem path by the fs type of its longest-matching mount point.
//!
//! The UI consumes the resulting is_network_mount flag to mark tracks
//! as unreachable when the user is under forced offline mode (cable
//! unplugged / ISP down). In that state a path that still reads
//! /home/user/music can be sitting on a CIFS share or SSHFS — the
//! heuristic the frontend originally used (string-match /mnt, /media)
//! misses those cases entirely, especially inside sandboxes where the
//! user's music folder is commonly bind-mounted from an SMB share.
use std::path::Path;
/// Filesystem types that require network to be reachable. Matched as
/// a prefix against the fs_type column of /proc/mounts so variants
/// like `fuse.sshfs` / `fuse.rclone` / `nfs4` all hit the same rule.
const NETWORK_FS_PREFIXES: &[&str] = &[
"nfs",
"cifs",
"smb",
"smbfs",
"smb3",
"fuse.sshfs",
"fuse.rclone",
"fuse.gvfs",
"fuse.gvfsd",
"fuse.davfs",
"fuse.rclonefs",
"davfs",
"webdav",
"9p",
"ceph",
"glusterfs",
"afs",
"afp",
];
/// Return true when `path` lives on a network-backed filesystem.
///
/// Non-Linux platforms fall through to `false` — we don't have a
/// portable story for macOS / Windows yet. The frontend still has a
/// defensive string-match heuristic for UNC paths and common mount
/// prefixes, which picks up the easy cases on those platforms.
#[cfg(target_os = "linux")]
pub fn is_network_path(path: &Path) -> bool {
let mounts = read_mounts();
if mounts.is_empty() {
return false;
}
// Canonicalize for best matching; fall back to raw path if the
// file already disappeared / permission denied.
let target = path
.canonicalize()
.unwrap_or_else(|_| path.to_path_buf());
let target_str = target.to_string_lossy();
// Longest-prefix match wins — `/` is always present but any
// deeper mount shadows it.
let mut best: Option<(&str, usize)> = None;
for (mount_point, fs_type) in &mounts {
if target_str.starts_with(mount_point) {
let len = mount_point.len();
if best.map(|(_, l)| l < len).unwrap_or(true) {
best = Some((fs_type.as_str(), len));
}
}
}
match best {
Some((fs_type, _)) => is_network_fs(fs_type),
None => false,
}
}
#[cfg(not(target_os = "linux"))]
pub fn is_network_path(_path: &Path) -> bool {
false
}
#[cfg(target_os = "linux")]
fn read_mounts() -> Vec<(String, String)> {
// Inside Flatpak the sandbox's own /proc/mounts reflects the
// sandbox view, which is the right lens for the app. Snap is the
// same. Both bind-mount the host share into the sandbox, so if the
// host mount is CIFS, the sandbox sees fuse.* or the same fs type
// (depending on the mechanism). /run/host/proc/mounts is the
// Flatpak escape hatch when we need the raw host view, used as a
// fallback for cases where the sandbox doesn't expose /proc/mounts.
for path in ["/proc/mounts", "/run/host/proc/mounts"] {
if let Ok(contents) = std::fs::read_to_string(path) {
return parse_mounts(&contents);
}
}
Vec::new()
}
#[cfg(target_os = "linux")]
fn parse_mounts(contents: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for line in contents.lines() {
let mut parts = line.split_whitespace();
let _device = parts.next();
let mount_point = match parts.next() {
Some(m) => m,
None => continue,
};
let fs_type = match parts.next() {
Some(t) => t,
None => continue,
};
// /proc/mounts escapes spaces as \040, tabs as \011, etc.
// Keep the raw string — starts_with on the pattern we care
// about is unaffected, and canonicalize will bring our input
// into the same encoding.
out.push((mount_point.to_string(), fs_type.to_string()));
}
out
}
#[cfg(target_os = "linux")]
fn is_network_fs(fs_type: &str) -> bool {
NETWORK_FS_PREFIXES
.iter()
.any(|prefix| fs_type == *prefix || fs_type.starts_with(&format!("{}.", prefix)))
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
#[test]
fn nfs_variants_classify_network() {
assert!(is_network_fs("nfs"));
assert!(is_network_fs("nfs4"));
assert!(is_network_fs("cifs"));
assert!(is_network_fs("smb3"));
assert!(is_network_fs("fuse.sshfs"));
assert!(is_network_fs("fuse.rclone"));
}
#[test]
fn local_fs_does_not_classify() {
assert!(!is_network_fs("ext4"));
assert!(!is_network_fs("btrfs"));
assert!(!is_network_fs("tmpfs"));
assert!(!is_network_fs("fuse.gocryptfs"));
}
#[test]
fn parse_mounts_reads_typical_entries() {
let sample = "\
/dev/sda1 / ext4 rw,relatime 0 0\n\
tmpfs /run tmpfs rw,nosuid 0 0\n\
nas:/music /mnt/music nfs4 rw,relatime 0 0\n\
";
let parsed = parse_mounts(sample);
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[2].0, "/mnt/music");
assert_eq!(parsed[2].1, "nfs4");
}
}