music (web): order album tracks by track number, not readdir order

meta.json is written in ffprobe/readdir order (arbitrary), and the web player
rendered/queued it as-is — so albums like Andrew Bird's "The Mysterious
Production of Eggs" showed scrambled (11, 7, 5, 14, 1, …). The web Track type
didn't even carry the `track` tag.

Add `track` to the Track type + a shared sortTracks(): by track NUMBER (parsed
from the "n/total" tag), falling back to tag title only for tracks without a
number. Applied to the rendered tracklist (setAlbum) and both play queues.
Verified it reorders that album to 1→14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 11:50:34 +00:00
co-authored by Claude Opus 4.8
parent 21cb3489a7
commit 6fc34363c8
2 changed files with 29 additions and 3 deletions
@@ -10,6 +10,7 @@ import {
TYPE_ORDER,
coverUrl,
isAudio,
sortTracks,
toRel,
type AlbumMeta,
type Discography,
@@ -66,7 +67,7 @@ export const MusicDetail = () => {
if (audio.length) {
try {
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(rel)}`);
if (!cancelled) setAlbum(meta.tracks);
if (!cancelled) setAlbum(sortTracks(meta.tracks));
} catch {
if (!cancelled) setAlbum(audio.sort().map((f) => ({ file: f })));
}
@@ -99,7 +100,7 @@ export const MusicDetail = () => {
const playAlbum = async (albumRel: string, startIndex = 0) => {
try {
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
const queue: PlayerTrack[] = meta.tracks.map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist }));
const queue: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist }));
player.playQueue(queue, startIndex);
} catch {
/* ignore */
@@ -8,8 +8,33 @@ export type DirEntry = { name: string; type: 'directory' | 'file'; size: number;
export type LsResult = { entries: DirEntry[] };
export type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean };
export type Manifest = { albums: Record<string, ManifestAlbum> };
export type Track = { file: string; title?: string; artist?: string };
export type Track = { file: string; title?: string; artist?: string; track?: string };
export type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
/** Parse a track-number tag ("7", "07", "7/14") to a number, or null when absent/unparseable. */
const trackNo = (t: Track): number | null => {
const raw = t.track?.split('/')[0]?.trim();
if (!raw) return null;
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
};
/**
* Canonical album track order: by the `track` NUMBER, falling back to the tag title only for tracks
* that have no number (numbered tracks always precede unnumbered ones; filename breaks a final tie).
* meta.json is in ffprobe/readdir order (arbitrary), so every consumer must sort with this.
*/
export const sortTracks = <T extends Track>(tracks: T[]): T[] => {
const key = (t: Track) => (t.title || t.file).toLowerCase();
return [...tracks].sort((a, b) => {
const na = trackNo(a);
const nb = trackNo(b);
if (na !== null && nb !== null) return na - nb || key(a).localeCompare(key(b));
if (na !== null) return -1;
if (nb !== null) return 1;
return key(a).localeCompare(key(b));
});
};
export type Discography = { artist: string; albums: Record<string, string> };
const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'mp4']);