// 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 }; 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 = (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 }; 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//") 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', ]; export const coverUrl = (rel: string, token: string | null) => `/api/music/cover?path=${encodeURIComponent(rel)}${token ? `&token=${encodeURIComponent(token)}` : ''}`; /** 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=`, 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; };