mirror of
https://github.com/markuryy/shark.git
synced 2025-12-15 12:41:02 +00:00
feat(services): add LRCLIB service, scan utility, and context menus
This commit is contained in:
@@ -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
|
||||
|
||||
468
src/routes/services/lrclib/+page.svelte
Normal file
468
src/routes/services/lrclib/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user