From 19c354819d16d9ed95c3c32923431045b2e13b3b Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Sat, 14 Feb 2026 06:34:55 -0600 Subject: [PATCH 1/2] feat: Add watched folder confirmation when importing music files When users drag-and-drop files or use the pill button to add music to the library, they are now prompted to add the parent directories to watched folders. The confirmation modal shows all candidate directories (both immediate and parent levels) with checkboxes, allowing users to select which folders to watch. Features: - Extract parent directories from imported file/folder paths (shows both levels as options) - Classify directories as already-watched or new to avoid redundant additions - Prompt user with confirmation modal before adding to watched folders - Auto-refresh Settings view when watched folders are added via the confirmation flow - Settings and UI stay in sync across the app Fixes critical watcher bug: - Scoped fingerprint queries in watcher.rs to only load tracks within the watched folder being scanned - Previously, adding a watched folder would mark all other library tracks as missing due to full-library fingerprint scope mismatch Tests: - 27 unit tests covering directory extraction, classification, and confirmation flow - All 273 frontend tests passing Co-Authored-By: Claude Haiku 4.5 --- .../__tests__/watched-folders.utils.test.js | 279 ++++++++++++++++++ app/frontend/js/components/settings-view.js | 4 + app/frontend/js/stores/library.js | 2 + app/frontend/js/stores/ui.js | 19 ++ app/frontend/js/utils/watched-folders.js | 93 ++++++ app/frontend/main.js | 75 +++-- app/frontend/views/modals.html | 85 ++++++ crates/mt-tauri/src/watcher.rs | 7 +- 8 files changed, 537 insertions(+), 27 deletions(-) create mode 100644 app/frontend/__tests__/watched-folders.utils.test.js create mode 100644 app/frontend/js/utils/watched-folders.js diff --git a/app/frontend/__tests__/watched-folders.utils.test.js b/app/frontend/__tests__/watched-folders.utils.test.js new file mode 100644 index 00000000..17e132ce --- /dev/null +++ b/app/frontend/__tests__/watched-folders.utils.test.js @@ -0,0 +1,279 @@ +/** + * Unit tests for watched-folders utility functions + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + classifyDirectories, + extractParentDirectories, + promptWatchedFolderConfirmation, +} from '../js/utils/watched-folders.js'; + +describe('extractParentDirectories', () => { + it('returns immediate parent and grandparent for audio files', () => { + const paths = ['/Users/lance/Music/Media.localized/Beginbot/song.m4a']; + const result = extractParentDirectories(paths); + expect(result).toContain('/Users/lance/Music/Media.localized/Beginbot'); + expect(result).toContain('/Users/lance/Music/Media.localized'); + expect(result).toHaveLength(2); + }); + + it('returns directory itself and its parent for directory paths', () => { + const paths = ['/Users/lance/Music/Media.localized/Beginbot']; + const result = extractParentDirectories(paths); + expect(result).toContain('/Users/lance/Music/Media.localized/Beginbot'); + expect(result).toContain('/Users/lance/Music/Media.localized'); + expect(result).toHaveLength(2); + }); + + it('deduplicates directories from multiple files in the same folder', () => { + const paths = [ + '/Music/Artist/track1.mp3', + '/Music/Artist/track2.mp3', + ]; + const result = extractParentDirectories(paths); + expect(result).toContain('/Music/Artist'); + expect(result).toContain('/Music'); + expect(result).toHaveLength(2); + }); + + it('handles files from multiple different directories', () => { + const paths = [ + '/Music/Rock/song.mp3', + '/Music/Jazz/track.flac', + ]; + const result = extractParentDirectories(paths); + expect(result).toContain('/Music/Rock'); + expect(result).toContain('/Music/Jazz'); + expect(result).toContain('/Music'); + expect(result).toHaveLength(3); + }); + + it('handles all supported audio extensions', () => { + const extensions = ['mp3', 'flac', 'm4a', 'ogg', 'wav', 'aac', 'wma', 'opus']; + for (const ext of extensions) { + const result = extractParentDirectories([`/Music/Artist/song.${ext}`]); + expect(result).toContain('/Music/Artist'); + expect(result).toContain('/Music'); + } + }); + + it('handles case-insensitive extensions', () => { + const result = extractParentDirectories(['/Music/Artist/song.MP3']); + expect(result).toContain('/Music/Artist'); + expect(result).toContain('/Music'); + }); + + it('returns empty array for root-level paths', () => { + const result = extractParentDirectories(['/song.mp3']); + expect(result).toEqual([]); + }); + + it('returns empty array for empty input', () => { + expect(extractParentDirectories([])).toEqual([]); + }); + + it('skips paths without slashes', () => { + const result = extractParentDirectories(['song.mp3']); + expect(result).toEqual([]); + }); + + it('does not go above root for shallow paths', () => { + const result = extractParentDirectories(['/Music/song.mp3']); + // Immediate parent is /Music, grandparent would be / which is excluded (parentSlash <= 0) + expect(result).toContain('/Music'); + expect(result).toHaveLength(1); + }); +}); + +describe('classifyDirectories', () => { + it('marks exact matches as already watched', () => { + const result = classifyDirectories( + ['/Music/Rock'], + [{ path: '/Music/Rock' }], + ); + expect(result).toEqual([{ path: '/Music/Rock', alreadyWatched: true }]); + }); + + it('marks subdirectories of watched folders as already watched', () => { + const result = classifyDirectories( + ['/Music/Rock/Album'], + [{ path: '/Music' }], + ); + expect(result).toEqual([{ path: '/Music/Rock/Album', alreadyWatched: true }]); + }); + + it('marks directories not under watched folders as new', () => { + const result = classifyDirectories( + ['/Downloads/Music'], + [{ path: '/Music' }], + ); + expect(result).toEqual([{ path: '/Downloads/Music', alreadyWatched: false }]); + }); + + it('does not match partial path prefixes', () => { + // /MusicExtra should NOT be considered a subdirectory of /Music + const result = classifyDirectories( + ['/MusicExtra'], + [{ path: '/Music' }], + ); + expect(result).toEqual([{ path: '/MusicExtra', alreadyWatched: false }]); + }); + + it('handles empty watched folders list', () => { + const result = classifyDirectories(['/Music', '/Downloads'], []); + expect(result).toEqual([ + { path: '/Music', alreadyWatched: false }, + { path: '/Downloads', alreadyWatched: false }, + ]); + }); + + it('handles empty directories list', () => { + const result = classifyDirectories([], [{ path: '/Music' }]); + expect(result).toEqual([]); + }); + + it('classifies mixed directories correctly', () => { + const result = classifyDirectories( + ['/Music/Rock', '/Music', '/Downloads/New'], + [{ path: '/Music' }], + ); + expect(result).toEqual([ + { path: '/Music/Rock', alreadyWatched: true }, + { path: '/Music', alreadyWatched: true }, + { path: '/Downloads/New', alreadyWatched: false }, + ]); + }); +}); + +describe('promptWatchedFolderConfirmation', () => { + let mockInvoke; + let mockUi; + + beforeEach(() => { + mockInvoke = vi.fn(); + mockUi = { + showWatchedFolderConfirm: vi.fn(), + toast: vi.fn(), + }; + + global.window = { + __TAURI__: { + core: { invoke: mockInvoke }, + }, + Alpine: { + store: vi.fn((name) => { + if (name === 'ui') return mockUi; + }), + }, + dispatchEvent: vi.fn(), + CustomEvent: class CustomEvent { + constructor(type) { + this.type = type; + } + }, + }; + + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'watched_folders_list') return Promise.resolve([]); + if (cmd === 'watched_folders_add') return Promise.resolve({ id: 1 }); + return Promise.resolve(null); + }); + }); + + it('does nothing when __TAURI__ is not available', async () => { + global.window.__TAURI__ = undefined; + await promptWatchedFolderConfirmation(['/Music/Artist/song.mp3']); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it('does nothing when paths produce no directories', async () => { + await promptWatchedFolderConfirmation([]); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it('skips when all directories are already watched', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'watched_folders_list') { + return Promise.resolve([{ path: '/Music' }]); + } + return Promise.resolve(null); + }); + + await promptWatchedFolderConfirmation(['/Music/Artist/song.mp3']); + expect(mockUi.showWatchedFolderConfirm).not.toHaveBeenCalled(); + }); + + it('shows confirmation dialog with classified directories', async () => { + mockUi.showWatchedFolderConfirm.mockResolvedValue([]); + + await promptWatchedFolderConfirmation(['/Music/Artist/song.mp3']); + + expect(mockUi.showWatchedFolderConfirm).toHaveBeenCalledWith([ + { path: '/Music/Artist', alreadyWatched: false }, + { path: '/Music', alreadyWatched: false }, + ]); + }); + + it('adds selected folders via invoke', async () => { + mockUi.showWatchedFolderConfirm.mockResolvedValue(['/Music']); + + await promptWatchedFolderConfirmation(['/Music/Artist/song.mp3']); + + expect(mockInvoke).toHaveBeenCalledWith('watched_folders_add', { + request: { path: '/Music', mode: 'continuous', cadence_minutes: 10, enabled: true }, + }); + }); + + it('shows toast after adding folders', async () => { + mockUi.showWatchedFolderConfirm.mockResolvedValue(['/Music']); + + await promptWatchedFolderConfirmation(['/Music/Artist/song.mp3']); + + expect(mockUi.toast).toHaveBeenCalledWith('Added 1 folder to watch list', 'success'); + }); + + it('dispatches mt:watched-folders-updated event after adding', async () => { + mockUi.showWatchedFolderConfirm.mockResolvedValue(['/Music']); + + await promptWatchedFolderConfirmation(['/Music/Artist/song.mp3']); + + expect(global.window.dispatchEvent).toHaveBeenCalled(); + }); + + it('does not add folders when user skips', async () => { + mockUi.showWatchedFolderConfirm.mockResolvedValue([]); + + await promptWatchedFolderConfirmation(['/Music/Artist/song.mp3']); + + expect(mockInvoke).not.toHaveBeenCalledWith('watched_folders_add', expect.anything()); + expect(mockUi.toast).not.toHaveBeenCalled(); + }); + + it('handles plural toast message for multiple folders', async () => { + mockUi.showWatchedFolderConfirm.mockResolvedValue(['/Music', '/Downloads']); + + await promptWatchedFolderConfirmation(['/Music/Artist/song.mp3']); + + expect(mockUi.toast).toHaveBeenCalledWith('Added 2 folders to watch list', 'success'); + }); + + it('continues adding remaining folders if one fails', async () => { + mockInvoke.mockImplementation((cmd, args) => { + if (cmd === 'watched_folders_list') return Promise.resolve([]); + if (cmd === 'watched_folders_add') { + if (args.request.path === '/Music/Artist') { + return Promise.reject(new Error('duplicate')); + } + return Promise.resolve({ id: 1 }); + } + return Promise.resolve(null); + }); + + mockUi.showWatchedFolderConfirm.mockResolvedValue(['/Music/Artist', '/Music']); + + await promptWatchedFolderConfirmation(['/Music/Artist/song.mp3']); + + expect(mockUi.toast).toHaveBeenCalledWith('Added 1 folder to watch list', 'success'); + }); +}); diff --git a/app/frontend/js/components/settings-view.js b/app/frontend/js/components/settings-view.js index b431aadb..04b0deb5 100644 --- a/app/frontend/js/components/settings-view.js +++ b/app/frontend/js/components/settings-view.js @@ -54,6 +54,10 @@ export function createSettingsView(Alpine) { await this.loadWatchedFolders(); await this.loadLastfmSettings(); this.loadColumnSettings(); + + window.addEventListener('mt:watched-folders-updated', () => { + this.loadWatchedFolders(); + }); }, async loadAppInfo() { diff --git a/app/frontend/js/stores/library.js b/app/frontend/js/stores/library.js index 52aefacb..bacd7a8f 100644 --- a/app/frontend/js/stores/library.js +++ b/app/frontend/js/stores/library.js @@ -6,6 +6,7 @@ */ import { api } from '../api.js'; +import { promptWatchedFolderConfirmation } from '../utils/watched-folders.js'; const { listen } = window.__TAURI__?.event ?? { listen: () => Promise.resolve(() => {}) }; @@ -863,6 +864,7 @@ export function createLibraryStore(Alpine) { `Added ${result.added} track${result.added === 1 ? '' : 's'} to library`, 'success', ); + promptWatchedFolderConfirmation(pathArray); } else if (result.skipped > 0) { ui.toast( `All ${result.skipped} track${result.skipped === 1 ? '' : 's'} already in library`, diff --git a/app/frontend/js/stores/ui.js b/app/frontend/js/stores/ui.js index 0f5d447e..6658b444 100644 --- a/app/frontend/js/stores/ui.js +++ b/app/frontend/js/stores/ui.js @@ -457,6 +457,25 @@ export function createUIStore(Alpine) { } this.missingTrackModal = null; }, + + // Watched folder confirmation dialog + watchedFolderConfirm: null, + _watchedFolderResolve: null, + + showWatchedFolderConfirm(directories) { + return new Promise((resolve) => { + this._watchedFolderResolve = resolve; + this.watchedFolderConfirm = { directories }; + }); + }, + + closeWatchedFolderConfirm(selectedPaths = []) { + if (this._watchedFolderResolve) { + this._watchedFolderResolve(selectedPaths); + this._watchedFolderResolve = null; + } + this.watchedFolderConfirm = null; + }, }); // Setup watchers to sync store changes to backend settings diff --git a/app/frontend/js/utils/watched-folders.js b/app/frontend/js/utils/watched-folders.js new file mode 100644 index 00000000..6f1bdc1e --- /dev/null +++ b/app/frontend/js/utils/watched-folders.js @@ -0,0 +1,93 @@ +/** + * Utility functions for watched folder confirmation when adding files to the library. + */ + +const AUDIO_EXTENSIONS = /\.(mp3|flac|m4a|ogg|wav|aac|wma|opus)$/i; + +/** + * Extract unique parent directories from a list of file/folder paths. + * For files (audio extensions), uses the parent directory. + * For directories (no audio extension), uses the path itself. + * @param {string[]} paths + * @returns {string[]} Unique parent directory paths + */ +export function extractParentDirectories(paths) { + const dirs = new Set(); + for (const p of paths) { + const lastSlash = p.lastIndexOf('/'); + if (lastSlash <= 0) continue; + const basename = p.substring(lastSlash + 1); + // For audio files, the immediate parent is the containing folder (e.g. artist/album dir) + // For directories, it's the path itself + const dir = AUDIO_EXTENSIONS.test(basename) ? p.substring(0, lastSlash) : p; + dirs.add(dir); + // Also include the grandparent (e.g. music root) as an option + const parentSlash = dir.lastIndexOf('/'); + if (parentSlash > 0) { + dirs.add(dir.substring(0, parentSlash)); + } + } + return Array.from(dirs); +} + +/** + * Classify directories as already-watched or new. + * A directory is "already watched" if it matches or is a subdirectory of any watched folder. + * @param {string[]} directories - Candidate directories + * @param {Array<{path: string}>} watchedFolders - Existing watched folders + * @returns {Array<{path: string, alreadyWatched: boolean}>} + */ +export function classifyDirectories(directories, watchedFolders) { + const watchedPaths = watchedFolders.map((f) => f.path); + return directories.map((dir) => ({ + path: dir, + alreadyWatched: watchedPaths.some( + (wp) => dir === wp || dir.startsWith(wp + '/'), + ), + })); +} + +/** + * Prompt the user to add parent directories of imported files to watched folders. + * Skips silently if all directories are already watched. + * @param {string[]} paths - File or directory paths that were added to the library + */ +export async function promptWatchedFolderConfirmation(paths) { + if (!window.__TAURI__) return; + + try { + const { invoke } = window.__TAURI__.core; + const directories = extractParentDirectories(paths); + if (directories.length === 0) return; + + const watchedFolders = await invoke('watched_folders_list'); + const classified = classifyDirectories(directories, watchedFolders); + + if (classified.every((d) => d.alreadyWatched)) return; + + const Alpine = window.Alpine; + const ui = Alpine.store('ui'); + const selectedPaths = await ui.showWatchedFolderConfirm(classified); + + if (!selectedPaths || selectedPaths.length === 0) return; + + let added = 0; + for (const path of selectedPaths) { + try { + await invoke('watched_folders_add', { + request: { path, mode: 'continuous', cadence_minutes: 10, enabled: true }, + }); + added++; + } catch (err) { + console.warn('[watched-folders] Failed to add watched folder:', path, err); + } + } + + if (added > 0) { + ui.toast(`Added ${added} folder${added === 1 ? '' : 's'} to watch list`, 'success'); + window.dispatchEvent(new CustomEvent('mt:watched-folders-updated')); + } + } catch (error) { + console.error('[watched-folders] Confirmation prompt failed:', error); + } +} diff --git a/app/frontend/main.js b/app/frontend/main.js index dbbdeb85..a349cb40 100644 --- a/app/frontend/main.js +++ b/app/frontend/main.js @@ -6,7 +6,8 @@ import { initStores } from './js/stores/index.js'; import { initComponents } from './js/components/index.js'; import { initKeyboardShortcuts } from './js/shortcuts.js'; import api from './js/api.js'; -import { formatTime, formatDuration, formatBytes } from './js/utils/formatting.js'; +import { formatBytes, formatDuration, formatTime } from './js/utils/formatting.js'; +import { promptWatchedFolderConfirmation } from './js/utils/watched-folders.js'; import { settings } from './js/services/settings.js'; import './styles.css'; @@ -27,7 +28,7 @@ window._mtInternalDragActive = false; window._mtDragJustEnded = false; window._mtDraggedTrackIds = null; -window.handleFileDrop = async function(event) { +window.handleFileDrop = async function (event) { console.log('[main] Browser drop event (Tauri handles via native events)'); }; @@ -36,49 +37,63 @@ async function initTauriDragDrop() { console.log('[main] No Tauri environment detected'); return; } - + console.log('[main] Tauri object keys:', Object.keys(window.__TAURI__)); - + try { const { getCurrentWebview } = window.__TAURI__.webview; - + await getCurrentWebview().onDragDropEvent(async (event) => { const { type, paths, position } = event.payload; - + // Skip if internal HTML5 drag is active (e.g., dragging tracks to playlists) if (window._mtInternalDragActive) { console.log('[main] Skipping Tauri drag event - internal drag active:', type); return; } - + console.log('[main] Drag-drop event:', event); - + if (type === 'over') { console.log('[main] Drag over:', position); } else if (type === 'drop') { console.log('[main] Files dropped:', paths); - + // Handle internal track drag to playlist (Tauri intercepts HTML5 drop) if ((!paths || paths.length === 0) && window._mtDraggedTrackIds && position) { const x = position.x / window.devicePixelRatio; const y = position.y / window.devicePixelRatio; const element = document.elementFromPoint(x, y); const playlistButton = element?.closest('[data-testid^="sidebar-playlist-"]'); - + if (playlistButton) { const testId = playlistButton.dataset.testid; const playlistId = parseInt(testId.replace('sidebar-playlist-', ''), 10); const playlistName = playlistButton.querySelector('span')?.textContent || 'playlist'; - console.log('[main] Internal drop on playlist:', playlistId, playlistName, 'tracks:', window._mtDraggedTrackIds); - + console.log( + '[main] Internal drop on playlist:', + playlistId, + playlistName, + 'tracks:', + window._mtDraggedTrackIds, + ); + try { const result = await api.playlists.addTracks(playlistId, window._mtDraggedTrackIds); const ui = Alpine.store('ui'); - + if (result.added > 0) { - ui.toast(`Added ${result.added} track${result.added > 1 ? 's' : ''} to "${playlistName}"`, 'success'); + ui.toast( + `Added ${result.added} track${result.added > 1 ? 's' : ''} to "${playlistName}"`, + 'success', + ); } else { - ui.toast(`Track${window._mtDraggedTrackIds.length > 1 ? 's' : ''} already in "${playlistName}"`, 'info'); + ui.toast( + `Track${ + window._mtDraggedTrackIds.length > 1 ? 's' : '' + } already in "${playlistName}"`, + 'info', + ); } window.dispatchEvent(new CustomEvent('mt:playlists-updated')); } catch (error) { @@ -89,15 +104,22 @@ async function initTauriDragDrop() { return; } } - + if (paths && paths.length > 0) { try { const result = await Alpine.store('library').scan(paths); const ui = Alpine.store('ui'); if (result.added > 0) { - ui.toast(`Added ${result.added} track${result.added === 1 ? '' : 's'} to library`, 'success'); + ui.toast( + `Added ${result.added} track${result.added === 1 ? '' : 's'} to library`, + 'success', + ); + promptWatchedFolderConfirmation(paths); } else if (result.skipped > 0) { - ui.toast(`All ${result.skipped} track${result.skipped === 1 ? '' : 's'} already in library`, 'info'); + ui.toast( + `All ${result.skipped} track${result.skipped === 1 ? '' : 's'} already in library`, + 'info', + ); } else { ui.toast('No audio files found', 'info'); } @@ -110,18 +132,21 @@ async function initTauriDragDrop() { console.log('[main] Drag cancelled'); } }); - + console.log('[main] Tauri drag-drop listener initialized'); } catch (error) { console.error('[main] Failed to initialize Tauri drag-drop:', error); } } -window.testDialog = async function() { +window.testDialog = async function () { console.log('[test] Testing dialog...'); - console.log('[test] window.__TAURI__:', window.__TAURI__ ? Object.keys(window.__TAURI__) : 'undefined'); + console.log( + '[test] window.__TAURI__:', + window.__TAURI__ ? Object.keys(window.__TAURI__) : 'undefined', + ); console.log('[test] window.__TAURI__.dialog:', window.__TAURI__?.dialog); - + if (window.__TAURI__?.dialog?.open) { try { const result = await window.__TAURI__.dialog.open({ directory: true, multiple: true }); @@ -140,14 +165,14 @@ function initGlobalKeyboardShortcuts() { async function initTitlebarDrag() { if (!window.__TAURI__) return; - + const dragRegion = document.querySelector('[data-tauri-drag-region]'); if (!dragRegion) return; - + try { const { getCurrentWindow } = window.__TAURI__.window; const appWindow = getCurrentWindow(); - + dragRegion.addEventListener('mousedown', async (e) => { if (e.buttons === 1 && !e.target.closest('button, input, a')) { e.preventDefault(); diff --git a/app/frontend/views/modals.html b/app/frontend/views/modals.html index 95afe384..dee4ad33 100644 --- a/app/frontend/views/modals.html +++ b/app/frontend/views/modals.html @@ -361,6 +361,91 @@

File Not Found

+ +
+
+

Watch Folders?

+

+ Would you like to watch these folders for changes? +

+
+ +
+
+ + +
+
+
+