diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts index 4982436..1bd387f 100644 --- a/app/api/analyze/route.ts +++ b/app/api/analyze/route.ts @@ -61,9 +61,13 @@ export async function POST(request: Request) { // Calculate total size const totalSize = tracks.reduce((acc, track) => acc + (track.size || 0), 0); - // Sort tracks by track number if available, else by filename + // Sort tracks by disc number first, then track number, else by filename tracks.sort((a, b) => { if ('error' in a || 'error' in b) return 0; // Keep order if error + const discA = a.common?.disk?.no ?? 1; + const discB = b.common?.disk?.no ?? 1; + if (discA !== discB) return discA - discB; + const trackA = a.common?.track?.no ?? 0; const trackB = b.common?.track?.no ?? 0; if (trackA && trackB) return trackA - trackB; @@ -95,6 +99,11 @@ export async function POST(request: Request) { } }); + const discNumbers = new Set( + tracks.map(t => ('error' in t ? 1 : (t.common?.disk?.no || 1))) + ); + const isMultiDisc = discNumbers.size > 1 || Array.from(discNumbers).some(d => d > 1); + if (sampleRates.size > 1 || bitDepths.size > 1) { mixed = true; qualityDetails = `NOTE: Mixed formats. Sample Rates: ${Array.from(sampleRates).join(', ')} Hz. Bit Depths: ${Array.from(bitDepths).join(', ')} bit.`; @@ -102,7 +111,13 @@ export async function POST(request: Request) { // Generate detailed message for mixed content const mixedDetails = tracks.map(t => { if ('error' in t) return `${t.filename}: Error`; - return `Track ${t.common?.track?.no || '?'}: ${t.format?.bitsPerSample}bit / ${t.format?.sampleRate}Hz`; + const discNo = t.common?.disk?.no || 1; + const trackNo = t.common?.track?.no; + let trackStr = trackNo !== undefined && trackNo !== null ? String(trackNo).padStart(2, '0') : '?'; + if (isMultiDisc) { + trackStr = `${discNo}-${trackStr}`; + } + return `Track ${trackStr}: ${t.format?.bitsPerSample}bit / ${t.format?.sampleRate}Hz`; }).join('\n'); qualityDetails += '\n' + mixedDetails; diff --git a/app/api/red-template/route.ts b/app/api/red-template/route.ts index be7f221..8b9cf53 100644 --- a/app/api/red-template/route.ts +++ b/app/api/red-template/route.ts @@ -34,6 +34,10 @@ export async function POST(request: Request) { // Sort tracks tracks.sort((a, b) => { if ('error' in a || 'error' in b) return 0; + const discA = a.common?.disk?.no ?? 1; + const discB = b.common?.disk?.no ?? 1; + if (discA !== discB) return discA - discB; + const trackA = a.common?.track?.no ?? 0; const trackB = b.common?.track?.no ?? 0; if (trackA && trackB) return trackA - trackB; @@ -58,13 +62,24 @@ export async function POST(request: Request) { if (!sr || sr < 48000 || !bd || bd < 24) isHiRes = false; }); + const discNumbers = new Set( + tracks.map(t => ('error' in t ? 1 : (t.common?.disk?.no || 1))) + ); + const isMultiDisc = discNumbers.size > 1 || Array.from(discNumbers).some(d => d > 1); + if (sampleRates.size > 1 || bitDepths.size > 1) { mixed = true; qualityDetails = `\n\nNOTE: Mixed formats. Sample Rates: ${Array.from(sampleRates).join(', ')} Hz. Bit Depths: ${Array.from(bitDepths).join(', ')} bit.`; const mixedDetails = tracks.map(t => { if ('error' in t) return `${t.filename}: Error`; - return `Track ${t.common?.track?.no || '?'}: ${t.format?.bitsPerSample}bit / ${t.format?.sampleRate}Hz`; + const discNo = t.common?.disk?.no || 1; + const trackNo = t.common?.track?.no; + let trackStr = trackNo !== undefined && trackNo !== null ? String(trackNo).padStart(2, '0') : '?'; + if (isMultiDisc) { + trackStr = `${discNo}-${trackStr}`; + } + return `Track ${trackStr}: ${t.format?.bitsPerSample}bit / ${t.format?.sampleRate}Hz`; }).join('\n'); qualityDetails += '\n' + mixedDetails; @@ -155,10 +170,32 @@ export async function POST(request: Request) { } // Tracklist - const tracklist = tracks.map(t => { - if ('error' in t) return `${t.filename} (Error)`; - return `${t.common?.track?.no || '00'}. ${t.common?.title || t.filename}`; - }).join('\n'); + + let currentDisc: number | null = null; + const tracklistLines: string[] = []; + + tracks.forEach(t => { + if ('error' in t) { + tracklistLines.push(`${t.filename} (Error)`); + return; + } + + const discNo = t.common?.disk?.no || 1; + const trackNo = t.common?.track?.no + ? String(t.common.track.no).padStart(2, '0') + : '00'; + + if (isMultiDisc && discNo !== currentDisc) { + if (currentDisc !== null) tracklistLines.push(''); + tracklistLines.push(`[b]Disc ${discNo}[/b]`); + currentDisc = discNo; + } + + const trackLabel = isMultiDisc ? `${discNo}-${trackNo}` : trackNo; + tracklistLines.push(`${trackLabel}. ${t.common?.title || t.filename}`); + }); + + const tracklist = tracklistLines.join('\n'); // Description let description = "Sourced from TIDAL"; diff --git a/app/api/spectrogram/route.ts b/app/api/spectrogram/route.ts index 4c3fba5..3ad2304 100644 --- a/app/api/spectrogram/route.ts +++ b/app/api/spectrogram/route.ts @@ -32,9 +32,14 @@ export async function POST(request: Request) { if ('error' in track) return; const trackNo = track.common?.track?.no; + const discNo = track.common?.disk?.no; if (trackNo === undefined || trackNo === null) return; - const outputFilename = `${trackNo}.png`; + const discPrefix = (discNo && discNo > 1) || tracks.some(t => !('error' in t) && (t.common?.disk?.no ?? 1) > 1) + ? `${discNo ?? 1}-` + : ''; + + const outputFilename = `${discPrefix}${trackNo}.png`; const outputPath = path.join(albumSpecDir, outputFilename); const relativePath = path.join(albumId, outputFilename); const publicUrl = `/api/spectrogram?path=${encodeURIComponent(relativePath)}`; @@ -43,7 +48,7 @@ export async function POST(request: Request) { if (!exists) { // Generate - const title = `${trackNo} - ${track.common?.title || track.filename}`; + const title = `${discPrefix}${trackNo} - ${track.common?.title || track.filename}`; const args = [ track.path, diff --git a/components/album-view.tsx b/components/album-view.tsx index 6c9a343..1f3a013 100644 --- a/components/album-view.tsx +++ b/components/album-view.tsx @@ -975,12 +975,64 @@ export function AlbumView({ ) : null; }, [album.coverPath, album.name]); + const discGroups = useMemo(() => { + if (!data?.tracks) return []; + const map = new Map(); + + data.tracks.forEach((track: AnalysisTrack, i: number) => { + let discNo = 1; + if (!("error" in track)) { + if (track.common?.disk?.no) { + discNo = track.common.disk.no; + } else { + const match = track.filename.match(/^(\d+)-(\d+)\b/); + if (match) discNo = parseInt(match[1], 10); + } + } + if (!map.has(discNo)) { + map.set(discNo, []); + } + map.get(discNo)!.push({ track, originalIndex: i }); + }); + + const sortedDiscs = Array.from(map.keys()).sort((a, b) => a - b); + return sortedDiscs.map((discNo) => ({ + discNo, + items: map.get(discNo)!, + })); + }, [data?.tracks]); + + const isMultiDisc = useMemo(() => { + return ( + discGroups.length > 1 || + (discGroups.length === 1 && discGroups[0].discNo > 1) + ); + }, [discGroups]); + const spectrogramsMemo = useMemo(() => { if (!data?.tracks) return null; return data.tracks.map((track: AnalysisTrack) => { const spec = spectrograms[track.filename]; if (!spec?.exists || !spec.url) return null; + let discNo = 1; + let trackNo = 0; + if (!("error" in track)) { + discNo = track.common?.disk?.no || 1; + trackNo = track.common?.track?.no || 0; + if (!track.common?.disk?.no) { + const match = track.filename.match(/^(\d+)-(\d+)\b/); + if (match) { + discNo = parseInt(match[1], 10); + if (!track.common?.track?.no) trackNo = parseInt(match[2], 10); + } + } + } + + const trackNumStr = isMultiDisc + ? `${discNo}-${trackNo.toString().padStart(2, "0")}` + : (trackNo || "").toString().padStart(2, "0"); + return (
- - {(!("error" in track) && track.common?.track?.no) || ""} + + {trackNumStr} {(!("error" in track) && track.common?.title) || track.filename} @@ -1017,7 +1069,7 @@ export function AlbumView({
); }); - }, [data?.tracks, spectrograms]); + }, [data?.tracks, spectrograms, isMultiDisc]); if (loading) { return ( @@ -1050,12 +1102,35 @@ export function AlbumView({ if (!data) return null; const { metadata, tracks, quality } = data; - const tracklistText = tracks - .map((t) => { - if ("error" in t) return `${t.filename} (Error)`; - return `${t.common?.track?.no || "00"} - ${t.common?.title || t.filename}`; - }) - .join("\n"); + let currentDiscText: number | null = null; + const tracklistTextLines: string[] = []; + + tracks.forEach((t) => { + if ("error" in t) { + tracklistTextLines.push(`${t.filename} (Error)`); + return; + } + let discNo = t.common?.disk?.no || 1; + let trackNo = (t.common?.track?.no || 0).toString().padStart(2, "0"); + if (!t.common?.disk?.no) { + const match = t.filename.match(/^(\d+)-(\d+)\b/); + if (match) { + discNo = parseInt(match[1], 10); + if (!t.common?.track?.no) trackNo = match[2].padStart(2, "0"); + } + } + + if (isMultiDisc && discNo !== currentDiscText) { + if (currentDiscText !== null) tracklistTextLines.push(""); + tracklistTextLines.push(`Disc ${discNo}`); + currentDiscText = discNo; + } + + const trackNumStr = isMultiDisc ? `${discNo}-${trackNo}` : trackNo; + tracklistTextLines.push(`${trackNumStr} - ${t.common?.title || t.filename}`); + }); + + const tracklistText = tracklistTextLines.join("\n"); // Dynamic offset deduction for Tracklist Max Height const isLogEnabled = process.env.NEXT_PUBLIC_SHOW_SYSTEM_LOG === "true"; @@ -1420,97 +1495,128 @@ export function AlbumView({ style={{ maxHeight: `calc(100vh - ${tracklistOffsetRem}rem)` }} >
-
- {tracks.map((track: AnalysisTrack, i: number) => { - const isError = "error" in track; - return ( - - handleTrackMouseDown(e, track.filename) - } - onMouseMove={(e) => - handleTrackMouseMove(e, track.filename) - } - onMouseUp={(e) => handleTrackMouseUp(e, track.filename)} - onMouseLeave={handleTrackMouseLeave} - > - {/* Progress Bar */} - {playingTrack === track.filename && ( -
- )} - -
- - {isError - ? "!!" - : (track.common?.track?.no || i + 1) - .toString() - .padStart(2, "0")} +
+ {discGroups.map((group) => ( +
+ {isMultiDisc && ( +
+ + Disc {group.discNo} + + ({group.items.length} {group.items.length === 1 ? "track" : "tracks"}) -
- - - {track.filename} - -
-
- {!isError && ( -
- - {track.format?.bitsPerSample}bit/ - {track.format?.sampleRate - ? track.format.sampleRate / 1000 - : "?"} - kHz - - {process.env.NEXT_PUBLIC_SHOW_TRACK_BITRATE === - "true" && ( - - {Math.round( - (track.format?.bitrate || 0) / 1000, - )} - kbps - - )} -
- )} - {/* Spectrogram Button */} - -
- - ); - })} + {/* Progress Bar */} + {playingTrack === track.filename && ( +
+ )} + +
+ + {trackNumDisplay} + +
+ + + {track.filename} + +
+
+
+ {!isError && ( +
+ + {track.format?.bitsPerSample}bit/ + {track.format?.sampleRate + ? track.format.sampleRate / 1000 + : "?"} + kHz + + {process.env.NEXT_PUBLIC_SHOW_TRACK_BITRATE === + "true" && ( + + {Math.round( + (track.format?.bitrate || 0) / 1000, + )} + kbps + + )} +
+ )} + {/* Spectrogram Button */} + +
+ + ); + })} +
+ ))}
diff --git a/lib/utils/audio.ts b/lib/utils/audio.ts index c26172f..1cf6dc6 100644 --- a/lib/utils/audio.ts +++ b/lib/utils/audio.ts @@ -1,4 +1,5 @@ import { parseFile } from 'music-metadata'; +import path from 'path'; export interface AudioMetadata { format: { @@ -39,6 +40,26 @@ export async function getFlacMetadata(filePath: string): Promise //console.log(metadata.native.vorbis); } + const filename = path.basename(filePath); + let diskNo = metadata.common.disk?.no; + let trackNo = metadata.common.track?.no; + + // Extract disc and track number from multi-disc filename prefix e.g. "1-03 - Title" + const multiDiscMatch = filename.match(/^(\d+)-(\d+)\b/); + if (multiDiscMatch) { + if (!diskNo) diskNo = parseInt(multiDiscMatch[1], 10); + if (!trackNo) trackNo = parseInt(multiDiscMatch[2], 10); + } + + const trackObj = { + no: trackNo ?? metadata.common.track?.no ?? null, + of: metadata.common.track?.of ?? null + }; + const diskObj = { + no: diskNo ?? metadata.common.disk?.no ?? null, + of: metadata.common.disk?.of ?? null + }; + return { format: { sampleRate: metadata.format.sampleRate, @@ -50,8 +71,8 @@ export async function getFlacMetadata(filePath: string): Promise title: metadata.common.title, artist: metadata.common.artist, album: metadata.common.album, - track: metadata.common.track, - disk: metadata.common.disk, + track: trackObj, + disk: diskObj, year: metadata.common.year, date: metadata.common.date, copyright: metadata.common.copyright,