Files
psysonic/src/api/navidromeAdmin.ts
T
Frank Stellmacher a41e3a624a feat(song-info): show absolute file path on Navidrome via native API (#504)
* feat(song-info): show absolute file path on Navidrome via native API

Subsonic's `getSong.view` returns at most a relative path (or none on
Navidrome), so the Path row in the Song Info modal stayed empty for
most users. Feishin and the Navidrome web client surface the full
server-side path by hitting Navidrome's native `/api/song/{id}` instead.

Added an `nd_get_song_path` Tauri command that logs in to the native
API with the active server's credentials, fetches the song, and returns
the `path` string. Wired it into `SongInfoModal`: the Subsonic
`getSong` call still drives the rest of the dialog, and the native call
runs in parallel only when the active server's identity is
"navidrome". When it returns a path, that absolute path replaces the
relative Subsonic value; native-API failures are silent and the modal
falls back to whatever Subsonic provided.

No token cache yet — the modal is opened occasionally enough that one
fresh login per call is fine.

Closes discussion #479.

* docs: changelog entry for PR #504

Logs the Navidrome native-API absolute file path support for the
Song Info dialog in v1.46.0 "## Added".

* docs: settings contributor entry for PR #504, drop competitor mention

Adds the song-info absolute path bullet to Psychotoxical's contributors
list in Settings, and rewords the existing CHANGELOG entry so neither
text references competing clients.
2026-05-07 20:22:55 +02:00

134 lines
4.1 KiB
TypeScript

import { invoke } from '@tauri-apps/api/core';
export interface NdLibrary {
id: number;
name: string;
}
export interface NdUser {
id: string;
userName: string;
name: string;
email: string;
isAdmin: boolean;
libraryIds: number[];
lastLoginAt?: string | null;
lastAccessAt?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface NdLoginResult {
token: string;
userId: string;
isAdmin: boolean;
}
export async function ndLogin(
serverUrl: string,
username: string,
password: string,
): Promise<NdLoginResult> {
return invoke<NdLoginResult>('navidrome_login', { serverUrl, username, password });
}
function extractLibraryIds(o: Record<string, unknown>): number[] {
const libs = o.libraries;
if (!Array.isArray(libs)) return [];
const ids: number[] = [];
for (const l of libs) {
const id = (l as Record<string, unknown>)?.id;
if (typeof id === 'number') ids.push(id);
else if (typeof id === 'string' && /^\d+$/.test(id)) ids.push(Number(id));
}
return ids;
}
export async function ndListUsers(serverUrl: string, token: string): Promise<NdUser[]> {
const raw = await invoke<unknown>('nd_list_users', { serverUrl, token });
if (!Array.isArray(raw)) return [];
return raw.map(u => {
const o = u as Record<string, unknown>;
return {
id: String(o.id ?? ''),
userName: String(o.userName ?? ''),
name: String(o.name ?? ''),
email: String(o.email ?? ''),
isAdmin: !!o.isAdmin,
libraryIds: extractLibraryIds(o),
lastLoginAt: (o.lastLoginAt as string | null | undefined) ?? null,
lastAccessAt: (o.lastAccessAt as string | null | undefined) ?? null,
createdAt: o.createdAt as string | undefined,
updatedAt: o.updatedAt as string | undefined,
};
});
}
export async function ndListLibraries(serverUrl: string, token: string): Promise<NdLibrary[]> {
const raw = await invoke<unknown>('nd_list_libraries', { serverUrl, token });
if (!Array.isArray(raw)) return [];
return raw.map(l => {
const o = l as Record<string, unknown>;
const id = typeof o.id === 'number'
? o.id
: typeof o.id === 'string' && /^\d+$/.test(o.id) ? Number(o.id) : 0;
return { id, name: String(o.name ?? '') };
}).filter(l => l.id > 0);
}
export async function ndSetUserLibraries(
serverUrl: string,
token: string,
id: string,
libraryIds: number[],
): Promise<void> {
await invoke('nd_set_user_libraries', { serverUrl, token, id, libraryIds });
}
export async function ndCreateUser(
serverUrl: string,
token: string,
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
): Promise<{ id: string }> {
const raw = await invoke<unknown>('nd_create_user', { serverUrl, token, ...data });
const o = (raw as Record<string, unknown> | null) ?? {};
return { id: String(o.id ?? '') };
}
export async function ndUpdateUser(
serverUrl: string,
token: string,
id: string,
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
): Promise<void> {
await invoke('nd_update_user', { serverUrl, token, id, ...data });
}
export async function ndDeleteUser(serverUrl: string, token: string, id: string): Promise<void> {
await invoke('nd_delete_user', { serverUrl, token, id });
}
/**
* Fetch the absolute filesystem path of a song from Navidrome's native API.
* Subsonic `getSong.view` only returns a relative path (or none on Navidrome);
* the native `/api/song/:id` endpoint returns the absolute path the server
* stores the file at, the same way Feishin and the Navidrome web client get it.
*
* Returns `null` when the server doesn't expose the field (non-admin on some
* Navidrome configurations) or when the call fails — the Song Info modal then
* falls back to whatever the Subsonic response had.
*/
export async function ndGetSongPath(
serverUrl: string,
username: string,
password: string,
id: string,
): Promise<string | null> {
try {
const raw = await invoke<string | null>('nd_get_song_path', { serverUrl, username, password, id });
return raw && raw.length > 0 ? raw : null;
} catch {
return null;
}
}