feat(services): add LRCLIB service, scan utility, and context menus

This commit is contained in:
2025-10-04 23:56:58 -04:00
parent 38db835973
commit 25ce2d676e
7 changed files with 877 additions and 0 deletions

View File

@@ -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)
}
];
}

View File

@@ -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<boolean> {
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<void> {
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<TrackWithoutLyrics[]> {
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;
}

197
src/lib/services/lrclib.ts Normal file
View File

@@ -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<boolean> {
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<LRCLIBLyrics | null> {
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<LRCLIBLyrics | null> {
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<LRCLIBLyrics[]> {
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<void> {
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 };
}
}

View File

@@ -140,6 +140,10 @@
<img src="/icons/deezer.png" alt="" class="nav-icon" />
Deezer
</a>
<a href="/services/lrclib" class="nav-item nav-subitem">
<img src="/icons/lrclib-logo.svg" alt="" class="nav-icon" />
LRCLIB
</a>
<!-- <a href="/services/soulseek" class="nav-item nav-subitem">
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
Soulseek

View File

@@ -0,0 +1,468 @@
<script lang="ts">
import { onMount } from 'svelte';
import { settings } from '$lib/stores/settings';
import { setSuccess, setWarning, setError, setInfo } from '$lib/stores/status';
import { checkApiStatus, fetchAndSaveLyrics } from '$lib/services/lrclib';
import { scanForTracksWithoutLyrics, type TrackWithoutLyrics } from '$lib/library/lyricScanner';
import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte';
type ViewMode = 'tracks' | 'info';
let viewMode = $state<ViewMode>('tracks');
let apiAvailable = $state<boolean | null>(null);
let checkingApi = $state(false);
let scanning = $state(false);
let scanProgress = $state<string | null>(null);
let tracks = $state<TrackWithoutLyrics[]>([]);
let selectedTrackIndex = $state<number | null>(null);
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
onMount(async () => {
await checkApi();
});
async function checkApi() {
checkingApi = true;
apiAvailable = await checkApiStatus();
checkingApi = false;
}
async function handleScan() {
if (!$settings.musicFolder || scanning) {
return;
}
scanning = true;
scanProgress = 'Starting scan...';
tracks = [];
try {
const foundTracks = await scanForTracksWithoutLyrics(
$settings.musicFolder,
(current, total, message) => {
scanProgress = message;
}
);
tracks = foundTracks;
if (tracks.length === 0) {
setInfo('All tracks have lyrics!');
} else {
setInfo(`Found ${tracks.length} track${tracks.length !== 1 ? 's' : ''} without lyrics`);
}
} catch (error) {
setError('Error scanning library: ' + (error instanceof Error ? error.message : String(error)));
} finally {
scanning = false;
scanProgress = null;
}
}
async function fetchLyricsForTrack(index: number) {
const track = tracks[index];
if (!track) return;
try {
const result = await fetchAndSaveLyrics(track.path, {
title: track.title,
artist: track.artist,
album: track.album,
duration: track.duration
});
if (result.success) {
if (result.instrumental) {
setInfo(`Track marked as instrumental: ${track.title}`);
} else if (result.hasLyrics) {
setSuccess(`Lyrics fetched for ${track.title}`);
}
// Remove from list on success
tracks = tracks.filter((_, i) => i !== index);
} else {
setWarning(`No lyrics found for ${track.title}`);
}
} catch (error) {
setError(`Failed to fetch lyrics for ${track.title}`);
}
}
async function fetchLyricsForAllTracks() {
if (tracks.length === 0) return;
let successCount = 0;
let failCount = 0;
setInfo(`Fetching lyrics for ${tracks.length} tracks...`, 0);
const tracksCopy = [...tracks];
for (let i = 0; i < tracksCopy.length; i++) {
const track = tracksCopy[i];
try {
const result = await fetchAndSaveLyrics(track.path, {
title: track.title,
artist: track.artist,
album: track.album,
duration: track.duration
});
if (result.success && (result.hasLyrics || result.instrumental)) {
successCount++;
} else {
failCount++;
}
} catch (error) {
failCount++;
}
// Update progress
if ((i + 1) % 10 === 0 || i === tracksCopy.length - 1) {
setInfo(`Fetching lyrics... ${i + 1}/${tracksCopy.length}`, 0);
}
}
// Rescan to update the list
tracks = [];
await handleScan();
// Show completion message
if (successCount > 0 && failCount > 0) {
setSuccess(`Lyrics found for ${successCount} track${successCount !== 1 ? 's' : ''} (${failCount} failed)`);
} else if (successCount > 0) {
setSuccess(`Lyrics found for ${successCount} track${successCount !== 1 ? 's' : ''}`);
} else {
setWarning('No lyrics found for any tracks');
}
}
function handleTrackClick(index: number) {
selectedTrackIndex = index;
}
function handleTrackDoubleClick(index: number) {
fetchLyricsForTrack(index);
}
function handleContextMenu(e: MouseEvent, index: number) {
e.preventDefault();
contextMenu = {
x: e.clientX,
y: e.clientY,
trackIndex: index
};
}
function getContextMenuItems(trackIndex: number): MenuItem[] {
return [
{
label: 'Fetch Lyrics',
action: () => fetchLyricsForTrack(trackIndex)
}
];
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${String(secs).padStart(2, '0')}`;
}
</script>
<div class="lrclib-wrapper">
<h2 style="padding: 8px">LRCLIB</h2>
<section class="lrclib-content">
<!-- Tabs -->
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
-->
<menu role="tablist">
<li role="tab" aria-selected={viewMode === 'tracks'}>
<button onclick={() => viewMode = 'tracks'}>Missing Lyrics</button>
</li>
<li role="tab" aria-selected={viewMode === 'info'}>
<button onclick={() => viewMode = 'info'}>Info</button>
</li>
</menu>
<!-- Tab Content -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if viewMode === 'tracks'}
<!-- Tracks View -->
<div class="tab-header">
<span>{tracks.length} track{tracks.length !== 1 ? 's' : ''} found</span>
<div class="actions-row">
<button onclick={handleScan} disabled={scanning || !$settings.musicFolder}>
{scanning ? 'Scanning...' : 'Scan Library'}
</button>
{#if tracks.length > 0}
<button onclick={fetchLyricsForAllTracks} disabled={scanning}>
Fetch All ({tracks.length})
</button>
{/if}
</div>
</div>
{#if scanProgress}
<div class="progress-banner">
{scanProgress}
</div>
{/if}
{#if !$settings.musicFolder}
<div class="help-banner">
Please set a music folder in Settings first
</div>
{/if}
<!-- Results Table -->
{#if tracks.length > 0}
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Duration</th>
<th>Format</th>
</tr>
</thead>
<tbody>
{#each tracks as track, i}
<tr
class:highlighted={selectedTrackIndex === i}
onclick={() => handleTrackClick(i)}
ondblclick={() => handleTrackDoubleClick(i)}
oncontextmenu={(e) => handleContextMenu(e, i)}
>
<td>{track.title}</td>
<td>{track.artist}</td>
<td>{track.album}</td>
<td class="duration">{formatDuration(track.duration)}</td>
<td class="format">{track.format.toUpperCase()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if !scanning}
<div class="empty-state">
<p>No tracks without lyrics found. Click "Scan Library" to check your library.</p>
</div>
{/if}
{:else if viewMode === 'info'}
<!-- Info View -->
<div class="info-container">
<fieldset>
<legend>API Status</legend>
<div class="field-row">
<span class="field-label">Status:</span>
{#if checkingApi}
<span>Checking...</span>
{:else if apiAvailable === true}
<span class="status-indicator status-ok">✓ Available</span>
{:else if apiAvailable === false}
<span class="status-indicator status-error">✗ Unavailable</span>
{:else}
<span class="status-indicator">Unknown</span>
{/if}
</div>
<div class="field-row">
<button onclick={checkApi} disabled={checkingApi}>
{checkingApi ? 'Checking...' : 'Check API'}
</button>
</div>
</fieldset>
<fieldset>
<legend>About LRCLIB</legend>
<p>LRCLIB is a free, open API for fetching synchronized and plain lyrics for music tracks.</p>
<p>For more info, see <a href="https://lrclib.net/" target="_blank" rel="noopener noreferrer">lrclib.net</a></p>
</fieldset>
</div>
{/if}
</div>
</div>
</section>
</div>
{#if contextMenu}
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={getContextMenuItems(contextMenu.trackIndex)}
onClose={() => contextMenu = null}
/>
{/if}
<style>
.lrclib-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
h2 {
margin: 0;
flex-shrink: 0;
}
.lrclib-content {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content {
margin-top: -2px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--button-shadow, #808080);
flex-shrink: 0;
}
.status-row {
display: flex;
align-items: center;
gap: 12px;
}
.status-indicator {
font-weight: bold;
font-size: 11px;
}
.status-ok {
color: #00aa00;
}
.status-error {
color: #ff6b6b;
}
.actions-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.progress-banner {
padding: 8px;
background: var(--button-shadow, #2a2a2a);
border-bottom: 1px solid var(--button-shadow, #808080);
font-size: 11px;
text-align: center;
flex-shrink: 0;
}
.help-banner {
padding: 8px;
background: var(--button-shadow, #2a2a2a);
border-bottom: 1px solid var(--button-shadow, #808080);
font-size: 11px;
color: #808080;
text-align: center;
flex-shrink: 0;
}
.window-body {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
table {
width: 100%;
}
thead {
position: sticky;
top: 0;
z-index: 1;
background: #121212;
}
th {
text-align: left;
}
.duration {
font-family: monospace;
font-size: 0.9em;
text-align: center;
width: 80px;
}
.format {
font-family: monospace;
font-size: 0.85em;
text-transform: uppercase;
text-align: center;
width: 80px;
}
.empty-state {
padding: 32px 16px;
text-align: center;
opacity: 0.6;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state p {
margin: 0;
}
.info-container {
padding: 16px;
overflow-y: auto;
flex: 1;
}
.info-container fieldset {
margin-bottom: 16px;
}
.info-container p {
margin: 8px 0;
line-height: 1.4;
}
.field-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.field-label {
font-weight: bold;
min-width: 60px;
}
</style>