feat(cluster): playback, browse, discovery, Tier 2/3, and infra

Enqueue-time cluster resolve with mixed-server queue refs; play_session
and scrobble target resolved server; mid-session cascade monitor.
Queue share filters to representative with export count. Excluded-member
banner and cluster settings diagnostics. Cluster advanced search, random
tracks, native favorite albums/artists, extended player stats, sync
rebuild hook. Discovery merge helper, filtered Tier 1, Tier 3 by-source
UI, i18n, and tauri test mocks.
This commit is contained in:
Maxim Isaev
2026-06-05 17:36:53 +03:00
parent 3447cf9fdd
commit 884951fbc2
74 changed files with 2750 additions and 156 deletions
+135
View File
@@ -668,11 +668,13 @@ export function libraryClusterArtistDetail(args: {
export function librarySearchCluster(args: {
query: string;
limit?: number;
offset?: number;
serversOrdered: string[];
}): Promise<LibraryCrossServerSearchResponse> {
return invoke<LibraryCrossServerSearchResponse>('library_search_cluster', {
query: args.query,
limit: args.limit,
offset: args.offset,
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
}).then(response => ({
...response,
@@ -682,6 +684,85 @@ export function librarySearchCluster(args: {
}));
}
export interface LibraryClusterAdvancedSearchRequest {
serversOrdered: string[];
query?: string | null;
entityTypes: LibraryEntityType[];
filters?: LibraryFilterClause[];
starredOnly?: boolean | null;
restrictAlbumIds?: string[] | null;
queryAlbumTitleOnly?: boolean | null;
sort?: LibrarySortClause[];
limit: number;
offset?: number;
skipTotals?: boolean;
}
export function libraryClusterAdvancedSearch(
request: LibraryClusterAdvancedSearchRequest,
): Promise<LibraryAdvancedSearchResponse> {
return invoke<LibraryAdvancedSearchResponse>('library_cluster_advanced_search', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(request.serversOrdered),
query: request.query ?? undefined,
entityTypes: request.entityTypes,
filters: request.filters ?? [],
starredOnly: request.starredOnly ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
queryAlbumTitleOnly: request.queryAlbumTitleOnly ?? undefined,
sort: request.sort ?? [],
limit: request.limit,
offset: request.offset ?? 0,
skipTotals: request.skipTotals ?? false,
},
}).then(response => ({
...response,
artists: response.artists.map(artist => ({
...artist,
serverId: mapServerIdFromIndexKey(artist.serverId),
})),
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId),
})),
tracks: mapTracksServerId(response.tracks),
}));
}
export function libraryClusterListFavoriteAlbums(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryClusterAlbumsResponse> {
return invoke<LibraryClusterAlbumsResponse>('library_cluster_list_favorite_albums', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(resp => ({
...resp,
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryClusterListFavoriteArtists(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryClusterArtistsResponse> {
return invoke<LibraryClusterArtistsResponse>('library_cluster_list_favorite_artists', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(resp => ({
...resp,
artists: resp.artists.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryGetTrack(
serverId: string,
trackId: string,
@@ -1032,6 +1113,60 @@ export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySession
return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit });
}
export interface PlaySessionMostPlayed {
track: LibraryTrackDto;
trackPlayCount: number;
totalListenedSec: number;
}
export function libraryClusterPlayerStatsDayDetail(args: {
serversOrdered: string[];
dateIso: string;
}): Promise<PlaySessionDayDetail> {
return invoke<PlaySessionDayDetail>('library_cluster_player_stats_day_detail', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
dateIso: args.dateIso,
},
}).then(detail => ({
...detail,
tracks: detail.tracks.map(track => ({
...track,
serverId: mapServerIdFromIndexKey(track.serverId),
})),
}));
}
export function libraryClusterPlayerStatsRecentDays(args: {
serversOrdered: string[];
limit?: number;
}): Promise<PlaySessionRecentDay[]> {
return invoke<PlaySessionRecentDay[]>('library_cluster_player_stats_recent_days', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
},
});
}
export function libraryClusterPlayerStatsMostPlayed(args: {
serversOrdered: string[];
limit?: number;
}): Promise<PlaySessionMostPlayed[]> {
return invoke<PlaySessionMostPlayed[]>('library_cluster_player_stats_most_played', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
},
}).then(rows => rows.map(row => ({
...row,
track: {
...row.track,
serverId: mapServerIdFromIndexKey(row.track.serverId),
},
})));
}
// ── Event subscriptions ───────────────────────────────────────────────
export interface LibrarySyncProgressPayload {
+107 -1
View File
@@ -8,6 +8,10 @@ import type {
SubsonicArtistInfo,
SubsonicSong,
} from './subsonicTypes';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { resolveClusterBrowseMembers } from '../utils/serverCluster/clusterBrowse';
import { libraryClusterResolveCandidates } from './library';
import { mergeClusterTracks, resolveClusterSeedIds } from '../utils/serverCluster/clusterDiscoveryMerge';
export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: any } }>('getArtists.view', {
@@ -60,6 +64,9 @@ export async function getArtistInfoForServer(
}
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
if (isClusterMode()) {
return getTopSongsCluster(artist);
}
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return [];
return getTopSongsForServer(activeServerId, artist);
@@ -83,7 +90,59 @@ export async function getTopSongsForServer(serverId: string, artist: string): Pr
}
}
async function getTopSongsCluster(artist: string): Promise<SubsonicSong[]> {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return [];
const settled = await Promise.allSettled(
members.map(serverId =>
apiForServer<{ topSongs: { song: SubsonicSong[] } }>(
serverId,
'getTopSongs.view',
{ artist, count: 20, ...libraryFilterParamsForServer(serverId) },
).then(data => ({ serverId, songs: data.topSongs?.song ?? [] })),
),
);
const merged = mergeClusterTracks(
settled.flatMap((row, idx) =>
row.status === 'fulfilled'
? row.value.songs.map(song => ({
item: { ...song, clusterBrowseServerId: row.value.serverId },
serverId: row.value.serverId,
priorityRank: idx,
}))
: [],
),
);
return merged.slice(0, 5);
}
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
if (isClusterMode()) {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return [];
const requestCount = similarSongsRequestCount(count);
const settled = await Promise.allSettled(
members.map(serverId =>
apiForServer<{ similarSongs2: { song: SubsonicSong[] } }>(
serverId,
'getSimilarSongs2.view',
{ id, count: requestCount, ...libraryFilterParamsForServer(serverId) },
).then(data => ({ serverId, songs: data.similarSongs2?.song ?? [] })),
),
);
const merged = mergeClusterTracks(
settled.flatMap((row, idx) =>
row.status === 'fulfilled'
? row.value.songs.map(song => ({
item: { ...song, clusterBrowseServerId: row.value.serverId },
serverId: row.value.serverId,
priorityRank: idx,
}))
: [],
),
);
return merged.filter(s => s.id !== id).slice(0, count);
}
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() });
@@ -96,7 +155,54 @@ export async function getSimilarSongs2(id: string, count = 50): Promise<Subsonic
}
/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */
export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicSong[]> {
export async function getSimilarSongs(
id: string,
count = 50,
browseServerId?: string,
): Promise<SubsonicSong[]> {
if (isClusterMode()) {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return [];
const activeServerId = browseServerId ?? useAuthStore.getState().activeServerId ?? members[0] ?? '';
const requestCount = similarSongsRequestCount(count);
const seedCandidates = await libraryClusterResolveCandidates({
serversOrdered: members,
serverId: activeServerId,
trackId: id,
}).catch(() => null);
const seeds = resolveClusterSeedIds(
Object.fromEntries((seedCandidates?.candidates ?? []).map(c => [c.serverId, c.trackId])),
members,
);
const resolvedSeeds = seeds.length > 0
? seeds
: members.map(serverId => ({ serverId, seedId: id }));
const settled = await Promise.allSettled(
resolvedSeeds.map(({ serverId, seedId }) =>
apiForServer<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>(
serverId,
'getSimilarSongs.view',
{ id: seedId, count: requestCount, ...libraryFilterParamsForServer(serverId) },
).then(data => {
const raw = data.similarSongs?.song;
const songs = !raw ? [] : Array.isArray(raw) ? raw : [raw];
return { serverId, songs };
}),
),
);
const merged = mergeClusterTracks(
settled.flatMap((row, idx) =>
row.status === 'fulfilled'
? row.value.songs.map(song => ({
item: { ...song, clusterBrowseServerId: row.value.serverId },
serverId: row.value.serverId,
priorityRank: idx,
}))
: [],
),
);
return merged.filter(s => s.id !== id).slice(0, count);
}
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() });
+7 -2
View File
@@ -16,11 +16,16 @@ async function scrobbleOnServer(
await apiForServer(serverId, 'scrobble.view', params);
}
export async function scrobbleSong(id: string, time: number, serverId: string): Promise<void> {
export async function scrobbleSong(
id: string,
time: number,
serverId: string,
resolvedServerId?: string,
): Promise<void> {
if (!serverId) return;
if (isClusterMode()) {
const browseId = useAuthStore.getState().activeServerId ?? serverId;
await clusterFanOutScrobbleSubmission(browseId, id, time);
await clusterFanOutScrobbleSubmission(browseId, id, time, resolvedServerId ?? serverId);
return;
}
try {
+2
View File
@@ -100,6 +100,8 @@ export interface SubsonicSong {
subRole?: string;
artist: { id?: string; name: string };
}>;
/** Psysonic cluster: browse-origin server for fan-out resolution (client-only). */
clusterBrowseServerId?: string;
}
export interface InternetRadioStation {
+2
View File
@@ -48,6 +48,7 @@ import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelect
import { useAppActivityTracking } from '../hooks/useAppActivityTracking';
import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator';
import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority';
import { useClusterPlaybackMonitor } from '../hooks/useClusterPlaybackMonitor';
import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope';
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
@@ -102,6 +103,7 @@ export function AppShell() {
const location = useLocation();
const prevPathnameRef = useRef(location.pathname);
useCoverNavigationPriority();
useClusterPlaybackMonitor();
useLiveSearchRouteScope();
useNowPlayingPrewarm();
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ServerCluster } from '../utils/serverCluster/types';
import {
formatExcludedMemberLabels,
getClusterMergeDiagnostics,
type ClusterMergeDiagnostics,
} from '../utils/serverCluster/clusterMergeStatus';
export default function ClusterMergeBanner({ cluster }: { cluster: ServerCluster }) {
const { t } = useTranslation();
const [diag, setDiag] = useState<ClusterMergeDiagnostics | null>(null);
useEffect(() => {
let cancelled = false;
void getClusterMergeDiagnostics(cluster).then(res => {
if (!cancelled) setDiag(res);
}).catch(() => {
if (!cancelled) setDiag(null);
});
return () => { cancelled = true; };
}, [cluster]);
if (!diag || diag.mergeCount >= diag.totalCount) return null;
return (
<div className="connection-indicator-cluster-banner">
{t('cluster.mergeBanner', {
excluded: formatExcludedMemberLabels(diag.members),
})}
</div>
);
}
+5
View File
@@ -10,6 +10,7 @@ import { switchActiveCluster, switchActiveServer } from '../utils/server/switchA
import { showToast } from '../utils/ui/toast';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
import type { ServerCluster } from '../utils/serverCluster/types';
import ClusterMergeBanner from './ClusterMergeBanner';
interface Props {
status: ConnectionStatus;
@@ -31,6 +32,9 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
const menuPanelRef = useRef<HTMLDivElement>(null);
const multi = servers.length > 1 || clusters.length > 0;
const activeCluster = activeClusterId
? clusters.find(cluster => cluster.id === activeClusterId) ?? null
: null;
const updateMenuPosition = useCallback(() => {
const el = hostRef.current;
@@ -170,6 +174,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
</span>
</div>
</div>
{activeCluster && <ClusterMergeBanner cluster={activeCluster} />}
{multi &&
menuOpen &&
typeof document !== 'undefined' &&
+7 -1
View File
@@ -15,6 +15,7 @@ import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { isLanUrl, serverShareBaseUrl } from '../utils/server/serverEndpoint';
import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
interface Props { onClose: () => void; }
@@ -36,6 +37,7 @@ export default function OrbitStartModal({ onClose }: Props) {
const [copied, setCopied] = useState(false);
const [hasCopied, setHasCopied] = useState(false);
const [clearQueue, setClearQueue] = useState(false);
const clusterBlocked = isClusterMode();
const server = useAuthStore.getState().getActiveServer();
// Orbit links go to remote guests — use the share URL (public by default
@@ -70,6 +72,10 @@ export default function OrbitStartModal({ onClose }: Props) {
const onStart = async () => {
setError(null);
if (clusterBlocked) {
setError(t('orbit.clusterCreateBlocked'));
return;
}
const trimmed = name.trim();
if (!trimmed) { setError(t('orbit.errNameRequired')); return; }
@@ -222,7 +228,7 @@ export default function OrbitStartModal({ onClose }: Props) {
type="button"
className="btn btn-primary"
onClick={onStart}
disabled={busy || !name.trim()}
disabled={busy || !name.trim() || clusterBlocked}
>
{busy
? t('orbit.btnStarting')
+28 -3
View File
@@ -209,10 +209,35 @@ function QueuePanelHostOrSolo() {
if (!active) return;
if (isClusterMode()) {
const repId = active.id;
const foreign = queueItems.some(
r => resolveServerIdForIndexKey(r.serverId) !== repId,
const exportRefs = queueItems.filter(
r => resolveServerIdForIndexKey(r.serverId) === repId,
);
if (foreign && !confirm(t('queue.shareClusterMixedWarning'))) return;
const foreignCount = queueItems.length - exportRefs.length;
if (foreignCount > 0) {
const msg = t('queue.shareClusterMixedWarning', {
exported: exportRefs.length,
total: queueItems.length,
});
if (!confirm(msg)) return;
}
if (exportRefs.length === 0) {
showToast(t('queue.shareClusterNothingOnPrimary'), 4000, 'info');
return;
}
const srv = serverShareBaseUrl(active);
if (!srv) return;
const ids = exportRefs.map(r => r.trackId);
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
if (ok) {
showToast(
foreignCount > 0
? t('queue.shareClusterExportedCount', { exported: exportRefs.length, total: queueItems.length })
: t('contextMenu.shareCopied'),
);
} else {
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
}
return;
}
const srv = serverShareBaseUrl(active);
if (!srv) return;
@@ -6,6 +6,10 @@ import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
import { switchActiveCluster } from '../../utils/server/switchActiveServer';
import type { ServerCluster } from '../../utils/serverCluster/types';
import {
getClusterMergeDiagnostics,
type ClusterMergeDiagnostics,
} from '../../utils/serverCluster/clusterMergeStatus';
type MemberDropTarget = { idx: number; before: boolean } | null;
@@ -19,6 +23,7 @@ export function ServerClustersSection() {
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [memberDropTarget, setMemberDropTarget] = useState<MemberDropTarget>(null);
const [diagnosticsByCluster, setDiagnosticsByCluster] = useState<Record<string, ClusterMergeDiagnostics>>({});
const memberDropRef = useRef<MemberDropTarget>(null);
const dragClusterIdRef = useRef<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -32,6 +37,20 @@ export function ServerClustersSection() {
}
}, [psyDragState.isDragging]);
useEffect(() => {
let cancelled = false;
void Promise.all(auth.clusters.map(async cluster => {
const diag = await getClusterMergeDiagnostics(cluster);
return [cluster.id, diag] as const;
})).then(entries => {
if (cancelled) return;
setDiagnosticsByCluster(Object.fromEntries(entries));
}).catch(() => {
if (!cancelled) setDiagnosticsByCluster({});
});
return () => { cancelled = true; };
}, [auth.clusters, auth.servers, auth.activeClusterId, auth.musicLibraryFilterVersion]);
const startCreate = () => {
setCreating(true);
setNewName('');
@@ -215,6 +234,14 @@ export function ServerClustersSection() {
{cluster.serverIds.map((sid, memberIdx) => {
const srv = auth.servers.find(s => s.id === sid);
if (!srv) return null;
const status = diagnosticsByCluster[cluster.id]?.members.find(m => m.serverId === sid);
const statusLabel = !status
? ''
: status.included
? t('cluster.memberIncluded')
: status.reason === 'indexing'
? t('cluster.memberExcludedIndexing')
: t('cluster.memberExcludedOffline');
const isBefore =
psyDragState.isDragging &&
dragClusterIdRef.current === cluster.id &&
@@ -244,6 +271,11 @@ export function ServerClustersSection() {
>
<ClusterMemberGrip clusterId={cluster.id} idx={memberIdx} label={cluster.name} />
<span style={{ flex: 1 }}>{serverListDisplayLabel(srv, auth.servers)}</span>
{statusLabel && (
<span style={{ fontSize: 11, color: status?.included ? 'var(--text-muted)' : 'var(--warning)' }}>
{statusLabel}
</span>
)}
<button
type="button"
className="btn btn-ghost"
@@ -2,11 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
libraryGetPlayerStatsDayDetail,
libraryGetPlayerStatsRecentDays,
type PlaySessionDayDetail,
type PlaySessionRecentDay,
} from '../../api/library';
import {
loadPlayerStatsDayDetail,
loadPlayerStatsRecentDays,
} from '../../utils/serverCluster/clusterPlayerStats';
import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration';
import {
formatPlayerStatsDayLabel,
@@ -54,7 +56,7 @@ export default function PlayerStatsRecentDays({
const loadDetail = useCallback(async (date: string) => {
setLoadingDates(prev => new Set(prev).add(date));
try {
const detail = await libraryGetPlayerStatsDayDetail(date);
const detail = await loadPlayerStatsDayDetail(date);
setDetails(prev => new Map(prev).set(date, detail));
} catch {
/* ignore */
@@ -70,7 +72,7 @@ export default function PlayerStatsRecentDays({
useEffect(() => {
let cancelled = false;
setLoading(true);
libraryGetPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT)
loadPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT)
.then(rows => {
if (!cancelled) {
setDays(rows);
@@ -89,7 +91,7 @@ export default function PlayerStatsRecentDays({
useEffect(() => {
if (liveRefreshKey === 0) return;
let cancelled = false;
libraryGetPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT)
loadPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT)
.then(rows => {
if (cancelled) return;
setDays(rows);
+1 -1
View File
@@ -48,7 +48,7 @@ export function useCliBridge(navigate: NavigateFunction) {
}
const serverId = useAuthStore.getState().activeServerId;
try {
const similar = await getSimilarSongs(song.id, 50);
const similar = await getSimilarSongs(song.id, 50, song.clusterBrowseServerId);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
const mixCfg = getMixMinRatingsConfigFromAuth();
const ratedFiltered = await filterSongsForLuckyMixRatings(
+1
View File
@@ -0,0 +1 @@
export { useClusterPlaybackMonitor } from '../utils/serverCluster/clusterPlaybackMonitor';
+10
View File
@@ -0,0 +1,10 @@
export const cluster = {
fanOutStarFailed: 'Stern-Synchronisierung fehlgeschlagen auf: {{servers}}.',
fanOutRatingFailed: 'Bewertungs-Synchronisierung fehlgeschlagen auf: {{servers}}.',
fanOutScrobbleFailed: 'Scrobble-Synchronisierung fehlgeschlagen auf: {{servers}}.',
mergeBanner: 'Cluster-Zusammenführung schließt aus: {{excluded}}.',
memberIncluded: 'Eingeschlossen',
memberExcludedOffline: 'Ausgeschlossen (offline)',
memberExcludedIndexing: 'Ausgeschlossen (Index läuft)',
browseUnsupported: 'Cluster-Browse ist aktuell nicht verfügbar.',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { cluster } from './cluster';
export const deTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const deTranslation = {
orbit,
tray,
licenses,
cluster,
};
+3
View File
@@ -29,6 +29,9 @@ export const queue = {
nextTracks: 'Nächste Titel',
shareQueue: 'Warteschlangen-Link kopieren',
shareQueueEmpty: 'Die Warteschlange ist leer — nichts zu teilen.',
shareClusterMixedWarning: 'Gemischte Cluster-Warteschlange: {{exported}} von {{total}} Titeln vom primären Server exportieren?',
shareClusterExportedCount: '{{exported}} von {{total}} Titeln vom primären Server exportiert.',
shareClusterNothingOnPrimary: 'Nichts zu exportieren: keine Titel vom primären Server in der Warteschlange.',
emptyQueue: 'Die Warteschlange ist leer.',
crossServerEnqueueBlocked: 'Titel von einem anderen Server können der aktuellen Warteschlange nicht hinzugefügt werden. Beende oder leere die Warteschlange zuerst.',
trackSingular: 'Titel',
+10
View File
@@ -0,0 +1,10 @@
export const cluster = {
fanOutStarFailed: 'Star sync failed on: {{servers}}.',
fanOutRatingFailed: 'Rating sync failed on: {{servers}}.',
fanOutScrobbleFailed: 'Scrobble sync failed on: {{servers}}.',
mergeBanner: 'Cluster merge excludes: {{excluded}}.',
memberIncluded: 'Included',
memberExcludedOffline: 'Excluded (offline)',
memberExcludedIndexing: 'Excluded (indexing)',
browseUnsupported: 'Cluster browse is currently unavailable.',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { cluster } from './cluster';
export const enTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const enTranslation = {
orbit,
tray,
licenses,
cluster,
};
+3 -1
View File
@@ -29,7 +29,9 @@ 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?',
shareClusterMixedWarning: 'This queue has mixed cluster tracks. Export {{exported}} of {{total}} tracks from the primary server?',
shareClusterExportedCount: 'Exported {{exported}} of {{total}} tracks from the primary server.',
shareClusterNothingOnPrimary: 'Nothing to export: no queued tracks from the primary server.',
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',
+10
View File
@@ -0,0 +1,10 @@
export const cluster = {
fanOutStarFailed: 'La sincronización de favoritos falló en: {{servers}}.',
fanOutRatingFailed: 'La sincronización de valoraciones falló en: {{servers}}.',
fanOutScrobbleFailed: 'La sincronización de scrobble falló en: {{servers}}.',
mergeBanner: 'La fusión del clúster excluye: {{excluded}}.',
memberIncluded: 'Incluido',
memberExcludedOffline: 'Excluido (sin conexión)',
memberExcludedIndexing: 'Excluido (indexando)',
browseUnsupported: 'La exploración del clúster no está disponible por ahora.',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { cluster } from './cluster';
export const esTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const esTranslation = {
orbit,
tray,
licenses,
cluster,
};
+3
View File
@@ -29,6 +29,9 @@ export const queue = {
nextTracks: 'Siguientes',
shareQueue: 'Copiar enlace de la cola',
shareQueueEmpty: 'La cola está vacía — no hay nada que compartir.',
shareClusterMixedWarning: 'Cola de clúster mixta: ¿exportar {{exported}} de {{total}} pistas del servidor principal?',
shareClusterExportedCount: 'Se exportaron {{exported}} de {{total}} pistas del servidor principal.',
shareClusterNothingOnPrimary: 'Nada para exportar: no hay pistas del servidor principal en la cola.',
emptyQueue: 'La cola está vacía.',
crossServerEnqueueBlocked: 'No se pueden añadir pistas de otro servidor a la cola actual. Termina o vacía la cola primero.',
trackSingular: 'pista',
+10
View File
@@ -0,0 +1,10 @@
export const cluster = {
fanOutStarFailed: 'Échec de synchro des favoris sur : {{servers}}.',
fanOutRatingFailed: 'Échec de synchro des notes sur : {{servers}}.',
fanOutScrobbleFailed: 'Échec de synchro des scrobbles sur : {{servers}}.',
mergeBanner: 'La fusion de cluster exclut : {{excluded}}.',
memberIncluded: 'Inclus',
memberExcludedOffline: 'Exclu (hors ligne)',
memberExcludedIndexing: 'Exclu (indexation)',
browseUnsupported: 'La navigation cluster est indisponible pour le moment.',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { cluster } from './cluster';
export const frTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const frTranslation = {
orbit,
tray,
licenses,
cluster,
};
+3
View File
@@ -29,6 +29,9 @@ export const queue = {
nextTracks: 'Pistes suivantes',
shareQueue: 'Copier le lien de la file dattente',
shareQueueEmpty: 'La file dattente est vide — rien à partager.',
shareClusterMixedWarning: 'File de cluster mixte : exporter {{exported}} pistes sur {{total}} depuis le serveur principal ?',
shareClusterExportedCount: '{{exported}} pistes sur {{total}} exportées depuis le serveur principal.',
shareClusterNothingOnPrimary: 'Rien à exporter : aucune piste du serveur principal dans la file.',
emptyQueue: 'La file d\'attente est vide.',
crossServerEnqueueBlocked: 'Les titres d\'un autre serveur ne peuvent pas être ajoutés à la file en cours. Terminez ou videz la file d\'abord.',
trackSingular: 'piste',
+10
View File
@@ -0,0 +1,10 @@
export const cluster = {
fanOutStarFailed: 'Favoritt-synk mislyktes på: {{servers}}.',
fanOutRatingFailed: 'Vurderings-synk mislyktes på: {{servers}}.',
fanOutScrobbleFailed: 'Scrobble-synk mislyktes på: {{servers}}.',
mergeBanner: 'Cluster-fletting ekskluderer: {{excluded}}.',
memberIncluded: 'Inkludert',
memberExcludedOffline: 'Ekskludert (offline)',
memberExcludedIndexing: 'Ekskludert (indeksering)',
browseUnsupported: 'Cluster-blaing er ikke tilgjengelig akkurat nå.',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { cluster } from './cluster';
export const nbTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const nbTranslation = {
orbit,
tray,
licenses,
cluster,
};
+3
View File
@@ -29,6 +29,9 @@ export const queue = {
nextTracks: 'Neste spor',
shareQueue: 'Kopiér lenke til kø',
shareQueueEmpty: 'Køen er tom — ingenting å dele.',
shareClusterMixedWarning: 'Blandet cluster-kø: eksporter {{exported}} av {{total}} spor fra primærserveren?',
shareClusterExportedCount: 'Eksporterte {{exported}} av {{total}} spor fra primærserveren.',
shareClusterNothingOnPrimary: 'Ingenting å eksportere: ingen spor fra primærserveren i køen.',
emptyQueue: 'Køen er tom.',
crossServerEnqueueBlocked: 'Spor fra en annen server kan ikke legges til i den nåværende køen. Tøm eller avslutt køen først.',
trackSingular: 'spor',
+10
View File
@@ -0,0 +1,10 @@
export const cluster = {
fanOutStarFailed: 'Favoriet-sync mislukt op: {{servers}}.',
fanOutRatingFailed: 'Beoordelings-sync mislukt op: {{servers}}.',
fanOutScrobbleFailed: 'Scrobble-sync mislukt op: {{servers}}.',
mergeBanner: 'Cluster-samenvoeging sluit uit: {{excluded}}.',
memberIncluded: 'Inbegrepen',
memberExcludedOffline: 'Uitgesloten (offline)',
memberExcludedIndexing: 'Uitgesloten (indexeren)',
browseUnsupported: 'Cluster bladeren is momenteel niet beschikbaar.',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { cluster } from './cluster';
export const nlTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const nlTranslation = {
orbit,
tray,
licenses,
cluster,
};
+3
View File
@@ -29,6 +29,9 @@ export const queue = {
nextTracks: 'Volgende nummers',
shareQueue: 'Wachtrij-deellink kopiëren',
shareQueueEmpty: 'De wachtrij is leeg — niets om te delen.',
shareClusterMixedWarning: 'Gemengde clusterwachtrij: {{exported}} van {{total}} nummers van de primaire server exporteren?',
shareClusterExportedCount: '{{exported}} van {{total}} nummers van de primaire server geëxporteerd.',
shareClusterNothingOnPrimary: 'Niets te exporteren: geen nummers van de primaire server in de wachtrij.',
emptyQueue: 'De wachtrij is leeg.',
crossServerEnqueueBlocked: 'Tracks van een andere server kunnen niet aan de huidige wachtrij worden toegevoegd. Maak de wachtrij eerst leeg of stop afspelen.',
trackSingular: 'nummer',
+10
View File
@@ -0,0 +1,10 @@
export const cluster = {
fanOutStarFailed: 'Sincronizarea favorite a eșuat pe: {{servers}}.',
fanOutRatingFailed: 'Sincronizarea rating-urilor a eșuat pe: {{servers}}.',
fanOutScrobbleFailed: 'Sincronizarea scrobble a eșuat pe: {{servers}}.',
mergeBanner: 'Îmbinarea clusterului exclude: {{excluded}}.',
memberIncluded: 'Inclus',
memberExcludedOffline: 'Exclus (offline)',
memberExcludedIndexing: 'Exclus (indexare)',
browseUnsupported: 'Navigarea în cluster nu este disponibilă momentan.',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { cluster } from './cluster';
export const roTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const roTranslation = {
orbit,
tray,
licenses,
cluster,
};
+3
View File
@@ -29,6 +29,9 @@ export const queue = {
nextTracks: 'Următoarele Piese',
shareQueue: 'Copiază link-ul distribuirii cozii',
shareQueueEmpty: 'Coada este goală — nimic de distribuit.',
shareClusterMixedWarning: 'Coada de cluster este mixtă: export {{exported}} din {{total}} piese de pe serverul principal?',
shareClusterExportedCount: 'Au fost exportate {{exported}} din {{total}} piese de pe serverul principal.',
shareClusterNothingOnPrimary: 'Nimic de exportat: nu există piese de pe serverul principal în coadă.',
emptyQueue: 'Coada este goală.',
crossServerEnqueueBlocked: 'Piesele de pe alt server nu pot fi adăugate în coada curentă. Golește sau oprește coada mai întâi.',
trackSingular: 'piesă',
+10
View File
@@ -0,0 +1,10 @@
export const cluster = {
fanOutStarFailed: 'Не удалось синхронизировать избранное на: {{servers}}.',
fanOutRatingFailed: 'Не удалось синхронизировать рейтинг на: {{servers}}.',
fanOutScrobbleFailed: 'Не удалось синхронизировать скроббл на: {{servers}}.',
mergeBanner: 'Из объединения кластера исключены: {{excluded}}.',
memberIncluded: 'Включён',
memberExcludedOffline: 'Исключён (офлайн)',
memberExcludedIndexing: 'Исключён (индексация)',
browseUnsupported: 'Просмотр кластера сейчас недоступен.',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { cluster } from './cluster';
export const ruTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const ruTranslation = {
orbit,
tray,
licenses,
cluster,
};
+3
View File
@@ -29,6 +29,9 @@ export const queue = {
nextTracks: 'Дальше',
shareQueue: 'Копировать ссылку на очередь',
shareQueueEmpty: 'Очередь пуста — нечем поделиться.',
shareClusterMixedWarning: 'Смешанная очередь кластера: экспортировать {{exported}} из {{total}} треков с основного сервера?',
shareClusterExportedCount: 'Экспортировано {{exported}} из {{total}} треков с основного сервера.',
shareClusterNothingOnPrimary: 'Экспорт невозможен: в очереди нет треков с основного сервера.',
emptyQueue: 'Очередь пуста.',
crossServerEnqueueBlocked: 'Треки с другого сервера нельзя добавить в текущую очередь. Завершите или очистите очередь.',
trackSingular: 'трек',
+10
View File
@@ -0,0 +1,10 @@
export const cluster = {
fanOutStarFailed: '以下服务器收藏同步失败:{{servers}}。',
fanOutRatingFailed: '以下服务器评分同步失败:{{servers}}。',
fanOutScrobbleFailed: '以下服务器 scrobble 同步失败:{{servers}}。',
mergeBanner: '集群合并已排除:{{excluded}}。',
memberIncluded: '已纳入',
memberExcludedOffline: '已排除(离线)',
memberExcludedIndexing: '已排除(索引中)',
browseUnsupported: '集群浏览当前不可用。',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { cluster } from './cluster';
export const zhTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const zhTranslation = {
orbit,
tray,
licenses,
cluster,
};
+3
View File
@@ -29,6 +29,9 @@ export const queue = {
nextTracks: '即将播放',
shareQueue: '复制队列分享链接',
shareQueueEmpty: '队列为空,无法分享。',
shareClusterMixedWarning: '队列包含混合集群曲目:是否仅导出主服务器上的 {{exported}} / {{total}} 首曲目?',
shareClusterExportedCount: '已从主服务器导出 {{exported}} / {{total}} 首曲目。',
shareClusterNothingOnPrimary: '无可导出内容:队列中没有主服务器曲目。',
emptyQueue: '队列为空。',
crossServerEnqueueBlocked: '无法将其他服务器的曲目加入当前播放队列。请先清空或结束当前队列。',
trackSingular: '首曲目',
+83
View File
@@ -11,6 +11,11 @@ import FolderBrowserColumn from '../components/folderBrowser/FolderBrowserColumn
import { useFolderBrowserNowPlayingPath } from '../hooks/useFolderBrowserNowPlayingPath';
import { useFolderBrowserScrolling } from '../hooks/useFolderBrowserScrolling';
import { useFolderBrowserKeyboardNav } from '../hooks/useFolderBrowserKeyboardNav';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { resolveClusterBrowseMembers } from '../utils/serverCluster/clusterBrowse';
import { apiForServer, libraryFilterParamsForServer } from '../api/subsonicClient';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
import { useAuthStore } from '../store/authStore';
export default function FolderBrowser() {
const { t } = useTranslation();
@@ -22,6 +27,8 @@ export default function FolderBrowser() {
const pendingNavColRef = useRef<number | null>(null);
const [keyboardPos, setKeyboardPos] = useState<NavPos | null>(null);
const [contextAnchorPos, setContextAnchorPos] = useState<NavPos | null>(null);
const [clusterGroups, setClusterGroups] = useState<Array<{ serverId: string; label: string; items: SubsonicDirectoryEntry[] }>>([]);
const [clusterLoading, setClusterLoading] = useState(false);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const playTrack = usePlayerStore(s => s.playTrack);
@@ -61,6 +68,48 @@ export default function FolderBrowser() {
});
}, []);
useEffect(() => {
if (!isClusterMode()) return;
let cancelled = false;
setClusterLoading(true);
void (async () => {
const members = await resolveClusterBrowseMembers();
if (!members?.length) {
if (!cancelled) {
setClusterGroups([]);
setClusterLoading(false);
}
return;
}
const all = useAuthStore.getState().servers;
const settled = await Promise.allSettled(
members.map(async (serverId: string) => {
const data = await apiForServer<{ musicFolders?: { musicFolder?: Array<{ id: string; name: string }> } }>(
serverId,
'getMusicFolders.view',
libraryFilterParamsForServer(serverId),
);
const server = all.find(s => s.id === serverId);
const label = server ? serverListDisplayLabel(server, all) : serverId;
const items = (data.musicFolders?.musicFolder ?? []).map(f => ({
id: f.id,
title: f.name,
isDir: true,
}));
return { serverId, label, items };
}),
);
if (cancelled) return;
const groups: Array<{ serverId: string; label: string; items: SubsonicDirectoryEntry[] }> = [];
settled.forEach((r) => {
if (r.status === 'fulfilled') groups.push(r.value);
});
setClusterGroups(groups);
setClusterLoading(false);
})();
return () => { cancelled = true; };
}, []);
useEffect(() => {
setColumnFilters(prev => {
const next: Record<number, string> = {};
@@ -316,6 +365,40 @@ export default function FolderBrowser() {
return Math.abs(colIndex - visibleAnchorColIndex) > 1;
}, [compactColumnsEnabled, visibleAnchorColIndex]);
if (isClusterMode()) {
return (
<div className="folder-browser">
<h1 className="page-title folder-browser-title">{t('sidebar.folderBrowser')}</h1>
{clusterLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
<div className="spinner" />
</div>
) : clusterGroups.length === 0 ? (
<div className="empty-state">{t('cluster.browseUnsupported')}</div>
) : (
<div style={{ display: 'grid', gap: 16 }}>
{clusterGroups.map(group => (
<section key={group.serverId}>
<h3 style={{ margin: '0 0 10px' }}>{group.label}</h3>
{group.items.length === 0 ? (
<div className="empty-state" style={{ padding: '10px 0' }}>{t('cluster.browseUnsupported')}</div>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{group.items.map(item => (
<div key={`${group.serverId}:${item.id}`} className="card" style={{ padding: '10px 12px' }}>
{item.title}
</div>
))}
</div>
)}
</section>
))}
</div>
)}
</div>
);
}
return (
<div className="folder-browser">
<h1 className="page-title folder-browser-title">{t('sidebar.folderBrowser')}</h1>
+83
View File
@@ -20,6 +20,11 @@ import RadioEditModal from '../components/internetRadio/RadioEditModal';
import RadioDirectoryModal from '../components/internetRadio/RadioDirectoryModal';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { resolveClusterBrowseMembers } from '../utils/serverCluster/clusterBrowse';
import { apiForServer } from '../api/subsonicClient';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
import { useAuthStore } from '../store/authStore';
export default function InternetRadio() {
const { t } = useTranslation();
@@ -45,6 +50,8 @@ export default function InternetRadio() {
});
const [manualOrder, setManualOrder] = useState<string[]>([]);
const [dragOver, setDragOver] = useState<{ id: string; side: 'before' | 'after' } | null>(null);
const [clusterGroups, setClusterGroups] = useState<Array<{ serverId: string; label: string; stations: InternetRadioStation[] }>>([]);
const [clusterLoading, setClusterLoading] = useState(false);
useEffect(() => {
getInternetRadioStations()
@@ -53,6 +60,42 @@ export default function InternetRadio() {
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!isClusterMode()) return;
let cancelled = false;
setClusterLoading(true);
void (async () => {
const members = await resolveClusterBrowseMembers();
if (!members?.length) {
if (!cancelled) {
setClusterGroups([]);
setClusterLoading(false);
}
return;
}
const all = useAuthStore.getState().servers;
const settled = await Promise.allSettled(
members.map(async (serverId: string) => {
const data = await apiForServer<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>(
serverId,
'getInternetRadioStations.view',
);
const server = all.find(s => s.id === serverId);
const label = server ? serverListDisplayLabel(server, all) : serverId;
return { serverId, label, stations: data.internetRadioStations?.internetRadioStation ?? [] };
}),
);
if (cancelled) return;
const groups: Array<{ serverId: string; label: string; stations: InternetRadioStation[] }> = [];
settled.forEach((r) => {
if (r.status === 'fulfilled') groups.push(r.value);
});
setClusterGroups(groups);
setClusterLoading(false);
})();
return () => { cancelled = true; };
}, []);
const reload = async () => {
const list = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]);
setStations(list);
@@ -223,6 +266,46 @@ export default function InternetRadio() {
);
}
if (isClusterMode()) {
return (
<div className="content-body animate-fade-in">
<h1 className="page-title">{t('radio.title')}</h1>
{clusterLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
<div className="spinner" />
</div>
) : clusterGroups.length === 0 ? (
<div className="empty-state">{t('radio.empty')}</div>
) : (
<div style={{ display: 'grid', gap: 16 }}>
{clusterGroups.map(group => (
<section key={group.serverId}>
<h3 style={{ margin: '0 0 10px' }}>{group.label}</h3>
{group.stations.length === 0 ? (
<div className="empty-state" style={{ padding: '10px 0' }}>{t('radio.empty')}</div>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{group.stations.map(station => (
<button
key={`${group.serverId}:${station.id}`}
className="btn btn-secondary"
style={{ width: '100%', justifyContent: 'space-between' }}
onClick={() => playRadio(station)}
>
<span>{station.name}</span>
<span style={{ opacity: 0.7 }}>{group.label}</span>
</button>
))}
</div>
)}
</section>
))}
</div>
)}
</div>
);
}
return (
<div className="content-body animate-fade-in">
+22
View File
@@ -12,6 +12,9 @@ import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from '../components/LongPressWaveOverlay';
import { useTranslation } from 'react-i18next';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { loadPlayerStatsMostPlayed } from '../utils/serverCluster/clusterPlayerStats';
import { trackToSong } from '../utils/library/advancedSearchLocal';
const PAGE_SIZE = 50;
@@ -113,6 +116,24 @@ export default function MostPlayed() {
setAlbums([]);
setHasMore(true);
try {
if (isClusterMode()) {
const rows = await loadPlayerStatsMostPlayed(PAGE_SIZE);
const asAlbums = rows.map(row => {
const s = trackToSong(row.track);
return {
id: s.albumId || `${row.track.serverId}:${s.id}`,
name: s.album,
artist: s.artist,
artistId: s.artistId || '',
coverArt: s.coverArt,
playCount: row.trackPlayCount,
} as SubsonicAlbum;
});
setAlbums(asAlbums);
setHasMore(false);
setLoading(false);
return;
}
const result = await getAlbumList('frequent', PAGE_SIZE, 0);
setAlbums(result);
setHasMore(result.length === PAGE_SIZE);
@@ -123,6 +144,7 @@ export default function MostPlayed() {
useEffect(() => { load(); }, [load]);
const loadMore = async () => {
if (isClusterMode()) return;
if (loadingMore || !hasMore) return;
setLoadingMore(true);
try {
+100 -1
View File
@@ -1,7 +1,8 @@
import { getPlaylist } from '../api/subsonicPlaylists';
import { getGenres } from '../api/subsonicGenres';
import { apiForServer } from '../api/subsonicClient';
import { filterSongsToActiveLibrary } from '../api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes';
import type { SubsonicPlaylist, SubsonicGenre, SubsonicSong } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
@@ -29,6 +30,9 @@ import PlaylistsHeader from '../components/playlists/PlaylistsHeader';
import PlaylistCard from '../components/playlists/PlaylistCard';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { resolveClusterBrowseMembers } from '../utils/serverCluster/clusterBrowse';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
function formatDuration(seconds: number): string {
return formatHumanHoursMinutes(seconds);
@@ -65,6 +69,8 @@ export default function Playlists() {
usePlaylistsLibraryScopeCounts(playlists, musicLibraryFilterVersion);
const [playingId, setPlayingId] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [clusterGroups, setClusterGroups] = useState<Array<{ serverId: string; label: string; playlists: SubsonicPlaylist[] }>>([]);
const [clusterLoading, setClusterLoading] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
// ── Multi-selection ──────────────────────────────────────────────────────
@@ -97,6 +103,44 @@ export default function Playlists() {
getGenres().then(setGenres).catch(() => {});
}, [fetchPlaylists]);
useEffect(() => {
if (!isClusterMode()) return;
let cancelled = false;
setClusterLoading(true);
void (async () => {
const members = await resolveClusterBrowseMembers();
if (!members || members.length === 0) {
if (!cancelled) {
setClusterGroups([]);
setClusterLoading(false);
}
return;
}
const all = useAuthStore.getState().servers;
const settled = await Promise.allSettled(
members.map(async (serverId: string) => {
const data = await apiForServer<{ playlists?: { playlist?: SubsonicPlaylist[] } }>(
serverId,
'getPlaylists.view',
{ _t: Date.now() },
);
const playlistsForServer = (data.playlists?.playlist ?? []).filter(p => !p.name.startsWith('__psyorbit_'));
const server = all.find(s => s.id === serverId);
const label = server ? serverListDisplayLabel(server, all) : serverId;
return { serverId, label, playlists: playlistsForServer };
}),
);
if (cancelled) return;
const groups: Array<{ serverId: string; label: string; playlists: SubsonicPlaylist[] }> = [];
settled.forEach((r) => {
if (r.status === 'fulfilled') groups.push(r.value);
});
setClusterGroups(groups);
setClusterLoading(false);
})();
return () => { cancelled = true; };
}, [musicLibraryFilterVersion]);
useEffect(() => {
if (creating) nameInputRef.current?.focus();
}, [creating]);
@@ -169,6 +213,61 @@ export default function Playlists() {
);
}
if (isClusterMode()) {
return (
<div className="content-body animate-fade-in">
<h1 className="page-title">{t('playlists.title')}</h1>
{clusterLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
<div className="spinner" />
</div>
) : clusterGroups.length === 0 ? (
<div className="empty-state">{t('playlists.empty')}</div>
) : (
<div style={{ display: 'grid', gap: 16 }}>
{clusterGroups.map(group => (
<section key={group.serverId}>
<h3 style={{ margin: '0 0 10px' }}>{group.label}</h3>
{group.playlists.length === 0 ? (
<div className="empty-state" style={{ padding: '10px 0' }}>{t('playlists.empty')}</div>
) : (
<div className="playlist-grid">
{group.playlists.map(pl => (
<button
key={`${group.serverId}:${pl.id}`}
className="btn btn-secondary"
style={{ width: '100%', justifyContent: 'space-between', marginBottom: 8 }}
onClick={async () => {
if (playingId === `${group.serverId}:${pl.id}`) return;
setPlayingId(`${group.serverId}:${pl.id}`);
try {
const data = await apiForServer<{ playlist: SubsonicPlaylist & { entry?: SubsonicSong[] } }>(
group.serverId,
'getPlaylist.view',
{ id: pl.id },
);
const tracks = (data.playlist.entry ?? [])
.map(song => ({ ...song, clusterBrowseServerId: group.serverId }))
.map(songToTrack);
if (tracks.length > 0) playTrack(tracks[0], tracks);
} catch {}
setPlayingId(null);
}}
>
<span>{pl.name}</span>
<span style={{ opacity: 0.75 }}>{pl.songCount ?? 0}</span>
</button>
))}
</div>
)}
</section>
))}
</div>
)}
</div>
);
}
return (
<div className="content-body animate-fade-in">
<style>{`
+2 -1
View File
@@ -55,6 +55,7 @@ import {
runLocalBrowseFullSearch,
runNetworkBrowseFullSearch,
} from '../utils/library/browseTextSearch';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { MOOD_GROUP_IDS } from '../config/moodGroups';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
@@ -864,7 +865,7 @@ export default function SearchBrowsePage() {
{!perfFlags.disableMainstageVirtualLists && (
<SongBrowseSection
title={t('tracks.browseTitle')}
emptyBrowseText={t('tracks.browseUnsupported')}
emptyBrowseText={isClusterMode() ? t('cluster.browseUnsupported') : t('tracks.browseUnsupported')}
searchActive={tracksSearchActive}
songs={songBrowse.songs}
hasMore={songBrowse.hasMore}
+14 -3
View File
@@ -18,6 +18,7 @@ import {
getPlaybackIndexKey,
getPlaybackServerId,
} from '../utils/playback/playbackServer';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
import { showToast } from '../utils/ui/toast';
@@ -93,7 +94,10 @@ export function handleAudioPlaying(duration: number): void {
notifyLibraryPlaybackHint('playing');
const track = usePlayerStore.getState().currentTrack;
if (track) {
void playListenSessionOpen(track, getPlaybackServerId(), duration);
const st = usePlayerStore.getState();
const ref = st.queueItems[st.queueIndex];
const sid = ref ? resolveServerIdForIndexKey(ref.serverId) : getPlaybackServerId();
void playListenSessionOpen(track, sid, duration);
}
}
@@ -187,7 +191,11 @@ export function handleAudioProgress(
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
if (progress >= 0.5 && !store.scrobbled) {
usePlayerStore.setState({ scrobbled: true });
scrobbleSong(track.id, Date.now(), getPlaybackServerId());
const ref = store.queueItems[store.queueIndex];
const resolvedSid = ref
? resolveServerIdForIndexKey(ref.serverId)
: getPlaybackServerId();
scrobbleSong(track.id, Date.now(), getPlaybackServerId(), resolvedSid);
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
if (scrobblingEnabled && lastfmSessionKey) {
lastfmScrobble(track, Date.now(), lastfmSessionKey);
@@ -476,7 +484,10 @@ export function handleAudioTrackSwitched(_duration: number): void {
// Report Now Playing to Navidrome + Last.fm
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
if (nowPlayingEnabled) reportNowPlaying(nextTrack.id, getPlaybackServerId());
const st = usePlayerStore.getState();
const ref = st.queueItems[st.queueIndex];
const resolvedSid = ref ? resolveServerIdForIndexKey(ref.serverId) : getPlaybackServerId();
if (nowPlayingEnabled) reportNowPlaying(nextTrack.id, resolvedSid);
if (lastfmSessionKey) {
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
+6 -1
View File
@@ -7,6 +7,7 @@ import {
} from '../api/library';
import { libraryIsReady } from '../utils/library/libraryReady';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
import { emitPlaySessionRecorded } from './playSessionRecorded';
import { useLibraryIndexStore } from './libraryIndexStore';
@@ -179,7 +180,11 @@ export async function playListenSessionFinalize(reason: PlaySessionEndReason): P
}
export async function playListenSessionOnTrackSwitched(nextTrack: Track): Promise<void> {
const serverId = getPlaybackServerId();
const state = usePlayerStore.getState();
const ref = state.queueItems[state.queueIndex];
const serverId = ref
? resolveServerIdForIndexKey(ref.serverId)
: getPlaybackServerId();
await playListenSessionFinalize('switch');
await playListenSessionOpen(nextTrack, serverId, nextTrack.duration);
}
+2
View File
@@ -33,6 +33,8 @@ export interface Track {
* end of the current Play-Next streak. Stale flags behind queueIndex are
* harmless the streak scan only looks forward from queueIndex+1. */
playNextAdded?: boolean;
/** Psysonic cluster: browse-origin server for resolve-on-enqueue (client-only). */
clusterBrowseServerId?: string;
}
/**
+110 -65
View File
@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
import { useAuthStore } from './authStore';
import { setIsAudioPaused } from './engineState';
import { useLuckyMixStore } from './luckyMixStore';
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
@@ -26,7 +27,8 @@ import {
ensureQueueServerPinned,
playbackServerDiffersFromActive,
} from '../utils/playback/playbackServer';
import { useLuckyMixStore } from './luckyMixStore';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { clusterAwareQueueRefs } from '../utils/serverCluster/clusterEnqueueResolve';
import { showToast } from '../utils/ui/toast';
type SetState = (
@@ -35,6 +37,7 @@ type SetState = (
type GetState = () => PlayerState;
function blockCrossServerEnqueue(): boolean {
if (isClusterMode()) return false;
if (useLuckyMixStore.getState().isRolling) return false;
if (!playbackServerDiffersFromActive()) return false;
showToast(i18n.t('queue.crossServerEnqueueBlocked'), 4500, 'error');
@@ -89,20 +92,35 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
}
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
ensureQueueServerPinned();
set(state => {
seedIncoming(state, tracks);
const items = itemsOf(state);
const state = get();
seedIncoming(state, tracks);
if (!isClusterMode()) {
const incoming = toQueueItemRefs(state.queueServerId ?? '', tracks);
// Insert before the first upcoming auto-added track so the
// "Added automatically" separator always stays at the boundary.
const firstAutoIdx = items.findIndex((r, i) => r.autoAdded && i > state.queueIndex);
const newItems = firstAutoIdx === -1
? [...items, ...incoming]
: [...items.slice(0, firstAutoIdx), ...incoming, ...items.slice(firstAutoIdx)];
syncQueueToServer(newItems, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(newItems, state.queueIndex);
return { queueItems: newItems };
});
set(s => {
const items = itemsOf(s);
const firstAutoIdx = items.findIndex((r, i) => r.autoAdded && i > s.queueIndex);
const newItems = firstAutoIdx === -1
? [...items, ...incoming]
: [...items.slice(0, firstAutoIdx), ...incoming, ...items.slice(firstAutoIdx)];
syncQueueToServer(newItems, s.currentTrack, s.currentTime);
prefetchLoudnessForEnqueuedTracks(newItems, s.queueIndex);
return { queueItems: newItems };
});
return;
}
void (async () => {
const incoming = await clusterAwareQueueRefs(tracks, state.queueServerId ?? '');
set(s => {
const items = itemsOf(s);
const firstAutoIdx = items.findIndex((r, i) => r.autoAdded && i > s.queueIndex);
const newItems = firstAutoIdx === -1
? [...items, ...incoming]
: [...items.slice(0, firstAutoIdx), ...incoming, ...items.slice(firstAutoIdx)];
syncQueueToServer(newItems, s.currentTrack, s.currentTime);
prefetchLoudnessForEnqueuedTracks(newItems, s.queueIndex);
return { queueItems: newItems };
});
})();
},
setRadioArtistId: (artistId) => {
@@ -121,46 +139,55 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
}
pushQueueUndoFromGetter(get);
ensureQueueServerPinned();
set(state => {
const items = itemsOf(state);
// Drop all upcoming (not yet played) radio tracks — clicking "Start Radio"
// again replaces the pending radio batch instead of stacking on top.
const beforeAndCurrent = items.slice(0, state.queueIndex + 1);
const upcoming = items.slice(state.queueIndex + 1).filter(r => !r.radioAdded);
// Tracks about to leave the queue here. Callers like ContextMenu.startRadio
// pass the previous pending radio back in `tracks` to merge with new
// similars — the seen-set must not block those re-introductions.
const droppedRadioIds = items
.slice(state.queueIndex + 1)
.filter(r => r.radioAdded)
.map(r => r.trackId);
for (const id of droppedRadioIds) deleteRadioSessionSeen(id);
// Capture surviving queue ids in the seen-set so the next radio top-up
// can dedupe against the seed track + already-queued non-radio items.
for (const r of beforeAndCurrent) addRadioSessionSeen(r.trackId);
for (const r of upcoming) addRadioSessionSeen(r.trackId);
// Drop incoming tracks already seen earlier this session AND
// intra-batch duplicates (top + similar Last.fm responses commonly
// overlap). The seen-set is mutated inside the loop so a repeated
// id later in `tracks` is rejected by the same pass that admitted
// the first occurrence (issue #500).
const dedupedTracks: Track[] = [];
for (const t of tracks) {
if (hasRadioSessionSeen(t.id)) continue;
addRadioSessionSeen(t.id);
dedupedTracks.push(t);
}
seedIncoming(state, dedupedTracks);
const state = get();
const items = itemsOf(state);
const beforeAndCurrent = items.slice(0, state.queueIndex + 1);
const upcoming = items.slice(state.queueIndex + 1).filter(r => !r.radioAdded);
const droppedRadioIds = items
.slice(state.queueIndex + 1)
.filter(r => r.radioAdded)
.map(r => r.trackId);
for (const id of droppedRadioIds) deleteRadioSessionSeen(id);
for (const r of beforeAndCurrent) addRadioSessionSeen(r.trackId);
for (const r of upcoming) addRadioSessionSeen(r.trackId);
const dedupedTracks: Track[] = [];
for (const t of tracks) {
if (hasRadioSessionSeen(t.id)) continue;
addRadioSessionSeen(t.id);
dedupedTracks.push(t);
}
seedIncoming(state, dedupedTracks);
if (!isClusterMode()) {
const incoming = toQueueItemRefs(state.queueServerId ?? '', dedupedTracks);
// Insert new radio tracks before any autoAdded tracks in the upcoming section.
const firstAutoIdx = upcoming.findIndex(r => r.autoAdded);
const mergedItems = firstAutoIdx === -1
? [...upcoming, ...incoming]
: [...upcoming.slice(0, firstAutoIdx), ...incoming, ...upcoming.slice(firstAutoIdx)];
const newItems = [...beforeAndCurrent, ...mergedItems];
syncQueueToServer(newItems, state.currentTrack, state.currentTime);
return { queueItems: newItems };
});
set(s => {
const liveItems = itemsOf(s);
const head = liveItems.slice(0, s.queueIndex + 1);
const tail = liveItems.slice(s.queueIndex + 1).filter(r => !r.radioAdded);
const firstAutoIdx = tail.findIndex(r => r.autoAdded);
const mergedItems = firstAutoIdx === -1
? [...tail, ...incoming]
: [...tail.slice(0, firstAutoIdx), ...incoming, ...tail.slice(firstAutoIdx)];
const newItems = [...head, ...mergedItems];
syncQueueToServer(newItems, s.currentTrack, s.currentTime);
return { queueItems: newItems };
});
return;
}
void (async () => {
const incoming = await clusterAwareQueueRefs(dedupedTracks, state.queueServerId ?? '');
set(s => {
const liveItems = itemsOf(s);
const head = liveItems.slice(0, s.queueIndex + 1);
const tail = liveItems.slice(s.queueIndex + 1).filter(r => !r.radioAdded);
const firstAutoIdx = tail.findIndex(r => r.autoAdded);
const mergedItems = firstAutoIdx === -1
? [...tail, ...incoming]
: [...tail.slice(0, firstAutoIdx), ...incoming, ...tail.slice(firstAutoIdx)];
const newItems = [...head, ...mergedItems];
syncQueueToServer(newItems, s.currentTrack, s.currentTime);
return { queueItems: newItems };
});
})();
},
enqueueAt: (tracks, insertIndex, _orbitConfirmed = false) => {
@@ -173,19 +200,37 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
}
pushQueueUndoFromGetter(get);
ensureQueueServerPinned();
set(state => {
seedIncoming(state, tracks);
const items = itemsOf(state);
const idx = Math.max(0, Math.min(insertIndex, items.length));
const state = get();
seedIncoming(state, tracks);
if (!isClusterMode()) {
const incoming = toQueueItemRefs(state.queueServerId ?? '', tracks);
const newItems = [...items.slice(0, idx), ...incoming, ...items.slice(idx)];
const newQueueIndex = idx <= state.queueIndex
? state.queueIndex + tracks.length
: state.queueIndex;
syncQueueToServer(newItems, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(newItems, newQueueIndex);
return { queueItems: newItems, queueIndex: newQueueIndex };
});
set(s => {
const items = itemsOf(s);
const idx = Math.max(0, Math.min(insertIndex, items.length));
const newItems = [...items.slice(0, idx), ...incoming, ...items.slice(idx)];
const newQueueIndex = idx <= s.queueIndex
? s.queueIndex + tracks.length
: s.queueIndex;
syncQueueToServer(newItems, s.currentTrack, s.currentTime);
prefetchLoudnessForEnqueuedTracks(newItems, newQueueIndex);
return { queueItems: newItems, queueIndex: newQueueIndex };
});
return;
}
void (async () => {
const incoming = await clusterAwareQueueRefs(tracks, state.queueServerId ?? '');
set(s => {
const items = itemsOf(s);
const idx = Math.max(0, Math.min(insertIndex, items.length));
const newItems = [...items.slice(0, idx), ...incoming, ...items.slice(idx)];
const newQueueIndex = idx <= s.queueIndex
? s.queueIndex + tracks.length
: s.queueIndex;
syncQueueToServer(newItems, s.currentTrack, s.currentTime);
prefetchLoudnessForEnqueuedTracks(newItems, newQueueIndex);
return { queueItems: newItems, queueIndex: newQueueIndex };
});
})();
},
playNext: (tracks) => {
@@ -83,6 +83,14 @@
white-space: nowrap;
}
.connection-indicator-cluster-banner {
margin-top: 4px;
max-width: 280px;
font-size: 10px;
line-height: 1.25;
color: var(--text-muted);
}
/* Server card actions collapse to icon-only on narrow viewports labels
* remain available as native tooltips via the existing `data-tooltip` /
* `aria-label` attributes. Breakpoint is tuned to where the action row
+42
View File
@@ -35,6 +35,47 @@ export function registerDefaultCoverInvokeHandlers(): void {
onInvoke('cover_cache_ensure', () => ({ hit: false, path: '', tier: 128 }));
}
export function registerDefaultLibraryClusterInvokeHandlers(): void {
onInvoke('library_search_cluster', () => ({ hits: [], fuzzy: [], serversSearched: [] }));
onInvoke('library_cluster_advanced_search', () => ({
artists: [],
albums: [],
tracks: [],
totals: { artists: 0, albums: 0, tracks: 0 },
appliedFilters: [],
source: 'local',
}));
onInvoke('library_cluster_list_tracks', () => ({ tracks: [], total: 0 }));
onInvoke('library_cluster_list_favorites', () => ({ tracks: [], total: 0 }));
onInvoke('library_cluster_list_albums', () => ({ albums: [], hasMore: false }));
onInvoke('library_cluster_list_artists', () => ({ artists: [], hasMore: false }));
onInvoke('library_cluster_list_favorite_albums', () => ({ albums: [], hasMore: false }));
onInvoke('library_cluster_list_favorite_artists', () => ({ artists: [], hasMore: false }));
onInvoke('library_cluster_player_stats_year_summary', () => ({
totalListenedSec: 0,
sessionCount: 0,
trackPlayCount: 0,
uniqueTrackCount: 0,
listeningDayCount: 0,
fullCount: 0,
partialCount: 0,
}));
onInvoke('library_cluster_player_stats_heatmap', () => []);
onInvoke('library_cluster_player_stats_day_detail', () => ({
totals: {
totalListenedSec: 0,
sessionCount: 0,
trackPlayCount: 0,
fullCount: 0,
partialCount: 0,
},
tracks: [],
}));
onInvoke('library_cluster_player_stats_recent_days', () => []);
onInvoke('library_cluster_player_stats_most_played', () => []);
onInvoke('library_cluster_resolve_candidates', () => ({ candidates: [], clusterKey: null }));
}
// Tauri's typed signatures are strict (InvokeArgs / Event<T>). Tests don't
// need that level of precision — cast the mocks to `any` so the helpers
// accept simple `{ payload }` envelopes and plain object args.
@@ -94,6 +135,7 @@ export function resetTauriMocks(): void {
eventListeners.clear();
invokeMock.mockClear();
listenMock.mockClear();
registerDefaultLibraryClusterInvokeHandlers();
}
export { invokeMock, listenMock };
@@ -112,7 +112,7 @@ export async function startInstantMix(
usePlayerStore.getState().reseedQueueForInstantMix(song);
const serverId = useAuthStore.getState().activeServerId;
try {
const similar = await getSimilarSongs(song.id, 50);
const similar = await getSimilarSongs(song.id, 50, song.clusterBrowseServerId);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
const mixCfg = getMixMinRatingsConfigFromAuth();
const ratedFiltered = await filterSongsForLuckyMixRatings(
+60 -5
View File
@@ -36,6 +36,8 @@ import { logLibrarySearch, timed } from './libraryDevLog';
import { isLosslessSuffix } from './losslessFormats';
import { albumIsCompilation } from './albumCompilation';
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment';
import { clusterAdvancedSearchLocal } from './clusterAdvancedSearchLocal';
import { isClusterMode } from '../serverCluster/clusterScope';
export const ADVANCED_SEARCH_YEAR_ALBUM_LIMIT = 100;
@@ -200,6 +202,7 @@ export function trackToSong(t: LibraryTrackDto): SubsonicSong {
bpm: resolvedBpm,
isrc: t.isrc ?? undefined,
albumArtist: t.albumArtist ?? undefined,
clusterBrowseServerId: t.serverId,
};
// `rawJson` is the authoritative original song — let it override the
// hot-column fallbacks (it carries OpenSubsonic extras too).
@@ -320,7 +323,25 @@ export async function runLocalAdvancedSearch(
0,
skipTotals,
);
const { result: resp, ms: invokeMs } = await timed(() => libraryAdvancedSearch(req));
const run = async () => {
if (isClusterMode()) {
return await clusterAdvancedSearchLocal({
query: req.query,
entityTypes: req.entityTypes,
filters: req.filters,
starredOnly: req.starredOnly,
restrictAlbumIds: req.restrictAlbumIds,
sort: req.sort,
limit: req.limit,
offset: req.offset,
skipTotals: req.skipTotals,
queryAlbumTitleOnly: req.queryAlbumTitleOnly,
});
}
return libraryAdvancedSearch(req);
};
const { result: resp, ms: invokeMs } = await timed(run);
if (!resp) return null;
if (resp.source !== 'local') return null;
const page = {
artists: resp.artists.map(artistToArtist),
@@ -380,7 +401,7 @@ export async function runLocalSongBrowse(
if (!serverId) return null;
if (!(await libraryIsReady(serverId))) return null;
try {
const resp = await libraryAdvancedSearch({
const req: LibraryAdvancedSearchRequest = {
serverId,
libraryScope: libraryScopeForServer(serverId),
query: undefined,
@@ -388,7 +409,22 @@ export async function runLocalSongBrowse(
limit: pageSize,
offset,
skipTotals: true,
});
};
const resp = isClusterMode()
? await clusterAdvancedSearchLocal({
entityTypes: req.entityTypes,
query: req.query,
filters: req.filters,
starredOnly: req.starredOnly,
restrictAlbumIds: req.restrictAlbumIds,
sort: req.sort,
limit: req.limit,
offset: req.offset,
skipTotals: req.skipTotals,
queryAlbumTitleOnly: req.queryAlbumTitleOnly,
})
: await libraryAdvancedSearch(req);
if (!resp) return [];
if (resp.source !== 'local') return null;
return resp.tracks.map(trackToSong);
} catch {
@@ -416,7 +452,21 @@ export async function loadMoreLocalSongs(
if (clusterPage) return clusterPage;
}
const req = buildRequest(serverId, opts, ['track'], pageSize, offset, true);
const resp = await libraryAdvancedSearch(req);
const resp = isClusterMode()
? await clusterAdvancedSearchLocal({
query: req.query,
entityTypes: req.entityTypes,
filters: req.filters,
starredOnly: req.starredOnly,
restrictAlbumIds: req.restrictAlbumIds,
sort: req.sort,
limit: req.limit,
offset: req.offset,
skipTotals: req.skipTotals,
queryAlbumTitleOnly: req.queryAlbumTitleOnly,
})
: await libraryAdvancedSearch(req);
if (!resp) return [];
return resp.tracks.map(trackToSong);
}
@@ -436,7 +486,12 @@ export async function tryRunLocalAdvancedSearch(
suppressLog,
);
if (readyPage) return readyPage;
return runLocalAdvancedSearch(serverId, opts, songsLimit, true, true, suppressLog);
const retryPage = await runLocalAdvancedSearch(serverId, opts, songsLimit, true, true, suppressLog);
if (retryPage) return retryPage;
if (isClusterMode()) {
return { artists: [], albums: [], songs: [], songsTotal: 0 };
}
return null;
}
function yearOnlyAlbumBrowseQuery(opts: LocalSearchOpts): AlbumBrowseQuery | null {
+24 -1
View File
@@ -3,12 +3,14 @@ import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { libraryScopeForServer } from '../../api/subsonicClient';
import { dedupeById } from '../dedupeById';
import { albumToAlbum } from './advancedSearchLocal';
import { sharedServerFilters } from './albumBrowseFilters';
import { albumBrowseHasServerFilters, sharedServerFilters } from './albumBrowseFilters';
import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
import { libraryIsReady } from './libraryReady';
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
import { canUseClusterAlbumBrowse, clusterBrowseAlbumsPage } from '../serverCluster/clusterBrowse';
import { clusterAdvancedSearchLocal } from './clusterAdvancedSearchLocal';
import { isClusterMode } from '../serverCluster/clusterScope';
function markServerStarredAlbums(albums: SubsonicAlbum[]) {
return albums.map(a => ({ ...a, starred: a.starred ?? 'true' }));
@@ -22,10 +24,31 @@ export async function runLocalAlbumBrowse(
pageSize: number,
restrictAlbumIds?: string[],
): Promise<AlbumBrowsePageResult | null> {
const clusterAndFiltered = isClusterMode() && albumBrowseHasServerFilters(query);
if (canUseClusterAlbumBrowse(query, restrictAlbumIds)) {
const clusterPage = await clusterBrowseAlbumsPage(offset, pageSize);
if (clusterPage) return clusterPage;
}
if (clusterAndFiltered) {
const shared = sharedServerFilters(query, restrictAlbumIds != null);
const resp = await clusterAdvancedSearchLocal({
query: undefined,
entityTypes: ['album'],
filters: shared,
starredOnly: restrictAlbumIds != null ? undefined : (query.starredOnly || undefined),
restrictAlbumIds: restrictAlbumIds ?? undefined,
sort: albumSortClauses(query.sort),
limit: pageSize,
offset,
skipTotals: true,
});
if (!resp) return { albums: [], hasMore: false };
return {
albums: resp.albums.map(albumToAlbum),
hasMore: resp.albums.length === pageSize,
};
}
if (isClusterMode()) return { albums: [], hasMore: false };
if (!serverId || !(await libraryIsReady(serverId))) return null;
const scope = libraryScopeForServer(serverId) ?? undefined;
+3
View File
@@ -30,6 +30,7 @@ import {
} from './libraryDevLog';
import { libraryIsReady } from './libraryReady';
import { clusterBrowseArtistsPage, clusterBrowseTextSearchPage, clusterBrowseTracksPage } from '../serverCluster/clusterBrowse';
import { isClusterMode } from '../serverCluster/clusterScope';
import { raceSearchSources, type SearchRaceWinner } from './searchRace';
export type { LibrarySearchSurface };
@@ -304,6 +305,7 @@ export async function runLocalBrowseSongPage(
if (q) {
const clusterPage = await clusterBrowseTextSearchPage(q, offset, pageSize);
if (clusterPage) return clusterPage;
if (isClusterMode()) return [];
}
if (!serverId || !(await libraryIsReady(serverId))) return null;
if (!q) return null;
@@ -407,6 +409,7 @@ export async function runLocalRandomSongs(
const clusterOffset = Math.floor(Math.random() * 500);
const clusterPage = await clusterBrowseTracksPage(clusterOffset, limit);
if (clusterPage?.length) return clusterPage;
if (isClusterMode()) return [];
if (!serverId || !(await libraryIsReady(serverId))) return null;
try {
const resp = await libraryAdvancedSearch({
@@ -0,0 +1,23 @@
import {
libraryClusterAdvancedSearch,
type LibraryAdvancedSearchResponse,
type LibraryClusterAdvancedSearchRequest,
} from '../../api/library';
import { resolveClusterBrowseMembers } from '../serverCluster/clusterBrowse';
import { isClusterMode } from '../serverCluster/clusterScope';
export async function clusterAdvancedSearchLocal(
request: Omit<LibraryClusterAdvancedSearchRequest, 'serversOrdered'>,
): Promise<LibraryAdvancedSearchResponse | null> {
if (!isClusterMode()) return null;
const members = await resolveClusterBrowseMembers();
if (!members?.length) return null;
try {
return await libraryClusterAdvancedSearch({
...request,
serversOrdered: members,
});
} catch {
return null;
}
}
+2
View File
@@ -5,6 +5,7 @@ import { albumToAlbum } from './advancedSearchLocal';
import { albumSortClauses, sortSubsonicAlbums, type AlbumBrowseSort } from './albumBrowseSort';
import type { AlbumBrowsePageResult } from './albumBrowseTypes';
import { libraryIsReady } from './libraryReady';
import { isClusterMode } from '../serverCluster/clusterScope';
/** First paint — one visible slice only. */
export const GENRE_ALBUM_FIRST_PAGE = 60;
@@ -72,6 +73,7 @@ export async function fetchGenreAlbumPage(
if (indexEnabled) {
const local = await fetchLocalGenreAlbumPage(serverId, genre, offset, pageSize, sort);
if (local != null) return local;
if (isClusterMode()) return { albums: [], hasMore: false };
}
return fetchNetworkGenreAlbumPage(genre, offset, pageSize, sort);
+2 -1
View File
@@ -292,7 +292,8 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
for (let i = 0; i < seeds.length; i++) {
bailIfCancelled();
const seed = seeds[i];
const oneRaw = await getSimilarSongs(seed.id, 60).catch(() => [] as SubsonicSong[]);
const oneRaw = await getSimilarSongs(seed.id, 60, seed.clusterBrowseServerId)
.catch(() => [] as SubsonicSong[]);
const oneScoped = await filterSongsToActiveLibrary(oneRaw);
similarRaw = uniqueAppend(similarRaw, oneRaw);
similar = uniqueAppend(similar, oneScoped);
+4
View File
@@ -15,6 +15,7 @@ import {
} from '../../api/orbit';
import { generateSessionId } from './helpers';
import { writeOrbitHeartbeat, writeOrbitState } from './remote';
import { isClusterMode } from '../serverCluster/clusterScope';
export interface StartOrbitArgs {
/** Human-readable name the host chose. */
@@ -39,6 +40,9 @@ export interface StartOrbitArgs {
* On throw the store is left in the pre-call state nothing partially bound.
*/
export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitState> {
if (isClusterMode()) {
throw new Error('Orbit host is disabled in cluster mode');
}
const server = useAuthStore.getState().getActiveServer();
const username = server?.username;
if (!username) throw new Error('No active Navidrome server / user');
+25 -33
View File
@@ -2,6 +2,8 @@
* Cluster-mode browse helpers merged index reads when `activeClusterId` is set.
*/
import {
libraryClusterListFavoriteAlbums,
libraryClusterListFavoriteArtists,
libraryClusterListAlbums,
libraryClusterListArtists,
libraryClusterListFavorites,
@@ -119,9 +121,21 @@ export async function clusterBrowseTextSearchPage(
offset: number,
pageSize: number,
): Promise<SubsonicSong[] | null> {
const all = await clusterBrowseTextSearch(query, offset + pageSize);
if (!all) return null;
return all.slice(offset, offset + pageSize);
const members = await resolveClusterBrowseMembers();
if (!members) return null;
const q = query.trim();
if (!q) return null;
try {
const resp = await librarySearchCluster({
query: q,
limit: pageSize,
offset,
serversOrdered: members,
});
return resp.hits.map(trackToSong);
} catch {
return null;
}
}
/** Merged favorites from the local index (starred on any member). */
@@ -133,36 +147,14 @@ export async function clusterLoadFavorites(): Promise<{
const members = await resolveClusterBrowseMembers();
if (!members) return null;
try {
const env = await libraryClusterListFavorites({ serversOrdered: members, limit: 2000, offset: 0 });
const songs = env.tracks.map(trackToSong);
const albums = dedupeById(
songs
.filter(s => s.albumId)
.map(
s =>
({
id: s.albumId!,
name: s.album,
artist: s.artist,
artistId: s.artistId,
coverArt: s.coverArt,
starred: 'true',
}) as SubsonicAlbum,
),
);
const artists = dedupeById(
songs
.filter(s => s.artistId || s.artist)
.map(
s =>
({
id: s.artistId || s.artist,
name: s.artist,
coverArt: s.artistId,
starred: 'true',
}) as SubsonicArtist,
),
);
const [tracksEnv, albumsResp, artistsResp] = await Promise.all([
libraryClusterListFavorites({ serversOrdered: members, limit: 2000, offset: 0 }),
libraryClusterListFavoriteAlbums({ serversOrdered: members, limit: 1000, offset: 0 }),
libraryClusterListFavoriteArtists({ serversOrdered: members, limit: 1000, offset: 0 }),
]);
const songs = tracksEnv.tracks.map(trackToSong);
const albums = dedupeById(albumsResp.albums.map(albumToAlbum).map(a => ({ ...a, starred: a.starred ?? 'true' })));
const artists = dedupeById(artistsResp.artists.map(artistToArtist).map(a => ({ ...a, starred: a.starred ?? 'true' })));
return { songs, albums, artists };
} catch {
return null;
@@ -0,0 +1,68 @@
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
type Ranked<T> = {
item: T;
serverId: string;
priorityRank: number;
clusterKey?: string | null;
};
function mergeByPriority<T extends { id?: string | null }>(
rows: Ranked<T>[],
keyOf: (row: Ranked<T>) => string | null,
): T[] {
const best = new Map<string, Ranked<T>>();
for (const row of rows) {
const key = keyOf(row);
if (!key) continue;
const prev = best.get(key);
if (!prev || row.priorityRank < prev.priorityRank) best.set(key, row);
}
return [...best.values()]
.sort((a, b) => a.priorityRank - b.priorityRank)
.map(r => r.item);
}
function safeLower(v: string | undefined | null): string {
return (v ?? '').trim().toLowerCase();
}
export function mergeClusterTracks(rows: Ranked<SubsonicSong>[]): SubsonicSong[] {
return mergeByPriority(rows, row => {
const keyed = row.clusterKey?.trim();
if (keyed) return `cluster:${keyed}`;
if (row.item.id) return `id:${row.item.id}`;
return null;
});
}
export function mergeClusterAlbums(rows: Ranked<SubsonicAlbum>[]): SubsonicAlbum[] {
return mergeByPriority(rows, row => {
const keyed = row.clusterKey?.trim();
if (keyed) return `album:${keyed}`;
const a = row.item;
if (a.id) return `id:${a.id}`;
const title = safeLower(a.name);
const artist = safeLower(a.artist);
return title ? `sig:${artist}:${title}` : null;
});
}
export function mergeClusterArtists(rows: Ranked<SubsonicArtist>[]): SubsonicArtist[] {
return mergeByPriority(rows, row => {
const keyed = row.clusterKey?.trim();
if (keyed) return `artist:${keyed}`;
if (row.item.id) return `id:${row.item.id}`;
const name = safeLower(row.item.name);
return name ? `sig:${name}` : null;
});
}
export function resolveClusterSeedIds(
seedByServer: Record<string, string | undefined>,
members: string[],
): Array<{ serverId: string; seedId: string }> {
return members
.map(serverId => ({ serverId, seedId: seedByServer[serverId] ?? '' }))
.filter(row => row.seedId.length > 0);
}
@@ -0,0 +1,43 @@
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { useAuthStore } from '../../store/authStore';
import { resolveServerIdForIndexKey } from '../server/serverLookup';
import { toQueueItemRefs, toQueueItemRefsMulti } from '../library/queueItemRef';
import { isClusterMode } from './clusterScope';
import { resolveClusterPlaybackForTrack } from './clusterPlaybackResolve';
/** Resolve merged tracks to concrete server refs for enqueue (spec §6). */
export async function resolveTracksForClusterEnqueue(
tracks: Track[],
browseServerId: string,
): Promise<Array<{ serverId: string; track: Track }>> {
if (!isClusterMode() || tracks.length === 0) {
const sid = browseServerId || useAuthStore.getState().activeServerId || '';
return tracks.map(track => ({ serverId: sid, track }));
}
const fallback = browseServerId || useAuthStore.getState().activeServerId || '';
const out: Array<{ serverId: string; track: Track }> = [];
for (const track of tracks) {
const seedServer = track.clusterBrowseServerId ?? fallback;
const resolved = await resolveClusterPlaybackForTrack(seedServer, track.id);
if (resolved) {
out.push({
serverId: resolved.serverId,
track: { ...track, id: resolved.trackId, clusterBrowseServerId: resolved.serverId },
});
} else {
out.push({ serverId: seedServer, track });
}
}
return out;
}
export async function clusterAwareQueueRefs(
tracks: Track[],
browseServerId: string,
): Promise<QueueItemRef[]> {
if (!isClusterMode()) {
return toQueueItemRefs(browseServerId, tracks);
}
const resolved = await resolveTracksForClusterEnqueue(tracks, browseServerId);
return toQueueItemRefsMulti(resolved);
}
@@ -0,0 +1,55 @@
import { libraryIsReady } from '../library/libraryReady';
import { serverListDisplayLabel } from '../server/serverDisplayName';
import { getClusterMemberProfiles } from './clusterScope';
import { isServerLikelyReachable } from './representative';
import type { ServerCluster } from './types';
export type ClusterMemberExcludeReason = 'unreachable' | 'indexing';
export interface ClusterMemberStatus {
serverId: string;
label: string;
included: boolean;
reason?: ClusterMemberExcludeReason;
}
export interface ClusterMergeDiagnostics {
members: ClusterMemberStatus[];
mergeCount: number;
totalCount: number;
}
/** Per-member merge eligibility (spec §4 exclusion rules). */
export async function getClusterMergeDiagnostics(
cluster: ServerCluster,
): Promise<ClusterMergeDiagnostics> {
const profiles = getClusterMemberProfiles(cluster);
const members: ClusterMemberStatus[] = [];
let mergeCount = 0;
for (const p of profiles) {
const label = serverListDisplayLabel(p, profiles);
if (!isServerLikelyReachable(p.id)) {
members.push({ serverId: p.id, label, included: false, reason: 'unreachable' });
continue;
}
if (!(await libraryIsReady(p.id))) {
members.push({ serverId: p.id, label, included: false, reason: 'indexing' });
continue;
}
members.push({ serverId: p.id, label, included: true });
mergeCount += 1;
}
return { members, mergeCount, totalCount: profiles.length };
}
export function formatExcludedMemberLabels(
members: ClusterMemberStatus[],
): string {
return members
.filter(m => !m.included)
.map(m => {
const suffix = m.reason === 'indexing' ? ' (indexing)' : ' (offline)';
return `${m.label}${suffix}`;
})
.join(', ');
}
@@ -0,0 +1,54 @@
/**
* Mid-session cascade when the streaming member becomes unavailable (spec §6).
*/
import { useEffect, useRef } from 'react';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
import { resolveServerIdForIndexKey } from '../server/serverLookup';
import { isServerLikelyReachable } from './representative';
import { cascadeClusterPlayback } from './clusterPlaybackResolve';
import { isClusterMode } from './clusterScope';
export function useClusterPlaybackMonitor(): void {
const activeClusterId = useAuthStore(s => s.activeClusterId);
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const queueIndex = usePlayerStore(s => s.queueIndex);
const queueItems = usePlayerStore(s => s.queueItems);
const lastCheck = useRef(0);
useEffect(() => {
if (!isClusterMode() || !activeClusterId || !isPlaying || !currentTrack) return;
const tick = () => {
const now = Date.now();
if (now - lastCheck.current < 3000) return;
lastCheck.current = now;
const st = usePlayerStore.getState();
const ref = st.queueItems[st.queueIndex];
if (!ref) return;
const streamSid = resolveServerIdForIndexKey(ref.serverId);
if (isServerLikelyReachable(streamSid)) return;
const browseId = useAuthStore.getState().activeServerId ?? streamSid;
void cascadeClusterPlayback(browseId, ref.trackId, streamSid).then(next => {
if (!next) {
usePlayerStore.getState().next(false);
return;
}
const track = st.currentTrack;
if (!track) return;
usePlayerStore.getState().playTrack(
{ ...track, id: next.trackId, clusterBrowseServerId: next.serverId },
undefined,
false,
true,
st.queueIndex,
);
});
};
const id = window.setInterval(tick, 4000);
return () => window.clearInterval(id);
}, [activeClusterId, isPlaying, currentTrack?.id, queueIndex, queueItems.length]);
}
@@ -1,10 +1,18 @@
import {
libraryClusterPlayerStatsDayDetail,
libraryClusterPlayerStatsHeatmap,
libraryClusterPlayerStatsMostPlayed,
libraryClusterPlayerStatsRecentDays,
libraryClusterPlayerStatsYearSummary,
libraryGetPlayerStatsDayDetail,
libraryGetPlayerStatsHeatmap,
libraryGetPlayerStatsRecentDays,
libraryGetPlayerStatsYearBounds,
libraryGetPlayerStatsYearSummary,
type PlaySessionDayDetail,
type PlaySessionHeatmapDay,
type PlaySessionMostPlayed,
type PlaySessionRecentDay,
type PlaySessionYearSummary,
} from '../../api/library';
import { resolveClusterBrowseMembers } from './clusterBrowse';
@@ -28,3 +36,27 @@ export async function loadPlayerStatsHeatmap(year: number): Promise<PlaySessionH
export async function loadPlayerStatsYearBounds() {
return libraryGetPlayerStatsYearBounds();
}
export async function loadPlayerStatsDayDetail(dateIso: string): Promise<PlaySessionDayDetail> {
const members = await resolveClusterBrowseMembers();
if (members) {
return libraryClusterPlayerStatsDayDetail({ serversOrdered: members, dateIso });
}
return libraryGetPlayerStatsDayDetail(dateIso);
}
export async function loadPlayerStatsRecentDays(limit = 30): Promise<PlaySessionRecentDay[]> {
const members = await resolveClusterBrowseMembers();
if (members) {
return libraryClusterPlayerStatsRecentDays({ serversOrdered: members, limit });
}
return libraryGetPlayerStatsRecentDays(limit);
}
export async function loadPlayerStatsMostPlayed(limit = 50): Promise<PlaySessionMostPlayed[]> {
const members = await resolveClusterBrowseMembers();
if (members) {
return libraryClusterPlayerStatsMostPlayed({ serversOrdered: members, limit });
}
return [];
}
+58 -20
View File
@@ -1,8 +1,15 @@
/**
* Cluster write fan-out with resolved-server targeting and failure toasts (spec §7).
*/
import { apiForServer } from '../../api/subsonicClient';
import { libraryClusterResolveCandidates } from '../../api/library';
import { patchLibraryTrackOnUse } from '../library/patchOnUse';
import { serverListDisplayLabel } from '../server/serverDisplayName';
import { showToast } from '../ui/toast';
import { resolveClusterBrowseMembers } from './clusterBrowse';
import { getActiveCluster, isClusterMode } from './clusterScope';
import { useAuthStore } from '../../store/authStore';
import i18n from '../../i18n';
async function allCandidateTrackIds(
browseServerId: string,
@@ -22,23 +29,59 @@ async function allCandidateTrackIds(
}
}
function toastFanOutFailures(
action: 'star' | 'rating' | 'scrobble',
failures: Array<{ serverId: string; reason: unknown }>,
): void {
if (failures.length === 0) return;
const servers = useAuthStore.getState().servers;
const names = failures
.map(f => {
const server = servers.find(s => s.id === f.serverId);
return server ? serverListDisplayLabel(server, servers) : f.serverId;
})
.filter(Boolean)
.join(', ');
const key =
action === 'star' ? 'cluster.fanOutStarFailed'
: action === 'rating' ? 'cluster.fanOutRatingFailed'
: 'cluster.fanOutScrobbleFailed';
showToast(i18n.t(key, { servers: names || failures.length }), 5000, 'error');
}
async function fanOutWithToast(
action: 'star' | 'rating' | 'scrobble',
targets: Array<{ serverId: string; trackId: string }>,
run: (serverId: string, trackId: string) => Promise<void>,
): Promise<void> {
const results = await Promise.allSettled(
targets.map(({ serverId, trackId }) => run(serverId, trackId)),
);
const failures: Array<{ serverId: string; reason: unknown }> = [];
results.forEach((r, i) => {
if (r.status === 'rejected') failures.push({ serverId: targets[i]!.serverId, reason: r.reason });
});
toastFanOutFailures(action, failures);
}
/** Fan-out scrobble submission=true per cluster settings (spec §7.2). */
export async function clusterFanOutScrobbleSubmission(
browseServerId: string,
trackId: string,
time: number,
resolvedServerId?: string,
): 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, trackId: tid }) => {
await apiForServer(serverId, 'scrobble.view', { id: tid, submission: true, time });
patchLibraryTrackOnUse(serverId, tid, { playedAt: time });
}),
);
const playing = resolvedServerId ?? browseServerId;
const match = targets.find(t => t.serverId === playing);
const toWrite = syncAll ? targets : (match ? [match] : targets.slice(0, 1));
await fanOutWithToast('scrobble', toWrite, 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). */
@@ -49,13 +92,10 @@ export async function clusterFanOutStar(
): Promise<void> {
if (!isClusterMode()) return;
const targets = await allCandidateTrackIds(browseServerId, trackId);
await Promise.allSettled(
targets.map(async ({ serverId, trackId: tid }) => {
const params = { id: tid };
await apiForServer(serverId, star ? 'star.view' : 'unstar.view', params);
patchLibraryTrackOnUse(serverId, tid, { starredAt: star ? Date.now() : null });
}),
);
await fanOutWithToast('star', targets, async (serverId, tid) => {
await apiForServer(serverId, star ? 'star.view' : 'unstar.view', { id: tid });
patchLibraryTrackOnUse(serverId, tid, { starredAt: star ? Date.now() : null });
});
}
/** Fan-out rating to all cluster members holding the track. */
@@ -66,10 +106,8 @@ export async function clusterFanOutRating(
): Promise<void> {
if (!isClusterMode()) return;
const targets = await allCandidateTrackIds(browseServerId, trackId);
await Promise.allSettled(
targets.map(async ({ serverId, trackId: tid }) => {
await apiForServer(serverId, 'setRating.view', { id: tid, rating });
patchLibraryTrackOnUse(serverId, tid, { userRating: rating });
}),
);
await fanOutWithToast('rating', targets, async (serverId, tid) => {
await apiForServer(serverId, 'setRating.view', { id: tid, rating });
patchLibraryTrackOnUse(serverId, tid, { userRating: rating });
});
}