From b990d721c2ed237c2199fda38527a1fb2f2f2e55 Mon Sep 17 00:00:00 2001 From: Markury Date: Tue, 30 Sep 2025 20:45:01 -0400 Subject: [PATCH] feat: music-metadata --- bun.lock | 27 +++ package.json | 3 +- src/lib/library/playlist.ts | 205 +++++++++++++++++++++++ src/lib/types/track.ts | 39 +++++ src/routes/playlists/[name]/+page.svelte | 147 ++++++++++++++++ 5 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 src/lib/library/playlist.ts create mode 100644 src/lib/types/track.ts create mode 100644 src/routes/playlists/[name]/+page.svelte diff --git a/bun.lock b/bun.lock index 494e562..962f4ec 100644 --- a/bun.lock +++ b/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=="], } } diff --git a/package.json b/package.json index 089ae12..ef13922 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/library/playlist.ts b/src/lib/library/playlist.ts new file mode 100644 index 0000000..c7c109e --- /dev/null +++ b/src/lib/library/playlist.ts @@ -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 { + 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 { + // 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 { + try { + // Read file as binary + const fileData = await readFile(filePath); + + // Get MIME type from format + const mimeMap: Record = { + '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 { + 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 + }; +} diff --git a/src/lib/types/track.ts b/src/lib/types/track.ts new file mode 100644 index 0000000..f362452 --- /dev/null +++ b/src/lib/types/track.ts @@ -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[]; +} diff --git a/src/routes/playlists/[name]/+page.svelte b/src/routes/playlists/[name]/+page.svelte new file mode 100644 index 0000000..eb38021 --- /dev/null +++ b/src/routes/playlists/[name]/+page.svelte @@ -0,0 +1,147 @@ + + +
+

{playlistName}

+ + {#if loading} +

Loading playlist...

+ {:else if error} +

{error}

+ {:else if playlistData && playlistData.tracks.length === 0} +

No tracks in this playlist.

+ {:else if playlistData} +
+

{playlistData.tracks.length} track{playlistData.tracks.length !== 1 ? 's' : ''}

+ + + + + + + + + + + + + + {#each playlistData.tracks as track, index} + + + + + + + + + {/each} + +
#TitleArtistAlbumDurationFormat
{index + 1}{track.metadata.title || track.filename}{track.metadata.artist || '—'}{track.metadata.album || '—'} + {#if track.metadata.duration} + {Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')} + {:else} + — + {/if} + {track.format.toUpperCase()}
+
+ {/if} +
+ +