Add Subsonic API and types
This commit is contained in:
parent
ea8c63b71b
commit
bec328f1f4
5 changed files with 832 additions and 0 deletions
166
src/renderer/api/subsonic/subsonic-api.ts
Normal file
166
src/renderer/api/subsonic/subsonic-api.ts
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
import { initClient, initContract } from '@ts-rest/core';
|
||||||
|
import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios';
|
||||||
|
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||||
|
import { toast } from '/@/renderer/components';
|
||||||
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
|
|
||||||
|
const c = initContract();
|
||||||
|
|
||||||
|
export const contract = c.router({
|
||||||
|
authenticate: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'ping.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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getArtistInfo: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'getArtistInfo.view',
|
||||||
|
query: ssType._parameters.artistInfo,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.artistInfo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getMusicFolderList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'getMusicFolders.view',
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.musicFolderList,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getTopSongsList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'getTopSongs.view',
|
||||||
|
query: ssType._parameters.topSongsList,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.topSongsList,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
removeFavorite: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'unstar.view',
|
||||||
|
query: ssType._parameters.removeFavorite,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.removeFavorite,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scrobble: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'scrobble.view',
|
||||||
|
query: ssType._parameters.scrobble,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.scrobble,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setRating: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'setRating.view',
|
||||||
|
query: ssType._parameters.setRating,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.setRating,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const axiosClient = axios.create({});
|
||||||
|
|
||||||
|
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: 'Issue from Subsonic API',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data['subsonic-response'];
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ssApiClient = (args: { serverId?: string; signal?: AbortSignal; url?: string }) => {
|
||||||
|
const { serverId, url, signal } = args;
|
||||||
|
|
||||||
|
return initClient(contract, {
|
||||||
|
api: async ({ path, method, headers, body }) => {
|
||||||
|
let baseUrl: string | undefined;
|
||||||
|
const authParams: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
const selectedServer = useAuthStore.getState().actions.getServer(serverId);
|
||||||
|
|
||||||
|
if (!selectedServer) {
|
||||||
|
return {
|
||||||
|
body: { data: null, headers: null },
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUrl = `${selectedServer?.url}/rest`;
|
||||||
|
const token = selectedServer.credential;
|
||||||
|
const params = token.split(/&?\w=/gm);
|
||||||
|
|
||||||
|
authParams.u = selectedServer.username;
|
||||||
|
if (params?.length === 4) {
|
||||||
|
authParams.s = params[2];
|
||||||
|
authParams.t = params[3];
|
||||||
|
} else if (params?.length === 3) {
|
||||||
|
authParams.p = params[2];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baseUrl = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await axiosClient.request({
|
||||||
|
data: body,
|
||||||
|
headers,
|
||||||
|
method: method as Method,
|
||||||
|
params: {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
url: `${baseUrl}/${path}`,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
body: { data: result.data, headers: result.headers },
|
||||||
|
status: result.status,
|
||||||
|
};
|
||||||
|
} catch (e: Error | AxiosError | any) {
|
||||||
|
if (isAxiosError(e)) {
|
||||||
|
const error = e as AxiosError;
|
||||||
|
const response = error.response as AxiosResponse;
|
||||||
|
return {
|
||||||
|
body: { data: response.data, headers: response.headers },
|
||||||
|
status: response.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
baseHeaders: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
baseUrl: '',
|
||||||
|
});
|
||||||
|
};
|
349
src/renderer/api/subsonic/subsonic-controller.ts
Normal file
349
src/renderer/api/subsonic/subsonic-controller.ts
Normal file
|
@ -0,0 +1,349 @@
|
||||||
|
import md5 from 'md5';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||||
|
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
|
||||||
|
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||||
|
import {
|
||||||
|
ArtistInfoArgs,
|
||||||
|
AuthenticationResponse,
|
||||||
|
FavoriteArgs,
|
||||||
|
FavoriteResponse,
|
||||||
|
LibraryItem,
|
||||||
|
MusicFolderListArgs,
|
||||||
|
MusicFolderListResponse,
|
||||||
|
RatingArgs,
|
||||||
|
RatingResponse,
|
||||||
|
ScrobbleArgs,
|
||||||
|
ScrobbleResponse,
|
||||||
|
SongListResponse,
|
||||||
|
TopSongListArgs,
|
||||||
|
} from '/@/renderer/api/types';
|
||||||
|
import { randomString } from '/@/renderer/utils';
|
||||||
|
|
||||||
|
const authenticate = async (
|
||||||
|
url: string,
|
||||||
|
body: {
|
||||||
|
legacy?: boolean;
|
||||||
|
password: string;
|
||||||
|
username: string;
|
||||||
|
},
|
||||||
|
): Promise<AuthenticationResponse> => {
|
||||||
|
let credential: string;
|
||||||
|
let credentialParams: {
|
||||||
|
p?: string;
|
||||||
|
s?: string;
|
||||||
|
t?: string;
|
||||||
|
u: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanServerUrl = url.replace(/\/$/, '');
|
||||||
|
|
||||||
|
if (body.legacy) {
|
||||||
|
credential = `u=${body.username}&p=${body.password}`;
|
||||||
|
credentialParams = {
|
||||||
|
p: body.password,
|
||||||
|
u: body.username,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const salt = randomString(12);
|
||||||
|
const hash = md5(body.password + salt);
|
||||||
|
credential = `u=${body.username}&s=${salt}&t=${hash}`;
|
||||||
|
credentialParams = {
|
||||||
|
s: salt,
|
||||||
|
t: hash,
|
||||||
|
u: body.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await ssApiClient({ url: cleanServerUrl }).authenticate({
|
||||||
|
query: {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...credentialParams,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
credential,
|
||||||
|
userId: null,
|
||||||
|
username: body.username,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolderListResponse> => {
|
||||||
|
const { signal, serverId } = args;
|
||||||
|
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error('No server id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await ssApiClient({ serverId, signal }).getMusicFolderList({});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get music folder list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.musicFolders.musicFolder,
|
||||||
|
startIndex: 0,
|
||||||
|
totalRecordCount: res.body.musicFolders.musicFolder.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// export const getAlbumArtistDetail = async (
|
||||||
|
// args: AlbumArtistDetailArgs,
|
||||||
|
// ): Promise<SSAlbumArtistDetail> => {
|
||||||
|
// const { server, signal, query } = args;
|
||||||
|
// const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
|
// const searchParams: SSAlbumArtistDetailParams = {
|
||||||
|
// id: query.id,
|
||||||
|
// ...defaultParams,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const data = await api
|
||||||
|
// .get('/getArtist.view', {
|
||||||
|
// prefixUrl: server?.url,
|
||||||
|
// searchParams,
|
||||||
|
// signal,
|
||||||
|
// })
|
||||||
|
// .json<SSAlbumArtistDetailResponse>();
|
||||||
|
|
||||||
|
// return data.artist;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArtistList> => {
|
||||||
|
// const { signal, server, query } = args;
|
||||||
|
// const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
|
// const searchParams: SSAlbumArtistListParams = {
|
||||||
|
// musicFolderId: query.musicFolderId,
|
||||||
|
// ...defaultParams,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const data = await api
|
||||||
|
// .get('rest/getArtists.view', {
|
||||||
|
// prefixUrl: server?.url,
|
||||||
|
// searchParams,
|
||||||
|
// signal,
|
||||||
|
// })
|
||||||
|
// .json<SSAlbumArtistListResponse>();
|
||||||
|
|
||||||
|
// const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist);
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// items: artists,
|
||||||
|
// startIndex: query.startIndex,
|
||||||
|
// totalRecordCount: null,
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
|
||||||
|
// const { server, signal } = args;
|
||||||
|
// const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
|
// const data = await api
|
||||||
|
// .get('rest/getGenres.view', {
|
||||||
|
// prefixUrl: server?.url,
|
||||||
|
// searchParams: defaultParams,
|
||||||
|
// signal,
|
||||||
|
// })
|
||||||
|
// .json<SSGenreListResponse>();
|
||||||
|
|
||||||
|
// return data.genres.genre;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
|
||||||
|
// const { server, query, signal } = args;
|
||||||
|
// const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
|
// const searchParams = {
|
||||||
|
// id: query.id,
|
||||||
|
// ...defaultParams,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const data = await api
|
||||||
|
// .get('rest/getAlbum.view', {
|
||||||
|
// prefixUrl: server?.url,
|
||||||
|
// searchParams: parseSearchParams(searchParams),
|
||||||
|
// signal,
|
||||||
|
// })
|
||||||
|
// .json<SSAlbumDetailResponse>();
|
||||||
|
|
||||||
|
// const { song: songs, ...dataWithoutSong } = data.album;
|
||||||
|
// return { ...dataWithoutSong, songs };
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
|
||||||
|
// const { server, query, signal } = args;
|
||||||
|
// const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
|
// const searchParams = {
|
||||||
|
// ...defaultParams,
|
||||||
|
// };
|
||||||
|
// const data = await api
|
||||||
|
// .get('rest/getAlbumList2.view', {
|
||||||
|
// prefixUrl: server?.url,
|
||||||
|
// searchParams: parseSearchParams(searchParams),
|
||||||
|
// signal,
|
||||||
|
// })
|
||||||
|
// .json<SSAlbumListResponse>();
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// items: data.albumList2.album,
|
||||||
|
// startIndex: query.startIndex,
|
||||||
|
// totalRecordCount: null,
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
|
||||||
|
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||||
|
const { serverId, query, signal } = args;
|
||||||
|
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error('No server id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await ssApiClient({ serverId, signal }).createFavorite({
|
||||||
|
query: {
|
||||||
|
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
|
||||||
|
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
|
||||||
|
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to create favorite');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: query.id,
|
||||||
|
type: query.type,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||||
|
const { serverId, query, signal } = args;
|
||||||
|
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error('No server id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await ssApiClient({ serverId, signal }).removeFavorite({
|
||||||
|
query: {
|
||||||
|
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
|
||||||
|
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
|
||||||
|
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to delete favorite');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: query.id,
|
||||||
|
type: query.type,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRating = async (args: RatingArgs): Promise<RatingResponse> => {
|
||||||
|
const { serverId, query, signal } = args;
|
||||||
|
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error('No server id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemIds = query.item.map((item) => item.id);
|
||||||
|
|
||||||
|
for (const id of itemIds) {
|
||||||
|
await ssApiClient({ serverId, signal }).setRating({
|
||||||
|
query: {
|
||||||
|
id,
|
||||||
|
rating: query.rating,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse> => {
|
||||||
|
const { signal, serverId, query, server } = args;
|
||||||
|
|
||||||
|
if (!serverId || !server) {
|
||||||
|
throw new Error('No server id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await ssApiClient({ serverId, signal }).getTopSongsList({
|
||||||
|
query: {
|
||||||
|
artist: query.artist,
|
||||||
|
count: query.limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get top songs');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.topSongs.song.map((song) => ssNormalize.song(song, server, '')),
|
||||||
|
startIndex: 0,
|
||||||
|
totalRecordCount: res.body.topSongs.song.length || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArtistInfo = async (
|
||||||
|
args: ArtistInfoArgs,
|
||||||
|
): Promise<z.infer<typeof ssType._response.artistInfo>> => {
|
||||||
|
const { signal, serverId, query } = args;
|
||||||
|
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error('No server id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await ssApiClient({ serverId, signal }).getArtistInfo({
|
||||||
|
query: {
|
||||||
|
count: query.limit,
|
||||||
|
id: query.artistId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get artist info');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
|
||||||
|
const { signal, serverId, query } = args;
|
||||||
|
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error('No server id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await ssApiClient({ serverId, signal }).scrobble({
|
||||||
|
query: {
|
||||||
|
id: query.id,
|
||||||
|
submission: query.submission,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to scrobble');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ssController = {
|
||||||
|
authenticate,
|
||||||
|
createFavorite,
|
||||||
|
getArtistInfo,
|
||||||
|
getMusicFolderList,
|
||||||
|
getTopSongList,
|
||||||
|
removeFavorite,
|
||||||
|
scrobble,
|
||||||
|
setRating,
|
||||||
|
};
|
103
src/renderer/api/subsonic/subsonic-normalize.ts
Normal file
103
src/renderer/api/subsonic/subsonic-normalize.ts
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||||
|
import { QueueSong, LibraryItem } from '/@/renderer/api/types';
|
||||||
|
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||||
|
|
||||||
|
const getCoverArtUrl = (args: {
|
||||||
|
baseUrl: string;
|
||||||
|
coverArtId?: string;
|
||||||
|
credential: string;
|
||||||
|
size: number;
|
||||||
|
}) => {
|
||||||
|
const size = args.size ? args.size : 150;
|
||||||
|
|
||||||
|
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||||
|
`?id=${args.coverArtId}` +
|
||||||
|
`&${args.credential}` +
|
||||||
|
'&v=1.13.0' +
|
||||||
|
'&c=feishin' +
|
||||||
|
`&size=${size}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSong = (
|
||||||
|
item: z.infer<typeof ssType._response.song>,
|
||||||
|
server: ServerListItem,
|
||||||
|
deviceId: string,
|
||||||
|
): QueueSong => {
|
||||||
|
const imageUrl =
|
||||||
|
getCoverArtUrl({
|
||||||
|
baseUrl: server.url,
|
||||||
|
coverArtId: item.coverArt,
|
||||||
|
credential: server.credential,
|
||||||
|
size: 300,
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
const streamUrl = `${server.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
album: item.album || '',
|
||||||
|
albumArtists: [
|
||||||
|
{
|
||||||
|
id: item.artistId || '',
|
||||||
|
imageUrl: null,
|
||||||
|
name: item.artist || '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
albumId: item.albumId || '',
|
||||||
|
artistName: item.artist || '',
|
||||||
|
artists: [
|
||||||
|
{
|
||||||
|
id: item.artistId || '',
|
||||||
|
imageUrl: null,
|
||||||
|
name: item.artist || '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bitRate: item.bitRate || 0,
|
||||||
|
bpm: null,
|
||||||
|
channels: null,
|
||||||
|
comment: null,
|
||||||
|
compilation: null,
|
||||||
|
container: item.contentType,
|
||||||
|
createdAt: item.created,
|
||||||
|
discNumber: item.discNumber || 1,
|
||||||
|
duration: item.duration || 0,
|
||||||
|
genres: item.genre
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: item.genre,
|
||||||
|
name: item.genre,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
id: item.id,
|
||||||
|
imagePlaceholderUrl: null,
|
||||||
|
imageUrl,
|
||||||
|
itemType: LibraryItem.SONG,
|
||||||
|
lastPlayedAt: null,
|
||||||
|
name: item.title,
|
||||||
|
path: item.path,
|
||||||
|
playCount: item?.playCount || 0,
|
||||||
|
releaseDate: null,
|
||||||
|
releaseYear: item.year ? String(item.year) : null,
|
||||||
|
serverId: server.id,
|
||||||
|
serverType: ServerType.SUBSONIC,
|
||||||
|
size: item.size,
|
||||||
|
streamUrl,
|
||||||
|
trackNumber: item.track || 1,
|
||||||
|
uniqueId: nanoid(),
|
||||||
|
updatedAt: '',
|
||||||
|
userFavorite: item.starred || false,
|
||||||
|
userRating: item.userRating || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ssNormalize = {
|
||||||
|
song: normalizeSong,
|
||||||
|
};
|
201
src/renderer/api/subsonic/subsonic-types.ts
Normal file
201
src/renderer/api/subsonic/subsonic-types.ts
Normal file
|
@ -0,0 +1,201 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const baseResponse = z.object({
|
||||||
|
'subsonic-response': z.object({
|
||||||
|
status: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const authenticate = z.null();
|
||||||
|
|
||||||
|
const authenticateParameters = z.object({
|
||||||
|
c: z.string(),
|
||||||
|
f: z.string(),
|
||||||
|
p: z.string().optional(),
|
||||||
|
s: z.string().optional(),
|
||||||
|
t: z.string().optional(),
|
||||||
|
u: z.string(),
|
||||||
|
v: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createFavoriteParameters = z.object({
|
||||||
|
albumId: z.array(z.string()).optional(),
|
||||||
|
artistId: z.array(z.string()).optional(),
|
||||||
|
id: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createFavorite = z.null();
|
||||||
|
|
||||||
|
const removeFavoriteParameters = z.object({
|
||||||
|
albumId: z.array(z.string()).optional(),
|
||||||
|
artistId: z.array(z.string()).optional(),
|
||||||
|
id: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeFavorite = z.null();
|
||||||
|
|
||||||
|
const setRatingParameters = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
rating: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setRating = z.null();
|
||||||
|
|
||||||
|
const musicFolder = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const musicFolderList = z.object({
|
||||||
|
musicFolders: z.object({
|
||||||
|
musicFolder: z.array(musicFolder),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const song = z.object({
|
||||||
|
album: z.string().optional(),
|
||||||
|
albumId: z.string().optional(),
|
||||||
|
artist: z.string().optional(),
|
||||||
|
artistId: z.string().optional(),
|
||||||
|
averageRating: z.number().optional(),
|
||||||
|
bitRate: z.number().optional(),
|
||||||
|
contentType: z.string(),
|
||||||
|
coverArt: z.string().optional(),
|
||||||
|
created: z.string(),
|
||||||
|
discNumber: z.number(),
|
||||||
|
duration: z.number().optional(),
|
||||||
|
genre: z.string().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
isDir: z.boolean(),
|
||||||
|
isVideo: z.boolean(),
|
||||||
|
parent: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
playCount: z.number().optional(),
|
||||||
|
size: z.number(),
|
||||||
|
starred: z.boolean().optional(),
|
||||||
|
suffix: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
track: z.number().optional(),
|
||||||
|
type: z.string(),
|
||||||
|
userRating: z.number().optional(),
|
||||||
|
year: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const album = z.object({
|
||||||
|
album: z.string(),
|
||||||
|
artist: z.string(),
|
||||||
|
artistId: z.string(),
|
||||||
|
coverArt: z.string(),
|
||||||
|
created: z.string(),
|
||||||
|
duration: z.number(),
|
||||||
|
genre: z.string().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
isDir: z.boolean(),
|
||||||
|
isVideo: z.boolean(),
|
||||||
|
name: z.string(),
|
||||||
|
parent: z.string(),
|
||||||
|
song: z.array(song),
|
||||||
|
songCount: z.number(),
|
||||||
|
starred: z.boolean().optional(),
|
||||||
|
title: z.string(),
|
||||||
|
userRating: z.number().optional(),
|
||||||
|
year: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumListParameters = z.object({
|
||||||
|
fromYear: z.number().optional(),
|
||||||
|
genre: z.string().optional(),
|
||||||
|
musicFolderId: z.string().optional(),
|
||||||
|
offset: z.number().optional(),
|
||||||
|
size: z.number().optional(),
|
||||||
|
toYear: z.number().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumList = z.array(album.omit({ song: true }));
|
||||||
|
|
||||||
|
const albumArtist = z.object({
|
||||||
|
albumCount: z.string(),
|
||||||
|
artistImageUrl: z.string().optional(),
|
||||||
|
coverArt: z.string().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumArtistList = z.object({
|
||||||
|
artist: z.array(albumArtist),
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const artistInfoParameters = z.object({
|
||||||
|
count: z.number().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
includeNotPresent: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const artistInfo = z.object({
|
||||||
|
artistInfo2: z.object({
|
||||||
|
biography: z.string().optional(),
|
||||||
|
largeImageUrl: z.string().optional(),
|
||||||
|
lastFmUrl: z.string().optional(),
|
||||||
|
mediumImageUrl: z.string().optional(),
|
||||||
|
musicBrainzId: z.string().optional(),
|
||||||
|
similarArtist: z.array(
|
||||||
|
z.object({
|
||||||
|
albumCount: z.string(),
|
||||||
|
artistImageUrl: z.string().optional(),
|
||||||
|
coverArt: z.string().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
smallImageUrl: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const topSongsListParameters = z.object({
|
||||||
|
artist: z.string(), // The name of the artist, not the artist ID
|
||||||
|
count: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const topSongsList = z.object({
|
||||||
|
topSongs: z.object({
|
||||||
|
song: z.array(song),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrobbleParameters = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
submission: z.boolean().optional(),
|
||||||
|
time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to.
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrobble = z.null();
|
||||||
|
|
||||||
|
export const ssType = {
|
||||||
|
_parameters: {
|
||||||
|
albumList: albumListParameters,
|
||||||
|
artistInfo: artistInfoParameters,
|
||||||
|
authenticate: authenticateParameters,
|
||||||
|
createFavorite: createFavoriteParameters,
|
||||||
|
removeFavorite: removeFavoriteParameters,
|
||||||
|
scrobble: scrobbleParameters,
|
||||||
|
setRating: setRatingParameters,
|
||||||
|
topSongsList: topSongsListParameters,
|
||||||
|
},
|
||||||
|
_response: {
|
||||||
|
albumArtistList,
|
||||||
|
albumList,
|
||||||
|
artistInfo,
|
||||||
|
authenticate,
|
||||||
|
baseResponse,
|
||||||
|
createFavorite,
|
||||||
|
musicFolderList,
|
||||||
|
removeFavorite,
|
||||||
|
scrobble,
|
||||||
|
setRating,
|
||||||
|
song,
|
||||||
|
topSongsList,
|
||||||
|
},
|
||||||
|
};
|
|
@ -8,3 +8,16 @@ export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: Ite
|
||||||
headers: z.instanceof(AxiosHeaders),
|
headers: z.instanceof(AxiosHeaders),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
|
||||||
|
itemSchema: ItemType,
|
||||||
|
) => {
|
||||||
|
return z.object({
|
||||||
|
'subsonic-response': z
|
||||||
|
.object({
|
||||||
|
status: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
})
|
||||||
|
.extend(itemSchema),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
Reference in a new issue