mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user