From 2db8544a984a05ecb90f1687be4671d7f7f481b0 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:56:21 -0500 Subject: [PATCH 1/8] feat(library): add unified library_get_section command Replace separate library_get_count + library_get_all calls with a single library_get_section Tauri command that returns tracks + authoritative stats (total_tracks, total_duration) in one SQLite transaction. - Add library_revision table with monotonic counter for cache invalidation - Add stats query functions for favorites, top25, recent, added, playlists - Bump revision on all library mutation paths (add/delete/favorite/playlist) - Add getSection() API method on frontend - Simplify store section loaders to use unified endpoint - Prefer backend-owned total_tracks/total_duration over JS computation - Add 14 Rust tests and 10 Vitest tests for the new command Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 2 +- .../__tests__/library-section.test.js | 165 +++++ app/frontend/js/api/library.js | 40 ++ app/frontend/js/stores/library.js | 71 +-- app/frontend/js/utils/library-cache.js | 12 +- app/frontend/js/utils/library-operations.js | 179 +++++- .../mt-tauri/gen/schemas/acl-manifests.json | 291 --------- .../mt-tauri/gen/schemas/desktop-schema.json | 162 ----- crates/mt-tauri/gen/schemas/macOS-schema.json | 162 ----- crates/mt-tauri/src/db/favorites.rs | 136 ++++ crates/mt-tauri/src/db/library.rs | 14 + crates/mt-tauri/src/db/mod.rs | 1 + crates/mt-tauri/src/db/playlists.rs | 49 ++ crates/mt-tauri/src/db/revision.rs | 65 ++ crates/mt-tauri/src/db/schema.rs | 28 +- crates/mt-tauri/src/lib.rs | 7 +- crates/mt-tauri/src/library/commands.rs | 581 +++++++++++++++++- 17 files changed, 1262 insertions(+), 703 deletions(-) create mode 100644 app/frontend/__tests__/library-section.test.js create mode 100644 crates/mt-tauri/src/db/revision.rs diff --git a/Cargo.lock b/Cargo.lock index 16f53061..7a6e4090 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3252,7 +3252,7 @@ dependencies = [ [[package]] name = "mt-tauri" -version = "1.3.2" +version = "1.4.1" dependencies = [ "ab_glyph", "base64 0.22.1", diff --git a/app/frontend/__tests__/library-section.test.js b/app/frontend/__tests__/library-section.test.js new file mode 100644 index 00000000..fd15ceb7 --- /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/js/api/library.js b/app/frontend/js/api/library.js index f9537ec5..50c0792a 100644 --- a/app/frontend/js/api/library.js +++ b/app/frontend/js/api/library.js @@ -103,6 +103,46 @@ 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()); + } + } + throw new ApiError(501, 'getSection requires Tauri backend'); + }, + /** * Get a single track by ID (uses Tauri command) * @param {number} id - Track ID diff --git a/app/frontend/js/stores/library.js b/app/frontend/js/stores/library.js index 4bd4f5a6..2c1ac7e7 100644 --- a/app/frontend/js/stores/library.js +++ b/app/frontend/js/stores/library.js @@ -72,6 +72,7 @@ export function createLibraryStore(Alpine) { _sectionCache: {}, _backgroundRefreshing: false, _dataVersion: 0, + _lastRevision: undefined, _saveCache: null, async init() { @@ -364,7 +365,7 @@ export function createLibraryStore(Alpine) { }, loadFavorites() { - return this._loadSection('liked', () => favorites.get({ limit: 1000 })); + return this._loadSection('liked', null); }, _backgroundRefreshFavorites() { @@ -373,55 +374,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 +415,6 @@ export function createLibraryStore(Alpine) { console.log('[navigation]', 'load_playlist_complete', { playlistId, - playlistName: data.name, trackCount: this.filteredTracks.length, }); return data; @@ -442,8 +422,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 +431,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 ba13667d..1a4b6174 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 a1877ba5..47bb2813 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,36 @@ 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 }); + + 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 +141,61 @@ 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 }); + + 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 +211,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 @@ -182,12 +257,19 @@ export async function loadLibraryData(store, { forceReload = false } = {}) { // Reset to paginated mode store._resetPages(); - // Fetch count and first page in parallel + // 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(); @@ -199,16 +281,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(); @@ -217,12 +308,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) { @@ -237,6 +328,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 */ @@ -248,26 +341,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 reload page 0 + // 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/crates/mt-tauri/gen/schemas/acl-manifests.json b/crates/mt-tauri/gen/schemas/acl-manifests.json index f9550aff..33d3e7a7 100644 --- a/crates/mt-tauri/gen/schemas/acl-manifests.json +++ b/crates/mt-tauri/gen/schemas/acl-manifests.json @@ -3395,12 +3395,6 @@ "permission_sets": {}, "global_scope_schema": null }, - "devtools": { - "default_permission": null, - "permissions": {}, - "permission_sets": {}, - "global_scope_schema": null - }, "dialog": { "default_permission": { "identifier": "default", @@ -3629,291 +3623,6 @@ "permission_sets": {}, "global_scope_schema": null }, - "mcp-bridge": { - "default_permission": { - "identifier": "default", - "description": "Default permissions for MCP Bridge plugin", - "permissions": [ - "allow-capture-native-screenshot", - "allow-emit-event", - "allow-execute-command", - "allow-execute-js", - "allow-get-backend-state", - "allow-get-ipc-events", - "allow-get-window-info", - "allow-list-windows", - "allow-report-ipc-event", - "allow-request-script-injection", - "allow-script-result", - "allow-start-ipc-monitor", - "allow-stop-ipc-monitor" - ] - }, - "permissions": { - "allow-capture-native-screenshot": { - "identifier": "allow-capture-native-screenshot", - "description": "Enables the capture_native_screenshot command without any pre-configured scope.", - "commands": { - "allow": [ - "capture_native_screenshot" - ], - "deny": [] - } - }, - "allow-emit-event": { - "identifier": "allow-emit-event", - "description": "Enables the emit_event command without any pre-configured scope.", - "commands": { - "allow": [ - "emit_event" - ], - "deny": [] - } - }, - "allow-execute-command": { - "identifier": "allow-execute-command", - "description": "Enables the execute_command command without any pre-configured scope.", - "commands": { - "allow": [ - "execute_command" - ], - "deny": [] - } - }, - "allow-execute-js": { - "identifier": "allow-execute-js", - "description": "Enables the execute_js command without any pre-configured scope.", - "commands": { - "allow": [ - "execute_js" - ], - "deny": [] - } - }, - "allow-get-backend-state": { - "identifier": "allow-get-backend-state", - "description": "Enables the get_backend_state command without any pre-configured scope.", - "commands": { - "allow": [ - "get_backend_state" - ], - "deny": [] - } - }, - "allow-get-ipc-events": { - "identifier": "allow-get-ipc-events", - "description": "Enables the get_ipc_events command without any pre-configured scope.", - "commands": { - "allow": [ - "get_ipc_events" - ], - "deny": [] - } - }, - "allow-get-window-info": { - "identifier": "allow-get-window-info", - "description": "Enables the get_window_info command without any pre-configured scope.", - "commands": { - "allow": [ - "get_window_info" - ], - "deny": [] - } - }, - "allow-list-windows": { - "identifier": "allow-list-windows", - "description": "Enables the list_windows command without any pre-configured scope.", - "commands": { - "allow": [ - "list_windows" - ], - "deny": [] - } - }, - "allow-report-ipc-event": { - "identifier": "allow-report-ipc-event", - "description": "Enables the report_ipc_event command without any pre-configured scope.", - "commands": { - "allow": [ - "report_ipc_event" - ], - "deny": [] - } - }, - "allow-request-script-injection": { - "identifier": "allow-request-script-injection", - "description": "Enables the request_script_injection command without any pre-configured scope.", - "commands": { - "allow": [ - "request_script_injection" - ], - "deny": [] - } - }, - "allow-script-result": { - "identifier": "allow-script-result", - "description": "Enables the script_result command without any pre-configured scope.", - "commands": { - "allow": [ - "script_result" - ], - "deny": [] - } - }, - "allow-start-ipc-monitor": { - "identifier": "allow-start-ipc-monitor", - "description": "Enables the start_ipc_monitor command without any pre-configured scope.", - "commands": { - "allow": [ - "start_ipc_monitor" - ], - "deny": [] - } - }, - "allow-stop-ipc-monitor": { - "identifier": "allow-stop-ipc-monitor", - "description": "Enables the stop_ipc_monitor command without any pre-configured scope.", - "commands": { - "allow": [ - "stop_ipc_monitor" - ], - "deny": [] - } - }, - "deny-capture-native-screenshot": { - "identifier": "deny-capture-native-screenshot", - "description": "Denies the capture_native_screenshot command without any pre-configured scope.", - "commands": { - "allow": [], - "deny": [ - "capture_native_screenshot" - ] - } - }, - "deny-emit-event": { - "identifier": "deny-emit-event", - "description": "Denies the emit_event command without any pre-configured scope.", - "commands": { - "allow": [], - "deny": [ - "emit_event" - ] - } - }, - "deny-execute-command": { - "identifier": "deny-execute-command", - "description": "Denies the execute_command command without any pre-configured scope.", - "commands": { - "allow": [], - "deny": [ - "execute_command" - ] - } - }, - "deny-execute-js": { - "identifier": "deny-execute-js", - "description": "Denies the execute_js command without any pre-configured scope.", - "commands": { - "allow": [], - "deny": [ - "execute_js" - ] - } - }, - "deny-get-backend-state": { - "identifier": "deny-get-backend-state", - "description": "Denies the get_backend_state command without any pre-configured scope.", - "commands": { - "allow": [], - "deny": [ - "get_backend_state" - ] - } - }, - "deny-get-ipc-events": { - "identifier": "deny-get-ipc-events", - "description": "Denies the get_ipc_events command without any pre-configured scope.", - "commands": { - "allow": [], - "deny": [ - "get_ipc_events" - ] - } - }, - "deny-get-window-info": { - "identifier": "deny-get-window-info", - "description": "Denies the get_window_info command without any pre-configured scope.", - "commands": { - "allow": [], - "deny": [ - "get_window_info" - ] - } - }, - "deny-list-windows": { - "identifier": "deny-list-windows", - "description": "Denies the list_windows command without any pre-configured scope.", - "commands": { - "allow": [], - "deny": [ - "list_windows" - ] - } - }, - "deny-report-ipc-event": { - "identifier": "deny-report-ipc-event", - "description": "Denies the report_ipc_event command without any pre-configured scope.", - "commands": { - "allow": [], - "deny": [ - "report_ipc_event" - ] - } - }, - "deny-request-script-injection": { - "identifier": "deny-request-script-injection", - "description": "Denies the request_script_injection command without any pre-configured scope.", - "commands": { - "allow": [], - "deny": [ - "request_script_injection" - ] - } - }, - "deny-script-result": { - "identifier": "deny-script-result", - "description": "Denies the script_result command without any pre-configured scope.", - "commands": { - "allow": [], - "deny": [ - "script_result" - ] - } - }, - "deny-start-ipc-monitor": { - "identifier": "deny-start-ipc-monitor", - "description": "Denies the start_ipc_monitor command without any pre-configured scope.", - "commands": { - "allow": [], - "deny": [ - "start_ipc_monitor" - ] - } - }, - "deny-stop-ipc-monitor": { - "identifier": "deny-stop-ipc-monitor", - "description": "Denies the stop_ipc_monitor command without any pre-configured scope.", - "commands": { - "allow": [], - "deny": [ - "stop_ipc_monitor" - ] - } - } - }, - "permission_sets": {}, - "global_scope_schema": null - }, "opener": { "default_permission": { "identifier": "default", diff --git a/crates/mt-tauri/gen/schemas/desktop-schema.json b/crates/mt-tauri/gen/schemas/desktop-schema.json index 8ef916a9..0d313963 100644 --- a/crates/mt-tauri/gen/schemas/desktop-schema.json +++ b/crates/mt-tauri/gen/schemas/desktop-schema.json @@ -2654,168 +2654,6 @@ "const": "global-shortcut:deny-unregister-all", "markdownDescription": "Denies the unregister_all command without any pre-configured scope." }, - { - "description": "Default permissions for MCP Bridge plugin\n#### This default permission set includes:\n\n- `allow-capture-native-screenshot`\n- `allow-emit-event`\n- `allow-execute-command`\n- `allow-execute-js`\n- `allow-get-backend-state`\n- `allow-get-ipc-events`\n- `allow-get-window-info`\n- `allow-list-windows`\n- `allow-report-ipc-event`\n- `allow-request-script-injection`\n- `allow-script-result`\n- `allow-start-ipc-monitor`\n- `allow-stop-ipc-monitor`", - "type": "string", - "const": "mcp-bridge:default", - "markdownDescription": "Default permissions for MCP Bridge plugin\n#### This default permission set includes:\n\n- `allow-capture-native-screenshot`\n- `allow-emit-event`\n- `allow-execute-command`\n- `allow-execute-js`\n- `allow-get-backend-state`\n- `allow-get-ipc-events`\n- `allow-get-window-info`\n- `allow-list-windows`\n- `allow-report-ipc-event`\n- `allow-request-script-injection`\n- `allow-script-result`\n- `allow-start-ipc-monitor`\n- `allow-stop-ipc-monitor`" - }, - { - "description": "Enables the capture_native_screenshot command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-capture-native-screenshot", - "markdownDescription": "Enables the capture_native_screenshot command without any pre-configured scope." - }, - { - "description": "Enables the emit_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-emit-event", - "markdownDescription": "Enables the emit_event command without any pre-configured scope." - }, - { - "description": "Enables the execute_command command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-execute-command", - "markdownDescription": "Enables the execute_command command without any pre-configured scope." - }, - { - "description": "Enables the execute_js command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-execute-js", - "markdownDescription": "Enables the execute_js command without any pre-configured scope." - }, - { - "description": "Enables the get_backend_state command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-get-backend-state", - "markdownDescription": "Enables the get_backend_state command without any pre-configured scope." - }, - { - "description": "Enables the get_ipc_events command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-get-ipc-events", - "markdownDescription": "Enables the get_ipc_events command without any pre-configured scope." - }, - { - "description": "Enables the get_window_info command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-get-window-info", - "markdownDescription": "Enables the get_window_info command without any pre-configured scope." - }, - { - "description": "Enables the list_windows command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-list-windows", - "markdownDescription": "Enables the list_windows command without any pre-configured scope." - }, - { - "description": "Enables the report_ipc_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-report-ipc-event", - "markdownDescription": "Enables the report_ipc_event command without any pre-configured scope." - }, - { - "description": "Enables the request_script_injection command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-request-script-injection", - "markdownDescription": "Enables the request_script_injection command without any pre-configured scope." - }, - { - "description": "Enables the script_result command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-script-result", - "markdownDescription": "Enables the script_result command without any pre-configured scope." - }, - { - "description": "Enables the start_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-start-ipc-monitor", - "markdownDescription": "Enables the start_ipc_monitor command without any pre-configured scope." - }, - { - "description": "Enables the stop_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-stop-ipc-monitor", - "markdownDescription": "Enables the stop_ipc_monitor command without any pre-configured scope." - }, - { - "description": "Denies the capture_native_screenshot command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-capture-native-screenshot", - "markdownDescription": "Denies the capture_native_screenshot command without any pre-configured scope." - }, - { - "description": "Denies the emit_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-emit-event", - "markdownDescription": "Denies the emit_event command without any pre-configured scope." - }, - { - "description": "Denies the execute_command command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-execute-command", - "markdownDescription": "Denies the execute_command command without any pre-configured scope." - }, - { - "description": "Denies the execute_js command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-execute-js", - "markdownDescription": "Denies the execute_js command without any pre-configured scope." - }, - { - "description": "Denies the get_backend_state command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-get-backend-state", - "markdownDescription": "Denies the get_backend_state command without any pre-configured scope." - }, - { - "description": "Denies the get_ipc_events command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-get-ipc-events", - "markdownDescription": "Denies the get_ipc_events command without any pre-configured scope." - }, - { - "description": "Denies the get_window_info command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-get-window-info", - "markdownDescription": "Denies the get_window_info command without any pre-configured scope." - }, - { - "description": "Denies the list_windows command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-list-windows", - "markdownDescription": "Denies the list_windows command without any pre-configured scope." - }, - { - "description": "Denies the report_ipc_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-report-ipc-event", - "markdownDescription": "Denies the report_ipc_event command without any pre-configured scope." - }, - { - "description": "Denies the request_script_injection command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-request-script-injection", - "markdownDescription": "Denies the request_script_injection command without any pre-configured scope." - }, - { - "description": "Denies the script_result command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-script-result", - "markdownDescription": "Denies the script_result command without any pre-configured scope." - }, - { - "description": "Denies the start_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-start-ipc-monitor", - "markdownDescription": "Denies the start_ipc_monitor command without any pre-configured scope." - }, - { - "description": "Denies the stop_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-stop-ipc-monitor", - "markdownDescription": "Denies the stop_ipc_monitor command without any pre-configured scope." - }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/crates/mt-tauri/gen/schemas/macOS-schema.json b/crates/mt-tauri/gen/schemas/macOS-schema.json index 8ef916a9..0d313963 100644 --- a/crates/mt-tauri/gen/schemas/macOS-schema.json +++ b/crates/mt-tauri/gen/schemas/macOS-schema.json @@ -2654,168 +2654,6 @@ "const": "global-shortcut:deny-unregister-all", "markdownDescription": "Denies the unregister_all command without any pre-configured scope." }, - { - "description": "Default permissions for MCP Bridge plugin\n#### This default permission set includes:\n\n- `allow-capture-native-screenshot`\n- `allow-emit-event`\n- `allow-execute-command`\n- `allow-execute-js`\n- `allow-get-backend-state`\n- `allow-get-ipc-events`\n- `allow-get-window-info`\n- `allow-list-windows`\n- `allow-report-ipc-event`\n- `allow-request-script-injection`\n- `allow-script-result`\n- `allow-start-ipc-monitor`\n- `allow-stop-ipc-monitor`", - "type": "string", - "const": "mcp-bridge:default", - "markdownDescription": "Default permissions for MCP Bridge plugin\n#### This default permission set includes:\n\n- `allow-capture-native-screenshot`\n- `allow-emit-event`\n- `allow-execute-command`\n- `allow-execute-js`\n- `allow-get-backend-state`\n- `allow-get-ipc-events`\n- `allow-get-window-info`\n- `allow-list-windows`\n- `allow-report-ipc-event`\n- `allow-request-script-injection`\n- `allow-script-result`\n- `allow-start-ipc-monitor`\n- `allow-stop-ipc-monitor`" - }, - { - "description": "Enables the capture_native_screenshot command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-capture-native-screenshot", - "markdownDescription": "Enables the capture_native_screenshot command without any pre-configured scope." - }, - { - "description": "Enables the emit_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-emit-event", - "markdownDescription": "Enables the emit_event command without any pre-configured scope." - }, - { - "description": "Enables the execute_command command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-execute-command", - "markdownDescription": "Enables the execute_command command without any pre-configured scope." - }, - { - "description": "Enables the execute_js command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-execute-js", - "markdownDescription": "Enables the execute_js command without any pre-configured scope." - }, - { - "description": "Enables the get_backend_state command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-get-backend-state", - "markdownDescription": "Enables the get_backend_state command without any pre-configured scope." - }, - { - "description": "Enables the get_ipc_events command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-get-ipc-events", - "markdownDescription": "Enables the get_ipc_events command without any pre-configured scope." - }, - { - "description": "Enables the get_window_info command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-get-window-info", - "markdownDescription": "Enables the get_window_info command without any pre-configured scope." - }, - { - "description": "Enables the list_windows command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-list-windows", - "markdownDescription": "Enables the list_windows command without any pre-configured scope." - }, - { - "description": "Enables the report_ipc_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-report-ipc-event", - "markdownDescription": "Enables the report_ipc_event command without any pre-configured scope." - }, - { - "description": "Enables the request_script_injection command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-request-script-injection", - "markdownDescription": "Enables the request_script_injection command without any pre-configured scope." - }, - { - "description": "Enables the script_result command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-script-result", - "markdownDescription": "Enables the script_result command without any pre-configured scope." - }, - { - "description": "Enables the start_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-start-ipc-monitor", - "markdownDescription": "Enables the start_ipc_monitor command without any pre-configured scope." - }, - { - "description": "Enables the stop_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-stop-ipc-monitor", - "markdownDescription": "Enables the stop_ipc_monitor command without any pre-configured scope." - }, - { - "description": "Denies the capture_native_screenshot command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-capture-native-screenshot", - "markdownDescription": "Denies the capture_native_screenshot command without any pre-configured scope." - }, - { - "description": "Denies the emit_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-emit-event", - "markdownDescription": "Denies the emit_event command without any pre-configured scope." - }, - { - "description": "Denies the execute_command command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-execute-command", - "markdownDescription": "Denies the execute_command command without any pre-configured scope." - }, - { - "description": "Denies the execute_js command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-execute-js", - "markdownDescription": "Denies the execute_js command without any pre-configured scope." - }, - { - "description": "Denies the get_backend_state command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-get-backend-state", - "markdownDescription": "Denies the get_backend_state command without any pre-configured scope." - }, - { - "description": "Denies the get_ipc_events command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-get-ipc-events", - "markdownDescription": "Denies the get_ipc_events command without any pre-configured scope." - }, - { - "description": "Denies the get_window_info command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-get-window-info", - "markdownDescription": "Denies the get_window_info command without any pre-configured scope." - }, - { - "description": "Denies the list_windows command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-list-windows", - "markdownDescription": "Denies the list_windows command without any pre-configured scope." - }, - { - "description": "Denies the report_ipc_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-report-ipc-event", - "markdownDescription": "Denies the report_ipc_event command without any pre-configured scope." - }, - { - "description": "Denies the request_script_injection command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-request-script-injection", - "markdownDescription": "Denies the request_script_injection command without any pre-configured scope." - }, - { - "description": "Denies the script_result command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-script-result", - "markdownDescription": "Denies the script_result command without any pre-configured scope." - }, - { - "description": "Denies the start_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-start-ipc-monitor", - "markdownDescription": "Denies the start_ipc_monitor command without any pre-configured scope." - }, - { - "description": "Denies the stop_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-stop-ipc-monitor", - "markdownDescription": "Denies the stop_ipc_monitor command without any pre-configured scope." - }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/crates/mt-tauri/src/db/favorites.rs b/crates/mt-tauri/src/db/favorites.rs index 4a27bd79..4e71429f 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 c1733f66..528b25fd 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 00000000..5d4341df --- /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 73279e1d..9f4bc4b5 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 945b3021..f808eace 100644 --- a/crates/mt-tauri/src/lib.rs +++ b/crates/mt-tauri/src/lib.rs @@ -41,9 +41,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}; @@ -481,6 +481,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 a8ec87bb..608ba130 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))] @@ -1180,4 +1449,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())); + } + } } From 9ce3c10d3243d470d7dfa9419d1554e245e20325 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:00:02 -0500 Subject: [PATCH 2/8] fix(ci): remove unused imports and update FOUC tests for unified endpoint - Remove unused favorites and playlists imports from library store (unified endpoint handles all sections via library.getSection) - Update FOUC regression tests to mock library.getSection instead of library.getCount + store._fetchPage Co-Authored-By: Claude Opus 4.6 --- app/frontend/__tests__/library.store.test.js | 39 ++++++++++++++++---- app/frontend/js/stores/library.js | 2 - 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/app/frontend/__tests__/library.store.test.js b/app/frontend/__tests__/library.store.test.js index 146acc94..e584631e 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/stores/library.js b/app/frontend/js/stores/library.js index 2c1ac7e7..9cf7cdc2 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, From 4a7a2fc592a8051e1b92f520f0702314c004d154 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:05:13 -0500 Subject: [PATCH 3/8] fix(ci): prefix unused conn variable in test_section_unknown Co-Authored-By: Claude Opus 4.6 --- crates/mt-tauri/src/library/commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mt-tauri/src/library/commands.rs b/crates/mt-tauri/src/library/commands.rs index f6823d3c..bdbb0452 100644 --- a/crates/mt-tauri/src/library/commands.rs +++ b/crates/mt-tauri/src/library/commands.rs @@ -1677,7 +1677,7 @@ mod tests { #[test] fn test_section_unknown() { - let conn = setup_test_db(); + 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. From b1cdc5fa8d75a132853505a4e066d4306cf0da8f Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:23:02 -0500 Subject: [PATCH 4/8] fix(ci): add HTTP fallback for getSection in Playwright E2E tests The getSection method only had a Tauri invoke path and threw a 501 in non-Tauri environments. Playwright E2E tests run without a Tauri backend, so all accessibility tests timed out waiting for tracks to load. Add an HTTP fallback that composes existing /library + /library/count endpoints into the unified response shape. Co-Authored-By: Claude Opus 4.6 --- app/frontend/js/api/library.js | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/app/frontend/js/api/library.js b/app/frontend/js/api/library.js index 50c0792a..8989126a 100644 --- a/app/frontend/js/api/library.js +++ b/app/frontend/js/api/library.js @@ -140,7 +140,30 @@ export const library = { throw new ApiError(500, error.toString()); } } - throw new ApiError(501, 'getSection requires Tauri backend'); + // HTTP fallback: compose from existing /library + /library/count endpoints + 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: params.section || 'all', + 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, + }; }, /** From 29f914aa822494f67a9a88820101ac9684833dcd Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:33:36 -0500 Subject: [PATCH 5/8] fix(ci): handle playlist sections in getSection HTTP fallback The HTTP fallback for getSection always called /library + /library/count regardless of section type. Playlist sections (playlist-{id}) need to call /playlists/:id instead, matching the pre-existing REST endpoint that E2E mock fixtures provide. Co-Authored-By: Claude Opus 4.6 --- app/frontend/js/api/library.js | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/app/frontend/js/api/library.js b/app/frontend/js/api/library.js index 8989126a..d60421aa 100644 --- a/app/frontend/js/api/library.js +++ b/app/frontend/js/api/library.js @@ -140,7 +140,28 @@ export const library = { throw new ApiError(500, error.toString()); } } - // HTTP fallback: compose from existing /library + /library/count endpoints + // 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); @@ -155,7 +176,7 @@ export const library = { request(`/library/count${qs ? `?${qs}` : ''}`), ]); return { - section: params.section || 'all', + section, tracks: trackData.tracks || [], total_tracks: countData.total ?? (trackData.tracks || []).length, total_duration: countData.total_duration ?? 0, From 4faf620bf99dae7e8f954a36e4e47a4c62499b83 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:56:18 -0500 Subject: [PATCH 6/8] fix(e2e): add HTTP fallback for queue.playContext() Replace the hard throw in browser mode with a REST POST to /queue/play-context. Add corresponding Playwright route mock that builds queue items from library state, rotates to the start track, and returns the expected response shape. Fixes 10 pre-existing E2E failures where double-click-to-play timed out because playContext was unavailable without Tauri IPC. --- app/frontend/js/api/queue.js | 9 ++++- app/frontend/tests/fixtures/mock-library.js | 39 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/frontend/js/api/queue.js b/app/frontend/js/api/queue.js index d3acd45c..5869bef2 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/tests/fixtures/mock-library.js b/app/frontend/tests/fixtures/mock-library.js index 44ed9e3a..23b10c7d 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') { From 35959ad5a1bb11505adeba1149f29c21570489ed Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:03:41 -0500 Subject: [PATCH 7/8] fix(e2e): route missing tracks through playTrack for modal display handleDoubleClickPlay bypassed player.playTrack() (which contains the missing track modal check) by going directly to playContext. Add track.missing to the guard clause so missing tracks fall through to playTrack, which shows the missing track modal as expected. --- app/frontend/js/utils/queue-builder.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend/js/utils/queue-builder.js b/app/frontend/js/utils/queue-builder.js index 028966dd..ebc03c1b 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; } From 7bffca5e0bd7a28ff13b83d4f442488d23195b5a Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:42:55 -0500 Subject: [PATCH 8/8] fix: skip _filterByLibrary for unified backend section loads When using the unified library_get_section backend command, the returned tracks are already authoritative for the requested section. Filtering them against the 'all' section's filteredTracks (via _filterByLibrary) incorrectly drops all tracks when the 'all' section hasn't been loaded or only has a partial first page. Skip the filter in both loadSection and backgroundRefreshSection when useUnified is true. --- app/frontend/js/utils/library-operations.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/frontend/js/utils/library-operations.js b/app/frontend/js/utils/library-operations.js index e9027b8b..df3036ed 100644 --- a/app/frontend/js/utils/library-operations.js +++ b/app/frontend/js/utils/library-operations.js @@ -111,7 +111,13 @@ export async function loadSection(store, section, fetchFn, opts = {}) { } const rawTracks = data.tracks || []; - const tracks = transform ? transform(rawTracks, data) : store._filterByLibrary(rawTracks); + // 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 @@ -181,7 +187,11 @@ export async function backgroundRefreshSection(store, section, fetchFn, opts = { if (store._lastLoadedSection === section) { const rawTracks = data.tracks || []; - const tracks = transform ? transform(rawTracks, data) : store._filterByLibrary(rawTracks); + const tracks = transform + ? transform(rawTracks, data) + : useUnified + ? rawTracks + : store._filterByLibrary(rawTracks); if (useUnified) { applySectionData(store, section, tracks, data);