feat(cluster): playback resolve, writes fan-out, Orbit gating, random browse

Resolve tracks to cluster candidates at play time with cascade fallback;
fan-out scrobble/star/rating to all members; block Orbit host in cluster
mode; warn on mixed-server queue share; serve random picks from merged index.
This commit is contained in:
Maxim Isaev
2026-06-05 16:40:37 +03:00
parent 2ee601683c
commit e601b5888a
11 changed files with 331 additions and 77 deletions
+8
View File
@@ -1,6 +1,9 @@
import { api, apiForServer } from './subsonicClient';
import type { SubsonicNowPlaying } from './subsonicTypes';
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { clusterFanOutScrobbleSubmission } from '../utils/serverCluster/clusterWriteFanout';
import { useAuthStore } from '../store/authStore';
async function scrobbleOnServer(
serverId: string,
@@ -15,6 +18,11 @@ async function scrobbleOnServer(
export async function scrobbleSong(id: string, time: number, serverId: string): Promise<void> {
if (!serverId) return;
if (isClusterMode()) {
const browseId = useAuthStore.getState().activeServerId ?? serverId;
await clusterFanOutScrobbleSubmission(browseId, id, time);
return;
}
try {
await scrobbleOnServer(serverId, id, true, time);
// Patch-on-use (§6.5 / F3): reflect the play in the local index so the
+21 -2
View File
@@ -7,6 +7,11 @@ import {
invalidateStarredAlbumBrowse,
refreshStarredAlbumIndexFromServer,
} from '../utils/library/starredAlbumIndexSync';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import {
clusterFanOutRating,
clusterFanOutStar,
} from '../utils/serverCluster/clusterWriteFanout';
import type {
EntityRatingSupportLevel,
StarredResults,
@@ -32,12 +37,16 @@ export async function star(
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
): Promise<void> {
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song' && isClusterMode() && serverId) {
await clusterFanOutStar(serverId, id, true);
return;
}
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('star.view', params);
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
} else if (type === 'album' && serverId) {
@@ -52,12 +61,16 @@ export async function unstar(
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
): Promise<void> {
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song' && isClusterMode() && serverId) {
await clusterFanOutStar(serverId, id, false);
return;
}
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('unstar.view', params);
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: null });
} else if (type === 'album' && serverId) {
@@ -68,6 +81,12 @@ export async function unstar(
}
export async function setRating(id: string, rating: number): Promise<void> {
const serverId = useAuthStore.getState().activeServerId;
if (isClusterMode() && serverId) {
await clusterFanOutRating(serverId, id, rating);
invalidateEntityUserRatingCaches(id);
return;
}
await api('setRating.view', { id, rating });
// No-op in Rust when `id` is an album/artist (no track row matches).
patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { userRating: rating });
+16 -2
View File
@@ -4,6 +4,7 @@ import { Orbit as OrbitIcon, Plus, LogIn, HelpCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { useHelpModalStore } from '../store/helpModalStore';
import OrbitStartModal from './OrbitStartModal';
import OrbitJoinModal from './OrbitJoinModal';
@@ -57,7 +58,14 @@ export default function OrbitStartTrigger() {
}
: { display: 'none' };
const pickCreate = () => { setPopoverOpen(false); setStartOpen(true); };
const pickCreate = () => {
if (isClusterMode()) {
setPopoverOpen(false);
return;
}
setPopoverOpen(false);
setStartOpen(true);
};
const pickJoin = () => { setPopoverOpen(false); setJoinOpen(true); };
const pickHelp = () => { setPopoverOpen(false); useHelpModalStore.getState().open(); };
@@ -83,7 +91,13 @@ export default function OrbitStartTrigger() {
{popoverOpen && createPortal(
<div ref={popRef} className="nav-library-dropdown-panel orbit-launch-pop" style={popoverStyle} role="menu">
<button type="button" className="orbit-launch-pop__item" onClick={pickCreate}>
<button
type="button"
className="orbit-launch-pop__item"
onClick={pickCreate}
disabled={isClusterMode()}
title={isClusterMode() ? t('orbit.clusterCreateBlocked') : undefined}
>
<Plus size={14} />
<span>{t('orbit.launchCreate')}</span>
</button>
+9 -1
View File
@@ -12,7 +12,8 @@ import { usePlaylistStore } from '../store/playlistStore';
import { useTranslation } from 'react-i18next';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useAuthStore } from '../store/authStore';
import { encodeSharePayload } from '../utils/share/shareLink';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { serverShareBaseUrl } from '../utils/server/serverEndpoint';
import { copyTextToClipboard } from '../utils/server/serverMagicString';
import { showToast } from '../utils/ui/toast';
@@ -205,6 +206,13 @@ function QueuePanelHostOrSolo() {
// host on a dual-address profile).
const active = useAuthStore.getState().getActiveServer();
if (!active) return;
if (isClusterMode()) {
const repId = active.id;
const foreign = queueItems.some(
r => resolveServerIdForIndexKey(r.serverId) !== repId,
);
if (foreign && !confirm(t('queue.shareClusterMixedWarning'))) return;
}
const srv = serverShareBaseUrl(active);
if (!srv) return;
const ids = queueItems.map(r => r.trackId);
+1
View File
@@ -2,6 +2,7 @@ export const orbit = {
triggerLabel: 'Orbit',
triggerTooltip: 'Start or join a shared listening session',
launchCreate: 'Create a session',
clusterCreateBlocked: 'Exit cluster mode to host Orbit',
launchJoin: 'Join a session',
launchHelp: 'How does this work?',
launchHelpSoon: 'Coming soon',
+1
View File
@@ -29,6 +29,7 @@ export const queue = {
nextTracks: 'Next Tracks',
shareQueue: 'Copy queue share link',
shareQueueEmpty: 'The queue is empty — nothing to share.',
shareClusterMixedWarning: 'This queue has tracks from multiple servers. Only tracks from the primary server will be included in the share link. Continue?',
emptyQueue: 'The queue is empty.',
crossServerEnqueueBlocked: 'Tracks from another server cannot be added to the current queue. Finish or clear the queue first.',
trackSingular: 'track',
+117 -60
View File
@@ -58,6 +58,13 @@ import {
setSeekTarget,
} from './seekTargetState';
import { refreshWaveformForTrack } from './waveformRefresh';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import {
cascadeClusterPlayback,
resolveClusterPlaybackForTrack,
} from '../utils/serverCluster/clusterPlaybackResolve';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
@@ -259,22 +266,45 @@ export function runPlayTrack(
&& getPlaybackCacheServerKey(),
);
const runPlayTrackBody = () => {
const browseServerId = authState.activeServerId ?? '';
const browseTrackId = track.id;
void (async () => {
let playTrackResolved = track;
let streamServerProfileId = '';
if (isClusterMode() && browseServerId) {
const resolved = await resolveClusterPlaybackForTrack(browseServerId, track.id);
if (resolved) {
playTrackResolved = { ...track, id: resolved.trackId };
streamServerProfileId = resolved.serverId;
}
}
const runPlayTrackBody = () => {
const authStateNow = useAuthStore.getState();
const playbackSid = getPlaybackServerId();
const playbackCacheSid = getPlaybackCacheServerKey();
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
recordEnginePlayUrl(track.id, url);
const stateNow = get();
const refAtIdx = stateNow.queueItems[playIdx];
const refServerProfile =
streamServerProfileId
|| (refAtIdx ? resolveServerIdForIndexKey(refAtIdx.serverId) : '')
|| getPlaybackServerId();
const refServer = authStateNow.servers.find(s => s.id === refServerProfile);
const playbackCacheSid = refServer
? serverIndexKeyForProfile(refServer) || refServerProfile
: getPlaybackCacheServerKey();
const url = resolvePlaybackUrl(playTrackResolved.id, playbackCacheSid);
recordEnginePlayUrl(playTrackResolved.id, url);
const preloadedTrackId = get().enginePreloadedTrackId;
const keepPreloadHint = preloadedTrackId === track.id;
const keepPreloadHint = preloadedTrackId === playTrackResolved.id;
const playbackSourceHint = playbackSourceHintForResolvedUrl(
track.id,
playTrackResolved.id,
playbackCacheSid,
url,
);
if (import.meta.env.DEV) {
console.info('[psysonic][playTrack-source]', {
trackId: track.id,
trackId: playTrackResolved.id,
resolvedUrl: url,
preloadedTrackId,
keepPreloadHint,
@@ -282,37 +312,45 @@ export function runPlayTrack(
});
}
// Set state immediately so the UI updates before the download completes.
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
const queueSid = get().queueServerId ?? '';
// When the caller replaced the queue (explicit `queue` arg), seed the
// resolver with those tracks so the UI / hot paths resolve them without a
// network round-trip. No-arg jumps reuse already-cached refs.
if (queue && queueSid) seedQueueResolver(queueSid, queue);
const queuePatch =
streamServerProfileId && refAtIdx
? {
queueItems: stateNow.queueItems.map((r, i) =>
i === playIdx
? {
...r,
serverId: serverIndexKeyForProfile(refServer!) || streamServerProfileId,
trackId: playTrackResolved.id,
}
: r,
),
}
: replacing
? { queueItems: toQueueItemRefs(queueSid, queue) }
: {};
set({
currentTrack: track,
currentTrack: playTrackResolved,
currentRadio: null,
waveformBins: null,
...deriveNormalizationSnapshot(track, normWindow, normIdx),
// Only a replace rewrites the queue; navigation keeps the canonical refs.
...(replacing ? { queueItems: toQueueItemRefs(queueSid, queue) } : {}),
...deriveNormalizationSnapshot(playTrackResolved, normWindow, normIdx),
...queuePatch,
queueIndex: idx >= 0 ? idx : 0,
progress: initialProgress,
buffered: 0,
currentTime: initialTime,
scrobbled: false,
lastfmLoved: false,
// HTTP stream: wait for Rust `audio:playing` so the seekbar does not
// extrapolate while RangedHttpSource / legacy reader is still buffering.
isPlaying: playbackSourceHint !== 'stream',
isPlaybackBuffering: playbackSourceHint === 'stream',
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
enginePreloadedTrackId: keepPreloadHint ? playTrackResolved.id : null,
});
if (
prevTrack
&& !sameQueueTrackId(prevTrack.id, track.id)
&& !sameQueueTrackId(prevTrack.id, playTrackResolved.id)
&& authStateNow.hotCacheEnabled
) {
const prevPromoteSid = getPlaybackCacheServerKey();
@@ -324,35 +362,37 @@ export function runPlayTrack(
);
}
}
void refreshWaveformForTrack(track.id);
void refreshLoudnessForTrack(track.id);
void refreshWaveformForTrack(playTrackResolved.id);
void refreshLoudnessForTrack(playTrackResolved.id);
setDeferHotCachePrefetch(true);
const replayGainDb = resolveReplayGainDb(
track, prevTrack, nextNeighbour,
playTrackResolved, prevTrack, nextNeighbour,
isReplayGainActive(), authStateNow.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
const replayGainPeak = isReplayGainActive() ? (playTrackResolved.replayGainPeak ?? null) : null;
invoke('audio_play', {
url,
volume: state.volume,
durationHint: track.duration,
durationHint: playTrackResolved.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
loudnessGainDb: loudnessGainDbForEngineBind(playTrackResolved.id),
preGainDb: authStateNow.replayGainPreGainDb,
fallbackDb: authStateNow.replayGainFallbackDb,
manual,
hiResEnabled: authStateNow.enableHiRes,
analysisTrackId: track.id,
serverId: getPlaybackIndexKey() || null,
streamFormatSuffix: track.suffix ?? null,
analysisTrackId: playTrackResolved.id,
serverId: playbackCacheSid || null,
streamFormatSuffix: playTrackResolved.suffix ?? null,
})
.then(() => {
if (getPlayGeneration() !== gen) return;
if (keepPreloadHint) {
set({ enginePreloadedTrackId: null });
}
const durSeek = track.duration && track.duration > 0 ? track.duration : null;
const durSeek = playTrackResolved.duration && playTrackResolved.duration > 0
? playTrackResolved.duration
: null;
const seekTo = initialTime;
const canSeekAfterPlay =
seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05);
@@ -361,12 +401,12 @@ export function runPlayTrack(
.then(() => {
if (getPlayGeneration() !== gen) return;
setSeekTarget(seekTo);
if (getSeekFallbackVisualTarget()?.trackId === track.id) {
if (getSeekFallbackVisualTarget()?.trackId === playTrackResolved.id) {
setSeekFallbackVisualTarget(null);
}
})
.catch(() => {
if (getSeekFallbackVisualTarget()?.trackId === track.id) {
if (getSeekFallbackVisualTarget()?.trackId === playTrackResolved.id) {
setSeekFallbackVisualTarget(null);
}
});
@@ -374,6 +414,23 @@ export function runPlayTrack(
})
.catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
if (isClusterMode() && browseServerId && refServerProfile) {
void cascadeClusterPlayback(browseServerId, browseTrackId, refServerProfile).then(next => {
if (!next || getPlayGeneration() !== gen) {
setDeferHotCachePrefetch(false);
set({ isPlaying: false });
setTimeout(() => {
if (getPlayGeneration() !== gen) return;
get().next(false);
}, 500);
return;
}
playTrackResolved = { ...playTrackResolved, id: next.trackId };
streamServerProfileId = next.serverId;
runPlayTrackBody();
});
return;
}
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play failed:', err);
set({ isPlaying: false });
@@ -383,41 +440,41 @@ export function runPlayTrack(
}, 500);
});
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
if (npEnabled) reportNowPlaying(track.id, getPlaybackServerId());
if (npEnabled) reportNowPlaying(playTrackResolved.id, refServerProfile || getPlaybackServerId());
if (lfmKey) {
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
const cacheKey = `${track.title}::${track.artist}`;
if (lfmEnabled) lastfmUpdateNowPlaying(playTrackResolved, lfmKey);
lastfmGetTrackLoved(playTrackResolved.title, playTrackResolved.artist, lfmKey).then(loved => {
const cacheKey = `${playTrackResolved.title}::${playTrackResolved.artist}`;
set(s => ({
lastfmLoved: loved,
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
}));
});
}
syncQueueToServer(get().queueItems, track, initialTime);
touchHotCacheOnPlayback(track.id, playbackCacheSid);
syncQueueToServer(get().queueItems, playTrackResolved, initialTime);
touchHotCacheOnPlayback(playTrackResolved.id, playbackCacheSid);
};
const hotPromoteSid = getPlaybackCacheServerKey();
if (needSameTrackHotPromote && hotPromoteSid) {
void promoteCompletedStreamToHotCache(
track,
hotPromoteSid,
authState.hotCacheDownloadDir || null,
)
.then(() => {
if (getPlayGeneration() !== gen) return;
runPlayTrackBody();
})
.catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] same-track hot promote / play body failed:', err);
set({ isPlaying: false });
});
} else {
runPlayTrackBody();
}
const hotPromoteSid = getPlaybackCacheServerKey();
if (needSameTrackHotPromote && hotPromoteSid) {
void promoteCompletedStreamToHotCache(
track,
hotPromoteSid,
authState.hotCacheDownloadDir || null,
)
.then(() => {
if (getPlayGeneration() !== gen) return;
runPlayTrackBody();
})
.catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] same-track hot promote / play body failed:', err);
set({ isPlaying: false });
});
} else {
runPlayTrackBody();
}
})();
}
+3
View File
@@ -404,6 +404,9 @@ export async function runLocalRandomSongs(
serverId: string | null | undefined,
limit: number,
): Promise<SubsonicSong[] | null> {
const clusterOffset = Math.floor(Math.random() * 500);
const clusterPage = await clusterBrowseTracksPage(clusterOffset, limit);
if (clusterPage?.length) return clusterPage;
if (!serverId || !(await libraryIsReady(serverId))) return null;
try {
const resp = await libraryAdvancedSearch({
+13 -12
View File
@@ -1,23 +1,24 @@
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { canonicalQueueServerKey } from '../server/serverIndexKey';
/**
* Derive thin `QueueItemRef`s from a `Track[]` queue (thin-state). Per-item
* `serverId` is the canonical server index key — every writer normalizes here
* so refs are unambiguous across mixed-server queues (same `trackId` on two
* servers must collide on nothing, since the resolver uses `serverId:trackId`).
* Queue-only flags are carried through, others omitted to keep the persisted /
* derived list small. Pure — no store import beyond the canonicalizer, so both
* `playerStore` (persist) and the resolver bridge can use it without a
* circular dependency.
*/
export function toQueueItemRefs(serverId: string, queue: Track[]): QueueItemRef[] {
const canonicalId = canonicalQueueServerKey(serverId);
return queue.map(t => {
return queue.map(t => toQueueItemRef(canonicalId, t));
}
/** Per-track server ids (mixed-server / cluster-resolved queues). */
export function toQueueItemRefsMulti(
entries: Array<{ serverId: string; track: Track }>,
): QueueItemRef[] {
return entries.map(({ serverId, track }) =>
toQueueItemRef(canonicalQueueServerKey(serverId), track),
);
}
function toQueueItemRef(canonicalId: string, t: Track): QueueItemRef {
const ref: QueueItemRef = { serverId: canonicalId, trackId: t.id };
if (t.autoAdded) ref.autoAdded = true;
if (t.radioAdded) ref.radioAdded = true;
if (t.playNextAdded) ref.playNextAdded = true;
return ref;
});
}
@@ -0,0 +1,67 @@
import { libraryClusterResolveCandidates, type LibraryClusterCandidateDto } from '../../api/library';
import { isClusterMode } from './clusterScope';
import { resolveClusterBrowseMembers } from './clusterBrowse';
import { isServerLikelyReachable } from './representative';
const candidateCache = new Map<string, LibraryClusterCandidateDto[]>();
function cacheKey(browseServerId: string, trackId: string): string {
return `${browseServerId}:${trackId}`;
}
function pickAvailableCandidate(
candidates: LibraryClusterCandidateDto[],
skipServerId?: string,
): LibraryClusterCandidateDto | null {
const sorted = [...candidates].sort((a, b) => a.priorityRank - b.priorityRank);
for (const c of sorted) {
if (skipServerId && c.serverId === skipServerId) continue;
if (!isServerLikelyReachable(c.serverId)) continue;
return c;
}
return sorted.find(c => !skipServerId || c.serverId !== skipServerId) ?? null;
}
/** Resolve a browsed track to a concrete `(serverId, trackId)` for playback. */
export async function resolveClusterPlaybackForTrack(
browseServerId: string,
trackId: string,
): Promise<{ serverId: string; trackId: string } | null> {
if (!isClusterMode()) return null;
const members = await resolveClusterBrowseMembers();
if (!members) return null;
try {
const resp = await libraryClusterResolveCandidates({
serversOrdered: members,
serverId: browseServerId,
trackId,
});
candidateCache.set(cacheKey(browseServerId, trackId), resp.candidates);
const winner = pickAvailableCandidate(resp.candidates);
if (!winner) return null;
return { serverId: winner.serverId, trackId: winner.trackId };
} catch {
return null;
}
}
/** Next candidate after a stream failure (cascade fallback). */
export async function cascadeClusterPlayback(
browseServerId: string,
browseTrackId: string,
failedServerId: string,
): Promise<{ serverId: string; trackId: string } | null> {
let candidates = candidateCache.get(cacheKey(browseServerId, browseTrackId));
if (!candidates) {
await resolveClusterPlaybackForTrack(browseServerId, browseTrackId);
candidates = candidateCache.get(cacheKey(browseServerId, browseTrackId));
}
if (!candidates?.length) return null;
const next = pickAvailableCandidate(candidates, failedServerId);
if (!next) return null;
return { serverId: next.serverId, trackId: next.trackId };
}
export function clearClusterPlaybackCache(): void {
candidateCache.clear();
}
@@ -0,0 +1,75 @@
import { apiForServer } from '../../api/subsonicClient';
import { libraryClusterResolveCandidates } from '../../api/library';
import { patchLibraryTrackOnUse } from '../library/patchOnUse';
import { resolveClusterBrowseMembers } from './clusterBrowse';
import { getActiveCluster, isClusterMode } from './clusterScope';
async function allCandidateTrackIds(
browseServerId: string,
trackId: string,
): Promise<Array<{ serverId: string; trackId: string }>> {
const members = await resolveClusterBrowseMembers();
if (!members) return [{ serverId: browseServerId, trackId }];
try {
const resp = await libraryClusterResolveCandidates({
serversOrdered: members,
serverId: browseServerId,
trackId,
});
return resp.candidates.map(c => ({ serverId: c.serverId, trackId: c.trackId }));
} catch {
return [{ serverId: browseServerId, trackId }];
}
}
/** Fan-out scrobble submission=true per cluster settings (spec §7.2). */
export async function clusterFanOutScrobbleSubmission(
browseServerId: string,
trackId: string,
time: number,
): Promise<void> {
if (!isClusterMode()) return;
const cluster = getActiveCluster();
const syncAll = cluster?.clusterSyncPlayCounts ?? true;
const targets = await allCandidateTrackIds(browseServerId, trackId);
const toWrite = syncAll ? targets : targets.slice(0, 1);
await Promise.allSettled(
toWrite.map(async ({ serverId, tid }) => {
await apiForServer(serverId, 'scrobble.view', { id: tid, submission: true, time });
patchLibraryTrackOnUse(serverId, tid, { playedAt: time });
}),
);
}
/** Fan-out star/unstar to all cluster members holding the track (spec §7.1). */
export async function clusterFanOutStar(
browseServerId: string,
trackId: string,
star: boolean,
): Promise<void> {
if (!isClusterMode()) return;
const targets = await allCandidateTrackIds(browseServerId, trackId);
await Promise.allSettled(
targets.map(async ({ serverId, tid }) => {
const params = { id: tid };
await apiForServer(serverId, star ? 'star.view' : 'unstar.view', params);
patchLibraryTrackOnUse(serverId, tid, { starredAt: star ? Date.now() : null });
}),
);
}
/** Fan-out rating to all cluster members holding the track. */
export async function clusterFanOutRating(
browseServerId: string,
trackId: string,
rating: number,
): Promise<void> {
if (!isClusterMode()) return;
const targets = await allCandidateTrackIds(browseServerId, trackId);
await Promise.allSettled(
targets.map(async ({ serverId, tid }) => {
await apiForServer(serverId, 'setRating.view', { id: tid, rating });
patchLibraryTrackOnUse(serverId, tid, { userRating: rating });
}),
);
}