mirror of
https://github.com/markuryy/shark.git
synced 2025-12-13 12:01:01 +00:00
feat: music-metadata
This commit is contained in:
205
src/lib/library/playlist.ts
Normal file
205
src/lib/library/playlist.ts
Normal 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
39
src/lib/types/track.ts
Normal 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[];
|
||||
}
|
||||
147
src/routes/playlists/[name]/+page.svelte
Normal file
147
src/routes/playlists/[name]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user