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 0000000..17e132c --- /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 b431aad..04b0deb 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 52aefac..bacd7a8 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 0f5d447..6658b44 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 0000000..6f1bdc1 --- /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 dbbdeb8..a349cb4 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 95afe38..dee4ad3 100644 --- a/app/frontend/views/modals.html +++ b/app/frontend/views/modals.html @@ -361,6 +361,91 @@
+ Would you like to watch these folders for changes? +
+