Add tidal api spec

This commit is contained in:
jeffvli 2023-11-19 03:44:30 -08:00
parent 1d2e9484d8
commit 1afd812aa4
2 changed files with 695 additions and 0 deletions

View File

@ -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<string, any> = {};
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,
});
};

View File

@ -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,
},
};