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[];
}

View File

@@ -0,0 +1,147 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { settings, loadSettings } from '$lib/stores/settings';
import { scanPlaylists } from '$lib/library/scanner';
import { loadPlaylistTracks, type PlaylistWithTracks } from '$lib/library/playlist';
import type { Track } from '$lib/types/track';
let playlistData = $state<PlaylistWithTracks | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let playlistName = $derived(decodeURIComponent($page.params.name));
onMount(async () => {
await loadSettings();
await loadPlaylist();
});
async function loadPlaylist() {
loading = true;
error = null;
if (!$settings.playlistsFolder || !$settings.musicFolder) {
error = 'Playlists or music folder not configured. Please set them in Settings.';
loading = false;
return;
}
try {
// Find the playlist file
const playlists = await scanPlaylists($settings.playlistsFolder);
const playlist = playlists.find(p => p.name === playlistName);
if (!playlist) {
error = `Playlist "${playlistName}" not found.`;
loading = false;
return;
}
// Load tracks from the playlist file
playlistData = await loadPlaylistTracks(
playlist.path,
playlist.name,
$settings.musicFolder
);
loading = false;
} catch (e) {
error = 'Error loading playlist: ' + (e as Error).message;
loading = false;
}
}
</script>
<div>
<h2>{playlistName}</h2>
{#if loading}
<p>Loading playlist...</p>
{:else if error}
<p class="error">{error}</p>
{:else if playlistData && playlistData.tracks.length === 0}
<p>No tracks in this playlist.</p>
{:else if playlistData}
<section class="playlist-content">
<p class="track-count">{playlistData.tracks.length} track{playlistData.tracks.length !== 1 ? 's' : ''}</p>
<table>
<thead>
<tr>
<th style="width: 40px;">#</th>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Duration</th>
<th>Format</th>
</tr>
</thead>
<tbody>
{#each playlistData.tracks as track, index}
<tr>
<td class="track-number">{index + 1}</td>
<td>{track.metadata.title || track.filename}</td>
<td>{track.metadata.artist || '—'}</td>
<td class="album">{track.metadata.album || '—'}</td>
<td class="duration">
{#if track.metadata.duration}
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
{:else}
{/if}
</td>
<td class="format">{track.format.toUpperCase()}</td>
</tr>
{/each}
</tbody>
</table>
</section>
{/if}
</div>
<style>
h2 {
margin-top: 0;
}
.playlist-content {
margin-top: 20px;
}
.track-count {
margin-bottom: 12px;
opacity: 0.7;
font-size: 0.9em;
}
.error {
color: #ff6b6b;
}
table {
width: 100%;
}
.track-number {
text-align: center;
opacity: 0.6;
}
.album {
opacity: 0.7;
}
.duration {
font-family: monospace;
font-size: 0.9em;
text-align: center;
}
.format {
font-family: monospace;
font-size: 0.85em;
text-transform: uppercase;
text-align: center;
}
</style>