feat: music-metadata

This commit is contained in:
2025-09-30 20:45:01 -04:00
parent b5d14a71d6
commit b990d721c2
5 changed files with 420 additions and 1 deletions

205
src/lib/library/playlist.ts Normal file
View File

@@ -0,0 +1,205 @@
import { readTextFile, readFile, exists } from '@tauri-apps/plugin-fs';
import { parseBuffer } from 'music-metadata';
import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
/**
* 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';
}
}
/**
* Parse M3U/M3U8 playlist file
* Supports both basic M3U and extended M3U8 format
*/
export async function parsePlaylist(playlistPath: string): Promise<string[]> {
try {
const content = await readTextFile(playlistPath);
const lines = content.split('\n').map(line => line.trim());
const tracks: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip empty lines and comments (except #EXTINF which precedes track info)
if (!line || (line.startsWith('#') && !line.startsWith('#EXTINF'))) {
continue;
}
// If it's an EXTINF line, the next line should be the file path
if (line.startsWith('#EXTINF')) {
i++; // Move to next line
if (i < lines.length && lines[i] && !lines[i].startsWith('#')) {
tracks.push(lines[i]);
}
} else if (!line.startsWith('#')) {
// Regular M3U format - just file paths
tracks.push(line);
}
}
return tracks;
} catch (error) {
console.error('Error parsing playlist:', error);
return [];
}
}
/**
* Normalize path by resolving .. and . segments
*/
function normalizePath(path: string): string {
const parts = path.split('/').filter(p => p && p !== '.');
const normalized: string[] = [];
for (const part of parts) {
if (part === '..') {
normalized.pop(); // Go up one directory
} else {
normalized.push(part);
}
}
return '/' + normalized.join('/');
}
/**
* Try to find the actual file by attempting various path variations
* Some playlist generators add track numbers that don't exist in actual filenames
*/
async function findActualFilePath(basePath: string): Promise<string | null> {
// First try the exact path
if (await exists(basePath)) {
return basePath;
}
// Try removing leading track numbers from the filename (e.g., "01 " or "01. ")
const pathParts = basePath.split('/');
const filename = pathParts[pathParts.length - 1];
// Match patterns like "01 Filename.ext" or "01. Filename.ext"
const withoutNumber = filename.replace(/^\d+[\s.]+/, '');
if (withoutNumber !== filename) {
const altPath = [...pathParts.slice(0, -1), withoutNumber].join('/');
if (await exists(altPath)) {
return altPath;
}
}
return null;
}
/**
* Read metadata from audio file
*/
async function readAudioMetadata(filePath: string, format: AudioFormat): Promise<TrackMetadata> {
try {
// Read file as binary
const fileData = await readFile(filePath);
// Get MIME type from format
const mimeMap: Record<AudioFormat, string> = {
'flac': 'audio/flac',
'mp3': 'audio/mpeg',
'opus': 'audio/opus',
'ogg': 'audio/ogg',
'm4a': 'audio/mp4',
'wav': 'audio/wav',
'unknown': 'audio/mpeg'
};
// Parse metadata from buffer
const metadata = await parseBuffer(fileData, mimeMap[format], { duration: true });
return {
title: metadata.common.title,
artist: metadata.common.artist,
album: metadata.common.album,
albumArtist: metadata.common.albumartist,
year: metadata.common.year,
trackNumber: metadata.common.track?.no ?? undefined,
genre: metadata.common.genre?.[0],
duration: metadata.format.duration,
bitrate: metadata.format.bitrate ? Math.round(metadata.format.bitrate / 1000) : undefined,
sampleRate: metadata.format.sampleRate
};
} catch (error) {
console.error('Error reading audio metadata:', error);
return {};
}
}
/**
* Load playlist with track information
*/
export async function loadPlaylistTracks(
playlistPath: string,
playlistName: string,
baseFolder: string
): Promise<PlaylistWithTracks> {
const trackPaths = await parsePlaylist(playlistPath);
// Load tracks with metadata in parallel
const tracks: Track[] = await Promise.all(
trackPaths.map(async (trackPath) => {
// Handle relative paths - resolve relative to playlist location or music folder
let fullPath = trackPath.startsWith('/') || trackPath.includes(':\\')
? trackPath // Absolute path
: `${baseFolder}/${trackPath}`; // Relative path
// Normalize path to remove .. and . segments for Tauri security
fullPath = normalizePath(fullPath);
// Try to find the actual file (handles track number mismatches)
const actualPath = await findActualFilePath(fullPath);
const filename = trackPath.split('/').pop() || trackPath.split('\\').pop() || trackPath;
const format = getAudioFormat(filename);
// Read metadata from actual audio file if found
const metadata = actualPath
? await readAudioMetadata(actualPath, format)
: {};
// Fallback to filename parsing if no metadata
if (!metadata.title) {
const nameWithoutExt = filename.replace(/\.(flac|mp3|opus|ogg|m4a|wav)$/i, '');
const parts = nameWithoutExt.split(' - ');
metadata.title = parts.length > 1 ? parts[1] : nameWithoutExt;
metadata.artist = parts.length > 1 ? parts[0] : undefined;
}
return {
path: actualPath || fullPath, // Use actual path if found
filename,
format,
metadata
};
})
);
return {
name: playlistName,
path: playlistPath,
tracks
};
}

39
src/lib/types/track.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* Audio file formats supported
*/
export type AudioFormat = 'flac' | 'mp3' | 'opus' | 'ogg' | 'm4a' | 'wav' | 'unknown';
/**
* Track metadata from audio file tags
*/
export interface TrackMetadata {
title?: string;
artist?: string;
album?: string;
albumArtist?: string;
year?: number;
trackNumber?: number;
genre?: string;
duration?: number; // in seconds
bitrate?: number; // in kbps
sampleRate?: number; // in Hz
}
/**
* Complete track information
*/
export interface Track {
path: string;
filename: string;
format: AudioFormat;
metadata: TrackMetadata;
}
/**
* Playlist with tracks
*/
export interface PlaylistWithTracks {
name: string;
path: string;
tracks: Track[];
}