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
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;
+42 -5
View File
@@ -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";
+7 -2
View File
@@ -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,
+123 -17
View File
@@ -975,12 +975,64 @@ export function AlbumView({
) : null;
}, [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(() => {
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 (
<div
key={track.filename}
@@ -988,8 +1040,8 @@ export function AlbumView({
>
<div className="flex items-center justify-between">
<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">
{(!("error" in track) && track.common?.track?.no) || ""}
<span className="h-6 rounded bg-white/10 text-xs flex items-center justify-center font-mono text-white/50 px-2">
{trackNumStr}
</span>
<span>
{(!("error" in track) && track.common?.title) || track.filename}
@@ -1017,7 +1069,7 @@ export function AlbumView({
</div>
);
});
}, [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,9 +1495,42 @@ export function AlbumView({
style={{ maxHeight: `calc(100vh - ${tracklistOffsetRem}rem)` }}
>
<div className="h-full">
<div className="p-2 space-y-0.5">
{tracks.map((track: AnalysisTrack, i: number) => {
<div className="p-2 space-y-3">
{discGroups.map((group) => (
<div key={group.discNo} className="space-y-0.5">
{isMultiDisc && (
<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">
<Disc className="w-3.5 h-3.5 shrink-0 text-primary" />
<span>Disc {group.discNo}</span>
<span className="text-[10px] font-normal text-white/40 lowercase">
({group.items.length} {group.items.length === 1 ? "track" : "tracks"})
</span>
</div>
)}
{group.items.map(({ track, originalIndex: i }) => {
const isError = "error" in track;
let discNo = group.discNo;
let trackNo = 0;
if (!isError) {
discNo = track.common?.disk?.no || group.discNo;
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);
}
}
const trackNumDisplay = isError
? "!!"
: isMultiDisc
? `${discNo}-${(trackNo > 0 ? trackNo : i + 1)
.toString()
.padStart(2, "0")}`
: (trackNo > 0 ? trackNo : i + 1)
.toString()
.padStart(2, "0");
return (
<motion.div
key={i}
@@ -1448,12 +1556,8 @@ export function AlbumView({
)}
<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 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
@@ -1512,6 +1616,8 @@ export function AlbumView({
);
})}
</div>
))}
</div>
</div>
</div>
</div>
+23 -2
View File
@@ -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<AudioMetadata>
//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<AudioMetadata>
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,