diff --git a/.env.example b/.env.example
index e49d276..049c3d5 100644
--- a/.env.example
+++ b/.env.example
@@ -1,22 +1,27 @@
-SPOTIFY_AUTH_URL=
+LOG_LEVEL=debug
+
+APP_URL=http://localhost:3000
+DATABASE_PATH=./sqlite/db.sqlite
+
+SPOTIFY_API_URL=https://api.spotify.com/v1/search
+SPOTIFY_AUTH_URL=https://accounts.spotify.com/api/token
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_CLIENT_VERSION=
-SPOTIFY_API_URL=https://api.spotify.com/v1/search
+TIDAL_BASE_URL=https://tidal.com/browse
+TIDAL_API_URL=https://openapi.tidal.com/v2/searchresults
+TIDAL_AUTH_URL=https://auth.tidal.com/v1/oauth2/token
+TIDAL_CLIENT_ID=
+TIDAL_CLIENT_SECRET=
-YOUTUBE_MUSIC_URL=https://music.youtube.com/search
-YOUTUBE_COOKIES=
+YOUTUBE_API_URL=https://content-youtube.googleapis.com/youtube/v3/search
+YOUTUBE_MUSIC_BASE_URL=https://music.youtube.com
+YOUTUBE_API_KEY=
DEEZER_API_URL=https://api.deezer.com/search
-
APPLE_MUSIC_API_URL=https://music.apple.com/us
-
SOUNDCLOUD_BASE_URL=https://soundcloud.com
-TIDAL_BASE_URL=https://listen.tidal.com
-
-DATABASE_PATH=./sqlite/db.sqlite
-APP_URL=http://bit:3000
URL_SHORTENER_API_URL=http://localhost:4000/api/links
-URL_SHORTENER_API_KEY=
+URL_SHORTENER_API_KEY=secure_12345
diff --git a/.env.test b/.env.test
new file mode 100644
index 0000000..c6ebb3c
--- /dev/null
+++ b/.env.test
@@ -0,0 +1,27 @@
+LOG_LEVEL=fatal
+
+APP_URL=http://localhost:3000
+DATABASE_PATH=./sqlite/db.sqlite
+
+SPOTIFY_API_URL=https://api.spotify.com/v1/search
+SPOTIFY_AUTH_URL=https://accounts.spotify.com/api/token
+SPOTIFY_CLIENT_ID=spotify_client_id
+SPOTIFY_CLIENT_SECRET=spotify_client_secret
+SPOTIFY_CLIENT_VERSION=spotify_client_version
+
+TIDAL_BASE_URL=https://tidal.com/browse
+TIDAL_API_URL=https://openapi.tidal.com/v2/searchresults
+TIDAL_AUTH_URL=https://auth.tidal.com/v1/oauth2/token
+TIDAL_CLIENT_ID=tidal_client_id
+TIDAL_CLIENT_SECRET=tidal_client_secret
+
+YOUTUBE_API_URL=https://content-youtube.googleapis.com/youtube/v3/search
+YOUTUBE_MUSIC_BASE_URL=https://music.youtube.com
+YOUTUBE_API_KEY=youtube_api_key
+
+DEEZER_API_URL=https://api.deezer.com/search
+APPLE_MUSIC_API_URL=https://music.apple.com/ca
+SOUNDCLOUD_BASE_URL=https://soundcloud.com
+
+URL_SHORTENER_API_URL=http://localhost:4000/api/links
+URL_SHORTENER_API_KEY=url_shortener_api_key
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index e77fbc5..48b02c0 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -4,22 +4,18 @@ on:
pull_request:
branches: [master]
-env:
- SPOTIFY_AUTH_URL: https://accounts.spotify.com/api/token
- SPOTIFY_CLIENT_ID: ${{ secrets.SPOTIFY_CLIENT_ID }}
- SPOTIFY_CLIENT_SECRET: ${{ secrets.YOUTUBE_COOKIES }}
- SPOTIFY_CLIENT_VERSION: ${{ secrets.SPOTIFY_CLIENT_VERSION }}
- SPOTIFY_API_URL: https://api.spotify.com/v1/search
- YOUTUBE_MUSIC_URL: https://music.youtube.com/search
- YOUTUBE_COOKIES: ${{ secrets.YOUTUBE_COOKIES }}
- DEEZER_API_URL: https://api.deezer.com/search
- APPLE_MUSIC_API_URL: https://music.apple.com/ca
- SOUNDCLOUD_BASE_URL: https://soundcloud.com
- TIDAL_BASE_URL: https://listen.tidal.com
-
jobs:
tests:
runs-on: ubuntu-latest
+ services:
+ bit:
+ image: sjdonado/bit
+ env_file: .env.test
+ ports:
+ - 4000:4000
+ options: >-
+ --volume sqlite_data:/app/sqlite
+
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
@@ -27,3 +23,7 @@ jobs:
run: bun install
- name: Run integration tests
run: bun run test
+ env_file: .env.test
+
+volumes:
+ sqlite_data:
diff --git a/Dockerfile b/Dockerfile
index c10839a..f7121cb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,11 +4,7 @@ EXPOSE 3000/tcp
WORKDIR /usr/src/app
-RUN apk update && apk add --no-cache chromium nodejs python3
-
-ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
-ENV CHROME_PATH=/usr/bin/chromium
-ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
+RUN apk update && apk add --no-cache nodejs python3
COPY package.json bun.lockb .
RUN bun install --frozen-lockfile
diff --git a/README.md b/README.md
index 373b018..f5c421f 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ Adapters represent the streaming services supported by the Web App and the Rayca
![Uptime Badge](https://uptime.sjdonado.com/api/badge/2/uptime/24?labelPrefix=Web%20Page%20&labelSuffix=h) ![Uptime Badge](https://uptime.sjdonado.com/api/badge/2/ping/24?labelPrefix=Web%20Page%20)
-
+
## Raycast Extension
diff --git a/bun.lockb b/bun.lockb
index d8f4c75..6c5fe79 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/docker-compose.yml b/docker-compose.yml
index 45e334a..907034d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,7 +6,7 @@ services:
volumes:
- ./sqlite:/usr/src/app/sqlite
ports:
- - 3001:3000
+ - 3000:3000
depends_on:
- bit
bit:
@@ -14,7 +14,7 @@ services:
environment:
APP_URL: http://localhost:4001
ADMIN_NAME: 'Admin'
- ADMIN_API_KEY: 'E7gWaEu8JOIGKR/jTmOOgbmBCup2h48jmux2YvIzpxk='
+ ADMIN_API_KEY: 'secure_12345'
volumes:
- sqlite_data:/app/sqlite
ports:
diff --git a/package.json b/package.json
index d7bc9b8..c9f9c15 100644
--- a/package.json
+++ b/package.json
@@ -18,13 +18,11 @@
"cache-manager": "^5.7.3",
"cache-manager-bun-sqlite3": "^0.2.0",
"cheerio": "^1.0.0",
- "elysia": "^1.1.24",
- "elysia-compress": "^1.2.1",
+ "elysia": "^1.1.26",
"howler": "^2.2.4",
"notyf": "^3.10.0",
"pino": "^8.20.0",
"pino-pretty": "^11.0.0",
- "puppeteer": "^22.7.0",
"string-similarity": "^4.0.4"
},
"devDependencies": {
diff --git a/src/adapters/apple-music.ts b/src/adapters/apple-music.ts
index 56397a2..c3f5024 100644
--- a/src/adapters/apple-music.ts
+++ b/src/adapters/apple-music.ts
@@ -21,12 +21,9 @@ const APPLE_MUSIC_SEARCH_TYPES = {
export async function getAppleMusicLink(query: string, metadata: SearchMetadata) {
const searchType = APPLE_MUSIC_SEARCH_TYPES[metadata.type];
+ if (!searchType) return null;
- if (!searchType) {
- return null;
- }
-
- // apple music does not support x-www-form-urlencoded encoding
+ // x-www-form-urlencoded encoding required for browser url
const params = `term=${encodeURIComponent(query)}`;
const url = new URL(`${ENV.adapters.appleMusic.apiUrl}/search?${params}`);
diff --git a/src/adapters/deezer.ts b/src/adapters/deezer.ts
index e6b84ed..9bdb421 100644
--- a/src/adapters/deezer.ts
+++ b/src/adapters/deezer.ts
@@ -30,10 +30,7 @@ const DEEZER_SEARCH_TYPES = {
export async function getDeezerLink(query: string, metadata: SearchMetadata) {
const searchType = DEEZER_SEARCH_TYPES[metadata.type];
-
- if (!searchType) {
- return null;
- }
+ if (!searchType) return null;
const params = new URLSearchParams({
q: query,
diff --git a/src/adapters/spotify.ts b/src/adapters/spotify.ts
index 43b83e8..dbefbbc 100644
--- a/src/adapters/spotify.ts
+++ b/src/adapters/spotify.ts
@@ -44,10 +44,7 @@ const SPOTIFY_SEARCH_TYPES = {
export async function getSpotifyLink(query: string, metadata: SearchMetadata) {
const searchType = SPOTIFY_SEARCH_TYPES[metadata.type];
-
- if (!searchType) {
- return null;
- }
+ if (!searchType) return null;
const params = new URLSearchParams({
q: query,
@@ -72,7 +69,6 @@ export async function getSpotifyLink(query: string, metadata: SearchMetadata) {
});
const [[, data]] = Object.entries(response);
-
if (data.total === 0) {
throw new Error(`No results found: ${JSON.stringify(response)}`);
}
diff --git a/src/adapters/tidal.ts b/src/adapters/tidal.ts
index c73cf1e..e249a0d 100644
--- a/src/adapters/tidal.ts
+++ b/src/adapters/tidal.ts
@@ -27,12 +27,13 @@ interface TidalSearchResponse {
}>;
included: Array<{
attributes: {
- title: string;
+ title?: string;
+ name?: string;
};
}>;
}
-const TIDAL_SEARCH_TYPES = {
+export const TIDAL_SEARCH_TYPES = {
[MetadataType.Song]: 'tracks',
[MetadataType.Album]: 'albums',
[MetadataType.Playlist]: 'playlists',
@@ -43,10 +44,7 @@ const TIDAL_SEARCH_TYPES = {
export async function getTidalLink(query: string, metadata: SearchMetadata) {
const searchType = TIDAL_SEARCH_TYPES[metadata.type];
-
- if (!searchType) {
- return null;
- }
+ if (!searchType) return null;
const params = new URLSearchParams({
countryCode: 'US',
@@ -58,6 +56,7 @@ export async function getTidalLink(query: string, metadata: SearchMetadata) {
);
url.search = params.toString();
+ // console.log('tidal', url.toString(), await getOrUpdateTidalAccessToken());
const cache = await getCachedSearchResultLink(url);
if (cache) {
logger.info(`[Tidal] (${url}) cache hit`);
@@ -72,7 +71,6 @@ export async function getTidalLink(query: string, metadata: SearchMetadata) {
});
const { data, included } = response;
-
if (!data || data.length === 0) {
throw new Error(`No results found: ${JSON.stringify(response)}`);
}
@@ -81,7 +79,7 @@ export async function getTidalLink(query: string, metadata: SearchMetadata) {
let highestScore = 0;
for (const item of included) {
- const title = item.attributes.title;
+ const title = item.attributes.title ?? item.attributes.name ?? '';
const score = compareTwoStrings(title.toLowerCase(), query.toLowerCase());
if (score > highestScore) {
diff --git a/src/adapters/youtube.ts b/src/adapters/youtube.ts
index 69252bd..27697a8 100644
--- a/src/adapters/youtube.ts
+++ b/src/adapters/youtube.ts
@@ -4,25 +4,60 @@ import { cacheSearchResultLink, getCachedSearchResultLink } from '~/services/cac
import { SearchMetadata, SearchResultLink } from '~/services/search';
import HttpClient from '~/utils/http-client';
import { logger } from '~/utils/logger';
-import { getLinkWithPuppeteer } from '~/utils/scraper';
-const YOUTUBE_SEARCH_TYPES = {
- [MetadataType.Song]: 'song',
- [MetadataType.Album]: 'album',
- [MetadataType.Playlist]: '',
+interface YoutubeSearchResponse {
+ kind: string;
+ etag: string;
+ nextPageToken?: string;
+ regionCode: string;
+ pageInfo: {
+ totalResults: number;
+ resultsPerPage: number;
+ };
+ items: Array<{
+ kind: string;
+ etag: string;
+ id: {
+ kind: string;
+ videoId?: string;
+ playlistId?: string;
+ channelId?: string;
+ };
+ }>;
+}
+
+export const YOUTUBE_SEARCH_TYPES = {
+ [MetadataType.Song]: 'video',
+ [MetadataType.Album]: 'playlist',
+ [MetadataType.Playlist]: 'playlist',
[MetadataType.Artist]: 'channel',
- [MetadataType.Podcast]: '',
- [MetadataType.Show]: '',
+ [MetadataType.Podcast]: 'video',
+ [MetadataType.Show]: undefined,
};
+const YOUTUBE_SEARCH_LINK_TYPE = (item: YoutubeSearchResponse['items'][number]) => ({
+ [MetadataType.Song]: `watch?v=${item.id.videoId}`,
+ [MetadataType.Album]: `playlist?list=${item.id.playlistId}`,
+ [MetadataType.Playlist]: `playlist?list=${item.id.playlistId}`,
+ [MetadataType.Artist]: `channel/${item.id.channelId}`,
+ [MetadataType.Podcast]: `podcast/${item.id.videoId}`,
+ [MetadataType.Show]: undefined,
+});
+
export async function getYouTubeLink(query: string, metadata: SearchMetadata) {
- return null; // TEMPFIX: youtube blocked the server ip
+ const searchType = YOUTUBE_SEARCH_TYPES[metadata.type];
+ if (!searchType) return null;
const params = new URLSearchParams({
- q: `${query} ${YOUTUBE_SEARCH_TYPES[metadata.type]}`,
+ type: searchType,
+ regionCode: 'US',
+ q: query,
+ part: 'id',
+ safeSearch: 'none',
+ key: ENV.adapters.youTube.apiKey,
});
- const url = new URL(ENV.adapters.youTube.musicUrl);
+ const url = new URL(ENV.adapters.youTube.apiUrl);
url.search = params.toString();
const cache = await getCachedSearchResultLink(url);
@@ -32,36 +67,19 @@ export async function getYouTubeLink(query: string, metadata: SearchMetadata) {
}
try {
- const youtubeCookie = {
- domain: '.youtube.com',
- path: '/',
- expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
- secure: true,
- };
-
- const cookies = ENV.adapters.youTube.cookies.split(';').map(cookie => {
- const [name, value] = cookie.split('=');
- return {
- ...youtubeCookie,
- name,
- value,
- };
- });
-
- const link = await getLinkWithPuppeteer(
- url.toString(),
- 'ytmusic-card-shelf-renderer a',
- cookies
- );
+ const response = await HttpClient.get(url.toString());
- if (!link) {
- return null;
+ const { items } = response;
+ if (!items || !items[0]) {
+ throw new Error(`No results found: ${JSON.stringify(response)}`);
}
+ const link = `${ENV.adapters.youTube.musicBaseUrl}/${YOUTUBE_SEARCH_LINK_TYPE(items[0])[metadata.type]}`;
+
const searchResultLink = {
type: Adapter.YouTube,
url: link,
- isVerified: true,
+ isVerified: false,
} as SearchResultLink;
await cacheSearchResultLink(url, searchResultLink);
@@ -72,15 +90,3 @@ export async function getYouTubeLink(query: string, metadata: SearchMetadata) {
return null;
}
}
-
-export async function fetchYoutubeMetadata(youtubeLink: string) {
- logger.info(`[${fetchYoutubeMetadata.name}] parse metadata (desktop): ${youtubeLink}`);
-
- const html = await HttpClient.get(youtubeLink, {
- headers: {
- Cookie: ENV.adapters.youTube.cookies,
- },
- });
-
- return html;
-}
diff --git a/src/config/env.ts b/src/config/env.ts
index 5d96316..41dccf6 100644
--- a/src/config/env.ts
+++ b/src/config/env.ts
@@ -17,8 +17,9 @@ export const ENV = {
clientSecret: Bun.env.TIDAL_CLIENT_SECRET!,
},
youTube: {
- musicUrl: Bun.env.YOUTUBE_MUSIC_URL!,
- cookies: Bun.env.YOUTUBE_COOKIES!,
+ apiUrl: Bun.env.YOUTUBE_API_URL!,
+ apiKey: Bun.env.YOUTUBE_API_KEY!,
+ musicBaseUrl: Bun.env.YOUTUBE_MUSIC_BASE_URL!,
},
deezer: {
apiUrl: Bun.env.DEEZER_API_URL!,
@@ -42,6 +43,6 @@ export const ENV = {
},
cache: {
databasePath: Bun.env.DATABASE_PATH ?? ':memory:',
- expTime: 60 * 60 * 24 * 7, // 1 week in seconds
+ expTime: 60 * 60 * 24 * 7 * 4, // 4 weeks in seconds
},
};
diff --git a/src/index.ts b/src/index.ts
index 1b09174..235fdaa 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,20 +1,13 @@
-import { Elysia } from 'elysia';
import { html } from '@elysiajs/html';
import { staticPlugin } from '@elysiajs/static';
-import compression from 'elysia-compress';
-
-import { logger } from './utils/logger';
+import { Elysia } from 'elysia';
import { apiRouter } from './routes/api';
import { pageRouter } from './routes/page';
+import { logger } from './utils/logger';
export const app = new Elysia()
.use(html())
- .use(
- compression({
- as: 'scoped',
- })
- )
.use(
staticPlugin({
prefix: '',
diff --git a/src/parsers/apple-music.ts b/src/parsers/apple-music.ts
index 0ad4341..67a0bdc 100644
--- a/src/parsers/apple-music.ts
+++ b/src/parsers/apple-music.ts
@@ -27,7 +27,7 @@ export const getAppleMusicMetadata = async (id: string, link: string) => {
}
try {
- const html = await fetchMetadata(link, {});
+ const html = await fetchMetadata(link);
const doc = getCheerioDoc(html);
diff --git a/src/parsers/deezer.ts b/src/parsers/deezer.ts
index 17bec7a..34aa668 100644
--- a/src/parsers/deezer.ts
+++ b/src/parsers/deezer.ts
@@ -27,7 +27,7 @@ export const getDeezerMetadata = async (id: string, link: string) => {
}
try {
- const html = await fetchMetadata(link, {});
+ const html = await fetchMetadata(link);
const doc = getCheerioDoc(html);
diff --git a/src/parsers/link.ts b/src/parsers/link.ts
index 42414e2..2829d45 100644
--- a/src/parsers/link.ts
+++ b/src/parsers/link.ts
@@ -1,4 +1,4 @@
-import { ParseError } from 'elysia';
+import { InternalServerError } from 'elysia';
import {
APPLE_MUSIC_LINK_REGEX,
@@ -31,10 +31,7 @@ export const getSearchParser = (link?: string, searchId?: string) => {
}
if (!source) {
- const error = new ParseError();
- error.message = 'Source not found';
-
- throw error;
+ throw new InternalServerError('Source not found');
}
let id, type;
@@ -80,10 +77,7 @@ export const getSearchParser = (link?: string, searchId?: string) => {
}
if (!id || !type) {
- const error = new ParseError();
- error.message = 'Service id could not be extracted from source.';
-
- throw error;
+ throw new InternalServerError('Service id could not be extracted from source.');
}
const searchParser = {
diff --git a/src/parsers/sound-cloud.ts b/src/parsers/sound-cloud.ts
index 745a961..0fb8cc7 100644
--- a/src/parsers/sound-cloud.ts
+++ b/src/parsers/sound-cloud.ts
@@ -25,7 +25,7 @@ export const getSoundCloudMetadata = async (id: string, link: string) => {
}
try {
- const html = await fetchMetadata(link, {});
+ const html = await fetchMetadata(link);
const doc = getCheerioDoc(html);
diff --git a/src/parsers/spotify.ts b/src/parsers/spotify.ts
index 22b2e64..28bb400 100644
--- a/src/parsers/spotify.ts
+++ b/src/parsers/spotify.ts
@@ -1,10 +1,13 @@
+import { InternalServerError } from 'elysia';
+
import {
SPOTIFY_LINK_DESKTOP_REGEX,
SPOTIFY_LINK_MOBILE_REGEX,
} from '~/config/constants';
import { MetadataType, Parser } from '~/config/enum';
+import { ENV } from '~/config/env';
import { cacheSearchMetadata, getCachedSearchMetadata } from '~/services/cache';
-import { defaultHeaders, fetchMetadata } from '~/services/metadata';
+import { fetchMetadata } from '~/services/metadata';
import { SearchMetadata } from '~/services/search';
import HttpClient from '~/utils/http-client';
import { logger } from '~/utils/logger';
@@ -28,6 +31,10 @@ const SPOTIFY_METADATA_TO_METADATA_TYPE = {
[SpotifyMetadataType.Show]: MetadataType.Show,
};
+const spotifyClientHeaders = {
+ 'User-Agent': `${ENV.adapters.spotify.clientVersion} (Macintosh; Apple Silicon)`,
+};
+
export const getSpotifyMetadata = async (id: string, link: string) => {
const cached = await getCachedSearchMetadata(id, Parser.Spotify);
if (cached) {
@@ -36,7 +43,7 @@ export const getSpotifyMetadata = async (id: string, link: string) => {
}
try {
- let html = await fetchMetadata(link);
+ let html = await fetchMetadata(link, spotifyClientHeaders);
if (SPOTIFY_LINK_MOBILE_REGEX.test(link)) {
link = html.match(SPOTIFY_LINK_DESKTOP_REGEX)?.[0] ?? '';
@@ -51,7 +58,7 @@ export const getSpotifyMetadata = async (id: string, link: string) => {
logger.info(`[${getSpotifyMetadata.name}] parse metadata (desktop): ${link}`);
html = await HttpClient.get(link, {
- headers: defaultHeaders,
+ headers: spotifyClientHeaders,
retries: 2,
});
}
@@ -84,7 +91,7 @@ export const getSpotifyMetadata = async (id: string, link: string) => {
return metadata;
} catch (err) {
- throw new Error(`[${getSpotifyMetadata.name}] (${link}) ${err}`);
+ throw new InternalServerError(`[${getSpotifyMetadata.name}] (${link}) ${err}`);
}
};
diff --git a/src/parsers/tidal-universal-link.ts b/src/parsers/tidal-universal-link.ts
new file mode 100644
index 0000000..f4b66e6
--- /dev/null
+++ b/src/parsers/tidal-universal-link.ts
@@ -0,0 +1,51 @@
+import { Adapter } from '~/config/enum';
+import {
+ cacheTidalUniversalLinkResponse,
+ getCachedTidalUniversalLinkResponse,
+} from '~/services/cache';
+import { fetchMetadata } from '~/services/metadata';
+import { SearchResultLink } from '~/services/search';
+import { logger } from '~/utils/logger';
+import { getCheerioDoc } from '~/utils/scraper';
+
+export const getUniversalMetadataFromTidal = async (
+ link: string,
+ isVerified: boolean
+): Promise | undefined> => {
+ const cached = await getCachedTidalUniversalLinkResponse(link);
+ if (cached) {
+ logger.info(`[Tidal] (${link}) universalLink metadata cache hit`);
+ return cached;
+ }
+
+ try {
+ const html = await fetchMetadata(link);
+ const doc = getCheerioDoc(html);
+
+ const extractLink = (selector: string, type: Adapter): SearchResultLink | null => {
+ const url = doc(selector).attr('href');
+ return url
+ ? {
+ type,
+ url,
+ isVerified,
+ }
+ : null;
+ };
+
+ const adapterLinks: Record = {
+ [Adapter.Spotify]: extractLink('a[href*="spotify.com"]', Adapter.Spotify),
+ [Adapter.YouTube]: extractLink('a[href*="music.youtube.com"]', Adapter.YouTube),
+ [Adapter.AppleMusic]: extractLink('a[href*="music.apple.com"]', Adapter.AppleMusic),
+ [Adapter.Deezer]: null,
+ [Adapter.SoundCloud]: null,
+ [Adapter.Tidal]: null,
+ };
+
+ await cacheTidalUniversalLinkResponse(link, adapterLinks);
+
+ return adapterLinks;
+ } catch (err) {
+ logger.error(`[${getUniversalMetadataFromTidal.name}] (${link}) ${err}`);
+ }
+};
diff --git a/src/parsers/tidal.ts b/src/parsers/tidal.ts
index b257035..be96134 100644
--- a/src/parsers/tidal.ts
+++ b/src/parsers/tidal.ts
@@ -1,14 +1,7 @@
-import { CheerioAPI } from 'cheerio';
-
-import { Adapter, MetadataType, Parser } from '~/config/enum';
-import {
- cacheSearchMetadata,
- cacheTidalUniversalLinkResponse,
- getCachedSearchMetadata,
- getCachedTidalUniversalLinkResponse,
-} from '~/services/cache';
+import { MetadataType, Parser } from '~/config/enum';
+import { cacheSearchMetadata, getCachedSearchMetadata } from '~/services/cache';
import { fetchMetadata } from '~/services/metadata';
-import { SearchMetadata, SearchResultLink } from '~/services/search';
+import { SearchMetadata } from '~/services/search';
import { logger } from '~/utils/logger';
import { getCheerioDoc, metaTagContent } from '~/utils/scraper';
@@ -90,57 +83,3 @@ export const getTidalQueryFromMetadata = (metadata: SearchMetadata) => {
return query;
};
-
-export const getUniversalMetadataFromTidal = async (
- link: string,
- isVerified: boolean
-): Promise | undefined> => {
- const cached = await getCachedTidalUniversalLinkResponse(link);
- if (cached) {
- logger.info(`[Tidal] (${link}) universalLink metadata cache hit`);
- return cached;
- }
-
- const extractLink = (
- doc: CheerioAPI,
- selector: string,
- type: Adapter
- ): SearchResultLink | null => {
- const url = doc(selector).attr('href');
- return url
- ? {
- type,
- url,
- isVerified,
- }
- : null;
- };
-
- try {
- const html = await fetchMetadata(link);
- const doc = getCheerioDoc(html);
-
- const adapterLinks: Record = {
- [Adapter.Spotify]: extractLink(doc, 'a[href*="spotify.com"]', Adapter.Spotify),
- [Adapter.YouTube]: extractLink(
- doc,
- 'a[href*="music.youtube.com"]',
- Adapter.YouTube
- ),
- [Adapter.AppleMusic]: extractLink(
- doc,
- 'a[href*="music.apple.com"]',
- Adapter.AppleMusic
- ),
- [Adapter.Deezer]: null,
- [Adapter.SoundCloud]: null,
- [Adapter.Tidal]: null,
- };
-
- await cacheTidalUniversalLinkResponse(link, adapterLinks);
-
- return adapterLinks;
- } catch (err) {
- logger.error(`[${getUniversalMetadataFromTidal.name}] (${link}) ${err}`);
- }
-};
diff --git a/src/parsers/youtube.ts b/src/parsers/youtube.ts
index 44bbaff..84657d2 100644
--- a/src/parsers/youtube.ts
+++ b/src/parsers/youtube.ts
@@ -30,7 +30,8 @@ export const getYouTubeMetadata = async (id: string, link: string) => {
}
try {
- const html = await fetchMetadata(link, {});
+ const rawLink = link.replace('music.youtube', 'www.youtube');
+ const html = await fetchMetadata(rawLink);
const doc = getCheerioDoc(html);
diff --git a/src/routes/api.ts b/src/routes/api.ts
index f2f400f..a53db65 100644
--- a/src/routes/api.ts
+++ b/src/routes/api.ts
@@ -1,17 +1,14 @@
import { Elysia } from 'elysia';
-import { logger } from '~/utils/logger';
import { Adapter } from '~/config/enum';
-
-import { apiVersionValidator, searchPayloadValidator } from '~/validations/search';
-
import { search } from '~/services/search';
+import { logger } from '~/utils/logger';
+import { apiVersionValidator, searchPayloadValidator } from '~/validations/search';
export const apiRouter = new Elysia().group('/api', app =>
app
.onError(({ code, error }) => {
logger.error(error);
-
return {
code,
message: error.message,
@@ -21,7 +18,6 @@ export const apiRouter = new Elysia().group('/api', app =>
'/search',
async ({ body: { link, adapters } }) => {
const searchResult = await search({ link, adapters: adapters as Adapter[] });
-
return searchResult;
},
{
diff --git a/src/routes/page.tsx b/src/routes/page.tsx
index 951bbb2..622d499 100644
--- a/src/routes/page.tsx
+++ b/src/routes/page.tsx
@@ -1,32 +1,25 @@
-import { Elysia } from 'elysia';
import { Html } from '@elysiajs/html';
+import { Elysia, InternalServerError } from 'elysia';
+import { search } from '~/services/search';
import { logger } from '~/utils/logger';
-
import { searchPayloadValidator, searchQueryValidator } from '~/validations/search';
-
-import { search } from '~/services/search';
-
+import ErrorMessage from '~/views/components/error-message';
+import SearchCard from '~/views/components/search-card';
import MainLayout from '~/views/layouts/main';
import Home from '~/views/pages/home';
-import SearchCard from '~/views/components/search-card';
-import ErrorMessage from '~/views/components/error-message';
-
export const pageRouter = new Elysia()
.onError(({ error, code, set }) => {
- logger.error(`[pageRouter]: ${error}`);
+ logger.error(`[pageRouter]: ${code}:${error}`);
if (code === 'NOT_FOUND') {
set.headers = {
'HX-Location': '/',
};
-
return;
}
- set.status = 200;
-
if (code === 'VALIDATION' || code === 'PARSE') {
return ;
}
@@ -50,12 +43,21 @@ export const pageRouter = new Elysia()
);
- } catch (err) {
- logger.error(err);
+ } catch (error) {
+ logger.error(`[indexRoute]: ${error}`);
- set.status = 404;
+ if (error instanceof InternalServerError) {
+ set.status = 404;
+ return redirect('/');
+ }
- return redirect('/');
+ return (
+
+
+
+
+
+ );
}
},
{
@@ -66,7 +68,6 @@ export const pageRouter = new Elysia()
'/search',
async ({ body: { link } }) => {
const searchResult = await search({ link });
-
return ;
},
{
diff --git a/src/services/metadata.ts b/src/services/metadata.ts
index ab674b4..c61d44c 100644
--- a/src/services/metadata.ts
+++ b/src/services/metadata.ts
@@ -1,18 +1,14 @@
-import { ENV } from '~/config/env';
import HttpClient from '~/utils/http-client';
import { logger } from '~/utils/logger';
-export const defaultHeaders = {
- 'User-Agent': `${ENV.adapters.spotify.clientVersion} (Macintosh; Apple Silicon)`,
-};
+export const defaultHeaders = {};
-export async function fetchMetadata(
- link: string,
- headers: Record = defaultHeaders
-) {
+export async function fetchMetadata(link: string, headers: Record = {}) {
const url = link;
-
- const html = await HttpClient.get(url, { headers });
+ const html = await HttpClient.get(url, {
+ ...defaultHeaders,
+ headers,
+ });
logger.info(`[${fetchMetadata.name}] parse metadata: ${url}`);
diff --git a/src/services/search.ts b/src/services/search.ts
index 3484ba9..7b1a1b8 100644
--- a/src/services/search.ts
+++ b/src/services/search.ts
@@ -1,3 +1,5 @@
+import { InternalServerError } from 'elysia';
+
import { getAppleMusicLink } from '~/adapters/apple-music';
import { getDeezerLink } from '~/adapters/deezer';
import { getSoundCloudLink } from '~/adapters/sound-cloud';
@@ -17,11 +19,8 @@ import {
getSoundCloudQueryFromMetadata,
} from '~/parsers/sound-cloud';
import { getSpotifyMetadata, getSpotifyQueryFromMetadata } from '~/parsers/spotify';
-import {
- getTidalMetadata,
- getTidalQueryFromMetadata,
- getUniversalMetadataFromTidal,
-} from '~/parsers/tidal';
+import { getTidalMetadata, getTidalQueryFromMetadata } from '~/parsers/tidal';
+import { getUniversalMetadataFromTidal } from '~/parsers/tidal-universal-link';
import { getYouTubeMetadata, getYouTubeQueryFromMetadata } from '~/parsers/youtube';
import { generateId } from '~/utils/encoding';
import { logger } from '~/utils/logger';
@@ -73,7 +72,7 @@ export const search = async ({
const searchParser = getSearchParser(link, searchId);
- const metadataFetchers = {
+ const metadataFetchersMap = {
[Parser.Spotify]: getSpotifyMetadata,
[Parser.YouTube]: getYouTubeMetadata,
[Parser.AppleMusic]: getAppleMusicMetadata,
@@ -82,7 +81,7 @@ export const search = async ({
[Parser.Tidal]: getTidalMetadata,
};
- const queryExtractors = {
+ const queryExtractorsMap = {
[Parser.Spotify]: getSpotifyQueryFromMetadata,
[Parser.YouTube]: getYouTubeQueryFromMetadata,
[Parser.AppleMusic]: getAppleMusicQueryFromMetadata,
@@ -91,7 +90,7 @@ export const search = async ({
[Parser.Tidal]: getTidalQueryFromMetadata,
};
- const linkGetters = {
+ const linkGettersMap = {
[Adapter.Spotify]: getSpotifyLink,
[Adapter.YouTube]: getYouTubeLink,
[Adapter.AppleMusic]: getAppleMusicLink,
@@ -100,15 +99,15 @@ export const search = async ({
[Adapter.Tidal]: getTidalLink,
};
- const fetchMetadata = metadataFetchers[searchParser.type];
- const extractQuery = queryExtractors[searchParser.type];
+ const metadataFetcher = metadataFetchersMap[searchParser.type];
+ const queryExtractor = queryExtractorsMap[searchParser.type];
- if (!fetchMetadata || !extractQuery) {
- throw new Error('Parser not implemented yet');
+ if (!metadataFetcher || !queryExtractor) {
+ throw new InternalServerError('Parser not implemented yet');
}
- let metadata = await fetchMetadata(searchParser.id, searchParser.source);
- const query = extractQuery(metadata);
+ let metadata = await metadataFetcher(searchParser.id, searchParser.source);
+ const query = queryExtractor(metadata);
const parserType = searchParser.type as StreamingServiceType;
logger.info(
@@ -146,7 +145,6 @@ export const search = async ({
const links: SearchResultLink[] = [];
const existingAdapters = new Set(links.map(link => link.type));
- // Fetch from Tidal first
let tidalLink: SearchResultLink | null = linkSearchResult;
if (parserType !== Adapter.Tidal) {
tidalLink = await getTidalLink(query, metadata);
@@ -189,10 +187,10 @@ export const search = async ({
await Promise.all(
remainingAdapters
.map(adapter => {
- const getLink = linkGetters[adapter];
- if (!getLink) return null;
+ const linkGetter = linkGettersMap[adapter];
+ if (!linkGetter) return null;
- return getLink(query, metadata).then(link => {
+ return linkGetter(query, metadata).then(link => {
if (link) {
links.push({ type: adapter, url: link.url, isVerified: true });
existingAdapters.add(adapter);
diff --git a/src/utils/http-client.ts b/src/utils/http-client.ts
index 10c5b0c..133b66a 100644
--- a/src/utils/http-client.ts
+++ b/src/utils/http-client.ts
@@ -2,7 +2,6 @@ import axios, { AxiosError } from 'axios';
import axiosRetry from 'axios-retry';
import { DEFAULT_TIMEOUT } from '~/config/constants';
-
import { logger } from '~/utils/logger';
type HttpClientOptions = {
@@ -20,33 +19,21 @@ function getRandomUserAgent() {
];
const chromeVersions = [
- '86.0.4240.198',
- '87.0.4280.88',
- '88.0.4324.96',
- '89.0.4389.72',
- '90.0.4430.85',
+ '91.0.4472.124',
+ '92.0.4515.107',
+ '93.0.4577.63',
+ '94.0.4606.71',
+ '95.0.4638.69',
];
- const firefoxVersions = ['84.0', '85.0', '86.0', '87.0', '88.0'];
-
- const isChrome = Math.random() > 0.5;
-
const os = osOptions[Math.floor(Math.random() * osOptions.length)];
+ const chromeVersion = chromeVersions[Math.floor(Math.random() * chromeVersions.length)];
- if (isChrome) {
- const chromeVersion =
- chromeVersions[Math.floor(Math.random() * chromeVersions.length)];
-
- return `Mozilla/5.0 (${os}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
- }
- const firefoxVersion =
- firefoxVersions[Math.floor(Math.random() * firefoxVersions.length)];
-
- return `Mozilla/5.0 (${os}; rv:${firefoxVersion}) Gecko/20100101 Firefox/${firefoxVersion}`;
+ return `Mozilla/5.0 (${os}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36 Edg/91.0.864.67`;
}
axiosRetry(axios, {
- retries: 2,
+ retries: 1,
retryCondition: error => {
// Retry on network errors or 5xx status codes
return axiosRetry.isNetworkError(error) || axiosRetry.isRetryableError(error);
@@ -99,8 +86,9 @@ export default class HttpClient {
return data as T;
} catch (err) {
+ const axiosError = err as AxiosError;
logger.error(
- `[${HttpClient.request.name}] Request failed ${(err as Error).message}`
+ `[${HttpClient.request.name}] Request failed ${axiosError.message} ${axiosError.response ? axiosError.response.data : ''}`
);
logger.debug(err);
throw err;
diff --git a/src/utils/logger.ts b/src/utils/logger.ts
index 6e4465b..73b33e8 100644
--- a/src/utils/logger.ts
+++ b/src/utils/logger.ts
@@ -7,7 +7,7 @@ export const stream = pretty({
export const logger = pino(
{
- level: process.env.NODE_ENV === 'test' ? 'fatal' : 'debug',
+ level: process.env.LOG_LEVEL ?? 'debug',
},
stream
);
diff --git a/src/utils/scraper.ts b/src/utils/scraper.ts
index 2e5b69f..66a474a 100644
--- a/src/utils/scraper.ts
+++ b/src/utils/scraper.ts
@@ -1,68 +1,4 @@
import * as cheerio from 'cheerio';
-import puppeteer, { Browser, CookieParam, Page } from 'puppeteer';
-
-let browser: Browser | null = null;
-
-async function launchBrowser(): Promise {
- if (!browser) {
- browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] });
- }
- return browser;
-}
-
-async function withTimeout(promise: Promise, timeout: number): Promise {
- return Promise.race([
- promise,
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error('Operation timed out')), timeout)
- ),
- ]);
-}
-
-export async function getLinkWithPuppeteer(
- url: string,
- linkQuerySelector: string,
- cookies: CookieParam[] = [],
- timeout: number = 4000 // Default timeout of 4 seconds
-) {
- const browser = await launchBrowser();
- const page: Page = await browser.newPage();
-
- try {
- await page.setUserAgent(
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0'
- );
-
- await page.setCookie(...cookies);
- await page.setRequestInterception(true);
-
- page.on('request', request => {
- if (request.resourceType() === 'image') {
- request.abort();
- } else {
- request.continue();
- }
- });
-
- await page.setViewport({ width: 768, height: 600 });
-
- await withTimeout(page.goto(url, { waitUntil: 'networkidle0' }), timeout);
-
- const href = await withTimeout(
- page.evaluate(
- // eslint-disable-next-line
- // @ts-ignore
- selector => document.querySelector(selector)?.href,
- linkQuerySelector
- ),
- timeout
- );
-
- return href;
- } finally {
- page.close();
- }
-}
export function getCheerioDoc(html: string) {
return cheerio.load(html);
diff --git a/src/validations/search.ts b/src/validations/search.ts
index 5cf093c..e719371 100644
--- a/src/validations/search.ts
+++ b/src/validations/search.ts
@@ -1,4 +1,5 @@
import { t } from 'elysia';
+
import { ALLOWED_LINKS_REGEX } from '~/config/constants';
import { Adapter } from '~/config/enum';
diff --git a/src/views/controllers/search_card_controller.js b/src/views/controllers/search_card_controller.js
index f3c9983..3db6040 100644
--- a/src/views/controllers/search_card_controller.js
+++ b/src/views/controllers/search_card_controller.js
@@ -1,5 +1,6 @@
import { Controller } from '@hotwired/stimulus';
import { Howl } from 'howler';
+
import { copyToClipboard } from './helpers';
export default class extends Controller {
diff --git a/src/views/controllers/search_controller.js b/src/views/controllers/search_controller.js
index 1943bed..803815c 100644
--- a/src/views/controllers/search_controller.js
+++ b/src/views/controllers/search_controller.js
@@ -1,9 +1,9 @@
import { Controller } from '@hotwired/stimulus';
-import { toast } from './helpers';
-
import { SPOTIFY_LINK_REGEX, YOUTUBE_LINK_REGEX } from '~/config/constants';
+import { toast } from './helpers';
+
export default class extends Controller {
static targets = ['form', 'link'];
diff --git a/src/views/layouts/main.tsx b/src/views/layouts/main.tsx
index 527afc1..ad86cbf 100644
--- a/src/views/layouts/main.tsx
+++ b/src/views/layouts/main.tsx
@@ -13,6 +13,8 @@ export default function MainLayout({
image?: string;
children: JSX.Element;
}) {
+ const isProduction = process.env.NODE_ENV === 'production';
+
return (
@@ -59,18 +61,20 @@ export default function MainLayout({
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css"
/>
-
+
{children}
-
+ {isProduction && (
+
+ )}
diff --git a/tests/fixtures/tidal/albumResponseMock.json b/tests/fixtures/tidal/albumResponseMock.json
new file mode 100644
index 0000000..e50785a
--- /dev/null
+++ b/tests/fixtures/tidal/albumResponseMock.json
@@ -0,0 +1,1134 @@
+{
+ "data": [
+ {
+ "id": "320189583",
+ "type": "albums"
+ },
+ {
+ "id": "329293912",
+ "type": "albums"
+ },
+ {
+ "id": "320189476",
+ "type": "albums"
+ },
+ {
+ "id": "320198404",
+ "type": "albums"
+ },
+ {
+ "id": "320199597",
+ "type": "albums"
+ },
+ {
+ "id": "329295367",
+ "type": "albums"
+ },
+ {
+ "id": "329297553",
+ "type": "albums"
+ },
+ {
+ "id": "329295302",
+ "type": "albums"
+ },
+ {
+ "id": "330725461",
+ "type": "albums"
+ },
+ {
+ "id": "330725474",
+ "type": "albums"
+ }
+ ],
+ "links": {
+ "self": "/searchresults/For All The Dogs Drake/relationships/albums?include=albums&countryCode=US"
+ },
+ "included": [
+ {
+ "attributes": {
+ "title": "For All The Dogs",
+ "barcodeId": "00602458700145",
+ "numberOfVolumes": 1,
+ "numberOfItems": 23,
+ "duration": "PT1H24M50S",
+ "explicit": true,
+ "releaseDate": "2023-10-06",
+ "copyright": "© 2023 OVO, under exclusive license to Republic Records, a division of UMG Recordings, Inc.",
+ "popularity": 42.0,
+ "availability": [
+ "STREAM",
+ "DJ"
+ ],
+ "mediaTags": [
+ "LOSSLESS"
+ ],
+ "imageLinks": [
+ {
+ "href": "https://resources.tidal.com/images/e345754d/3361/43f8/b4e9/ddafda46a6e9/1280x1280.jpg",
+ "meta": {
+ "width": 1280,
+ "height": 1280
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/e345754d/3361/43f8/b4e9/ddafda46a6e9/1080x1080.jpg",
+ "meta": {
+ "width": 1080,
+ "height": 1080
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/e345754d/3361/43f8/b4e9/ddafda46a6e9/750x750.jpg",
+ "meta": {
+ "width": 750,
+ "height": 750
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/e345754d/3361/43f8/b4e9/ddafda46a6e9/640x640.jpg",
+ "meta": {
+ "width": 640,
+ "height": 640
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/e345754d/3361/43f8/b4e9/ddafda46a6e9/320x320.jpg",
+ "meta": {
+ "width": 320,
+ "height": 320
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/e345754d/3361/43f8/b4e9/ddafda46a6e9/160x160.jpg",
+ "meta": {
+ "width": 160,
+ "height": 160
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/e345754d/3361/43f8/b4e9/ddafda46a6e9/80x80.jpg",
+ "meta": {
+ "width": 80,
+ "height": 80
+ }
+ }
+ ],
+ "videoLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/album/320189476",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "type": "ALBUM"
+ },
+ "relationships": {
+ "similarAlbums": {
+ "links": {
+ "self": "/albums/320189476/relationships/similarAlbums?countryCode=US"
+ }
+ },
+ "artists": {
+ "links": {
+ "self": "/albums/320189476/relationships/artists?countryCode=US"
+ }
+ },
+ "items": {
+ "links": {
+ "self": "/albums/320189476/relationships/items?countryCode=US"
+ }
+ },
+ "providers": {
+ "links": {
+ "self": "/albums/320189476/relationships/providers?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/albums/320189476?countryCode=US"
+ },
+ "id": "320189476",
+ "type": "albums"
+ },
+ {
+ "attributes": {
+ "title": "For All The Dogs Scary Hours Edition",
+ "barcodeId": "00602458922813",
+ "numberOfVolumes": 2,
+ "numberOfItems": 29,
+ "duration": "PT1H48M48S",
+ "explicit": false,
+ "releaseDate": "2023-10-06",
+ "copyright": "© 2023 OVO, under exclusive license to Republic Records, a division of UMG Recordings, Inc.",
+ "popularity": 23.0,
+ "availability": [
+ "STREAM",
+ "DJ"
+ ],
+ "mediaTags": [
+ "HIRES_LOSSLESS",
+ "LOSSLESS"
+ ],
+ "imageLinks": [
+ {
+ "href": "https://resources.tidal.com/images/533f9030/4087/4b46/a4de/8758cf10e7a1/1280x1280.jpg",
+ "meta": {
+ "width": 1280,
+ "height": 1280
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/533f9030/4087/4b46/a4de/8758cf10e7a1/1080x1080.jpg",
+ "meta": {
+ "width": 1080,
+ "height": 1080
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/533f9030/4087/4b46/a4de/8758cf10e7a1/750x750.jpg",
+ "meta": {
+ "width": 750,
+ "height": 750
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/533f9030/4087/4b46/a4de/8758cf10e7a1/640x640.jpg",
+ "meta": {
+ "width": 640,
+ "height": 640
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/533f9030/4087/4b46/a4de/8758cf10e7a1/320x320.jpg",
+ "meta": {
+ "width": 320,
+ "height": 320
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/533f9030/4087/4b46/a4de/8758cf10e7a1/160x160.jpg",
+ "meta": {
+ "width": 160,
+ "height": 160
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/533f9030/4087/4b46/a4de/8758cf10e7a1/80x80.jpg",
+ "meta": {
+ "width": 80,
+ "height": 80
+ }
+ }
+ ],
+ "videoLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/album/329295302",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "type": "ALBUM"
+ },
+ "relationships": {
+ "similarAlbums": {
+ "links": {
+ "self": "/albums/329295302/relationships/similarAlbums?countryCode=US"
+ }
+ },
+ "artists": {
+ "links": {
+ "self": "/albums/329295302/relationships/artists?countryCode=US"
+ }
+ },
+ "items": {
+ "links": {
+ "self": "/albums/329295302/relationships/items?countryCode=US"
+ }
+ },
+ "providers": {
+ "links": {
+ "self": "/albums/329295302/relationships/providers?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/albums/329295302?countryCode=US"
+ },
+ "id": "329295302",
+ "type": "albums"
+ },
+ {
+ "attributes": {
+ "title": "For All The Dogs Scary Hours Edition (Instrumental)",
+ "barcodeId": "00602458952117",
+ "numberOfVolumes": 1,
+ "numberOfItems": 6,
+ "duration": "PT23M58S",
+ "explicit": false,
+ "releaseDate": "2023-10-06",
+ "copyright": "© 2023 OVO, under exclusive license to Republic Records, a division of UMG Recordings, Inc.",
+ "popularity": 1.0,
+ "availability": [
+ "STREAM",
+ "DJ"
+ ],
+ "mediaTags": [
+ "LOSSLESS"
+ ],
+ "imageLinks": [
+ {
+ "href": "https://resources.tidal.com/images/fd5e62f3/63e6/4ae6/95e1/d6c6200eb254/1280x1280.jpg",
+ "meta": {
+ "width": 1280,
+ "height": 1280
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/fd5e62f3/63e6/4ae6/95e1/d6c6200eb254/1080x1080.jpg",
+ "meta": {
+ "width": 1080,
+ "height": 1080
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/fd5e62f3/63e6/4ae6/95e1/d6c6200eb254/750x750.jpg",
+ "meta": {
+ "width": 750,
+ "height": 750
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/fd5e62f3/63e6/4ae6/95e1/d6c6200eb254/640x640.jpg",
+ "meta": {
+ "width": 640,
+ "height": 640
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/fd5e62f3/63e6/4ae6/95e1/d6c6200eb254/320x320.jpg",
+ "meta": {
+ "width": 320,
+ "height": 320
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/fd5e62f3/63e6/4ae6/95e1/d6c6200eb254/160x160.jpg",
+ "meta": {
+ "width": 160,
+ "height": 160
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/fd5e62f3/63e6/4ae6/95e1/d6c6200eb254/80x80.jpg",
+ "meta": {
+ "width": 80,
+ "height": 80
+ }
+ }
+ ],
+ "videoLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/album/330725474",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "type": "ALBUM"
+ },
+ "relationships": {
+ "similarAlbums": {
+ "links": {
+ "self": "/albums/330725474/relationships/similarAlbums?countryCode=US"
+ }
+ },
+ "artists": {
+ "links": {
+ "self": "/albums/330725474/relationships/artists?countryCode=US"
+ }
+ },
+ "items": {
+ "links": {
+ "self": "/albums/330725474/relationships/items?countryCode=US"
+ }
+ },
+ "providers": {
+ "links": {
+ "self": "/albums/330725474/relationships/providers?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/albums/330725474?countryCode=US"
+ },
+ "id": "330725474",
+ "type": "albums"
+ },
+ {
+ "attributes": {
+ "title": "For All The Dogs",
+ "barcodeId": "00602458700138",
+ "numberOfVolumes": 1,
+ "numberOfItems": 23,
+ "duration": "PT1H24M50S",
+ "explicit": false,
+ "releaseDate": "2023-10-06",
+ "copyright": "© 2023 OVO, under exclusive license to Republic Records, a division of UMG Recordings, Inc.",
+ "popularity": 42.0,
+ "availability": [
+ "STREAM",
+ "DJ"
+ ],
+ "mediaTags": [
+ "HIRES_LOSSLESS",
+ "LOSSLESS"
+ ],
+ "imageLinks": [
+ {
+ "href": "https://resources.tidal.com/images/5d7f58dd/5476/458c/9e5e/5af9a68c88c2/1280x1280.jpg",
+ "meta": {
+ "width": 1280,
+ "height": 1280
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/5d7f58dd/5476/458c/9e5e/5af9a68c88c2/1080x1080.jpg",
+ "meta": {
+ "width": 1080,
+ "height": 1080
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/5d7f58dd/5476/458c/9e5e/5af9a68c88c2/750x750.jpg",
+ "meta": {
+ "width": 750,
+ "height": 750
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/5d7f58dd/5476/458c/9e5e/5af9a68c88c2/640x640.jpg",
+ "meta": {
+ "width": 640,
+ "height": 640
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/5d7f58dd/5476/458c/9e5e/5af9a68c88c2/320x320.jpg",
+ "meta": {
+ "width": 320,
+ "height": 320
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/5d7f58dd/5476/458c/9e5e/5af9a68c88c2/160x160.jpg",
+ "meta": {
+ "width": 160,
+ "height": 160
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/5d7f58dd/5476/458c/9e5e/5af9a68c88c2/80x80.jpg",
+ "meta": {
+ "width": 80,
+ "height": 80
+ }
+ }
+ ],
+ "videoLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/album/320198404",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "type": "ALBUM"
+ },
+ "relationships": {
+ "similarAlbums": {
+ "links": {
+ "self": "/albums/320198404/relationships/similarAlbums?countryCode=US"
+ }
+ },
+ "artists": {
+ "links": {
+ "self": "/albums/320198404/relationships/artists?countryCode=US"
+ }
+ },
+ "items": {
+ "links": {
+ "self": "/albums/320198404/relationships/items?countryCode=US"
+ }
+ },
+ "providers": {
+ "links": {
+ "self": "/albums/320198404/relationships/providers?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/albums/320198404?countryCode=US"
+ },
+ "id": "320198404",
+ "type": "albums"
+ },
+ {
+ "attributes": {
+ "title": "For All The Dogs Scary Hours Edition",
+ "barcodeId": "00602458922851",
+ "numberOfVolumes": 2,
+ "numberOfItems": 29,
+ "duration": "PT1H48M48S",
+ "explicit": true,
+ "releaseDate": "2023-10-06",
+ "copyright": "© 2023 OVO, under exclusive license to Republic Records, a division of UMG Recordings, Inc.",
+ "popularity": 24.0,
+ "availability": [
+ "STREAM",
+ "DJ"
+ ],
+ "mediaTags": [
+ "LOSSLESS"
+ ],
+ "imageLinks": [
+ {
+ "href": "https://resources.tidal.com/images/1ad59134/5ac3/4344/82d2/8cc00a7760b9/1280x1280.jpg",
+ "meta": {
+ "width": 1280,
+ "height": 1280
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/1ad59134/5ac3/4344/82d2/8cc00a7760b9/1080x1080.jpg",
+ "meta": {
+ "width": 1080,
+ "height": 1080
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/1ad59134/5ac3/4344/82d2/8cc00a7760b9/750x750.jpg",
+ "meta": {
+ "width": 750,
+ "height": 750
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/1ad59134/5ac3/4344/82d2/8cc00a7760b9/640x640.jpg",
+ "meta": {
+ "width": 640,
+ "height": 640
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/1ad59134/5ac3/4344/82d2/8cc00a7760b9/320x320.jpg",
+ "meta": {
+ "width": 320,
+ "height": 320
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/1ad59134/5ac3/4344/82d2/8cc00a7760b9/160x160.jpg",
+ "meta": {
+ "width": 160,
+ "height": 160
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/1ad59134/5ac3/4344/82d2/8cc00a7760b9/80x80.jpg",
+ "meta": {
+ "width": 80,
+ "height": 80
+ }
+ }
+ ],
+ "videoLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/album/329297553",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "type": "ALBUM"
+ },
+ "relationships": {
+ "similarAlbums": {
+ "links": {
+ "self": "/albums/329297553/relationships/similarAlbums?countryCode=US"
+ }
+ },
+ "artists": {
+ "links": {
+ "self": "/albums/329297553/relationships/artists?countryCode=US"
+ }
+ },
+ "items": {
+ "links": {
+ "self": "/albums/329297553/relationships/items?countryCode=US"
+ }
+ },
+ "providers": {
+ "links": {
+ "self": "/albums/329297553/relationships/providers?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/albums/329297553?countryCode=US"
+ },
+ "id": "329297553",
+ "type": "albums"
+ },
+ {
+ "attributes": {
+ "title": "For All The Dogs Scary Hours Edition",
+ "barcodeId": "00602458922790",
+ "numberOfVolumes": 2,
+ "numberOfItems": 29,
+ "duration": "PT1H48M48S",
+ "explicit": false,
+ "releaseDate": "2023-10-06",
+ "copyright": "© 2023 OVO, under exclusive license to Republic Records, a division of UMG Recordings, Inc.",
+ "popularity": 32.0,
+ "availability": [
+ "STREAM",
+ "DJ"
+ ],
+ "mediaTags": [
+ "LOSSLESS"
+ ],
+ "imageLinks": [
+ {
+ "href": "https://resources.tidal.com/images/55ccfb6f/ecba/4a95/b74d/52139b942f28/1280x1280.jpg",
+ "meta": {
+ "width": 1280,
+ "height": 1280
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/55ccfb6f/ecba/4a95/b74d/52139b942f28/1080x1080.jpg",
+ "meta": {
+ "width": 1080,
+ "height": 1080
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/55ccfb6f/ecba/4a95/b74d/52139b942f28/750x750.jpg",
+ "meta": {
+ "width": 750,
+ "height": 750
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/55ccfb6f/ecba/4a95/b74d/52139b942f28/640x640.jpg",
+ "meta": {
+ "width": 640,
+ "height": 640
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/55ccfb6f/ecba/4a95/b74d/52139b942f28/320x320.jpg",
+ "meta": {
+ "width": 320,
+ "height": 320
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/55ccfb6f/ecba/4a95/b74d/52139b942f28/160x160.jpg",
+ "meta": {
+ "width": 160,
+ "height": 160
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/55ccfb6f/ecba/4a95/b74d/52139b942f28/80x80.jpg",
+ "meta": {
+ "width": 80,
+ "height": 80
+ }
+ }
+ ],
+ "videoLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/album/329295367",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "type": "ALBUM"
+ },
+ "relationships": {
+ "similarAlbums": {
+ "links": {
+ "self": "/albums/329295367/relationships/similarAlbums?countryCode=US"
+ }
+ },
+ "artists": {
+ "links": {
+ "self": "/albums/329295367/relationships/artists?countryCode=US"
+ }
+ },
+ "items": {
+ "links": {
+ "self": "/albums/329295367/relationships/items?countryCode=US"
+ }
+ },
+ "providers": {
+ "links": {
+ "self": "/albums/329295367/relationships/providers?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/albums/329295367?countryCode=US"
+ },
+ "id": "329295367",
+ "type": "albums"
+ },
+ {
+ "attributes": {
+ "title": "For All The Dogs Scary Hours Edition (Instrumental)",
+ "barcodeId": "00602458952186",
+ "numberOfVolumes": 1,
+ "numberOfItems": 6,
+ "duration": "PT23M58S",
+ "explicit": false,
+ "releaseDate": "2023-10-06",
+ "copyright": "© 2023 OVO, under exclusive license to Republic Records, a division of UMG Recordings, Inc.",
+ "popularity": 16.0,
+ "availability": [
+ "STREAM",
+ "DJ"
+ ],
+ "mediaTags": [
+ "HIRES_LOSSLESS",
+ "LOSSLESS"
+ ],
+ "imageLinks": [
+ {
+ "href": "https://resources.tidal.com/images/6937fa09/c0eb/40b7/a8f2/1abb03d67716/1280x1280.jpg",
+ "meta": {
+ "width": 1280,
+ "height": 1280
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/6937fa09/c0eb/40b7/a8f2/1abb03d67716/1080x1080.jpg",
+ "meta": {
+ "width": 1080,
+ "height": 1080
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/6937fa09/c0eb/40b7/a8f2/1abb03d67716/750x750.jpg",
+ "meta": {
+ "width": 750,
+ "height": 750
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/6937fa09/c0eb/40b7/a8f2/1abb03d67716/640x640.jpg",
+ "meta": {
+ "width": 640,
+ "height": 640
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/6937fa09/c0eb/40b7/a8f2/1abb03d67716/320x320.jpg",
+ "meta": {
+ "width": 320,
+ "height": 320
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/6937fa09/c0eb/40b7/a8f2/1abb03d67716/160x160.jpg",
+ "meta": {
+ "width": 160,
+ "height": 160
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/6937fa09/c0eb/40b7/a8f2/1abb03d67716/80x80.jpg",
+ "meta": {
+ "width": 80,
+ "height": 80
+ }
+ }
+ ],
+ "videoLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/album/330725461",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "type": "ALBUM"
+ },
+ "relationships": {
+ "similarAlbums": {
+ "links": {
+ "self": "/albums/330725461/relationships/similarAlbums?countryCode=US"
+ }
+ },
+ "artists": {
+ "links": {
+ "self": "/albums/330725461/relationships/artists?countryCode=US"
+ }
+ },
+ "items": {
+ "links": {
+ "self": "/albums/330725461/relationships/items?countryCode=US"
+ }
+ },
+ "providers": {
+ "links": {
+ "self": "/albums/330725461/relationships/providers?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/albums/330725461?countryCode=US"
+ },
+ "id": "330725461",
+ "type": "albums"
+ },
+ {
+ "attributes": {
+ "title": "For All The Dogs",
+ "barcodeId": "00602458700121",
+ "numberOfVolumes": 1,
+ "numberOfItems": 23,
+ "duration": "PT1H24M50S",
+ "explicit": true,
+ "releaseDate": "2023-10-06",
+ "copyright": "© 2023 OVO, under exclusive license to Republic Records, a division of UMG Recordings, Inc.",
+ "popularity": 83.0,
+ "availability": [
+ "STREAM",
+ "DJ"
+ ],
+ "mediaTags": [
+ "HIRES_LOSSLESS",
+ "LOSSLESS"
+ ],
+ "imageLinks": [
+ {
+ "href": "https://resources.tidal.com/images/6f79e5d7/26cd/4aee/95eb/46f91ffcf1c2/1280x1280.jpg",
+ "meta": {
+ "width": 1280,
+ "height": 1280
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/6f79e5d7/26cd/4aee/95eb/46f91ffcf1c2/1080x1080.jpg",
+ "meta": {
+ "width": 1080,
+ "height": 1080
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/6f79e5d7/26cd/4aee/95eb/46f91ffcf1c2/750x750.jpg",
+ "meta": {
+ "width": 750,
+ "height": 750
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/6f79e5d7/26cd/4aee/95eb/46f91ffcf1c2/640x640.jpg",
+ "meta": {
+ "width": 640,
+ "height": 640
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/6f79e5d7/26cd/4aee/95eb/46f91ffcf1c2/320x320.jpg",
+ "meta": {
+ "width": 320,
+ "height": 320
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/6f79e5d7/26cd/4aee/95eb/46f91ffcf1c2/160x160.jpg",
+ "meta": {
+ "width": 160,
+ "height": 160
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/6f79e5d7/26cd/4aee/95eb/46f91ffcf1c2/80x80.jpg",
+ "meta": {
+ "width": 80,
+ "height": 80
+ }
+ }
+ ],
+ "videoLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/album/320189583",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "type": "ALBUM"
+ },
+ "relationships": {
+ "similarAlbums": {
+ "links": {
+ "self": "/albums/320189583/relationships/similarAlbums?countryCode=US"
+ }
+ },
+ "artists": {
+ "links": {
+ "self": "/albums/320189583/relationships/artists?countryCode=US"
+ }
+ },
+ "items": {
+ "links": {
+ "self": "/albums/320189583/relationships/items?countryCode=US"
+ }
+ },
+ "providers": {
+ "links": {
+ "self": "/albums/320189583/relationships/providers?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/albums/320189583?countryCode=US"
+ },
+ "id": "320189583",
+ "type": "albums"
+ },
+ {
+ "attributes": {
+ "title": "For All The Dogs Scary Hours Edition",
+ "barcodeId": "00602458922769",
+ "numberOfVolumes": 2,
+ "numberOfItems": 29,
+ "duration": "PT1H48M48S",
+ "explicit": true,
+ "releaseDate": "2023-10-06",
+ "copyright": "© 2023 OVO, under exclusive license to Republic Records, a division of UMG Recordings, Inc.",
+ "popularity": 73.0,
+ "availability": [
+ "STREAM",
+ "DJ"
+ ],
+ "mediaTags": [
+ "HIRES_LOSSLESS",
+ "LOSSLESS"
+ ],
+ "imageLinks": [
+ {
+ "href": "https://resources.tidal.com/images/162dc7c9/3935/4746/9eb8/3582a276ce40/1280x1280.jpg",
+ "meta": {
+ "width": 1280,
+ "height": 1280
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/162dc7c9/3935/4746/9eb8/3582a276ce40/1080x1080.jpg",
+ "meta": {
+ "width": 1080,
+ "height": 1080
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/162dc7c9/3935/4746/9eb8/3582a276ce40/750x750.jpg",
+ "meta": {
+ "width": 750,
+ "height": 750
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/162dc7c9/3935/4746/9eb8/3582a276ce40/640x640.jpg",
+ "meta": {
+ "width": 640,
+ "height": 640
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/162dc7c9/3935/4746/9eb8/3582a276ce40/320x320.jpg",
+ "meta": {
+ "width": 320,
+ "height": 320
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/162dc7c9/3935/4746/9eb8/3582a276ce40/160x160.jpg",
+ "meta": {
+ "width": 160,
+ "height": 160
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/162dc7c9/3935/4746/9eb8/3582a276ce40/80x80.jpg",
+ "meta": {
+ "width": 80,
+ "height": 80
+ }
+ }
+ ],
+ "videoLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/album/329293912",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "type": "ALBUM"
+ },
+ "relationships": {
+ "similarAlbums": {
+ "links": {
+ "self": "/albums/329293912/relationships/similarAlbums?countryCode=US"
+ }
+ },
+ "artists": {
+ "links": {
+ "self": "/albums/329293912/relationships/artists?countryCode=US"
+ }
+ },
+ "items": {
+ "links": {
+ "self": "/albums/329293912/relationships/items?countryCode=US"
+ }
+ },
+ "providers": {
+ "links": {
+ "self": "/albums/329293912/relationships/providers?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/albums/329293912?countryCode=US"
+ },
+ "id": "329293912",
+ "type": "albums"
+ },
+ {
+ "attributes": {
+ "title": "For All The Dogs",
+ "barcodeId": "00602458700152",
+ "numberOfVolumes": 1,
+ "numberOfItems": 23,
+ "duration": "PT1H24M50S",
+ "explicit": false,
+ "releaseDate": "2023-10-06",
+ "copyright": "© 2023 OVO, under exclusive license to Republic Records, a division of UMG Recordings, Inc.",
+ "popularity": 32.0,
+ "availability": [
+ "STREAM",
+ "DJ"
+ ],
+ "mediaTags": [
+ "LOSSLESS"
+ ],
+ "imageLinks": [
+ {
+ "href": "https://resources.tidal.com/images/debeab5c/d7e3/41ff/9107/0836c5144251/1280x1280.jpg",
+ "meta": {
+ "width": 1280,
+ "height": 1280
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/debeab5c/d7e3/41ff/9107/0836c5144251/1080x1080.jpg",
+ "meta": {
+ "width": 1080,
+ "height": 1080
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/debeab5c/d7e3/41ff/9107/0836c5144251/750x750.jpg",
+ "meta": {
+ "width": 750,
+ "height": 750
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/debeab5c/d7e3/41ff/9107/0836c5144251/640x640.jpg",
+ "meta": {
+ "width": 640,
+ "height": 640
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/debeab5c/d7e3/41ff/9107/0836c5144251/320x320.jpg",
+ "meta": {
+ "width": 320,
+ "height": 320
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/debeab5c/d7e3/41ff/9107/0836c5144251/160x160.jpg",
+ "meta": {
+ "width": 160,
+ "height": 160
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/debeab5c/d7e3/41ff/9107/0836c5144251/80x80.jpg",
+ "meta": {
+ "width": 80,
+ "height": 80
+ }
+ }
+ ],
+ "videoLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/album/320199597",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "type": "ALBUM"
+ },
+ "relationships": {
+ "similarAlbums": {
+ "links": {
+ "self": "/albums/320199597/relationships/similarAlbums?countryCode=US"
+ }
+ },
+ "artists": {
+ "links": {
+ "self": "/albums/320199597/relationships/artists?countryCode=US"
+ }
+ },
+ "items": {
+ "links": {
+ "self": "/albums/320199597/relationships/items?countryCode=US"
+ }
+ },
+ "providers": {
+ "links": {
+ "self": "/albums/320199597/relationships/providers?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/albums/320199597?countryCode=US"
+ },
+ "id": "320199597",
+ "type": "albums"
+ }
+ ]
+}
diff --git a/tests/fixtures/tidal/artistResponseMock.json b/tests/fixtures/tidal/artistResponseMock.json
new file mode 100644
index 0000000..2741565
--- /dev/null
+++ b/tests/fixtures/tidal/artistResponseMock.json
@@ -0,0 +1,1332 @@
+{
+ "data": [
+ {
+ "id": "3652822",
+ "type": "artists"
+ },
+ {
+ "id": "38702458",
+ "type": "artists"
+ },
+ {
+ "id": "3610096",
+ "type": "artists"
+ },
+ {
+ "id": "4801418",
+ "type": "artists"
+ },
+ {
+ "id": "8686233",
+ "type": "artists"
+ },
+ {
+ "id": "17797474",
+ "type": "artists"
+ },
+ {
+ "id": "15275831",
+ "type": "artists"
+ },
+ {
+ "id": "37628112",
+ "type": "artists"
+ },
+ {
+ "id": "16845889",
+ "type": "artists"
+ },
+ {
+ "id": "12786709",
+ "type": "artists"
+ },
+ {
+ "id": "16148702",
+ "type": "artists"
+ },
+ {
+ "id": "36838746",
+ "type": "artists"
+ },
+ {
+ "id": "28583973",
+ "type": "artists"
+ },
+ {
+ "id": "19685416",
+ "type": "artists"
+ },
+ {
+ "id": "45743262",
+ "type": "artists"
+ },
+ {
+ "id": "23962961",
+ "type": "artists"
+ },
+ {
+ "id": "36722767",
+ "type": "artists"
+ },
+ {
+ "id": "28974977",
+ "type": "artists"
+ },
+ {
+ "id": "47244196",
+ "type": "artists"
+ },
+ {
+ "id": "11462555",
+ "type": "artists"
+ }
+ ],
+ "links": {
+ "self": "/searchresults/J. Cole/relationships/artists?include=artists&countryCode=US",
+ "next": "/searchresults/J. Cole/relationships/artists?include=artists&countryCode=US&page%5Bcursor%5D=3nI1Esi"
+ },
+ "included": [
+ {
+ "attributes": {
+ "name": "Tijuan J Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/16148702",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/16148702/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/16148702/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/16148702/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/16148702/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/16148702/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/16148702/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/16148702/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/16148702?countryCode=US"
+ },
+ "id": "16148702",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "J. Cole and Nas",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/23962961",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/23962961/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/23962961/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/23962961/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/23962961/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/23962961/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/23962961/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/23962961/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/23962961?countryCode=US"
+ },
+ "id": "23962961",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "D J Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/37628112",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/37628112/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/37628112/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/37628112/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/37628112/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/37628112/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/37628112/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/37628112/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/37628112?countryCode=US"
+ },
+ "id": "37628112",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "J- Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/15275831",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/15275831/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/15275831/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/15275831/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/15275831/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/15275831/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/15275831/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/15275831/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/15275831?countryCode=US"
+ },
+ "id": "15275831",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "B. J. Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/3610096",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "ARTIST",
+ "PERFORMER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/3610096/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/3610096/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/3610096/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/3610096/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/3610096/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/3610096/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/3610096/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/3610096?countryCode=US"
+ },
+ "id": "3610096",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "J Cole x Isaiah Rashad x Joey Badass",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/45743262",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "PRODUCER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/45743262/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/45743262/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/45743262/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/45743262/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/45743262/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/45743262/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/45743262/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/45743262?countryCode=US"
+ },
+ "id": "45743262",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "B J Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/11462555",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "PERFORMER",
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/11462555/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/11462555/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/11462555/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/11462555/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/11462555/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/11462555/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/11462555/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/11462555?countryCode=US"
+ },
+ "id": "11462555",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "Thomas J Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/28583973",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/28583973/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/28583973/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/28583973/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/28583973/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/28583973/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/28583973/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/28583973/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/28583973?countryCode=US"
+ },
+ "id": "28583973",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "G-Money / Bryson Tiller / J Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/17797474",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/17797474/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/17797474/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/17797474/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/17797474/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/17797474/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/17797474/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/17797474/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/17797474?countryCode=US"
+ },
+ "id": "17797474",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "Cameron J Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/28974977",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/28974977/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/28974977/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/28974977/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/28974977/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/28974977/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/28974977/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/28974977/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/28974977?countryCode=US"
+ },
+ "id": "28974977",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "Sandra J Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/36838746",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "ARTIST"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/36838746/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/36838746/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/36838746/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/36838746/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/36838746/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/36838746/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/36838746/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/36838746?countryCode=US"
+ },
+ "id": "36838746",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "Teagan J Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/47244196",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/47244196/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/47244196/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/47244196/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/47244196/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/47244196/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/47244196/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/47244196/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/47244196?countryCode=US"
+ },
+ "id": "47244196",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "John J. Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/16845889",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/16845889/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/16845889/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/16845889/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/16845889/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/16845889/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/16845889/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/16845889/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/16845889?countryCode=US"
+ },
+ "id": "16845889",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "C J Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/4801418",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "ARTIST"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/4801418/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/4801418/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/4801418/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/4801418/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/4801418/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/4801418/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/4801418/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/4801418?countryCode=US"
+ },
+ "id": "4801418",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "J Cole Nelson",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/12786709",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/12786709/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/12786709/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/12786709/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/12786709/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/12786709/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/12786709/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/12786709/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/12786709?countryCode=US"
+ },
+ "id": "12786709",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "Melvin J Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/19685416",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/19685416/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/19685416/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/19685416/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/19685416/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/19685416/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/19685416/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/19685416/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/19685416?countryCode=US"
+ },
+ "id": "19685416",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "J. Cole",
+ "popularity": 50.0,
+ "imageLinks": [
+ {
+ "href": "https://resources.tidal.com/images/99231b6e/176f/49cf/a978/b9c31308cb7b/750x750.jpg",
+ "meta": {
+ "width": 750,
+ "height": 750
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/99231b6e/176f/49cf/a978/b9c31308cb7b/480x480.jpg",
+ "meta": {
+ "width": 480,
+ "height": 480
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/99231b6e/176f/49cf/a978/b9c31308cb7b/320x320.jpg",
+ "meta": {
+ "width": 320,
+ "height": 320
+ }
+ },
+ {
+ "href": "https://resources.tidal.com/images/99231b6e/176f/49cf/a978/b9c31308cb7b/160x160.jpg",
+ "meta": {
+ "width": 160,
+ "height": 160
+ }
+ }
+ ],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/3652822",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "ARTIST",
+ "SONGWRITER",
+ "PRODUCER",
+ "PERFORMER",
+ "PRODUCTION_TEAM",
+ "ENGINEER",
+ null
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/3652822/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/3652822/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/3652822/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/3652822/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/3652822/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/3652822/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/3652822/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/3652822?countryCode=US"
+ },
+ "id": "3652822",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "Ian J Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/8686233",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "ARTIST",
+ "SONGWRITER",
+ "PRODUCER",
+ "PERFORMER",
+ "ENGINEER",
+ null
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/8686233/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/8686233/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/8686233/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/8686233/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/8686233/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/8686233/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/8686233/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/8686233?countryCode=US"
+ },
+ "id": "8686233",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "Nicholas J Cole",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/36722767",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/36722767/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/36722767/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/36722767/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/36722767/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/36722767/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/36722767/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/36722767/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/36722767?countryCode=US"
+ },
+ "id": "36722767",
+ "type": "artists"
+ },
+ {
+ "attributes": {
+ "name": "J. Cole -",
+ "popularity": 0.0,
+ "imageLinks": [],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/artist/38702458",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ],
+ "roles": [
+ "SONGWRITER"
+ ]
+ },
+ "relationships": {
+ "similarArtists": {
+ "links": {
+ "self": "/artists/38702458/relationships/similarArtists?countryCode=US"
+ }
+ },
+ "albums": {
+ "links": {
+ "self": "/artists/38702458/relationships/albums?countryCode=US"
+ }
+ },
+ "roles": {
+ "links": {
+ "self": "/artists/38702458/relationships/roles?countryCode=US"
+ }
+ },
+ "videos": {
+ "links": {
+ "self": "/artists/38702458/relationships/videos?countryCode=US"
+ }
+ },
+ "trackProviders": {
+ "links": {
+ "self": "/artists/38702458/relationships/trackProviders?countryCode=US"
+ }
+ },
+ "tracks": {
+ "links": {
+ "self": "/artists/38702458/relationships/tracks?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/artists/38702458/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/artists/38702458?countryCode=US"
+ },
+ "id": "38702458",
+ "type": "artists"
+ }
+ ]
+}
diff --git a/tests/fixtures/tidal/emptyResponseMock.json b/tests/fixtures/tidal/emptyResponseMock.json
new file mode 100644
index 0000000..e834894
--- /dev/null
+++ b/tests/fixtures/tidal/emptyResponseMock.json
@@ -0,0 +1,7 @@
+{
+ "data": [],
+ "links": {
+ "self": "/searchresults/Do Not Disturb Drake/relationships/tracks?include=tracks&countryCode=US"
+ },
+ "included": []
+}
diff --git a/tests/fixtures/tidal/songResponseMock.json b/tests/fixtures/tidal/songResponseMock.json
new file mode 100644
index 0000000..5367298
--- /dev/null
+++ b/tests/fixtures/tidal/songResponseMock.json
@@ -0,0 +1,192 @@
+{
+ "data": [
+ {
+ "id": "71717750",
+ "type": "tracks"
+ },
+ {
+ "id": "234416610",
+ "type": "tracks"
+ },
+ {
+ "id": "71989673",
+ "type": "tracks"
+ }
+ ],
+ "links": {
+ "self": "/searchresults/Do Not Disturb Drake/relationships/tracks?include=tracks&countryCode=US"
+ },
+ "included": [
+ {
+ "attributes": {
+ "title": "Do Not Disturb",
+ "isrc": "USCM51700050",
+ "duration": "PT4M43S",
+ "copyright": "℗ 2017 Young Money Entertainment/Cash Money Records",
+ "explicit": false,
+ "popularity": 0.27,
+ "availability": [
+ "STREAM",
+ "DJ"
+ ],
+ "mediaTags": [
+ "LOSSLESS"
+ ],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/track/71989673",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ]
+ },
+ "relationships": {
+ "albums": {
+ "links": {
+ "self": "/tracks/71989673/relationships/albums?countryCode=US"
+ }
+ },
+ "artists": {
+ "links": {
+ "self": "/tracks/71989673/relationships/artists?countryCode=US"
+ }
+ },
+ "similarTracks": {
+ "links": {
+ "self": "/tracks/71989673/relationships/similarTracks?countryCode=US"
+ }
+ },
+ "providers": {
+ "links": {
+ "self": "/tracks/71989673/relationships/providers?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/tracks/71989673/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/tracks/71989673?countryCode=US"
+ },
+ "id": "71989673",
+ "type": "tracks"
+ },
+ {
+ "attributes": {
+ "title": "Do Not Disturb",
+ "isrc": "USCM51700049",
+ "duration": "PT4M44S",
+ "copyright": "℗ 2017 Young Money Entertainment/Cash Money Records",
+ "explicit": true,
+ "popularity": 0.6,
+ "availability": [
+ "STREAM",
+ "DJ"
+ ],
+ "mediaTags": [
+ "LOSSLESS"
+ ],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/track/71717750",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ]
+ },
+ "relationships": {
+ "albums": {
+ "links": {
+ "self": "/tracks/71717750/relationships/albums?countryCode=US"
+ }
+ },
+ "artists": {
+ "links": {
+ "self": "/tracks/71717750/relationships/artists?countryCode=US"
+ }
+ },
+ "similarTracks": {
+ "links": {
+ "self": "/tracks/71717750/relationships/similarTracks?countryCode=US"
+ }
+ },
+ "providers": {
+ "links": {
+ "self": "/tracks/71717750/relationships/providers?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/tracks/71717750/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/tracks/71717750?countryCode=US"
+ },
+ "id": "71717750",
+ "type": "tracks"
+ },
+ {
+ "attributes": {
+ "title": "Do Not Disturb (Drake Remix)",
+ "isrc": "QZK6L2266049",
+ "duration": "PT2M48S",
+ "copyright": "SelfMade.",
+ "explicit": true,
+ "popularity": 0.0,
+ "availability": [
+ "STREAM",
+ "DJ"
+ ],
+ "mediaTags": [
+ "LOSSLESS"
+ ],
+ "externalLinks": [
+ {
+ "href": "https://tidal.com/browse/track/234416610",
+ "meta": {
+ "type": "TIDAL_SHARING"
+ }
+ }
+ ]
+ },
+ "relationships": {
+ "albums": {
+ "links": {
+ "self": "/tracks/234416610/relationships/albums?countryCode=US"
+ }
+ },
+ "artists": {
+ "links": {
+ "self": "/tracks/234416610/relationships/artists?countryCode=US"
+ }
+ },
+ "similarTracks": {
+ "links": {
+ "self": "/tracks/234416610/relationships/similarTracks?countryCode=US"
+ }
+ },
+ "providers": {
+ "links": {
+ "self": "/tracks/234416610/relationships/providers?countryCode=US"
+ }
+ },
+ "radio": {
+ "links": {
+ "self": "/tracks/234416610/relationships/radio?countryCode=US"
+ }
+ }
+ },
+ "links": {
+ "self": "/tracks/234416610?countryCode=US"
+ },
+ "id": "234416610",
+ "type": "tracks"
+ }
+ ]
+}
diff --git a/tests/fixtures/youtube/albumResponseMock.json b/tests/fixtures/youtube/albumResponseMock.json
new file mode 100644
index 0000000..a6793bf
--- /dev/null
+++ b/tests/fixtures/youtube/albumResponseMock.json
@@ -0,0 +1,52 @@
+{
+ "kind": "youtube#searchListResponse",
+ "etag": "fFArRdD9gycv2X5Lff-Xmx3Ol04",
+ "nextPageToken": "CAUQAA",
+ "regionCode": "US",
+ "pageInfo": {
+ "totalResults": 52428,
+ "resultsPerPage": 5
+ },
+ "items": [
+ {
+ "kind": "youtube#searchResult",
+ "etag": "MScnAZUJK85ZfIVTHjnvJugWGF4",
+ "id": {
+ "kind": "youtube#playlist",
+ "playlistId": "PLbUIPZJL6vw-7ef4PuuPJhaK3K-0UxRKO"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "jNTta5ON1dDHq1sYvditYCRSr9s",
+ "id": {
+ "kind": "youtube#playlist",
+ "playlistId": "PLGxQs-Q59UneXtjndClk4s5T108T8fD5S"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "8_Y5Ifnb2zzQjyn0gxTXIRHowpk",
+ "id": {
+ "kind": "youtube#playlist",
+ "playlistId": "PL32REY8qN7H0U63mEP4SzkFs_q9A7UpHe"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "_qrTvcWIs0typswN57hBQZ6Fr5I",
+ "id": {
+ "kind": "youtube#playlist",
+ "playlistId": "PLKZWLu6q09LNWSEty4ZFc8bBHQkXOFH9v"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "ddQ7rvWe4-j8JL2_yfN-q_o8TGU",
+ "id": {
+ "kind": "youtube#playlist",
+ "playlistId": "PLzD6fY9PJrF0zU7v3VdaNHEzyw5yf8u8X"
+ }
+ }
+ ]
+}
diff --git a/tests/fixtures/youtube/artistResponseMock.json b/tests/fixtures/youtube/artistResponseMock.json
new file mode 100644
index 0000000..11c3238
--- /dev/null
+++ b/tests/fixtures/youtube/artistResponseMock.json
@@ -0,0 +1,52 @@
+{
+ "kind": "youtube#searchListResponse",
+ "etag": "4xyDz34sLpcy2SvE1CaVxJBLS18",
+ "nextPageToken": "CAUQAA",
+ "regionCode": "US",
+ "pageInfo": {
+ "totalResults": 23711,
+ "resultsPerPage": 5
+ },
+ "items": [
+ {
+ "kind": "youtube#searchResult",
+ "etag": "c3MYDufnrKc9diMF5OMbwpPBG-g",
+ "id": {
+ "kind": "youtube#channel",
+ "channelId": "UCByOQJjav0CUDwxCk-jVNRQ"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "jFoT0-Fx7u_G8PLyHfBI5o50avw",
+ "id": {
+ "kind": "youtube#channel",
+ "channelId": "UCSePgMI6WVzSE7rjiNj6jnA"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "ASmpFt7gK6oWFVf0Ro6q61pV27s",
+ "id": {
+ "kind": "youtube#channel",
+ "channelId": "UCbXFIDyUzy1nqFEh1hfy27w"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "LOB2Qbyc242lAZvMTI3tUZF46p8",
+ "id": {
+ "kind": "youtube#channel",
+ "channelId": "UCdNrmY2q0EfwmKSv5JA4SSg"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "O7Eq9ODf4gIyMA39dga6aoYjxqw",
+ "id": {
+ "kind": "youtube#channel",
+ "channelId": "UCOyndc4dxGvq8Wefsqm_DsQ"
+ }
+ }
+ ]
+}
diff --git a/tests/fixtures/youtube/emptyResponseMock.json b/tests/fixtures/youtube/emptyResponseMock.json
new file mode 100644
index 0000000..ad2f22d
--- /dev/null
+++ b/tests/fixtures/youtube/emptyResponseMock.json
@@ -0,0 +1,11 @@
+{
+ "kind": "youtube#searchListResponse",
+ "etag": "fFArRdD9gycv2X5Lff-Xmx3Ol04",
+ "nextPageToken": "CAUQAA",
+ "regionCode": "US",
+ "pageInfo": {
+ "totalResults": 0,
+ "resultsPerPage": 5
+ },
+ "items": []
+}
diff --git a/tests/fixtures/youtube/playlistResponseMock.json b/tests/fixtures/youtube/playlistResponseMock.json
new file mode 100644
index 0000000..7482ed2
--- /dev/null
+++ b/tests/fixtures/youtube/playlistResponseMock.json
@@ -0,0 +1,52 @@
+{
+ "kind": "youtube#searchListResponse",
+ "etag": "8g2Hwxo8YYb0qAd3VF-bayGY3xQ",
+ "nextPageToken": "CAUQAA",
+ "regionCode": "US",
+ "pageInfo": {
+ "totalResults": 27734,
+ "resultsPerPage": 5
+ },
+ "items": [
+ {
+ "kind": "youtube#searchResult",
+ "etag": "mvfCLmuLDiPWaaBiKsSYIF1cSys",
+ "id": {
+ "kind": "youtube#playlist",
+ "playlistId": "PLRW7iEDD9RDR3nExwJcID9uzkHI3y53YX"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "GDTRnMzs0I9iU1Yrlh-x3NboO4A",
+ "id": {
+ "kind": "youtube#playlist",
+ "playlistId": "PLexunLlotTJ8pCJ-gX3UG1jc3SUGssMfm"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "bo0BnPa1PJaoKpsT7-OLH3w0X0g",
+ "id": {
+ "kind": "youtube#playlist",
+ "playlistId": "RDCLAK5uy_k3jElZuYeDhqZsFkUnRf519q4CD52CaRY"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "8ulTX_GRYRjhwCvoCFl4GS_uFVs",
+ "id": {
+ "kind": "youtube#playlist",
+ "playlistId": "PLvavgSnUy4t8-NzXn_p7YXw097iaFSuIi"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "iRndIN04shCjqtpQcsfDcYIQSxQ",
+ "id": {
+ "kind": "youtube#playlist",
+ "playlistId": "PL8rR1fnYeGR_PoLbt8lSrkVfSmYQUm_m4"
+ }
+ }
+ ]
+}
diff --git a/tests/fixtures/youtube/podcastResponseMock.json b/tests/fixtures/youtube/podcastResponseMock.json
new file mode 100644
index 0000000..fc3c45f
--- /dev/null
+++ b/tests/fixtures/youtube/podcastResponseMock.json
@@ -0,0 +1,52 @@
+{
+ "kind": "youtube#searchListResponse",
+ "etag": "qu4QsNHSA2QNBFZzyxH6_ON-Ik0",
+ "nextPageToken": "CAUQAA",
+ "regionCode": "US",
+ "pageInfo": {
+ "totalResults": 5953,
+ "resultsPerPage": 5
+ },
+ "items": [
+ {
+ "kind": "youtube#searchResult",
+ "etag": "0nPLJ-Kpocsq20OK-F52TChpnuw",
+ "id": {
+ "kind": "youtube#video",
+ "videoId": "0atwuUWhKWs"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "6g6DYBoKkZdO9KwDfEE9OD7GgLU",
+ "id": {
+ "kind": "youtube#video",
+ "videoId": "X8gvuyi7oz0"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "Ro41Y_KOKAux7WpELANngknEaF4",
+ "id": {
+ "kind": "youtube#video",
+ "videoId": "N-_X42WGhqo"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "A1ggAPJRYwvn6-vE4ywE9eruT4U",
+ "id": {
+ "kind": "youtube#video",
+ "videoId": "ygt0hV_9kGc"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "PURLtOumPxySg9ZoMjlUnD6JLzk",
+ "id": {
+ "kind": "youtube#video",
+ "videoId": "7tZ6F9aPxCU"
+ }
+ }
+ ]
+}
diff --git a/tests/fixtures/youtube/songResponseMock.json b/tests/fixtures/youtube/songResponseMock.json
new file mode 100644
index 0000000..9df0032
--- /dev/null
+++ b/tests/fixtures/youtube/songResponseMock.json
@@ -0,0 +1,52 @@
+{
+ "kind": "youtube#searchListResponse",
+ "etag": "WyrMKkgR-zfm5NzAA-AEzfdC20Q",
+ "nextPageToken": "CAUQAA",
+ "regionCode": "US",
+ "pageInfo": {
+ "totalResults": 1000000,
+ "resultsPerPage": 5
+ },
+ "items": [
+ {
+ "kind": "youtube#searchResult",
+ "etag": "1Svp7WKgcA5TZCftZa7eDOtuL18",
+ "id": {
+ "kind": "youtube#video",
+ "videoId": "vVd4T5NxLgI"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "DUL1dKNZ5b0Sw_NuxlMov09JElU",
+ "id": {
+ "kind": "youtube#video",
+ "videoId": "zhY_0DoQCQs"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "61NBo0R2R9Snhy3SxJ970pgM8P0",
+ "id": {
+ "kind": "youtube#video",
+ "videoId": "5aEf3gFis4M"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "YHanlLp8znmWQKAbwyci5DAMGUE",
+ "id": {
+ "kind": "youtube#video",
+ "videoId": "Zo2pMNSkIvE"
+ }
+ },
+ {
+ "kind": "youtube#searchResult",
+ "etag": "zWijjP4Z9tDR1Z5PrgHoXgA2_dY",
+ "id": {
+ "kind": "youtube#video",
+ "videoId": "KZnJjjoDop4"
+ }
+ }
+ ]
+}
diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts
index 4b5e256..3c3e177 100644
--- a/tests/integration/api.test.ts
+++ b/tests/integration/api.test.ts
@@ -1,22 +1,27 @@
+import axios from 'axios';
+import AxiosMockAdapter from 'axios-mock-adapter';
import {
+ afterAll,
+ afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
- mock,
- jest,
- afterEach,
+ Mock,
+ spyOn,
} from 'bun:test';
-import axios from 'axios';
-import AxiosMockAdapter from 'axios-mock-adapter';
-
+import { Adapter, MetadataType } from '~/config/enum';
+import { ENV } from '~/config/env';
import { app } from '~/index';
-import { getLinkWithPuppeteer } from '~/utils/scraper';
+import * as tidalUniversalLinkParser from '~/parsers/tidal-universal-link';
import { cacheStore } from '~/services/cache';
-import { JSONRequest } from '../utils/request';
+import deezerSongResponseMock from '../fixtures/deezer/songResponseMock.json';
+import tidalSongResponseMock from '../fixtures/tidal/songResponseMock.json';
+import youtubeSongResponseMock from '../fixtures/youtube/songResponseMock.json';
+import { jsonRequest } from '../utils/request';
import {
API_ENDPOINT,
API_SEARCH_ENDPOINT,
@@ -24,13 +29,12 @@ import {
getAppleMusicSearchLink,
getDeezerSearchLink,
getSoundCloudSearchLink,
+ getTidalSearchLink,
getYouTubeSearchLink,
urlShortenerLink,
urlShortenerResponseMock,
} from '../utils/shared';
-import deezerSongResponseMock from '../fixtures/deezer/songResponseMock.json';
-
const [
spotifySongHeadResponseMock,
appleMusicSongResponseMock,
@@ -41,25 +45,37 @@ const [
Bun.file('tests/fixtures/soundcloud/songResponseMock.html').text(),
]);
-mock.module('~/utils/scraper', () => ({
- getLinkWithPuppeteer: jest.fn(),
-}));
-
describe('Api router', () => {
- let mock: AxiosMockAdapter;
- const getLinkWithPuppeteerMock = getLinkWithPuppeteer as jest.Mock;
+ let axiosMock: AxiosMockAdapter;
+ let getUniversalMetadataFromTidalaxiosMock: Mock<
+ typeof tidalUniversalLinkParser.getUniversalMetadataFromTidal
+ >;
beforeAll(() => {
- mock = new AxiosMockAdapter(axios);
+ axiosMock = new AxiosMockAdapter(axios);
+ getUniversalMetadataFromTidalaxiosMock = spyOn(
+ tidalUniversalLinkParser,
+ 'getUniversalMetadataFromTidal'
+ );
+ });
+
+ afterAll(() => {
+ axiosMock.reset();
+ getUniversalMetadataFromTidalaxiosMock.mockReset();
});
beforeEach(() => {
cacheStore.reset();
- mock.reset();
+ axiosMock.reset();
+
+ getUniversalMetadataFromTidalaxiosMock.mockResolvedValue(undefined);
+ axiosMock.onPost(ENV.adapters.spotify.authUrl).reply(200, {});
+ axiosMock.onPost(ENV.adapters.tidal.authUrl).reply(200, {});
+ axiosMock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
});
afterEach(() => {
- mock.reset();
+ axiosMock.reset();
});
describe('GET /search', () => {
@@ -67,26 +83,26 @@ describe('Api router', () => {
const link = 'https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384';
const query = 'Do Not Disturb Drake';
+ const tidalSearchLink = getTidalSearchLink(query, MetadataType.Song);
const appleMusicSearchLink = getAppleMusicSearchLink(query);
- const youtubeSearchLink = getYouTubeSearchLink(query, 'song');
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Song);
const deezerSearchLink = getDeezerSearchLink(query, 'track');
const soundCloudSearchLink = getSoundCloudSearchLink(query);
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link });
- mock.onGet(link).reply(200, spotifySongHeadResponseMock);
- mock.onGet(appleMusicSearchLink).reply(500);
- mock.onGet(deezerSearchLink).reply(200, deezerSongResponseMock);
- mock.onGet(soundCloudSearchLink).reply(200, soundCloudSongResponseMock);
- mock.onGet(soundCloudSearchLink).reply(200, soundCloudSongResponseMock);
- mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
+ axiosMock.onGet(link).reply(200, spotifySongHeadResponseMock);
- const mockedYoutubeLink = 'https://music.youtube.com/watch?v=zhY_0DoQCQs';
- getLinkWithPuppeteerMock.mockResolvedValueOnce(mockedYoutubeLink);
+ axiosMock.onGet(tidalSearchLink).reply(200, tidalSongResponseMock);
+ axiosMock.onGet(appleMusicSearchLink).reply(500);
+ axiosMock.onGet(deezerSearchLink).reply(200, deezerSongResponseMock);
+ axiosMock.onGet(soundCloudSearchLink).reply(200, soundCloudSongResponseMock);
+ axiosMock.onGet(youtubeSearchLink).reply(200, youtubeSongResponseMock);
- const response = await app.handle(request).then(res => res.json());
+ const response = await app.handle(request);
+ const data = await response.json();
- expect(response).toEqual({
+ expect(data).toEqual({
id: 'b3Blbi5zcG90aWZ5LmNvbS90cmFjay8yS3ZIQzl6MTRHU2w0WXBrTk1YMzg0',
type: 'song',
title: 'Do Not Disturb',
@@ -96,11 +112,6 @@ describe('Api router', () => {
source: 'https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384',
universalLink: urlShortenerResponseMock.data.refer,
links: [
- {
- type: 'youTube',
- url: mockedYoutubeLink,
- isVerified: true,
- },
{
type: 'deezer',
url: 'https://www.deezer.com/track/144572248',
@@ -113,44 +124,44 @@ describe('Api router', () => {
},
{
type: 'tidal',
- url: 'https://listen.tidal.com/search?q=Do+Not+Disturb+Drake',
+ url: 'https://tidal.com/browse/track/71717750',
+ isVerified: true,
+ },
+ {
+ type: 'youTube',
+ url: 'https://music.youtube.com/watch?v=vVd4T5NxLgI',
+ isVerified: true,
},
],
});
- expect(mock.history.get).toHaveLength(5);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalled();
- // expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- // expect.stringContaining(youtubeSearchLink),
- // 'ytmusic-card-shelf-renderer a',
- // expect.any(Array)
- // );
+ expect(axiosMock.history.get).toHaveLength(7);
});
it('should return 200 when adapter returns error - Youtube', async () => {
const link = 'https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384';
const query = 'Do Not Disturb Drake';
+ const tidalSearchLink = getTidalSearchLink(query, MetadataType.Song);
const appleMusicSearchLink = getAppleMusicSearchLink(query);
- // const youtubeSearchLink = getYouTubeSearchLink(query, 'video');
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Song);
const deezerSearchLink = getDeezerSearchLink(query, 'track');
const soundCloudSearchLink = getSoundCloudSearchLink(query);
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link });
- mock.onGet(link).reply(200, spotifySongHeadResponseMock);
- mock.onGet(appleMusicSearchLink).reply(200, appleMusicSongResponseMock);
- mock.onGet(deezerSearchLink).reply(200, deezerSongResponseMock);
- mock.onGet(soundCloudSearchLink).reply(200, soundCloudSongResponseMock);
- mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
+ axiosMock.onGet(link).reply(200, spotifySongHeadResponseMock);
- getLinkWithPuppeteerMock.mockImplementationOnce(() => {
- throw new Error('Injected Error');
- });
+ axiosMock.onGet(tidalSearchLink).reply(200, tidalSongResponseMock);
+ axiosMock.onGet(appleMusicSearchLink).reply(200, appleMusicSongResponseMock);
+ axiosMock.onGet(deezerSearchLink).reply(200, deezerSongResponseMock);
+ axiosMock.onGet(soundCloudSearchLink).reply(200, soundCloudSongResponseMock);
+ axiosMock.onGet(youtubeSearchLink).reply(500);
- const response = await app.handle(request).then(res => res.json());
+ const response = await app.handle(request);
+ const data = await response.json();
- expect(response).toEqual({
+ expect(data).toEqual({
id: 'b3Blbi5zcG90aWZ5LmNvbS90cmFjay8yS3ZIQzl6MTRHU2w0WXBrTk1YMzg0',
type: 'song',
title: 'Do Not Disturb',
@@ -177,43 +188,39 @@ describe('Api router', () => {
},
{
type: 'tidal',
- url: 'https://listen.tidal.com/search?q=Do+Not+Disturb+Drake',
+ url: 'https://tidal.com/browse/track/71717750',
+ isVerified: true,
},
],
});
- expect(mock.history.get).toHaveLength(4);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalled();
- // expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- // expect.stringContaining(youtubeSearchLink),
- // 'ytmusic-card-shelf-renderer a',
- // expect.any(Array)
- // );
+ expect(axiosMock.history.get).toHaveLength(7);
});
it('should return 200 when adapter returns error - Deezer', async () => {
const link = 'https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384';
const query = 'Do Not Disturb Drake';
+ const tidalSearchLink = getTidalSearchLink(query, MetadataType.Song);
const appleMusicSearchLink = getAppleMusicSearchLink(query);
- const youtubeSearchLink = getYouTubeSearchLink(query, 'video');
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Song);
const deezerSearchLink = getDeezerSearchLink(query, 'track');
const soundCloudSearchLink = getSoundCloudSearchLink(query);
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link });
- mock.onGet(link).reply(200, spotifySongHeadResponseMock);
- mock.onGet(appleMusicSearchLink).reply(200, appleMusicSongResponseMock);
- mock.onGet(deezerSearchLink).reply(500);
- mock.onGet(soundCloudSearchLink).reply(200, soundCloudSongResponseMock);
- mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
+ axiosMock.onGet(link).reply(200, spotifySongHeadResponseMock);
- const mockedYoutubeLink = 'https://music.youtube.com/watch?v=zhY_0DoQCQs';
- getLinkWithPuppeteerMock.mockResolvedValueOnce(mockedYoutubeLink);
+ axiosMock.onGet(tidalSearchLink).reply(200, tidalSongResponseMock);
+ axiosMock.onGet(appleMusicSearchLink).reply(200, appleMusicSongResponseMock);
+ axiosMock.onGet(deezerSearchLink).reply(500);
+ axiosMock.onGet(soundCloudSearchLink).reply(200, soundCloudSongResponseMock);
+ axiosMock.onGet(youtubeSearchLink).reply(200, youtubeSongResponseMock);
- const response = await app.handle(request).then(res => res.json());
+ const response = await app.handle(request);
+ const data = await response.json();
- expect(response).toEqual({
+ expect(data).toEqual({
id: 'b3Blbi5zcG90aWZ5LmNvbS90cmFjay8yS3ZIQzl6MTRHU2w0WXBrTk1YMzg0',
type: 'song',
title: 'Do Not Disturb',
@@ -223,11 +230,6 @@ describe('Api router', () => {
source: 'https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384',
universalLink: urlShortenerResponseMock.data.refer,
links: [
- {
- type: 'youTube',
- url: mockedYoutubeLink,
- isVerified: true,
- },
{
type: 'appleMusic',
url: 'https://music.apple.com/us/album/do-not-disturb/1440890708?i=1440892237',
@@ -240,43 +242,44 @@ describe('Api router', () => {
},
{
type: 'tidal',
- url: 'https://listen.tidal.com/search?q=Do+Not+Disturb+Drake',
+ url: 'https://tidal.com/browse/track/71717750',
+ isVerified: true,
+ },
+ {
+ type: 'youTube',
+ url: 'https://music.youtube.com/watch?v=vVd4T5NxLgI',
+ isVerified: true,
},
],
});
- expect(mock.history.get).toHaveLength(5);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalled();
- // expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- // expect.stringContaining(youtubeSearchLink),
- // 'ytmusic-card-shelf-renderer a',
- // expect.any(Array)
- // );
+ expect(axiosMock.history.get).toHaveLength(7);
});
it('should return 200 when adapter returns error - SoundCloud', async () => {
const link = 'https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384';
const query = 'Do Not Disturb Drake';
+ const tidalSearchLink = getTidalSearchLink(query, MetadataType.Song);
const appleMusicSearchLink = getAppleMusicSearchLink(query);
- const youtubeSearchLink = getYouTubeSearchLink(query, 'video');
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Song);
const deezerSearchLink = getDeezerSearchLink(query, 'track');
const soundCloudSearchLink = getSoundCloudSearchLink(query);
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link });
- mock.onGet(link).reply(200, spotifySongHeadResponseMock);
- mock.onGet(appleMusicSearchLink).reply(200, appleMusicSongResponseMock);
- mock.onGet(deezerSearchLink).reply(200, deezerSongResponseMock);
- mock.onGet(soundCloudSearchLink).reply(500);
- mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
+ axiosMock.onGet(link).reply(200, spotifySongHeadResponseMock);
- const mockedYoutubeLink = 'https://music.youtube.com/watch?v=zhY_0DoQCQs';
- getLinkWithPuppeteerMock.mockResolvedValueOnce(mockedYoutubeLink);
+ axiosMock.onGet(tidalSearchLink).reply(200, tidalSongResponseMock);
+ axiosMock.onGet(appleMusicSearchLink).reply(200, appleMusicSongResponseMock);
+ axiosMock.onGet(deezerSearchLink).reply(200, deezerSongResponseMock);
+ axiosMock.onGet(soundCloudSearchLink).reply(500);
+ axiosMock.onGet(youtubeSearchLink).reply(200, youtubeSongResponseMock);
- const response = await app.handle(request).then(res => res.json());
+ const response = await app.handle(request);
+ const data = await response.json();
- expect(response).toEqual({
+ expect(data).toEqual({
id: 'b3Blbi5zcG90aWZ5LmNvbS90cmFjay8yS3ZIQzl6MTRHU2w0WXBrTk1YMzg0',
type: 'song',
title: 'Do Not Disturb',
@@ -286,11 +289,6 @@ describe('Api router', () => {
source: 'https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384',
universalLink: urlShortenerResponseMock.data.refer,
links: [
- {
- type: 'youTube',
- url: mockedYoutubeLink,
- isVerified: true,
- },
{
type: 'appleMusic',
url: 'https://music.apple.com/us/album/do-not-disturb/1440890708?i=1440892237',
@@ -303,31 +301,66 @@ describe('Api router', () => {
},
{
type: 'tidal',
- url: 'https://listen.tidal.com/search?q=Do+Not+Disturb+Drake',
+ url: 'https://tidal.com/browse/track/71717750',
+ isVerified: true,
+ },
+ {
+ type: 'youTube',
+ url: 'https://music.youtube.com/watch?v=vVd4T5NxLgI',
+ isVerified: true,
+ },
+ ],
+ });
+
+ expect(axiosMock.history.get).toHaveLength(7);
+ });
+
+ it('should return 200 when adapter adapter matches the parser type', async () => {
+ const link = 'https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384';
+
+ const request = jsonRequest(API_SEARCH_ENDPOINT, {
+ link,
+ adapters: [Adapter.Spotify],
+ });
+
+ axiosMock.onGet(link).reply(200, spotifySongHeadResponseMock);
+
+ const response = await app.handle(request);
+ const data = await response.json();
+
+ expect(data).toEqual({
+ id: 'b3Blbi5zcG90aWZ5LmNvbS90cmFjay8yS3ZIQzl6MTRHU2w0WXBrTk1YMzg0',
+ type: 'song',
+ title: 'Do Not Disturb',
+ description: 'Drake · Song · 2017',
+ image: 'https://i.scdn.co/image/ab67616d0000b2734f0fd9dad63977146e685700',
+ audio: 'https://p.scdn.co/mp3-preview/df989a31c8233f46b6a997c59025f9c8021784aa',
+ source: 'https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384',
+ universalLink:
+ 'http://localhost:3000?id=b3Blbi5zcG90aWZ5LmNvbS90cmFjay8yS3ZIQzl6MTRHU2w0WXBrTk1YMzg0',
+ links: [
+ {
+ isVerified: true,
+ type: 'spotify',
+ url: 'https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384',
},
],
});
- expect(mock.history.get).toHaveLength(5);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalled();
- // expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- // expect.stringContaining(youtubeSearchLink),
- // 'ytmusic-card-shelf-renderer a',
- // expect.any(Array)
- // );
+ expect(axiosMock.history.get).toHaveLength(1);
});
it('should return unknown error - could not parse Spotify metadata', async () => {
- const request = JSONRequest(API_SEARCH_ENDPOINT, {
+ const request = jsonRequest(API_SEARCH_ENDPOINT, {
link: cachedSpotifyLink,
});
- mock.onGet(cachedSpotifyLink).reply(200, '');
+ axiosMock.onGet(cachedSpotifyLink).reply(200, '');
- const response = await app.handle(request).then(res => res.json());
+ const response = await app.handle(request);
- expect(response).toEqual({
- code: 'UNKNOWN',
+ expect(response.text()).resolves.toThrow({
+ code: 'INTERNAL_SERVER_ERROR',
message:
'[getSpotifyMetadata] (https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384) Error: Spotify metadata not found',
});
@@ -336,10 +369,11 @@ describe('Api router', () => {
it('should return bad request - invalid link', async () => {
const link = 'https://open.spotify.com/invalid';
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link });
- const response = await app.handle(request).then(res => res.json());
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link });
- expect(response).toEqual({
+ const response = await app.handle(request);
+
+ expect(response.text()).resolves.toThrow({
code: 'VALIDATION',
message: 'Invalid link, please try with Spotify or Youtube links.',
});
@@ -348,44 +382,47 @@ describe('Api router', () => {
it('should return bad request - invalid searchId', async () => {
const searchId = 123;
- const request = JSONRequest(API_SEARCH_ENDPOINT, { searchId });
- const response = await app.handle(request).then(res => res.json());
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { searchId });
+ const response = await app.handle(request);
- expect(response).toEqual({
+ expect(response.text()).resolves.toThrow({
code: 'VALIDATION',
message: 'Invalid link, please try with Spotify or Youtube links.',
});
});
it('should return bad request - unknown body param', async () => {
- const request = JSONRequest(API_SEARCH_ENDPOINT, { foo: 'bar' });
- const response = await app.handle(request).then(res => res.json());
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { foo: 'bar' });
+
+ const response = await app.handle(request);
- expect(response).toEqual({
+ expect(response.text()).resolves.toThrow({
code: 'VALIDATION',
message: 'Invalid link, please try with Spotify or Youtube links.',
});
});
it('should return bad request - unsupported API version', async () => {
- const request = JSONRequest(`${API_ENDPOINT}/search?v=2`, {
+ const request = jsonRequest(`${API_ENDPOINT}/search?v=2`, {
link: cachedSpotifyLink,
});
- const response = await app.handle(request).then(res => res.json());
- expect(response).toEqual({
+ const response = await app.handle(request);
+
+ expect(response.text()).resolves.toThrow({
code: 'VALIDATION',
message: 'Unsupported API version',
});
});
it('should return bad request - missing API version query param', async () => {
- const request = JSONRequest(`${API_ENDPOINT}/search`, {
+ const request = jsonRequest(`${API_ENDPOINT}/search`, {
link: cachedSpotifyLink,
});
- const response = await app.handle(request).then(res => res.json());
- expect(response).toEqual({
+ const response = await app.handle(request);
+
+ expect(response.text()).resolves.toThrow({
code: 'VALIDATION',
message: 'Unsupported API version',
});
diff --git a/tests/integration/cache.test.ts b/tests/integration/cache.test.ts
index 12141f4..4259456 100644
--- a/tests/integration/cache.test.ts
+++ b/tests/integration/cache.test.ts
@@ -1,26 +1,29 @@
-import { beforeAll, describe, expect, it, mock, jest, afterAll } from 'bun:test';
-
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { afterAll, beforeAll, describe, expect, it, spyOn } from 'bun:test';
+import { MetadataType } from '~/config/enum';
+import { ENV } from '~/config/env';
import { app } from '~/index';
-import { getLinkWithPuppeteer } from '~/utils/scraper';
+import * as tidalUniversalLinkParser from '~/parsers/tidal-universal-link';
+import { cacheStore } from '~/services/cache';
-import { JSONRequest } from '../utils/request';
+import deezerSongResponseMock from '../fixtures/deezer/songResponseMock.json';
+import tidalSongResponseMock from '../fixtures/tidal/songResponseMock.json';
+import youtubeSongResponseMock from '../fixtures/youtube/songResponseMock.json';
+import { jsonRequest } from '../utils/request';
import {
API_SEARCH_ENDPOINT,
cachedSpotifyLink,
getAppleMusicSearchLink,
getDeezerSearchLink,
getSoundCloudSearchLink,
+ getTidalSearchLink,
+ getYouTubeSearchLink,
urlShortenerLink,
urlShortenerResponseMock,
} from '../utils/shared';
-import { cacheStore } from '~/services/cache';
-
-import deezerSongResponseMock from '../fixtures/deezer/songResponseMock.json';
-
const [
spotifySongHeadResponseMock,
appleMusicSongResponseMock,
@@ -32,50 +35,53 @@ const [
Bun.file('tests/fixtures/soundcloud/songResponseMock.html').text(),
]);
-mock.module('~/utils/scraper', () => ({
- getLinkWithPuppeteer: jest.fn(),
-}));
-
describe('Searches cache', () => {
- let mock: AxiosMockAdapter;
- const getLinkWithPuppeteerMock = getLinkWithPuppeteer as jest.Mock;
+ let axiosMock: AxiosMockAdapter;
+ const getUniversalMetadataFromTidalMock = spyOn(
+ tidalUniversalLinkParser,
+ 'getUniversalMetadataFromTidal'
+ );
beforeAll(async () => {
cacheStore.reset();
- mock = new AxiosMockAdapter(axios);
+ axiosMock = new AxiosMockAdapter(axios);
const query = 'Do Not Disturb Drake';
+ const tidalSearchLink = getTidalSearchLink(query, MetadataType.Song);
const appleMusicSearchLink = getAppleMusicSearchLink(query);
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Song);
const deezerSearchLink = getDeezerSearchLink(query, 'track');
const soundCloudSearchLink = getSoundCloudSearchLink(query);
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link: cachedSpotifyLink });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link: cachedSpotifyLink });
+
+ axiosMock.onGet(cachedSpotifyLink).reply(200, spotifySongHeadResponseMock);
- mock.onGet(cachedSpotifyLink).reply(200, spotifySongHeadResponseMock);
- mock.onGet(appleMusicSearchLink).reply(200, appleMusicSongResponseMock);
- mock.onGet(deezerSearchLink).reply(200, deezerSongResponseMock);
- mock.onGet(soundCloudSearchLink).reply(200, soundCloudSongResponseMock);
- mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
+ axiosMock.onGet(tidalSearchLink).reply(200, tidalSongResponseMock);
+ axiosMock.onGet(appleMusicSearchLink).reply(200, appleMusicSongResponseMock);
+ axiosMock.onGet(deezerSearchLink).reply(200, deezerSongResponseMock);
+ axiosMock.onGet(soundCloudSearchLink).reply(200, soundCloudSongResponseMock);
+ axiosMock.onGet(youtubeSearchLink).reply(200, youtubeSongResponseMock);
- getLinkWithPuppeteerMock.mockResolvedValueOnce(
- 'https://music.youtube.com/watch?v=zhY_0DoQCQs'
- );
+ getUniversalMetadataFromTidalMock.mockResolvedValue(undefined);
+ axiosMock.onPost(ENV.adapters.spotify.authUrl).reply(200, {});
+ axiosMock.onPost(ENV.adapters.tidal.authUrl).reply(200, {});
+ axiosMock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
// fill cache
- await app.handle(request).then(res => res.json());
+ await app.handle(request);
- expect(mock.history.get).toHaveLength(4);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledTimes(1);
+ expect(axiosMock.history.get).toHaveLength(6);
});
afterAll(() => {
- mock.reset();
+ axiosMock.reset();
});
it('should return 200 from cache', async () => {
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link: cachedSpotifyLink });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link: cachedSpotifyLink });
const response = await app.handle(request).then(res => res.json());
expect(response.source).toEqual(cachedSpotifyLink);
diff --git a/tests/integration/page.test.ts b/tests/integration/page.test.ts
index 4af26fd..1199374 100644
--- a/tests/integration/page.test.ts
+++ b/tests/integration/page.test.ts
@@ -1,26 +1,46 @@
+import axios from 'axios';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import {
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ mock,
+ spyOn,
+} from 'bun:test';
+
+import { Adapter, MetadataType, Parser } from '~/config/enum';
import { ENV } from '~/config/env';
-import { beforeEach, describe, expect, it, spyOn } from 'bun:test';
-
-import { getCheerioDoc } from '~/utils/scraper';
-import { formDataRequest } from '../utils/request';
-
import { app } from '~/index';
-
-import { MetadataType, Adapter, Parser } from '~/config/enum';
-
+import * as linkParser from '~/parsers/link';
import {
cacheSearchMetadata,
cacheSearchResultLink,
cacheShortenLink,
cacheStore,
+ cacheTidalUniversalLinkResponse,
} from '~/services/cache';
+import { getCheerioDoc } from '~/utils/scraper';
-import * as linkParser from '~/parsers/link';
+import { formDataRequest } from '../utils/request';
import { urlShortenerResponseMock } from '../utils/shared';
const INDEX_ENDPOINT = 'http://localhost';
describe('Page router', () => {
+ let axiosMock: AxiosMockAdapter;
+
+ beforeAll(() => {
+ axiosMock = new AxiosMockAdapter(axios);
+ mock.restore();
+ });
+
+ afterEach(() => {
+ axiosMock.reset();
+ });
+
beforeEach(async () => {
cacheStore.reset();
@@ -33,7 +53,7 @@ describe('Page router', () => {
audio: 'https://p.scdn.co/mp3-preview/df989a31c8233f46b6a997c59025f9c8021784aa',
}),
cacheShortenLink(
- `${ENV.app.url}?id=b3Blbi5zcG90aWZ5LmNvbS90cmFjay8yS3ZIQzl6MTRHU2w0WXBrTk1YMzg0`,
+ `${ENV.app.url}?id=2KvHC9z14GSl4YpkNMX384`,
urlShortenerResponseMock.data.refer
),
]);
@@ -55,8 +75,8 @@ describe('Page router', () => {
expect(footerText).toContain('@sjdonado');
expect(footerText).toContain('Status');
- expect(footerText).toContain('Install Extension');
expect(footerText).toContain('Source Code');
+ expect(footerText).toContain('Raycast Extension');
});
});
@@ -66,8 +86,18 @@ describe('Page router', () => {
it('should return search card with a valid link', async () => {
await Promise.all([
+ cacheTidalUniversalLinkResponse('https://tidal.com/browse/track/71717750/u', {
+ spotify: null,
+ youTube: null,
+ appleMusic: null,
+ deezer: null,
+ soundCloud: null,
+ tidal: null,
+ }),
cacheSearchResultLink(
- new URL('https://music.youtube.com/search?q=Do+Not+Disturb+Drake+song'),
+ new URL(
+ 'https://content-youtube.googleapis.com/youtube/v3/search?type=video®ionCode=US&q=Do+Not+Disturb+Drake&part=id&safeSearch=none&key=youtube_api_key'
+ ),
{
type: Adapter.YouTube,
url: 'https://music.youtube.com/watch?v=zhY_0DoQCQs',
@@ -78,12 +108,12 @@ describe('Page router', () => {
new URL('https://music.apple.com/ca/search?term=Do%20Not%20Disturb%20Drake'),
{
type: Adapter.AppleMusic,
- url: 'https://music.apple.com/us/album/do-not-disturb/1440890708?i=1440892237',
+ url: 'https://music.apple.com/ca/album/do-not-disturb/1440890708?i=1440892237',
isVerified: true,
}
),
cacheSearchResultLink(
- new URL('https://api.deezer.com/search/track?q=Do+Not+Disturb+Drake&limit=1'),
+ new URL('https://api.deezer.com/search/track?q=Do+Not+Disturb+Drake&limit=4'),
{
type: Adapter.Deezer,
url: 'https://www.deezer.com/track/144572248',
@@ -98,12 +128,23 @@ describe('Page router', () => {
isVerified: true,
}
),
+ cacheSearchResultLink(
+ new URL(
+ 'https://openapi.tidal.com/v2/searchresults/Do%20Not%20Disturb%20Drake/relationships/tracks?countryCode=US&include=tracks'
+ ),
+ {
+ type: Adapter.Tidal,
+ url: 'https://tidal.com/browse/track/71717750',
+ isVerified: true,
+ }
+ ),
]);
const request = formDataRequest(endpoint, { link: spotifyLink });
- const response = await app.handle(request).then(res => res.text());
+ const response = await app.handle(request);
+ const data = await response.text();
- const doc = getCheerioDoc(response);
+ const doc = getCheerioDoc(data);
const searchCardText = doc('[data-controller="search-card"]').text();
@@ -113,33 +154,120 @@ describe('Page router', () => {
const searchLinks = doc('[data-controller="search-link"] > a').toArray();
expect(searchLinks).toHaveLength(5);
- expect(searchLinks[0].attribs['aria-label']).toContain('Listen on YouTube');
+ expect(searchLinks[0].attribs['aria-label']).toContain('Listen on Apple Music');
expect(searchLinks[0].attribs['href']).toBe(
- 'https://music.youtube.com/watch?v=zhY_0DoQCQs'
+ 'https://music.apple.com/ca/album/do-not-disturb/1440890708?i=1440892237'
);
- expect(searchLinks[1].attribs['aria-label']).toContain('Listen on Apple Music');
+ expect(searchLinks[1].attribs['aria-label']).toContain('Listen on Deezer');
expect(searchLinks[1].attribs['href']).toBe(
- 'https://music.apple.com/us/album/do-not-disturb/1440890708?i=1440892237'
+ 'https://www.deezer.com/track/144572248'
);
- expect(searchLinks[2].attribs['aria-label']).toContain('Listen on Deezer');
+ expect(searchLinks[2].attribs['aria-label']).toContain('Listen on SoundCloud');
expect(searchLinks[2].attribs['href']).toBe(
- 'https://www.deezer.com/track/144572248'
+ 'https://soundcloud.com/octobersveryown/drake-do-not-disturb'
);
- expect(searchLinks[3].attribs['aria-label']).toContain('Listen on SoundCloud');
+ expect(searchLinks[3].attribs['aria-label']).toContain('Listen on Tidal');
expect(searchLinks[3].attribs['href']).toBe(
+ 'https://tidal.com/browse/track/71717750'
+ );
+ expect(searchLinks[4].attribs['aria-label']).toContain('Listen on YouTube');
+ expect(searchLinks[4].attribs['href']).toBe(
+ 'https://music.youtube.com/watch?v=zhY_0DoQCQs'
+ );
+ });
+
+ it('should return search card with a valid link - From Universal link', async () => {
+ await Promise.all([
+ cacheSearchResultLink(
+ new URL(
+ 'https://openapi.tidal.com/v2/searchresults/Do%20Not%20Disturb%20Drake/relationships/tracks?countryCode=US&include=tracks'
+ ),
+ {
+ type: Adapter.Tidal,
+ url: 'https://tidal.com/browse/track/71717750',
+ isVerified: true,
+ }
+ ),
+ cacheTidalUniversalLinkResponse('https://tidal.com/browse/track/71717750/u', {
+ spotify: {
+ type: Adapter.Spotify,
+ url: 'https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384',
+ isVerified: true,
+ },
+ youTube: {
+ type: Adapter.YouTube,
+ url: 'https://music.youtube.com/watch?v=zhY_0DoQCQs',
+ isVerified: true,
+ },
+ appleMusic: {
+ type: Adapter.AppleMusic,
+ url: 'https://geo.music.apple.com/de/album/do-not-disturb/1440890708?i=1440892237&app=music&ls=1',
+ isVerified: true,
+ },
+ deezer: null,
+ soundCloud: null,
+ tidal: null,
+ }),
+ cacheSearchResultLink(
+ new URL('https://api.deezer.com/search/track?q=Do+Not+Disturb+Drake&limit=4'),
+ {
+ type: Adapter.Deezer,
+ url: 'https://www.deezer.com/track/144572248',
+ isVerified: true,
+ }
+ ),
+ cacheSearchResultLink(
+ new URL('https://soundcloud.com/search?q=Do+Not+Disturb+Drake'),
+ {
+ type: Adapter.SoundCloud,
+ url: 'https://soundcloud.com/octobersveryown/drake-do-not-disturb',
+ isVerified: true,
+ }
+ ),
+ ]);
+
+ const request = formDataRequest(endpoint, { link: spotifyLink });
+ const response = await app.handle(request);
+ const data = await response.text();
+
+ const doc = getCheerioDoc(data);
+
+ const searchCardText = doc('[data-controller="search-card"]').text();
+
+ expect(searchCardText).toContain('Do Not Disturb');
+ expect(searchCardText).toContain('Drake · Song · 2017');
+
+ const searchLinks = doc('[data-controller="search-link"] > a').toArray();
+
+ expect(searchLinks).toHaveLength(5);
+ expect(searchLinks[0].attribs['aria-label']).toContain('Listen on Apple Music');
+ expect(searchLinks[0].attribs['href']).toBe(
+ 'https://geo.music.apple.com/de/album/do-not-disturb/1440890708?i=1440892237&app=music&ls=1'
+ );
+ expect(searchLinks[1].attribs['aria-label']).toContain('Listen on Deezer');
+ expect(searchLinks[1].attribs['href']).toBe(
+ 'https://www.deezer.com/track/144572248'
+ );
+ expect(searchLinks[2].attribs['aria-label']).toContain('Listen on SoundCloud');
+ expect(searchLinks[2].attribs['href']).toBe(
'https://soundcloud.com/octobersveryown/drake-do-not-disturb'
);
- expect(searchLinks[4].attribs['aria-label']).toContain('Listen on Tidal');
+ expect(searchLinks[3].attribs['aria-label']).toContain('Listen on Tidal');
+ expect(searchLinks[3].attribs['href']).toBe(
+ 'https://tidal.com/browse/track/71717750'
+ );
+ expect(searchLinks[4].attribs['aria-label']).toContain('Listen on YouTube');
expect(searchLinks[4].attribs['href']).toBe(
- 'https://listen.tidal.com/search?q=Do+Not+Disturb+Drake'
+ 'https://music.youtube.com/watch?v=zhY_0DoQCQs'
);
});
it('should return search card when searchLinks are empty', async () => {
const request = formDataRequest(endpoint, { link: spotifyLink });
- const response = await app.handle(request).then(res => res.text());
+ const response = await app.handle(request);
+ const data = await response.text();
- const doc = getCheerioDoc(response);
+ const doc = getCheerioDoc(data);
const searchCardText = doc('[data-controller="search-card"]').text();
@@ -156,9 +284,10 @@ describe('Page router', () => {
link: 'https://open.spotify.com/invalid',
});
- const response = await app.handle(request).then(res => res.text());
- const doc = getCheerioDoc(response);
+ const response = await app.handle(request);
+ const data = await response.text();
+ const doc = getCheerioDoc(data);
const errorMessage = doc('p').text();
expect(errorMessage).toContain(
'Invalid link, please try with Spotify or Youtube links.'
@@ -173,13 +302,12 @@ describe('Page router', () => {
});
const request = formDataRequest(endpoint, { link: spotifyLink });
- const response = await app.handle(request).then(res => res.text());
-
- const doc = getCheerioDoc(response);
+ const response = await app.handle(request);
+ const data = await response.text();
+ const doc = getCheerioDoc(data);
const errorMessage = doc('p').text();
expect(errorMessage).toContain('Something went wrong, please try again later.');
-
expect(getSearchParserMock).toHaveBeenCalledTimes(1);
});
});
diff --git a/tests/integration/search/album.test.ts b/tests/integration/search/album.test.ts
index 9bb5a72..c80ecd3 100644
--- a/tests/integration/search/album.test.ts
+++ b/tests/integration/search/album.test.ts
@@ -1,25 +1,28 @@
-import { beforeEach, beforeAll, describe, expect, it, mock, jest } from 'bun:test';
-
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test';
+import { MetadataType } from '~/config/enum';
+import { ENV } from '~/config/env';
import { app } from '~/index';
-import { getLinkWithPuppeteer } from '~/utils/scraper';
+import * as tidalUniversalLinkParser from '~/parsers/tidal-universal-link';
import { cacheStore } from '~/services/cache';
-import { JSONRequest } from '../../utils/request';
+import deezerAlbumResponseMock from '../../fixtures/deezer/albumResponseMock.json';
+import tidalAlbumResponseMock from '../../fixtures/tidal/albumResponseMock.json';
+import youtubeAlbumResponseMock from '../../fixtures/youtube/albumResponseMock.json';
+import { jsonRequest } from '../../utils/request';
import {
API_SEARCH_ENDPOINT,
getAppleMusicSearchLink,
getDeezerSearchLink,
getSoundCloudSearchLink,
+ getTidalSearchLink,
getYouTubeSearchLink,
urlShortenerLink,
urlShortenerResponseMock,
} from '../../utils/shared';
-import deezerAlbumResponseMock from '../../fixtures/deezer/albumResponseMock.json';
-
const [
spotifyAlbumHeadResponseMock,
appleMusicAlbumResponseMock,
@@ -30,48 +33,52 @@ const [
Bun.file('tests/fixtures/soundcloud/albumResponseMock.html').text(),
]);
-mock.module('~/utils/scraper', () => ({
- getLinkWithPuppeteer: jest.fn(),
-}));
-
describe('GET /search - Album', () => {
let mock: AxiosMockAdapter;
- const getLinkWithPuppeteerMock = getLinkWithPuppeteer as jest.Mock;
+ const getUniversalMetadataFromTidalMock = spyOn(
+ tidalUniversalLinkParser,
+ 'getUniversalMetadataFromTidal'
+ );
beforeAll(() => {
mock = new AxiosMockAdapter(axios);
});
beforeEach(() => {
- getLinkWithPuppeteerMock.mockClear();
- mock.reset();
+ getUniversalMetadataFromTidalMock.mockReset();
cacheStore.reset();
+ mock.reset();
+
+ getUniversalMetadataFromTidalMock.mockResolvedValue(undefined);
+ mock.onPost(ENV.adapters.spotify.authUrl).reply(200, {});
+ mock.onPost(ENV.adapters.tidal.authUrl).reply(200, {});
+ mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
});
it('should return 200', async () => {
const link = 'https://open.spotify.com/album/4czdORdCWP9umpbhFXK2fW';
const query = 'For All The Dogs Drake';
+ const tidalSearchLink = getTidalSearchLink(query, MetadataType.Album);
const appleMusicSearchLink = getAppleMusicSearchLink(query);
- const youtubeSearchLink = getYouTubeSearchLink(query, 'album');
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Album);
const deezerSearchLink = getDeezerSearchLink(query, 'album');
const soundCloudSearchLink = getSoundCloudSearchLink(query);
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link });
mock.onGet(link).reply(200, spotifyAlbumHeadResponseMock);
+
+ mock.onGet(tidalSearchLink).reply(200, tidalAlbumResponseMock);
mock.onGet(appleMusicSearchLink).reply(200, appleMusicAlbumResponseMock);
mock.onGet(deezerSearchLink).reply(200, deezerAlbumResponseMock);
mock.onGet(soundCloudSearchLink).reply(200, soundCloudAlbumResponseMock);
- mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
+ mock.onGet(youtubeSearchLink).reply(200, youtubeAlbumResponseMock);
- const mockedYoutubeLink =
- 'https://music.youtube.com/watch?v=k20wnICXpps&list=OLAK5uy_lRly1oG8OVTI3C2gZv0pPjYxH-Q3U6GrM';
- getLinkWithPuppeteerMock.mockResolvedValueOnce(mockedYoutubeLink);
+ const response = await app.handle(request);
+ const data = await response.json();
- const response = await app.handle(request).then(res => res.json());
-
- expect(response).toEqual({
+ expect(data).toEqual({
id: 'b3Blbi5zcG90aWZ5LmNvbS9hbGJ1bS80Y3pkT1JkQ1dQOXVtcGJoRlhLMmZX',
type: 'album',
title: 'For All The Dogs',
@@ -80,11 +87,6 @@ describe('GET /search - Album', () => {
source: 'https://open.spotify.com/album/4czdORdCWP9umpbhFXK2fW',
universalLink: urlShortenerResponseMock.data.refer,
links: [
- {
- type: 'youTube',
- url: mockedYoutubeLink,
- isVerified: true,
- },
{
type: 'appleMusic',
url: 'https://music.apple.com/us/album/for-all-the-dogs/1710685602',
@@ -102,17 +104,17 @@ describe('GET /search - Album', () => {
},
{
type: 'tidal',
- url: 'https://listen.tidal.com/search?q=For+All+The+Dogs+Drake',
+ url: 'https://tidal.com/browse/album/320189583',
+ isVerified: true,
+ },
+ {
+ type: 'youTube',
+ url: 'https://music.youtube.com/playlist?list=PLbUIPZJL6vw-7ef4PuuPJhaK3K-0UxRKO',
+ isVerified: true,
},
],
});
- expect(mock.history.get).toHaveLength(4);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledTimes(1);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- expect.stringContaining(youtubeSearchLink),
- 'ytmusic-card-shelf-renderer a',
- expect.any(Array)
- );
+ expect(mock.history.get).toHaveLength(6);
});
});
diff --git a/tests/integration/search/artist.test.ts b/tests/integration/search/artist.test.ts
index f918ff4..6be09b2 100644
--- a/tests/integration/search/artist.test.ts
+++ b/tests/integration/search/artist.test.ts
@@ -1,25 +1,28 @@
-import { beforeAll, beforeEach, describe, expect, it, mock, jest } from 'bun:test';
-
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test';
+import { MetadataType } from '~/config/enum';
+import { ENV } from '~/config/env';
import { app } from '~/index';
-import { getLinkWithPuppeteer } from '~/utils/scraper';
+import * as tidalUniversalLinkParser from '~/parsers/tidal-universal-link';
import { cacheStore } from '~/services/cache';
-import { JSONRequest } from '../../utils/request';
+import deezerArtistResponseMock from '../../fixtures/deezer/artistResponseMock.json';
+import tidalArtistResponseMock from '../../fixtures/tidal/artistResponseMock.json';
+import youtubeArtistResponseMock from '../../fixtures/youtube/artistResponseMock.json';
+import { jsonRequest } from '../../utils/request';
import {
API_SEARCH_ENDPOINT,
getAppleMusicSearchLink,
getDeezerSearchLink,
getSoundCloudSearchLink,
+ getTidalSearchLink,
getYouTubeSearchLink,
urlShortenerLink,
urlShortenerResponseMock,
} from '../../utils/shared';
-import deezerArtistResponseMock from '../../fixtures/deezer/artistResponseMock.json';
-
const [
spotifyArtistHeadResponseMock,
appleMusicArtistResponseMock,
@@ -30,48 +33,53 @@ const [
Bun.file('tests/fixtures/soundcloud/artistResponseMock.html').text(),
]);
-mock.module('~/utils/scraper', () => ({
- getLinkWithPuppeteer: jest.fn(),
-}));
-
describe('GET /search - Artist', () => {
let mock: AxiosMockAdapter;
- const getLinkWithPuppeteerMock = getLinkWithPuppeteer as jest.Mock;
+ const getUniversalMetadataFromTidalMock = spyOn(
+ tidalUniversalLinkParser,
+ 'getUniversalMetadataFromTidal'
+ );
beforeAll(() => {
mock = new AxiosMockAdapter(axios);
});
beforeEach(() => {
- getLinkWithPuppeteerMock.mockClear();
+ getUniversalMetadataFromTidalMock.mockReset();
mock.reset();
cacheStore.reset();
+
+ getUniversalMetadataFromTidalMock.mockResolvedValue(undefined);
+ mock.onPost(ENV.adapters.spotify.authUrl).reply(200, {});
+ mock.onPost(ENV.adapters.tidal.authUrl).reply(200, {});
+ mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
});
it('should return 200', async () => {
const link = 'https://open.spotify.com/artist/6l3HvQ5sa6mXTsMTB19rO5';
const query = 'J. Cole';
+ const tidalSearchLink = getTidalSearchLink(query, MetadataType.Artist);
const appleMusicSearchLink = getAppleMusicSearchLink(query);
- const youtubeSearchLink = getYouTubeSearchLink(query, 'channel');
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Artist);
const deezerSearchLink = getDeezerSearchLink(query, 'artist');
const soundCloudSearchLink = getSoundCloudSearchLink(query);
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link });
mock.onGet(link).reply(200, spotifyArtistHeadResponseMock);
+
+ mock.onGet(tidalSearchLink).reply(200, tidalArtistResponseMock);
mock.onGet(appleMusicSearchLink).reply(200, appleMusicArtistResponseMock);
+ mock.onGet(youtubeSearchLink).reply(200, youtubeArtistResponseMock);
mock.onGet(deezerSearchLink).reply(200, deezerArtistResponseMock);
mock.onGet(soundCloudSearchLink).reply(200, soundCloudArtistResponseMock);
mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
- const mockedYoutubeLink =
- 'https://music.youtube.com/channel/UC0ajkOzj8xE3Gs3LHCE243A';
- getLinkWithPuppeteerMock.mockResolvedValueOnce(mockedYoutubeLink);
-
- const response = await app.handle(request).then(res => res.json());
+ const response = await app.handle(request);
+ const data = await response.json();
- expect(response).toEqual({
+ expect(data).toEqual({
id: 'b3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvNmwzSHZRNXNhNm1YVHNNVEIxOXJPNQ%3D%3D',
type: 'artist',
title: 'J. Cole',
@@ -80,11 +88,6 @@ describe('GET /search - Artist', () => {
source: 'https://open.spotify.com/artist/6l3HvQ5sa6mXTsMTB19rO5',
universalLink: urlShortenerResponseMock.data.refer,
links: [
- {
- type: 'youTube',
- url: mockedYoutubeLink,
- isVerified: true,
- },
{
type: 'appleMusic',
url: 'https://music.apple.com/us/artist/j-cole/73705833',
@@ -102,17 +105,17 @@ describe('GET /search - Artist', () => {
},
{
type: 'tidal',
- url: 'https://listen.tidal.com/search?q=J.+Cole',
+ url: 'https://tidal.com/browse/artist/3652822',
+ isVerified: true,
+ },
+ {
+ type: 'youTube',
+ url: 'https://music.youtube.com/channel/UCByOQJjav0CUDwxCk-jVNRQ',
+ isVerified: true,
},
],
});
- expect(mock.history.get).toHaveLength(4);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledTimes(1);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- expect.stringContaining(youtubeSearchLink),
- 'ytmusic-card-shelf-renderer a',
- expect.any(Array)
- );
+ expect(mock.history.get).toHaveLength(6);
});
});
diff --git a/tests/integration/search/playlist.test.ts b/tests/integration/search/playlist.test.ts
index f50e08d..53c8182 100644
--- a/tests/integration/search/playlist.test.ts
+++ b/tests/integration/search/playlist.test.ts
@@ -1,25 +1,28 @@
-import { beforeAll, beforeEach, describe, expect, it, mock, jest } from 'bun:test';
-
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test';
+import { MetadataType } from '~/config/enum';
+import { ENV } from '~/config/env';
import { app } from '~/index';
-import { getLinkWithPuppeteer } from '~/utils/scraper';
+import * as tidalUniversalLinkParser from '~/parsers/tidal-universal-link';
import { cacheStore } from '~/services/cache';
-import { JSONRequest } from '../../utils/request';
+import deezerPlaylistResponseMock from '../../fixtures/deezer/playlistResponseMock.json';
+import tidalEmptyResponseMock from '../../fixtures/tidal/emptyResponseMock.json';
+import youtubePlaylistResponseMock from '../../fixtures/youtube/playlistResponseMock.json';
+import { jsonRequest } from '../../utils/request';
import {
API_SEARCH_ENDPOINT,
getAppleMusicSearchLink,
getDeezerSearchLink,
getSoundCloudSearchLink,
+ getTidalSearchLink,
getYouTubeSearchLink,
urlShortenerLink,
urlShortenerResponseMock,
} from '../../utils/shared';
-import deezerPlaylistResponseMock from '../../fixtures/deezer/playlistResponseMock.json';
-
const [
spotifyPlaylistHeadResponseMock,
appleMusicPlaylistResponseMock,
@@ -30,48 +33,52 @@ const [
Bun.file('tests/fixtures/soundcloud/playlistResponseMock.html').text(),
]);
-mock.module('~/utils/scraper', () => ({
- getLinkWithPuppeteer: jest.fn(),
-}));
-
describe('GET /search - Playlist', () => {
let mock: AxiosMockAdapter;
- const getLinkWithPuppeteerMock = getLinkWithPuppeteer as jest.Mock;
+ const getUniversalMetadataFromTidalMock = spyOn(
+ tidalUniversalLinkParser,
+ 'getUniversalMetadataFromTidal'
+ );
beforeAll(() => {
mock = new AxiosMockAdapter(axios);
});
beforeEach(() => {
- getLinkWithPuppeteerMock.mockClear();
+ getUniversalMetadataFromTidalMock.mockReset();
mock.reset();
cacheStore.reset();
+
+ getUniversalMetadataFromTidalMock.mockResolvedValue(undefined);
+ mock.onPost(ENV.adapters.spotify.authUrl).reply(200, {});
+ mock.onPost(ENV.adapters.tidal.authUrl).reply(200, {});
+ mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
});
it('should return 200', async () => {
const link = 'https://open.spotify.com/playlist/37i9dQZF1DX2apWzyECwyZ';
const query = 'This Is Bad Bunny Playlist';
+ const tidalSearchLink = getTidalSearchLink(query, MetadataType.Playlist);
const appleMusicSearchLink = getAppleMusicSearchLink(query);
- const youtubeSearchLink = getYouTubeSearchLink(query, '');
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Playlist);
const deezerSearchLink = getDeezerSearchLink(query, 'playlist');
const soundCloudSearchLink = getSoundCloudSearchLink(query);
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link });
mock.onGet(link).reply(200, spotifyPlaylistHeadResponseMock);
+
+ mock.onGet(tidalSearchLink).reply(200, tidalEmptyResponseMock);
mock.onGet(appleMusicSearchLink).reply(200, appleMusicPlaylistResponseMock);
mock.onGet(deezerSearchLink).reply(200, deezerPlaylistResponseMock);
mock.onGet(soundCloudSearchLink).reply(200, soundCloudPlaylistResponseMock);
- mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
-
- const mockedYoutubeLink =
- 'https://music.youtube.com/playlist?list=RDCLAK5uy_k3jElZuYeDhqZsFkUnRf519q4CD52CaRY';
- getLinkWithPuppeteerMock.mockResolvedValueOnce(mockedYoutubeLink);
+ mock.onGet(youtubeSearchLink).reply(200, youtubePlaylistResponseMock);
- const response = await app.handle(request).then(res => res.json());
+ const response = await app.handle(request);
+ const data = await response.json();
- expect(response).toEqual({
+ expect(data).toEqual({
id: 'b3Blbi5zcG90aWZ5LmNvbS9wbGF5bGlzdC8zN2k5ZFFaRjFEWDJhcFd6eUVDd3la',
type: 'playlist',
title: 'This Is Bad Bunny',
@@ -79,40 +86,9 @@ describe('GET /search - Playlist', () => {
image: 'https://i.scdn.co/image/ab67706f000000029c0eb2fdff534f803ea018e1',
source: 'https://open.spotify.com/playlist/37i9dQZF1DX2apWzyECwyZ',
universalLink: urlShortenerResponseMock.data.refer,
- links: [
- {
- type: 'youTube',
- url: mockedYoutubeLink,
- isVerified: true,
- },
- {
- type: 'appleMusic',
- url: 'https://music.apple.com/us/playlist/bad-bunny-veranito/pl.3160473c423f407c979deb589b41046e',
- isVerified: false,
- },
- {
- type: 'deezer',
- url: 'https://www.deezer.com/playlist/3370896142',
- isVerified: true,
- },
- {
- type: 'soundCloud',
- url: 'https://soundcloud.com/rafael-moreno-180913328/sets/this-is-bad-bunny',
- isVerified: true,
- },
- {
- type: 'tidal',
- url: 'https://listen.tidal.com/search?q=This+Is+Bad+Bunny+Playlist',
- },
- ],
+ links: [],
});
- expect(mock.history.get).toHaveLength(4);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledTimes(1);
- // expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- // expect.stringContaining(youtubeSearchLink),
- // 'ytmusic-card-shelf-renderer a',
- // expect.any(Array)
- // );
+ expect(mock.history.get).toHaveLength(6);
});
});
diff --git a/tests/integration/search/podcast.test.ts b/tests/integration/search/podcast.test.ts
index d291027..bd2e65e 100644
--- a/tests/integration/search/podcast.test.ts
+++ b/tests/integration/search/podcast.test.ts
@@ -1,13 +1,15 @@
-import { beforeAll, beforeEach, describe, expect, it, mock, jest } from 'bun:test';
-
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test';
+import { MetadataType } from '~/config/enum';
+import { ENV } from '~/config/env';
import { app } from '~/index';
-import { getLinkWithPuppeteer } from '~/utils/scraper';
+import * as tidalUniversalLinkParser from '~/parsers/tidal-universal-link';
import { cacheStore } from '~/services/cache';
-import { JSONRequest } from '../../utils/request';
+import youtubePodcastResponseMock from '../../fixtures/youtube/podcastResponseMock.json';
+import { jsonRequest } from '../../utils/request';
import {
API_SEARCH_ENDPOINT,
getAppleMusicSearchLink,
@@ -27,22 +29,26 @@ const [
Bun.file('tests/fixtures/soundcloud/emptyResponseMock.html').text(),
]);
-mock.module('~/utils/scraper', () => ({
- getLinkWithPuppeteer: jest.fn(),
-}));
-
describe('GET /search - Podcast Episode', () => {
let mock: AxiosMockAdapter;
- const getLinkWithPuppeteerMock = getLinkWithPuppeteer as jest.Mock;
+ const getUniversalMetadataFromTidalMock = spyOn(
+ tidalUniversalLinkParser,
+ 'getUniversalMetadataFromTidal'
+ );
beforeAll(() => {
mock = new AxiosMockAdapter(axios);
});
beforeEach(() => {
- getLinkWithPuppeteerMock.mockClear();
+ getUniversalMetadataFromTidalMock.mockReset();
mock.reset();
cacheStore.reset();
+
+ getUniversalMetadataFromTidalMock.mockResolvedValue(undefined);
+ mock.onPost(ENV.adapters.spotify.authUrl).reply(200, {});
+ mock.onPost(ENV.adapters.tidal.authUrl).reply(200, {});
+ mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
});
it('should return 200', async () => {
@@ -50,23 +56,21 @@ describe('GET /search - Podcast Episode', () => {
const query = 'The End of Twitter as We Know It Waveform: The MKBHD Podcast';
const appleMusicSearchLink = getAppleMusicSearchLink(query);
- const youtubeSearchLink = getYouTubeSearchLink(query, '');
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Podcast);
const soundCloudSearchLink = getSoundCloudSearchLink(query);
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link });
mock.onGet(link).reply(200, spotifyPodcastHeadResponseMock);
mock.onGet(appleMusicSearchLink).reply(200, appleMusicPodcastResponseMock);
+ mock.onGet(youtubeSearchLink).reply(200, youtubePodcastResponseMock);
mock.onGet(soundCloudSearchLink).reply(200, soundCloudPodcastResponseMock);
mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
- const mockedYoutubeLink =
- 'https://music.youtube.com/watch?v=v4FYdo-oZQk&list=PL70yIS6vx_Y2xaKD3w2qb6Eu06jNBdNJb';
- getLinkWithPuppeteerMock.mockResolvedValueOnce(mockedYoutubeLink);
+ const response = await app.handle(request);
+ const data = await response.json();
- const response = await app.handle(request).then(res => res.json());
-
- expect(response).toEqual({
+ expect(data).toEqual({
id: 'b3Blbi5zcG90aWZ5LmNvbS9lcGlzb2RlLzQzVENyZ21QMjNxa0xjQVhaUU44cVQ%3D',
type: 'podcast',
title: 'The End of Twitter as We Know It',
@@ -80,22 +84,12 @@ describe('GET /search - Podcast Episode', () => {
links: [
{
type: 'youTube',
- url: mockedYoutubeLink,
+ url: 'https://music.youtube.com/podcast/0atwuUWhKWs',
isVerified: true,
},
- {
- type: 'tidal',
- url: 'https://listen.tidal.com/search?q=The+End+of+Twitter+as+We+Know+It+Waveform%3A+The+MKBHD+Podcast',
- },
],
});
- expect(mock.history.get).toHaveLength(2);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledTimes(1);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- expect.stringContaining(youtubeSearchLink),
- 'ytmusic-card-shelf-renderer a',
- expect.any(Array)
- );
+ expect(mock.history.get).toHaveLength(3);
});
});
diff --git a/tests/integration/search/show.test.ts b/tests/integration/search/show.test.ts
index 6108b40..0d4e586 100644
--- a/tests/integration/search/show.test.ts
+++ b/tests/integration/search/show.test.ts
@@ -1,45 +1,47 @@
-import { beforeAll, beforeEach, describe, expect, it, mock, jest } from 'bun:test';
-
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test';
+import { ENV } from '~/config/env';
import { app } from '~/index';
-import { getLinkWithPuppeteer } from '~/utils/scraper';
+import * as tidalUniversalLinkParser from '~/parsers/tidal-universal-link';
import { cacheStore } from '~/services/cache';
-import { JSONRequest } from '../../utils/request';
+import deezerShowResponseMock from '../../fixtures/deezer/showResponseMock.json';
+import { jsonRequest } from '../../utils/request';
import {
API_SEARCH_ENDPOINT,
getAppleMusicSearchLink,
getDeezerSearchLink,
- getYouTubeSearchLink,
urlShortenerLink,
urlShortenerResponseMock,
} from '../../utils/shared';
-import deezerShowResponseMock from '../../fixtures/deezer/showResponseMock.json';
-
const [spotifyShowHeadResponseMock, appleMusicShowResponseMock] = await Promise.all([
Bun.file('tests/fixtures/spotify/showHeadResponseMock.html').text(),
Bun.file('tests/fixtures/apple-music/showResponseMock.html').text(),
]);
-mock.module('~/utils/scraper', () => ({
- getLinkWithPuppeteer: jest.fn(),
-}));
-
describe('GET /search - Podcast Show', () => {
let mock: AxiosMockAdapter;
- const getLinkWithPuppeteerMock = getLinkWithPuppeteer as jest.Mock;
+ const getUniversalMetadataFromTidalMock = spyOn(
+ tidalUniversalLinkParser,
+ 'getUniversalMetadataFromTidal'
+ );
beforeAll(() => {
mock = new AxiosMockAdapter(axios);
});
beforeEach(() => {
- getLinkWithPuppeteerMock.mockReset();
+ getUniversalMetadataFromTidalMock.mockReset();
mock.reset();
cacheStore.reset();
+
+ getUniversalMetadataFromTidalMock.mockResolvedValue(undefined);
+ mock.onPost(ENV.adapters.spotify.authUrl).reply(200, {});
+ mock.onPost(ENV.adapters.tidal.authUrl).reply(200, {});
+ mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
});
it('should return 200', async () => {
@@ -47,23 +49,19 @@ describe('GET /search - Podcast Show', () => {
const query = 'Waveform: The MKBHD Podcast';
const appleMusicSearchLink = getAppleMusicSearchLink(query);
- const youtubeSearchLink = getYouTubeSearchLink(query, 'channel');
const deezerSearchLink = getDeezerSearchLink(query, 'podcast');
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link });
mock.onGet(link).reply(200, spotifyShowHeadResponseMock);
mock.onGet(appleMusicSearchLink).reply(200, appleMusicShowResponseMock);
mock.onGet(deezerSearchLink).reply(200, deezerShowResponseMock);
mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
- const mockedYoutubeLink =
- 'https://music.youtube.com/watch?v=v4FYdo-oZQk&list=PL70yIS6vx_Y2xaKD3w2qb6Eu06jNBdNJb';
- getLinkWithPuppeteerMock.mockResolvedValueOnce(mockedYoutubeLink);
+ const response = await app.handle(request);
+ const data = await response.json();
- const response = await app.handle(request).then(res => res.json());
-
- expect(response).toEqual({
+ expect(data).toEqual({
id: 'b3Blbi5zcG90aWZ5LmNvbS9zaG93LzZvODFRdVcyMnM1bTJuZmNYV2p1Y2M%3D',
type: 'show',
title: 'Waveform: The MKBHD Podcast',
@@ -73,29 +71,14 @@ describe('GET /search - Podcast Show', () => {
source: 'https://open.spotify.com/show/6o81QuW22s5m2nfcXWjucc',
universalLink: urlShortenerResponseMock.data.refer,
links: [
- {
- type: 'youTube',
- url: mockedYoutubeLink,
- isVerified: true,
- },
{
type: 'deezer',
url: 'https://www.deezer.com/show/1437252',
isVerified: true,
},
- {
- type: 'tidal',
- url: 'https://listen.tidal.com/search?q=Waveform%3A+The+MKBHD+Podcast',
- },
],
});
expect(mock.history.get).toHaveLength(2);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledTimes(1);
- // expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- // expect.stringContaining(youtubeSearchLink),
- // 'ytmusic-card-shelf-renderer a',
- // expect.any(Array)
- // );
});
});
diff --git a/tests/integration/search/song.test.ts b/tests/integration/search/song.test.ts
index 14159b6..3f68dca 100644
--- a/tests/integration/search/song.test.ts
+++ b/tests/integration/search/song.test.ts
@@ -1,25 +1,28 @@
-import { beforeAll, beforeEach, describe, expect, it, mock, jest } from 'bun:test';
-
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test';
+import { MetadataType } from '~/config/enum';
+import { ENV } from '~/config/env';
import { app } from '~/index';
-import { getLinkWithPuppeteer } from '~/utils/scraper';
+import * as tidalUniversalLinkParser from '~/parsers/tidal-universal-link';
import { cacheStore } from '~/services/cache';
-import { JSONRequest } from '../../utils/request';
+import deezerSongResponseMock from '../../fixtures/deezer/songResponseMock.json';
+import tidalSongResponseMock from '../../fixtures/tidal/songResponseMock.json';
+import youtubeSongResponseMock from '../../fixtures/youtube/songResponseMock.json';
+import { jsonRequest } from '../../utils/request';
import {
API_SEARCH_ENDPOINT,
getAppleMusicSearchLink,
getDeezerSearchLink,
getSoundCloudSearchLink,
+ getTidalSearchLink,
getYouTubeSearchLink,
urlShortenerLink,
urlShortenerResponseMock,
} from '../../utils/shared';
-import deezerSongResponseMock from '../../fixtures/deezer/songResponseMock.json';
-
const [
spotifySongHeadResponseMock,
spotifyMobileHeadResponseMock,
@@ -32,47 +35,52 @@ const [
Bun.file('tests/fixtures/soundcloud/songResponseMock.html').text(),
]);
-mock.module('~/utils/scraper', () => ({
- getLinkWithPuppeteer: jest.fn(),
-}));
-
describe('GET /search - Song', () => {
let mock: AxiosMockAdapter;
- const getLinkWithPuppeteerMock = getLinkWithPuppeteer as jest.Mock;
+ const getUniversalMetadataFromTidalMock = spyOn(
+ tidalUniversalLinkParser,
+ 'getUniversalMetadataFromTidal'
+ );
beforeAll(() => {
mock = new AxiosMockAdapter(axios);
});
beforeEach(() => {
- getLinkWithPuppeteerMock.mockClear();
+ getUniversalMetadataFromTidalMock.mockReset();
mock.reset();
cacheStore.reset();
+
+ getUniversalMetadataFromTidalMock.mockResolvedValue(undefined);
+ mock.onPost(ENV.adapters.spotify.authUrl).reply(200, {});
+ mock.onPost(ENV.adapters.tidal.authUrl).reply(200, {});
+ mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
});
- it.only('should return 200', async () => {
+ it('should return 200', async () => {
const link = 'https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384';
const query = 'Do Not Disturb Drake';
+ const tidalSearchLink = getTidalSearchLink(query, MetadataType.Song);
const appleMusicSearchLink = getAppleMusicSearchLink(query);
- const youtubeSearchLink = getYouTubeSearchLink(query, 'song');
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Song);
const deezerSearchLink = getDeezerSearchLink(query, 'track');
const soundCloudSearchLink = getSoundCloudSearchLink(query);
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link });
mock.onGet(link).reply(200, spotifySongHeadResponseMock);
+
+ mock.onGet(tidalSearchLink).reply(200, tidalSongResponseMock);
mock.onGet(appleMusicSearchLink).reply(200, appleMusicSongResponseMock);
mock.onGet(deezerSearchLink).reply(200, deezerSongResponseMock);
mock.onGet(soundCloudSearchLink).reply(200, soundCloudSongResponseMock);
- mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
+ mock.onGet(youtubeSearchLink).reply(200, youtubeSongResponseMock);
- const mockedYoutubeLink = 'https://music.youtube.com/watch?v=zhY_0DoQCQs';
- getLinkWithPuppeteerMock.mockResolvedValue(mockedYoutubeLink);
+ const response = await app.handle(request);
+ const data = await response.json();
- const response = await app.handle(request).then(res => res.json());
-
- expect(response).toEqual({
+ expect(data).toEqual({
id: 'b3Blbi5zcG90aWZ5LmNvbS90cmFjay8yS3ZIQzl6MTRHU2w0WXBrTk1YMzg0',
type: 'song',
title: 'Do Not Disturb',
@@ -82,11 +90,6 @@ describe('GET /search - Song', () => {
source: link,
universalLink: urlShortenerResponseMock.data.refer,
links: [
- {
- type: 'youTube',
- url: mockedYoutubeLink,
- isVerified: true,
- },
{
type: 'appleMusic',
url: 'https://music.apple.com/us/album/do-not-disturb/1440890708?i=1440892237',
@@ -104,18 +107,19 @@ describe('GET /search - Song', () => {
},
{
type: 'tidal',
- url: 'https://listen.tidal.com/search?q=Do+Not+Disturb+Drake',
+ url: 'https://tidal.com/browse/track/71717750',
+ isVerified: true,
+ },
+ {
+ type: 'youTube',
+ url: 'https://music.youtube.com/watch?v=vVd4T5NxLgI',
+ isVerified: true,
},
],
});
- expect(mock.history.get).toHaveLength(4);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledTimes(1);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- expect.stringContaining(youtubeSearchLink),
- 'ytmusic-card-shelf-renderer a',
- expect.any(Array)
- );
+ expect(mock.history.get).toHaveLength(6);
+ expect(getUniversalMetadataFromTidalMock).toHaveBeenCalledTimes(1);
});
it('should return 200 - Mobile link', async () => {
@@ -123,28 +127,27 @@ describe('GET /search - Song', () => {
const desktopSpotifyLink = 'https://open.spotify.com/track/3eP13S8D5m2cweMEg3ZDed';
const query = 'Do Not Disturb Drake';
+ const tidalSearchLink = getTidalSearchLink(query, MetadataType.Song);
const appleMusicSearchLink = getAppleMusicSearchLink(query);
- const youtubeSearchLink = getYouTubeSearchLink(query, 'song');
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Song);
const deezerSearchLink = getDeezerSearchLink(query, 'track');
const soundCloudSearchLink = getSoundCloudSearchLink(query);
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link: mobileSpotifyLink });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link: mobileSpotifyLink });
mock.onGet(mobileSpotifyLink).reply(200, spotifyMobileHeadResponseMock);
mock.onGet(desktopSpotifyLink).reply(200, spotifySongHeadResponseMock);
+ mock.onGet(tidalSearchLink).reply(200, tidalSongResponseMock);
mock.onGet(appleMusicSearchLink).reply(200, appleMusicSongResponseMock);
mock.onGet(deezerSearchLink).reply(200, deezerSongResponseMock);
mock.onGet(soundCloudSearchLink).reply(200, soundCloudSongResponseMock);
- mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
-
- const mockedYoutubeLink = 'https://music.youtube.com/watch?v=zhY_0DoQCQs';
- getLinkWithPuppeteerMock.mockResolvedValue(mockedYoutubeLink);
+ mock.onGet(youtubeSearchLink).reply(200, youtubeSongResponseMock);
const response = await app.handle(request).then(res => res.json());
expect(response).toEqual({
- id: 'b3Blbi5zcG90aWZ5LmNvbS90cmFjay8yS3ZIQzl6MTRHU2w0WXBrTk1YMzg0',
+ id: 'c3BvdGlmeS5saW5rL21PUUtmcUpaMURi',
type: 'song',
title: 'Do Not Disturb',
description: 'Drake · Song · 2017',
@@ -153,11 +156,6 @@ describe('GET /search - Song', () => {
source: mobileSpotifyLink,
universalLink: urlShortenerResponseMock.data.refer,
links: [
- {
- type: 'youTube',
- url: mockedYoutubeLink,
- isVerified: true,
- },
{
type: 'appleMusic',
url: 'https://music.apple.com/us/album/do-not-disturb/1440890708?i=1440892237',
@@ -175,19 +173,20 @@ describe('GET /search - Song', () => {
},
{
type: 'tidal',
- url: 'https://listen.tidal.com/search?q=Do+Not+Disturb+Drake',
+ url: 'https://tidal.com/browse/track/71717750',
+ isVerified: true,
+ },
+ {
+ type: 'youTube',
+ url: 'https://music.youtube.com/watch?v=vVd4T5NxLgI',
+ isVerified: true,
},
],
});
// extra call due to parsing mobile link to desktop
- expect(mock.history.get).toHaveLength(5);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledTimes(1);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- expect.stringContaining(youtubeSearchLink),
- 'ytmusic-card-shelf-renderer a',
- expect.any(Array)
- );
+ expect(mock.history.get).toHaveLength(7);
+ expect(getUniversalMetadataFromTidalMock).toHaveBeenCalledTimes(1);
});
it('should return 200 - Extra query params', async () => {
@@ -195,37 +194,34 @@ describe('GET /search - Song', () => {
'https://open.spotify.com/track/2KvHC9z14GSl4YpkNMX384?si=NbEEVPZvTVuov_nA3ylJJQ&utm_source=copy-link&utm_medium=copy-link&context=spotify%3Aalbum%3A4czdORdCWP9umpbhFXK2aW&_branch_match_id=1238568162599463760&_branch_referrer=H2sIAAAAAAAAA8soKSkottLXLy7IL8lMq9TLyczL1q%2Fy8nHxLLXwM3RJAgDKC3LnIAAAAA%3D%3D';
const query = 'Do Not Disturb Drake';
+ const tidalSearchLink = getTidalSearchLink(query, MetadataType.Song);
const appleMusicSearchLink = getAppleMusicSearchLink(query);
- const youtubeSearchLink = getYouTubeSearchLink(query, 'song');
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Song);
const deezerSearchLink = getDeezerSearchLink(query, 'track');
const soundCloudSearchLink = getSoundCloudSearchLink(query);
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link });
mock.onGet(link).reply(200, spotifySongHeadResponseMock);
+
+ mock.onGet(tidalSearchLink).reply(200, tidalSongResponseMock);
mock.onGet(appleMusicSearchLink).reply(200, appleMusicSongResponseMock);
mock.onGet(deezerSearchLink).reply(200, deezerSongResponseMock);
mock.onGet(soundCloudSearchLink).reply(200, soundCloudSongResponseMock);
-
- const mockedYoutubeLink = 'https://music.youtube.com/watch?v=zhY_0DoQCQs';
- getLinkWithPuppeteerMock.mockResolvedValue(mockedYoutubeLink);
+ mock.onGet(youtubeSearchLink).reply(200, youtubeSongResponseMock);
const response = await app.handle(request).then(res => res.json());
expect(response).toEqual({
- id: '2KvHC9z14GSl4YpkNMX384',
+ id: 'b3Blbi5zcG90aWZ5LmNvbS90cmFjay8yS3ZIQzl6MTRHU2w0WXBrTk1YMzg0P3NpPU5iRUVWUFp2VFZ1b3ZfbkEzeWxKSlE%3D',
type: 'song',
title: 'Do Not Disturb',
description: 'Drake · Song · 2017',
image: 'https://i.scdn.co/image/ab67616d0000b2734f0fd9dad63977146e685700',
audio: 'https://p.scdn.co/mp3-preview/df989a31c8233f46b6a997c59025f9c8021784aa',
source: link,
+ universalLink: urlShortenerResponseMock.data.refer,
links: [
- {
- type: 'youTube',
- url: mockedYoutubeLink,
- isVerified: true,
- },
{
type: 'appleMusic',
url: 'https://music.apple.com/us/album/do-not-disturb/1440890708?i=1440892237',
@@ -243,17 +239,18 @@ describe('GET /search - Song', () => {
},
{
type: 'tidal',
- url: 'https://listen.tidal.com/search?q=Do+Not+Disturb+Drake',
+ url: 'https://tidal.com/browse/track/71717750',
+ isVerified: true,
+ },
+ {
+ type: 'youTube',
+ url: 'https://music.youtube.com/watch?v=vVd4T5NxLgI',
+ isVerified: true,
},
],
});
- expect(mock.history.get).toHaveLength(4);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledTimes(1);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- expect.stringContaining(youtubeSearchLink),
- 'ytmusic-card-shelf-renderer a',
- expect.any(Array)
- );
+ expect(mock.history.get).toHaveLength(6);
+ expect(getUniversalMetadataFromTidalMock).toHaveBeenCalledTimes(1);
});
});
diff --git a/tests/integration/search/spotify-exculsive.test.ts b/tests/integration/search/spotify-exculsive.test.ts
index c31c57b..4582bc5 100644
--- a/tests/integration/search/spotify-exculsive.test.ts
+++ b/tests/integration/search/spotify-exculsive.test.ts
@@ -1,13 +1,16 @@
-import { beforeAll, beforeEach, describe, expect, it, mock, jest } from 'bun:test';
-
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test';
+import { MetadataType } from '~/config/enum';
+import { ENV } from '~/config/env';
import { app } from '~/index';
-import { getLinkWithPuppeteer } from '~/utils/scraper';
+import * as tidalUniversalLinkParser from '~/parsers/tidal-universal-link';
import { cacheStore } from '~/services/cache';
-import { JSONRequest } from '../../utils/request';
+import deezerExclusiveContentResponseMock from '../../fixtures/deezer/emptyResponseMock.json';
+import youtubeEmptyResponseMock from '../../fixtures/youtube/emptyResponseMock.json';
+import { jsonRequest } from '../../utils/request';
import {
API_SEARCH_ENDPOINT,
getAppleMusicSearchLink,
@@ -18,8 +21,6 @@ import {
urlShortenerResponseMock,
} from '../../utils/shared';
-import deezerExclusiveContentResponseMock from '../../fixtures/deezer/emptyResponseMock.json';
-
const [
spotifyExclusiveContentHeadResponseMock,
appleMusicExclusiveContentResponseMock,
@@ -30,22 +31,26 @@ const [
Bun.file('tests/fixtures/soundcloud/emptyResponseMock.html').text(),
]);
-mock.module('~/utils/scraper', () => ({
- getLinkWithPuppeteer: jest.fn(),
-}));
-
describe('GET /search - Spotify Exclusive Content', () => {
let mock: AxiosMockAdapter;
- const getLinkWithPuppeteerMock = getLinkWithPuppeteer as jest.Mock;
+ const getUniversalMetadataFromTidalMock = spyOn(
+ tidalUniversalLinkParser,
+ 'getUniversalMetadataFromTidal'
+ );
beforeAll(() => {
mock = new AxiosMockAdapter(axios);
});
beforeEach(() => {
- getLinkWithPuppeteerMock.mockClear();
+ getUniversalMetadataFromTidalMock.mockReset();
mock.reset();
cacheStore.reset();
+
+ getUniversalMetadataFromTidalMock.mockResolvedValue(undefined);
+ mock.onPost(ENV.adapters.spotify.authUrl).reply(200, {});
+ mock.onPost(ENV.adapters.tidal.authUrl).reply(200, {});
+ mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
});
it('should return 200', async () => {
@@ -53,23 +58,23 @@ describe('GET /search - Spotify Exclusive Content', () => {
const query = 'The Louis Theroux Podcast';
const appleMusicSearchLink = getAppleMusicSearchLink(query);
- const youtubeSearchLink = getYouTubeSearchLink(query, '');
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Podcast);
const deezerSearchLink = getDeezerSearchLink(query, 'podcast');
const soundCloudSearchLink = getSoundCloudSearchLink(query);
- const request = JSONRequest(API_SEARCH_ENDPOINT, { link });
+ const request = jsonRequest(API_SEARCH_ENDPOINT, { link });
mock.onGet(link).reply(200, spotifyExclusiveContentHeadResponseMock);
mock.onGet(appleMusicSearchLink).reply(200, appleMusicExclusiveContentResponseMock);
+ mock.onGet(youtubeSearchLink).reply(200, youtubeEmptyResponseMock);
mock.onGet(deezerSearchLink).reply(200, deezerExclusiveContentResponseMock);
mock.onGet(soundCloudSearchLink).reply(200, soundCloudExclusiveContentResponseMock);
mock.onPost(urlShortenerLink).reply(200, urlShortenerResponseMock);
- getLinkWithPuppeteerMock.mockResolvedValueOnce(undefined);
-
- const response = await app.handle(request).then(res => res.json());
+ const response = await app.handle(request);
+ const data = await response.json();
- expect(response).toEqual({
+ expect(data).toEqual({
id: 'b3Blbi5zcG90aWZ5LmNvbS9zaG93LzdMdVF2NDAwSkZ6emxKck91TXVrUmo%3D',
type: 'show',
title: 'The Louis Theroux Podcast',
@@ -82,11 +87,5 @@ describe('GET /search - Spotify Exclusive Content', () => {
});
expect(mock.history.get).toHaveLength(2);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledTimes(1);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- expect.stringContaining(youtubeSearchLink),
- 'ytmusic-card-shelf-renderer a',
- expect.any(Array)
- );
});
});
diff --git a/tests/unit/apple-music.test.ts b/tests/unit/apple-music.test.ts
index 7281a02..af311f2 100644
--- a/tests/unit/apple-music.test.ts
+++ b/tests/unit/apple-music.test.ts
@@ -1,10 +1,9 @@
-import { beforeAll, afterEach, describe, expect, it } from 'bun:test';
-
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeAll, describe, expect, it } from 'bun:test';
-import { MetadataType, Adapter } from '~/config/enum';
import { getAppleMusicLink } from '~/adapters/apple-music';
+import { Adapter, MetadataType } from '~/config/enum';
import { SearchMetadata } from '~/services/search';
import { getAppleMusicSearchLink } from '../utils/shared';
diff --git a/tests/unit/deezer.test.ts b/tests/unit/deezer.test.ts
index 7d58584..e4d23bf 100644
--- a/tests/unit/deezer.test.ts
+++ b/tests/unit/deezer.test.ts
@@ -1,15 +1,13 @@
-import { beforeAll, afterEach, describe, expect, it } from 'bun:test';
-
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeAll, describe, expect, it } from 'bun:test';
-import { MetadataType, Adapter } from '~/config/enum';
import { getDeezerLink } from '~/adapters/deezer';
+import { Adapter, MetadataType } from '~/config/enum';
import { SearchMetadata } from '~/services/search';
-import { getDeezerSearchLink } from '../utils/shared';
-
import deezerSongResponseMock from '../fixtures/deezer/songResponseMock.json';
+import { getDeezerSearchLink } from '../utils/shared';
describe('Adapter - Deezer', () => {
let mock: AxiosMockAdapter;
diff --git a/tests/unit/soundcloud.test.ts b/tests/unit/soundcloud.test.ts
index 1a88f91..a8651cc 100644
--- a/tests/unit/soundcloud.test.ts
+++ b/tests/unit/soundcloud.test.ts
@@ -1,10 +1,9 @@
-import { beforeAll, afterEach, describe, expect, it } from 'bun:test';
-
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeAll, describe, expect, it } from 'bun:test';
-import { MetadataType, Adapter } from '~/config/enum';
import { getSoundCloudLink } from '~/adapters/sound-cloud';
+import { Adapter, MetadataType } from '~/config/enum';
import { SearchMetadata } from '~/services/search';
import { getSoundCloudSearchLink } from '../utils/shared';
diff --git a/tests/unit/tidal.test.ts b/tests/unit/tidal.test.ts
new file mode 100644
index 0000000..fdb50c7
--- /dev/null
+++ b/tests/unit/tidal.test.ts
@@ -0,0 +1,43 @@
+import axios from 'axios';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeAll, describe, expect, it } from 'bun:test';
+
+import { getTidalLink } from '~/adapters/tidal';
+import { Adapter, MetadataType } from '~/config/enum';
+import { ENV } from '~/config/env';
+import { SearchMetadata } from '~/services/search';
+
+import tidalSongResponseMock from '../fixtures/tidal/songResponseMock.json';
+import { getTidalSearchLink } from '../utils/shared';
+
+describe('Adapter - Youtube', () => {
+ let mock: AxiosMockAdapter;
+
+ beforeAll(() => {
+ mock = new AxiosMockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.reset();
+ });
+
+ it('should return verified link', async () => {
+ const query = 'Do Not Disturb Drake';
+
+ const tidalSearchLink = getTidalSearchLink(query, MetadataType.Song);
+ mock.onPost(ENV.adapters.tidal.authUrl).reply(200, {});
+ mock.onGet(tidalSearchLink).reply(200, tidalSongResponseMock);
+
+ const tidalLink = await getTidalLink(query, {
+ type: MetadataType.Song,
+ } as SearchMetadata);
+
+ expect(tidalLink).toEqual({
+ type: Adapter.Tidal,
+ url: 'https://tidal.com/browse/track/71717750',
+ isVerified: true,
+ });
+
+ expect(mock.history.get).toHaveLength(1);
+ });
+});
diff --git a/tests/unit/youtube.test.ts b/tests/unit/youtube.test.ts
index 06a5f73..3e408b6 100644
--- a/tests/unit/youtube.test.ts
+++ b/tests/unit/youtube.test.ts
@@ -1,51 +1,41 @@
-import { afterEach, describe, expect, it, mock, jest, beforeAll } from 'bun:test';
+import axios from 'axios';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeAll, describe, expect, it } from 'bun:test';
-import { MetadataType, Adapter } from '~/config/enum';
import { getYouTubeLink } from '~/adapters/youtube';
-import { getLinkWithPuppeteer } from '~/utils/scraper';
-import { cacheStore } from '~/services/cache';
+import { Adapter, MetadataType } from '~/config/enum';
import { SearchMetadata } from '~/services/search';
+import youtubeSongResponseMock from '../fixtures/youtube/songResponseMock.json';
import { getYouTubeSearchLink } from '../utils/shared';
-mock.module('~/utils/scraper', () => ({
- getLinkWithPuppeteer: jest.fn(),
-}));
-
-describe('Adapter - YouTube', () => {
- const getLinkWithPuppeteerMock = getLinkWithPuppeteer as jest.Mock;
+describe('Adapter - Youtube', () => {
+ let mock: AxiosMockAdapter;
beforeAll(() => {
- cacheStore.reset();
+ mock = new AxiosMockAdapter(axios);
});
afterEach(() => {
- getLinkWithPuppeteerMock.mockClear();
+ mock.reset();
});
it('should return verified link', async () => {
const query = 'Do Not Disturb Drake';
- const searchLink = getYouTubeSearchLink(query, 'song');
-
- const mockedYoutubeLink = 'https://music.youtube.com/watch?v=zhY_0DoQCQs';
- getLinkWithPuppeteerMock.mockResolvedValueOnce(mockedYoutubeLink);
+ const youtubeSearchLink = getYouTubeSearchLink(query, MetadataType.Song);
+ mock.onGet(youtubeSearchLink).reply(200, youtubeSongResponseMock);
- const youTubeLink = await getYouTubeLink(query, {
+ const youtubeLink = await getYouTubeLink(query, {
type: MetadataType.Song,
} as SearchMetadata);
- expect(youTubeLink).toEqual({
+ expect(youtubeLink).toEqual({
type: Adapter.YouTube,
- url: mockedYoutubeLink,
- isVerified: true,
+ url: 'https://music.youtube.com/watch?v=vVd4T5NxLgI',
+ isVerified: false,
});
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledTimes(1);
- expect(getLinkWithPuppeteerMock).toHaveBeenCalledWith(
- expect.stringContaining(searchLink),
- 'ytmusic-card-shelf-renderer a',
- expect.any(Array)
- );
+ expect(mock.history.get).toHaveLength(1);
});
});
diff --git a/tests/utils/request.ts b/tests/utils/request.ts
index 2f1091d..cb6533f 100644
--- a/tests/utils/request.ts
+++ b/tests/utils/request.ts
@@ -1,4 +1,4 @@
-export const JSONRequest = (endpoint: string, body: object) => {
+export const jsonRequest = (endpoint: string, body: object) => {
return new Request(endpoint, {
method: 'POST',
headers: {
diff --git a/tests/utils/shared.ts b/tests/utils/shared.ts
index 022a0cb..9625e8c 100644
--- a/tests/utils/shared.ts
+++ b/tests/utils/shared.ts
@@ -1,3 +1,6 @@
+import { TIDAL_SEARCH_TYPES } from '~/adapters/tidal';
+import { YOUTUBE_SEARCH_TYPES } from '~/adapters/youtube';
+import { MetadataType } from '~/config/enum';
import { ENV } from '~/config/env';
export const API_ENDPOINT = 'http://localhost/api';
@@ -31,11 +34,6 @@ export const cachedResponse = {
url: 'https://music.apple.com/us/album/do-not-disturb/1440890708?i=1440892237',
isVerified: true,
},
- {
- type: 'youTube',
- url: 'https://music.youtube.com/watch?v=zhY_0DoQCQs',
- isVerified: true,
- },
{
type: 'deezer',
url: 'https://www.deezer.com/track/144572248',
@@ -48,17 +46,30 @@ export const cachedResponse = {
},
{
type: 'tidal',
- url: 'https://listen.tidal.com/search?q=Do+Not+Disturb+Drake',
+ url: 'https://tidal.com/browse/track/71717750',
+ isVerified: true,
+ },
+ {
+ type: 'youTube',
+ url: 'https://music.youtube.com/watch?v=vVd4T5NxLgI',
+ isVerified: true,
},
],
};
-export const getYouTubeSearchLink = (query: string, type: string) => {
+export const getYouTubeSearchLink = (query: string, type: MetadataType) => {
+ const searchType = YOUTUBE_SEARCH_TYPES[type]!;
+
const params = new URLSearchParams({
- q: `${query} ${type}`,
+ type: searchType,
+ regionCode: 'US',
+ q: query,
+ part: 'id',
+ safeSearch: 'none',
+ key: ENV.adapters.youTube.apiKey,
});
- const url = new URL(ENV.adapters.youTube.musicUrl);
+ const url = new URL(ENV.adapters.youTube.apiUrl);
url.search = params.toString();
return url.toString();
@@ -76,7 +87,7 @@ export const getAppleMusicSearchLink = (query: string) => {
export const getDeezerSearchLink = (query: string, type: string) => {
const params = new URLSearchParams({
q: query,
- limit: '1',
+ limit: '4',
});
const url = new URL(`${ENV.adapters.deezer.apiUrl}/${type}`);
@@ -95,3 +106,19 @@ export const getSoundCloudSearchLink = (query: string) => {
return url.toString();
};
+
+export const getTidalSearchLink = (query: string, type: MetadataType) => {
+ const searchType = TIDAL_SEARCH_TYPES[type]!;
+
+ const params = new URLSearchParams({
+ countryCode: 'US',
+ include: searchType,
+ });
+
+ const url = new URL(
+ `${ENV.adapters.tidal.apiUrl}/${encodeURIComponent(query)}/relationships/${searchType}`
+ );
+ url.search = params.toString();
+
+ return url.toString();
+};