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

@@ -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>