From 9bd12df8f61c7d83fabca46eb739dfabbb342980 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 23 Apr 2023 01:39:47 -0700 Subject: [PATCH] Add navidrome API and types --- src/renderer/api/navidrome/navidrome-api.ts | 192 +++++++++++ src/renderer/api/navidrome/navidrome-types.ts | 321 ++++++++++++++++++ 2 files changed, 513 insertions(+) create mode 100644 src/renderer/api/navidrome/navidrome-api.ts create mode 100644 src/renderer/api/navidrome/navidrome-types.ts diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts new file mode 100644 index 00000000..a0deb8b9 --- /dev/null +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -0,0 +1,192 @@ +import { initClient, initContract } from '@ts-rest/core'; +import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios'; +import { ndType } from './navidrome-types'; +import { toast } from '/@/renderer/components'; +import { useAuthStore } from '/@/renderer/store'; + +const c = initContract(); + +export const contract = c.router({ + addToPlaylist: { + body: ndType._parameters.addToPlaylist, + method: 'POST', + path: 'playlist/:id/tracks', + responses: { + 200: ndType._response.addToPlaylist, + }, + }, + authenticate: { + body: ndType._parameters.authenticate, + method: 'POST', + path: 'auth/login', + responses: { + 200: ndType._response.authenticate, + }, + }, + createPlaylist: { + body: ndType._parameters.createPlaylist, + method: 'POST', + path: 'playlist', + responses: { + 200: ndType._response.createPlaylist, + }, + }, + deletePlaylist: { + body: null, + method: 'DELETE', + path: 'playlist/:id', + responses: { + 200: ndType._response.deletePlaylist, + }, + }, + getAlbumArtistDetail: { + method: 'GET', + path: 'albumArtist/:id', + responses: { + 200: ndType._response.albumArtist, + }, + }, + getAlbumArtistList: { + method: 'GET', + path: 'albumArtist', + pathParams: ndType._parameters.albumArtistList, + responses: { + 200: ndType._response.albumArtistList, + }, + }, + getAlbumDetail: { + method: 'GET', + path: 'album/:id', + responses: { + 200: ndType._response.album, + }, + }, + getAlbumList: { + method: 'GET', + path: 'album', + responses: { + 200: ndType._response.albumList, + }, + }, + getGenreList: { + method: 'GET', + path: 'genre', + responses: { + 200: ndType._response.genreList, + }, + }, + getPlaylistDetail: { + method: 'GET', + path: 'playlist/:id', + responses: { + 200: ndType._response.playlist, + }, + }, + getPlaylistList: { + method: 'GET', + path: 'playlist', + responses: { + 200: ndType._response.playlistList, + }, + }, + getSongDetail: { + method: 'GET', + path: 'song/:id', + responses: { + 200: ndType._response.song, + }, + }, + getSongList: { + method: 'GET', + path: 'song', + pathParams: ndType._parameters.songList, + responses: { + 200: ndType._response.songList, + }, + }, + getUserList: { + method: 'GET', + path: 'user', + responses: { + 200: ndType._response.userList, + }, + }, + removeFromPlaylist: { + body: null, + method: 'DELETE', + path: 'playlist/:id/tracks', + query: ndType._parameters.removeFromPlaylist, + responses: { + 200: ndType._response.removeFromPlaylist, + }, + }, + updatePlaylist: { + body: ndType._parameters.updatePlaylist, + method: 'PUT', + path: 'playlist/:id', + responses: { + 200: ndType._response.updatePlaylist, + }, + }, +}); + +const axiosClient = axios.create({}); + +axiosClient.interceptors.response.use( + (response) => { + const serverId = useAuthStore.getState().currentServer?.id; + + if (serverId) { + useAuthStore.getState().actions.updateServer(serverId, { + ndCredential: response.headers['x-nd-authorization'] as string, + }); + } + + return response; + }, + (error) => { + if (error.response && error.response.status === 401) { + toast.error({ + message: 'Your session has expired.', + }); + + const serverId = useAuthStore.getState().currentServer?.id; + + if (serverId) { + useAuthStore.getState().actions.setCurrentServer(null); + useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined }); + } + } + + return Promise.reject(error); + }, +); + +export const ndApiClient = initClient(contract, { + api: async ({ path, method, headers, body }) => { + const server = useAuthStore.getState().currentServer; + const baseUrl = `${server?.url}/api`; + const token = server?.ndCredential; + + try { + const result = await axiosClient.request({ + data: body, + headers: { ...headers, 'x-nd-authorization': `Bearer ${token}` }, + method: method as Method, + url: `${baseUrl}/${path}`, + }); + return { body: result.data, status: result.status }; + } catch (e: Error | AxiosError | any) { + if (isAxiosError(e)) { + const error = e as AxiosError; + const response = error.response as AxiosResponse; + return { body: response.data, status: response.status }; + } + throw e; + } + }, + baseHeaders: { + 'Content-Type': 'application/json', + }, + baseUrl: '', +}); diff --git a/src/renderer/api/navidrome/navidrome-types.ts b/src/renderer/api/navidrome/navidrome-types.ts new file mode 100644 index 00000000..3412576e --- /dev/null +++ b/src/renderer/api/navidrome/navidrome-types.ts @@ -0,0 +1,321 @@ +import z from 'zod'; + +const sortOrderValues = ['ASC', 'DESC'] as const; + +const paginationParameters = z.object({ + _end: z.number().optional(), + _order: z.enum(sortOrderValues), + _start: z.number().optional(), +}); + +const authenticate = z.object({ + id: z.string(), + isAdmin: z.boolean(), + name: z.string(), + subsonicSalt: z.string(), + subsonicToken: z.string(), + token: z.string(), + username: z.string(), +}); + +const authenticateParameters = z.object({ + password: z.string(), + username: z.string(), +}); + +const user = z.object({ + createdAt: z.string(), + email: z.string().optional(), + id: z.string(), + isAdmin: z.boolean(), + lastAccessAt: z.string(), + lastLoginAt: z.string(), + name: z.string(), + updatedAt: z.string(), + userName: z.string(), +}); + +const userList = z.array(user); + +const genre = z.object({ + id: z.string(), + name: z.string(), +}); + +const genreList = z.array(genre); + +const albumArtist = z.object({ + albumCount: z.number(), + biography: z.string(), + externalInfoUpdatedAt: z.string(), + externalUrl: z.string(), + fullText: z.string(), + genres: z.array(genre), + id: z.string(), + largeImageUrl: z.string().optional(), + mbzArtistId: z.string().optional(), + mediumImageUrl: z.string().optional(), + name: z.string(), + orderArtistName: z.string(), + playCount: z.number(), + playDate: z.string(), + rating: z.number(), + size: z.number(), + smallImageUrl: z.string().optional(), + songCount: z.number(), + starred: z.boolean(), + starredAt: z.string(), +}); + +const albumArtistList = z.array(albumArtist); + +const ndAlbumArtistListSort = { + ALBUM_COUNT: 'albumCount', + FAVORITED: 'starred ASC, starredAt ASC', + NAME: 'name', + PLAY_COUNT: 'playCount', + RATING: 'rating', + SONG_COUNT: 'songCount', +} as const; + +const albumArtistListParameters = paginationParameters.extend({ + _sort: z.nativeEnum(ndAlbumArtistListSort).optional(), + genre_id: z.string().optional(), + name: z.string().optional(), + starred: z.boolean().optional(), +}); + +const album = z.object({ + albumArtist: z.string(), + albumArtistId: z.string(), + allArtistIds: z.string(), + artist: z.string(), + artistId: z.string(), + compilation: z.boolean(), + coverArtId: z.string().optional(), // Removed after v0.48.0 + coverArtPath: z.string().optional(), // Removed after v0.48.0 + createdAt: z.string(), + duration: z.number(), + fullText: z.string(), + genre: z.string(), + genres: z.array(genre), + id: z.string(), + maxYear: z.number(), + mbzAlbumArtistId: z.string().optional(), + mbzAlbumId: z.string().optional(), + minYear: z.number(), + name: z.string(), + orderAlbumArtistName: z.string(), + orderAlbumName: z.string(), + playCount: z.number(), + playDate: z.string(), + rating: z.number().optional(), + size: z.number(), + songCount: z.number(), + sortAlbumArtistName: z.string(), + sortArtistName: z.string(), + starred: z.boolean(), + starredAt: z.string().optional(), + updatedAt: z.string(), +}); + +const albumList = z.array(album); + +const ndAlbumListSort = { + ALBUM_ARTIST: 'albumArtist', + ARTIST: 'artist', + DURATION: 'duration', + NAME: 'name', + PLAY_COUNT: 'playCount', + PLAY_DATE: 'play_date', + RANDOM: 'random', + RATING: 'rating', + RECENTLY_ADDED: 'recently_added', + SONG_COUNT: 'songCount', + STARRED: 'starred', + YEAR: 'max_year', +} as const; + +const albumListParameters = paginationParameters.extend({ + _sort: z.nativeEnum(ndAlbumListSort).optional(), + album_id: z.string().optional(), + artist_id: z.string().optional(), + compilation: z.boolean().optional(), + genre_id: z.string().optional(), + has_rating: z.boolean().optional(), + id: z.string().optional(), + name: z.string().optional(), + recently_added: z.boolean().optional(), + starred: z.boolean().optional(), + year: z.number().optional(), +}); + +const song = z.object({ + album: z.string(), + albumArtist: z.string(), + albumArtistId: z.string(), + albumId: z.string(), + artist: z.string(), + artistId: z.string(), + bitRate: z.number(), + bookmarkPosition: z.number(), + bpm: z.number().optional(), + channels: z.number().optional(), + comment: z.string().optional(), + compilation: z.boolean(), + createdAt: z.string(), + discNumber: z.number(), + duration: z.number(), + fullText: z.string(), + genre: z.string(), + genres: z.array(genre), + hasCoverArt: z.boolean(), + id: z.string(), + lyrics: z.string().optional(), + mbzAlbumArtistId: z.string().optional(), + mbzAlbumId: z.string().optional(), + mbzArtistId: z.string().optional(), + mbzTrackId: z.string().optional(), + orderAlbumArtistName: z.string(), + orderAlbumName: z.string(), + orderArtistName: z.string(), + orderTitle: z.string(), + path: z.string(), + playCount: z.number(), + playDate: z.string(), + rating: z.number().optional(), + size: z.number(), + sortAlbumArtistName: z.string(), + sortArtistName: z.string(), + starred: z.boolean(), + starredAt: z.string().optional(), + suffix: z.string(), + title: z.string(), + trackNumber: z.number(), + updatedAt: z.string(), + year: z.number(), +}); + +const songList = z.array(song); + +const ndSongListSort = { + ALBUM: 'album, order_album_artist_name, disc_number, track_number, title', + ALBUM_ARTIST: 'order_album_artist_name, album, disc_number, track_number, title', + ALBUM_SONGS: 'album, discNumber, trackNumber', + ARTIST: 'artist', + BPM: 'bpm', + CHANNELS: 'channels', + COMMENT: 'comment', + DURATION: 'duration', + FAVORITED: 'starred ASC, starredAt ASC', + GENRE: 'genre', + ID: 'id', + PLAY_COUNT: 'playCount', + PLAY_DATE: 'playDate', + RATING: 'rating', + RECENTLY_ADDED: 'createdAt', + TITLE: 'title', + TRACK: 'track', + YEAR: 'year, album, discNumber, trackNumber', +}; + +const songListParameters = paginationParameters.extend({ + _sort: z.nativeEnum(ndSongListSort).optional(), + album_id: z.string().optional(), + artist_id: z.string().optional(), + genre_id: z.string().optional(), + starred: z.boolean().optional(), +}); + +const playlist = z.object({ + comment: z.string(), + createdAt: z.string(), + duration: z.number(), + evaluatedAt: z.string(), + id: z.string(), + name: z.string(), + ownerId: z.string(), + ownerName: z.string(), + path: z.string(), + public: z.boolean(), + rules: z.string(), + size: z.number(), + songCount: z.number(), + sync: z.boolean(), + updatedAt: z.string(), +}); + +const playlistList = z.array(playlist); + +const playlistSong = playlist.extend({ + mediaFileId: z.string(), + playlistId: z.string(), +}); + +const createPlaylist = playlist.pick({ + id: true, +}); + +const createPlaylistParameters = z.object({ + comment: z.string().optional(), + name: z.string(), + public: z.boolean().optional(), + rules: z.record(z.any()).optional(), + sync: z.boolean().optional(), +}); + +const updatePlaylist = playlist; + +const updatePlaylistParameters = createPlaylistParameters.partial(); + +const deletePlaylist = z.null(); + +const addToPlaylist = z.object({ + added: z.number(), +}); + +const addToPlaylistParameters = z.object({ + ids: z.array(z.string()), +}); + +const removeFromPlaylist = z.object({ + ids: z.array(z.string()), +}); + +const removeFromPlaylistParameters = z.object({ + ids: z.array(z.string()), +}); + +export const ndType = { + _parameters: { + addToPlaylist: addToPlaylistParameters, + albumArtistList: albumArtistListParameters, + albumList: albumListParameters, + authenticate: authenticateParameters, + createPlaylist: createPlaylistParameters, + removeFromPlaylist: removeFromPlaylistParameters, + songList: songListParameters, + updatePlaylist: updatePlaylistParameters, + }, + _response: { + addToPlaylist, + album, + albumArtist, + albumArtistList, + albumList, + authenticate, + createPlaylist, + deletePlaylist, + genre, + genreList, + playlist, + playlistList, + playlistSong, + removeFromPlaylist, + song, + songList, + updatePlaylist, + user, + userList, + }, +};