diff --git a/Cargo.lock b/Cargo.lock
index 89f1c5f..0bf7472 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2832,6 +2832,7 @@ dependencies = [
"chrono",
"dirs",
"dispatch2",
+ "libc",
"lofty",
"lru",
"md5",
@@ -2870,6 +2871,7 @@ dependencies = [
"tracing-subscriber",
"uuid",
"walkdir",
+ "windows-sys 0.59.0",
]
[[package]]
diff --git a/app/frontend/js/components/settings-view.js b/app/frontend/js/components/settings-view.js
index aa60e8d..e9c76a6 100644
--- a/app/frontend/js/components/settings-view.js
+++ b/app/frontend/js/components/settings-view.js
@@ -11,6 +11,7 @@ export function createSettingsView(Alpine) {
navSections: [
{ id: 'general', label: 'General' },
+ { id: 'audio', label: 'Audio' },
{ id: 'appearance', label: 'Appearance' },
{ id: 'library', label: 'Library' },
{ id: 'columns', label: 'Columns' },
@@ -46,6 +47,15 @@ export function createSettingsView(Alpine) {
progress: null,
},
+ networkCache: {
+ enabled: false,
+ persistent: false,
+ maxGb: 2,
+ usedBytes: 0,
+ fileCount: 0,
+ isPurging: false,
+ },
+
// Column settings for Settings > Columns section
columnSettings: {
visibleCount: 0,
@@ -134,6 +144,33 @@ export function createSettingsView(Alpine) {
return this.lastfm.enabled ? 'translate-x-6' : 'translate-x-1';
},
+ networkCacheToggleTrackClass() {
+ return this.networkCache.enabled ? 'bg-primary' : 'bg-muted';
+ },
+
+ networkCacheToggleThumbClass() {
+ return this.networkCache.enabled ? 'translate-x-6' : 'translate-x-1';
+ },
+
+ networkCachePersistentTrackClass() {
+ return this.networkCache.persistent ? 'bg-primary' : 'bg-muted';
+ },
+
+ networkCachePersistentThumbClass() {
+ return this.networkCache.persistent ? 'translate-x-6' : 'translate-x-1';
+ },
+
+ purgeButtonText() {
+ return this.networkCache.isPurging ? 'Clearing...' : 'Clear Cache';
+ },
+
+ formatCacheSize(bytes) {
+ if (bytes === 0) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
+ },
+
connectButtonText() {
return this.lastfm.isConnecting ? 'Connecting...' : 'Connect';
},
@@ -162,6 +199,7 @@ export function createSettingsView(Alpine) {
await this.loadAppInfo();
await this.loadWatchedFolders();
await this.loadLastfmSettings();
+ await this.loadNetworkCacheStatus();
this.loadColumnSettings();
},
@@ -350,6 +388,94 @@ export function createSettingsView(Alpine) {
}
},
+ // ============================================
+ // Network Cache methods
+ // ============================================
+
+ async loadNetworkCacheStatus() {
+ if (!window.__TAURI__) return;
+
+ try {
+ const { invoke } = window.__TAURI__.core;
+ const status = await invoke('network_cache_status');
+ this.networkCache.enabled = status.enabled;
+ this.networkCache.persistent = status.persistent;
+ this.networkCache.maxGb = status.max_bytes / 1_073_741_824;
+ this.networkCache.usedBytes = status.used_bytes;
+ this.networkCache.fileCount = status.file_count;
+ } catch (error) {
+ console.error('[settings] Failed to load network cache status:', error);
+ }
+ },
+
+ async toggleNetworkCache() {
+ if (!window.__TAURI__) return;
+
+ try {
+ const newValue = !this.networkCache.enabled;
+ await window.settings.set('network_cache_enabled', newValue);
+ this.networkCache.enabled = newValue;
+ Alpine.store('ui').toast(
+ `Network file caching ${newValue ? 'enabled' : 'disabled'}`,
+ 'success',
+ );
+ } catch (error) {
+ console.error('[settings] Failed to toggle network cache:', error);
+ Alpine.store('ui').toast('Failed to update network cache setting', 'error');
+ }
+ },
+
+ async toggleNetworkCachePersistent() {
+ if (!window.__TAURI__) return;
+
+ try {
+ const newValue = !this.networkCache.persistent;
+ await window.settings.set('network_cache_persistent', newValue);
+ this.networkCache.persistent = newValue;
+ Alpine.store('ui').toast(
+ `Persistent cache ${newValue ? 'enabled' : 'disabled'}`,
+ 'success',
+ );
+ } catch (error) {
+ console.error('[settings] Failed to toggle persistent cache:', error);
+ Alpine.store('ui').toast('Failed to update persistent cache setting', 'error');
+ }
+ },
+
+ async updateNetworkCacheMaxGb() {
+ if (!window.__TAURI__) return;
+
+ try {
+ const clamped = Math.max(0.5, Math.min(20, this.networkCache.maxGb));
+ if (clamped !== this.networkCache.maxGb) {
+ this.networkCache.maxGb = clamped;
+ }
+
+ await window.settings.set('network_cache_max_gb', this.networkCache.maxGb);
+ } catch (error) {
+ console.error('[settings] Failed to update cache size limit:', error);
+ Alpine.store('ui').toast('Failed to update cache size limit', 'error');
+ }
+ },
+
+ async purgeNetworkCache() {
+ if (!window.__TAURI__) return;
+
+ this.networkCache.isPurging = true;
+ try {
+ const { invoke } = window.__TAURI__.core;
+ await invoke('network_cache_purge');
+ this.networkCache.usedBytes = 0;
+ this.networkCache.fileCount = 0;
+ Alpine.store('ui').toast('Network cache cleared', 'success');
+ } catch (error) {
+ console.error('[settings] Failed to purge network cache:', error);
+ Alpine.store('ui').toast('Failed to clear network cache', 'error');
+ } finally {
+ this.networkCache.isPurging = false;
+ }
+ },
+
// ============================================
// Last.fm methods
// ============================================
diff --git a/app/frontend/tests/network-cache-settings.spec.js b/app/frontend/tests/network-cache-settings.spec.js
new file mode 100644
index 0000000..20ec1f0
--- /dev/null
+++ b/app/frontend/tests/network-cache-settings.spec.js
@@ -0,0 +1,110 @@
+import { expect, test } from '@playwright/test';
+import { waitForAlpine } from './fixtures/helpers.js';
+import { createLibraryState, setupLibraryMocks } from './fixtures/mock-library.js';
+
+test.describe('Network Cache Settings', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.setViewportSize({ width: 1624, height: 1057 });
+
+ const libraryState = createLibraryState();
+ await setupLibraryMocks(page, libraryState);
+
+ // Mock Last.fm to prevent error toasts
+ await page.route(/\/api\/lastfm\/settings/, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ enabled: false,
+ username: null,
+ authenticated: false,
+ configured: false,
+ scrobble_threshold: 50,
+ }),
+ });
+ });
+
+ await page.goto('/');
+ await waitForAlpine(page);
+
+ // Navigate to Settings > Audio
+ await page.click('[data-testid="sidebar-settings"]');
+ await page.waitForTimeout(500);
+ await page.click('[data-testid="settings-nav-audio"]');
+ await page.waitForTimeout(300);
+ });
+
+ test('should show Audio nav item in settings', async ({ page }) => {
+ const navItem = page.locator('[data-testid="settings-nav-audio"]');
+ await expect(navItem).toBeVisible();
+ await expect(navItem).toHaveText('Audio');
+ });
+
+ test('should display the Audio section', async ({ page }) => {
+ const section = page.locator('[data-testid="settings-section-audio"]');
+ await expect(section).toBeVisible();
+ });
+
+ test('should show network cache toggle defaulting to off', async ({ page }) => {
+ const toggle = page.locator('[data-testid="network-cache-toggle"]');
+ await expect(toggle).toBeVisible();
+
+ // Default is off, so the toggle should have the muted class
+ const classes = await toggle.getAttribute('class');
+ expect(classes).toContain('bg-muted');
+ });
+
+ test('should show sub-settings when cache is enabled', async ({ page }) => {
+ // Persistent toggle, slider, and purge should not be visible initially
+ const persistentToggle = page.locator('[data-testid="network-cache-persistent-toggle"]');
+ await expect(persistentToggle).not.toBeVisible();
+
+ const slider = page.locator('[data-testid="network-cache-size-slider"]');
+ await expect(slider).not.toBeVisible();
+
+ const purgeButton = page.locator('[data-testid="network-cache-purge"]');
+ await expect(purgeButton).not.toBeVisible();
+
+ // Enable the cache
+ await page.click('[data-testid="network-cache-toggle"]');
+ await page.waitForTimeout(300);
+
+ // Now sub-settings should be visible
+ await expect(persistentToggle).toBeVisible();
+ await expect(slider).toBeVisible();
+ await expect(purgeButton).toBeVisible();
+ });
+
+ test('should have range slider with correct min/max', async ({ page }) => {
+ // Enable cache to show slider
+ await page.click('[data-testid="network-cache-toggle"]');
+ await page.waitForTimeout(300);
+
+ const slider = page.locator('[data-testid="network-cache-size-slider"]');
+ await expect(slider).toHaveAttribute('min', '0.5');
+ await expect(slider).toHaveAttribute('max', '20');
+ await expect(slider).toHaveAttribute('step', '0.5');
+ });
+
+ test('should show cache status when enabled', async ({ page }) => {
+ // Enable cache
+ await page.click('[data-testid="network-cache-toggle"]');
+ await page.waitForTimeout(300);
+
+ // Cache status card should show "0 B" and "0" files
+ const usedText = page.locator('text=Used');
+ await expect(usedText).toBeVisible();
+
+ const filesText = page.locator('text=Files');
+ await expect(filesText).toBeVisible();
+ });
+
+ test('should show purge button as disabled when cache is empty', async ({ page }) => {
+ // Enable cache
+ await page.click('[data-testid="network-cache-toggle"]');
+ await page.waitForTimeout(300);
+
+ const purgeButton = page.locator('[data-testid="network-cache-purge"]');
+ await expect(purgeButton).toBeDisabled();
+ });
+});
diff --git a/app/frontend/views/settings-audio.html b/app/frontend/views/settings-audio.html
new file mode 100644
index 0000000..8572cc5
--- /dev/null
+++ b/app/frontend/views/settings-audio.html
@@ -0,0 +1,110 @@
+
+
+
Audio
+
+
+
+
+ Network File Caching
+
+
+
+
+
+
Cache network files locally
+
+ Copy files from network mounts (SMB/NFS) to a local cache before playback
+
+
+
+
+
+
+
+
+
+
+
Persistent cache
+
+ Keep cached files across app restarts (otherwise cleared on exit)
+
+
+
+
+
+
+
+
Maximum cache size
+
+ Limit local cache to GB
+
+
+
+
+ 0.5 GB
+ 10 GB
+ 20 GB
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/frontend/views/settings.html b/app/frontend/views/settings.html
index d61c527..695872c 100644
--- a/app/frontend/views/settings.html
+++ b/app/frontend/views/settings.html
@@ -36,6 +36,8 @@ General
+ {{> settings-audio}}
+
{{> settings-library}}
diff --git a/crates/mt-tauri/Cargo.toml b/crates/mt-tauri/Cargo.toml
index dbc4a5c..53783c3 100644
--- a/crates/mt-tauri/Cargo.toml
+++ b/crates/mt-tauri/Cargo.toml
@@ -80,10 +80,14 @@ default = ["mcp"]
devtools = ["dep:tauri-plugin-devtools", "tauri/devtools"]
mcp = ["dep:tauri-plugin-mcp-bridge"]
+[target.'cfg(unix)'.dependencies]
+libc = "0.2"
+
[target.'cfg(target_os = "windows")'.dependencies]
# Required to extract the HWND from the Tauri window for souvlaki SMTC registration.
# souvlaki panics on Windows if PlatformConfig.hwnd is None.
raw-window-handle = "0.6"
+windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
diff --git a/crates/mt-tauri/src/cache/mod.rs b/crates/mt-tauri/src/cache/mod.rs
new file mode 100644
index 0000000..7f03579
--- /dev/null
+++ b/crates/mt-tauri/src/cache/mod.rs
@@ -0,0 +1,4 @@
+pub mod mount_detect;
+pub mod network_cache;
+
+pub use network_cache::NetworkFileCache;
diff --git a/crates/mt-tauri/src/cache/mount_detect.rs b/crates/mt-tauri/src/cache/mount_detect.rs
new file mode 100644
index 0000000..b4eea81
--- /dev/null
+++ b/crates/mt-tauri/src/cache/mount_detect.rs
@@ -0,0 +1,160 @@
+/// Detect whether a file path resides on a network mount (SMB/NFS/CIFS).
+///
+/// Returns `true` when the path is on a remote filesystem.
+/// Returns `false` for local paths, non-existent paths, or on detection failure.
+#[cfg(target_os = "macos")]
+pub fn is_network_mount(path: &str) -> bool {
+ macos::is_network_mount_impl(path)
+}
+
+#[cfg(target_os = "linux")]
+pub fn is_network_mount(path: &str) -> bool {
+ linux::is_network_mount_impl(path)
+}
+
+#[cfg(target_os = "windows")]
+pub fn is_network_mount(path: &str) -> bool {
+ windows::is_network_mount_impl(path)
+}
+
+#[cfg(target_os = "macos")]
+mod macos {
+ use std::ffi::CString;
+ use std::path::Path;
+
+ /// MNT_LOCAL flag from
+ const MNT_LOCAL: u32 = 0x0000_1000;
+
+ pub fn is_network_mount_impl(path: &str) -> bool {
+ let real_path = match Path::new(path).canonicalize() {
+ Ok(p) => p,
+ Err(_) => return false,
+ };
+
+ let c_path = match CString::new(real_path.to_string_lossy().as_bytes()) {
+ Ok(c) => c,
+ Err(_) => return false,
+ };
+
+ unsafe {
+ let mut stat: libc::statfs = std::mem::zeroed();
+ if libc::statfs(c_path.as_ptr(), &mut stat) != 0 {
+ return false;
+ }
+ // If MNT_LOCAL is NOT set, the filesystem is remote
+ (stat.f_flags as u32 & MNT_LOCAL) == 0
+ }
+ }
+}
+
+#[cfg(target_os = "linux")]
+mod linux {
+ use std::path::Path;
+
+ const NETWORK_FS_TYPES: &[&str] =
+ &["nfs", "nfs4", "cifs", "smbfs", "fuse.sshfs", "ncpfs", "9p"];
+
+ pub fn is_network_mount_impl(path: &str) -> bool {
+ let real_path = match Path::new(path).canonicalize() {
+ Ok(p) => p,
+ Err(_) => return false,
+ };
+
+ let mounts = match std::fs::read_to_string("/proc/mounts") {
+ Ok(m) => m,
+ Err(_) => return false,
+ };
+
+ // Find the mount with the longest prefix match
+ let path_str = real_path.to_string_lossy();
+ let mut best_mount_point = "";
+ let mut best_fs_type = "";
+
+ for line in mounts.lines() {
+ let fields: Vec<&str> = line.split_whitespace().collect();
+ if fields.len() < 3 {
+ continue;
+ }
+ let mount_point = fields[1];
+ let fs_type = fields[2];
+
+ if path_str.starts_with(mount_point) && mount_point.len() > best_mount_point.len() {
+ best_mount_point = mount_point;
+ best_fs_type = fs_type;
+ }
+ }
+
+ NETWORK_FS_TYPES.contains(&best_fs_type)
+ }
+}
+
+#[cfg(target_os = "windows")]
+mod windows {
+ pub fn is_network_mount_impl(path: &str) -> bool {
+ // UNC paths (\\server\share\...) are network paths
+ if path.starts_with("\\\\") {
+ return true;
+ }
+
+ // Check if the drive is a network drive via GetDriveTypeW
+ if let Some(drive_root) = drive_root_from_path(path) {
+ let wide: Vec = drive_root
+ .encode_utf16()
+ .chain(std::iter::once(0))
+ .collect();
+ unsafe {
+ let drive_type =
+ windows_sys::Win32::Storage::FileSystem::GetDriveTypeW(wide.as_ptr());
+ // DRIVE_REMOTE == 4
+ return drive_type == 4;
+ }
+ }
+
+ false
+ }
+
+ fn drive_root_from_path(path: &str) -> Option {
+ let bytes = path.as_bytes();
+ if bytes.len() >= 3
+ && bytes[0].is_ascii_alphabetic()
+ && bytes[1] == b':'
+ && (bytes[2] == b'\\' || bytes[2] == b'/')
+ {
+ Some(format!("{}:\\", bytes[0] as char))
+ } else {
+ None
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_local_path_returns_false() {
+ // A path in the temp directory should be local
+ let dir = tempfile::tempdir().unwrap();
+ let file = dir.path().join("test.txt");
+ std::fs::write(&file, b"hello").unwrap();
+ assert!(!is_network_mount(file.to_str().unwrap()));
+ }
+
+ #[test]
+ fn test_nonexistent_path_returns_false() {
+ assert!(!is_network_mount("/nonexistent/path/file.mp3"));
+ }
+
+ #[cfg(target_os = "windows")]
+ #[test]
+ fn test_unc_path_detected() {
+ assert!(is_network_mount("\\\\server\\share\\music\\song.mp3"));
+ }
+
+ #[cfg(target_os = "windows")]
+ #[test]
+ fn test_local_drive_not_network() {
+ // C:\ is almost always local
+ assert!(!is_network_mount("C:\\Windows\\System32"));
+ }
+}
diff --git a/crates/mt-tauri/src/cache/network_cache.rs b/crates/mt-tauri/src/cache/network_cache.rs
new file mode 100644
index 0000000..5d976fb
--- /dev/null
+++ b/crates/mt-tauri/src/cache/network_cache.rs
@@ -0,0 +1,457 @@
+use lru::LruCache;
+use parking_lot::Mutex;
+use sha2::{Digest, Sha256};
+use std::io;
+use std::num::NonZeroUsize;
+use std::path::{Path, PathBuf};
+use tracing::{debug, warn};
+
+const MAX_LRU_ENTRIES: usize = 10_000;
+
+struct CacheEntry {
+ cached_path: PathBuf,
+ size_bytes: u64,
+}
+
+struct CacheInner {
+ index: LruCache,
+ current_bytes: u64,
+ max_bytes: u64,
+}
+
+/// Disk-based LRU cache for audio files on network mounts.
+///
+/// Files are copied to a local cache directory keyed by SHA-256 hash
+/// of the source path. The original file extension is preserved so
+/// Rodio can detect the audio format.
+pub struct NetworkFileCache {
+ cache_dir: PathBuf,
+ inner: Mutex,
+}
+
+impl NetworkFileCache {
+ pub fn new(cache_dir: PathBuf, max_bytes: u64) -> io::Result {
+ std::fs::create_dir_all(&cache_dir)?;
+
+ let lru_cap = NonZeroUsize::new(MAX_LRU_ENTRIES).unwrap();
+ let cache = Self {
+ cache_dir,
+ inner: Mutex::new(CacheInner {
+ index: LruCache::new(lru_cap),
+ current_bytes: 0,
+ max_bytes,
+ }),
+ };
+
+ cache.rebuild_index()?;
+ Ok(cache)
+ }
+
+ /// Return a local cached path for the given source file.
+ /// Copies the file into the cache on a miss; returns the existing
+ /// cached path on a hit.
+ pub fn get_or_cache(&self, source_path: &str) -> io::Result {
+ let key = Self::cache_key(source_path);
+
+ // Check for cache hit
+ {
+ let mut inner = self.inner.lock();
+ if let Some(entry) = inner.index.get(&key) {
+ if entry.cached_path.exists() {
+ debug!(source = source_path, "Network cache hit");
+ return Ok(entry.cached_path.clone());
+ }
+ // Cached file was deleted externally; remove stale entry
+ let entry = inner.index.pop(&key).unwrap();
+ inner.current_bytes = inner.current_bytes.saturating_sub(entry.size_bytes);
+ }
+ }
+
+ // Cache miss: copy file
+ let source = Path::new(source_path);
+ let ext = source.extension().and_then(|e| e.to_str()).unwrap_or("");
+ let cached_name = if ext.is_empty() {
+ key.clone()
+ } else {
+ format!("{key}.{ext}")
+ };
+ let cached_path = self.cache_dir.join(&cached_name);
+
+ let file_size = std::fs::metadata(source)?.len();
+
+ // Evict entries until there is room
+ {
+ let mut inner = self.inner.lock();
+ Self::evict_to_fit(&mut inner, file_size);
+ }
+
+ std::fs::copy(source, &cached_path)?;
+ debug!(source = source_path, cached = %cached_path.display(), "Network cache miss; file cached");
+
+ // Insert into index
+ {
+ let mut inner = self.inner.lock();
+ inner.current_bytes += file_size;
+ inner.index.put(
+ key,
+ CacheEntry {
+ cached_path: cached_path.clone(),
+ size_bytes: file_size,
+ },
+ );
+ }
+
+ Ok(cached_path)
+ }
+
+ /// Remove all cached files and reset the index.
+ pub fn purge(&self) -> io::Result<()> {
+ let mut inner = self.inner.lock();
+ // Delete each cached file
+ for (_key, entry) in inner.index.iter() {
+ if entry.cached_path.exists()
+ && let Err(e) = std::fs::remove_file(&entry.cached_path)
+ {
+ warn!(path = %entry.cached_path.display(), error = %e, "Failed to remove cached file");
+ }
+ }
+ inner.index.clear();
+ inner.current_bytes = 0;
+ Ok(())
+ }
+
+ pub fn set_max_bytes(&self, max_bytes: u64) {
+ let mut inner = self.inner.lock();
+ inner.max_bytes = max_bytes;
+ // Evict if current size exceeds the new limit
+ Self::evict_to_fit(&mut inner, 0);
+ }
+
+ pub fn current_size_bytes(&self) -> u64 {
+ self.inner.lock().current_bytes
+ }
+
+ pub fn entry_count(&self) -> usize {
+ self.inner.lock().index.len()
+ }
+
+ fn cache_key(source_path: &str) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(source_path.as_bytes());
+ format!("{:x}", hasher.finalize())
+ }
+
+ fn evict_to_fit(inner: &mut CacheInner, needed_bytes: u64) {
+ while inner.current_bytes + needed_bytes > inner.max_bytes {
+ match inner.index.pop_lru() {
+ Some((_key, entry)) => {
+ if entry.cached_path.exists() {
+ let _ = std::fs::remove_file(&entry.cached_path);
+ }
+ inner.current_bytes = inner.current_bytes.saturating_sub(entry.size_bytes);
+ }
+ None => break,
+ }
+ }
+ }
+
+ /// Scan the cache directory and rebuild the in-memory LRU index
+ /// from file modification times (oldest first = LRU).
+ fn rebuild_index(&self) -> io::Result<()> {
+ let mut entries: Vec<(String, PathBuf, u64, std::time::SystemTime)> = Vec::new();
+
+ for dir_entry in std::fs::read_dir(&self.cache_dir)? {
+ let dir_entry = dir_entry?;
+ let path = dir_entry.path();
+ if !path.is_file() {
+ continue;
+ }
+ let meta = std::fs::metadata(&path)?;
+ let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
+ let size = meta.len();
+
+ // The cache key is the file stem (hash before extension)
+ let stem = path
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or("")
+ .to_string();
+
+ entries.push((stem, path, size, mtime));
+ }
+
+ // Sort by mtime ascending so that oldest entries are inserted first
+ // (and thus become LRU candidates)
+ entries.sort_by_key(|(_, _, _, mtime)| *mtime);
+
+ let mut inner = self.inner.lock();
+ inner.index.clear();
+ inner.current_bytes = 0;
+
+ for (key, path, size, _mtime) in entries {
+ inner.current_bytes += size;
+ inner.index.put(
+ key,
+ CacheEntry {
+ cached_path: path,
+ size_bytes: size,
+ },
+ );
+ }
+
+ debug!(
+ entries = inner.index.len(),
+ bytes = inner.current_bytes,
+ "Network cache index rebuilt"
+ );
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tempfile::tempdir;
+
+ fn create_test_file(dir: &Path, name: &str, size: usize) -> PathBuf {
+ let path = dir.join(name);
+ let data = vec![0xABu8; size];
+ std::fs::write(&path, &data).unwrap();
+ path
+ }
+
+ #[test]
+ fn test_create_cache() {
+ let dir = tempdir().unwrap();
+ let cache_dir = dir.path().join("cache");
+ let cache = NetworkFileCache::new(cache_dir.clone(), 1024 * 1024).unwrap();
+ assert!(cache_dir.exists());
+ assert_eq!(cache.entry_count(), 0);
+ assert_eq!(cache.current_size_bytes(), 0);
+ }
+
+ #[test]
+ fn test_write_and_retrieve() {
+ let dir = tempdir().unwrap();
+ let source_dir = dir.path().join("source");
+ let cache_dir = dir.path().join("cache");
+ std::fs::create_dir_all(&source_dir).unwrap();
+
+ let source = create_test_file(&source_dir, "song.flac", 1000);
+ let cache = NetworkFileCache::new(cache_dir, 1024 * 1024).unwrap();
+
+ let cached = cache.get_or_cache(source.to_str().unwrap()).unwrap();
+
+ assert!(cached.exists());
+ assert_ne!(cached, source);
+ assert_eq!(std::fs::read(&cached).unwrap().len(), 1000);
+ assert_eq!(cache.entry_count(), 1);
+ assert_eq!(cache.current_size_bytes(), 1000);
+ }
+
+ #[test]
+ fn test_cache_hit() {
+ let dir = tempdir().unwrap();
+ let source_dir = dir.path().join("source");
+ let cache_dir = dir.path().join("cache");
+ std::fs::create_dir_all(&source_dir).unwrap();
+
+ let source = create_test_file(&source_dir, "song.mp3", 500);
+ let cache = NetworkFileCache::new(cache_dir, 1024 * 1024).unwrap();
+ let source_str = source.to_str().unwrap();
+
+ let first = cache.get_or_cache(source_str).unwrap();
+ let second = cache.get_or_cache(source_str).unwrap();
+
+ assert_eq!(first, second);
+ assert_eq!(cache.entry_count(), 1);
+ }
+
+ #[test]
+ fn test_lru_eviction_by_size() {
+ let dir = tempdir().unwrap();
+ let source_dir = dir.path().join("source");
+ let cache_dir = dir.path().join("cache");
+ std::fs::create_dir_all(&source_dir).unwrap();
+
+ // Cache can hold 1500 bytes; each file is 600 bytes
+ let cache = NetworkFileCache::new(cache_dir, 1500).unwrap();
+
+ let f1 = create_test_file(&source_dir, "a.mp3", 600);
+ let f2 = create_test_file(&source_dir, "b.mp3", 600);
+ let f3 = create_test_file(&source_dir, "c.mp3", 600);
+
+ let p1 = cache.get_or_cache(f1.to_str().unwrap()).unwrap();
+ let _p2 = cache.get_or_cache(f2.to_str().unwrap()).unwrap();
+
+ assert_eq!(cache.entry_count(), 2);
+ assert_eq!(cache.current_size_bytes(), 1200);
+
+ // Adding third file should evict the first (LRU)
+ let _p3 = cache.get_or_cache(f3.to_str().unwrap()).unwrap();
+
+ assert_eq!(cache.entry_count(), 2);
+ assert_eq!(cache.current_size_bytes(), 1200);
+ // First file's cached copy should be deleted
+ assert!(!p1.exists());
+ }
+
+ #[test]
+ fn test_access_refresh() {
+ let dir = tempdir().unwrap();
+ let source_dir = dir.path().join("source");
+ let cache_dir = dir.path().join("cache");
+ std::fs::create_dir_all(&source_dir).unwrap();
+
+ let cache = NetworkFileCache::new(cache_dir, 1500).unwrap();
+
+ let f1 = create_test_file(&source_dir, "a.mp3", 600);
+ let f2 = create_test_file(&source_dir, "b.mp3", 600);
+ let f3 = create_test_file(&source_dir, "c.mp3", 600);
+
+ let _p1 = cache.get_or_cache(f1.to_str().unwrap()).unwrap();
+ let p2 = cache.get_or_cache(f2.to_str().unwrap()).unwrap();
+
+ // Access f1 again to refresh it
+ let _p1_again = cache.get_or_cache(f1.to_str().unwrap()).unwrap();
+
+ // Adding f3 should evict f2 (now LRU), not f1
+ let _p3 = cache.get_or_cache(f3.to_str().unwrap()).unwrap();
+
+ assert_eq!(cache.entry_count(), 2);
+ assert!(!p2.exists());
+ }
+
+ #[test]
+ fn test_purge() {
+ let dir = tempdir().unwrap();
+ let source_dir = dir.path().join("source");
+ let cache_dir = dir.path().join("cache");
+ std::fs::create_dir_all(&source_dir).unwrap();
+
+ let cache = NetworkFileCache::new(cache_dir.clone(), 1024 * 1024).unwrap();
+ let f = create_test_file(&source_dir, "song.mp3", 100);
+
+ let cached = cache.get_or_cache(f.to_str().unwrap()).unwrap();
+ assert!(cached.exists());
+
+ cache.purge().unwrap();
+
+ assert_eq!(cache.entry_count(), 0);
+ assert_eq!(cache.current_size_bytes(), 0);
+ assert!(!cached.exists());
+ }
+
+ #[test]
+ fn test_set_max_bytes() {
+ let dir = tempdir().unwrap();
+ let source_dir = dir.path().join("source");
+ let cache_dir = dir.path().join("cache");
+ std::fs::create_dir_all(&source_dir).unwrap();
+
+ let cache = NetworkFileCache::new(cache_dir, 2000).unwrap();
+
+ let f1 = create_test_file(&source_dir, "a.mp3", 600);
+ let f2 = create_test_file(&source_dir, "b.mp3", 600);
+ let f3 = create_test_file(&source_dir, "c.mp3", 600);
+
+ cache.get_or_cache(f1.to_str().unwrap()).unwrap();
+ cache.get_or_cache(f2.to_str().unwrap()).unwrap();
+ cache.get_or_cache(f3.to_str().unwrap()).unwrap();
+ assert_eq!(cache.entry_count(), 3);
+ assert_eq!(cache.current_size_bytes(), 1800);
+
+ // Shrink limit to 1300 — should evict the LRU entry
+ cache.set_max_bytes(1300);
+ assert_eq!(cache.entry_count(), 2);
+ assert_eq!(cache.current_size_bytes(), 1200);
+ }
+
+ #[test]
+ fn test_extension_preservation() {
+ let dir = tempdir().unwrap();
+ let source_dir = dir.path().join("source");
+ let cache_dir = dir.path().join("cache");
+ std::fs::create_dir_all(&source_dir).unwrap();
+
+ let cache = NetworkFileCache::new(cache_dir, 1024 * 1024).unwrap();
+
+ let f = create_test_file(&source_dir, "track.flac", 100);
+ let cached = cache.get_or_cache(f.to_str().unwrap()).unwrap();
+
+ assert_eq!(cached.extension().and_then(|e| e.to_str()), Some("flac"));
+ }
+
+ #[test]
+ fn test_no_extension() {
+ let dir = tempdir().unwrap();
+ let source_dir = dir.path().join("source");
+ let cache_dir = dir.path().join("cache");
+ std::fs::create_dir_all(&source_dir).unwrap();
+
+ let cache = NetworkFileCache::new(cache_dir, 1024 * 1024).unwrap();
+
+ let f = create_test_file(&source_dir, "noext", 100);
+ let cached = cache.get_or_cache(f.to_str().unwrap()).unwrap();
+
+ assert!(cached.extension().is_none());
+ }
+
+ #[test]
+ fn test_rebuild_index() {
+ let dir = tempdir().unwrap();
+ let source_dir = dir.path().join("source");
+ let cache_dir = dir.path().join("cache");
+ std::fs::create_dir_all(&source_dir).unwrap();
+
+ // Create a cache and populate it
+ let cache = NetworkFileCache::new(cache_dir.clone(), 1024 * 1024).unwrap();
+ let f1 = create_test_file(&source_dir, "a.mp3", 300);
+ let f2 = create_test_file(&source_dir, "b.flac", 500);
+ cache.get_or_cache(f1.to_str().unwrap()).unwrap();
+ cache.get_or_cache(f2.to_str().unwrap()).unwrap();
+ assert_eq!(cache.entry_count(), 2);
+
+ // Create a new cache instance from the same directory
+ let cache2 = NetworkFileCache::new(cache_dir, 1024 * 1024).unwrap();
+ assert_eq!(cache2.entry_count(), 2);
+ assert_eq!(cache2.current_size_bytes(), 800);
+ }
+
+ #[test]
+ fn test_stale_entry_recovery() {
+ let dir = tempdir().unwrap();
+ let source_dir = dir.path().join("source");
+ let cache_dir = dir.path().join("cache");
+ std::fs::create_dir_all(&source_dir).unwrap();
+
+ let cache = NetworkFileCache::new(cache_dir, 1024 * 1024).unwrap();
+ let f = create_test_file(&source_dir, "song.mp3", 200);
+ let source_str = f.to_str().unwrap();
+
+ let cached = cache.get_or_cache(source_str).unwrap();
+ assert!(cached.exists());
+
+ // Externally delete the cached file
+ std::fs::remove_file(&cached).unwrap();
+
+ // Should detect the missing file and re-cache
+ let cached2 = cache.get_or_cache(source_str).unwrap();
+ assert!(cached2.exists());
+ }
+
+ #[test]
+ fn test_cache_key_deterministic() {
+ let k1 = NetworkFileCache::cache_key("/music/song.mp3");
+ let k2 = NetworkFileCache::cache_key("/music/song.mp3");
+ assert_eq!(k1, k2);
+ }
+
+ #[test]
+ fn test_cache_key_different_for_different_paths() {
+ let k1 = NetworkFileCache::cache_key("/music/a.mp3");
+ let k2 = NetworkFileCache::cache_key("/music/b.mp3");
+ assert_ne!(k1, k2);
+ }
+}
diff --git a/crates/mt-tauri/src/commands/audio.rs b/crates/mt-tauri/src/commands/audio.rs
index 574c586..af15183 100644
--- a/crates/mt-tauri/src/commands/audio.rs
+++ b/crates/mt-tauri/src/commands/audio.rs
@@ -1,10 +1,13 @@
use crate::audio::{AudioEngine, PlaybackState, TrackInfo};
+use crate::cache::NetworkFileCache;
+use crate::cache::mount_detect::is_network_mount;
use serde::{Deserialize, Serialize};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager, State};
-use tracing::{debug, error};
+use tauri_plugin_store::StoreExt;
+use tracing::{debug, error, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybackStatus {
@@ -237,27 +240,65 @@ fn audio_thread(rx: Receiver, app: AppHandle) {
}
}
-#[tracing::instrument(skip(state))]
+/// If network caching is enabled and the path is on a network mount,
+/// copy the file to the local cache and return the cached path.
+/// Otherwise return the original path unchanged.
+fn resolve_cached_path(path: &str, cache: &State, app: &AppHandle) -> String {
+ let enabled = app
+ .store("settings.json")
+ .ok()
+ .and_then(|s| s.get("network_cache_enabled"))
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+
+ if !enabled {
+ return path.to_string();
+ }
+
+ if !is_network_mount(path) {
+ return path.to_string();
+ }
+
+ match cache.get_or_cache(path) {
+ Ok(cached) => {
+ let cached_str = cached.to_string_lossy().to_string();
+ debug!(source = path, cached = %cached_str, "Using cached network file");
+ cached_str
+ }
+ Err(e) => {
+ warn!(source = path, error = %e, "Failed to cache network file, using original");
+ path.to_string()
+ }
+ }
+}
+
+#[tracing::instrument(skip(state, cache, app))]
#[tauri::command]
pub(crate) fn audio_load(
path: String,
track_id: Option,
state: State,
+ cache: State,
+ app: AppHandle,
) -> Result {
+ let resolved = resolve_cached_path(&path, &cache, &app);
let (tx, rx) = mpsc::channel();
- state.send_command(AudioCommand::Load(path, track_id, tx));
+ state.send_command(AudioCommand::Load(resolved, track_id, tx));
rx.recv().map_err(|_| "Channel closed".to_string())?
}
-#[tracing::instrument(skip(state))]
+#[tracing::instrument(skip(state, cache, app))]
#[tauri::command]
pub(crate) fn audio_load_and_play(
path: String,
track_id: Option,
state: State,
+ cache: State,
+ app: AppHandle,
) -> Result {
+ let resolved = resolve_cached_path(&path, &cache, &app);
let (tx, rx) = mpsc::channel();
- state.send_command(AudioCommand::LoadAndPlay(path, track_id, tx));
+ state.send_command(AudioCommand::LoadAndPlay(resolved, track_id, tx));
rx.recv().map_err(|_| "Channel closed".to_string())?
}
@@ -323,6 +364,55 @@ pub(crate) fn audio_get_status(state: State) -> PlaybackStatus {
})
}
+#[derive(Serialize)]
+pub(crate) struct CacheStatusResponse {
+ pub enabled: bool,
+ pub persistent: bool,
+ pub max_bytes: u64,
+ pub used_bytes: u64,
+ pub file_count: usize,
+}
+
+#[tracing::instrument(skip(cache, app))]
+#[tauri::command]
+pub(crate) fn network_cache_status(
+ cache: State,
+ app: AppHandle,
+) -> Result {
+ let store = app
+ .store("settings.json")
+ .map_err(|e| format!("Failed to open settings store: {}", e))?;
+
+ let enabled = store
+ .get("network_cache_enabled")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ let persistent = store
+ .get("network_cache_persistent")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ let max_gb = store
+ .get("network_cache_max_gb")
+ .and_then(|v| v.as_f64())
+ .unwrap_or(2.0);
+
+ Ok(CacheStatusResponse {
+ enabled,
+ persistent,
+ max_bytes: (max_gb * 1_073_741_824.0) as u64,
+ used_bytes: cache.current_size_bytes(),
+ file_count: cache.entry_count(),
+ })
+}
+
+#[tracing::instrument(skip(cache))]
+#[tauri::command]
+pub(crate) fn network_cache_purge(cache: State) -> Result<(), String> {
+ cache
+ .purge()
+ .map_err(|e| format!("Failed to purge cache: {}", e))
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/mt-tauri/src/commands/mod.rs b/crates/mt-tauri/src/commands/mod.rs
index cd81977..1f8cfd2 100644
--- a/crates/mt-tauri/src/commands/mod.rs
+++ b/crates/mt-tauri/src/commands/mod.rs
@@ -7,7 +7,8 @@ mod settings;
pub(crate) use audio::{
AudioState, audio_get_status, audio_get_volume, audio_load, audio_load_and_play, audio_pause,
- audio_play, audio_seek, audio_set_volume, audio_stop,
+ audio_play, audio_seek, audio_set_volume, audio_stop, network_cache_purge,
+ network_cache_status,
};
pub(crate) use favorites::{
diff --git a/crates/mt-tauri/src/lib.rs b/crates/mt-tauri/src/lib.rs
index 0ed0153..c3f3c85 100644
--- a/crates/mt-tauri/src/lib.rs
+++ b/crates/mt-tauri/src/lib.rs
@@ -1,4 +1,5 @@
pub mod audio;
+pub(crate) mod cache;
pub(crate) mod commands;
pub(crate) mod db;
pub(crate) mod dialog;
@@ -14,6 +15,7 @@ pub(crate) mod watcher;
#[cfg(test)]
mod concurrency_test;
+use cache::NetworkFileCache;
use commands::{
AudioState, audio_get_status, audio_get_volume, audio_load, audio_load_and_play, audio_pause,
audio_play, audio_seek, audio_set_volume, audio_stop, favorites_add, favorites_check,
@@ -22,10 +24,10 @@ use commands::{
lastfm_disconnect, lastfm_get_auth_url, lastfm_get_settings, lastfm_import_loved_tracks,
lastfm_loved_stats, lastfm_match_loved_tracks, lastfm_now_playing, lastfm_queue_retry,
lastfm_queue_status, lastfm_reset_loved_cache, lastfm_scrobble, lastfm_update_settings,
- match_loved_tracks_impl, playlist_add_tracks, playlist_create, playlist_delete,
- playlist_generate_name, playlist_get, playlist_list, playlist_remove_track,
- playlist_reorder_tracks, playlist_update, playlists_reorder, queue_add, queue_add_files,
- queue_clear, queue_get, queue_get_playback_state, queue_remove, queue_reorder,
+ match_loved_tracks_impl, network_cache_purge, network_cache_status, playlist_add_tracks,
+ playlist_create, playlist_delete, playlist_generate_name, playlist_get, playlist_list,
+ playlist_remove_track, playlist_reorder_tracks, playlist_update, playlists_reorder, queue_add,
+ queue_add_files, queue_clear, queue_get, queue_get_playback_state, queue_remove, queue_reorder,
queue_set_current_index, queue_set_loop, queue_set_shuffle, queue_shuffle, settings_get,
settings_get_all, settings_reset, settings_set, settings_update,
};
@@ -47,6 +49,7 @@ use serde::Serialize;
use std::time::Duration;
use tauri::{Emitter, Manager, State};
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut};
+use tauri_plugin_store::StoreExt;
use tokio::io::AsyncWriteExt;
use tracing::{debug, error, info, warn};
use watcher::{
@@ -417,6 +420,8 @@ pub fn run() {
settings_set,
settings_update,
settings_reset,
+ network_cache_status,
+ network_cache_purge,
])
.setup(|app| {
// Initialize database
@@ -440,6 +445,36 @@ pub fn run() {
app.manage(artwork_cache);
debug!("Artwork cache initialized (LRU, capacity: 50)");
+ // Initialize network file cache for SMB/NFS mounts
+ let cache_dir = app.path().app_data_dir()
+ .expect("Failed to get app data directory")
+ .join("network_cache");
+ // Default 2 GB; actual limit is read from settings at runtime via set_max_bytes
+ let default_max_bytes: u64 = 2 * 1024 * 1024 * 1024;
+ match NetworkFileCache::new(cache_dir, default_max_bytes) {
+ Ok(network_cache) => {
+ // Apply persisted max_bytes from settings if available
+ if let Ok(store) = app.store("settings.json")
+ && let Some(max_gb) =
+ store.get("network_cache_max_gb").and_then(|v| v.as_f64())
+ {
+ network_cache.set_max_bytes((max_gb * 1_073_741_824.0) as u64);
+ }
+ let count = network_cache.entry_count();
+ let bytes = network_cache.current_size_bytes();
+ app.manage(network_cache);
+ info!(entries = count, bytes, "Network file cache initialized");
+ }
+ Err(e) => {
+ warn!(error = %e, "Failed to initialize network file cache; creating empty fallback");
+ // Create a fallback with a temp dir so the app still runs
+ let fallback_dir = std::env::temp_dir().join("mt_network_cache_fallback");
+ let fallback = NetworkFileCache::new(fallback_dir, default_max_bytes)
+ .expect("Failed to create fallback network cache");
+ app.manage(fallback);
+ }
+ }
+
// Pass database clone to watcher manager
let watcher = WatcherManager::new(app.handle().clone(), database_for_watcher);
app.manage(watcher);
@@ -571,6 +606,27 @@ pub fn run() {
.on_window_event(|_window, _event| {
// Window event handler (sidecar removed in migration)
})
- .run(tauri::generate_context!())
- .expect("error while running tauri application");
+ .build(tauri::generate_context!())
+ .expect("error while building tauri application")
+ .run(|app_handle, event| {
+ if let tauri::RunEvent::Exit = event {
+ // Purge non-persistent cache on exit
+ let persistent = app_handle
+ .store("settings.json")
+ .ok()
+ .and_then(|s| s.get("network_cache_persistent"))
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+
+ if !persistent
+ && let Some(cache) = app_handle.try_state::()
+ {
+ if let Err(e) = cache.purge() {
+ error!(error = %e, "Failed to purge network cache on exit");
+ } else {
+ info!("Non-persistent network cache purged on exit");
+ }
+ }
+ }
+ });
}