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:
6 files changed
+227
-7
No files matched your search
@@ -272,6 +272,7 @@ pub fn cue_to_tracks(
|
||||
indexed_at: now,
|
||||
source: None,
|
||||
qobuz_track_id: None,
|
||||
is_network_mount: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user