mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
import axios from 'axios';
|
|
import { getLuckyMixLibraryScopeOverride } from '@/lib/library/luckyMixScopeOverride';
|
|
import md5 from 'md5';
|
|
import { version } from '@/../package.json';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
import type { ServerProfile } from '@/store/authStoreTypes';
|
|
import { connectBaseUrlForServer } from '@/lib/server/serverEndpoint';
|
|
import { headersForServerRequest } from '@/lib/server/serverHttpHeaders';
|
|
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
|
|
|
|
export const SUBSONIC_CLIENT = `psysonic/${version}`;
|
|
|
|
/** Subset of `ServerProfile` needed to attach gate headers on credential-based REST calls. */
|
|
export type ServerHttpHeaderProfile = Pick<
|
|
ServerProfile,
|
|
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
|
|
>;
|
|
|
|
export function secureRandomSalt(): string {
|
|
const buf = new Uint8Array(8);
|
|
crypto.getRandomValues(buf);
|
|
return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
export function getAuthParams(username: string, password: string) {
|
|
const salt = secureRandomSalt();
|
|
const token = md5(password + salt);
|
|
return { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' };
|
|
}
|
|
|
|
export function restBaseFromUrl(serverUrl: string): string {
|
|
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
|
|
return `${base}/rest`;
|
|
}
|
|
|
|
/**
|
|
* Encode Subsonic REST params the same way axios does with `paramsSerializer: { indexes: null }`
|
|
* (repeated keys for arrays: `id=a&id=b`). Used for OpenSubsonic form POST bodies.
|
|
*/
|
|
export function serializeSubsonicParams(params: Record<string, unknown>): string {
|
|
const parts: string[] = [];
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value === undefined || value === null) continue;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
if (item === undefined || item === null) continue;
|
|
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(item))}`);
|
|
}
|
|
continue;
|
|
}
|
|
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
}
|
|
return parts.join('&');
|
|
}
|
|
|
|
function parseSubsonicResponse<T>(respData: unknown): T {
|
|
const data = (respData as { ['subsonic-response']?: { status?: string; error?: { message?: string } } })?.[
|
|
'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;
|
|
}
|
|
|
|
/** True when a reverse proxy / server rejected the request because the URI was too long. */
|
|
export function isHttp414(err: unknown): boolean {
|
|
if (err && typeof err === 'object' && 'response' in err) {
|
|
const status = (err as { response?: { status?: number } }).response?.status;
|
|
if (status === 414) return true;
|
|
}
|
|
if (err instanceof Error) {
|
|
const msg = err.message.toLowerCase();
|
|
return msg.includes('414') || msg.includes('uri too long') || msg.includes('request-uri too large');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function apiWithCredentials<T>(
|
|
serverUrl: string,
|
|
username: string,
|
|
password: string,
|
|
endpoint: string,
|
|
extra: Record<string, unknown> = {},
|
|
timeout = 15000,
|
|
headerProfile?: ServerHttpHeaderProfile,
|
|
): Promise<T> {
|
|
const params = { ...getAuthParams(username, password), ...extra };
|
|
const headers = headerProfile ? headersForServerRequest(headerProfile, serverUrl) : {};
|
|
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
|
|
params,
|
|
headers,
|
|
paramsSerializer: { indexes: null },
|
|
timeout,
|
|
});
|
|
return parseSubsonicResponse<T>(resp.data);
|
|
}
|
|
|
|
/**
|
|
* OpenSubsonic `formPost`: send all API args in an `application/x-www-form-urlencoded` body
|
|
* (path-only URL - no query string) so large multi-`id` calls avoid HTTP 414.
|
|
*/
|
|
export async function apiPostFormWithCredentials<T>(
|
|
serverUrl: string,
|
|
username: string,
|
|
password: string,
|
|
endpoint: string,
|
|
extra: Record<string, unknown> = {},
|
|
timeout = 15000,
|
|
headerProfile?: ServerHttpHeaderProfile,
|
|
): Promise<T> {
|
|
const params = { ...getAuthParams(username, password), ...extra };
|
|
const headers = {
|
|
...(headerProfile ? headersForServerRequest(headerProfile, serverUrl) : {}),
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
};
|
|
const resp = await axios.post(
|
|
`${restBaseFromUrl(serverUrl)}/${endpoint}`,
|
|
serializeSubsonicParams(params),
|
|
{ headers, timeout },
|
|
);
|
|
return parseSubsonicResponse<T>(resp.data);
|
|
}
|
|
|
|
export function getClient() {
|
|
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
|
const server = getActiveServer();
|
|
const baseUrl = getBaseUrl();
|
|
if (!baseUrl) throw new Error('No server configured');
|
|
const params = getAuthParams(server?.username ?? '', server?.password ?? '');
|
|
return { baseUrl: `${baseUrl}/rest`, params };
|
|
}
|
|
|
|
export function getServerById(serverId: string): ServerProfile | undefined {
|
|
return findServerByIdOrIndexKey(serverId);
|
|
}
|
|
|
|
/** True when the server advertises the OpenSubsonic `formPost` extension. */
|
|
export function serverSupportsFormPost(serverId: string): boolean {
|
|
const exts = useAuthStore.getState().openSubsonicExtensionsByServer[serverId] ?? [];
|
|
return exts.includes('formPost');
|
|
}
|
|
|
|
/** Subsonic REST call against an explicit saved server (not necessarily the active one). */
|
|
export async function apiForServer<T>(
|
|
serverId: string,
|
|
endpoint: string,
|
|
extra: Record<string, unknown> = {},
|
|
timeout = 15000,
|
|
): Promise<T> {
|
|
const server = getServerById(serverId);
|
|
if (!server) throw new Error(`Unknown server: ${serverId}`);
|
|
// Dual-address: route through the cached connect URL when one has been
|
|
// probed for this profile; otherwise the normalized primary url is the
|
|
// same string the legacy code path used, so single-address profiles are
|
|
// byte-identical to before.
|
|
return apiWithCredentials(
|
|
connectBaseUrlForServer(server),
|
|
server.username,
|
|
server.password,
|
|
endpoint,
|
|
extra,
|
|
timeout,
|
|
server,
|
|
);
|
|
}
|
|
|
|
/** Form-POST variant of `apiForServer` (OpenSubsonic `formPost`). */
|
|
export async function apiPostFormForServer<T>(
|
|
serverId: string,
|
|
endpoint: string,
|
|
extra: Record<string, unknown> = {},
|
|
timeout = 15000,
|
|
): Promise<T> {
|
|
const server = getServerById(serverId);
|
|
if (!server) throw new Error(`Unknown server: ${serverId}`);
|
|
return apiPostFormWithCredentials(
|
|
connectBaseUrlForServer(server),
|
|
server.username,
|
|
server.password,
|
|
endpoint,
|
|
extra,
|
|
timeout,
|
|
server,
|
|
);
|
|
}
|
|
|
|
export async function api<T>(
|
|
endpoint: string,
|
|
extra: Record<string, unknown> = {},
|
|
timeout = 15000,
|
|
signal?: AbortSignal,
|
|
): Promise<T> {
|
|
const { baseUrl, params } = getClient();
|
|
const server = useAuthStore.getState().getActiveServer();
|
|
const connectBase = useAuthStore.getState().getBaseUrl();
|
|
const headers =
|
|
server && connectBase ? headersForServerRequest(server, connectBase) : {};
|
|
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
|
|
params: { ...params, ...extra },
|
|
headers,
|
|
paramsSerializer: { indexes: null },
|
|
timeout,
|
|
signal,
|
|
});
|
|
return parseSubsonicResponse<T>(resp.data);
|
|
}
|
|
|
|
/** Optional `musicFolderId` when the user narrowed browsing to one Subsonic library (see `getMusicFolders`). */
|
|
export function libraryFilterParams(): Record<string, string | number | string[]> {
|
|
const { activeServerId } = useAuthStore.getState();
|
|
return activeServerId ? libraryFilterParamsForServer(activeServerId) : {};
|
|
}
|
|
|
|
type AuthSnapshot = ReturnType<typeof useAuthStore.getState>;
|
|
|
|
function rawLibrarySelection(state: AuthSnapshot, resolved: string): string[] {
|
|
const selection = state.musicLibrarySelectionByServer[resolved];
|
|
if (selection !== undefined) return selection;
|
|
const legacy = state.musicLibraryFilterByServer[resolved];
|
|
if (legacy === undefined || legacy === 'all') return [];
|
|
return [legacy];
|
|
}
|
|
|
|
/**
|
|
* True when `selection` already covers every library of the active server, so it
|
|
* is equivalent to "All libraries". Only checked for the active server, since
|
|
* `musicFolders` is the folder list of that server.
|
|
*/
|
|
function selectionCoversAllLibraries(
|
|
state: AuthSnapshot,
|
|
resolved: string,
|
|
selection: string[],
|
|
): boolean {
|
|
if (resolved !== state.activeServerId) return false;
|
|
const folders = state.musicFolders;
|
|
if (folders.length === 0 || selection.length < folders.length) return false;
|
|
const selected = new Set(selection);
|
|
return folders.every(folder => selected.has(folder.id));
|
|
}
|
|
|
|
/** Ordered library folder ids for a server; empty = all libraries. */
|
|
export function librarySelectionForServer(serverId: string): string[] {
|
|
const resolved = resolveServerIdForIndexKey(serverId);
|
|
const state = useAuthStore.getState();
|
|
const selection = rawLibrarySelection(state, resolved);
|
|
// Selecting every library one-by-one is the same as "All libraries": collapse
|
|
// to the empty/all scope so browse and search take the faster unscoped path
|
|
// (no per-library `IN` filter, no cross-library merge) and share the "all"
|
|
// cache — identical to picking the All-libraries option. The sidebar picker
|
|
// reads raw state, so its per-library checkmarks are unaffected.
|
|
if (selection.length > 0 && selectionCoversAllLibraries(state, resolved, selection)) {
|
|
return [];
|
|
}
|
|
return selection;
|
|
}
|
|
|
|
/** Ordered, resolved library folder ids for Subsonic / local index scope. */
|
|
export function libraryScopesForServer(serverId: string): string[] {
|
|
return librarySelectionForServer(serverId);
|
|
}
|
|
|
|
/** Ordered scope pairs for local index reads — profile `serverId` space; empty when all libraries. */
|
|
export function libraryScopePairsForServer(serverId: string): { serverId: string; libraryId: string }[] {
|
|
return librarySelectionForServer(serverId).map(libraryId => ({ serverId, libraryId }));
|
|
}
|
|
|
|
/** Navidrome/Subsonic music folder id for the local library index, or undefined for all libraries. */
|
|
export function libraryScopeForServer(serverId: string): string | undefined {
|
|
const selection = librarySelectionForServer(serverId);
|
|
return selection.length === 1 ? selection[0] : undefined;
|
|
}
|
|
|
|
/** True when the user narrowed browsing to one or more libraries (not "all"). */
|
|
export function libraryScopeIsActive(serverId: string): boolean {
|
|
return librarySelectionForServer(serverId).length > 0;
|
|
}
|
|
|
|
/** Stable cache-key segment for scoped reads (`all` or comma-joined library ids). */
|
|
export function libraryScopeCacheKeyForServer(serverId: string): string {
|
|
const selection = librarySelectionForServer(serverId);
|
|
if (selection.length === 0) return 'all';
|
|
return selection.join(',');
|
|
}
|
|
|
|
/** Library folder filter for an explicit saved server (e.g. Now Playing while browsing another). */
|
|
export function libraryFilterParamsForServer(
|
|
serverId: string,
|
|
): Record<string, string | number | string[]> {
|
|
const luckyMixScope = getLuckyMixLibraryScopeOverride();
|
|
if (luckyMixScope) return { musicFolderId: luckyMixScope };
|
|
|
|
const scopes = libraryScopesForServer(serverId);
|
|
if (scopes.length === 0) return {};
|
|
if (scopes.length === 1) return { musicFolderId: scopes[0] };
|
|
return { musicFolderId: scopes };
|
|
}
|