diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts new file mode 100644 index 00000000..7642310b --- /dev/null +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -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 = {}; + + 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: '', + }); +}; diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts new file mode 100644 index 00000000..0aeb6011 --- /dev/null +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -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 => { + 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 => { + 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 => { +// 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(); + +// return data.artist; +// }; + +// const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { +// 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(); + +// const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist); + +// return { +// items: artists, +// startIndex: query.startIndex, +// totalRecordCount: null, +// }; +// }; + +// const getGenreList = async (args: GenreListArgs): Promise => { +// const { server, signal } = args; +// const defaultParams = getDefaultParams(server); + +// const data = await api +// .get('rest/getGenres.view', { +// prefixUrl: server?.url, +// searchParams: defaultParams, +// signal, +// }) +// .json(); + +// return data.genres.genre; +// }; + +// const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { +// 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(); + +// const { song: songs, ...dataWithoutSong } = data.album; +// return { ...dataWithoutSong, songs }; +// }; + +// const getAlbumList = async (args: AlbumListArgs): Promise => { +// 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(); + +// return { +// items: data.albumList2.album, +// startIndex: query.startIndex, +// totalRecordCount: null, +// }; +// }; + +const createFavorite = async (args: FavoriteArgs): Promise => { + 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 => { + 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 => { + 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 => { + 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> => { + 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 => { + 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, +}; diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts new file mode 100644 index 00000000..48ecd97e --- /dev/null +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -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, + 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, +}; diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts new file mode 100644 index 00000000..8f38fdd8 --- /dev/null +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -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, + }, +}; diff --git a/src/renderer/api/utils.ts b/src/renderer/api/utils.ts index e6cf48e2..2315969b 100644 --- a/src/renderer/api/utils.ts +++ b/src/renderer/api/utils.ts @@ -8,3 +8,16 @@ export const resultWithHeaders = (itemSchema: Ite headers: z.instanceof(AxiosHeaders), }); }; + +export const resultSubsonicBaseResponse = ( + itemSchema: ItemType, +) => { + return z.object({ + 'subsonic-response': z + .object({ + status: z.string(), + version: z.string(), + }) + .extend(itemSchema), + }); +};