feat: add support for multi-disc albums
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user