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:
27
bun.lock
27
bun.lock
@@ -9,6 +9,7 @@
|
||||
"@tauri-apps/plugin-fs": "~2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-store": "~2",
|
||||
"music-metadata": "^11.9.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
@@ -23,6 +24,8 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@borewit/text-codec": ["@borewit/text-codec@0.2.0", "", {}, "sha512-X999CKBxGwX8wW+4gFibsbiNdwqmdQEXmUejIWaIqdrHBgS5ARIOOeyiQbHjP9G58xVEPcuvP6VwwH3A0OFTOA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
|
||||
@@ -177,6 +180,10 @@
|
||||
|
||||
"@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-PjBnlnH6jyI71MGhrPaxUUCsOzc7WO1mbc4gRhME0m2oxLgCqbksw6JyeKQimuzv4ysdpNO3YbmaY2haf82a3A=="],
|
||||
|
||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
@@ -191,6 +198,8 @@
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
@@ -207,8 +216,14 @@
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
@@ -217,12 +232,16 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"music-metadata": ["music-metadata@11.9.0", "", { "dependencies": { "@borewit/text-codec": "^0.2.0", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.0.0", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.5.0" } }, "sha512-J7VqD8FY6KRcm75Fzj86FPsckiD/EdvO5OS3P+JiMf/2krP3TcAseZYfkic6eFeJ0iBhhzcdxgfu8hLW95aXXw=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
@@ -243,20 +262,28 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
|
||||
|
||||
"svelte": ["svelte@5.39.6", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-bOJXmuwLNaoqPCTWO8mPu/fwxI5peGE5Efe7oo6Cakpz/G60vsnVF6mxbGODaxMUFUKEnjm6XOwHEqOht6cbvw=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.3.2", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-71udP5w2kaSTcX8iV0hn3o2FWlabQHhJTJLIQrCqMsrcOeDUO2VhCQKKCA8AMVHSPwdxLEWkUWh9OKxns5PD9w=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
"vite": ["vite@6.3.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
|
||||
"token-types/@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-fs": "~2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-store": "~2"
|
||||
"@tauri-apps/plugin-store": "~2",
|
||||
"music-metadata": "^11.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
|
||||
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