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 +
+
+
+ + +
+
+
+
+
Used
+
+
+
+
Files
+
+
+
+ + + +
+
+
+
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"); + } + } + } + }); }