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 = {}; 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 = {}; 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) : {}), }; } 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>( 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: '', }); };