From 1afd812aa44f78fa31d44b05efc652057e477deb Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 19 Nov 2023 03:44:30 -0800 Subject: [PATCH] Add tidal api spec --- src/renderer/api/tidal/tidal-api.ts | 320 ++++++++++++++++++++++ src/renderer/api/tidal/tidal-types.ts | 375 ++++++++++++++++++++++++++ 2 files changed, 695 insertions(+) create mode 100644 src/renderer/api/tidal/tidal-api.ts create mode 100644 src/renderer/api/tidal/tidal-types.ts diff --git a/src/renderer/api/tidal/tidal-api.ts b/src/renderer/api/tidal/tidal-api.ts new file mode 100644 index 00000000..52c44e31 --- /dev/null +++ b/src/renderer/api/tidal/tidal-api.ts @@ -0,0 +1,320 @@ +// Tidal Developer API: https://developer.tidal.com/apiref +// Create an account and register an app to get a clientId and clientSecret: https://developer.tidal.com/dashboard/ + +import { initClient, initContract } from '@ts-rest/core'; +import axios, { AxiosError, AxiosResponse, Method, isAxiosError } from 'axios'; +import omitBy from 'lodash/omitBy'; +import qs from 'qs'; +import i18n from '/@/i18n/i18n'; +import { tidalType } from '/@/renderer/api/tidal/tidal-types'; +import { resultWithHeaders } from '/@/renderer/api/utils'; +import { z } from 'zod'; + +const c = initContract(); + +const authContract = c.router({ + authenticate: { + body: tidalType._parameters.authenticate, + headers: z.object({ + Authorization: z.string(), + 'Content-Type': z.string(), + }), + method: 'POST', + path: '', + responses: { + 200: resultWithHeaders(tidalType._response.authenticate), + 400: resultWithHeaders(tidalType._response.authenticate400Error), + }, + }, +}); + +const albumContract = c.router({ + getAlbumByBarcodeId: { + method: 'GET', + path: '/albums/byBarcodeId', + query: tidalType._parameters.getAlbumByBarcodeId, + responses: { + 207: resultWithHeaders(tidalType._response.getAlbumByBarcodeId), + 400: resultWithHeaders(tidalType._response.error), + 404: resultWithHeaders(tidalType._response.error), + 405: resultWithHeaders(tidalType._response.error), + 406: resultWithHeaders(tidalType._response.error), + 415: resultWithHeaders(tidalType._response.error), + 500: resultWithHeaders(tidalType._response.error), + }, + }, + getAlbumById: { + method: 'GET', + path: '/albums/:id', + query: tidalType._parameters.getAlbumById, + responses: { + 207: resultWithHeaders(tidalType._response.getAlbumById), + 400: resultWithHeaders(tidalType._response.error), + 404: resultWithHeaders(tidalType._response.error), + 405: resultWithHeaders(tidalType._response.error), + 406: resultWithHeaders(tidalType._response.error), + 415: resultWithHeaders(tidalType._response.error), + 451: resultWithHeaders(tidalType._response.error), + 500: resultWithHeaders(tidalType._response.error), + }, + }, + getAlbumItems: { + method: 'GET', + path: '/albums/:id/items', + responses: { + 207: resultWithHeaders(tidalType._response.getAlbumItems), + 400: resultWithHeaders(tidalType._response.error), + 404: resultWithHeaders(tidalType._response.error), + 405: resultWithHeaders(tidalType._response.error), + 406: resultWithHeaders(tidalType._response.error), + 415: resultWithHeaders(tidalType._response.error), + 500: resultWithHeaders(tidalType._response.error), + }, + }, + getAlbumsByArtistId: { + method: 'GET', + path: '/artists/:id/albums', + responses: { + 207: resultWithHeaders(tidalType._response.getAlbumsByArtistId), + 400: resultWithHeaders(tidalType._response.error), + 404: resultWithHeaders(tidalType._response.error), + 405: resultWithHeaders(tidalType._response.error), + 406: resultWithHeaders(tidalType._response.error), + 415: resultWithHeaders(tidalType._response.error), + 500: resultWithHeaders(tidalType._response.error), + }, + }, + getAlbumsByIds: { + method: 'GET', + path: '/albums/byIds', + responses: { + 207: resultWithHeaders(tidalType._response.getAlbumsByIds), + 400: resultWithHeaders(tidalType._response.error), + 404: resultWithHeaders(tidalType._response.error), + 405: resultWithHeaders(tidalType._response.error), + 406: resultWithHeaders(tidalType._response.error), + 415: resultWithHeaders(tidalType._response.error), + 500: resultWithHeaders(tidalType._response.error), + }, + }, + getSimilarAlbums: { + method: 'GET', + path: '/albums/:id/similar', + responses: { + 207: resultWithHeaders(tidalType._response.getSimilarAlbums), + 400: resultWithHeaders(tidalType._response.error), + 404: resultWithHeaders(tidalType._response.error), + 405: resultWithHeaders(tidalType._response.error), + 406: resultWithHeaders(tidalType._response.error), + 415: resultWithHeaders(tidalType._response.error), + 500: resultWithHeaders(tidalType._response.error), + }, + }, +}); + +const artistContract = c.router({ + getArtistById: { + method: 'GET', + path: '/artists/:id', + responses: { + 207: resultWithHeaders(tidalType._response.getArtistById), + 400: resultWithHeaders(tidalType._response.error), + 404: resultWithHeaders(tidalType._response.error), + 405: resultWithHeaders(tidalType._response.error), + 406: resultWithHeaders(tidalType._response.error), + 415: resultWithHeaders(tidalType._response.error), + 451: resultWithHeaders(tidalType._response.error), + 500: resultWithHeaders(tidalType._response.error), + }, + }, + getArtistsByIds: { + method: 'GET', + path: '/artists', + responses: { + 207: resultWithHeaders(tidalType._response.getArtistsByIds), + 400: resultWithHeaders(tidalType._response.error), + 404: resultWithHeaders(tidalType._response.error), + 405: resultWithHeaders(tidalType._response.error), + 406: resultWithHeaders(tidalType._response.error), + 415: resultWithHeaders(tidalType._response.error), + 500: resultWithHeaders(tidalType._response.error), + }, + }, + getSimilarArtists: { + method: 'GET', + path: '/artists/:id/similar', + responses: { + 207: resultWithHeaders(tidalType._response.getSimilarArtists), + 400: resultWithHeaders(tidalType._response.error), + 404: resultWithHeaders(tidalType._response.error), + 405: resultWithHeaders(tidalType._response.error), + 415: resultWithHeaders(tidalType._response.error), + 500: resultWithHeaders(tidalType._response.error), + }, + }, +}); + +const trackContract = c.router({ + getSimilarTracks: { + method: 'GET', + path: '/tracks/:id/similar', + responses: { + 207: resultWithHeaders(tidalType._response.getSimilarTracks), + 400: resultWithHeaders(tidalType._response.error), + 404: resultWithHeaders(tidalType._response.error), + 451: resultWithHeaders(tidalType._response.error), + 500: resultWithHeaders(tidalType._response.error), + }, + }, + getTrackById: { + method: 'GET', + path: '/tracks/:id', + responses: { + 207: resultWithHeaders(tidalType._response.getTrackById), + 400: resultWithHeaders(tidalType._response.error), + 404: resultWithHeaders(tidalType._response.error), + 451: resultWithHeaders(tidalType._response.error), + 500: resultWithHeaders(tidalType._response.error), + }, + }, + getTracksByIds: { + method: 'GET', + path: '/tracks', + responses: { + 207: resultWithHeaders(tidalType._response.getTracksByIds), + 400: resultWithHeaders(tidalType._response.error), + 404: resultWithHeaders(tidalType._response.error), + 451: resultWithHeaders(tidalType._response.error), + 500: resultWithHeaders(tidalType._response.error), + }, + }, +}); + +const searchContract = c.router({ + search: { + method: 'GET', + path: '/search', + query: tidalType._parameters.search, + responses: { + 207: resultWithHeaders(tidalType._response.search), + 400: resultWithHeaders(tidalType._response.error), + 404: resultWithHeaders(tidalType._response.error), + 405: resultWithHeaders(tidalType._response.error), + 406: resultWithHeaders(tidalType._response.error), + 415: resultWithHeaders(tidalType._response.error), + 500: resultWithHeaders(tidalType._response.error), + }, + }, +}); + +export const tidalContract = c.router({ + album: albumContract, + artist: artistContract, + authenticate: authContract.authenticate, + search: searchContract.search, + track: trackContract, +}); + +const axiosClient = axios.create({}); + +axiosClient.defaults.paramsSerializer = (params) => { + return qs.stringify(params, { arrayFormat: 'repeat' }); +}; + +const parsePath = (fullPath: string) => { + const [path, params] = fullPath.split('?'); + + const parsedParams = qs.parse(params); + + // Convert indexed object to array + const newParams: Record = {}; + Object.keys(parsedParams).forEach((key) => { + const isIndexedArrayObject = + typeof parsedParams[key] === 'object' && + Object.keys(parsedParams[key] || {}).includes('0'); + + if (!isIndexedArrayObject) { + newParams[key] = parsedParams[key]; + } else { + newParams[key] = Object.values(parsedParams[key] || {}); + } + }); + + const notNilParams = omitBy(newParams, (value) => value === 'undefined' || value === 'null'); + + return { + params: notNilParams, + path, + }; +}; + +axiosClient.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + return Promise.reject(error); + }, +); + +export const tidalApiClient = (args: { signal?: AbortSignal; url?: string }) => { + const { url, signal } = args; + + return initClient(tidalContract, { + api: async ({ path, method, headers, body, rawBody }) => { + let baseUrl: string | undefined = 'https://openapi.tidal.com'; + let token: string | undefined; + + const { params, path: api } = parsePath(path); + + if (url !== undefined) { + baseUrl = url; + } + + // Authentication requires using the raw body + const useBody = headers['content-type'] !== 'application/x-www-form-urlencoded'; + + try { + const result = await axiosClient.request({ + data: useBody ? body : rawBody, + headers: { + 'Content-Type': 'application/vnd.tidal.v1+json', + ...headers, + ...(token && { Authorization: `Basic ${token}` }), + }, + method: method as Method, + params, + signal, + url: `${baseUrl}/${api}`, + }); + return { + body: { data: result.data, headers: result.headers }, + headers: result.headers as any, + status: result.status, + }; + } catch (e: Error | AxiosError | any) { + 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: { data: response?.data, headers: response?.headers }, + headers: response?.headers as any, + status: response?.status, + }; + } + throw e; + } + }, + baseHeaders: {}, + baseUrl: '', + jsonQuery: false, + }); +}; diff --git a/src/renderer/api/tidal/tidal-types.ts b/src/renderer/api/tidal/tidal-types.ts new file mode 100644 index 00000000..ff82a313 --- /dev/null +++ b/src/renderer/api/tidal/tidal-types.ts @@ -0,0 +1,375 @@ +import { z } from 'zod'; + +const error = z.object({ + errors: z.array( + z.object({ + category: z.string(), + code: z.string(), + detail: z.string(), + field: z.string().optional(), + }), + ), +}); + +const authenticate400Error = z.object({ + error: z.string(), + error_description: z.string(), + status: z.number(), + sub_status: z.number(), +}); + +const image = z.object({ height: z.number(), url: z.string(), width: z.number() }); + +const authenticate = z.object({ + access_token: z.string(), + expires_in: z.number(), + token_type: z.string(), +}); + +const authenticateParameters = z.string(); + +const artist = z.object({ + id: z.string(), + main: z.boolean().optional(), + name: z.string(), + picture: z.array(image), +}); + +const album = z.object({ + artists: z.array(artist), + barcodeId: z.string(), + copyright: z.string(), + duration: z.number(), + id: z.string(), + imageCover: z.array(image), + mediaMetadata: z.object({ tags: z.string() }), + numberOfTracks: z.number(), + numberOfVideos: z.number(), + numberOfVolumes: z.number(), + properties: z.object({ content: z.string() }), + releaseDate: z.string(), + title: z.string(), + type: z.string(), + videoCover: z.array(image), +}); + +const getAlbumByIdParameters = z.object({ + countryCode: z.string(), +}); + +const getAlbumById = z.object({ + resource: album, +}); + +const getAlbumByBarcodeIdParameters = z.object({ + barcodeId: z.string(), + countryCode: z.string(), +}); + +const getAlbumByBarcodeId = z.object({ + data: z.array( + z.object({ + id: z.string(), + message: z.string(), + resource: album, + status: z.number(), + }), + ), + metadata: z.object({ + failure: z.number(), + requested: z.number(), + success: z.number(), + }), +}); + +const getAlbumItemsParameters = z.object({ + countryCode: z.string(), + limit: z.number().optional(), + offset: z.number().optional(), +}); + +const getAlbumItems = z.object({ + data: z.array( + z.object({ + id: z.string(), + message: z.string(), + resource: z.object({ + album: z.object({ + id: z.string(), + imageCover: z.array( + z.object({ height: z.number(), url: z.string(), width: z.number() }), + ), + title: z.string(), + videoCover: z.array( + z.object({ height: z.number(), url: z.string(), width: z.number() }), + ), + }), + albumId: z.string(), + artifactType: z.string(), + artists: z.array(artist), + copyright: z.string(), + duration: z.number(), + id: z.string(), + isrc: z.string(), + mediaMetadata: z.object({ tags: z.string() }), + properties: z.object({ + additionalProp1: z.array(z.string()).optional(), + additionalProp2: z.array(z.string()).optional(), + additionalProp3: z.array(z.string()).optional(), + content: z.string(), + }), + providerId: z.string(), + title: z.string(), + trackNumber: z.number(), + version: z.string(), + volumeNumber: z.number(), + }), + status: z.number(), + }), + ), + metadata: z.object({ total: z.number() }), +}); + +const getSimilarAlbumsParameters = z.object({ + albumId: z.string(), + countryCode: z.string(), + limit: z.number().optional(), + offset: z.number().optional(), +}); + +const getSimilarAlbums = z.object({ + data: z.array(z.object({ resource: z.object({ id: z.string() }) })), + metadata: z.object({ total: z.number() }), +}); + +const getAlbumsByArtistIdParameters = z.object({ + countryCode: z.string(), + limit: z.number().optional(), + offset: z.number().optional(), +}); + +const getAlbumsByArtistId = z.object({ + data: z.array( + z.object({ id: z.string(), message: z.string(), resource: album, status: z.number() }), + ), + metadata: z.object({ total: z.number() }), +}); + +const getAlbumsByIdsParameters = z.object({ + countryCode: z.string(), + ids: z.string(), +}); + +const getAlbumsByIds = z.object({ + data: z.array( + z.object({ id: z.string(), message: z.string(), resource: album, status: z.number() }), + ), + metadata: z.object({ total: z.number() }), +}); + +const track = z.object({ + album: z.object({ + id: z.string(), + imageCover: z.array(image), + title: z.string(), + videoCover: z.array(image), + }), + artists: z.array(artist), + copyright: z.string(), + duration: z.number(), + id: z.string(), + isrc: z.string(), + mediaMetadata: z.object({ tags: z.string() }), + properties: z.object({ content: z.string() }), + title: z.string(), + trackNumber: z.number(), + version: z.string(), + volumeNumber: z.number(), +}); + +const video = z.object({ + album: z.object({ + id: z.string(), + imageCover: z.array(z.object({ height: z.number(), url: z.string(), width: z.number() })), + title: z.string(), + videoCover: z.array(z.object({ height: z.number(), url: z.string(), width: z.number() })), + }), + artists: z.array( + z.object({ + id: z.string(), + main: z.boolean(), + name: z.string(), + picture: z.array( + z.object({ + height: z.number(), + url: z.string(), + width: z.number(), + }), + ), + }), + ), + copyright: z.string(), + duration: z.number(), + id: z.string(), + image: z.array(z.object({ height: z.number(), url: z.string(), width: z.number() })), + isrc: z.string(), + properties: z.object({ content: z.string(), 'video-type': z.string() }), + releaseDate: z.string(), + title: z.string(), + trackNumber: z.number(), + version: z.string(), + volumeNumber: z.number(), +}); + +const getArtistByIdParameters = z.object({ + countryCode: z.string(), + limit: z.number().optional(), + offset: z.number().optional(), +}); + +const getArtistById = z.object({ + resource: artist, +}); + +const getArtistsByIdsParameters = z.object({ + countryCode: z.string(), + ids: z.string(), +}); + +const getArtistsByIds = z.object({ + data: z.array( + z.object({ id: z.string(), message: z.string(), resource: artist, status: z.number() }), + ), + metadata: z.object({ total: z.number() }), +}); + +const getSimilarArtistsParameters = z.object({ + countryCode: z.string(), + limit: z.number().optional(), + offset: z.number().optional(), +}); + +const getSimilarArtists = z.object({ + data: z.array(z.object({ resource: z.object({ id: z.string() }) })), + metadata: z.object({ total: z.number() }), +}); + +const getSimilarTracksParameters = z.object({ + countryCode: z.string(), + limit: z.number().optional(), + offset: z.number().optional(), +}); + +const getSimilarTracks = z.object({ + data: z.array(z.object({ resource: z.object({ id: z.string() }) })), + metadata: z.object({ total: z.number() }), +}); + +const getTrackByIdParameters = z.object({ + countryCode: z.string(), +}); + +const getTrackById = z.object({ + resource: track, +}); + +const getTracksByIdsParameters = z.object({ + countryCode: z.string(), + ids: z.string(), +}); + +const getTracksByIds = z.object({ + data: z.array( + z.object({ id: z.string(), message: z.string(), resource: track, status: z.number() }), + ), + metadata: z.object({ total: z.number() }), +}); + +const tidalSearchType = { + ALBUM: 'ALBUM', + ARTIST: 'ARTIST', + PLAYLIST: 'PLAYLIST', + TRACK: 'TRACK', + VIDEO: 'VIDEO', +} as const; + +const searchParameters = z.object({ + countryCode: z.string(), + limit: z.number().optional(), + offset: z.number().optional(), + popularity: z.string().optional(), + query: z.string(), + type: z.nativeEnum(tidalSearchType).optional(), +}); + +const search = z.object({ + albums: z.array( + z.object({ + id: z.string(), + message: z.string(), + resource: album, + status: z.number(), + }), + ), + artists: z.array( + z.object({ + id: z.string(), + message: z.string(), + resource: artist, + status: z.number(), + }), + ), + tracks: z.array( + z.object({ + id: z.string(), + message: z.string(), + resource: track, + status: z.number(), + }), + ), + videos: z.array( + z.object({ + id: z.string(), + message: z.string(), + resource: video, + status: z.number(), + }), + ), +}); + +export const tidalType = { + _parameters: { + authenticate: authenticateParameters, + getAlbumByBarcodeId: getAlbumByBarcodeIdParameters, + getAlbumById: getAlbumByIdParameters, + getAlbumItems: getAlbumItemsParameters, + getAlbumsByArtistId: getAlbumsByArtistIdParameters, + getAlbumsByIds: getAlbumsByIdsParameters, + getArtistById: getArtistByIdParameters, + getArtistsByIds: getArtistsByIdsParameters, + getSimilarAlbums: getSimilarAlbumsParameters, + getSimilarArtists: getSimilarArtistsParameters, + getSimilarTracks: getSimilarTracksParameters, + getTrackById: getTrackByIdParameters, + getTracksByIds: getTracksByIdsParameters, + search: searchParameters, + }, + _response: { + authenticate, + authenticate400Error, + error, + getAlbumByBarcodeId, + getAlbumById, + getAlbumItems, + getAlbumsByArtistId, + getAlbumsByIds, + getArtistById, + getArtistsByIds, + getSimilarAlbums, + getSimilarArtists, + getSimilarTracks, + getTrackById, + getTracksByIds, + search, + }, +};