diff --git a/app/frontend/__tests__/library-section.test.js b/app/frontend/__tests__/library-section.test.js new file mode 100644 index 0000000..fd15ceb --- /dev/null +++ b/app/frontend/__tests__/library-section.test.js @@ -0,0 +1,165 @@ +/** + * Tests for unified library_get_section integration. + * + * Verifies that applySectionData and buildCacheEntry correctly use + * authoritative stats from the backend response (total_tracks, total_duration) + * instead of computing them from the tracks array. + */ + +import { describe, expect, it } from 'vitest'; + +// Mock window and Alpine before importing modules that depend on them +globalThis.window = { + ...globalThis.window, + Alpine: { + disableEffectScheduling: (fn) => fn(), + }, + __TAURI__: undefined, +}; + +// Dynamic import to ensure window mock is set up first +const { applySectionData } = await import('../js/utils/library-operations.js'); +const { buildCacheEntry } = await import('../js/utils/library-cache.js'); + +// --------------------------------------------------------------------------- +// applySectionData +// --------------------------------------------------------------------------- + +describe('applySectionData', () => { + function createMockStore() { + return { + totalTracks: 0, + totalDuration: 0, + _lastLoadedSection: null, + _sectionTracks: null, + _setSectionTracks(tracks) { + this._sectionTracks = tracks; + }, + }; + } + + it('uses total_tracks and total_duration from backend response', () => { + const store = createMockStore(); + const tracks = [ + { id: 1, duration: 100 }, + { id: 2, duration: 200 }, + ]; + const data = { + total_tracks: 42, + total_duration: 12345.5, + }; + + applySectionData(store, 'all', tracks, data); + + expect(store.totalTracks).toBe(42); + expect(store.totalDuration).toBe(12345.5); + expect(store._lastLoadedSection).toBe('all'); + expect(store._sectionTracks).toEqual(tracks); + }); + + it('falls back to total field when total_tracks is absent', () => { + const store = createMockStore(); + const tracks = [{ id: 1, duration: 60 }]; + const data = { total: 10 }; + + applySectionData(store, 'liked', tracks, data); + + expect(store.totalTracks).toBe(10); + }); + + it('falls back to tracks.length when no total fields present', () => { + const store = createMockStore(); + const tracks = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + applySectionData(store, 'recent', tracks, {}); + + expect(store.totalTracks).toBe(3); + }); + + it('falls back to computing totalDuration from tracks when total_duration absent', () => { + const store = createMockStore(); + const tracks = [ + { id: 1, duration: 100 }, + { id: 2, duration: 200 }, + ]; + + applySectionData(store, 'added', tracks, {}); + + expect(store.totalDuration).toBe(300); + }); + + it('prefers total_duration of 0 over JS-computed fallback', () => { + const store = createMockStore(); + const tracks = [{ id: 1, duration: 100 }]; + const data = { total_duration: 0 }; + + applySectionData(store, 'top25', tracks, data); + + // total_duration: 0 is a valid authoritative value + expect(store.totalDuration).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// buildCacheEntry +// --------------------------------------------------------------------------- + +describe('buildCacheEntry', () => { + it('uses total_tracks and total_duration from backend response', () => { + const data = { + tracks: [{ duration: 100 }], + total_tracks: 500, + total_duration: 99999.0, + }; + + const entry = buildCacheEntry(data); + + expect(entry.totalTracks).toBe(500); + expect(entry.totalDuration).toBe(99999.0); + expect(entry.timestamp).toBeGreaterThan(0); + }); + + it('falls back to total field when total_tracks is absent', () => { + const data = { + tracks: [{ duration: 100 }], + total: 10, + }; + + const entry = buildCacheEntry(data); + + expect(entry.totalTracks).toBe(10); + }); + + it('falls back to tracks.length when no total fields', () => { + const data = { + tracks: [{ duration: 100 }, { duration: 200 }], + }; + + const entry = buildCacheEntry(data); + + expect(entry.totalTracks).toBe(2); + }); + + it('falls back to computing duration from tracks when total_duration absent', () => { + const data = { + tracks: [{ duration: 100 }, { duration: 200 }], + }; + + const entry = buildCacheEntry(data); + + expect(entry.totalDuration).toBe(300); + }); + + it('handles empty tracks with backend stats', () => { + const data = { + tracks: [], + total_tracks: 0, + total_duration: 0, + }; + + const entry = buildCacheEntry(data); + + expect(entry.totalTracks).toBe(0); + expect(entry.totalDuration).toBe(0); + }); +}); diff --git a/app/frontend/__tests__/library.store.test.js b/app/frontend/__tests__/library.store.test.js index 146acc9..e584631 100644 --- a/app/frontend/__tests__/library.store.test.js +++ b/app/frontend/__tests__/library.store.test.js @@ -569,13 +569,23 @@ describe('Paginated Store - _isPaginated', () => { describe('loadLibraryData - FOUC regression', () => { let loadLibraryData; + let mockGetSection; + beforeEach(async () => { vi.resetModules(); - // Mock the library API + // Mock the library API with getSection (unified endpoint) + mockGetSection = vi.fn().mockResolvedValue({ + section: 'all', + tracks: makeTracks(50), + total_tracks: 50, + total_duration: 3600, + has_more: false, + revision: 1, + }); vi.doMock('../js/api/library.js', () => ({ library: { - getCount: vi.fn().mockResolvedValue({ total: 50, total_duration: 3600 }), + getSection: mockGetSection, }, })); @@ -603,7 +613,6 @@ describe('loadLibraryData - FOUC regression', () => { store.currentSection = 'all'; store._lastLoadedSection = 'all'; store._sectionCache = {}; - store._fetchPage = vi.fn().mockResolvedValue(undefined); store._getFilterParams = () => ({}); store._updateCache = vi.fn(); return store; @@ -613,14 +622,21 @@ describe('loadLibraryData - FOUC regression', () => { const store = createLoadStore(); const statesDuringFetch = []; - // Capture state when _fetchPage is called (during the await) - store._fetchPage = vi.fn().mockImplementation(() => { + // Capture state when getSection is called (during the await) + mockGetSection.mockImplementation(() => { statesDuringFetch.push({ totalTracks: store.totalTracks, loading: store.loading, pagesEmpty: Object.keys(store._trackPages).length === 0, }); - return Promise.resolve(); + return Promise.resolve({ + section: 'all', + tracks: makeTracks(50), + total_tracks: 50, + total_duration: 3600, + has_more: false, + revision: 1, + }); }); await loadLibraryData(store); @@ -644,9 +660,16 @@ describe('loadLibraryData - FOUC regression', () => { }; const tracksSeenDuringFetch = []; - store._fetchPage = vi.fn().mockImplementation(() => { + mockGetSection.mockImplementation(() => { tracksSeenDuringFetch.push(store.totalTracks); - return Promise.resolve(); + return Promise.resolve({ + section: 'all', + tracks: makeTracks(50), + total_tracks: 50, + total_duration: 3600, + has_more: false, + revision: 1, + }); }); await loadLibraryData(store); diff --git a/app/frontend/js/api/library.js b/app/frontend/js/api/library.js index f9537ec..d60421a 100644 --- a/app/frontend/js/api/library.js +++ b/app/frontend/js/api/library.js @@ -103,6 +103,90 @@ export const library = { return request(`/library${queryString ? `?${queryString}` : ''}`); }, + /** + * Get a complete view model for any library section in a single call. + * Returns tracks, authoritative stats, pagination metadata, and a revision + * number for cache invalidation — all from the same DB transaction. + * @param {object} params + * @param {string} params.section - 'all', 'liked', 'top25', 'recent', 'added', or 'playlist-{id}' + * @param {string} [params.search] - Search query (all section only) + * @param {string} [params.artist] - Artist filter (all section only) + * @param {string} [params.album] - Album filter (all section only) + * @param {string} [params.sort] - Sort field (all section only) + * @param {string} [params.order] - Sort order (all section only) + * @param {number} [params.limit] - Page size / result limit + * @param {number} [params.offset] - Page offset (all section only) + * @param {string} [params.ignoreWords] - Ignore words for sort (all section only) + * @param {number} [params.days] - Days lookback (recent/added sections) + * @returns {Promise<{section: string, tracks: Array, total_tracks: number, total_duration: number, page: number|null, page_size: number|null, has_more: boolean, revision: number}>} + */ + async getSection(params = {}) { + if (invoke) { + try { + return await invoke('library_get_section', { + section: params.section, + search: params.search || null, + artist: params.artist || null, + album: params.album || null, + sortBy: params.sort || null, + sortOrder: params.order || null, + limit: params.limit || null, + offset: params.offset || null, + ignoreWords: params.ignoreWords || null, + days: params.days || null, + }); + } catch (error) { + console.error('[api.library.getSection] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + // HTTP fallback: dispatch to the appropriate REST endpoint per section + const section = params.section || 'all'; + + // Playlist sections use the playlists REST endpoint + const playlistMatch = section.match(/^playlist-(\d+)$/); + if (playlistMatch) { + const data = await request(`/playlists/${playlistMatch[1]}`); + const tracks = (data.tracks || []).map((item) => item.track || item); + const totalDuration = tracks.reduce((sum, t) => sum + (t.duration || 0), 0); + return { + section, + tracks, + total_tracks: tracks.length, + total_duration: totalDuration, + page: null, + page_size: null, + has_more: false, + revision: 0, + }; + } + + // All other sections compose from /library + /library/count + const query = new URLSearchParams(); + if (params.search) query.set('search', params.search); + if (params.artist) query.set('artist', params.artist); + if (params.album) query.set('album', params.album); + if (params.sort) query.set('sort_by', params.sort); + if (params.order) query.set('sort_order', params.order); + if (params.limit) query.set('limit', params.limit.toString()); + if (params.offset) query.set('offset', params.offset.toString()); + const qs = query.toString(); + const [trackData, countData] = await Promise.all([ + request(`/library${qs ? `?${qs}` : ''}`), + request(`/library/count${qs ? `?${qs}` : ''}`), + ]); + return { + section, + tracks: trackData.tracks || [], + total_tracks: countData.total ?? (trackData.tracks || []).length, + total_duration: countData.total_duration ?? 0, + page: params.offset != null ? Math.floor(params.offset / (params.limit || 50)) : null, + page_size: params.limit || null, + has_more: trackData.total > (params.offset || 0) + (trackData.tracks || []).length, + revision: 0, + }; + }, + /** * Get a single track by ID (uses Tauri command) * @param {number} id - Track ID diff --git a/app/frontend/js/api/queue.js b/app/frontend/js/api/queue.js index d3acd45..5869bef 100644 --- a/app/frontend/js/api/queue.js +++ b/app/frontend/js/api/queue.js @@ -175,7 +175,14 @@ export const queue = { throw new ApiError(500, error.toString()); } } - throw new ApiError(500, 'playContext not available in browser mode'); + return request('/queue/play-context', { + method: 'POST', + body: JSON.stringify({ + track_ids: trackIds, + start_index: startIndex, + shuffle, + }), + }); }, save(state) { diff --git a/app/frontend/js/stores/library.js b/app/frontend/js/stores/library.js index 4bd4f5a..9cf7cdc 100644 --- a/app/frontend/js/stores/library.js +++ b/app/frontend/js/stores/library.js @@ -10,8 +10,6 @@ */ import { library as libraryApi } from '../api/library.js'; -import { favorites } from '../api/favorites.js'; -import { playlists } from '../api/playlists.js'; import { buildCacheEntry, createCacheSaver, @@ -72,6 +70,7 @@ export function createLibraryStore(Alpine) { _sectionCache: {}, _backgroundRefreshing: false, _dataVersion: 0, + _lastRevision: undefined, _saveCache: null, async init() { @@ -364,7 +363,7 @@ export function createLibraryStore(Alpine) { }, loadFavorites() { - return this._loadSection('liked', () => favorites.get({ limit: 1000 })); + return this._loadSection('liked', null); }, _backgroundRefreshFavorites() { @@ -373,55 +372,35 @@ export function createLibraryStore(Alpine) { ? 'liked' : null; if (!section) return; - return this._backgroundRefreshSection( - 'liked', - () => favorites.get({ limit: 1000 }), - ); + return this._backgroundRefreshSection('liked', null); }, loadRecentlyPlayed(days = 14) { - return this._loadSection( - 'recent', - () => favorites.getRecentlyPlayed({ days, limit: 100 }), - ); + return this._loadSection('recent', null, { days }); }, _backgroundRefreshRecentlyPlayed(days = 14) { - return this._backgroundRefreshSection( - 'recent', - () => favorites.getRecentlyPlayed({ days, limit: 100 }), - ); + return this._backgroundRefreshSection('recent', null, { days }); }, loadRecentlyAdded(days = 14) { - return this._loadSection( - 'added', - () => favorites.getRecentlyAdded({ days, limit: 100 }), - ); + return this._loadSection('added', null, { days }); }, _backgroundRefreshRecentlyAdded(days = 14) { - return this._backgroundRefreshSection( - 'added', - () => favorites.getRecentlyAdded({ days, limit: 100 }), - ); + return this._backgroundRefreshSection('added', null, { days }); }, loadTop25() { - return this._loadSection('top25', () => favorites.getTop25()); + return this._loadSection('top25', null); }, _backgroundRefreshTop25() { - return this._backgroundRefreshSection( - 'top25', - () => favorites.getTop25(), - ); + return this._backgroundRefreshSection('top25', null); }, loadPlaylist(playlistId) { const section = `playlist-${playlistId}`; - const transformPlaylist = (_rawTracks, data) => - (data.tracks || []).map((item) => item.track || item); const cachePlaylist = (data) => { this._sectionCache[section] = { @@ -434,7 +413,6 @@ export function createLibraryStore(Alpine) { console.log('[navigation]', 'load_playlist_complete', { playlistId, - playlistName: data.name, trackCount: this.filteredTracks.length, }); return data; @@ -442,8 +420,8 @@ export function createLibraryStore(Alpine) { console.log('[navigation]', 'load_playlist', { playlistId }); - return this._loadSection(section, () => playlists.get(playlistId), { - transform: transformPlaylist, + // The unified endpoint returns flat Track objects for playlists + return this._loadSection(section, null, { onSuccess: cachePlaylist, logTag: 'navigation', }); @@ -451,25 +429,18 @@ export function createLibraryStore(Alpine) { _backgroundRefreshPlaylist(playlistId) { const section = `playlist-${playlistId}`; - const transformPlaylist = (_rawTracks, data) => - (data.tracks || []).map((item) => item.track || item); - - return this._backgroundRefreshSection( - section, - () => playlists.get(playlistId), - { - transform: transformPlaylist, - onSuccess: (data) => { - this._sectionCache[section] = { - totalTracks: this.totalTracks, - totalDuration: this.totalDuration, - playlistName: data.name, - timestamp: Date.now(), - }; - this._persistCache(); - }, + + return this._backgroundRefreshSection(section, null, { + onSuccess: (data) => { + this._sectionCache[section] = { + totalTracks: this.totalTracks, + totalDuration: this.totalDuration, + playlistName: data.name, + timestamp: Date.now(), + }; + this._persistCache(); }, - ); + }); }, // ----------------------------------------------------------------------- diff --git a/app/frontend/js/utils/library-cache.js b/app/frontend/js/utils/library-cache.js index ba13667..1a4b617 100644 --- a/app/frontend/js/utils/library-cache.js +++ b/app/frontend/js/utils/library-cache.js @@ -11,14 +11,20 @@ const SETTINGS_KEY = 'library:sectionCache'; /** * Compute summary stats for a section from fetched data. - * @param {{ tracks?: Array, total?: number }} data - Backend response + * + * When `data` includes `total_tracks` and `total_duration` (from the backend + * `library_get_section` response), those authoritative values are used + * directly. Otherwise falls back to computing from the tracks array. + * + * @param {{ tracks?: Array, total?: number, total_tracks?: number, total_duration?: number }} data * @returns {{ totalTracks: number, totalDuration: number, timestamp: number }} */ export function buildCacheEntry(data) { const tracks = data.tracks || []; return { - totalTracks: data.total || tracks.length, - totalDuration: tracks.reduce((sum, t) => sum + (t.duration || 0), 0), + totalTracks: data.total_tracks ?? data.total ?? tracks.length, + totalDuration: data.total_duration ?? + tracks.reduce((sum, t) => sum + (t.duration || 0), 0), timestamp: Date.now(), }; } diff --git a/app/frontend/js/utils/library-operations.js b/app/frontend/js/utils/library-operations.js index 8f595a0..df3036e 100644 --- a/app/frontend/js/utils/library-operations.js +++ b/app/frontend/js/utils/library-operations.js @@ -17,12 +17,18 @@ import { promptToAddWatchedFolders } from '../utils/watched-folders.js'; * Compute summary stats and update store state after a section fetch. * Shared between loadSection and backgroundRefreshSection. * Sets _sectionTracks for non-paginated sections. + * + * When `data` contains `total_tracks` and `total_duration` (from the + * backend `library_get_section` response), those authoritative values + * are used directly. Otherwise falls back to computing from the tracks + * array for backward compatibility. */ export function applySectionData(store, section, tracks, data) { window.Alpine.disableEffectScheduling(() => { store._setSectionTracks(tracks); - store.totalTracks = data?.total ?? tracks.length; - store.totalDuration = tracks.reduce((sum, t) => sum + (t.duration || 0), 0); + store.totalTracks = data?.total_tracks ?? data?.total ?? tracks.length; + store.totalDuration = data?.total_duration ?? + tracks.reduce((sum, t) => sum + (t.duration || 0), 0); store._lastLoadedSection = section; }); } @@ -59,6 +65,11 @@ export function getInitialSection() { /** * Load a section's tracks with cache preview and loading state. + * + * When `fetchFn` is omitted, uses `library.getSection({ section, ... })` — + * the unified backend command that returns tracks + authoritative stats in + * a single transaction. + * * @param {object} store - Library store instance * @param {string} section - Section identifier (e.g. 'liked', 'recent') * @param {Function} fetchFn - Async function returning { tracks, total?, … } @@ -66,10 +77,11 @@ export function getInitialSection() { * @param {Function} [opts.transform] - Transform raw tracks (default: store._filterByLibrary) * @param {Function} [opts.onSuccess] - Post-success callback receiving (data) * @param {string} [opts.logTag] - Log tag override (default: 'library') + * @param {number} [opts.days] - Days lookback for recent/added sections * @returns {*} Return value from onSuccess, or undefined */ export async function loadSection(store, section, fetchFn, opts = {}) { - const { transform, onSuccess, logTag = 'library' } = opts; + const { transform, onSuccess, logTag = 'library', days } = opts; const cached = store._sectionCache[section]; // Show cached summary stats if available (previous tracks stay visible during fetch) @@ -85,11 +97,42 @@ export async function loadSection(store, section, fetchFn, opts = {}) { store.loading = true; // DON'T clear tracks - keep showing previous data while loading try { - const data = await fetchFn(); + // Prefer the unified backend command when no custom fetchFn is provided + const useUnified = !fetchFn; + let data; + if (useUnified) { + data = await library.getSection({ + section, + days: days || null, + limit: 1000, + }); + } else { + data = await fetchFn(); + } + const rawTracks = data.tracks || []; - const tracks = transform ? transform(rawTracks, data) : store._filterByLibrary(rawTracks); - applySectionData(store, section, tracks, { total: tracks.length }); - store._updateCache(section, { tracks, total: tracks.length }); + // When using the unified backend command, tracks are already authoritative + // — no need to filter against the "all" section (which may not be loaded). + const tracks = transform + ? transform(rawTracks, data) + : useUnified + ? rawTracks + : store._filterByLibrary(rawTracks); + + if (useUnified) { + // Use authoritative stats from backend + applySectionData(store, section, tracks, data); + store._updateCache(section, { + total: data.total_tracks, + totalDuration: data.total_duration, + }); + if (data.revision !== undefined) { + store._lastRevision = data.revision; + } + } else { + applySectionData(store, section, tracks, { total: tracks.length }); + store._updateCache(section, { tracks, total: tracks.length }); + } if (onSuccess) { return onSuccess(data); @@ -104,25 +147,65 @@ export async function loadSection(store, section, fetchFn, opts = {}) { /** * Silently refresh a section's data in the background. + * + * When `fetchFn` is omitted, uses `library.getSection({ section, ... })` and + * skips the update if the returned revision matches the last-seen revision. + * * @param {object} store - Library store instance * @param {string} section - Section identifier * @param {Function} fetchFn - Async function returning { tracks, total?, … } * @param {Object} [opts] * @param {Function} [opts.transform] - Transform raw tracks (default: store._filterByLibrary) * @param {Function} [opts.onSuccess] - Post-success callback receiving (data) + * @param {number} [opts.days] - Days lookback for recent/added sections */ export async function backgroundRefreshSection(store, section, fetchFn, opts = {}) { if (store._backgroundRefreshing) return; store._backgroundRefreshing = true; - const { transform, onSuccess } = opts; + const { transform, onSuccess, days } = opts; try { - const data = await fetchFn(); + const useUnified = !fetchFn; + let data; + if (useUnified) { + data = await library.getSection({ + section, + days: days || null, + limit: 1000, + }); + // Skip update if revision hasn't changed + if ( + data.revision !== undefined && + store._lastRevision !== undefined && + data.revision === store._lastRevision + ) { + return; + } + } else { + data = await fetchFn(); + } + if (store._lastLoadedSection === section) { const rawTracks = data.tracks || []; - const tracks = transform ? transform(rawTracks, data) : store._filterByLibrary(rawTracks); - applySectionData(store, section, tracks, { total: tracks.length }); - store._updateCache(section, { tracks, total: tracks.length }); + const tracks = transform + ? transform(rawTracks, data) + : useUnified + ? rawTracks + : store._filterByLibrary(rawTracks); + + if (useUnified) { + applySectionData(store, section, tracks, data); + store._updateCache(section, { + total: data.total_tracks, + totalDuration: data.total_duration, + }); + if (data.revision !== undefined) { + store._lastRevision = data.revision; + } + } else { + applySectionData(store, section, tracks, { total: tracks.length }); + store._updateCache(section, { tracks, total: tracks.length }); + } if (onSuccess) onSuccess(data); } } catch (e) { @@ -138,7 +221,9 @@ export async function backgroundRefreshSection(store, section, fetchFn, opts = { /** * Load library tracks from backend using pagination. - * Fetches count + first page only; remaining pages load on demand via scroll. + * Uses the unified `library_get_section` command to fetch count + first page + * in a single transaction, ensuring consistent counts and track data. + * Remaining pages load on demand via scroll. * @param {object} store - Library store instance * @param {Object} [options] * @param {boolean} [options.forceReload=false] - Force reload even if data exists @@ -180,11 +265,20 @@ export async function loadLibraryData(store, { forceReload = false } = {}) { store._resetPages(); store.totalTracks = 0; store.totalDuration = 0; + + // Fetch first page using unified endpoint (count + tracks in one transaction) const filterParams = store._getFilterParams(); - const [countData] = await Promise.all([ - library.getCount(filterParams), - store._fetchPage(0), - ]); + const sectionData = await library.getSection({ + section: 'all', + search: filterParams.search || null, + artist: filterParams.artist || null, + album: filterParams.album || null, + sort: filterParams.sort || null, + order: filterParams.order || null, + limit: store._pageSize, + offset: 0, + ignoreWords: filterParams.ignoreWords || null, + }); const _t1 = performance.now(); @@ -196,16 +290,25 @@ export async function loadLibraryData(store, { forceReload = false } = {}) { return; } + // Store page 0 tracks from the unified response + if (sectionData.tracks && sectionData.tracks.length > 0) { + store._trackPages[0] = sectionData.tracks; + } + window.Alpine.disableEffectScheduling(() => { - store.totalTracks = countData.total; - store.totalDuration = countData.total_duration; + store.totalTracks = sectionData.total_tracks; + store.totalDuration = sectionData.total_duration; store._lastLoadedSection = loadSection; store._dataVersion++; }); + if (sectionData.revision !== undefined) { + store._lastRevision = sectionData.revision; + } + store._updateCache(loadSection, { - total: countData.total, - totalDuration: countData.total_duration, + total: sectionData.total_tracks, + totalDuration: sectionData.total_duration, }); const _t2 = performance.now(); @@ -214,12 +317,12 @@ export async function loadLibraryData(store, { forceReload = false } = {}) { process_ms: Math.round(_t2 - _t1), total_ms: Math.round(_t2 - _t0), page0_tracks: store._trackPages[0]?.length || 0, - total_tracks: countData.total, + total_tracks: sectionData.total_tracks, }; console.log('[perf] library.load breakdown:', window._perfLibLoad); console.log('[library]', 'load_complete', { page0Count: store._trackPages[0]?.length || 0, - totalTracks: countData.total, + totalTracks: sectionData.total_tracks, section: loadSection, }); } catch (error) { @@ -234,6 +337,8 @@ export async function loadLibraryData(store, { forceReload = false } = {}) { /** * Silently refresh the main library data in background without spinner. + * Uses the unified `library_get_section` command. Skips the update if the + * returned revision matches the last-seen revision. * @param {object} store - Library store instance * @param {string} section - Current section to refresh */ @@ -245,25 +350,52 @@ export async function backgroundRefreshLibrary(store, section) { console.log('[library] background refresh starting for:', section); const filterParams = store._getFilterParams(); - const countData = await library.getCount(filterParams); + const sectionData = await library.getSection({ + section: 'all', + search: filterParams.search || null, + artist: filterParams.artist || null, + album: filterParams.album || null, + sort: filterParams.sort || null, + order: filterParams.order || null, + limit: store._pageSize, + offset: 0, + ignoreWords: filterParams.ignoreWords || null, + }); + + // Skip update if revision hasn't changed + if ( + sectionData.revision !== undefined && + store._lastRevision !== undefined && + sectionData.revision === store._lastRevision + ) { + return; + } if (store.currentSection === section) { + // Reset and store page 0 from the unified response store._resetPages(); - await store._fetchPage(0); + if (sectionData.tracks && sectionData.tracks.length > 0) { + store._trackPages[0] = sectionData.tracks; + } window.Alpine.disableEffectScheduling(() => { - store.totalTracks = countData.total; - store.totalDuration = countData.total_duration; + store.totalTracks = sectionData.total_tracks; + store.totalDuration = sectionData.total_duration; store._dataVersion++; }); + + if (sectionData.revision !== undefined) { + store._lastRevision = sectionData.revision; + } + store._updateCache(section, { - total: countData.total, - totalDuration: countData.total_duration, + total: sectionData.total_tracks, + totalDuration: sectionData.total_duration, }); console.log('[library] background refresh complete:', { section, - totalTracks: countData.total, + totalTracks: sectionData.total_tracks, page0Count: store._trackPages[0]?.length || 0, }); } diff --git a/app/frontend/js/utils/queue-builder.js b/app/frontend/js/utils/queue-builder.js index 028966d..ebc03c1 100644 --- a/app/frontend/js/utils/queue-builder.js +++ b/app/frontend/js/utils/queue-builder.js @@ -14,7 +14,7 @@ import { queue } from '../api/queue.js'; * @param {Function} [options.beforePlay] - Called before play starts (e.g. to push history) */ export async function handleDoubleClickPlay(ctx, track, allTracks, index, logPrefix, options) { - if (index < 0 || index >= allTracks.length) { + if (index < 0 || index >= allTracks.length || track.missing) { await ctx.player.playTrack(track); return; } diff --git a/app/frontend/tests/fixtures/mock-library.js b/app/frontend/tests/fixtures/mock-library.js index 44ed9e3..23b10c7 100644 --- a/app/frontend/tests/fixtures/mock-library.js +++ b/app/frontend/tests/fixtures/mock-library.js @@ -650,6 +650,45 @@ export async function setupLibraryMocks(page, state) { // --- Queue API mocks (needed for handleDoubleClick background queue build) --- + // POST /api/queue/play-context - atomic queue replace + play + await page.route(/\/api\/queue\/play-context(\?.*)?$/, async (route, request) => { + if (request.method() !== 'POST') { + await route.continue(); + return; + } + const body = request.postDataJSON(); + const trackIds = body?.track_ids || []; + const startIndex = body?.start_index ?? 0; + const shuffle = body?.shuffle ?? false; + state.apiCalls.push({ method: 'POST', url: '/queue/play-context', body }); + + // Build queue items from library state tracks, preserving request order + const trackMap = new Map(state.tracks.map((t) => [t.id, t])); + let items = trackIds + .map((id) => trackMap.get(id)) + .filter(Boolean); + + // Rotate so the start track is first + if (startIndex > 0 && startIndex < items.length) { + items = [...items.slice(startIndex), ...items.slice(0, startIndex)]; + } + + const currentTrack = items[0] || null; + const totalDuration = items.reduce((sum, t) => sum + (t.duration || 0), 0); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: items.map((t) => ({ track: t })), + current_index: 0, + track: currentTrack, + shuffle_enabled: shuffle, + duration_ms: totalDuration, + }), + }); + }); + // POST /api/queue/clear await page.route(/\/api\/queue\/clear(\?.*)?$/, async (route, request) => { if (request.method() !== 'POST') { diff --git a/crates/mt-tauri/src/db/favorites.rs b/crates/mt-tauri/src/db/favorites.rs index 4a27bd7..4e71429 100644 --- a/crates/mt-tauri/src/db/favorites.rs +++ b/crates/mt-tauri/src/db/favorites.rs @@ -217,6 +217,65 @@ pub(crate) fn get_recently_added(conn: &Connection, days: i64, limit: i64) -> Db Ok(tracks) } +/// Count and total duration of favorited tracks. +pub(crate) fn get_favorites_stats(conn: &Connection) -> DbResult<(i64, f64)> { + let (count, duration) = conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(l.duration), 0) + FROM favorites f JOIN library l ON f.track_id = l.id", + [], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?)), + )?; + Ok((count, duration)) +} + +/// Count and total duration of top 25 most played tracks. +pub(crate) fn get_top_25_stats(conn: &Connection) -> DbResult<(i64, f64)> { + let (count, duration) = conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(duration), 0) + FROM (SELECT duration FROM library WHERE play_count > 0 + ORDER BY play_count DESC, last_played DESC LIMIT 25)", + [], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?)), + )?; + Ok((count, duration)) +} + +/// Count and total duration of recently played tracks. +pub(crate) fn get_recently_played_stats( + conn: &Connection, + days: i64, + limit: i64, +) -> DbResult<(i64, f64)> { + let modifier = format!("-{} days", days); + let (count, duration) = conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(duration), 0) + FROM (SELECT duration FROM library + WHERE last_played IS NOT NULL AND last_played >= datetime('now', ?) + ORDER BY last_played DESC LIMIT ?)", + params![modifier, limit], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?)), + )?; + Ok((count, duration)) +} + +/// Count and total duration of recently added tracks. +pub(crate) fn get_recently_added_stats( + conn: &Connection, + days: i64, + limit: i64, +) -> DbResult<(i64, f64)> { + let modifier = format!("-{} days", days); + let (count, duration) = conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(duration), 0) + FROM (SELECT duration FROM library + WHERE added_date IS NOT NULL AND added_date >= datetime('now', ?) + ORDER BY added_date DESC LIMIT ?)", + params![modifier, limit], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?)), + )?; + Ok((count, duration)) +} + /// Check if a track is favorited pub(crate) fn is_favorite(conn: &Connection, track_id: i64) -> DbResult<(bool, Option)> { match conn.query_row( @@ -239,6 +298,7 @@ pub(crate) fn add_favorite(conn: &Connection, track_id: i64) -> DbResult DbResult DbResult { let deleted = conn.execute("DELETE FROM favorites WHERE track_id = ?", [track_id])?; + if deleted > 0 { + crate::db::revision::bump_revision(conn)?; + } Ok(deleted > 0) } @@ -564,6 +627,79 @@ mod tests { assert!(!is_fav); } + #[test] + fn test_get_favorites_stats_empty() { + let conn = setup_test_db(); + let (count, duration) = get_favorites_stats(&conn).unwrap(); + assert_eq!(count, 0); + assert_eq!(duration, 0.0); + } + + #[test] + fn test_get_favorites_stats_with_tracks() { + let conn = setup_test_db(); + for i in 1..=3 { + let metadata = TrackMetadata { + title: Some(format!("Track {}", i)), + duration: Some(120.0 + i as f64), + ..Default::default() + }; + let id = add_track(&conn, &format!("/music/track{}.mp3", i), &metadata).unwrap(); + add_favorite(&conn, id).unwrap(); + } + let (count, duration) = get_favorites_stats(&conn).unwrap(); + assert_eq!(count, 3); + assert_eq!(duration, 121.0 + 122.0 + 123.0); + } + + #[test] + fn test_get_top_25_stats() { + let conn = setup_test_db(); + for i in 1..=3 { + let metadata = TrackMetadata { + title: Some(format!("Track {}", i)), + duration: Some(100.0 * i as f64), + ..Default::default() + }; + let id = add_track(&conn, &format!("/music/track{}.mp3", i), &metadata).unwrap(); + update_play_count(&conn, id).unwrap(); + } + let (count, duration) = get_top_25_stats(&conn).unwrap(); + assert_eq!(count, 3); + assert_eq!(duration, 100.0 + 200.0 + 300.0); + } + + #[test] + fn test_get_recently_played_stats() { + let conn = setup_test_db(); + let metadata = TrackMetadata { + title: Some("Played".to_string()), + duration: Some(200.0), + ..Default::default() + }; + let id = add_track(&conn, "/music/played.mp3", &metadata).unwrap(); + update_play_count(&conn, id).unwrap(); + + let (count, duration) = get_recently_played_stats(&conn, 7, 100).unwrap(); + assert_eq!(count, 1); + assert_eq!(duration, 200.0); + } + + #[test] + fn test_get_recently_added_stats() { + let conn = setup_test_db(); + let metadata = TrackMetadata { + title: Some("New".to_string()), + duration: Some(150.0), + ..Default::default() + }; + add_track(&conn, "/music/new.mp3", &metadata).unwrap(); + + let (count, duration) = get_recently_added_stats(&conn, 7, 100).unwrap(); + assert_eq!(count, 1); + assert_eq!(duration, 150.0); + } + #[test] fn test_favorite_track_without_metadata() { let conn = setup_test_db(); diff --git a/crates/mt-tauri/src/db/library.rs b/crates/mt-tauri/src/db/library.rs index 54124dd..c611c3c 100644 --- a/crates/mt-tauri/src/db/library.rs +++ b/crates/mt-tauri/src/db/library.rs @@ -413,6 +413,7 @@ pub(crate) fn add_track( ], )?; + crate::db::revision::bump_revision(conn)?; Ok(conn.last_insert_rowid()) } @@ -537,6 +538,9 @@ pub(crate) fn update_tracks_bulk( count += rows as i64; } + if count > 0 { + crate::db::revision::bump_revision(conn)?; + } Ok(count) } @@ -573,6 +577,9 @@ pub(crate) fn delete_tracks_bulk(conn: &Connection, filepaths: &[String]) -> DbR let sql = format!("DELETE FROM library WHERE filepath IN ({})", placeholders); let deleted = conn.execute(&sql, params.as_slice())?; + if deleted > 0 { + crate::db::revision::bump_revision(conn)?; + } Ok(deleted as i64) } @@ -581,6 +588,9 @@ pub(crate) fn delete_track(conn: &Connection, track_id: i64) -> DbResult { conn.execute("DELETE FROM favorites WHERE track_id = ?", [track_id])?; conn.execute("DELETE FROM playlist_items WHERE track_id = ?", [track_id])?; let deleted = conn.execute("DELETE FROM library WHERE id = ?", [track_id])?; + if deleted > 0 { + crate::db::revision::bump_revision(conn)?; + } Ok(deleted > 0) } @@ -622,6 +632,9 @@ pub(crate) fn delete_tracks_by_ids(conn: &Connection, track_ids: &[i64]) -> DbRe let sql = format!("DELETE FROM library WHERE id IN ({})", placeholders); let deleted = conn.execute(&sql, params.as_slice())?; + if deleted > 0 { + crate::db::revision::bump_revision(conn)?; + } Ok(deleted) } @@ -689,6 +702,7 @@ pub(crate) fn update_play_count(conn: &Connection, track_id: i64) -> DbResult 0 { + crate::db::revision::bump_revision(conn)?; + } Ok(added) } @@ -271,6 +274,7 @@ pub(crate) fn remove_track_from_playlist( )?; } + crate::db::revision::bump_revision(conn)?; Ok(true) } @@ -312,6 +316,19 @@ pub(crate) fn reorder_playlist( Ok(true) } +/// Count and total duration of tracks in a playlist. +pub(crate) fn get_playlist_stats(conn: &Connection, playlist_id: i64) -> DbResult<(i64, f64)> { + let (count, duration) = conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(l.duration), 0) + FROM playlist_items pi + JOIN library l ON pi.track_id = l.id + WHERE pi.playlist_id = ?", + [playlist_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?)), + )?; + Ok((count, duration)) +} + /// Get the number of tracks in a playlist pub(crate) fn get_playlist_track_count(conn: &Connection, playlist_id: i64) -> DbResult { let count: i64 = conn.query_row( @@ -671,6 +688,38 @@ mod tests { assert_eq!(count, 5); } + #[test] + fn test_get_playlist_stats() { + let conn = setup_test_db(); + + let mut track_ids = Vec::new(); + for i in 1..=3 { + let metadata = TrackMetadata { + title: Some(format!("Track {}", i)), + duration: Some(60.0 * i as f64), + ..Default::default() + }; + let id = add_track(&conn, &format!("/music/track{}.mp3", i), &metadata).unwrap(); + track_ids.push(id); + } + + let playlist = create_playlist(&conn, "Stats Test").unwrap().unwrap(); + add_tracks_to_playlist(&conn, playlist.id, &track_ids, None).unwrap(); + + let (count, duration) = get_playlist_stats(&conn, playlist.id).unwrap(); + assert_eq!(count, 3); + assert_eq!(duration, 60.0 + 120.0 + 180.0); + } + + #[test] + fn test_get_playlist_stats_empty() { + let conn = setup_test_db(); + let playlist = create_playlist(&conn, "Empty Stats").unwrap().unwrap(); + let (count, duration) = get_playlist_stats(&conn, playlist.id).unwrap(); + assert_eq!(count, 0); + assert_eq!(duration, 0.0); + } + #[test] fn test_get_playlist_track_count_empty() { let conn = setup_test_db(); diff --git a/crates/mt-tauri/src/db/revision.rs b/crates/mt-tauri/src/db/revision.rs new file mode 100644 index 0000000..5d4341d --- /dev/null +++ b/crates/mt-tauri/src/db/revision.rs @@ -0,0 +1,65 @@ +//! Library revision counter for cache invalidation. +//! +//! Monotonic counter that increments on any library mutation (insert, delete, update). +//! The frontend compares revisions to decide whether cached data is stale. + +use rusqlite::Connection; + +use crate::db::DbResult; + +/// Get the current library revision number. +pub(crate) fn get_revision(conn: &Connection) -> DbResult { + let revision: i64 = conn.query_row( + "SELECT revision FROM library_revision WHERE id = 1", + [], + |row| row.get(0), + )?; + Ok(revision) +} + +/// Increment and return the new library revision number. +pub(crate) fn bump_revision(conn: &Connection) -> DbResult { + conn.execute( + "UPDATE library_revision SET revision = revision + 1 WHERE id = 1", + [], + )?; + get_revision(conn) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::schema::{create_tables, run_migrations}; + + fn setup_test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + create_tables(&conn).unwrap(); + run_migrations(&conn).unwrap(); + conn + } + + #[test] + fn test_initial_revision_is_zero() { + let conn = setup_test_db(); + let rev = get_revision(&conn).unwrap(); + assert_eq!(rev, 0); + } + + #[test] + fn test_bump_revision_increments() { + let conn = setup_test_db(); + let rev1 = bump_revision(&conn).unwrap(); + assert_eq!(rev1, 1); + let rev2 = bump_revision(&conn).unwrap(); + assert_eq!(rev2, 2); + } + + #[test] + fn test_bump_revision_returns_new_value() { + let conn = setup_test_db(); + for expected in 1..=5 { + let rev = bump_revision(&conn).unwrap(); + assert_eq!(rev, expected); + } + } +} diff --git a/crates/mt-tauri/src/db/schema.rs b/crates/mt-tauri/src/db/schema.rs index 73279e1..9f4bc4b 100644 --- a/crates/mt-tauri/src/db/schema.rs +++ b/crates/mt-tauri/src/db/schema.rs @@ -160,6 +160,13 @@ pub const CREATE_TABLES: &[(&str, &str)] = &[ FOREIGN KEY (track_id) REFERENCES library(id) ON DELETE CASCADE )", ), + ( + "library_revision", + "CREATE TABLE IF NOT EXISTS library_revision ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL DEFAULT 0 + )", + ), ]; /// Create all database tables @@ -167,6 +174,11 @@ pub(crate) fn create_tables(conn: &Connection) -> DbResult<()> { for (_, sql) in CREATE_TABLES { conn.execute(sql, [])?; } + // Seed the singleton revision row + conn.execute( + "INSERT OR IGNORE INTO library_revision (id, revision) VALUES (1, 0)", + [], + )?; Ok(()) } @@ -474,6 +486,19 @@ pub(crate) fn run_migrations(conn: &Connection) -> DbResult<()> { info!("deduplicated_tracks table created"); } + // Migration: Create library_revision table for cache invalidation + if !table_exists(conn, "library_revision")? { + info!("Creating library_revision table"); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS library_revision ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL DEFAULT 0 + ); + INSERT OR IGNORE INTO library_revision (id, revision) VALUES (1, 0);", + )?; + info!("library_revision table created"); + } + // Migration: Add indexes on deduplicated_tracks if !index_exists(conn, "idx_dedup_kept_track")? { info!("Creating kept_track_id index on deduplicated_tracks"); @@ -545,7 +570,7 @@ mod tests { .filter_map(|r| r.ok()) .collect(); - assert_eq!(tables.len(), 13); + assert_eq!(tables.len(), 14); assert!(tables.contains(&"library".to_string())); assert!(tables.contains(&"queue".to_string())); assert!(tables.contains(&"queue_state".to_string())); @@ -559,6 +584,7 @@ mod tests { assert!(tables.contains(&"lastfm_loved_tracks".to_string())); assert!(tables.contains(&"removed_tracks".to_string())); assert!(tables.contains(&"play_history".to_string())); + assert!(tables.contains(&"library_revision".to_string())); } #[test] diff --git a/crates/mt-tauri/src/lib.rs b/crates/mt-tauri/src/lib.rs index 820f042..e2255db 100644 --- a/crates/mt-tauri/src/lib.rs +++ b/crates/mt-tauri/src/lib.rs @@ -42,9 +42,9 @@ use dialog::{open_add_music_dialog, open_file_dialog, open_folder_dialog}; use library::commands::{ library_check_status, library_delete_all, library_delete_track, library_delete_tracks, library_find_offset, library_get_all, library_get_artwork, library_get_artwork_url, - library_get_count, library_get_missing, library_get_stats, library_get_track, - library_locate_track, library_mark_missing, library_mark_present, library_purge_missing, - library_reconcile_scan, library_rescan_track, library_update_play_count, + library_get_count, library_get_missing, library_get_section, library_get_stats, + library_get_track, library_locate_track, library_mark_missing, library_mark_present, + library_purge_missing, library_reconcile_scan, library_rescan_track, library_update_play_count, }; use media_keys::{MediaKeyManager, NowPlayingInfo}; use metadata::{get_track_metadata, get_tracks_metadata_batch, save_track_metadata}; @@ -482,6 +482,7 @@ pub fn run() { get_track_artwork_url, library_get_all, library_get_count, + library_get_section, library_find_offset, library_get_stats, library_get_track, diff --git a/crates/mt-tauri/src/library/commands.rs b/crates/mt-tauri/src/library/commands.rs index b7e5884..bdbb045 100644 --- a/crates/mt-tauri/src/library/commands.rs +++ b/crates/mt-tauri/src/library/commands.rs @@ -7,7 +7,10 @@ use std::path::Path; use tauri::{AppHandle, State}; use tracing::{debug, info}; -use crate::db::{Database, LibraryStats, SortOrder, Track, TrackMetadata, library, removed}; +use crate::db::{ + Database, LibraryStats, SortOrder, Track, TrackMetadata, favorites, library, playlists, + removed, revision, +}; use crate::events::{EventEmitter, LibraryUpdatedEvent}; use crate::scanner::artwork::Artwork; use crate::scanner::artwork_cache::ArtworkCache; @@ -114,6 +117,272 @@ pub(crate) fn library_get_count( library::get_filtered_count(&conn, &query).map_err(|e| e.to_string()) } +/// Unified response for any library section view. +#[derive(Clone, serde::Serialize)] +pub struct LibrarySectionResponse { + pub section: String, + pub tracks: Vec, + pub total_tracks: i64, + pub total_duration: f64, + pub page: Option, + pub page_size: Option, + pub has_more: bool, + pub revision: i64, +} + +/// Get a complete view model for any library section in a single call. +/// +/// Replaces the pattern of separate getCount + getTracks calls. Returns tracks, +/// authoritative stats, and a revision number for cache invalidation — all from +/// the same DB transaction so counts are consistent with the returned page. +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(skip(db))] +#[tauri::command] +pub(crate) fn library_get_section( + db: State<'_, Database>, + section: String, + search: Option, + artist: Option, + album: Option, + sort_by: Option, + sort_order: Option, + limit: Option, + offset: Option, + ignore_words: Option, + days: Option, +) -> Result { + let start_time = std::time::Instant::now(); + + let response = db + .transaction(|conn| { + let rev = revision::get_revision(conn)?; + + match section.as_str() { + "all" => get_section_all( + conn, + rev, + search, + artist, + album, + sort_by, + sort_order, + limit, + offset, + ignore_words, + ), + "liked" => get_section_liked(conn, rev, limit, offset), + "top25" => get_section_top25(conn, rev), + "recent" => get_section_recent(conn, rev, days, limit), + "added" => get_section_added(conn, rev, days, limit), + s if s.starts_with("playlist-") => { + let id_str = &s["playlist-".len()..]; + let playlist_id: i64 = id_str.parse().map_err(|_| { + crate::db::DbError::NotFound(format!("Invalid playlist id: {}", id_str)) + })?; + get_section_playlist(conn, rev, playlist_id) + } + _ => Err(crate::db::DbError::NotFound(format!( + "Unknown section: {}", + section + ))), + } + }) + .map_err(|e| e.to_string())?; + + info!( + section = %response.section, + total_tracks = response.total_tracks, + track_count = response.tracks.len(), + duration_ms = start_time.elapsed().as_millis() as u64, + "library_get_section completed" + ); + + Ok(response) +} + +/// "all" section: paginated library with search/sort/filter. +#[allow(clippy::too_many_arguments)] +fn get_section_all( + conn: &rusqlite::Connection, + rev: i64, + search: Option, + artist: Option, + album: Option, + sort_by: Option, + sort_order: Option, + limit: Option, + offset: Option, + ignore_words: Option, +) -> crate::db::DbResult { + let page_size = limit.unwrap_or(500); + let page_offset = offset.unwrap_or(0); + + let query = library::LibraryQuery { + search: search.clone(), + artist: artist.clone(), + album: album.clone(), + genre: None, + year_from: None, + year_to: None, + sort_by: sort_by + .as_ref() + .and_then(|s| s.parse().ok()) + .unwrap_or_default(), + sort_order: sort_order + .as_ref() + .map(|s| { + if s.to_lowercase() == "asc" { + SortOrder::Asc + } else { + SortOrder::Desc + } + }) + .unwrap_or(SortOrder::Desc), + limit: page_size, + offset: page_offset, + ignore_words, + }; + + let count_query = library::LibraryQuery { + search, + artist, + album, + ..Default::default() + }; + + let count = library::get_filtered_count(conn, &count_query)?; + let result = library::get_all_tracks(conn, &query)?; + let has_more = (page_offset + page_size) < count.total; + + Ok(LibrarySectionResponse { + section: "all".to_string(), + tracks: result.items, + total_tracks: count.total, + total_duration: count.total_duration as f64, + page: Some(page_offset / page_size), + page_size: Some(page_size), + has_more, + revision: rev, + }) +} + +/// "liked" section: favorited tracks. +fn get_section_liked( + conn: &rusqlite::Connection, + rev: i64, + limit: Option, + offset: Option, +) -> crate::db::DbResult { + let lim = limit.unwrap_or(10000); + let off = offset.unwrap_or(0); + let result = favorites::get_favorites(conn, lim, off)?; + let (total, duration) = favorites::get_favorites_stats(conn)?; + let tracks: Vec = result.items.into_iter().map(|ft| ft.track).collect(); + + Ok(LibrarySectionResponse { + section: "liked".to_string(), + tracks, + total_tracks: total, + total_duration: duration, + page: None, + page_size: None, + has_more: false, + revision: rev, + }) +} + +/// "top25" section: most played tracks. +fn get_section_top25( + conn: &rusqlite::Connection, + rev: i64, +) -> crate::db::DbResult { + let tracks = favorites::get_top_25(conn)?; + let (total, duration) = favorites::get_top_25_stats(conn)?; + + Ok(LibrarySectionResponse { + section: "top25".to_string(), + tracks, + total_tracks: total, + total_duration: duration, + page: None, + page_size: None, + has_more: false, + revision: rev, + }) +} + +/// "recent" section: recently played tracks. +fn get_section_recent( + conn: &rusqlite::Connection, + rev: i64, + days: Option, + limit: Option, +) -> crate::db::DbResult { + let d = days.unwrap_or(14).clamp(1, 365); + let lim = limit.unwrap_or(100).clamp(1, 1000); + let tracks = favorites::get_recently_played(conn, d, lim)?; + let (total, duration) = favorites::get_recently_played_stats(conn, d, lim)?; + + Ok(LibrarySectionResponse { + section: "recent".to_string(), + tracks, + total_tracks: total, + total_duration: duration, + page: None, + page_size: None, + has_more: false, + revision: rev, + }) +} + +/// "added" section: recently added tracks. +fn get_section_added( + conn: &rusqlite::Connection, + rev: i64, + days: Option, + limit: Option, +) -> crate::db::DbResult { + let d = days.unwrap_or(14).clamp(1, 365); + let lim = limit.unwrap_or(100).clamp(1, 1000); + let tracks = favorites::get_recently_added(conn, d, lim)?; + let (total, duration) = favorites::get_recently_added_stats(conn, d, lim)?; + + Ok(LibrarySectionResponse { + section: "added".to_string(), + tracks, + total_tracks: total, + total_duration: duration, + page: None, + page_size: None, + has_more: false, + revision: rev, + }) +} + +/// "playlist-{id}" section: tracks in a specific playlist. +fn get_section_playlist( + conn: &rusqlite::Connection, + rev: i64, + playlist_id: i64, +) -> crate::db::DbResult { + let playlist = playlists::get_playlist(conn, playlist_id)?.ok_or_else(|| { + crate::db::DbError::NotFound(format!("Playlist {} not found", playlist_id)) + })?; + let (total, duration) = playlists::get_playlist_stats(conn, playlist_id)?; + let tracks: Vec = playlist.tracks.into_iter().map(|pt| pt.track).collect(); + + Ok(LibrarySectionResponse { + section: format!("playlist-{}", playlist_id), + tracks, + total_tracks: total, + total_duration: duration, + page: None, + page_size: None, + has_more: false, + revision: rev, + }) +} + /// Find the 0-based offset of the first row matching a prefix in the current sort order #[allow(clippy::too_many_arguments)] #[tracing::instrument(skip(db))] @@ -1168,4 +1437,314 @@ mod tests { let path = Path::new(path_str); assert_eq!(path.to_str(), Some(path_str)); } + + // ========================================================================= + // library_get_section helper tests + // ========================================================================= + + mod section_tests { + use super::super::*; + use crate::db::{ + TrackMetadata, favorites, library, playlists, revision, + schema::{create_tables, run_migrations}, + }; + use rusqlite::Connection; + + fn setup_test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + create_tables(&conn).unwrap(); + run_migrations(&conn).unwrap(); + conn + } + + fn add_test_track(conn: &Connection, i: i32, duration: f64) -> i64 { + let metadata = TrackMetadata { + title: Some(format!("Track {}", i)), + artist: Some(format!("Artist {}", i)), + album: Some(format!("Album {}", i)), + duration: Some(duration), + ..Default::default() + }; + library::add_track(conn, &format!("/music/track{}.mp3", i), &metadata).unwrap() + } + + #[test] + fn test_section_all_empty() { + let conn = setup_test_db(); + let rev = revision::get_revision(&conn).unwrap(); + let resp = get_section_all(&conn, rev, None, None, None, None, None, None, None, None) + .unwrap(); + assert_eq!(resp.section, "all"); + assert_eq!(resp.total_tracks, 0); + assert_eq!(resp.total_duration, 0.0); + assert!(!resp.has_more); + assert!(resp.tracks.is_empty()); + assert_eq!(resp.revision, 0); + } + + #[test] + fn test_section_all_with_tracks() { + let conn = setup_test_db(); + for i in 1..=5 { + add_test_track(&conn, i, 100.0 * i as f64); + } + let rev = revision::get_revision(&conn).unwrap(); + let resp = get_section_all(&conn, rev, None, None, None, None, None, None, None, None) + .unwrap(); + assert_eq!(resp.total_tracks, 5); + assert_eq!(resp.total_duration, 100.0 + 200.0 + 300.0 + 400.0 + 500.0); + assert_eq!(resp.tracks.len(), 5); + assert!(!resp.has_more); + } + + #[test] + fn test_section_all_pagination() { + let conn = setup_test_db(); + for i in 1..=10 { + add_test_track(&conn, i, 60.0); + } + let rev = revision::get_revision(&conn).unwrap(); + let resp = get_section_all( + &conn, + rev, + None, + None, + None, + None, + None, + Some(3), + Some(0), + None, + ) + .unwrap(); + assert_eq!(resp.tracks.len(), 3); + assert_eq!(resp.total_tracks, 10); + assert!(resp.has_more); + assert_eq!(resp.page, Some(0)); + assert_eq!(resp.page_size, Some(3)); + } + + #[test] + fn test_section_all_beyond_last_page() { + let conn = setup_test_db(); + for i in 1..=5 { + add_test_track(&conn, i, 60.0); + } + let rev = revision::get_revision(&conn).unwrap(); + let resp = get_section_all( + &conn, + rev, + None, + None, + None, + None, + None, + Some(10), + Some(100), + None, + ) + .unwrap(); + assert!(resp.tracks.is_empty()); + assert_eq!(resp.total_tracks, 5); + assert!(!resp.has_more); + } + + #[test] + fn test_section_all_search() { + let conn = setup_test_db(); + add_test_track(&conn, 1, 60.0); + let metadata = TrackMetadata { + title: Some("Unique Song".to_string()), + artist: Some("Special Artist".to_string()), + duration: Some(120.0), + ..Default::default() + }; + library::add_track(&conn, "/music/unique.mp3", &metadata).unwrap(); + + let rev = revision::get_revision(&conn).unwrap(); + let resp = get_section_all( + &conn, + rev, + Some("Unique".to_string()), + None, + None, + None, + None, + None, + None, + None, + ) + .unwrap(); + assert_eq!(resp.total_tracks, 1); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].title, Some("Unique Song".to_string())); + } + + #[test] + fn test_section_liked() { + let conn = setup_test_db(); + let id1 = add_test_track(&conn, 1, 100.0); + let id2 = add_test_track(&conn, 2, 200.0); + add_test_track(&conn, 3, 300.0); // not favorited + + favorites::add_favorite(&conn, id1).unwrap(); + favorites::add_favorite(&conn, id2).unwrap(); + + let rev = revision::get_revision(&conn).unwrap(); + let resp = get_section_liked(&conn, rev, None, None).unwrap(); + assert_eq!(resp.section, "liked"); + assert_eq!(resp.total_tracks, 2); + assert_eq!(resp.total_duration, 300.0); + assert_eq!(resp.tracks.len(), 2); + assert!(!resp.has_more); + } + + #[test] + fn test_section_top25() { + let conn = setup_test_db(); + let id1 = add_test_track(&conn, 1, 150.0); + let id2 = add_test_track(&conn, 2, 250.0); + + for _ in 0..5 { + library::update_play_count(&conn, id1).unwrap(); + } + for _ in 0..10 { + library::update_play_count(&conn, id2).unwrap(); + } + + let rev = revision::get_revision(&conn).unwrap(); + let resp = get_section_top25(&conn, rev).unwrap(); + assert_eq!(resp.section, "top25"); + assert_eq!(resp.total_tracks, 2); + assert_eq!(resp.total_duration, 400.0); + // Most played first + assert_eq!(resp.tracks[0].play_count, 10); + assert_eq!(resp.tracks[1].play_count, 5); + } + + #[test] + fn test_section_recent() { + let conn = setup_test_db(); + let id = add_test_track(&conn, 1, 180.0); + library::update_play_count(&conn, id).unwrap(); + + let rev = revision::get_revision(&conn).unwrap(); + let resp = get_section_recent(&conn, rev, Some(7), Some(100)).unwrap(); + assert_eq!(resp.section, "recent"); + assert_eq!(resp.total_tracks, 1); + assert_eq!(resp.total_duration, 180.0); + } + + #[test] + fn test_section_added() { + let conn = setup_test_db(); + add_test_track(&conn, 1, 200.0); + add_test_track(&conn, 2, 300.0); + + let rev = revision::get_revision(&conn).unwrap(); + let resp = get_section_added(&conn, rev, Some(7), Some(100)).unwrap(); + assert_eq!(resp.section, "added"); + assert_eq!(resp.total_tracks, 2); + assert_eq!(resp.total_duration, 500.0); + } + + #[test] + fn test_section_playlist() { + let conn = setup_test_db(); + let id1 = add_test_track(&conn, 1, 100.0); + let id2 = add_test_track(&conn, 2, 200.0); + + let playlist = playlists::create_playlist(&conn, "Test Playlist") + .unwrap() + .unwrap(); + playlists::add_tracks_to_playlist(&conn, playlist.id, &[id1, id2], None).unwrap(); + + let rev = revision::get_revision(&conn).unwrap(); + let resp = get_section_playlist(&conn, rev, playlist.id).unwrap(); + assert_eq!(resp.section, format!("playlist-{}", playlist.id)); + assert_eq!(resp.total_tracks, 2); + assert_eq!(resp.total_duration, 300.0); + assert_eq!(resp.tracks.len(), 2); + } + + #[test] + fn test_section_playlist_not_found() { + let conn = setup_test_db(); + let rev = revision::get_revision(&conn).unwrap(); + let result = get_section_playlist(&conn, rev, 999); + assert!(result.is_err()); + } + + #[test] + fn test_section_unknown() { + let _conn = setup_test_db(); + // We can't directly test the match arm since it's inside the command, + // but the DB-level helpers cover each section type. This tests the + // response struct serialization for completeness. + let resp = LibrarySectionResponse { + section: "all".to_string(), + tracks: vec![], + total_tracks: 0, + total_duration: 0.0, + page: None, + page_size: None, + has_more: false, + revision: 0, + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("\"section\":\"all\"")); + assert!(json.contains("\"revision\":0")); + assert!(json.contains("\"has_more\":false")); + } + + #[test] + fn test_revision_increments_on_add() { + let conn = setup_test_db(); + let rev_before = revision::get_revision(&conn).unwrap(); + assert_eq!(rev_before, 0); + + // Bump revision (simulating what add_track will do after Phase 4) + revision::bump_revision(&conn).unwrap(); + + let rev_after = revision::get_revision(&conn).unwrap(); + assert_eq!(rev_after, 1); + } + + #[test] + fn test_section_all_sort() { + let conn = setup_test_db(); + let metadata_a = TrackMetadata { + title: Some("Alpha".to_string()), + artist: Some("Zeta".to_string()), + duration: Some(60.0), + ..Default::default() + }; + library::add_track(&conn, "/music/alpha.mp3", &metadata_a).unwrap(); + + let metadata_b = TrackMetadata { + title: Some("Beta".to_string()), + artist: Some("Alpha".to_string()), + duration: Some(120.0), + ..Default::default() + }; + library::add_track(&conn, "/music/beta.mp3", &metadata_b).unwrap(); + + let rev = revision::get_revision(&conn).unwrap(); + let resp = get_section_all( + &conn, + rev, + None, + None, + None, + Some("title".to_string()), + Some("asc".to_string()), + None, + None, + None, + ) + .unwrap(); + assert_eq!(resp.tracks[0].title, Some("Alpha".to_string())); + assert_eq!(resp.tracks[1].title, Some("Beta".to_string())); + } + } }