mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
Merge pull request #147: feat(discovery): Navidrome AudioMuse-AI integration
Resolves conflict in ContextMenu.tsx: keep useShallow import from main alongside getSimilarSongs added by this PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,3 +40,6 @@ memory/
|
|||||||
|
|
||||||
# Local scratchpad / notes (not committed)
|
# Local scratchpad / notes (not committed)
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
|
# Third-party clones for local research (not committed)
|
||||||
|
research/
|
||||||
|
|||||||
+202
-9
@@ -3,6 +3,11 @@ import md5 from 'md5';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { version } from '../../package.json';
|
import { version } from '../../package.json';
|
||||||
|
import {
|
||||||
|
isNavidromeAudiomuseSoftwareEligible,
|
||||||
|
type InstantMixProbeResult,
|
||||||
|
type SubsonicServerIdentity,
|
||||||
|
} from '../utils/subsonicServerIdentity';
|
||||||
|
|
||||||
// ─── Secure random salt ────────────────────────────────────────
|
// ─── Secure random salt ────────────────────────────────────────
|
||||||
function secureRandomSalt(): string {
|
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. */
|
/** 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 {
|
try {
|
||||||
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
|
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
|
||||||
const salt = secureRandomSalt();
|
const salt = secureRandomSalt();
|
||||||
@@ -277,12 +288,108 @@ export async function pingWithCredentials(serverUrl: string, username: string, p
|
|||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
const data = resp.data?.['subsonic-response'];
|
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 {
|
} 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[]> {
|
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
|
||||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||||
type: 'random',
|
type: 'random',
|
||||||
@@ -309,6 +416,68 @@ export async function getAlbumList(
|
|||||||
return data.albumList2?.album ?? [];
|
return data.albumList2?.album ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navidrome (and some servers) ignore `musicFolderId` on getSimilarSongs / getSimilarSongs2 / getTopSongs,
|
||||||
|
* so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of
|
||||||
|
* album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set.
|
||||||
|
*/
|
||||||
|
let scopedLibraryAlbumIdCache: {
|
||||||
|
serverId: string;
|
||||||
|
folderId: string;
|
||||||
|
filterVersion: number;
|
||||||
|
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];
|
||||||
|
if (folder === undefined || folder === 'all') {
|
||||||
|
scopedLibraryAlbumIdCache = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const hit = scopedLibraryAlbumIdCache;
|
||||||
|
if (
|
||||||
|
hit &&
|
||||||
|
hit.serverId === activeServerId &&
|
||||||
|
hit.folderId === folder &&
|
||||||
|
hit.filterVersion === musicLibraryFilterVersion
|
||||||
|
) {
|
||||||
|
return hit.ids;
|
||||||
|
}
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const pageSize = 500;
|
||||||
|
let offset = 0;
|
||||||
|
for (;;) {
|
||||||
|
const albums = await getAlbumList('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,
|
||||||
|
folderId: folder,
|
||||||
|
filterVersion: musicLibraryFilterVersion,
|
||||||
|
ids,
|
||||||
|
};
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise<SubsonicSong[]> {
|
||||||
|
const allowed = await albumIdsInActiveLibraryScope();
|
||||||
|
if (!allowed || allowed.size === 0) return songs;
|
||||||
|
return songs.filter(s => s.albumId && allowed.has(s.albumId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
|
||||||
|
function similarSongsRequestCount(desired: number): number {
|
||||||
|
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||||
|
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
|
||||||
|
if (f === undefined || f === 'all') return desired;
|
||||||
|
return Math.min(300, Math.max(desired, desired * 4));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
||||||
const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() };
|
const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() };
|
||||||
if (genre) params.genre = genre;
|
if (genre) params.genre = genre;
|
||||||
@@ -590,15 +759,21 @@ export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; a
|
|||||||
return { artist, albums: album ?? [] };
|
return { artist, albums: album ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getArtistInfo(id: string): Promise<SubsonicArtistInfo> {
|
export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise<SubsonicArtistInfo> {
|
||||||
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count: 5 });
|
const count = options?.similarArtistCount ?? 5;
|
||||||
|
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() });
|
||||||
return data.artistInfo2 ?? {};
|
return data.artistInfo2 ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
|
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
|
||||||
try {
|
try {
|
||||||
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: 5 });
|
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||||
return data.topSongs?.song ?? [];
|
const scoped = activeServerId && musicLibraryFilterByServer[activeServerId] && musicLibraryFilterByServer[activeServerId] !== 'all';
|
||||||
|
const topCount = scoped ? 20 : 5;
|
||||||
|
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: topCount, ...libraryFilterParams() });
|
||||||
|
const raw = data.topSongs?.song ?? [];
|
||||||
|
const filtered = await filterSongsToActiveLibrary(raw);
|
||||||
|
return filtered.slice(0, 5);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -606,8 +781,26 @@ export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
|
|||||||
|
|
||||||
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
|
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||||
try {
|
try {
|
||||||
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count });
|
const requestCount = similarSongsRequestCount(count);
|
||||||
return data.similarSongs2?.song ?? [];
|
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() });
|
||||||
|
const raw = data.similarSongs2?.song ?? [];
|
||||||
|
const filtered = await filterSongsToActiveLibrary(raw);
|
||||||
|
return filtered.slice(0, count);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */
|
||||||
|
export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||||
|
try {
|
||||||
|
const requestCount = similarSongsRequestCount(count);
|
||||||
|
const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() });
|
||||||
|
const raw = data.similarSongs?.song;
|
||||||
|
if (!raw) return [];
|
||||||
|
const list = Array.isArray(raw) ? raw : [raw];
|
||||||
|
const filtered = await filterSongsToActiveLibrary(list);
|
||||||
|
return filtered.slice(0, count);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
|
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles } from 'lucide-react';
|
||||||
import LastfmIcon from './LastfmIcon';
|
import LastfmIcon from './LastfmIcon';
|
||||||
import StarRating from './StarRating';
|
import StarRating from './StarRating';
|
||||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||||
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
|
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getSimilarSongs, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||||
@@ -15,6 +15,7 @@ import { join } from '@tauri-apps/api/path';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { showToast } from '../utils/toast';
|
||||||
|
|
||||||
function sanitizeFilename(name: string): string {
|
function sanitizeFilename(name: string): string {
|
||||||
return name
|
return name
|
||||||
@@ -195,6 +196,7 @@ export default function ContextMenu() {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const audiomuseNavidromeEnabled = !!(auth.activeServerId && auth.audiomuseNavidromeByServer[auth.activeServerId]);
|
||||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -253,12 +255,15 @@ export default function ContextMenu() {
|
|||||||
// same "Top 5" in the same order every time.
|
// same "Top 5" in the same order every time.
|
||||||
try {
|
try {
|
||||||
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
|
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
|
||||||
const radioTracks = shuffleArray(
|
// Keep artist top songs and similar-by-artist in two blocks (each shuffled), not one blended pile —
|
||||||
[...top, ...similar]
|
// otherwise this feels the same as Instant Mix (track-based similar only).
|
||||||
.map(songToTrack)
|
const topTracks = shuffleArray(
|
||||||
.filter(t => t.id !== seedTrack.id)
|
top.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const }))
|
||||||
.map(t => ({ ...t, radioAdded: true as const }))
|
|
||||||
);
|
);
|
||||||
|
const similarTracks = shuffleArray(
|
||||||
|
similar.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const }))
|
||||||
|
);
|
||||||
|
const radioTracks = [...topTracks, ...similarTracks];
|
||||||
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
|
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load radio queue', e);
|
console.error('Failed to load radio queue', e);
|
||||||
@@ -324,6 +329,33 @@ export default function ContextMenu() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startInstantMix = async (song: Track) => {
|
||||||
|
const state = usePlayerStore.getState();
|
||||||
|
if (state.currentTrack?.id === song.id) {
|
||||||
|
if (!state.isPlaying) state.resume();
|
||||||
|
} else {
|
||||||
|
playTrack(song, [song]);
|
||||||
|
}
|
||||||
|
const serverId = useAuthStore.getState().activeServerId;
|
||||||
|
try {
|
||||||
|
const similar = await getSimilarSongs(song.id, 50);
|
||||||
|
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
|
||||||
|
const shuffled = shuffleArray(
|
||||||
|
similar
|
||||||
|
.filter(s => s.id !== song.id)
|
||||||
|
.map(s => ({ ...songToTrack(s), radioAdded: true as const }))
|
||||||
|
);
|
||||||
|
if (shuffled.length > 0) {
|
||||||
|
const aid = song.artistId?.trim() || undefined;
|
||||||
|
usePlayerStore.getState().enqueueRadio(shuffled, aid);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Instant mix failed', e);
|
||||||
|
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true);
|
||||||
|
showToast(t('contextMenu.instantMixFailed'), 5000, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const downloadAlbum = async (albumName: string, albumId: string) => {
|
const downloadAlbum = async (albumName: string, albumId: string) => {
|
||||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||||
if (!folder) return;
|
if (!folder) return;
|
||||||
@@ -407,6 +439,11 @@ export default function ContextMenu() {
|
|||||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
</div>
|
</div>
|
||||||
|
{audiomuseNavidromeEnabled && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
|
||||||
|
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
const starred = isStarred(song.id, song.starred);
|
const starred = isStarred(song.id, song.starred);
|
||||||
setStarredOverride(song.id, !starred);
|
setStarredOverride(song.id, !starred);
|
||||||
@@ -557,6 +594,11 @@ export default function ContextMenu() {
|
|||||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
</div>
|
</div>
|
||||||
|
{audiomuseNavidromeEnabled && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
|
||||||
|
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
|
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
|
||||||
<StarRating
|
<StarRating
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { pingWithCredentials } from '../api/subsonic';
|
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||||
|
|
||||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
||||||
|
|
||||||
@@ -37,8 +37,20 @@ export function useConnectionStatus() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = await pingWithCredentials(server.url, server.username, server.password);
|
const ping = await pingWithCredentials(server.url, server.username, server.password);
|
||||||
setStatus(ok ? 'connected' : 'disconnected');
|
if (ping.ok) {
|
||||||
|
const sid = useAuthStore.getState().activeServerId;
|
||||||
|
if (sid) {
|
||||||
|
const identity = {
|
||||||
|
type: ping.type,
|
||||||
|
serverVersion: ping.serverVersion,
|
||||||
|
openSubsonic: ping.openSubsonic,
|
||||||
|
};
|
||||||
|
useAuthStore.getState().setSubsonicServerIdentity(sid, identity);
|
||||||
|
scheduleInstantMixProbeForServer(sid, server.url, server.username, server.password, identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStatus(ping.ok ? 'connected' : 'disconnected');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const retry = useCallback(async () => {
|
const retry = useCallback(async () => {
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export const deTranslation = {
|
|||||||
addToQueue: 'Zur Warteschlange hinzufügen',
|
addToQueue: 'Zur Warteschlange hinzufügen',
|
||||||
enqueueAlbum: 'Ganzes Album einreihen',
|
enqueueAlbum: 'Ganzes Album einreihen',
|
||||||
startRadio: 'Radio starten',
|
startRadio: 'Radio starten',
|
||||||
|
instantMix: 'Instant Mix',
|
||||||
|
instantMixFailed: 'Instant Mix konnte nicht erstellt werden — Server- oder Pluginfehler.',
|
||||||
lfmLove: 'Auf Last.fm liken',
|
lfmLove: 'Auf Last.fm liken',
|
||||||
lfmUnlove: 'Last.fm-Like entfernen',
|
lfmUnlove: 'Last.fm-Like entfernen',
|
||||||
favorite: 'Favorisieren',
|
favorite: 'Favorisieren',
|
||||||
@@ -396,6 +398,11 @@ export const deTranslation = {
|
|||||||
testBtn: 'Verbindung testen',
|
testBtn: 'Verbindung testen',
|
||||||
testingBtn: 'Teste…',
|
testingBtn: 'Teste…',
|
||||||
serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic',
|
serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic',
|
||||||
|
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||||
|
audiomuseDesc:
|
||||||
|
'Aktivieren, wenn dieser Server das <pluginLink>AudioMuse-AI-Navidrome-Plugin</pluginLink> nutzt. Schaltet Instant Mix pro Titel frei und nutzt ähnliche Künstler vom Server statt Last.fm auf Künstlerseiten.',
|
||||||
|
audiomuseIssueHint:
|
||||||
|
'Instant Mix ist kürzlich fehlgeschlagen — Navidrome-Plugin und AudioMuse-API prüfen. Ohne Server-Treffer werden ähnliche Künstler über Last.fm geladen.',
|
||||||
connected: 'Verbunden',
|
connected: 'Verbunden',
|
||||||
failed: 'Fehlgeschlagen',
|
failed: 'Fehlgeschlagen',
|
||||||
eqTitle: 'Equalizer',
|
eqTitle: 'Equalizer',
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export const enTranslation = {
|
|||||||
addToQueue: 'Add to Queue',
|
addToQueue: 'Add to Queue',
|
||||||
enqueueAlbum: 'Enqueue Album',
|
enqueueAlbum: 'Enqueue Album',
|
||||||
startRadio: 'Start Radio',
|
startRadio: 'Start Radio',
|
||||||
|
instantMix: 'Instant Mix',
|
||||||
|
instantMixFailed: 'Could not build Instant Mix — server or plugin error.',
|
||||||
lfmLove: 'Love on Last.fm',
|
lfmLove: 'Love on Last.fm',
|
||||||
lfmUnlove: 'Unlove on Last.fm',
|
lfmUnlove: 'Unlove on Last.fm',
|
||||||
favorite: 'Favorite',
|
favorite: 'Favorite',
|
||||||
@@ -397,6 +399,11 @@ export const enTranslation = {
|
|||||||
testBtn: 'Test Connection',
|
testBtn: 'Test Connection',
|
||||||
testingBtn: 'Testing…',
|
testingBtn: 'Testing…',
|
||||||
serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic',
|
serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic',
|
||||||
|
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||||
|
audiomuseDesc:
|
||||||
|
'Turn on if this server has the <pluginLink>AudioMuse-AI Navidrome plugin</pluginLink> configured. Enables Instant Mix from tracks and uses server-side similar artists instead of Last.fm on artist pages.',
|
||||||
|
audiomuseIssueHint:
|
||||||
|
'Instant Mix failed recently — check the Navidrome plugin and AudioMuse API. Similar artists fall back to Last.fm when the server returns none.',
|
||||||
connected: 'Connected',
|
connected: 'Connected',
|
||||||
failed: 'Failed',
|
failed: 'Failed',
|
||||||
eqTitle: 'Equalizer',
|
eqTitle: 'Equalizer',
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export const frTranslation = {
|
|||||||
addToQueue: 'Ajouter à la file',
|
addToQueue: 'Ajouter à la file',
|
||||||
enqueueAlbum: 'Mettre l\'album en file',
|
enqueueAlbum: 'Mettre l\'album en file',
|
||||||
startRadio: 'Démarrer la radio',
|
startRadio: 'Démarrer la radio',
|
||||||
|
instantMix: 'Mix instantané',
|
||||||
|
instantMixFailed: 'Impossible de créer le mix instantané — erreur serveur ou plugin.',
|
||||||
lfmLove: 'Aimer sur Last.fm',
|
lfmLove: 'Aimer sur Last.fm',
|
||||||
lfmUnlove: 'Ne plus aimer sur Last.fm',
|
lfmUnlove: 'Ne plus aimer sur Last.fm',
|
||||||
favorite: 'Favori',
|
favorite: 'Favori',
|
||||||
@@ -396,6 +398,11 @@ export const frTranslation = {
|
|||||||
testBtn: 'Tester la connexion',
|
testBtn: 'Tester la connexion',
|
||||||
testingBtn: 'Test en cours…',
|
testingBtn: 'Test en cours…',
|
||||||
serverCompatible: 'Compatible avec : Navidrome · Gonic · Airsonic · Subsonic',
|
serverCompatible: 'Compatible avec : Navidrome · Gonic · Airsonic · Subsonic',
|
||||||
|
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||||
|
audiomuseDesc:
|
||||||
|
'Activez si ce serveur utilise le <pluginLink>plugin Navidrome AudioMuse-AI</pluginLink>. Active le mix instantané depuis un morceau et affiche les artistes similaires côté serveur au lieu de Last.fm sur les pages artiste.',
|
||||||
|
audiomuseIssueHint:
|
||||||
|
'Le mix instantané a échoué récemment — vérifiez le plugin Navidrome et l’API AudioMuse. Les artistes similaires utilisent Last.fm si le serveur ne renvoie rien.',
|
||||||
connected: 'Connecté',
|
connected: 'Connecté',
|
||||||
failed: 'Échec',
|
failed: 'Échec',
|
||||||
eqTitle: 'Égaliseur',
|
eqTitle: 'Égaliseur',
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export const nbTranslation = {
|
|||||||
addToQueue: 'Legg til i kø',
|
addToQueue: 'Legg til i kø',
|
||||||
enqueueAlbum: 'Legg albumet i kø',
|
enqueueAlbum: 'Legg albumet i kø',
|
||||||
startRadio: 'Start radio',
|
startRadio: 'Start radio',
|
||||||
|
instantMix: 'Instant Mix',
|
||||||
|
instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.',
|
||||||
lfmLove: 'Lik på Last.fm',
|
lfmLove: 'Lik på Last.fm',
|
||||||
lfmUnlove: 'Fjern fra likte på Last.fm',
|
lfmUnlove: 'Fjern fra likte på Last.fm',
|
||||||
favorite: 'Favoritt',
|
favorite: 'Favoritt',
|
||||||
@@ -396,6 +398,11 @@ export const nbTranslation = {
|
|||||||
testBtn: 'Test tilkobling',
|
testBtn: 'Test tilkobling',
|
||||||
testingBtn: 'Tester…',
|
testingBtn: 'Tester…',
|
||||||
serverCompatible: 'Kompatibel med: Navidrome · Gonic · Airsonic · Subsonic',
|
serverCompatible: 'Kompatibel med: Navidrome · Gonic · Airsonic · Subsonic',
|
||||||
|
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||||
|
audiomuseDesc:
|
||||||
|
'Slå på hvis denne serveren bruker <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink>. Aktiverer Instant Mix fra spor og henter lignende artister fra serveren i stedet for Last.fm på artistsider.',
|
||||||
|
audiomuseIssueHint:
|
||||||
|
'Instant Mix feilet nylig — sjekk Navidrome-plugin og AudioMuse API. Lignende artister hentes fra Last.fm hvis serveren ikke returnerer noe.',
|
||||||
connected: 'Tilkoblet',
|
connected: 'Tilkoblet',
|
||||||
failed: 'Mislyktes',
|
failed: 'Mislyktes',
|
||||||
eqTitle: 'Jevnstiller',
|
eqTitle: 'Jevnstiller',
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export const nlTranslation = {
|
|||||||
addToQueue: 'Aan wachtrij toevoegen',
|
addToQueue: 'Aan wachtrij toevoegen',
|
||||||
enqueueAlbum: 'Album in wachtrij',
|
enqueueAlbum: 'Album in wachtrij',
|
||||||
startRadio: 'Radio starten',
|
startRadio: 'Radio starten',
|
||||||
|
instantMix: 'Instant Mix',
|
||||||
|
instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.',
|
||||||
lfmLove: 'Liken op Last.fm',
|
lfmLove: 'Liken op Last.fm',
|
||||||
lfmUnlove: 'Niet meer liken op Last.fm',
|
lfmUnlove: 'Niet meer liken op Last.fm',
|
||||||
favorite: 'Favoriet',
|
favorite: 'Favoriet',
|
||||||
@@ -396,6 +398,11 @@ export const nlTranslation = {
|
|||||||
testBtn: 'Verbinding testen',
|
testBtn: 'Verbinding testen',
|
||||||
testingBtn: 'Testen…',
|
testingBtn: 'Testen…',
|
||||||
serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic',
|
serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic',
|
||||||
|
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||||
|
audiomuseDesc:
|
||||||
|
'Zet aan als deze server de <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink> gebruikt. Schakelt Instant Mix per nummer in en toont vergelijkbare artiesten van de server i.p.v. Last.fm op artiestpagina’s.',
|
||||||
|
audiomuseIssueHint:
|
||||||
|
'Instant Mix is onlangs mislukt — controleer de Navidrome-plugin en AudioMuse API. Zonder serverresultaten worden vergelijkbare artiesten via Last.fm geladen.',
|
||||||
connected: 'Verbonden',
|
connected: 'Verbonden',
|
||||||
failed: 'Mislukt',
|
failed: 'Mislukt',
|
||||||
eqTitle: 'Equalizer',
|
eqTitle: 'Equalizer',
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export const ruTranslation = {
|
|||||||
addToQueue: 'В конец очереди',
|
addToQueue: 'В конец очереди',
|
||||||
enqueueAlbum: 'Альбом в очередь',
|
enqueueAlbum: 'Альбом в очередь',
|
||||||
startRadio: 'Радио по похожим',
|
startRadio: 'Радио по похожим',
|
||||||
|
instantMix: 'Instant Mix',
|
||||||
|
instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.',
|
||||||
lfmLove: 'Любимое на Last.fm',
|
lfmLove: 'Любимое на Last.fm',
|
||||||
lfmUnlove: 'Убрать с Last.fm',
|
lfmUnlove: 'Убрать с Last.fm',
|
||||||
favorite: 'В избранное',
|
favorite: 'В избранное',
|
||||||
@@ -411,6 +413,11 @@ export const ruTranslation = {
|
|||||||
testBtn: 'Проверить',
|
testBtn: 'Проверить',
|
||||||
testingBtn: 'Проверка…',
|
testingBtn: 'Проверка…',
|
||||||
serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic',
|
serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic',
|
||||||
|
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||||
|
audiomuseDesc:
|
||||||
|
'Включите, если на этом сервере настроен <pluginLink>плагин AudioMuse-AI для Navidrome</pluginLink>. Появится Instant Mix для треков, а на странице исполнителя похожие будут браться с сервера вместо Last.fm.',
|
||||||
|
audiomuseIssueHint:
|
||||||
|
'Недавно не удалось собрать Instant Mix — проверьте плагин Navidrome и API AudioMuse. Похожие исполнители подтянутся с Last.fm, если сервер ничего не вернёт.',
|
||||||
connected: 'Подключено',
|
connected: 'Подключено',
|
||||||
failed: 'Ошибка',
|
failed: 'Ошибка',
|
||||||
eqTitle: 'Эквалайзер',
|
eqTitle: 'Эквалайзер',
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export const zhTranslation = {
|
|||||||
addToQueue: '添加到队列',
|
addToQueue: '添加到队列',
|
||||||
enqueueAlbum: '专辑加入队列',
|
enqueueAlbum: '专辑加入队列',
|
||||||
startRadio: '开始电台',
|
startRadio: '开始电台',
|
||||||
|
instantMix: '即时混音',
|
||||||
|
instantMixFailed: '无法生成即时混音 — 服务器或插件出错。',
|
||||||
lfmLove: '在 Last.fm 上标记喜欢',
|
lfmLove: '在 Last.fm 上标记喜欢',
|
||||||
lfmUnlove: '取消 Last.fm 喜欢标记',
|
lfmUnlove: '取消 Last.fm 喜欢标记',
|
||||||
favorite: '收藏',
|
favorite: '收藏',
|
||||||
@@ -392,6 +394,11 @@ export const zhTranslation = {
|
|||||||
testBtn: '测试连接',
|
testBtn: '测试连接',
|
||||||
testingBtn: '正在测试…',
|
testingBtn: '正在测试…',
|
||||||
serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic',
|
serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic',
|
||||||
|
audiomuseTitle: 'AudioMuse-AI(Navidrome)',
|
||||||
|
audiomuseDesc:
|
||||||
|
'若此服务器已配置 <pluginLink>AudioMuse-AI Navidrome 插件</pluginLink>请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。',
|
||||||
|
audiomuseIssueHint:
|
||||||
|
'近期即时混音失败 — 请检查 Navidrome 插件与 AudioMuse API。若服务器无结果,将回退使用 Last.fm 的相似艺人。',
|
||||||
connected: '已连接',
|
connected: '已连接',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
eqTitle: '均衡器',
|
eqTitle: '均衡器',
|
||||||
|
|||||||
+84
-13
@@ -57,6 +57,7 @@ export default function ArtistDetail() {
|
|||||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||||
const [similarLoading, setSimilarLoading] = useState(false);
|
const [similarLoading, setSimilarLoading] = useState(false);
|
||||||
|
const [artistInfoLoading, setArtistInfoLoading] = useState(false);
|
||||||
const [featuredLoading, setFeaturedLoading] = useState(false);
|
const [featuredLoading, setFeaturedLoading] = useState(false);
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
const [bioExpanded, setBioExpanded] = useState(false);
|
const [bioExpanded, setBioExpanded] = useState(false);
|
||||||
@@ -73,6 +74,9 @@ export default function ArtistDetail() {
|
|||||||
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
||||||
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
|
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||||
|
const audiomuseNavidromeEnabled = useAuthStore(
|
||||||
|
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||||
|
);
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||||
@@ -95,12 +99,6 @@ export default function ArtistDetail() {
|
|||||||
// Render the page immediately from local data
|
// Render the page immediately from local data
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
// Fetch artist info (may trigger slow external lookup on the server)
|
|
||||||
// and top songs in the background — do not block rendering
|
|
||||||
getArtistInfo(id).then(artistInfo => {
|
|
||||||
if (!cancelled) setInfo(artistInfo ?? null);
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
getTopSongs(artistData.artist.name).then(songsData => {
|
getTopSongs(artistData.artist.name).then(songsData => {
|
||||||
if (!cancelled) setTopSongs(songsData ?? []);
|
if (!cancelled) setTopSongs(songsData ?? []);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
@@ -110,6 +108,23 @@ export default function ArtistDetail() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setArtistInfoLoading(true);
|
||||||
|
getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
|
||||||
|
.then(artistInfo => {
|
||||||
|
if (!cancelled) setInfo(artistInfo ?? null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setInfo(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setArtistInfoLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [id, audiomuseNavidromeEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
|
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
|
||||||
@@ -174,7 +189,7 @@ export default function ArtistDetail() {
|
|||||||
}, [artist?.id, musicLibraryFilterVersion]);
|
}, [artist?.id, musicLibraryFilterVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!artist || !lastfmIsConfigured()) return;
|
if (!artist || audiomuseNavidromeEnabled || !lastfmIsConfigured()) return;
|
||||||
setSimilarArtists([]);
|
setSimilarArtists([]);
|
||||||
setSimilarLoading(true);
|
setSimilarLoading(true);
|
||||||
lastfmGetSimilarArtists(artist.name).then(async names => {
|
lastfmGetSimilarArtists(artist.name).then(async names => {
|
||||||
@@ -197,7 +212,52 @@ export default function ArtistDetail() {
|
|||||||
setSimilarArtists(found);
|
setSimilarArtists(found);
|
||||||
setSimilarLoading(false);
|
setSimilarLoading(false);
|
||||||
}).catch(() => setSimilarLoading(false));
|
}).catch(() => setSimilarLoading(false));
|
||||||
}, [artist?.id, musicLibraryFilterVersion]);
|
}, [artist?.id, musicLibraryFilterVersion, audiomuseNavidromeEnabled]);
|
||||||
|
|
||||||
|
/** When AudioMuse is on but the server returns no similar artists, fall back to Last.fm (if configured). */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!artist || !audiomuseNavidromeEnabled || !lastfmIsConfigured()) return;
|
||||||
|
if (artistInfoLoading) return;
|
||||||
|
if ((info?.similarArtist?.length ?? 0) > 0) return;
|
||||||
|
|
||||||
|
setSimilarArtists([]);
|
||||||
|
setSimilarLoading(true);
|
||||||
|
lastfmGetSimilarArtists(artist.name).then(async names => {
|
||||||
|
if (names.length === 0) { setSimilarLoading(false); return; }
|
||||||
|
const results = await Promise.all(
|
||||||
|
names.slice(0, 30).map(name =>
|
||||||
|
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const seen = new Set<string>([artist.id]);
|
||||||
|
const found: SubsonicArtist[] = [];
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const targetName = names[i].toLowerCase();
|
||||||
|
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
|
||||||
|
if (match && !seen.has(match.id)) {
|
||||||
|
seen.add(match.id);
|
||||||
|
found.push(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSimilarArtists(found);
|
||||||
|
setSimilarLoading(false);
|
||||||
|
}).catch(() => setSimilarLoading(false));
|
||||||
|
}, [
|
||||||
|
artist?.id,
|
||||||
|
artist?.name,
|
||||||
|
musicLibraryFilterVersion,
|
||||||
|
audiomuseNavidromeEnabled,
|
||||||
|
artistInfoLoading,
|
||||||
|
info?.similarArtist?.length,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audiomuseNavidromeEnabled) return;
|
||||||
|
if ((info?.similarArtist?.length ?? 0) > 0) {
|
||||||
|
setSimilarArtists([]);
|
||||||
|
setSimilarLoading(false);
|
||||||
|
}
|
||||||
|
}, [id, audiomuseNavidromeEnabled, info?.similarArtist?.length]);
|
||||||
|
|
||||||
const openLink = (url: string, key: string) => {
|
const openLink = (url: string, key: string) => {
|
||||||
open(url);
|
open(url);
|
||||||
@@ -331,6 +391,18 @@ export default function ArtistDetail() {
|
|||||||
const coverId = artist.coverArt || artist.id;
|
const coverId = artist.coverArt || artist.id;
|
||||||
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
||||||
|
|
||||||
|
const serverSimilarArtists: SubsonicArtist[] = (info?.similarArtist ?? []).map(sa => ({
|
||||||
|
id: sa.id,
|
||||||
|
name: sa.name,
|
||||||
|
albumCount: sa.albumCount,
|
||||||
|
}));
|
||||||
|
const showAudiomuseSimilar = audiomuseNavidromeEnabled && serverSimilarArtists.length > 0;
|
||||||
|
const showLastfmSimilar =
|
||||||
|
lastfmIsConfigured() &&
|
||||||
|
(!audiomuseNavidromeEnabled || serverSimilarArtists.length === 0) &&
|
||||||
|
(similarLoading || similarArtists.length > 0);
|
||||||
|
const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-body animate-fade-in">
|
<div className="content-body animate-fade-in">
|
||||||
<button
|
<button
|
||||||
@@ -564,20 +636,19 @@ export default function ArtistDetail() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Similar Artists (Last.fm) */}
|
{showSimilarSection && (
|
||||||
{lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && (
|
|
||||||
<>
|
<>
|
||||||
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
||||||
{t('artistDetail.similarArtists')}
|
{t('artistDetail.similarArtists')}
|
||||||
</h2>
|
</h2>
|
||||||
{similarLoading ? (
|
{showLastfmSimilar && similarLoading ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||||
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||||
{t('artistDetail.loading')}
|
{t('artistDetail.loading')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
{similarArtists.map(a => (
|
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists).map(a => (
|
||||||
<button
|
<button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
className="artist-ext-link"
|
className="artist-ext-link"
|
||||||
@@ -592,7 +663,7 @@ export default function ArtistDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Albums */}
|
{/* Albums */}
|
||||||
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || lastfmIsConfigured()) ? '2rem' : '0', marginBottom: '1rem' }}>
|
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || showSimilarSection) ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||||
{t('artistDetail.albumsBy', { name: artist.name })}
|
{t('artistDetail.albumsBy', { name: artist.name })}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|||||||
+18
-5
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Wifi, WifiOff, Eye, EyeOff, Server } from 'lucide-react';
|
import { Wifi, WifiOff, Eye, EyeOff, Server } from 'lucide-react';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { pingWithCredentials } from '../api/subsonic';
|
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const PsysonicLogo = () => (
|
const PsysonicLogo = () => (
|
||||||
@@ -36,16 +36,16 @@ export default function Login() {
|
|||||||
|
|
||||||
// Test connection directly with entered credentials — don't touch the store yet.
|
// Test connection directly with entered credentials — don't touch the store yet.
|
||||||
// This avoids any race condition with Zustand's async store rehydration.
|
// This avoids any race condition with Zustand's async store rehydration.
|
||||||
let ok = false;
|
let ping: Awaited<ReturnType<typeof pingWithCredentials>> = { ok: false };
|
||||||
try {
|
try {
|
||||||
ok = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
|
ping = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
|
||||||
} catch {
|
} catch {
|
||||||
ok = false;
|
ping = { ok: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
|
|
||||||
if (ok) {
|
if (ping.ok) {
|
||||||
// Connection succeeded — now persist to store
|
// Connection succeeded — now persist to store
|
||||||
const existing = servers.find(s => s.url === profile.url.trim() && s.username === profile.username.trim());
|
const existing = servers.find(s => s.url === profile.url.trim() && s.username === profile.username.trim());
|
||||||
let serverId: string;
|
let serverId: string;
|
||||||
@@ -63,6 +63,19 @@ export default function Login() {
|
|||||||
password: profile.password,
|
password: profile.password,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const identity = {
|
||||||
|
type: ping.type,
|
||||||
|
serverVersion: ping.serverVersion,
|
||||||
|
openSubsonic: ping.openSubsonic,
|
||||||
|
};
|
||||||
|
useAuthStore.getState().setSubsonicServerIdentity(serverId, identity);
|
||||||
|
scheduleInstantMixProbeForServer(
|
||||||
|
serverId,
|
||||||
|
profile.url.trim(),
|
||||||
|
profile.username.trim(),
|
||||||
|
profile.password,
|
||||||
|
identity,
|
||||||
|
);
|
||||||
setActiveServer(serverId);
|
setActiveServer(serverId);
|
||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
setStatus('ok');
|
setStatus('ok');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward } from 'lucide-react';
|
import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward } from 'lucide-react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useLyricsStore } from '../store/lyricsStore';
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
import {
|
import {
|
||||||
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
|
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
|
||||||
@@ -219,6 +220,9 @@ export default function NowPlaying() {
|
|||||||
const activeTab = useLyricsStore(s => s.activeTab);
|
const activeTab = useLyricsStore(s => s.activeTab);
|
||||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||||
|
const audiomuseNavidromeEnabled = useAuthStore(
|
||||||
|
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||||
|
);
|
||||||
|
|
||||||
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
|
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
|
||||||
|
|
||||||
@@ -236,8 +240,10 @@ export default function NowPlaying() {
|
|||||||
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(null);
|
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentTrack?.artistId) { setArtistInfo(null); return; }
|
if (!currentTrack?.artistId) { setArtistInfo(null); return; }
|
||||||
getArtistInfo(currentTrack.artistId).then(setArtistInfo).catch(() => setArtistInfo(null));
|
getArtistInfo(currentTrack.artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
|
||||||
}, [currentTrack?.artistId]);
|
.then(setArtistInfo)
|
||||||
|
.catch(() => setArtistInfo(null));
|
||||||
|
}, [currentTrack?.artistId, audiomuseNavidromeEnabled]);
|
||||||
|
|
||||||
// Album tracks
|
// Album tracks
|
||||||
const [albumTracks, setAlbumTracks] = useState<SubsonicSong[]>([]);
|
const [albumTracks, setAlbumTracks] = useState<SubsonicSong[]>([]);
|
||||||
|
|||||||
+100
-9
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn
|
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { exportBackup, importBackup } from '../utils/backup';
|
import { exportBackup, importBackup } from '../utils/backup';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
@@ -29,14 +29,17 @@ import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../st
|
|||||||
import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
||||||
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
||||||
import { ALL_NAV_ITEMS } from '../components/Sidebar';
|
import { ALL_NAV_ITEMS } from '../components/Sidebar';
|
||||||
import { pingWithCredentials } from '../api/subsonic';
|
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import Equalizer from '../components/Equalizer';
|
import Equalizer from '../components/Equalizer';
|
||||||
import StarRating from '../components/StarRating';
|
import StarRating from '../components/StarRating';
|
||||||
|
import { showAudiomuseNavidromeServerSetting } from '../utils/subsonicServerIdentity';
|
||||||
|
|
||||||
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
|
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
|
||||||
|
|
||||||
|
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
|
||||||
|
|
||||||
const CONTRIBUTORS = [
|
const CONTRIBUTORS = [
|
||||||
{
|
{
|
||||||
github: 'jiezhuo',
|
github: 'jiezhuo',
|
||||||
@@ -318,8 +321,17 @@ export default function Settings() {
|
|||||||
const testConnection = async (server: ServerProfile) => {
|
const testConnection = async (server: ServerProfile) => {
|
||||||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||||||
try {
|
try {
|
||||||
const ok = await pingWithCredentials(server.url, server.username, server.password);
|
const ping = await pingWithCredentials(server.url, server.username, server.password);
|
||||||
setConnStatus(s => ({ ...s, [server.id]: ok ? 'ok' : 'error' }));
|
if (ping.ok) {
|
||||||
|
const identity = {
|
||||||
|
type: ping.type,
|
||||||
|
serverVersion: ping.serverVersion,
|
||||||
|
openSubsonic: ping.openSubsonic,
|
||||||
|
};
|
||||||
|
auth.setSubsonicServerIdentity(server.id, identity);
|
||||||
|
scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity);
|
||||||
|
}
|
||||||
|
setConnStatus(s => ({ ...s, [server.id]: ping.ok ? 'ok' : 'error' }));
|
||||||
} catch {
|
} catch {
|
||||||
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
||||||
}
|
}
|
||||||
@@ -328,8 +340,15 @@ export default function Settings() {
|
|||||||
const switchToServer = async (server: ServerProfile) => {
|
const switchToServer = async (server: ServerProfile) => {
|
||||||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||||||
try {
|
try {
|
||||||
const ok = await pingWithCredentials(server.url, server.username, server.password);
|
const ping = await pingWithCredentials(server.url, server.username, server.password);
|
||||||
if (ok) {
|
if (ping.ok) {
|
||||||
|
const identity = {
|
||||||
|
type: ping.type,
|
||||||
|
serverVersion: ping.serverVersion,
|
||||||
|
openSubsonic: ping.openSubsonic,
|
||||||
|
};
|
||||||
|
auth.setSubsonicServerIdentity(server.id, identity);
|
||||||
|
scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity);
|
||||||
auth.setActiveServer(server.id);
|
auth.setActiveServer(server.id);
|
||||||
auth.setLoggedIn(true);
|
auth.setLoggedIn(true);
|
||||||
navigate('/');
|
navigate('/');
|
||||||
@@ -352,9 +371,16 @@ export default function Settings() {
|
|||||||
const tempId = '_new';
|
const tempId = '_new';
|
||||||
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
|
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
|
||||||
try {
|
try {
|
||||||
const ok = await pingWithCredentials(data.url, data.username, data.password);
|
const ping = await pingWithCredentials(data.url, data.username, data.password);
|
||||||
if (ok) {
|
if (ping.ok) {
|
||||||
const id = auth.addServer(data);
|
const id = auth.addServer(data);
|
||||||
|
const identity = {
|
||||||
|
type: ping.type,
|
||||||
|
serverVersion: ping.serverVersion,
|
||||||
|
openSubsonic: ping.openSubsonic,
|
||||||
|
};
|
||||||
|
auth.setSubsonicServerIdentity(id, identity);
|
||||||
|
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity);
|
||||||
auth.setActiveServer(id);
|
auth.setActiveServer(id);
|
||||||
auth.setLoggedIn(true);
|
auth.setLoggedIn(true);
|
||||||
setConnStatus(s => ({ ...s, [id]: 'ok' }));
|
setConnStatus(s => ({ ...s, [id]: 'ok' }));
|
||||||
@@ -1642,6 +1668,71 @@ export default function Settings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showAudiomuseNavidromeServerSetting(
|
||||||
|
auth.subsonicServerIdentityByServer[srv.id],
|
||||||
|
auth.instantMixProbeByServer[srv.id],
|
||||||
|
) && (
|
||||||
|
<div
|
||||||
|
className="settings-toggle-row"
|
||||||
|
style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', minWidth: 0 }}>
|
||||||
|
<Sparkles size={16} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 2 }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{t('settings.audiomuseTitle')}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.hotCacheAlphaBadge')}
|
||||||
|
</span>
|
||||||
|
{!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && (
|
||||||
|
<AlertTriangle
|
||||||
|
size={16}
|
||||||
|
style={{ color: 'var(--color-warning, #f59e0b)', flexShrink: 0 }}
|
||||||
|
data-tooltip={t('settings.audiomuseIssueHint')}
|
||||||
|
aria-label={t('settings.audiomuseIssueHint')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45 }}>
|
||||||
|
<Trans
|
||||||
|
i18nKey="settings.audiomuseDesc"
|
||||||
|
components={{
|
||||||
|
pluginLink: (
|
||||||
|
<a
|
||||||
|
href={AUDIOMUSE_NV_PLUGIN_URL}
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
void openUrl(AUDIOMUSE_NV_PLUGIN_URL);
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--accent)', textDecoration: 'underline' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch" aria-label={t('settings.audiomuseTitle')}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!auth.audiomuseNavidromeByServer[srv.id]}
|
||||||
|
onChange={e => auth.setAudiomuseNavidromeEnabled(srv.id, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { create } from 'zustand';
|
|||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||||
|
import {
|
||||||
|
isNavidromeAudiomuseSoftwareEligible,
|
||||||
|
type InstantMixProbeResult,
|
||||||
|
type SubsonicServerIdentity,
|
||||||
|
} from '../utils/subsonicServerIdentity';
|
||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
|
|
||||||
export interface ServerProfile {
|
export interface ServerProfile {
|
||||||
@@ -104,6 +109,27 @@ interface AuthState {
|
|||||||
entityRatingSupportByServer: Record<string, EntityRatingSupportLevel>;
|
entityRatingSupportByServer: Record<string, EntityRatingSupportLevel>;
|
||||||
setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void;
|
setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per server: Navidrome has the AudioMuse-AI plugin — use `getSimilarSongs` (Instant Mix) and
|
||||||
|
* `getArtistInfo2` similar artists instead of Last.fm for discovery on this server.
|
||||||
|
*/
|
||||||
|
audiomuseNavidromeByServer: Record<string, boolean>;
|
||||||
|
setAudiomuseNavidromeEnabled: (serverId: string, enabled: boolean) => void;
|
||||||
|
|
||||||
|
/** From `ping` — used to show the AudioMuse toggle only on Navidrome ≥ 0.60. */
|
||||||
|
subsonicServerIdentityByServer: Record<string, SubsonicServerIdentity>;
|
||||||
|
setSubsonicServerIdentity: (serverId: string, identity: SubsonicServerIdentity) => void;
|
||||||
|
|
||||||
|
/** Instant Mix / similar path failed while this server had AudioMuse enabled (cleared on success or toggle off). */
|
||||||
|
audiomuseNavidromeIssueByServer: Record<string, boolean>;
|
||||||
|
setAudiomuseNavidromeIssue: (serverId: string, hasIssue: boolean) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `getSimilarSongs` probe per server (after ping). `empty` hides the AudioMuse row; re-run by testing connection.
|
||||||
|
*/
|
||||||
|
instantMixProbeByServer: Record<string, InstantMixProbeResult>;
|
||||||
|
setInstantMixProbe: (serverId: string, result: InstantMixProbeResult) => void;
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
@@ -250,6 +276,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
musicLibraryFilterByServer: {},
|
musicLibraryFilterByServer: {},
|
||||||
musicLibraryFilterVersion: 0,
|
musicLibraryFilterVersion: 0,
|
||||||
entityRatingSupportByServer: {},
|
entityRatingSupportByServer: {},
|
||||||
|
audiomuseNavidromeByServer: {},
|
||||||
|
subsonicServerIdentityByServer: {},
|
||||||
|
audiomuseNavidromeIssueByServer: {},
|
||||||
|
instantMixProbeByServer: {},
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
connectionError: null,
|
connectionError: null,
|
||||||
@@ -272,11 +302,19 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const newServers = s.servers.filter(srv => srv.id !== id);
|
const newServers = s.servers.filter(srv => srv.id !== id);
|
||||||
const switchedAway = s.activeServerId === id;
|
const switchedAway = s.activeServerId === id;
|
||||||
const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer;
|
const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer;
|
||||||
|
const { [id]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
|
||||||
|
const { [id]: _idn, ...identityRest } = s.subsonicServerIdentityByServer;
|
||||||
|
const { [id]: _iss, ...issueRest } = s.audiomuseNavidromeIssueByServer;
|
||||||
|
const { [id]: _pr, ...probeRest } = s.instantMixProbeByServer;
|
||||||
return {
|
return {
|
||||||
servers: newServers,
|
servers: newServers,
|
||||||
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
|
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
|
||||||
isLoggedIn: switchedAway ? false : s.isLoggedIn,
|
isLoggedIn: switchedAway ? false : s.isLoggedIn,
|
||||||
entityRatingSupportByServer: entityRatingRest,
|
entityRatingSupportByServer: entityRatingRest,
|
||||||
|
audiomuseNavidromeByServer: audiomuseRest,
|
||||||
|
subsonicServerIdentityByServer: identityRest,
|
||||||
|
audiomuseNavidromeIssueByServer: issueRest,
|
||||||
|
instantMixProbeByServer: probeRest,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -403,6 +441,60 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level },
|
entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
setAudiomuseNavidromeEnabled: (serverId, enabled) =>
|
||||||
|
set(s => {
|
||||||
|
const audiomuseNavidromeByServer = enabled
|
||||||
|
? { ...s.audiomuseNavidromeByServer, [serverId]: true }
|
||||||
|
: (() => {
|
||||||
|
const { [serverId]: _removed, ...rest } = s.audiomuseNavidromeByServer;
|
||||||
|
return rest;
|
||||||
|
})();
|
||||||
|
const { [serverId]: _issueRm, ...issueRest } = s.audiomuseNavidromeIssueByServer;
|
||||||
|
return { audiomuseNavidromeByServer, audiomuseNavidromeIssueByServer: issueRest };
|
||||||
|
}),
|
||||||
|
|
||||||
|
setSubsonicServerIdentity: (serverId, identity) =>
|
||||||
|
set(s => {
|
||||||
|
const subsonicServerIdentityByServer = { ...s.subsonicServerIdentityByServer, [serverId]: { ...identity } };
|
||||||
|
if (!isNavidromeAudiomuseSoftwareEligible(identity)) {
|
||||||
|
const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
|
||||||
|
const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer;
|
||||||
|
const { [serverId]: _p, ...probeRest } = s.instantMixProbeByServer;
|
||||||
|
return {
|
||||||
|
subsonicServerIdentityByServer,
|
||||||
|
audiomuseNavidromeByServer: audiomuseRest,
|
||||||
|
audiomuseNavidromeIssueByServer: issueRest,
|
||||||
|
instantMixProbeByServer: probeRest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { subsonicServerIdentityByServer };
|
||||||
|
}),
|
||||||
|
|
||||||
|
setInstantMixProbe: (serverId, result) =>
|
||||||
|
set(s => {
|
||||||
|
const instantMixProbeByServer = { ...s.instantMixProbeByServer, [serverId]: result };
|
||||||
|
if (result === 'empty') {
|
||||||
|
const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
|
||||||
|
const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer;
|
||||||
|
return {
|
||||||
|
instantMixProbeByServer,
|
||||||
|
audiomuseNavidromeByServer: audiomuseRest,
|
||||||
|
audiomuseNavidromeIssueByServer: issueRest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { instantMixProbeByServer };
|
||||||
|
}),
|
||||||
|
|
||||||
|
setAudiomuseNavidromeIssue: (serverId, hasIssue) =>
|
||||||
|
set(s =>
|
||||||
|
hasIssue
|
||||||
|
? { audiomuseNavidromeIssueByServer: { ...s.audiomuseNavidromeIssueByServer, [serverId]: true } }
|
||||||
|
: (() => {
|
||||||
|
const { [serverId]: _rm, ...rest } = s.audiomuseNavidromeIssueByServer;
|
||||||
|
return { audiomuseNavidromeIssueByServer: rest };
|
||||||
|
})(),
|
||||||
|
),
|
||||||
|
|
||||||
logout: () => set({ isLoggedIn: false, musicFolders: [] }),
|
logout: () => set({ isLoggedIn: false, musicFolders: [] }),
|
||||||
|
|
||||||
getBaseUrl: () => {
|
getBaseUrl: () => {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/** Fields from Subsonic `ping` / any `subsonic-response` root (Navidrome sets type + serverVersion). */
|
||||||
|
export type SubsonicServerIdentity = {
|
||||||
|
type?: string;
|
||||||
|
serverVersion?: string;
|
||||||
|
openSubsonic?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Result of `getRandomSongs` + `getSimilarSongs` probe (Instant Mix / agent chain). */
|
||||||
|
export type InstantMixProbeResult = 'ok' | 'empty' | 'error' | 'skipped';
|
||||||
|
|
||||||
|
const NAVIDROME_MIN_FOR_PLUGINS: [number, number, number] = [0, 60, 0];
|
||||||
|
|
||||||
|
function parseLeadingSemver(version: string | undefined): [number, number, number] | null {
|
||||||
|
if (!version) return null;
|
||||||
|
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
|
||||||
|
if (!m) return null;
|
||||||
|
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function semverGte(a: [number, number, number], b: [number, number, number]): boolean {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (a[i] > b[i]) return true;
|
||||||
|
if (a[i] < b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navidrome version from ping supports the plugin system (≥ 0.60). Unknown `type` stays permissive
|
||||||
|
* until the first successful ping with metadata.
|
||||||
|
*/
|
||||||
|
export function isNavidromeAudiomuseSoftwareEligible(identity: SubsonicServerIdentity | undefined): boolean {
|
||||||
|
if (!identity?.type?.trim()) return true;
|
||||||
|
const t = identity.type.trim().toLowerCase();
|
||||||
|
if (t !== 'navidrome') return false;
|
||||||
|
const parsed = parseLeadingSemver(identity.serverVersion);
|
||||||
|
if (!parsed) return true;
|
||||||
|
return semverGte(parsed, NAVIDROME_MIN_FOR_PLUGINS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the per-server AudioMuse (Navidrome plugin) toggle in Settings.
|
||||||
|
* Uses software eligibility from ping plus an optional Instant Mix probe: if the server returns no
|
||||||
|
* similar tracks for several random songs, the row stays hidden (typical when no plugin / no agents).
|
||||||
|
*/
|
||||||
|
export function showAudiomuseNavidromeServerSetting(
|
||||||
|
identity: SubsonicServerIdentity | undefined,
|
||||||
|
instantMixProbe: InstantMixProbeResult | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!isNavidromeAudiomuseSoftwareEligible(identity)) return false;
|
||||||
|
if (instantMixProbe === 'empty') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user