diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 98c9a08..8f299d9 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -55,6 +55,9 @@ }, { "url": "http://*.dzcdn.net/**" + }, + { + "url": "https://lrclib.net/**" } ] }, diff --git a/src/lib/components/CollectionView.svelte b/src/lib/components/CollectionView.svelte index 82da94a..d37f500 100644 --- a/src/lib/components/CollectionView.svelte +++ b/src/lib/components/CollectionView.svelte @@ -4,6 +4,8 @@ import { playback } from '$lib/stores/playback'; import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte'; import PageDecoration from '$lib/components/PageDecoration.svelte'; + import { fetchAndSaveLyrics } from '$lib/services/lrclib'; + import { setSuccess, setWarning, setError } from '$lib/stores/status'; interface Props { title: string; @@ -60,6 +62,32 @@ }; } + async function handleFetchLyrics(trackIndex: number) { + const track = tracks[trackIndex]; + if (!track) return; + + try { + const result = await fetchAndSaveLyrics(track.path, { + title: track.metadata.title || 'Unknown', + artist: track.metadata.artist || 'Unknown Artist', + album: track.metadata.album || 'Unknown Album', + duration: track.metadata.duration || 0 + }); + + if (result.success) { + if (result.instrumental) { + setWarning(`${track.metadata.title || track.filename} is instrumental`); + } else if (result.hasLyrics) { + setSuccess(`Lyrics fetched for ${track.metadata.title || track.filename}`); + } + } else { + setWarning(`No lyrics found for ${track.metadata.title || track.filename}`); + } + } catch (error) { + setError(`Failed to fetch lyrics for ${track.metadata.title || track.filename}`); + } + } + function getContextMenuItems(trackIndex: number): MenuItem[] { return [ { @@ -73,6 +101,10 @@ { label: 'Play Next', action: () => playback.playNext([tracks[trackIndex]]) + }, + { + label: 'Fetch Lyrics via LRCLIB', + action: () => handleFetchLyrics(trackIndex) } ]; } diff --git a/src/lib/library/lyricScanner.ts b/src/lib/library/lyricScanner.ts new file mode 100644 index 0000000..9efbc1d --- /dev/null +++ b/src/lib/library/lyricScanner.ts @@ -0,0 +1,137 @@ +/** + * Library scanner for tracks without lyrics files + */ + +import { readDir, exists, readFile } from '@tauri-apps/plugin-fs'; +import { parseBuffer } from 'music-metadata'; +import type { AudioFormat } from '$lib/types/track'; + +export interface TrackWithoutLyrics { + path: string; + filename: string; + title: string; + artist: string; + album: string; + duration: number; // in seconds + format: AudioFormat; +} + +/** + * Check if a track has an accompanying .lrc file + */ +async function hasLyricsFile(audioFilePath: string): Promise { + const lrcPath = audioFilePath.replace(/\.[^.]+$/, '.lrc'); + return await exists(lrcPath); +} + +/** + * Get audio format from file extension + */ +function getAudioFormat(filename: string): AudioFormat { + const ext = filename.toLowerCase().split('.').pop(); + switch (ext) { + case 'flac': + return 'flac'; + case 'mp3': + return 'mp3'; + case 'opus': + return 'opus'; + case 'ogg': + return 'ogg'; + case 'm4a': + return 'm4a'; + case 'wav': + return 'wav'; + default: + return 'unknown'; + } +} + +/** + * Scan a single directory for audio files without lyrics + */ +async function scanDirectoryForMissingLyrics( + dirPath: string, + results: TrackWithoutLyrics[] +): Promise { + const audioExtensions = ['.flac', '.mp3', '.opus', '.ogg', '.m4a', '.wav']; + + try { + const entries = await readDir(dirPath); + + for (const entry of entries) { + const fullPath = `${dirPath}/${entry.name}`; + + if (entry.isDirectory) { + // Recursively scan subdirectories + await scanDirectoryForMissingLyrics(fullPath, results); + } else { + // Check if it's an audio file + const hasAudioExt = audioExtensions.some(ext => + entry.name.toLowerCase().endsWith(ext) + ); + + if (hasAudioExt) { + // Check if it has a .lrc file + const hasLyrics = await hasLyricsFile(fullPath); + + if (!hasLyrics) { + // Read metadata + try { + const fileData = await readFile(fullPath); + const metadata = await parseBuffer( + fileData, + { mimeType: `audio/${getAudioFormat(entry.name)}` }, + { duration: true, skipCovers: true } + ); + + const title = metadata.common.title || entry.name.replace(/\.[^.]+$/, ''); + const artist = metadata.common.artist || metadata.common.albumartist || 'Unknown Artist'; + const album = metadata.common.album || 'Unknown Album'; + const duration = metadata.format.duration || 0; + + // Only add if we have minimum required metadata + if (title && artist && album && duration > 0) { + results.push({ + path: fullPath, + filename: entry.name, + title, + artist, + album, + duration, + format: getAudioFormat(entry.name) + }); + } + } catch (error) { + console.warn(`[LyricScanner] Could not read metadata for ${fullPath}:`, error); + } + } + } + } + } + } catch (error) { + console.error(`[LyricScanner] Error scanning directory ${dirPath}:`, error); + } +} + +/** + * Scan the music library for tracks without .lrc files + */ +export async function scanForTracksWithoutLyrics( + musicFolderPath: string, + onProgress?: (current: number, total: number, message: string) => void +): Promise { + const results: TrackWithoutLyrics[] = []; + + if (onProgress) { + onProgress(0, 0, 'Scanning for tracks without lyrics...'); + } + + await scanDirectoryForMissingLyrics(musicFolderPath, results); + + if (onProgress) { + onProgress(results.length, results.length, `Found ${results.length} tracks without lyrics`); + } + + return results; +} diff --git a/src/lib/services/lrclib.ts b/src/lib/services/lrclib.ts new file mode 100644 index 0000000..76fa5f1 --- /dev/null +++ b/src/lib/services/lrclib.ts @@ -0,0 +1,197 @@ +/** + * LRCLIB API client for fetching lyrics + * https://lrclib.net/ + */ + +import { fetch } from '@tauri-apps/plugin-http'; +import { writeFile } from '@tauri-apps/plugin-fs'; + +const LRCLIB_API_BASE = 'https://lrclib.net/api'; +const USER_AGENT = 'Shark Music Player v1.0.0 (https://github.com/soulshark)'; + +export interface LRCLIBLyrics { + id: number; + trackName: string; + artistName: string; + albumName: string; + duration: number; + instrumental: boolean; + plainLyrics: string | null; + syncedLyrics: string | null; +} + +export interface LRCLIBSearchParams { + trackName: string; + artistName: string; + albumName: string; + duration: number; // in seconds +} + +/** + * Check if LRCLIB API is available + */ +export async function checkApiStatus(): Promise { + try { + const response = await fetch(`${LRCLIB_API_BASE}/get/1`, { + method: 'GET', + headers: { + 'User-Agent': USER_AGENT + } + }); + return response.ok || response.status === 404; // 404 is fine, means API is up + } catch (error) { + console.error('[LRCLIB] API check failed:', error); + return false; + } +} + +/** + * Get lyrics for a track by its signature + * Searches external sources if not in LRCLIB database + */ +export async function getLyrics(params: LRCLIBSearchParams): Promise { + try { + const queryParams = new URLSearchParams({ + track_name: params.trackName, + artist_name: params.artistName, + album_name: params.albumName, + duration: params.duration.toString() + }); + + const response = await fetch(`${LRCLIB_API_BASE}/get?${queryParams}`, { + method: 'GET', + headers: { + 'User-Agent': USER_AGENT + } + }); + + if (response.status === 404) { + console.log('[LRCLIB] No lyrics found for:', params.trackName); + return null; + } + + if (!response.ok) { + throw new Error(`LRCLIB API error: ${response.status}`); + } + + const data = await response.json(); + return data as LRCLIBLyrics; + } catch (error) { + console.error('[LRCLIB] Error fetching lyrics:', error); + return null; + } +} + +/** + * Get lyrics from cache only (no external search) + */ +export async function getLyricsCached(params: LRCLIBSearchParams): Promise { + try { + const queryParams = new URLSearchParams({ + track_name: params.trackName, + artist_name: params.artistName, + album_name: params.albumName, + duration: params.duration.toString() + }); + + const response = await fetch(`${LRCLIB_API_BASE}/get-cached?${queryParams}`, { + method: 'GET', + headers: { + 'User-Agent': USER_AGENT + } + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error(`LRCLIB API error: ${response.status}`); + } + + const data = await response.json(); + return data as LRCLIBLyrics; + } catch (error) { + console.error('[LRCLIB] Error fetching cached lyrics:', error); + return null; + } +} + +/** + * Search for lyrics by keywords + */ +export async function searchLyrics(query: string): Promise { + try { + const queryParams = new URLSearchParams({ q: query }); + + const response = await fetch(`${LRCLIB_API_BASE}/search?${queryParams}`, { + method: 'GET', + headers: { + 'User-Agent': USER_AGENT + } + }); + + if (!response.ok) { + throw new Error(`LRCLIB API error: ${response.status}`); + } + + const data = await response.json(); + return data as LRCLIBLyrics[]; + } catch (error) { + console.error('[LRCLIB] Error searching lyrics:', error); + return []; + } +} + +/** + * Save lyrics as .lrc file next to the audio file + */ +export async function saveLyricsFile(audioFilePath: string, lyrics: string): Promise { + const lrcPath = audioFilePath.replace(/\.[^.]+$/, '.lrc'); + await writeFile(lrcPath, new TextEncoder().encode(lyrics)); + console.log('[LRCLIB] Saved lyrics to:', lrcPath); +} + +/** + * Fetch and save lyrics for a track + * Returns true if successful, false otherwise + */ +export async function fetchAndSaveLyrics( + trackPath: string, + metadata: { + title: string; + artist: string; + album: string; + duration: number; // in seconds + } +): Promise<{ success: boolean; hasLyrics: boolean; instrumental: boolean }> { + try { + const lyrics = await getLyrics({ + trackName: metadata.title, + artistName: metadata.artist, + albumName: metadata.album, + duration: Math.round(metadata.duration) + }); + + if (!lyrics) { + return { success: false, hasLyrics: false, instrumental: false }; + } + + if (lyrics.instrumental) { + return { success: true, hasLyrics: false, instrumental: true }; + } + + // Prefer synced lyrics, fall back to plain lyrics + const lyricsText = lyrics.syncedLyrics || lyrics.plainLyrics; + + if (!lyricsText) { + return { success: false, hasLyrics: false, instrumental: false }; + } + + await saveLyricsFile(trackPath, lyricsText); + return { success: true, hasLyrics: true, instrumental: false }; + } catch (error) { + console.error('[LRCLIB] Error fetching and saving lyrics:', error); + return { success: false, hasLyrics: false, instrumental: false }; + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5c6eeb3..ff6cf72 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -140,6 +140,10 @@ Deezer + + + LRCLIB + + + +
  • + +
  • +
  • + +
  • +
    + + +
    +
    + {#if viewMode === 'tracks'} + +
    + {tracks.length} track{tracks.length !== 1 ? 's' : ''} found +
    + + {#if tracks.length > 0} + + {/if} +
    +
    + + {#if scanProgress} +
    + {scanProgress} +
    + {/if} + + {#if !$settings.musicFolder} +
    + Please set a music folder in Settings first +
    + {/if} + + + {#if tracks.length > 0} +
    + + + + + + + + + + + + {#each tracks as track, i} + handleTrackClick(i)} + ondblclick={() => handleTrackDoubleClick(i)} + oncontextmenu={(e) => handleContextMenu(e, i)} + > + + + + + + + {/each} + +
    TitleArtistAlbumDurationFormat
    {track.title}{track.artist}{track.album}{formatDuration(track.duration)}{track.format.toUpperCase()}
    +
    + {:else if !scanning} +
    +

    No tracks without lyrics found. Click "Scan Library" to check your library.

    +
    + {/if} + {:else if viewMode === 'info'} + +
    +
    + API Status +
    + Status: + {#if checkingApi} + Checking... + {:else if apiAvailable === true} + ✓ Available + {:else if apiAvailable === false} + ✗ Unavailable + {:else} + Unknown + {/if} +
    +
    + +
    +
    + +
    + About LRCLIB +

    LRCLIB is a free, open API for fetching synchronized and plain lyrics for music tracks.

    +

    For more info, see lrclib.net

    +
    +
    + {/if} +
    +
    + + + +{#if contextMenu} + contextMenu = null} + /> +{/if} + + diff --git a/static/icons/lrclib-logo.svg b/static/icons/lrclib-logo.svg new file mode 100644 index 0000000..c1add16 --- /dev/null +++ b/static/icons/lrclib-logo.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file