mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 13:00:13 +02:00
5d4547080d
* add option to force remote url for api calls * force remote url for discord rpc image
552 lines
16 KiB
TypeScript
552 lines
16 KiB
TypeScript
import { initClient, initContract } from '@ts-rest/core';
|
|
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, isAxiosError } from 'axios';
|
|
import qs from 'qs';
|
|
import { z } from 'zod';
|
|
|
|
import i18n from '/@/i18n/i18n';
|
|
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
|
import { ssType } from '/@/shared/api/subsonic/subsonic-types';
|
|
import { hasFeature } from '/@/shared/api/utils';
|
|
import { toast } from '/@/shared/components/toast/toast';
|
|
import { ServerListItemWithCredential } from '/@/shared/types/domain-types';
|
|
import { ServerFeature } from '/@/shared/types/features-types';
|
|
|
|
const c = initContract();
|
|
|
|
export const contract = c.router({
|
|
authenticate: {
|
|
method: 'GET',
|
|
path: 'getUser.view',
|
|
query: ssType._parameters.authenticate,
|
|
responses: {
|
|
200: ssType._response.authenticate,
|
|
},
|
|
},
|
|
createFavorite: {
|
|
method: 'GET',
|
|
path: 'star.view',
|
|
query: ssType._parameters.createFavorite,
|
|
responses: {
|
|
200: ssType._response.createFavorite,
|
|
},
|
|
},
|
|
createInternetRadioStation: {
|
|
method: 'GET',
|
|
path: 'createInternetRadioStation.view',
|
|
query: ssType._parameters.createInternetRadioStation,
|
|
responses: {
|
|
200: ssType._response.createInternetRadioStation,
|
|
},
|
|
},
|
|
createPlaylist: {
|
|
method: 'GET',
|
|
path: 'createPlaylist.view',
|
|
query: ssType._parameters.createPlaylist,
|
|
responses: {
|
|
200: ssType._response.createPlaylist,
|
|
},
|
|
},
|
|
deleteInternetRadioStation: {
|
|
method: 'GET',
|
|
path: 'deleteInternetRadioStation.view',
|
|
query: ssType._parameters.deleteInternetRadioStation,
|
|
responses: {
|
|
200: ssType._response.deleteInternetRadioStation,
|
|
},
|
|
},
|
|
deletePlaylist: {
|
|
method: 'GET',
|
|
path: 'deletePlaylist.view',
|
|
query: ssType._parameters.deletePlaylist,
|
|
responses: {
|
|
200: ssType._response.baseResponse,
|
|
},
|
|
},
|
|
getAlbum: {
|
|
method: 'GET',
|
|
path: 'getAlbum.view',
|
|
query: ssType._parameters.getAlbum,
|
|
responses: {
|
|
200: ssType._response.getAlbum,
|
|
},
|
|
},
|
|
getAlbumInfo2: {
|
|
method: 'GET',
|
|
path: 'getAlbumInfo2.view',
|
|
query: ssType._parameters.albumInfo,
|
|
responses: {
|
|
200: ssType._response.albumInfo,
|
|
},
|
|
},
|
|
getAlbumList2: {
|
|
method: 'GET',
|
|
path: 'getAlbumList2.view',
|
|
query: ssType._parameters.getAlbumList2,
|
|
responses: {
|
|
200: ssType._response.getAlbumList2,
|
|
},
|
|
},
|
|
getArtist: {
|
|
method: 'GET',
|
|
path: 'getArtist.view',
|
|
query: ssType._parameters.getArtist,
|
|
responses: {
|
|
200: ssType._response.getArtist,
|
|
},
|
|
},
|
|
getArtistInfo: {
|
|
method: 'GET',
|
|
path: 'getArtistInfo.view',
|
|
query: ssType._parameters.artistInfo,
|
|
responses: {
|
|
200: ssType._response.artistInfo,
|
|
},
|
|
},
|
|
getArtists: {
|
|
method: 'GET',
|
|
path: 'getArtists.view',
|
|
query: ssType._parameters.getArtists,
|
|
responses: {
|
|
200: ssType._response.getArtists,
|
|
},
|
|
},
|
|
getGenres: {
|
|
method: 'GET',
|
|
path: 'getGenres.view',
|
|
query: ssType._parameters.getGenres,
|
|
responses: {
|
|
200: ssType._response.getGenres,
|
|
},
|
|
},
|
|
getIndexes: {
|
|
method: 'GET',
|
|
path: 'getIndexes.view',
|
|
query: ssType._parameters.getIndexes,
|
|
responses: {
|
|
200: ssType._response.getIndexes,
|
|
},
|
|
},
|
|
getInternetRadioStations: {
|
|
method: 'GET',
|
|
path: 'getInternetRadioStations.view',
|
|
responses: {
|
|
200: ssType._response.getInternetRadioStations,
|
|
},
|
|
},
|
|
getMusicDirectory: {
|
|
method: 'GET',
|
|
path: 'getMusicDirectory.view',
|
|
query: ssType._parameters.getMusicDirectory,
|
|
responses: {
|
|
200: ssType._response.getMusicDirectory,
|
|
},
|
|
},
|
|
getMusicFolderList: {
|
|
method: 'GET',
|
|
path: 'getMusicFolders.view',
|
|
responses: {
|
|
200: ssType._response.musicFolderList,
|
|
},
|
|
},
|
|
getPlaylist: {
|
|
method: 'GET',
|
|
path: 'getPlaylist.view',
|
|
query: ssType._parameters.getPlaylist,
|
|
responses: {
|
|
200: ssType._response.getPlaylist,
|
|
},
|
|
},
|
|
getPlaylists: {
|
|
method: 'GET',
|
|
path: 'getPlaylists.view',
|
|
query: ssType._parameters.getPlaylists,
|
|
responses: {
|
|
200: ssType._response.getPlaylists,
|
|
},
|
|
},
|
|
getPlayQueue: {
|
|
method: 'GET',
|
|
path: 'getPlayQueue.view',
|
|
responses: {
|
|
200: ssType._response.playQueue,
|
|
},
|
|
},
|
|
getPlayQueueByIndex: {
|
|
method: 'GET',
|
|
path: 'getPlayQueueByIndex.view',
|
|
responses: {
|
|
200: ssType._response.playQueueByIndex,
|
|
},
|
|
},
|
|
getRandomSongList: {
|
|
method: 'GET',
|
|
path: 'getRandomSongs.view',
|
|
query: ssType._parameters.randomSongList,
|
|
responses: {
|
|
200: ssType._response.randomSongList,
|
|
},
|
|
},
|
|
getServerInfo: {
|
|
method: 'GET',
|
|
path: 'getOpenSubsonicExtensions.view',
|
|
responses: {
|
|
200: ssType._response.serverInfo,
|
|
},
|
|
},
|
|
getSimilarSongs: {
|
|
method: 'GET',
|
|
path: 'getSimilarSongs',
|
|
query: ssType._parameters.similarSongs,
|
|
responses: {
|
|
200: ssType._response.similarSongs,
|
|
},
|
|
},
|
|
getSimilarSongs2: {
|
|
method: 'GET',
|
|
path: 'getSimilarSongs2',
|
|
query: ssType._parameters.similarSongs2,
|
|
responses: {
|
|
200: ssType._response.similarSongs2,
|
|
},
|
|
},
|
|
getSong: {
|
|
method: 'GET',
|
|
path: 'getSong.view',
|
|
query: ssType._parameters.getSong,
|
|
responses: {
|
|
200: ssType._response.getSong,
|
|
},
|
|
},
|
|
getSongsByGenre: {
|
|
method: 'GET',
|
|
path: 'getSongsByGenre.view',
|
|
query: ssType._parameters.getSongsByGenre,
|
|
responses: {
|
|
200: ssType._response.getSongsByGenre,
|
|
},
|
|
},
|
|
getStarred: {
|
|
method: 'GET',
|
|
path: 'getStarred.view',
|
|
query: ssType._parameters.getStarred,
|
|
responses: {
|
|
200: ssType._response.getStarred,
|
|
},
|
|
},
|
|
getStructuredLyrics: {
|
|
method: 'GET',
|
|
path: 'getLyricsBySongId.view',
|
|
query: ssType._parameters.structuredLyrics,
|
|
responses: {
|
|
200: ssType._response.structuredLyrics,
|
|
},
|
|
},
|
|
getTopSongsList: {
|
|
method: 'GET',
|
|
path: 'getTopSongs.view',
|
|
query: ssType._parameters.topSongsList,
|
|
responses: {
|
|
200: ssType._response.topSongsList,
|
|
},
|
|
},
|
|
getTranscodeDecision: {
|
|
body: ssType._body.getTranscodeDecision,
|
|
method: 'POST',
|
|
path: 'getTranscodeDecision.view',
|
|
query: ssType._parameters.getTranscodeDecision,
|
|
responses: {
|
|
200: ssType._response.getTranscodeDecision,
|
|
},
|
|
},
|
|
getTranscodeStream: {
|
|
method: 'GET',
|
|
path: 'getTranscodeStream.view',
|
|
query: ssType._parameters.getTranscodeStream,
|
|
responses: {
|
|
200: z.string(),
|
|
},
|
|
},
|
|
getUser: {
|
|
method: 'GET',
|
|
path: 'getUser.view',
|
|
query: ssType._parameters.user,
|
|
responses: {
|
|
200: ssType._response.user,
|
|
},
|
|
},
|
|
ping: {
|
|
method: 'GET',
|
|
path: 'ping.view',
|
|
responses: {
|
|
200: ssType._response.ping,
|
|
},
|
|
},
|
|
removeFavorite: {
|
|
method: 'GET',
|
|
path: 'unstar.view',
|
|
query: ssType._parameters.removeFavorite,
|
|
responses: {
|
|
200: ssType._response.removeFavorite,
|
|
},
|
|
},
|
|
savePlayQueue: {
|
|
method: 'GET',
|
|
path: 'savePlayQueue.view',
|
|
query: ssType._parameters.saveQueue,
|
|
responses: {
|
|
200: ssType._response.saveQueue,
|
|
},
|
|
},
|
|
savePlayQueueByIndex: {
|
|
method: 'GET',
|
|
path: 'savePlayQueueByIndex.view',
|
|
query: ssType._parameters.savePlayQueueByIndex,
|
|
responses: {
|
|
200: ssType._response.saveQueue,
|
|
},
|
|
},
|
|
scrobble: {
|
|
method: 'GET',
|
|
path: 'scrobble.view',
|
|
query: ssType._parameters.scrobble,
|
|
responses: {
|
|
200: ssType._response.scrobble,
|
|
},
|
|
},
|
|
search3: {
|
|
method: 'GET',
|
|
path: 'search3.view',
|
|
query: ssType._parameters.search3,
|
|
responses: {
|
|
200: ssType._response.search3,
|
|
},
|
|
},
|
|
setRating: {
|
|
method: 'GET',
|
|
path: 'setRating.view',
|
|
query: ssType._parameters.setRating,
|
|
responses: {
|
|
200: ssType._response.setRating,
|
|
},
|
|
},
|
|
updateInternetRadioStation: {
|
|
method: 'GET',
|
|
path: 'updateInternetRadioStation.view',
|
|
query: ssType._parameters.updateInternetRadioStation,
|
|
responses: {
|
|
200: ssType._response.updateInternetRadioStation,
|
|
},
|
|
},
|
|
updatePlaylist: {
|
|
method: 'GET',
|
|
path: 'updatePlaylist.view',
|
|
query: ssType._parameters.updatePlaylist,
|
|
responses: {
|
|
200: ssType._response.baseResponse,
|
|
},
|
|
},
|
|
});
|
|
|
|
const axiosClient = axios.create({});
|
|
|
|
axiosClient.defaults.paramsSerializer = (params) => {
|
|
return qs.stringify(params, { arrayFormat: 'repeat' });
|
|
};
|
|
|
|
axiosClient.interceptors.response.use(
|
|
(response) => {
|
|
const data = response.data;
|
|
if (data['subsonic-response'].status !== 'ok') {
|
|
// Suppress code related to non-linked lastfm or spotify from Navidrome
|
|
if (data['subsonic-response'].error.code !== 0) {
|
|
toast.error({
|
|
message: data['subsonic-response'].error.message,
|
|
title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string,
|
|
});
|
|
|
|
// Since we do status === 200, override this value with the error code
|
|
response.status = data['subsonic-response'].error.code;
|
|
}
|
|
}
|
|
|
|
return response;
|
|
},
|
|
(error) => {
|
|
return Promise.reject(error);
|
|
},
|
|
);
|
|
|
|
const keysToSkipEmptyCheck = new Set([
|
|
'artist',
|
|
'comment',
|
|
'genre',
|
|
'name',
|
|
'query',
|
|
'u',
|
|
'username',
|
|
]);
|
|
|
|
const parsePath = (fullPath: string) => {
|
|
const [path, params] = fullPath.split('?');
|
|
|
|
const url = new URLSearchParams(params);
|
|
const notNilParams: Record<string, string[]> = {};
|
|
|
|
for (const [key, value] of url) {
|
|
if (!keysToSkipEmptyCheck.has(key) && (value === 'undefined' || value === 'null')) {
|
|
continue;
|
|
}
|
|
|
|
let realKey = key;
|
|
|
|
if (key.includes('[') && key.includes(']')) {
|
|
realKey = key.split('[')[0];
|
|
}
|
|
|
|
if (realKey in notNilParams) {
|
|
notNilParams[realKey].push(value);
|
|
} else {
|
|
notNilParams[realKey] = [value];
|
|
}
|
|
}
|
|
|
|
return {
|
|
params: notNilParams,
|
|
path,
|
|
};
|
|
};
|
|
|
|
const silentlyTransformResponse = (data: any) => {
|
|
const jsonBody = JSON.parse(data);
|
|
const status = jsonBody ? jsonBody['subsonic-response']?.status : undefined;
|
|
|
|
if (status && status !== 'ok') {
|
|
jsonBody['subsonic-response'].error.code = 0;
|
|
}
|
|
|
|
return jsonBody;
|
|
};
|
|
|
|
export const ssApiClient = (args: {
|
|
forceRemoteUrl?: boolean;
|
|
server: null | ServerListItemWithCredential;
|
|
signal?: AbortSignal;
|
|
silent?: boolean;
|
|
url?: string;
|
|
}) => {
|
|
const { forceRemoteUrl, server, signal, silent, url } = args;
|
|
|
|
return initClient(contract, {
|
|
api: async ({ body, headers, method, path, rawQuery }) => {
|
|
let baseUrl: string | undefined;
|
|
const authParams: Record<string, any> = {};
|
|
|
|
const { params, path: api } = parsePath(path);
|
|
|
|
if (server) {
|
|
const serverUrl = getServerUrl(server, forceRemoteUrl);
|
|
baseUrl = serverUrl ? `${serverUrl}/rest` : undefined;
|
|
const token = server.credential;
|
|
const params = token.split(/&?\w=/gm);
|
|
|
|
authParams.u = decodeURIComponent(server.username);
|
|
if (params?.length === 4) {
|
|
authParams.s = params[2];
|
|
authParams.t = params[3];
|
|
} else if (params?.length === 3) {
|
|
authParams.p = decodeURIComponent(params[2]);
|
|
}
|
|
} else {
|
|
baseUrl = url;
|
|
}
|
|
|
|
const request: AxiosRequestConfig = {
|
|
headers,
|
|
signal,
|
|
// In cases where we have a fallback, don't notify the error
|
|
transformResponse: silent ? silentlyTransformResponse : undefined,
|
|
url: `${baseUrl}/${api}`,
|
|
};
|
|
|
|
const isGetTranscodeDecisionPost =
|
|
method === 'POST' && api === 'getTranscodeDecision.view';
|
|
|
|
if (isGetTranscodeDecisionPost && body != null) {
|
|
request.method = 'POST';
|
|
request.headers = {
|
|
...headers,
|
|
'Content-Type': 'application/json',
|
|
};
|
|
request.data = body;
|
|
request.params = {
|
|
c: 'Feishin',
|
|
f: 'json',
|
|
v: '1.13.0',
|
|
...authParams,
|
|
...(typeof rawQuery === 'object' && rawQuery !== null
|
|
? (rawQuery as Record<string, unknown>)
|
|
: {}),
|
|
};
|
|
} else if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
|
|
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
request.method = 'POST';
|
|
const data = {
|
|
c: 'Feishin',
|
|
f: 'json',
|
|
v: '1.13.0',
|
|
...authParams,
|
|
...params,
|
|
};
|
|
request.data = qs.stringify(data, { arrayFormat: 'repeat' });
|
|
} else {
|
|
const data = {
|
|
c: 'Feishin',
|
|
f: 'json',
|
|
v: '1.13.0',
|
|
...authParams,
|
|
...params,
|
|
};
|
|
request.method = method;
|
|
request.params = data;
|
|
}
|
|
|
|
try {
|
|
const result =
|
|
await axiosClient.request<z.infer<typeof ssType._response.baseResponse>>(
|
|
request,
|
|
);
|
|
|
|
return {
|
|
body: result.data['subsonic-response'],
|
|
headers: result.headers as any,
|
|
status: result.status,
|
|
};
|
|
} catch (e: any | AxiosError | Error) {
|
|
if (isAxiosError(e)) {
|
|
if (e.code === 'ERR_NETWORK') {
|
|
throw new Error(
|
|
i18n.t('error.networkError', {
|
|
postProcess: 'sentenceCase',
|
|
}) as string,
|
|
);
|
|
}
|
|
|
|
const error = e as AxiosError;
|
|
const response = error.response as AxiosResponse;
|
|
|
|
return {
|
|
body: response?.data,
|
|
headers: response?.headers as any,
|
|
status: response?.status,
|
|
};
|
|
}
|
|
throw e;
|
|
}
|
|
},
|
|
baseHeaders: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
baseUrl: '',
|
|
});
|
|
};
|