From 532bf87dad9e0c5648fee4a4cecb7377663d6233 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Sat, 7 Mar 2026 20:41:51 -0600 Subject: [PATCH] feat: show LRCLIB lyrics in Now Playing view with caching Add lyrics fetching from lrclib.net with per-track SQLite caching. When lyrics are available, the Now Playing left panel switches to a compact album art header with a scrollable lyrics panel below. When no lyrics are found the view remains unchanged. Backend: db/lyrics.rs (cache CRUD), lyrics.rs (LRCLIB HTTP client with 10s timeout, mt-desktop User-Agent), commands/lyrics.rs (lyrics_get cache-first async command, lyrics_clear_cache). Negative results are cached to avoid repeated failed lookups. Frontend: api/lyrics.js (Tauri command wrapper), now-playing-view.js (lyrics state with visibility-gated fetching, superseded-fetch guard), now-playing.html (conditional two-layout rendering). Tests: 11 Rust tests (DB round-trip, LRCLIB response parsing), 12 Vitest tests (API invocation, visibility gating, track change, error handling). All 622 existing Rust + 331 Vitest tests pass. Co-Authored-By: Claude Opus 4.6 --- app/frontend/__tests__/lyrics.test.js | 344 ++++++++++++++++++ app/frontend/js/api/index.js | 4 +- app/frontend/js/api/lyrics.js | 50 +++ .../js/components/now-playing-view.js | 63 ++++ app/frontend/views/now-playing.html | 36 +- ...lyrics-in-Now-Playing-view-with-caching.md | 93 ++++- crates/mt-tauri/src/commands/lyrics.rs | 136 +++++++ crates/mt-tauri/src/commands/mod.rs | 3 + crates/mt-tauri/src/db/lyrics.rs | 241 ++++++++++++ crates/mt-tauri/src/db/mod.rs | 1 + crates/mt-tauri/src/lib.rs | 7 +- crates/mt-tauri/src/lyrics.rs | 187 ++++++++++ 12 files changed, 1145 insertions(+), 20 deletions(-) create mode 100644 app/frontend/__tests__/lyrics.test.js create mode 100644 app/frontend/js/api/lyrics.js create mode 100644 crates/mt-tauri/src/commands/lyrics.rs create mode 100644 crates/mt-tauri/src/db/lyrics.rs create mode 100644 crates/mt-tauri/src/lyrics.rs diff --git a/app/frontend/__tests__/lyrics.test.js b/app/frontend/__tests__/lyrics.test.js new file mode 100644 index 0000000..39f5643 --- /dev/null +++ b/app/frontend/__tests__/lyrics.test.js @@ -0,0 +1,344 @@ +/** + * Unit tests for lyrics functionality + * + * Tests cover: + * - Lyrics API layer (Tauri command invocation) + * - Lyrics fetch behavior (track change, visibility gating) + * - Now Playing view component lyrics state management + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock Tauri invoke +const mockInvoke = vi.fn(); +globalThis.window = { + __TAURI__: { + core: { + invoke: mockInvoke, + }, + }, +}; + +// Import after mocks are set up +const { lyrics } = await import('../js/api/lyrics.js'); + +describe('lyrics API', () => { + beforeEach(() => { + mockInvoke.mockReset(); + }); + + describe('get', () => { + it('invokes lyrics_get with correct params', async () => { + mockInvoke.mockResolvedValue({ + plain_lyrics: 'Hello world', + synced_lyrics: null, + instrumental: false, + }); + + const result = await lyrics.get({ + artist: 'Queen', + title: 'Bohemian Rhapsody', + album: 'A Night at the Opera', + duration: 354, + }); + + expect(mockInvoke).toHaveBeenCalledWith('lyrics_get', { + artist: 'Queen', + title: 'Bohemian Rhapsody', + album: 'A Night at the Opera', + duration: 354, + }); + + expect(result).toEqual({ + plain_lyrics: 'Hello world', + synced_lyrics: null, + instrumental: false, + }); + }); + + it('passes null for missing optional params', async () => { + mockInvoke.mockResolvedValue(null); + + await lyrics.get({ + artist: 'Queen', + title: 'Bohemian Rhapsody', + }); + + expect(mockInvoke).toHaveBeenCalledWith('lyrics_get', { + artist: 'Queen', + title: 'Bohemian Rhapsody', + album: null, + duration: null, + }); + }); + + it('returns null when backend returns null (no lyrics)', async () => { + mockInvoke.mockResolvedValue(null); + + const result = await lyrics.get({ + artist: 'Unknown', + title: 'Unknown', + }); + + expect(result).toBeNull(); + }); + + it('throws ApiError on invoke failure', async () => { + mockInvoke.mockRejectedValue('Database error'); + + await expect( + lyrics.get({ artist: 'A', title: 'B' }), + ).rejects.toThrow(); + }); + }); + + describe('clearCache', () => { + it('invokes lyrics_clear_cache', async () => { + mockInvoke.mockResolvedValue(undefined); + + await lyrics.clearCache(); + + expect(mockInvoke).toHaveBeenCalledWith('lyrics_clear_cache'); + }); + }); +}); + +describe('now-playing-view lyrics state', () => { + /** + * Creates a minimal test instance of the now-playing-view component + * with mocked Alpine.js $store and $watch + */ + function createTestComponent() { + const watchers = {}; + const component = { + // Lyrics state + lyrics: null, + lyricsLoading: false, + _lyricsTrackKey: null, + _lyricsFetchId: 0, + + // Mock Alpine $store + $store: { + player: { currentTrack: null }, + ui: { view: 'library' }, + queue: { playOrderItems: [], items: [] }, + }, + + // Mock Alpine $watch + $watch(key, callback) { + watchers[key] = callback; + }, + + // Mock Alpine $refs + $refs: {}, + + // Trigger a watcher manually + _trigger(key) { + if (watchers[key]) watchers[key](); + }, + }; + + // Import and bind the methods from the actual module + // Instead, we replicate the core logic for unit testing + component._onTrackOrViewChange = function () { + const track = this.$store.player.currentTrack; + const isVisible = this.$store.ui.view === 'nowPlaying'; + + if (!track || !isVisible) return; + + const trackKey = `${track.artist || ''}::${track.title || ''}`; + if (trackKey === this._lyricsTrackKey) return; + + this._lyricsTrackKey = trackKey; + this._fetchLyrics(track); + }; + + component._fetchLyrics = async function (track) { + const fetchId = ++this._lyricsFetchId; + this.lyrics = null; + this.lyricsLoading = true; + + try { + const durationSecs = track.duration ? Math.round(track.duration / 1000) : null; + const result = await lyrics.get({ + artist: track.artist || '', + title: track.title || '', + album: track.album || '', + duration: durationSecs, + }); + + if (this._lyricsFetchId !== fetchId) return; + + if (result && result.plain_lyrics) { + this.lyrics = result.plain_lyrics; + } else { + this.lyrics = null; + } + } catch (_error) { + if (this._lyricsFetchId !== fetchId) return; + this.lyrics = null; + } finally { + if (this._lyricsFetchId === fetchId) { + this.lyricsLoading = false; + } + } + }; + + // Wire up watches like init() would + component.$watch('$store.player.currentTrack', () => component._onTrackOrViewChange()); + component.$watch('$store.ui.view', () => component._onTrackOrViewChange()); + + return component; + } + + beforeEach(() => { + mockInvoke.mockReset(); + }); + + it('does not fetch lyrics when view is not nowPlaying', () => { + const comp = createTestComponent(); + comp.$store.player.currentTrack = { id: 1, artist: 'Queen', title: 'Test', duration: 300000 }; + comp.$store.ui.view = 'library'; + + comp._trigger('$store.player.currentTrack'); + + expect(mockInvoke).not.toHaveBeenCalled(); + expect(comp.lyrics).toBeNull(); + }); + + it('fetches lyrics when track changes and view is nowPlaying', async () => { + const comp = createTestComponent(); + mockInvoke.mockResolvedValue({ + plain_lyrics: 'Some lyrics', + synced_lyrics: null, + instrumental: false, + }); + + comp.$store.ui.view = 'nowPlaying'; + comp.$store.player.currentTrack = { + id: 1, + artist: 'Queen', + title: 'Bohemian Rhapsody', + album: 'A Night at the Opera', + duration: 354000, + }; + + comp._trigger('$store.player.currentTrack'); + + // Wait for async fetch + await vi.waitFor(() => expect(comp.lyricsLoading).toBe(false)); + + expect(mockInvoke).toHaveBeenCalledWith('lyrics_get', { + artist: 'Queen', + title: 'Bohemian Rhapsody', + album: 'A Night at the Opera', + duration: 354, + }); + expect(comp.lyrics).toBe('Some lyrics'); + }); + + it('fetches lyrics when switching to nowPlaying view with a track', async () => { + const comp = createTestComponent(); + mockInvoke.mockResolvedValue({ + plain_lyrics: 'Lyrics here', + synced_lyrics: null, + instrumental: false, + }); + + comp.$store.player.currentTrack = { id: 1, artist: 'Queen', title: 'Test', duration: 200000 }; + comp.$store.ui.view = 'nowPlaying'; + + comp._trigger('$store.ui.view'); + + await vi.waitFor(() => expect(comp.lyricsLoading).toBe(false)); + + expect(mockInvoke).toHaveBeenCalled(); + expect(comp.lyrics).toBe('Lyrics here'); + }); + + it('sets lyrics to null when backend returns null', async () => { + const comp = createTestComponent(); + mockInvoke.mockResolvedValue(null); + + comp.$store.ui.view = 'nowPlaying'; + comp.$store.player.currentTrack = { + id: 1, + artist: 'Unknown', + title: 'Unknown', + duration: 100000, + }; + + comp._trigger('$store.player.currentTrack'); + + await vi.waitFor(() => expect(comp.lyricsLoading).toBe(false)); + + expect(comp.lyrics).toBeNull(); + }); + + it('does not re-fetch for the same track', async () => { + const comp = createTestComponent(); + mockInvoke.mockResolvedValue({ + plain_lyrics: 'Lyrics', + synced_lyrics: null, + instrumental: false, + }); + + comp.$store.ui.view = 'nowPlaying'; + comp.$store.player.currentTrack = { id: 1, artist: 'Queen', title: 'Test', duration: 200000 }; + + comp._trigger('$store.player.currentTrack'); + await vi.waitFor(() => expect(comp.lyricsLoading).toBe(false)); + + expect(mockInvoke).toHaveBeenCalledTimes(1); + + // Trigger again with same artist+title + comp._trigger('$store.player.currentTrack'); + + expect(mockInvoke).toHaveBeenCalledTimes(1); + }); + + it('clears lyrics before fetching for a new track', async () => { + const comp = createTestComponent(); + + // First track has lyrics + mockInvoke.mockResolvedValueOnce({ + plain_lyrics: 'First', + synced_lyrics: null, + instrumental: false, + }); + comp.$store.ui.view = 'nowPlaying'; + comp.$store.player.currentTrack = { id: 1, artist: 'A', title: 'Song1', duration: 200000 }; + comp._trigger('$store.player.currentTrack'); + await vi.waitFor(() => expect(comp.lyricsLoading).toBe(false)); + expect(comp.lyrics).toBe('First'); + + // Switch to second track — lyrics should clear immediately + mockInvoke.mockResolvedValueOnce({ + plain_lyrics: 'Second', + synced_lyrics: null, + instrumental: false, + }); + comp.$store.player.currentTrack = { id: 2, artist: 'B', title: 'Song2', duration: 300000 }; + comp._trigger('$store.player.currentTrack'); + + // During loading, lyrics should be null (cleared) + expect(comp.lyrics).toBeNull(); + expect(comp.lyricsLoading).toBe(true); + + await vi.waitFor(() => expect(comp.lyricsLoading).toBe(false)); + expect(comp.lyrics).toBe('Second'); + }); + + it('handles fetch error gracefully', async () => { + const comp = createTestComponent(); + mockInvoke.mockRejectedValue(new Error('Network error')); + + comp.$store.ui.view = 'nowPlaying'; + comp.$store.player.currentTrack = { id: 1, artist: 'A', title: 'B', duration: 100000 }; + comp._trigger('$store.player.currentTrack'); + + await vi.waitFor(() => expect(comp.lyricsLoading).toBe(false)); + + expect(comp.lyrics).toBeNull(); + }); +}); diff --git a/app/frontend/js/api/index.js b/app/frontend/js/api/index.js index 34b63b7..0f41be0 100644 --- a/app/frontend/js/api/index.js +++ b/app/frontend/js/api/index.js @@ -14,9 +14,10 @@ import { queue } from './queue.js'; import { favorites } from './favorites.js'; import { playlists } from './playlists.js'; import { lastfm } from './lastfm.js'; +import { lyrics } from './lyrics.js'; import { settings } from './settings.js'; -export { favorites, lastfm, library, playlists, queue, settings }; +export { favorites, lastfm, library, lyrics, playlists, queue, settings }; /** * Unified API object (backward compatibility). @@ -28,6 +29,7 @@ export const api = { }, library, + lyrics, queue, favorites, playlists, diff --git a/app/frontend/js/api/lyrics.js b/app/frontend/js/api/lyrics.js new file mode 100644 index 0000000..6ec0198 --- /dev/null +++ b/app/frontend/js/api/lyrics.js @@ -0,0 +1,50 @@ +/** + * Lyrics API + * + * LRCLIB lyrics lookup with SQLite caching via Tauri commands. + */ + +import { ApiError, invoke } from './shared.js'; + +export const lyrics = { + /** + * Get lyrics for a track (checks cache, fetches from LRCLIB on miss) + * @param {object} params + * @param {string} params.artist - Artist name + * @param {string} params.title - Track title + * @param {string} [params.album] - Album name + * @param {number} [params.duration] - Duration in seconds + * @returns {Promise<{plain_lyrics: string|null, synced_lyrics: string|null, instrumental: boolean}|null>} + */ + async get(params) { + if (invoke) { + try { + return await invoke('lyrics_get', { + artist: params.artist, + title: params.title, + album: params.album ?? null, + duration: params.duration ?? null, + }); + } catch (error) { + console.error('[api.lyrics.get] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + return null; + }, + + /** + * Clear all cached lyrics + * @returns {Promise} + */ + async clearCache() { + if (invoke) { + try { + return await invoke('lyrics_clear_cache'); + } catch (error) { + console.error('[api.lyrics.clearCache] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + }, +}; diff --git a/app/frontend/js/components/now-playing-view.js b/app/frontend/js/components/now-playing-view.js index 54447c7..2cc8e98 100644 --- a/app/frontend/js/components/now-playing-view.js +++ b/app/frontend/js/components/now-playing-view.js @@ -1,9 +1,16 @@ import { queueDragReorderMixin } from '../mixins/queue-drag-reorder.js'; +import { lyrics as lyricsApi } from '../api/lyrics.js'; export function createNowPlayingView(Alpine) { Alpine.data('nowPlayingView', () => ({ ...queueDragReorderMixin(), + // Lyrics state + lyrics: null, + lyricsLoading: false, + _lyricsTrackKey: null, + _lyricsFetchId: 0, + // Virtual scroll state _rowHeight: 41, _scrollTop: 0, @@ -23,6 +30,10 @@ export function createNowPlayingView(Alpine) { }); this._resizeObserver.observe(container); } + + // Watch for track changes and view visibility to fetch lyrics + this.$watch('$store.player.currentTrack', () => this._onTrackOrViewChange()); + this.$watch('$store.ui.view', () => this._onTrackOrViewChange()); }, destroy() { @@ -36,6 +47,58 @@ export function createNowPlayingView(Alpine) { } }, + _onTrackOrViewChange() { + const track = this.$store.player.currentTrack; + const isVisible = this.$store.ui.view === 'nowPlaying'; + + if (!track || !isVisible) { + return; + } + + // Build a cache key from artist+title to detect track changes + const trackKey = `${track.artist || ''}::${track.title || ''}`; + + if (trackKey === this._lyricsTrackKey) { + return; + } + + this._lyricsTrackKey = trackKey; + this._fetchLyrics(track); + }, + + async _fetchLyrics(track) { + const fetchId = ++this._lyricsFetchId; + this.lyrics = null; + this.lyricsLoading = true; + + try { + const durationSecs = track.duration ? Math.round(track.duration / 1000) : null; + const result = await lyricsApi.get({ + artist: track.artist || '', + title: track.title || '', + album: track.album || '', + duration: durationSecs, + }); + + // Check if this fetch was superseded + if (this._lyricsFetchId !== fetchId) return; + + if (result && result.plain_lyrics) { + this.lyrics = result.plain_lyrics; + } else { + this.lyrics = null; + } + } catch (error) { + console.error('[now-playing] Failed to fetch lyrics:', error); + if (this._lyricsFetchId !== fetchId) return; + this.lyrics = null; + } finally { + if (this._lyricsFetchId === fetchId) { + this.lyricsLoading = false; + } + } + }, + _onScroll() { if (this._rafId) return; this._rafId = requestAnimationFrame(() => { diff --git a/app/frontend/views/now-playing.html b/app/frontend/views/now-playing.html index db8b00e..cc1509c 100644 --- a/app/frontend/views/now-playing.html +++ b/app/frontend/views/now-playing.html @@ -1,10 +1,11 @@
- +