web /music: reindex now refreshes BOTH panels in place

The MusicBrowser and MusicDetail panels each kept their OWN local `manifest`
(and folder-listing) state, fetched in their own effects. The reindex (↻) button
lives in MusicBrowser and only refreshed its own copy — MusicDetail (the right
panel showing the tracklist/grid) never heard about it, so newly-indexed content
only appeared after navigating (which re-ran its effects).

Add a shared `music:resync` panel channel: when a reindex completes, MusicBrowser
bumps it to a fresh nonce, and both panels re-run their manifest/libraries/folder
fetches. In MusicDetail the fresh manifest object identity also re-triggers the
[cwd, manifest] listing effect, so the open album's meta/tracklist and any folder
grid refresh in place — no navigation required. Drops the browser's now-redundant
hand-refresh of its own state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 13:16:11 +00:00
co-authored by Claude Opus 4.8
parent 597675d6a0
commit 43a4372ba3
3 changed files with 87 additions and 23 deletions
@@ -6,6 +6,7 @@ import {
MUSIC_ROOT, MUSIC_ROOT,
MUSIC_CWD_CHANNEL, MUSIC_CWD_CHANNEL,
MUSIC_FAV_CHANNEL, MUSIC_FAV_CHANNEL,
MUSIC_RESYNC_CHANNEL,
coverUrl, coverUrl,
fuzzyMatch, fuzzyMatch,
toRel, toRel,
@@ -32,7 +33,10 @@ const RowThumb = ({ src, fallback }: { src: string | null; fallback: ReactNode }
// Hidden files/folders (dotfiles like .claude, .git) never belong in the library listing. // Hidden files/folders (dotfiles like .claude, .git) never belong in the library listing.
const visibleDirs = (r: LsResult) => const visibleDirs = (r: LsResult) =>
r.entries.filter((e) => e.type === 'directory' && !e.name.startsWith('.')).map((e) => e.name).sort(); r.entries
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
.map((e) => e.name)
.sort();
// Left panel of the /music workspace — a single-column drill-down LIST navigator (libraries → // Left panel of the /music workspace — a single-column drill-down LIST navigator (libraries →
// artists → albums as list items; never a grid). Publishes the selected path to the 'music:cwd' // artists → albums as list items; never a grid). Publishes the selected path to the 'music:cwd'
@@ -41,12 +45,14 @@ export const MusicBrowser = () => {
const { get, post, token } = useClient(); const { get, post, token } = useClient();
const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null); const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false); const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
const [resync, setResync] = usePanelChannel<number>(MUSIC_RESYNC_CHANNEL, 0);
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({}); const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
const [libraries, setLibraries] = useState<string[]>([]); const [libraries, setLibraries] = useState<string[]>([]);
const [folders, setFolders] = useState<string[]>([]); const [folders, setFolders] = useState<string[]>([]);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [reindexing, setReindexing] = useState(false); const [reindexing, setReindexing] = useState(false);
// Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce.
useEffect(() => { useEffect(() => {
get<Manifest>('/music/manifest') get<Manifest>('/music/manifest')
.then((m) => setManifest(m.albums)) .then((m) => setManifest(m.albums))
@@ -54,7 +60,7 @@ export const MusicBrowser = () => {
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`) get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
.then((r) => setLibraries(visibleDirs(r))) .then((r) => setLibraries(visibleDirs(r)))
.catch(() => setLibraries([])); .catch(() => setLibraries([]));
}, []); }, [resync]);
// The container folder whose children we list = the current folder, or its parent when the current // The container folder whose children we list = the current folder, or its parent when the current
// path is an album leaf (so its siblings stay listed while the right shows the tracklist). // path is an album leaf (so its siblings stay listed while the right shows the tracklist).
@@ -77,22 +83,19 @@ export const MusicBrowser = () => {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [navFolder]); }, [navFolder, resync]);
// Start each folder unfiltered. // Start each folder unfiltered.
useEffect(() => setQuery(''), [navFolder]); useEffect(() => setQuery(''), [navFolder]);
// Trigger a server-side library rebuild, then refresh the manifest + current listing. // Trigger a server-side library rebuild, then bump the resync nonce so BOTH panels refetch their
// manifest / listings / meta (a fresh Date.now() value guarantees the effects re-run).
const reindex = async () => { const reindex = async () => {
if (reindexing) return; if (reindexing) return;
setReindexing(true); setReindexing(true);
try { try {
await post('/music/reindex'); await post('/music/reindex');
const m = await get<Manifest>('/music/manifest').catch(() => null); setResync(Date.now());
if (m) setManifest(m.albums);
const target = navFolder ?? MUSIC_ROOT;
const r = await get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(target)}`).catch(() => null);
if (r) navFolder ? setFolders(visibleDirs(r)) : setLibraries(visibleDirs(r));
} finally { } finally {
setReindexing(false); setReindexing(false);
} }
@@ -129,7 +132,10 @@ export const MusicBrowser = () => {
title="Favorites" title="Favorites"
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md hover:bg-muted" className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md hover:bg-muted"
> >
<Heart size={18} className={favOpen ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-foreground'} /> <Heart
size={18}
className={favOpen ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-foreground'}
/>
</button> </button>
</div> </div>
@@ -143,7 +149,11 @@ export const MusicBrowser = () => {
className="min-w-0 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none" className="min-w-0 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
/> />
{query && ( {query && (
<button type="button" onClick={() => setQuery('')} className="shrink-0 text-muted-foreground hover:text-foreground"> <button
type="button"
onClick={() => setQuery('')}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X size={13} /> <X size={13} />
</button> </button>
)} )}
@@ -198,15 +208,22 @@ export const MusicBrowser = () => {
type="button" type="button"
onClick={() => setCwd(`${navFolder}/${f}`)} onClick={() => setCwd(`${navFolder}/${f}`)}
className={`flex items-center gap-3 truncate rounded-md px-2 py-2 text-left text-base ${ className={`flex items-center gap-3 truncate rounded-md px-2 py-2 text-left text-base ${
selected === f ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground' selected === f
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`} }`}
> >
<RowThumb src={coverFor(toRel(`${navFolder}/${f}`))} fallback={<Folder size={18} className="text-muted-foreground" />} /> <RowThumb
src={coverFor(toRel(`${navFolder}/${f}`))}
fallback={<Folder size={18} className="text-muted-foreground" />}
/>
<span className="truncate">{f}</span> <span className="truncate">{f}</span>
</button> </button>
))} ))}
{!shownFolders.length && ( {!shownFolders.length && (
<span className="px-2 py-2 text-base text-muted-foreground">{query ? 'No matches' : 'No subfolders'}</span> <span className="px-2 py-2 text-base text-muted-foreground">
{query ? 'No matches' : 'No subfolders'}
</span>
)} )}
</div> </div>
</> </>
@@ -10,6 +10,7 @@ import {
MUSIC_ROOT, MUSIC_ROOT,
MUSIC_CWD_CHANNEL, MUSIC_CWD_CHANNEL,
MUSIC_FAV_CHANNEL, MUSIC_FAV_CHANNEL,
MUSIC_RESYNC_CHANNEL,
TYPE_ORDER, TYPE_ORDER,
coverUrl, coverUrl,
fmtDuration, fmtDuration,
@@ -33,6 +34,7 @@ export const MusicDetail = () => {
const player = useMusicPlayer(); const player = useMusicPlayer();
const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null); const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false); const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
const [resync] = usePanelChannel<number>(MUSIC_RESYNC_CHANNEL, 0);
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({}); const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
const [libraries, setLibraries] = useState<string[]>([]); const [libraries, setLibraries] = useState<string[]>([]);
@@ -63,14 +65,24 @@ export const MusicDetail = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [cwd]); }, [cwd]);
// Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce. A fresh manifest
// object identity also re-runs the [cwd, manifest] listing effect below, refreshing the folder grid /
// album meta for whatever is currently open — so the right panel updates in place, no nav required.
useEffect(() => { useEffect(() => {
get<Manifest>('/music/manifest') get<Manifest>('/music/manifest')
.then((m) => setManifest(m.albums)) .then((m) => setManifest(m.albums))
.catch(() => setManifest({})); .catch(() => setManifest({}));
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`) get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
.then((r) => setLibraries(r.entries.filter((e) => e.type === 'directory' && !e.name.startsWith('.')).map((e) => e.name).sort())) .then((r) =>
setLibraries(
r.entries
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
.map((e) => e.name)
.sort(),
),
)
.catch(() => setLibraries([])); .catch(() => setLibraries([]));
}, []); }, [resync]);
const rel = toRel(cwd); const rel = toRel(cwd);
const childRel = (name: string) => (rel ? `${rel}/${name}` : name); const childRel = (name: string) => (rel ? `${rel}/${name}` : name);
@@ -90,7 +102,12 @@ export const MusicDetail = () => {
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`) get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
.then(async (r) => { .then(async (r) => {
if (cancelled) return; if (cancelled) return;
setFolders(r.entries.filter((e) => e.type === 'directory' && !e.name.startsWith('.')).map((e) => e.name).sort()); setFolders(
r.entries
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
.map((e) => e.name)
.sort(),
);
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name); const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
if (audio.length) { if (audio.length) {
try { try {
@@ -128,7 +145,12 @@ export const MusicDetail = () => {
const playAlbum = async (albumRel: string, startIndex = 0) => { const playAlbum = async (albumRel: string, startIndex = 0) => {
try { try {
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`); const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
const queue: PlayerTrack[] = sortTracks(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); player.playQueue(queue, startIndex);
} catch { } catch {
/* ignore */ /* ignore */
@@ -139,7 +161,8 @@ export const MusicDetail = () => {
const queue: PlayerTrack[] = album.map((t) => ({ albumRel: rel, file: t.file, title: t.title, artist: t.artist })); const queue: PlayerTrack[] = album.map((t) => ({ albumRel: rel, file: t.file, title: t.title, artist: t.artist }));
player.playQueue(queue, i); player.playQueue(queue, i);
}; };
const isCurrent = (albumRel: string, file: string) => player.current?.albumRel === albumRel && player.current?.file === file; const isCurrent = (albumRel: string, file: string) =>
player.current?.albumRel === albumRel && player.current?.file === file;
const crumbs = rel ? rel.split('/') : []; const crumbs = rel ? rel.split('/') : [];
@@ -173,7 +196,13 @@ export const MusicDetail = () => {
</button> </button>
)} )}
{playable && ( {playable && (
<MusicHeart kind="album" favKey={r} size={18} hoverReveal className="absolute right-2 top-2 rounded-full bg-black/40 p-1.5 text-white" /> <MusicHeart
kind="album"
favKey={r}
size={18}
hoverReveal
className="absolute right-2 top-2 rounded-full bg-black/40 p-1.5 text-white"
/>
)} )}
</div> </div>
); );
@@ -273,7 +302,13 @@ export const MusicDetail = () => {
</span> </span>
{dur && <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{dur}</span>} {dur && <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{dur}</span>}
</button> </button>
<MusicHeart kind="track" favKey={trackHomePath(rel, t.file)} size={16} hoverReveal className="shrink-0" /> <MusicHeart
kind="track"
favKey={trackHomePath(rel, t.file)}
size={16}
hoverReveal
className="shrink-0"
/>
</div> </div>
); );
})} })}
@@ -290,7 +325,9 @@ export const MusicDetail = () => {
</div> </div>
{TYPE_ORDER.filter((type) => folders.some((f) => (disco.albums[f] ?? 'Other') === type)).map((type) => ( {TYPE_ORDER.filter((type) => folders.some((f) => (disco.albums[f] ?? 'Other') === type)).map((type) => (
<section key={type}> <section key={type}>
<h2 className="mb-2 text-lg font-semibold text-foreground">{type === 'Studio' ? 'Studio Albums' : type}</h2> <h2 className="mb-2 text-lg font-semibold text-foreground">
{type === 'Studio' ? 'Studio Albums' : type}
</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{folders {folders
.filter((f) => (disco.albums[f] ?? 'Other') === type) .filter((f) => (disco.albums[f] ?? 'Other') === type)
@@ -4,6 +4,9 @@
export const MUSIC_ROOT = 'Music'; export const MUSIC_ROOT = 'Music';
export const MUSIC_CWD_CHANNEL = 'music:cwd'; export const MUSIC_CWD_CHANNEL = 'music:cwd';
export const MUSIC_FAV_CHANNEL = 'music:favorites'; 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. // Album folders are named "[year] Album Name" → display as "Album Name" + year.
const ALBUM_NAME_RE = /^\[(\d{4})\]\s*(.+)$/; const ALBUM_NAME_RE = /^\[(\d{4})\]\s*(.+)$/;
@@ -16,7 +19,14 @@ export type DirEntry = { name: string; type: 'directory' | 'file'; size: number;
export type LsResult = { entries: DirEntry[] }; export type LsResult = { entries: DirEntry[] };
export type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean }; export type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean };
export type Manifest = { albums: Record<string, ManifestAlbum> }; export type Manifest = { albums: Record<string, ManifestAlbum> };
export type Track = { file: string; title?: string; artist?: string; albumArtist?: string; track?: string; durationSec?: number }; export type Track = {
file: string;
title?: string;
artist?: string;
albumArtist?: string;
track?: string;
durationSec?: number;
};
export type AlbumMeta = { path: string; cover?: string; tracks: Track[] }; export type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
/** Seconds → "m:ss" (or "h:mm:ss" once past an hour); '' when unknown. */ /** Seconds → "m:ss" (or "h:mm:ss" once past an hour); '' when unknown. */