mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51: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[];
|
||||
}
|
||||
Reference in New Issue
Block a user