feat: add support for multi-disc albums

This commit is contained in:
2026-07-25 21:54:25 +01:00
parent 5185d4dc0b
commit c6080b975a
5 changed files with 292 additions and 108 deletions
+17 -2
View File
@@ -61,9 +61,13 @@ export async function POST(request: Request) {
// Calculate total size // Calculate total size
const totalSize = tracks.reduce((acc, track) => acc + (track.size || 0), 0); 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) => { tracks.sort((a, b) => {
if ('error' in a || 'error' in b) return 0; // Keep order if error 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 trackA = a.common?.track?.no ?? 0;
const trackB = b.common?.track?.no ?? 0; const trackB = b.common?.track?.no ?? 0;
if (trackA && trackB) return trackA - trackB; 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) { if (sampleRates.size > 1 || bitDepths.size > 1) {
mixed = true; mixed = true;
qualityDetails = `NOTE: Mixed formats. Sample Rates: ${Array.from(sampleRates).join(', ')} Hz. Bit Depths: ${Array.from(bitDepths).join(', ')} bit.`; 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 // Generate detailed message for mixed content
const mixedDetails = tracks.map(t => { const mixedDetails = tracks.map(t => {
if ('error' in t) return `${t.filename}: Error`; 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'); }).join('\n');
qualityDetails += '\n' + mixedDetails; qualityDetails += '\n' + mixedDetails;
+42 -5
View File
@@ -34,6 +34,10 @@ export async function POST(request: Request) {
// Sort tracks // Sort tracks
tracks.sort((a, b) => { tracks.sort((a, b) => {
if ('error' in a || 'error' in b) return 0; 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 trackA = a.common?.track?.no ?? 0;
const trackB = b.common?.track?.no ?? 0; const trackB = b.common?.track?.no ?? 0;
if (trackA && trackB) return trackA - trackB; 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; 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) { if (sampleRates.size > 1 || bitDepths.size > 1) {
mixed = true; mixed = true;
qualityDetails = `\n\nNOTE: Mixed formats. Sample Rates: ${Array.from(sampleRates).join(', ')} Hz. Bit Depths: ${Array.from(bitDepths).join(', ')} bit.`; qualityDetails = `\n\nNOTE: Mixed formats. Sample Rates: ${Array.from(sampleRates).join(', ')} Hz. Bit Depths: ${Array.from(bitDepths).join(', ')} bit.`;
const mixedDetails = tracks.map(t => { const mixedDetails = tracks.map(t => {
if ('error' in t) return `${t.filename}: Error`; 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'); }).join('\n');
qualityDetails += '\n' + mixedDetails; qualityDetails += '\n' + mixedDetails;
@@ -155,10 +170,32 @@ export async function POST(request: Request) {
} }
// Tracklist // Tracklist
const tracklist = tracks.map(t => {
if ('error' in t) return `${t.filename} (Error)`; let currentDisc: number | null = null;
return `${t.common?.track?.no || '00'}. ${t.common?.title || t.filename}`; const tracklistLines: string[] = [];
}).join('\n');
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 // Description
let description = "Sourced from TIDAL"; let description = "Sourced from TIDAL";
+7 -2
View File
@@ -32,9 +32,14 @@ export async function POST(request: Request) {
if ('error' in track) return; if ('error' in track) return;
const trackNo = track.common?.track?.no; const trackNo = track.common?.track?.no;
const discNo = track.common?.disk?.no;
if (trackNo === undefined || trackNo === null) return; 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 outputPath = path.join(albumSpecDir, outputFilename);
const relativePath = path.join(albumId, outputFilename); const relativePath = path.join(albumId, outputFilename);
const publicUrl = `/api/spectrogram?path=${encodeURIComponent(relativePath)}`; const publicUrl = `/api/spectrogram?path=${encodeURIComponent(relativePath)}`;
@@ -43,7 +48,7 @@ export async function POST(request: Request) {
if (!exists) { if (!exists) {
// Generate // Generate
const title = `${trackNo} - ${track.common?.title || track.filename}`; const title = `${discPrefix}${trackNo} - ${track.common?.title || track.filename}`;
const args = [ const args = [
track.path, track.path,
+203 -97
View File
@@ -975,12 +975,64 @@ export function AlbumView({
) : null; ) : null;
}, [album.coverPath, album.name]); }, [album.coverPath, album.name]);
const discGroups = useMemo(() => {
if (!data?.tracks) return [];
const map = new Map<number, { track: AnalysisTrack; originalIndex: number }[]>();
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(() => { const spectrogramsMemo = useMemo(() => {
if (!data?.tracks) return null; if (!data?.tracks) return null;
return data.tracks.map((track: AnalysisTrack) => { return data.tracks.map((track: AnalysisTrack) => {
const spec = spectrograms[track.filename]; const spec = spectrograms[track.filename];
if (!spec?.exists || !spec.url) return null; 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 ( return (
<div <div
key={track.filename} key={track.filename}
@@ -988,8 +1040,8 @@ export function AlbumView({
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="font-medium text-white/90 flex items-center gap-3"> <div className="font-medium text-white/90 flex items-center gap-3">
<span className="w-6 h-6 rounded bg-white/10 text-xs flex items-center justify-center font-mono text-white/50"> <span className="h-6 rounded bg-white/10 text-xs flex items-center justify-center font-mono text-white/50 px-2">
{(!("error" in track) && track.common?.track?.no) || ""} {trackNumStr}
</span> </span>
<span> <span>
{(!("error" in track) && track.common?.title) || track.filename} {(!("error" in track) && track.common?.title) || track.filename}
@@ -1017,7 +1069,7 @@ export function AlbumView({
</div> </div>
); );
}); });
}, [data?.tracks, spectrograms]); }, [data?.tracks, spectrograms, isMultiDisc]);
if (loading) { if (loading) {
return ( return (
@@ -1050,12 +1102,35 @@ export function AlbumView({
if (!data) return null; if (!data) return null;
const { metadata, tracks, quality } = data; const { metadata, tracks, quality } = data;
const tracklistText = tracks let currentDiscText: number | null = null;
.map((t) => { const tracklistTextLines: string[] = [];
if ("error" in t) return `${t.filename} (Error)`;
return `${t.common?.track?.no || "00"} - ${t.common?.title || t.filename}`; tracks.forEach((t) => {
}) if ("error" in t) {
.join("\n"); 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 // Dynamic offset deduction for Tracklist Max Height
const isLogEnabled = process.env.NEXT_PUBLIC_SHOW_SYSTEM_LOG === "true"; const isLogEnabled = process.env.NEXT_PUBLIC_SHOW_SYSTEM_LOG === "true";
@@ -1420,97 +1495,128 @@ export function AlbumView({
style={{ maxHeight: `calc(100vh - ${tracklistOffsetRem}rem)` }} style={{ maxHeight: `calc(100vh - ${tracklistOffsetRem}rem)` }}
> >
<div className="h-full"> <div className="h-full">
<div className="p-2 space-y-0.5"> <div className="p-2 space-y-3">
{tracks.map((track: AnalysisTrack, i: number) => { {discGroups.map((group) => (
const isError = "error" in track; <div key={group.discNo} className="space-y-0.5">
return ( {isMultiDisc && (
<motion.div <div className="flex items-center gap-2 pt-2 pb-1.5 px-2 text-xs font-semibold uppercase tracking-wider text-primary/80 border-b border-white/5 mb-1 select-none">
key={i} <Disc className="w-3.5 h-3.5 shrink-0 text-primary" />
initial={{ opacity: 0, x: -10 }} <span>Disc {group.discNo}</span>
animate={{ opacity: 1, x: 0 }} <span className="text-[10px] font-normal text-white/40 lowercase">
transition={{ delay: i * 0.02 }} ({group.items.length} {group.items.length === 1 ? "track" : "tracks"})
className={`group relative flex items-center justify-between p-2 rounded-lg transition-colors text-sm overflow-hidden select-none cursor-pointer ${playingTrack === track.filename ? "bg-white/10" : "hover:bg-white/5"}`}
onMouseDown={(e) =>
handleTrackMouseDown(e, track.filename)
}
onMouseMove={(e) =>
handleTrackMouseMove(e, track.filename)
}
onMouseUp={(e) => handleTrackMouseUp(e, track.filename)}
onMouseLeave={handleTrackMouseLeave}
>
{/* Progress Bar */}
{playingTrack === track.filename && (
<div
className="absolute inset-y-0 left-0 bg-white/10 z-0 pointer-events-none transition-[width] duration-100 ease-linear"
style={{ width: `${progress}%` }}
/>
)}
<div className="relative z-10 flex items-center gap-3 min-w-0 pointer-events-none">
<span className="text-white/30 font-mono w-6 text-right shrink-0">
{isError
? "!!"
: (track.common?.track?.no || i + 1)
.toString()
.padStart(2, "0")}
</span> </span>
<div className="flex flex-col min-w-0 pr-2 pointer-events-auto">
<ScrollableTrackName
text={
isError
? track.filename
: track.common?.title || track.filename
}
isError={isError}
/>
<span className="text-[10px] text-white/30 truncate font-mono">
{track.filename}
</span>
</div>
</div> </div>
<div className="relative z-10 flex items-center gap-4 shrink-0"> )}
{!isError && ( {group.items.map(({ track, originalIndex: i }) => {
<div className="flex items-center gap-2 text-xs text-white/40 font-mono"> const isError = "error" in track;
<span> let discNo = group.discNo;
{track.format?.bitsPerSample}bit/ let trackNo = 0;
{track.format?.sampleRate
? track.format.sampleRate / 1000 if (!isError) {
: "?"} discNo = track.common?.disk?.no || group.discNo;
kHz trackNo = track.common?.track?.no || 0;
</span> if (!track.common?.disk?.no) {
{process.env.NEXT_PUBLIC_SHOW_TRACK_BITRATE === const match = track.filename.match(/^(\d+)-(\d+)\b/);
"true" && ( if (match) discNo = parseInt(match[1], 10);
<span> }
{Math.round( }
(track.format?.bitrate || 0) / 1000,
)} const trackNumDisplay = isError
kbps ? "!!"
</span> : isMultiDisc
)} ? `${discNo}-${(trackNo > 0 ? trackNo : i + 1)
</div> .toString()
)} .padStart(2, "0")}`
{/* Spectrogram Button */} : (trackNo > 0 ? trackNo : i + 1)
<Button .toString()
variant="ghost" .padStart(2, "0");
size="icon"
className={`h-6 w-6 cursor-pointer relative z-20 ${spectrograms[track.filename]?.exists ? "text-primary hover:text-primary hover:bg-primary/10" : "text-white/20 hover:text-white hover:bg-white/10"}`} return (
onClick={(e) => { <motion.div
e.stopPropagation(); key={i}
const spec = spectrograms[track.filename]; initial={{ opacity: 0, x: -10 }}
if (spec?.exists && spec.url) { animate={{ opacity: 1, x: 0 }}
setPreviewUrl(spec.url); transition={{ delay: i * 0.02 }}
} className={`group relative flex items-center justify-between p-2 rounded-lg transition-colors text-sm overflow-hidden select-none cursor-pointer ${playingTrack === track.filename ? "bg-white/10" : "hover:bg-white/5"}`}
}} onMouseDown={(e) =>
disabled={!spectrograms[track.filename]?.exists} handleTrackMouseDown(e, track.filename)
title="View Spectrogram" }
onMouseMove={(e) =>
handleTrackMouseMove(e, track.filename)
}
onMouseUp={(e) => handleTrackMouseUp(e, track.filename)}
onMouseLeave={handleTrackMouseLeave}
> >
<Activity className="w-3 h-3" /> {/* Progress Bar */}
</Button> {playingTrack === track.filename && (
</div> <div
</motion.div> className="absolute inset-y-0 left-0 bg-white/10 z-0 pointer-events-none transition-[width] duration-100 ease-linear"
); style={{ width: `${progress}%` }}
})} />
)}
<div className="relative z-10 flex items-center gap-3 min-w-0 pointer-events-none">
<span className={`text-white/30 font-mono text-right shrink-0 ${isMultiDisc ? "w-10" : "w-6"}`}>
{trackNumDisplay}
</span>
<div className="flex flex-col min-w-0 pr-2 pointer-events-auto">
<ScrollableTrackName
text={
isError
? track.filename
: track.common?.title || track.filename
}
isError={isError}
/>
<span className="text-[10px] text-white/30 truncate font-mono">
{track.filename}
</span>
</div>
</div>
<div className="relative z-10 flex items-center gap-4 shrink-0">
{!isError && (
<div className="flex items-center gap-2 text-xs text-white/40 font-mono">
<span>
{track.format?.bitsPerSample}bit/
{track.format?.sampleRate
? track.format.sampleRate / 1000
: "?"}
kHz
</span>
{process.env.NEXT_PUBLIC_SHOW_TRACK_BITRATE ===
"true" && (
<span>
{Math.round(
(track.format?.bitrate || 0) / 1000,
)}
kbps
</span>
)}
</div>
)}
{/* Spectrogram Button */}
<Button
variant="ghost"
size="icon"
className={`h-6 w-6 cursor-pointer relative z-20 ${spectrograms[track.filename]?.exists ? "text-primary hover:text-primary hover:bg-primary/10" : "text-white/20 hover:text-white hover:bg-white/10"}`}
onClick={(e) => {
e.stopPropagation();
const spec = spectrograms[track.filename];
if (spec?.exists && spec.url) {
setPreviewUrl(spec.url);
}
}}
disabled={!spectrograms[track.filename]?.exists}
title="View Spectrogram"
>
<Activity className="w-3 h-3" />
</Button>
</div>
</motion.div>
);
})}
</div>
))}
</div> </div>
</div> </div>
</div> </div>
+23 -2
View File
@@ -1,4 +1,5 @@
import { parseFile } from 'music-metadata'; import { parseFile } from 'music-metadata';
import path from 'path';
export interface AudioMetadata { export interface AudioMetadata {
format: { format: {
@@ -39,6 +40,26 @@ export async function getFlacMetadata(filePath: string): Promise<AudioMetadata>
//console.log(metadata.native.vorbis); //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 { return {
format: { format: {
sampleRate: metadata.format.sampleRate, sampleRate: metadata.format.sampleRate,
@@ -50,8 +71,8 @@ export async function getFlacMetadata(filePath: string): Promise<AudioMetadata>
title: metadata.common.title, title: metadata.common.title,
artist: metadata.common.artist, artist: metadata.common.artist,
album: metadata.common.album, album: metadata.common.album,
track: metadata.common.track, track: trackObj,
disk: metadata.common.disk, disk: diskObj,
year: metadata.common.year, year: metadata.common.year,
date: metadata.common.date, date: metadata.common.date,
copyright: metadata.common.copyright, copyright: metadata.common.copyright,