Files
shark/src/lib/library/lyricScanner.ts

174 lines
4.8 KiB
TypeScript

/**
* 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';
import { upsertTrack, getTracksWithoutLyrics, type DbTrack } from '$lib/library/database';
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
* Results are cached in the database
*/
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);
// Save results to database
if (onProgress) {
onProgress(results.length, results.length, 'Caching results...');
}
for (const track of results) {
await upsertTrack({
path: track.path,
title: track.title,
artist: track.artist,
album: track.album,
duration: Math.round(track.duration),
format: track.format,
has_lyrics: false
});
}
if (onProgress) {
onProgress(results.length, results.length, `Found ${results.length} tracks without lyrics`);
}
return results;
}
/**
* Load cached tracks without lyrics from database
*/
export async function loadCachedTracksWithoutLyrics(): Promise<TrackWithoutLyrics[]> {
const dbTracks = await getTracksWithoutLyrics();
return dbTracks.map((track: DbTrack) => ({
path: track.path,
filename: track.path.split('/').pop() || track.path,
title: track.title,
artist: track.artist,
album: track.album,
duration: track.duration,
format: track.format as AudioFormat
}));
}