mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
feat(services): add LRCLIB service, scan utility, and context menus
This commit is contained in:
@@ -55,6 +55,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "http://*.dzcdn.net/**"
|
"url": "http://*.dzcdn.net/**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://lrclib.net/**"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import { playback } from '$lib/stores/playback';
|
import { playback } from '$lib/stores/playback';
|
||||||
import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte';
|
import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte';
|
||||||
import PageDecoration from '$lib/components/PageDecoration.svelte';
|
import PageDecoration from '$lib/components/PageDecoration.svelte';
|
||||||
|
import { fetchAndSaveLyrics } from '$lib/services/lrclib';
|
||||||
|
import { setSuccess, setWarning, setError } from '$lib/stores/status';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
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[] {
|
function getContextMenuItems(trackIndex: number): MenuItem[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -73,6 +101,10 @@
|
|||||||
{
|
{
|
||||||
label: 'Play Next',
|
label: 'Play Next',
|
||||||
action: () => playback.playNext([tracks[trackIndex]])
|
action: () => playback.playNext([tracks[trackIndex]])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fetch Lyrics via LRCLIB',
|
||||||
|
action: () => handleFetchLyrics(trackIndex)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
137
src/lib/library/lyricScanner.ts
Normal file
137
src/lib/library/lyricScanner.ts
Normal 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
197
src/lib/services/lrclib.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,6 +140,10 @@
|
|||||||
<img src="/icons/deezer.png" alt="" class="nav-icon" />
|
<img src="/icons/deezer.png" alt="" class="nav-icon" />
|
||||||
Deezer
|
Deezer
|
||||||
</a>
|
</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">
|
<!-- <a href="/services/soulseek" class="nav-item nav-subitem">
|
||||||
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
|
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
|
||||||
Soulseek
|
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>
|
||||||
36
static/icons/lrclib-logo.svg
Normal file
36
static/icons/lrclib-logo.svg
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #111041;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #fdfdfd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="QGdPvo.tif">
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M16.663,126.482C7.066,121.586,1.49,116.223,0,104.918V23.048C1.071,14.257,6.144,6.818,13.95,2.709c.515-.271,1.772-.711,3.089-1.26l1.247-.473C19.133.695,21.597.003,23.055,0h81.726c12.256,1.082,21.845,10.748,23.218,22.883l-.029,82.497c-1.337,10.446-8.245,18.709-18.356,21.676-.66.194-3.565.748-3.753.787-.191.04-.422.039-.627.073l-82.5.039c-1.539-.285-4.031-.886-4.833-1.085s-1.241-.389-1.241-.389ZM101.882,30.168c.536-.844-1.616-4.825-2.279-5.633-3.824-4.665-12.876-4.358-17.059-.33-5.699,5.488-5.939,20.919-.088,26.331,4.514,4.175,13.651,3.675,17.647-1.066.837-.993,3.255-4.753,1.639-5.583-.465-.239-5.132-1.468-5.462-1.368-.442.134-.908,1.926-1.294,2.49-1.213,1.769-3.804,2.347-5.808,1.761-4.917-1.438-4.464-13.841-2.336-17.285,1.674-2.708,5.696-3.17,7.586-.432.509.738.686,2.309,1.433,2.506.299.079,5.856-1.13,6.021-1.391ZM46.689,46.266h-11.65l-.246-.246v-24.114c0-.186-.672-.521-.913-.564-.655-.116-5.135-.084-5.608.114-.2.084-.435.269-.502.483l-.076,30.358.41.41,18.885.093c.342-.122.401-.565.441-.872.093-.711.076-4.809-.184-5.234-.078-.128-.426-.398-.557-.427ZM58.257,51.926v-10.991l.246-.246h3.446c.68,0,5.287,10.795,6.181,12.111.272.158,5.816.124,6.285.024.325-.069.597-.346.676-.65.287-1.101-5.527-10.991-6.005-12.888,1.046-.912,2.315-1.428,3.287-2.612,4.498-5.479.959-13.534-5.907-14.938-2.907-.594-11.097-.796-14.037-.408-.632.084-.93.259-1.053.916l.025,29.809c.117.379.295.683.707.77.543.115,5.498.041,5.754-.145.093-.067.373-.63.395-.753ZM97.498,85.496c.047-.09,1.235-.965,1.536-1.333,2.362-2.89,2.058-8.084-.66-10.663-3.188-3.025-11.839-2.811-16.085-2.633-.561.024-3.636.2-3.811.374l-.05,30.729.741.408c7.002-.546,20.72,2.585,22.058-7.492.348-2.626.038-5.033-1.701-7.117-.403-.483-2.268-1.812-2.028-2.272ZM46.896,96.011c-.662-.664-11.744.254-12.284-.433-.152-.194-.16-.429-.157-.663l-.027-23.267c-.083-.326-.176-.534-.538-.611-.571-.122-5.752-.039-6.081.153l-.148,30.845,18.961.262c.135-.04.247-.079.328-.203.269-.413.248-5.782-.053-6.084ZM58.703,71.073c-.327.095-.628.511-.619.858l.044,29.664c.117.481.41.675.878.763.698.131,4.515.126,5.229,0,.475-.083.929-.356.921-.884l-.013-29.694c-.079-.37-.368-.659-.738-.738-.47-.1-5.305-.084-5.701.031Z"/>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="cls-2" d="M46.896,96.011c.301.302.322,5.671.053,6.084-.081.124-.193.163-.328.203l-18.961-.262.148-30.845c.329-.191,5.51-.274,6.081-.153.361.077.455.285.538.611l.027,23.267c-.003.234.005.469.157.663.54.687,11.622-.231,12.284.433Z"/>
|
||||||
|
<path class="cls-2" d="M58.703,71.073c.396-.115,5.232-.131,5.701-.031.37.079.66.368.738.738l.013,29.694c.008.528-.446.801-.921.884-.714.125-4.531.131-5.229,0-.468-.088-.761-.282-.878-.763l-.044-29.664c-.009-.347.292-.763.619-.858Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M85.167,96.3v-7.382h5.825c.15,0,1.228.41,1.447.522,3.393,1.743,1.775,6.86-1.447,6.86h-5.825Z"/>
|
||||||
|
<path class="cls-1" d="M85.167,83.012v-5.906h5.989c.325,0,1.698.849,1.959,1.158,1.689,2.004-.298,4.747-2.615,4.747h-5.333Z"/>
|
||||||
|
<path class="cls-2" d="M97.498,85.496c-.24.46,1.625,1.789,2.028,2.272,1.738,2.084,2.049,4.492,1.701,7.117-1.338,10.078-15.056,6.947-22.058,7.492l-.741-.408.05-30.729c.175-.174,3.249-.35,3.811-.374,4.246-.178,12.896-.392,16.085,2.633,2.718,2.579,3.022,7.772.66,10.663-.301.368-1.489,1.243-1.536,1.333ZM85.167,83.012h5.333c2.317,0,4.304-2.743,2.615-4.747-.261-.31-1.634-1.158-1.959-1.158h-5.989v5.906ZM85.167,96.3h5.825c3.222,0,4.84-5.117,1.447-6.86-.219-.112-1.297-.522-1.447-.522h-5.825v7.382Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="cls-2" d="M58.257,51.926c-.022.123-.302.686-.395.753-.256.185-5.211.259-5.754.145-.412-.087-.589-.391-.707-.77l-.025-29.809c.123-.657.421-.832,1.053-.916,2.94-.389,11.13-.187,14.037.408,6.866,1.404,10.405,9.459,5.907,14.938-.972,1.184-2.24,1.7-3.287,2.612.478,1.897,6.291,11.786,6.005,12.888-.079.304-.351.58-.676.65-.469.1-6.013.133-6.285-.024-.894-1.316-5.501-12.111-6.181-12.111h-3.446l-.246.246v10.991ZM58.257,34.619h5.005c1.232,0,3.441-1.02,3.682-2.388.12-.682.075-2.405-.251-3.011-.288-.536-1.879-1.655-2.447-1.655h-5.907c-.639,0,.119,6.472-.082,7.054Z"/>
|
||||||
|
<path class="cls-2" d="M101.882,30.168c-.166.261-5.723,1.47-6.021,1.391-.747-.197-.924-1.768-1.433-2.506-1.89-2.738-5.912-2.277-7.586.432-2.128,3.444-2.581,15.847,2.336,17.285,2.004.586,4.596.008,5.808-1.761.386-.563.852-2.356,1.294-2.49.331-.1,4.997,1.129,5.462,1.368,1.616.829-.802,4.59-1.639,5.583-3.996,4.741-13.133,5.241-17.647,1.066-5.851-5.412-5.611-20.843.088-26.331,4.183-4.028,13.235-4.335,17.059.33.663.808,2.815,4.789,2.279,5.633Z"/>
|
||||||
|
<path class="cls-2" d="M46.689,46.266c.131.029.479.299.557.427.259.426.277,4.523.184,5.234-.04.307-.099.75-.441.872l-18.885-.093-.41-.41.076-30.358c.067-.213.302-.399.502-.483.473-.198,4.953-.23,5.608-.114.241.042.913.378.913.564v24.114l.246.246h11.65Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path class="cls-1" d="M58.257,34.619c.201-.582-.557-7.054.082-7.054h5.907c.568,0,2.159,1.118,2.447,1.655.325.606.371,2.329.251,3.011-.241,1.368-2.451,2.388-3.682,2.388h-5.005Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.3 KiB |
Reference in New Issue
Block a user