Measured, not guessed. ffprobe is gone from the stream path. It ran on the first request for each file — 50ms warm, 315ms cold on this machine, paid before the first byte, once per file per sidecar restart — to fill X-Audio-Duration, a header nothing in the web read and which duplicates the durationSec the indexer already wrote into meta.json. Removed rather than cached harder: the fastest work is the work not done, and a second copy of a number is a second thing that can disagree. BREAKING for any client that read that header; read /meta instead, which every client already fetches to build its queue. /stream gains an ETag and Cache-Control. It was the only route with no validator at all — cover, meta, lyrics and poster have had one all along — so every seek and every replay re-read the file over the network. mtime+size identifies the bytes, since a re-encode replaces the file. A Range request never answers 304: that would answer a question about part of a file with a statement about all of it, and players handle it badly. And `?v=` makes covers cacheable rather than merely revalidatable. `no-cache` means "ask before reusing", so a grid of forty albums cost forty conditional requests on every load, all answering 304. The manifest already carries each album's version, so passing it costs the client nothing — and the server then answers `immutable`, making a repeat visit send nothing at all. That last one is deliberately conditional on `v` being present, which keeps it backward compatible: a client that has not been updated gets today's behaviour and cannot end up holding a stale cover forever.
167 lines
7.0 KiB
TypeScript
167 lines
7.0 KiB
TypeScript
// Shared types/helpers for the /music workspace panels (MusicBrowser + MusicDetail), which coordinate
|
|
// via the `?path=` search param and play through the app-wide useMusicPlayer.
|
|
|
|
import { useSearchParams } from 'react-router';
|
|
|
|
export const MUSIC_ROOT = 'Music';
|
|
export const MUSIC_FAV_CHANNEL = 'music:favorites';
|
|
// Bumped (to a fresh nonce) when a library reindex finishes, so BOTH panels re-run their manifest /
|
|
// listing / meta fetches — otherwise only the panel that triggered the reindex refreshes.
|
|
export const MUSIC_RESYNC_CHANNEL = 'music:resync';
|
|
|
|
// Album folders are named "[year] Album Name" → display as "Album Name" + year.
|
|
const ALBUM_NAME_RE = /^\[(\d{4})\]\s*(.+)$/;
|
|
export const parseAlbumName = (name: string): { title: string; year?: string } => {
|
|
const m = ALBUM_NAME_RE.exec(name.trim());
|
|
return m ? { title: m[2]!.trim(), year: m[1] } : { title: name };
|
|
};
|
|
|
|
export type DirEntry = { name: string; type: 'directory' | 'file'; size: number; modifiedAt: 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;
|
|
albumArtist?: string;
|
|
track?: string;
|
|
durationSec?: number;
|
|
};
|
|
export type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
|
|
|
|
/** Seconds → "m:ss" (or "h:mm:ss" once past an hour); '' when unknown. */
|
|
export const fmtDuration = (sec?: number): string => {
|
|
if (!sec || sec <= 0) return '';
|
|
const s = Math.round(sec);
|
|
const h = Math.floor(s / 3600);
|
|
const m = Math.floor((s % 3600) / 60);
|
|
const ss = String(s % 60).padStart(2, '0');
|
|
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
|
|
};
|
|
|
|
/** Seconds → "m:ss" for a running clock: unknown reads as 0:00, never blank, so it doesn't jitter. */
|
|
export const fmtClock = (sec: number): string =>
|
|
Number.isFinite(sec) && sec >= 0
|
|
? `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`
|
|
: '0:00';
|
|
|
|
/** Case-insensitive subsequence fuzzy match: every char of `query` appears in order within `text`. */
|
|
export const fuzzyMatch = (query: string, text: string): boolean => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return true;
|
|
const t = text.toLowerCase();
|
|
let qi = 0;
|
|
for (let ti = 0; ti < t.length && qi < q.length; ti++) if (t[ti] === q[qi]!) qi++;
|
|
return qi === q.length;
|
|
};
|
|
|
|
/** 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> };
|
|
|
|
export type FavoriteKind = 'track' | 'album' | 'artist';
|
|
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
|
|
|
|
/** Per-user "currently playing" snapshot (GET/PUT /api/music/now-playing). */
|
|
export type NowPlaying = {
|
|
homePath: string;
|
|
dir: string;
|
|
title: string;
|
|
artist: string;
|
|
album: string;
|
|
durationSec: number;
|
|
positionSec: number;
|
|
updatedAt: string;
|
|
};
|
|
|
|
/** homePath ("Music/<rel>/<file>") for a track — its favorite key + /stream path. */
|
|
export const trackHomePath = (rel: string, file: string) => `${MUSIC_ROOT}/${rel}/${file}`;
|
|
|
|
const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'mp4']);
|
|
export const isAudio = (n: string) => {
|
|
const d = n.lastIndexOf('.');
|
|
return d >= 0 && AUDIO_EXT.has(n.slice(d + 1).toLowerCase());
|
|
};
|
|
|
|
// Section order for an artist's discography.
|
|
export const TYPE_ORDER = [
|
|
'Studio',
|
|
'Live',
|
|
'Compilation',
|
|
'EP',
|
|
'Single',
|
|
'Soundtrack',
|
|
'Remix',
|
|
'DJ-Mix',
|
|
'Demo',
|
|
'Mixtape',
|
|
'Bootleg',
|
|
'Other',
|
|
];
|
|
|
|
/**
|
|
* `version` is what makes a cover cacheable rather than merely revalidatable.
|
|
*
|
|
* Without it the browser holds the image but asks about it on every page load — a grid of forty albums
|
|
* is forty conditional requests before any art appears, and all forty answer 304. With it, the URL names
|
|
* one specific version of that artwork, the server marks it `immutable`, and a repeat visit sends
|
|
* nothing at all. When the album changes its version changes, so the URL changes, and the stale entry is
|
|
* simply never asked for again.
|
|
*
|
|
* Optional because the server only applies the aggressive caching when it is present — a caller without
|
|
* a version keeps the old behaviour rather than risking a cover frozen forever.
|
|
*/
|
|
export const coverUrl = (rel: string, token: string | null, version?: string) =>
|
|
`/api/music/cover?path=${encodeURIComponent(rel)}` +
|
|
`${token ? `&token=${encodeURIComponent(token)}` : ''}` +
|
|
`${version ? `&v=${encodeURIComponent(version)}` : ''}`;
|
|
|
|
/** Path (home-relative) → rel (relative to the Music root). */
|
|
export const toRel = (cwd: string | null) => (cwd ? cwd.slice(MUSIC_ROOT.length + 1) : '');
|
|
|
|
// Where you are in the library is `/music?path=<rel>`, not a `music:cwd` channel. A query param rather
|
|
// than `/music/*` because the location is one of several things this screen holds (the lyrics split and
|
|
// the favorites view are the others), and because a splat would have to be the last segment of the
|
|
// route — the same reason /chat spells its group that way. `rel === ''` is the library root, which is
|
|
// the bare /music and a real state, so there is no redirect guard.
|
|
export const MUSIC_PATH_PARAM = 'path';
|
|
|
|
/** Link target for a library location. `rel` is relative to the Music root; '' is the root itself. */
|
|
export const musicPath = (rel: string) => (rel ? `/music?${MUSIC_PATH_PARAM}=${encodeURIComponent(rel)}` : '/music');
|
|
|
|
/** Link target for the parent of `rel` — '' (the root) is its own parent, which is where "up" stops. */
|
|
export const musicParentPath = (rel: string) => musicPath(rel.split('/').slice(0, -1).join('/'));
|
|
|
|
/**
|
|
* The open library folder as a home-relative path ("Music/…"), or null at the root — the vocabulary the
|
|
* panels already speak, so reading the URL costs them nothing. Each panel calls this itself; they never
|
|
* tell each other where they are.
|
|
*/
|
|
export const useMusicCwd = (): string | null => {
|
|
const rel = useSearchParams()[0].get(MUSIC_PATH_PARAM)?.trim() ?? '';
|
|
return rel ? `${MUSIC_ROOT}/${rel}` : null;
|
|
};
|