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_CWD_CHANNEL,
MUSIC_FAV_CHANNEL,
MUSIC_RESYNC_CHANNEL,
coverUrl,
fuzzyMatch,
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.
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 →
// 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 [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
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 [libraries, setLibraries] = useState<string[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [query, setQuery] = useState('');
const [reindexing, setReindexing] = useState(false);
// Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce.
useEffect(() => {
get<Manifest>('/music/manifest')
.then((m) => setManifest(m.albums))
@@ -54,7 +60,7 @@ export const MusicBrowser = () => {
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
.then((r) => setLibraries(visibleDirs(r)))
.catch(() => setLibraries([]));
}, []);
}, [resync]);
// 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).
@@ -77,22 +83,19 @@ export const MusicBrowser = () => {
return () => {
cancelled = true;
};
}, [navFolder]);
}, [navFolder, resync]);
// Start each folder unfiltered.
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 () => {
if (reindexing) return;
setReindexing(true);
try {
await post('/music/reindex');
const m = await get<Manifest>('/music/manifest').catch(() => null);
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));
setResync(Date.now());
} finally {
setReindexing(false);
}
@@ -129,7 +132,10 @@ export const MusicBrowser = () => {
title="Favorites"
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>
</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"
/>
{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} />
</button>
)}
@@ -198,15 +208,22 @@ export const MusicBrowser = () => {
type="button"
onClick={() => setCwd(`${navFolder}/${f}`)}
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>
</button>
))}
{!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>
</>
@@ -10,6 +10,7 @@ import {
MUSIC_ROOT,
MUSIC_CWD_CHANNEL,
MUSIC_FAV_CHANNEL,
MUSIC_RESYNC_CHANNEL,
TYPE_ORDER,
coverUrl,
fmtDuration,
@@ -33,6 +34,7 @@ export const MusicDetail = () => {
const player = useMusicPlayer();
const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
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 [libraries, setLibraries] = useState<string[]>([]);
@@ -63,14 +65,24 @@ export const MusicDetail = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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(() => {
get<Manifest>('/music/manifest')
.then((m) => setManifest(m.albums))
.catch(() => setManifest({}));
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([]));
}, []);
}, [resync]);
const rel = toRel(cwd);
const childRel = (name: string) => (rel ? `${rel}/${name}` : name);
@@ -90,7 +102,12 @@ export const MusicDetail = () => {
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
.then(async (r) => {
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);
if (audio.length) {
try {
@@ -128,7 +145,12 @@ export const MusicDetail = () => {
const playAlbum = async (albumRel: string, startIndex = 0) => {
try {
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);
} catch {
/* 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 }));
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('/') : [];
@@ -173,7 +196,13 @@ export const MusicDetail = () => {
</button>
)}
{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>
);
@@ -273,7 +302,13 @@ export const MusicDetail = () => {
</span>
{dur && <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{dur}</span>}
</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>
);
})}
@@ -290,7 +325,9 @@ export const MusicDetail = () => {
</div>
{TYPE_ORDER.filter((type) => folders.some((f) => (disco.albums[f] ?? 'Other') === type)).map((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">
{folders
.filter((f) => (disco.albums[f] ?? 'Other') === type)
@@ -4,6 +4,9 @@
export const MUSIC_ROOT = 'Music';
export const MUSIC_CWD_CHANNEL = 'music:cwd';
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*(.+)$/;
@@ -16,7 +19,14 @@ 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; 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[] };
/** Seconds → "m:ss" (or "h:mm:ss" once past an hour); '' when unknown. */