feat(navidrome): Instant Mix probe, ping identity, and AudioMuse UX

Parse type and serverVersion from Subsonic ping; persist per-server identity and
run a background Instant Mix probe (random songs + getSimilarSongs) for
Navidrome 0.60+. Hide the AudioMuse server toggle when the probe returns no
similar tracks; clear prefs when the server is no longer eligible.

Artist pages fall back to Last.fm similar artists when AudioMuse is enabled but
the server returns none. Instant Mix failures set a per-server issue flag with a
settings warning and toast. Settings description links the official plugin repo
via i18n Trans.
This commit is contained in:
Maxim Isaev
2026-04-10 13:02:46 +03:00
parent 36f3d42dbe
commit 4de577eede
15 changed files with 463 additions and 68 deletions
+110 -3
View File
@@ -3,6 +3,11 @@ import md5 from 'md5';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { version } from '../../package.json';
import {
isNavidromeAudiomuseSoftwareEligible,
type InstantMixProbeResult,
type SubsonicServerIdentity,
} from '../utils/subsonicServerIdentity';
// ─── Secure random salt ────────────────────────────────────────
function secureRandomSalt(): string {
@@ -265,8 +270,14 @@ export async function ping(): Promise<boolean> {
}
}
export type PingWithCredentialsResult = SubsonicServerIdentity & { ok: boolean };
/** Test a connection with explicit credentials — does NOT depend on store state. */
export async function pingWithCredentials(serverUrl: string, username: string, password: string): Promise<boolean> {
export async function pingWithCredentials(
serverUrl: string,
username: string,
password: string,
): Promise<PingWithCredentialsResult> {
try {
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
const salt = secureRandomSalt();
@@ -277,12 +288,108 @@ export async function pingWithCredentials(serverUrl: string, username: string, p
timeout: 15000,
});
const data = resp.data?.['subsonic-response'];
return data?.status === 'ok';
const ok = data?.status === 'ok';
return {
ok,
type: typeof data?.type === 'string' ? data.type : undefined,
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
openSubsonic: data?.openSubsonic === true,
};
} catch {
return false;
return { ok: false };
}
}
function restBaseFromUrl(serverUrl: string): string {
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
return `${base}/rest`;
}
async function apiWithCredentials<T>(
serverUrl: string,
username: string,
password: string,
endpoint: string,
extra: Record<string, unknown> = {},
timeout = 15000,
): Promise<T> {
const params = { ...getAuthParams(username, password), ...extra };
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
params,
paramsSerializer: { indexes: null },
timeout,
});
const data = resp.data?.['subsonic-response'];
if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)');
if (data.status !== 'ok') throw new Error(data.error?.message ?? 'Subsonic API error');
return data as T;
}
const INSTANT_MIX_PROBE_RANDOM_SIZE = 8;
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
/**
* Probes whether `getSimilarSongs` returns any tracks (Instant Mix / Navidrome agent chain).
* Does not pass `musicFolderId` — probes the whole library as seen by the account.
* Note: if `ND_AGENTS` includes Last.fm, a positive result does not prove AudioMuse alone.
*/
export async function probeInstantMixWithCredentials(
serverUrl: string,
username: string,
password: string,
): Promise<InstantMixProbeResult> {
try {
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
serverUrl,
username,
password,
'getRandomSongs.view',
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
12000,
);
const raw = data.randomSongs?.song;
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
if (songs.length === 0) return 'skipped';
let anyError = false;
for (const song of songs.slice(0, INSTANT_MIX_PROBE_MAX_TRACKS)) {
try {
const simData = await apiWithCredentials<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>(
serverUrl,
username,
password,
'getSimilarSongs.view',
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
12000,
);
const sRaw = simData.similarSongs?.song;
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
if (list.some(s => s.id !== song.id)) return 'ok';
} catch {
anyError = true;
}
}
return anyError ? 'error' : 'empty';
} catch {
return 'error';
}
}
/** After a successful ping, probe Instant Mix in the background (Navidrome ≥ 0.60 only). */
export function scheduleInstantMixProbeForServer(
serverId: string,
serverUrl: string,
username: string,
password: string,
identity: SubsonicServerIdentity,
): void {
if (!isNavidromeAudiomuseSoftwareEligible(identity)) return;
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
useAuthStore.getState().setInstantMixProbe(serverId, result),
);
}
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'random',