fix(playback): cross-server browse, Lucky Mix, and Now Playing (#768)

* fix(playback): keep browsed server when queue plays elsewhere

Lucky Mix on a non-playback server now clears the old queue and pins
the active server before building. Now Playing metadata uses
apiForServer against the queue server instead of forcing
ensurePlaybackServerActive on every activeServerId change.

* docs: note PR #768 in CHANGELOG and settings credits

* fix(now-playing): gate AudioMuse similar artists on playback server

Match getArtistInfoForServer credentials: when browsing another server
while the queue plays elsewhere, similar-artist count follows the queue
server's AudioMuse flag, not the browsed server.
This commit is contained in:
cucadmuh
2026-05-18 11:40:46 +03:00
committed by GitHub
parent 6e0f076f43
commit db98d30a78
16 changed files with 253 additions and 91 deletions
+39 -5
View File
@@ -1,5 +1,6 @@
import { useAuthStore } from '../store/authStore';
import { api, libraryFilterParams } from './subsonicClient';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import { filterSongsToServerLibrary } from './subsonicLibrary';
import { filterSongsToActiveLibrary, similarSongsRequestCount } from './subsonicLibrary';
import type {
SubsonicAlbum,
@@ -29,20 +30,53 @@ export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; a
return { artist, albums: album ?? [] };
}
export async function getArtistForServer(
serverId: string,
id: string,
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
const data = await apiForServer<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(serverId, 'getArtist.view', { id });
const { album, ...artist } = data.artist;
return { artist, albums: album ?? [] };
}
export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise<SubsonicArtistInfo> {
const count = options?.similarArtistCount ?? 5;
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() });
return data.artistInfo2 ?? {};
}
export async function getArtistInfoForServer(
serverId: string,
id: string,
options?: { similarArtistCount?: number },
): Promise<SubsonicArtistInfo> {
const count = options?.similarArtistCount ?? 5;
const data = await apiForServer<{ artistInfo2: SubsonicArtistInfo }>(
serverId,
'getArtistInfo2.view',
{ id, count, ...libraryFilterParamsForServer(serverId) },
);
return data.artistInfo2 ?? {};
}
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return [];
return getTopSongsForServer(activeServerId, artist);
}
export async function getTopSongsForServer(serverId: string, artist: string): Promise<SubsonicSong[]> {
try {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
const scoped = activeServerId && musicLibraryFilterByServer[activeServerId] && musicLibraryFilterByServer[activeServerId] !== 'all';
const { musicLibraryFilterByServer } = useAuthStore.getState();
const scoped = musicLibraryFilterByServer[serverId] && musicLibraryFilterByServer[serverId] !== 'all';
const topCount = scoped ? 20 : 5;
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: topCount, ...libraryFilterParams() });
const data = await apiForServer<{ topSongs: { song: SubsonicSong[] } }>(
serverId,
'getTopSongs.view',
{ artist, count: topCount, ...libraryFilterParamsForServer(serverId) },
);
const raw = data.topSongs?.song ?? [];
const filtered = await filterSongsToActiveLibrary(raw);
const filtered = await filterSongsToServerLibrary(raw, serverId);
return filtered.slice(0, 5);
} catch {
return [];
+7 -3
View File
@@ -83,9 +83,13 @@ export async function api<T>(endpoint: string, extra: Record<string, unknown> =
/** Optional `musicFolderId` when the user narrowed browsing to one Subsonic library (see `getMusicFolders`). */
export function libraryFilterParams(): Record<string, string | number> {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return {};
const f = musicLibraryFilterByServer[activeServerId];
const { activeServerId } = useAuthStore.getState();
return activeServerId ? libraryFilterParamsForServer(activeServerId) : {};
}
/** Library folder filter for an explicit saved server (e.g. Now Playing while browsing another). */
export function libraryFilterParamsForServer(serverId: string): Record<string, string | number> {
const f = useAuthStore.getState().musicLibraryFilterByServer[serverId];
if (f === undefined || f === 'all') return {};
return { musicFolderId: f };
}
+60 -10
View File
@@ -1,5 +1,5 @@
import { useAuthStore } from '../store/authStore';
import { api, libraryFilterParams } from './subsonicClient';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import type {
RandomSongsFilters,
SubsonicAlbum,
@@ -94,10 +94,10 @@ let scopedLibraryAlbumIdCache: {
ids: Set<string>;
} | null = null;
async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
const { activeServerId, musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
if (!activeServerId) return null;
const folder = musicLibraryFilterByServer[activeServerId];
async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | null> {
const { musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
if (!serverId) return null;
const folder = musicLibraryFilterByServer[serverId];
if (folder === undefined || folder === 'all') {
scopedLibraryAlbumIdCache = null;
return null;
@@ -105,7 +105,7 @@ async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
const hit = scopedLibraryAlbumIdCache;
if (
hit &&
hit.serverId === activeServerId &&
hit.serverId === serverId &&
hit.folderId === folder &&
hit.filterVersion === musicLibraryFilterVersion
) {
@@ -115,14 +115,14 @@ async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
const pageSize = 500;
let offset = 0;
for (;;) {
const albums = await getAlbumList('alphabeticalByName', pageSize, offset);
const albums = await getAlbumListForServer(serverId, 'alphabeticalByName', pageSize, offset);
for (const a of albums) ids.add(a.id);
if (albums.length < pageSize) break;
offset += pageSize;
if (offset > 500_000) break;
}
scopedLibraryAlbumIdCache = {
serverId: activeServerId,
serverId,
folderId: folder,
filterVersion: musicLibraryFilterVersion,
ids,
@@ -130,12 +130,26 @@ async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
return ids;
}
export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise<SubsonicSong[]> {
const allowed = await albumIdsInActiveLibraryScope();
async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
const { activeServerId } = useAuthStore.getState();
return activeServerId ? albumIdsInLibraryScope(activeServerId) : null;
}
export async function filterSongsToServerLibrary(
songs: SubsonicSong[],
serverId: string,
): Promise<SubsonicSong[]> {
const allowed = await albumIdsInLibraryScope(serverId);
if (!allowed || allowed.size === 0) return songs;
return songs.filter(s => s.albumId && allowed.has(s.albumId));
}
export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise<SubsonicSong[]> {
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return songs;
return filterSongsToServerLibrary(songs, activeServerId);
}
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
export function similarSongsRequestCount(desired: number): number {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
@@ -168,6 +182,24 @@ export async function getRandomSongsFiltered(
return data.randomSongs?.song ?? [];
}
export async function getAlbumListForServer(
serverId: string,
type: 'random' | 'newest' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'byYear' | 'recent' | 'starred' | 'frequent' | 'highest',
size = 30,
offset = 0,
extra: Record<string, unknown> = {},
): Promise<SubsonicAlbum[]> {
const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', {
type,
size,
offset,
_t: Date.now(),
...libraryFilterParamsForServer(serverId),
...extra,
});
return data.albumList2?.album ?? [];
}
export async function getSong(id: string): Promise<SubsonicSong | null> {
try {
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
@@ -177,8 +209,26 @@ export async function getSong(id: string): Promise<SubsonicSong | null> {
}
}
export async function getSongForServer(serverId: string, id: string): Promise<SubsonicSong | null> {
try {
const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id });
return data.song ?? null;
} catch {
return null;
}
}
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
}
export async function getAlbumForServer(
serverId: string,
id: string,
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
const data = await apiForServer<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(serverId, 'getAlbum.view', { id });
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
}