mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
@@ -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
@@ -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() });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { useClusterPlaybackMonitor } from '../utils/serverCluster/clusterPlaybackMonitor';
|
||||
@@ -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.',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -29,6 +29,9 @@ export const queue = {
|
||||
nextTracks: 'Pistes suivantes',
|
||||
shareQueue: 'Copier le lien de la file d’attente',
|
||||
shareQueueEmpty: 'La file d’attente 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',
|
||||
|
||||
@@ -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å.',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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ă',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const cluster = {
|
||||
fanOutStarFailed: 'Не удалось синхронизировать избранное на: {{servers}}.',
|
||||
fanOutRatingFailed: 'Не удалось синхронизировать рейтинг на: {{servers}}.',
|
||||
fanOutScrobbleFailed: 'Не удалось синхронизировать скроббл на: {{servers}}.',
|
||||
mergeBanner: 'Из объединения кластера исключены: {{excluded}}.',
|
||||
memberIncluded: 'Включён',
|
||||
memberExcludedOffline: 'Исключён (офлайн)',
|
||||
memberExcludedIndexing: 'Исключён (индексация)',
|
||||
browseUnsupported: 'Просмотр кластера сейчас недоступен.',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -29,6 +29,9 @@ export const queue = {
|
||||
nextTracks: 'Дальше',
|
||||
shareQueue: 'Копировать ссылку на очередь',
|
||||
shareQueueEmpty: 'Очередь пуста — нечем поделиться.',
|
||||
shareClusterMixedWarning: 'Смешанная очередь кластера: экспортировать {{exported}} из {{total}} треков с основного сервера?',
|
||||
shareClusterExportedCount: 'Экспортировано {{exported}} из {{total}} треков с основного сервера.',
|
||||
shareClusterNothingOnPrimary: 'Экспорт невозможен: в очереди нет треков с основного сервера.',
|
||||
emptyQueue: 'Очередь пуста.',
|
||||
crossServerEnqueueBlocked: 'Треки с другого сервера нельзя добавить в текущую очередь. Завершите или очистите очередь.',
|
||||
trackSingular: 'трек',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const cluster = {
|
||||
fanOutStarFailed: '以下服务器收藏同步失败:{{servers}}。',
|
||||
fanOutRatingFailed: '以下服务器评分同步失败:{{servers}}。',
|
||||
fanOutScrobbleFailed: '以下服务器 scrobble 同步失败:{{servers}}。',
|
||||
mergeBanner: '集群合并已排除:{{excluded}}。',
|
||||
memberIncluded: '已纳入',
|
||||
memberExcludedOffline: '已排除(离线)',
|
||||
memberExcludedIndexing: '已排除(索引中)',
|
||||
browseUnsupported: '集群浏览当前不可用。',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -29,6 +29,9 @@ export const queue = {
|
||||
nextTracks: '即将播放',
|
||||
shareQueue: '复制队列分享链接',
|
||||
shareQueueEmpty: '队列为空,无法分享。',
|
||||
shareClusterMixedWarning: '队列包含混合集群曲目:是否仅导出主服务器上的 {{exported}} / {{total}} 首曲目?',
|
||||
shareClusterExportedCount: '已从主服务器导出 {{exported}} / {{total}} 首曲目。',
|
||||
shareClusterNothingOnPrimary: '无可导出内容:队列中没有主服务器曲目。',
|
||||
emptyQueue: '队列为空。',
|
||||
crossServerEnqueueBlocked: '无法将其他服务器的曲目加入当前播放队列。请先清空或结束当前队列。',
|
||||
trackSingular: '首曲目',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
|
||||
@@ -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
@@ -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>{`
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user