From 4672315b694da6644bda220d796183bef2ca7b89 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Sun, 24 Nov 2024 17:09:44 +0100 Subject: [PATCH 01/14] feat: eslint-plugin-simple-import-sort setup --- .eslintrc.json | 7 +++++-- bun.lockb | Bin 221012 -> 221461 bytes package.json | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index b77ffe5..4ab3546 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -15,7 +15,8 @@ "sourceType": "module" }, "plugins": [ - "jsx" + "jsx", + "simple-import-sort" ], "rules": { "no-unused-vars": "off", @@ -24,6 +25,8 @@ { "varsIgnorePattern": "^Html$" } - ] + ], + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error" } } diff --git a/bun.lockb b/bun.lockb index 895466f919d7fa89cbc978b44c42509d0cefe599..d8f4c75f4abceccfc8b6407a062bd9410bdb64f6 100755 GIT binary patch delta 34196 zcmeHwd0bY-_y2RRJjSCUq9`ID?wSe;h=2%>sO5@V?mH*~%A$Y^xPUwE%T>pH%go)( z%y3K1EK}R9Of6eYbIGy=bNRjBcjkhX_4$5Yug~lESGUe{=A1b*bLPyMGjr#8WIjCN zw)D8$!oVk8(^{t3j(fCw;GGzCa#OdM$X30ddVbx68RZ&&57b%>XK4e5D>d3d- zY-PZo1Y8QZTuF(CXU3h?7^S-vrJX8BBD)|VYOIwu9K z-Ba{Wz$`a1A#EtTdl}`Rt>LA6$N9t9AoE6Mq$g%1WM*c?Wdsa^S=z$*EE6A>kr0rc zX)`f0bSP%Fm6eeYmpab&E96Ko1)put0j4FU0JD6wu}JsNUFj(YXbvz9n+r_oOoiX8 zXtQ~NJ_*d)4gs^4S1MU{9TJxkH#A{LO7iGr^dJestbIE$22 zyao!E&zpt>tdQ5)TMDi53_0br`x1EYy@E%UyF04vx7 zFQ+1lt4S}<2WA6}s>_07L31`F0n>gyXpP-THLAP#S1%2Ub7pN#Db*EHv}WeWl;o^n zo2`Wr=i;9m0B!|vYXP&(D!^>RL(wCX)6#8ttkS>NvDsk$yl;Uq9C_~pW4Q8O1Evly z1G6*RfZ54)!0dyids=!{_Au0!ls+~kA;Z?qwh^6V!Gx6T%;Y$mZA*Qd4a1QqfZ4g( zz^rgcdisdu1e+}k@>Fn0QbIyTa=PtgLs@=w+R)@7$u`@IMpwI6t^qD5M;b7tcLhq* z=1crkLebG>wrJ3`t2(!?@aFz~@lh0iPcB9WYG~lV@d*Ot1|} zic1**IrmmZ%O~pOj%+RUO^ZuQPeuSs$c!JIoHEq*%hS@$F9Wlhw-kO!;k63S0;U6x z2BrhYDIBYCgu=BI_E7l#Q>J`w-c^~%`v|xKDtJxd7ZhHj@Elvyuz)4S-u`H8*(WAjaW&)4~)>3_c|~=qag-}9`0aL?<3MXdS#b^UiCYbn6;@XC+n~)^ zoDnx90nYx^ASpKon2KBiW`0>Ej}BHf8TWe1jMRjT#01QAtKrLrR>~`AL!7ine7yAJ zUBK?B=H0>49z&84(uT%mkQF?$NRRP15s_yd4x_4-M&oWH_jlahxZX-m#b7>A;^Js_ynv}CiM+`N%- zSxEsS60*CY0ydbMoR&N?E;F+oXj{*GX7nknIx6=P+WFB4jWES%n}-s8`A_Bb*`f0hNNd;UfJS? zkHIGv<2)H+x&yQJpNy_zRv>GrpF}L8MSk-OQAy65N*m=@48-Xd%9GDICn=ch?1x&en!1T$!3*?k|5tydh z3e0kikk5t_(?`PBZMG0q->rpGvCM?jG1w*9#-Uv|#{A(*!%~Z7MYEU4hQ9^oSPkRO zG;T;z2Seu1gd8noUnWO*yplTunv-ULkc#9(o(jc+re8G&W`{yl{xQgxhWF$pqXPD5 z5ek$A?grWmI8xy!RlYlL74YvujtzYeOa&2=GZGRL#(fH!4J2pAWn{!<+fIO{qN%GT z|4m>|(EGu61KtXp%LdmWK`+Zt1>%9JaZg}+XLDdS)CiabpIImQwL!C-J1`si4VIze zSAi*idcEw>$DrB43dpKM6@JnF4G30#+%nrU~+%D%8*c?sG(RJ z0*0k!4%{N;wiyu~{<(jC=i9keN_U5EQO6gzNxCg)8Y^$R?EGk8)}9PZeiwyL?U2Sg z1WaRY0p_UGL-}gJn|I0hVRg2Rxq9cng!bfv(v%5_LeGN zwv6k6G8ON4xiimqZ?&rHHP?IH8vi!gBVobbqyFJ8+DArVO{W%RMAUL>9~#;Cj5G@H znQIi{bDt4W+o}C%WaG1*QGm~Qqp-Hqq1$b?_GrP?&}xNg9gXZdPHl-%fX@$&!a7d9 zN=c(x-Ds_+kzLoR%`gh;IvuZ+lzVaAh^ZUq_!?AmRPcmxtXH_p6E<6GV`AM%ZKzRD z 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 Date: Sun, 24 Nov 2024 17:39:47 +0100 Subject: [PATCH 02/14] feat: tidal adapter --- src/adapters/apple-music.ts | 12 ++-- src/adapters/deezer.ts | 12 ++-- src/adapters/sound-cloud.ts | 12 ++-- src/adapters/spotify.ts | 19 ++---- src/adapters/tidal.ts | 116 +++++++++++++++++++++++++++++++++--- src/adapters/youtube.ts | 11 ++-- src/config/enum.ts | 1 + src/config/env.ts | 12 ++-- src/parsers/apple-music.ts | 8 +-- src/parsers/deezer.ts | 8 +-- src/parsers/link.ts | 11 +++- src/parsers/sound-cloud.ts | 8 +-- src/parsers/spotify.ts | 13 ++-- src/parsers/tidal.ts | 0 src/parsers/youtube.ts | 8 +-- src/services/cache.ts | 11 +++- src/services/search.ts | 40 ++++++------- 17 files changed, 194 insertions(+), 108 deletions(-) create mode 100644 src/parsers/tidal.ts diff --git a/src/adapters/apple-music.ts b/src/adapters/apple-music.ts index 9b5b9ee..5814780 100644 --- a/src/adapters/apple-music.ts +++ b/src/adapters/apple-music.ts @@ -1,14 +1,12 @@ -import { ENV } from '~/config/env'; -import { MetadataType, Adapter } from '~/config/enum'; import { RESPONSE_COMPARE_MIN_SCORE } from '~/config/constants'; - +import { Adapter, MetadataType } from '~/config/enum'; +import { ENV } from '~/config/env'; +import { cacheSearchResultLink, getCachedSearchResultLink } from '~/services/cache'; +import { SearchMetadata, SearchResultLink } from '~/services/search'; +import { getResultWithBestScore } from '~/utils/compare'; import HttpClient from '~/utils/http-client'; import { logger } from '~/utils/logger'; import { getCheerioDoc } from '~/utils/scraper'; -import { getResultWithBestScore } from '~/utils/compare'; - -import { SearchMetadata, SearchResultLink } from '~/services/search'; -import { cacheSearchResultLink, getCachedSearchResultLink } from '~/services/cache'; export const APPLE_MUSIC_LINK_SELECTOR = 'a[href^="https://music.apple.com/"]'; diff --git a/src/adapters/deezer.ts b/src/adapters/deezer.ts index 1c50c54..5d50fe2 100644 --- a/src/adapters/deezer.ts +++ b/src/adapters/deezer.ts @@ -1,13 +1,11 @@ -import { ENV } from '~/config/env'; -import { MetadataType, Adapter } from '~/config/enum'; import { ADAPTERS_QUERY_LIMIT } from '~/config/constants'; - +import { Adapter, MetadataType } from '~/config/enum'; +import { ENV } from '~/config/env'; +import { cacheSearchResultLink, getCachedSearchResultLink } from '~/services/cache'; +import { SearchMetadata, SearchResultLink } from '~/services/search'; +import { responseMatchesQuery } from '~/utils/compare'; import HttpClient from '~/utils/http-client'; import { logger } from '~/utils/logger'; -import { responseMatchesQuery } from '~/utils/compare'; - -import { SearchMetadata, SearchResultLink } from '~/services/search'; -import { cacheSearchResultLink, getCachedSearchResultLink } from '~/services/cache'; interface DeezerSearchResponse { total: number; diff --git a/src/adapters/sound-cloud.ts b/src/adapters/sound-cloud.ts index e57608c..7b43d5d 100644 --- a/src/adapters/sound-cloud.ts +++ b/src/adapters/sound-cloud.ts @@ -1,14 +1,12 @@ -import { ENV } from '~/config/env'; -import { MetadataType, Adapter } from '~/config/enum'; import { RESPONSE_COMPARE_MIN_SCORE } from '~/config/constants'; - +import { Adapter, MetadataType } from '~/config/enum'; +import { ENV } from '~/config/env'; +import { cacheSearchResultLink, getCachedSearchResultLink } from '~/services/cache'; +import { SearchMetadata, SearchResultLink } from '~/services/search'; +import { getResultWithBestScore } from '~/utils/compare'; import HttpClient from '~/utils/http-client'; import { logger } from '~/utils/logger'; import { getCheerioDoc } from '~/utils/scraper'; -import { getResultWithBestScore } from '~/utils/compare'; - -import { SearchMetadata, SearchResultLink } from '~/services/search'; -import { cacheSearchResultLink, getCachedSearchResultLink } from '~/services/cache'; export async function getSoundCloudLink(query: string, metadata: SearchMetadata) { if (metadata.type === MetadataType.Show) { diff --git a/src/adapters/spotify.ts b/src/adapters/spotify.ts index 3b21a25..dc1e02b 100644 --- a/src/adapters/spotify.ts +++ b/src/adapters/spotify.ts @@ -1,23 +1,16 @@ +import { ADAPTERS_QUERY_LIMIT } from '~/config/constants'; +import { Adapter, MetadataType } from '~/config/enum'; import { ENV } from '~/config/env'; - -import { - ADAPTERS_QUERY_LIMIT, - SPOTIFY_LINK_DESKTOP_REGEX, - SPOTIFY_LINK_MOBILE_REGEX, -} from '~/config/constants'; -import { MetadataType, Adapter } from '~/config/enum'; - -import HttpClient from '~/utils/http-client'; -import { logger } from '~/utils/logger'; -import { responseMatchesQuery } from '~/utils/compare'; - -import { SearchMetadata, SearchResultLink } from '~/services/search'; import { cacheSearchResultLink, cacheSpotifyAccessToken, getCachedSearchResultLink, getCachedSpotifyAccessToken, } from '~/services/cache'; +import { SearchMetadata, SearchResultLink } from '~/services/search'; +import { responseMatchesQuery } from '~/utils/compare'; +import HttpClient from '~/utils/http-client'; +import { logger } from '~/utils/logger'; interface SpotifyAuthResponse { access_token: string; diff --git a/src/adapters/tidal.ts b/src/adapters/tidal.ts index 55aa296..7cc30f0 100644 --- a/src/adapters/tidal.ts +++ b/src/adapters/tidal.ts @@ -1,18 +1,116 @@ +import { Adapter, MetadataType } from '~/config/enum'; import { ENV } from '~/config/env'; -import { Adapter } from '~/config/enum'; +import { + cacheSearchResultLink, + cacheTidalAccessToken, + getCachedSearchResultLink, + getCachedTidalAccessToken, +} from '~/services/cache'; +import { SearchMetadata, SearchResultLink } from '~/services/search'; +import HttpClient from '~/utils/http-client'; +import { logger } from '~/utils/logger'; -import { SearchResultLink } from '~/services/search'; +interface TidalAuthResponse { + access_token: string; + token_type: string; + expires_in: number; +} + +interface TidalSearchResponse { + data: Array<{ + id: string; + type: string; + }>; +} + +const TIDAL_SEARCH_TYPES = { + [MetadataType.Song]: 'tracks', + [MetadataType.Album]: 'albums', + [MetadataType.Playlist]: 'playlists', + [MetadataType.Artist]: 'artists', + [MetadataType.Show]: '', + [MetadataType.Podcast]: '', +}; + +export async function getTidalLink(query: string, metadata: SearchMetadata) { + const searchType = TIDAL_SEARCH_TYPES[metadata.type]; + + if (!searchType) { + return; + } -export function getTidalLink(query: string) { const params = new URLSearchParams({ - q: query, + countryCode: 'US', }); - const url = new URL(`${ENV.adapters.tidal.baseUrl}/search`); + const url = new URL( + `${ENV.adapters.tidal.apiUrl}/${encodeURIComponent(query)}/relationships/${searchType}` + ); url.search = params.toString(); - return { - type: Adapter.Tidal, - url: url.toString(), - } as SearchResultLink; + const cache = await getCachedSearchResultLink(url); + if (cache) { + logger.info(`[Tidal] (${url}) cache hit`); + return cache; + } + + try { + const response = await HttpClient.get(url.toString(), { + headers: { + Authorization: `Bearer ${await getOrUpdateTidalAccessToken()}`, + }, + }); + + const data = response.data; + + if (data.length === 0) { + throw new Error(`No results found: ${JSON.stringify(response)}`); + } + + const { id, type } = data[0]; + + const searchResultLink = { + type: Adapter.Tidal, + url: `${ENV.adapters.tidal.baseUrl}/${type.slice(0, -1)}/${id}`, + isVerified: type === searchType, + } as SearchResultLink; + + await cacheSearchResultLink(url, searchResultLink); + + return searchResultLink; + } catch (error) { + logger.error(`[Tidal] (${url}) ${error}`); + } +} + +export async function getOrUpdateTidalAccessToken() { + const cache = await getCachedTidalAccessToken(); + + if (cache) { + return cache; + } + + const data = new URLSearchParams({ + grant_type: 'client_credentials', + }); + + const response = await HttpClient.post( + ENV.adapters.tidal.authUrl, + data, + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: + 'Basic ' + + Buffer.from( + ENV.adapters.tidal.clientId + ':' + ENV.adapters.tidal.clientSecret + ).toString('base64'), + }, + } + ); + + await cacheTidalAccessToken(response.access_token, response.expires_in); + + return response.access_token; } diff --git a/src/adapters/youtube.ts b/src/adapters/youtube.ts index d6fec48..d21110a 100644 --- a/src/adapters/youtube.ts +++ b/src/adapters/youtube.ts @@ -1,13 +1,10 @@ +import { Adapter, MetadataType } from '~/config/enum'; import { ENV } from '~/config/env'; -import { MetadataType, Adapter } from '~/config/enum'; - -import { logger } from '~/utils/logger'; - +import { cacheSearchResultLink, getCachedSearchResultLink } from '~/services/cache'; import { SearchMetadata, SearchResultLink } from '~/services/search'; -import { getLinkWithPuppeteer } from '~/utils/scraper'; import HttpClient from '~/utils/http-client'; - -import { cacheSearchResultLink, getCachedSearchResultLink } from '~/services/cache'; +import { logger } from '~/utils/logger'; +import { getLinkWithPuppeteer } from '~/utils/scraper'; const YOUTUBE_SEARCH_TYPES = { [MetadataType.Song]: 'song', diff --git a/src/config/enum.ts b/src/config/enum.ts index 035fcc5..eee3a0c 100644 --- a/src/config/enum.ts +++ b/src/config/enum.ts @@ -13,6 +13,7 @@ export enum Parser { AppleMusic = 'appleMusic', Deezer = 'deezer', SoundCloud = 'soundCloud', + Tidal = 'tidal', } export enum MetadataType { diff --git a/src/config/env.ts b/src/config/env.ts index c1ab968..5d96316 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -3,12 +3,19 @@ import { version } from '../../package.json'; export const ENV = { adapters: { spotify: { + apiUrl: Bun.env.SPOTIFY_API_URL!, authUrl: Bun.env.SPOTIFY_AUTH_URL!, clientId: Bun.env.SPOTIFY_CLIENT_ID!, clientSecret: Bun.env.SPOTIFY_CLIENT_SECRET!, - apiUrl: Bun.env.SPOTIFY_API_URL!, clientVersion: Bun.env.SPOTIFY_CLIENT_VERSION!, }, + tidal: { + baseUrl: Bun.env.TIDAL_BASE_URL!, + apiUrl: Bun.env.TIDAL_API_URL!, + authUrl: Bun.env.TIDAL_AUTH_URL!, + clientId: Bun.env.TIDAL_CLIENT_ID!, + clientSecret: Bun.env.TIDAL_CLIENT_SECRET!, + }, youTube: { musicUrl: Bun.env.YOUTUBE_MUSIC_URL!, cookies: Bun.env.YOUTUBE_COOKIES!, @@ -19,9 +26,6 @@ export const ENV = { appleMusic: { apiUrl: Bun.env.APPLE_MUSIC_API_URL!, }, - tidal: { - baseUrl: Bun.env.TIDAL_BASE_URL!, - }, soundCloud: { baseUrl: Bun.env.SOUNDCLOUD_BASE_URL!, }, diff --git a/src/parsers/apple-music.ts b/src/parsers/apple-music.ts index 8f76044..0ad4341 100644 --- a/src/parsers/apple-music.ts +++ b/src/parsers/apple-music.ts @@ -1,11 +1,9 @@ import { MetadataType, Parser } from '~/config/enum'; - -import { logger } from '~/utils/logger'; -import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; - -import { SearchMetadata } from '~/services/search'; import { cacheSearchMetadata, getCachedSearchMetadata } from '~/services/cache'; import { fetchMetadata } from '~/services/metadata'; +import { SearchMetadata } from '~/services/search'; +import { logger } from '~/utils/logger'; +import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; enum AppleMusicMetadataType { Song = 'music.song', diff --git a/src/parsers/deezer.ts b/src/parsers/deezer.ts index 140f437..17bec7a 100644 --- a/src/parsers/deezer.ts +++ b/src/parsers/deezer.ts @@ -1,11 +1,9 @@ import { MetadataType, Parser } from '~/config/enum'; - -import { logger } from '~/utils/logger'; -import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; - -import { SearchMetadata } from '~/services/search'; import { cacheSearchMetadata, getCachedSearchMetadata } from '~/services/cache'; import { fetchMetadata } from '~/services/metadata'; +import { SearchMetadata } from '~/services/search'; +import { logger } from '~/utils/logger'; +import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; enum DeezerMetadataType { Song = 'music.song', diff --git a/src/parsers/link.ts b/src/parsers/link.ts index 2561a37..adee246 100644 --- a/src/parsers/link.ts +++ b/src/parsers/link.ts @@ -8,7 +8,6 @@ import { YOUTUBE_LINK_REGEX, } from '~/config/constants'; import { Parser } from '~/config/enum'; - import { getSourceFromId } from '~/utils/encoding'; import { logger } from '~/utils/logger'; @@ -31,7 +30,10 @@ export const getSearchParser = (link?: string, searchId?: string) => { } if (!source) { - throw new ParseError('Source not found'); + const error = new ParseError(); + error.message = 'Source not found'; + + throw error; } let id, type; @@ -71,7 +73,10 @@ export const getSearchParser = (link?: string, searchId?: string) => { } if (!id || !type) { - throw new ParseError('Service id could not be extracted from source.'); + const error = new ParseError(); + error.message = 'Service id could not be extracted from source.'; + + throw error; } const searchParser = { diff --git a/src/parsers/sound-cloud.ts b/src/parsers/sound-cloud.ts index bcb31c6..745a961 100644 --- a/src/parsers/sound-cloud.ts +++ b/src/parsers/sound-cloud.ts @@ -1,11 +1,9 @@ import { MetadataType, Parser } from '~/config/enum'; - -import { logger } from '~/utils/logger'; -import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; - -import { SearchMetadata } from '~/services/search'; import { cacheSearchMetadata, getCachedSearchMetadata } from '~/services/cache'; import { fetchMetadata } from '~/services/metadata'; +import { SearchMetadata } from '~/services/search'; +import { logger } from '~/utils/logger'; +import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; enum SoundCloudMetadataType { Song = 'music.song', diff --git a/src/parsers/spotify.ts b/src/parsers/spotify.ts index 480f933..3049713 100644 --- a/src/parsers/spotify.ts +++ b/src/parsers/spotify.ts @@ -1,17 +1,14 @@ -import { MetadataType, Parser } from '~/config/enum'; - -import { logger } from '~/utils/logger'; -import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; - -import { SearchMetadata } from '~/services/search'; -import { cacheSearchMetadata, getCachedSearchMetadata } from '~/services/cache'; - import { SPOTIFY_LINK_DESKTOP_REGEX, SPOTIFY_LINK_MOBILE_REGEX, } from '~/config/constants'; +import { MetadataType, Parser } from '~/config/enum'; +import { cacheSearchMetadata, getCachedSearchMetadata } from '~/services/cache'; import { defaultHeaders, fetchMetadata } from '~/services/metadata'; +import { SearchMetadata } from '~/services/search'; import HttpClient from '~/utils/http-client'; +import { logger } from '~/utils/logger'; +import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; enum SpotifyMetadataType { Song = 'music.song', diff --git a/src/parsers/tidal.ts b/src/parsers/tidal.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/parsers/youtube.ts b/src/parsers/youtube.ts index eea971a..44bbaff 100644 --- a/src/parsers/youtube.ts +++ b/src/parsers/youtube.ts @@ -1,11 +1,9 @@ import { MetadataType, Parser } from '~/config/enum'; - -import { logger } from '~/utils/logger'; -import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; - -import { SearchMetadata } from '~/services/search'; import { cacheSearchMetadata, getCachedSearchMetadata } from '~/services/cache'; import { fetchMetadata } from '~/services/metadata'; +import { SearchMetadata } from '~/services/search'; +import { logger } from '~/utils/logger'; +import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; enum YouTubeMetadataType { Song = 'video.other', diff --git a/src/services/cache.ts b/src/services/cache.ts index f9ebfec..c5342c9 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -1,8 +1,8 @@ import { caching } from 'cache-manager'; import bunSqliteStore from 'cache-manager-bun-sqlite3'; -import { ENV } from '~/config/env'; import type { Parser } from '~/config/enum'; +import { ENV } from '~/config/env'; import { SearchMetadata, SearchResultLink } from './search'; @@ -42,10 +42,19 @@ export const getCachedSearchMetadata = async (id: string, parser: Parser) => { export const cacheSpotifyAccessToken = async (accessToken: string, expTime: number) => { await cacheStore.set('spotify:accessToken', accessToken, expTime); }; + export const getCachedSpotifyAccessToken = async (): Promise => { return cacheStore.get('spotify:accessToken'); }; +export const cacheTidalAccessToken = async (accessToken: string, expTime: number) => { + await cacheStore.set('tidal:accessToken', accessToken, expTime); +}; + +export const getCachedTidalAccessToken = async (): Promise => { + return cacheStore.get('tidal:accessToken'); +}; + export const cacheShortenLink = async (link: string, refer: string) => { await cacheStore.set(`url-shortener:${link}`, refer); }; diff --git a/src/services/search.ts b/src/services/search.ts index e8b1bf5..732da3a 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -1,29 +1,26 @@ +import { getAppleMusicLink } from '~/adapters/apple-music'; +import { getDeezerLink } from '~/adapters/deezer'; +import { getSoundCloudLink } from '~/adapters/sound-cloud'; +import { getSpotifyLink } from '~/adapters/spotify'; +import { getTidalLink } from '~/adapters/tidal'; +import { getYouTubeLink } from '~/adapters/youtube'; +import { Adapter, MetadataType, Parser } from '~/config/enum'; import { ENV } from '~/config/env'; -import { MetadataType, Adapter, Parser } from '~/config/enum'; - -import { logger } from '~/utils/logger'; -import { generateId } from '~/utils/encoding'; -import { shortenLink } from '~/utils/url-shortener'; - -import { getSearchParser } from '~/parsers/link'; -import { getSpotifyMetadata, getSpotifyQueryFromMetadata } from '~/parsers/spotify'; -import { getYouTubeMetadata, getYouTubeQueryFromMetadata } from '~/parsers/youtube'; import { getAppleMusicMetadata, getAppleMusicQueryFromMetadata, } from '~/parsers/apple-music'; import { getDeezerMetadata, getDeezerQueryFromMetadata } from '~/parsers/deezer'; +import { getSearchParser } from '~/parsers/link'; import { getSoundCloudMetadata, getSoundCloudQueryFromMetadata, } from '~/parsers/sound-cloud'; - -import { getAppleMusicLink } from '~/adapters/apple-music'; -import { getYouTubeLink } from '~/adapters/youtube'; -import { getDeezerLink } from '~/adapters/deezer'; -import { getSoundCloudLink } from '~/adapters/sound-cloud'; -import { getTidalLink } from '~/adapters/tidal'; -import { getSpotifyLink } from '~/adapters/spotify'; +import { getSpotifyMetadata, getSpotifyQueryFromMetadata } from '~/parsers/spotify'; +import { getYouTubeMetadata, getYouTubeQueryFromMetadata } from '~/parsers/youtube'; +import { generateId } from '~/utils/encoding'; +import { logger } from '~/utils/logger'; +import { shortenLink } from '~/utils/url-shortener'; export type SearchMetadata = { title: string; @@ -135,6 +132,7 @@ export const search = async ({ appleMusicLink, deezerLink, soundCloudLink, + tidalLink, shortLink, ] = await Promise.all([ searchParser.type !== Parser.Spotify ? getSpotifyLink(query, metadata) : null, @@ -150,6 +148,9 @@ export const search = async ({ searchParser.type !== Parser.SoundCloud && searchAdapters.includes(Adapter.SoundCloud) ? getSoundCloudLink(query, metadata) : null, + searchParser.type !== Parser.Tidal && searchAdapters.includes(Adapter.Tidal) + ? getTidalLink(query, metadata) + : null, shortenLink(`${ENV.app.url}?id=${id}`), ]); @@ -164,16 +165,11 @@ export const search = async ({ appleMusicLink, deezerLink, soundCloudLink, + tidalLink, ].filter(Boolean); logger.info(`[${search.name}] (results) ${links.map(link => link?.url)}`); - // Add Tidal link if at least one link is verified and Tidal is included in the adapters - if (links.some(link => link?.isVerified) && searchAdapters.includes(Adapter.Tidal)) { - const tidalLink = getTidalLink(query); - links.push(tidalLink); - } - const searchResult: SearchResult = { id, type: metadata.type, From c8330f65cb5ed922d4dabca77918ad9d00e5db67 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Sun, 24 Nov 2024 18:25:40 +0100 Subject: [PATCH 03/14] feat: tidal parser --- src/config/constants.ts | 5 +- src/parsers/link.ts | 7 +++ src/parsers/tidal.ts | 85 ++++++++++++++++++++++++++++ src/services/search.ts | 5 ++ src/views/components/search-card.tsx | 2 +- 5 files changed, 102 insertions(+), 2 deletions(-) diff --git a/src/config/constants.ts b/src/config/constants.ts index b31970d..09f6128 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -17,7 +17,10 @@ export const DEEZER_LINK_REGEX = export const SOUNDCLOUD_LINK_REGEX = /^https:\/\/soundcloud\.com\/([\w-]+)\/([\w-]+)(?:\/sets\/([\w-]+))?(?:[\?#].*)?/; -export const ALLOWED_LINKS_REGEX = `${SPOTIFY_LINK_REGEX.source}|${YOUTUBE_LINK_REGEX.source}|${APPLE_MUSIC_LINK_REGEX.source}|${DEEZER_LINK_REGEX.source}|${SOUNDCLOUD_LINK_REGEX.source}`; +export const TIDAL_LINK_REGEX = + /^https:\/\/tidal\.com\/browse\/(track|artist|album|mix|video)\/([\w-]+)(?:\/[\w-]+)?(?:[\?#].*)?$/; + +export const ALLOWED_LINKS_REGEX = `${SPOTIFY_LINK_REGEX.source}|${YOUTUBE_LINK_REGEX.source}|${APPLE_MUSIC_LINK_REGEX.source}|${DEEZER_LINK_REGEX.source}|${SOUNDCLOUD_LINK_REGEX.source}|${TIDAL_LINK_REGEX.source}`; export const ADAPTERS_QUERY_LIMIT = 1; export const RESPONSE_COMPARE_MIN_SCORE = 0.4; diff --git a/src/parsers/link.ts b/src/parsers/link.ts index adee246..67eaa20 100644 --- a/src/parsers/link.ts +++ b/src/parsers/link.ts @@ -5,6 +5,7 @@ import { DEEZER_LINK_REGEX, SOUNDCLOUD_LINK_REGEX, SPOTIFY_LINK_REGEX, + TIDAL_LINK_REGEX, YOUTUBE_LINK_REGEX, } from '~/config/constants'; import { Parser } from '~/config/enum'; @@ -72,6 +73,12 @@ export const getSearchParser = (link?: string, searchId?: string) => { type = Parser.SoundCloud; } + const tidalId = source.match(TIDAL_LINK_REGEX)?.[1]; + if (tidalId) { + id = tidalId; + type = Parser.Tidal; + } + if (!id || !type) { const error = new ParseError(); error.message = 'Service id could not be extracted from source.'; diff --git a/src/parsers/tidal.ts b/src/parsers/tidal.ts index e69de29..be96134 100644 --- a/src/parsers/tidal.ts +++ b/src/parsers/tidal.ts @@ -0,0 +1,85 @@ +import { MetadataType, Parser } from '~/config/enum'; +import { cacheSearchMetadata, getCachedSearchMetadata } from '~/services/cache'; +import { fetchMetadata } from '~/services/metadata'; +import { SearchMetadata } from '~/services/search'; +import { logger } from '~/utils/logger'; +import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; + +enum TidalMetadataType { + Song = 'music.song', + Album = 'music.album', + Playlist = 'music.playlist', + Artist = 'profile', + Video = 'video.other', +} + +const TIDAL_METADATA_TO_METADATA_TYPE = { + [TidalMetadataType.Song]: MetadataType.Song, + [TidalMetadataType.Album]: MetadataType.Album, + [TidalMetadataType.Playlist]: MetadataType.Playlist, + [TidalMetadataType.Artist]: MetadataType.Artist, + [TidalMetadataType.Video]: MetadataType.Song, +}; + +export const getTidalMetadata = async (id: string, link: string) => { + const cached = await getCachedSearchMetadata(id, Parser.Tidal); + if (cached) { + logger.info(`[Tidal] (${id}) metadata cache hit`); + return cached; + } + + try { + const html = await fetchMetadata(link); + + const doc = getCheerioDoc(html); + + const title = metaTagContent(doc, 'og:title', 'property')?.trim(); + const description = metaTagContent(doc, 'og:description', 'property'); + const image = metaTagContent(doc, 'og:image', 'property'); + const audio = metaTagContent(doc, 'og:audio', 'property'); + const type = metaTagContent(doc, 'og:type', 'property') as TidalMetadataType; + + if (!title || !description || !type || !image) { + throw new Error('Tidal metadata not found'); + } + + const metadata = { + id, + title, + description, + type: TIDAL_METADATA_TO_METADATA_TYPE[type], + image, + audio, + } as SearchMetadata; + + await cacheSearchMetadata(id, Parser.Tidal, metadata); + + return metadata; + } catch (err) { + throw new Error(`[${getTidalMetadata.name}] (${link}) ${err}`); + } +}; + +export const getTidalQueryFromMetadata = (metadata: SearchMetadata) => { + let query = metadata.title; + + if (metadata.type === MetadataType.Song || metadata.type === MetadataType.Album) { + const ogTitlePattern = /^(.*?)\s*-\s*(.*)$/; + const matches = query.match(ogTitlePattern); + + if (matches) { + const [, artist, content] = matches; + if (metadata.type === MetadataType.Song) { + query = `${content} ${artist}`; + } else if (metadata.type === MetadataType.Album) { + query = `${content} ${artist} album`; + } + } + } + + if (metadata.type === MetadataType.Playlist) { + query = `${query} playlist`; + } + + return query; +}; diff --git a/src/services/search.ts b/src/services/search.ts index 732da3a..d17aefd 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -17,6 +17,7 @@ import { getSoundCloudQueryFromMetadata, } from '~/parsers/sound-cloud'; import { getSpotifyMetadata, getSpotifyQueryFromMetadata } from '~/parsers/spotify'; +import { getTidalMetadata, getTidalQueryFromMetadata } from '~/parsers/tidal'; import { getYouTubeMetadata, getYouTubeQueryFromMetadata } from '~/parsers/youtube'; import { generateId } from '~/utils/encoding'; import { logger } from '~/utils/logger'; @@ -91,6 +92,10 @@ export const search = async ({ metadata = await getSoundCloudMetadata(searchParser.id, searchParser.source); query = getSoundCloudQueryFromMetadata(metadata); break; + case Parser.Tidal: + metadata = await getTidalMetadata(searchParser.id, searchParser.source); + query = getTidalQueryFromMetadata(metadata); + break; } if (!metadata || !query) { diff --git a/src/views/components/search-card.tsx b/src/views/components/search-card.tsx index 43cd6e9..5df1ea8 100644 --- a/src/views/components/search-card.tsx +++ b/src/views/components/search-card.tsx @@ -5,7 +5,7 @@ import { SearchResult } from '~/services/search'; const SEARCH_LINK_DICT = { [Adapter.Spotify]: { - icon: 'ti ti-brand-spotify-filled', + icon: 'ti ti-brand-spotify', label: 'Listen on Spotify', }, [Adapter.YouTube]: { From 2508a85e81eed1379a2bf6bd79ff121467b16730 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Sun, 24 Nov 2024 18:25:55 +0100 Subject: [PATCH 04/14] feat: search extract from universal link --- src/adapters/apple-music.ts | 3 +- src/adapters/deezer.ts | 3 +- src/adapters/sound-cloud.ts | 5 ++- src/adapters/spotify.ts | 3 +- src/adapters/tidal.ts | 3 +- src/adapters/youtube.ts | 7 ++-- src/parsers/tidal.ts | 66 +++++++++++++++++++++++++++++++-- src/services/cache.ts | 15 +++++++- src/services/search.ts | 73 ++++++++++++++++++++++++++----------- src/utils/url-shortener.ts | 1 - 10 files changed, 144 insertions(+), 35 deletions(-) diff --git a/src/adapters/apple-music.ts b/src/adapters/apple-music.ts index 5814780..56397a2 100644 --- a/src/adapters/apple-music.ts +++ b/src/adapters/apple-music.ts @@ -23,7 +23,7 @@ export async function getAppleMusicLink(query: string, metadata: SearchMetadata) const searchType = APPLE_MUSIC_SEARCH_TYPES[metadata.type]; if (!searchType) { - return; + return null; } // apple music does not support x-www-form-urlencoded encoding @@ -58,5 +58,6 @@ export async function getAppleMusicLink(query: string, metadata: SearchMetadata) return searchResultLink; } catch (err) { logger.error(`[Apple Music] (${url}) ${err} `); + return null; } } diff --git a/src/adapters/deezer.ts b/src/adapters/deezer.ts index 5d50fe2..983e785 100644 --- a/src/adapters/deezer.ts +++ b/src/adapters/deezer.ts @@ -31,7 +31,7 @@ export async function getDeezerLink(query: string, metadata: SearchMetadata) { const searchType = DEEZER_SEARCH_TYPES[metadata.type]; if (!searchType) { - return; + return null; } const params = new URLSearchParams({ @@ -68,5 +68,6 @@ export async function getDeezerLink(query: string, metadata: SearchMetadata) { return searchResultLink; } catch (error) { logger.error(`[Deezer] (${url}) ${error}`); + return null; } } diff --git a/src/adapters/sound-cloud.ts b/src/adapters/sound-cloud.ts index 7b43d5d..64911cf 100644 --- a/src/adapters/sound-cloud.ts +++ b/src/adapters/sound-cloud.ts @@ -10,7 +10,7 @@ import { getCheerioDoc } from '~/utils/scraper'; export async function getSoundCloudLink(query: string, metadata: SearchMetadata) { if (metadata.type === MetadataType.Show) { - return; + return null; } const params = new URLSearchParams({ @@ -39,7 +39,7 @@ export async function getSoundCloudLink(query: string, metadata: SearchMetadata) const { href, score } = getResultWithBestScore(noscriptDoc, listElements, query); if (score <= RESPONSE_COMPARE_MIN_SCORE) { - return; + return null; } const searchResultLink = { @@ -53,5 +53,6 @@ export async function getSoundCloudLink(query: string, metadata: SearchMetadata) return searchResultLink; } catch (err) { logger.error(`[SoundCloud] (${url}) ${err}`); + return null; } } diff --git a/src/adapters/spotify.ts b/src/adapters/spotify.ts index dc1e02b..0159068 100644 --- a/src/adapters/spotify.ts +++ b/src/adapters/spotify.ts @@ -45,7 +45,7 @@ export async function getSpotifyLink(query: string, metadata: SearchMetadata) { const searchType = SPOTIFY_SEARCH_TYPES[metadata.type]; if (!searchType) { - return; + return null; } const params = new URLSearchParams({ @@ -89,6 +89,7 @@ export async function getSpotifyLink(query: string, metadata: SearchMetadata) { return searchResultLink; } catch (error) { logger.error(`[Spotify] (${url}) ${error}`); + return null; } } diff --git a/src/adapters/tidal.ts b/src/adapters/tidal.ts index 7cc30f0..4644727 100644 --- a/src/adapters/tidal.ts +++ b/src/adapters/tidal.ts @@ -36,7 +36,7 @@ export async function getTidalLink(query: string, metadata: SearchMetadata) { const searchType = TIDAL_SEARCH_TYPES[metadata.type]; if (!searchType) { - return; + return null; } const params = new URLSearchParams({ @@ -80,6 +80,7 @@ export async function getTidalLink(query: string, metadata: SearchMetadata) { return searchResultLink; } catch (error) { logger.error(`[Tidal] (${url}) ${error}`); + return null; } } diff --git a/src/adapters/youtube.ts b/src/adapters/youtube.ts index d21110a..69252bd 100644 --- a/src/adapters/youtube.ts +++ b/src/adapters/youtube.ts @@ -16,6 +16,8 @@ const YOUTUBE_SEARCH_TYPES = { }; export async function getYouTubeLink(query: string, metadata: SearchMetadata) { + return null; // TEMPFIX: youtube blocked the server ip + const params = new URLSearchParams({ q: `${query} ${YOUTUBE_SEARCH_TYPES[metadata.type]}`, }); @@ -46,8 +48,6 @@ export async function getYouTubeLink(query: string, metadata: SearchMetadata) { }; }); - return; // TEMPFIX: youtube is blocked - const link = await getLinkWithPuppeteer( url.toString(), 'ytmusic-card-shelf-renderer a', @@ -55,7 +55,7 @@ export async function getYouTubeLink(query: string, metadata: SearchMetadata) { ); if (!link) { - return; + return null; } const searchResultLink = { @@ -69,6 +69,7 @@ export async function getYouTubeLink(query: string, metadata: SearchMetadata) { return searchResultLink; } catch (error) { logger.error(`[YouTube] (${url}) ${error}`); + return null; } } diff --git a/src/parsers/tidal.ts b/src/parsers/tidal.ts index be96134..089c1a1 100644 --- a/src/parsers/tidal.ts +++ b/src/parsers/tidal.ts @@ -1,7 +1,14 @@ -import { MetadataType, Parser } from '~/config/enum'; -import { cacheSearchMetadata, getCachedSearchMetadata } from '~/services/cache'; +import { CheerioAPI } from 'cheerio'; + +import { Adapter, MetadataType, Parser } from '~/config/enum'; +import { + cacheSearchMetadata, + cacheTidalUniversalLinkResponse, + getCachedSearchMetadata, + getCachedTidalUniversalLinkResponse, +} from '~/services/cache'; import { fetchMetadata } from '~/services/metadata'; -import { SearchMetadata } from '~/services/search'; +import { SearchMetadata, SearchResultLink } from '~/services/search'; import { logger } from '~/utils/logger'; import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; @@ -83,3 +90,56 @@ export const getTidalQueryFromMetadata = (metadata: SearchMetadata) => { return query; }; + +export const getUniversalMetadataFromTidal = async ( + link: string +): 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: true, + } + : 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/services/cache.ts b/src/services/cache.ts index c5342c9..d3b0292 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -1,7 +1,7 @@ import { caching } from 'cache-manager'; import bunSqliteStore from 'cache-manager-bun-sqlite3'; -import type { Parser } from '~/config/enum'; +import type { Adapter, Parser } from '~/config/enum'; import { ENV } from '~/config/env'; import { SearchMetadata, SearchResultLink } from './search'; @@ -55,6 +55,19 @@ export const getCachedTidalAccessToken = async (): Promise = return cacheStore.get('tidal:accessToken'); }; +export const cacheTidalUniversalLinkResponse = async ( + link: string, + response: Record +) => { + await cacheStore.set(`tidal:universalLink:${link}`, response); +}; + +export const getCachedTidalUniversalLinkResponse = async ( + link: string +): Promise | undefined> => { + return cacheStore.get(`tidal:universalLink:${link}`); +}; + export const cacheShortenLink = async (link: string, refer: string) => { await cacheStore.set(`url-shortener:${link}`, refer); }; diff --git a/src/services/search.ts b/src/services/search.ts index d17aefd..4752249 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -17,7 +17,11 @@ import { getSoundCloudQueryFromMetadata, } from '~/parsers/sound-cloud'; import { getSpotifyMetadata, getSpotifyQueryFromMetadata } from '~/parsers/spotify'; -import { getTidalMetadata, getTidalQueryFromMetadata } from '~/parsers/tidal'; +import { + getTidalMetadata, + getTidalQueryFromMetadata, + getUniversalMetadataFromTidal, +} from '~/parsers/tidal'; import { getYouTubeMetadata, getYouTubeQueryFromMetadata } from '~/parsers/youtube'; import { generateId } from '~/utils/encoding'; import { logger } from '~/utils/logger'; @@ -131,32 +135,59 @@ export const search = async ({ }; } - const [ - spotifyLink, - youtubeLink, - appleMusicLink, - deezerLink, - soundCloudLink, - tidalLink, - shortLink, - ] = await Promise.all([ - searchParser.type !== Parser.Spotify ? getSpotifyLink(query, metadata) : null, - searchParser.type !== Parser.YouTube && searchAdapters.includes(Adapter.YouTube) - ? getYouTubeLink(query, metadata) - : null, - searchParser.type !== Parser.AppleMusic && searchAdapters.includes(Adapter.AppleMusic) - ? getAppleMusicLink(query, metadata) - : null, + let spotifyLink: SearchResultLink | null = null; + let youtubeLink: SearchResultLink | null = null; + let appleMusicLink: SearchResultLink | null = null; + let deezerLink: SearchResultLink | null = null; + let soundCloudLink: SearchResultLink | null = null; + let tidalLink: SearchResultLink | null = null; + + if (searchParser.type !== Parser.Tidal) { + tidalLink = await getTidalLink(query, metadata); + + const fromUniversalLink = await getUniversalMetadataFromTidal(`${tidalLink?.url}/u`); + + logger.info( + `[${search.name}] (universalLink results) ${Object.values(fromUniversalLink ?? {}) + .map(link => link?.url) + .filter(Boolean)}` + ); + + if (fromUniversalLink) { + spotifyLink = fromUniversalLink.spotify; + youtubeLink = fromUniversalLink.youTube; + appleMusicLink = fromUniversalLink.appleMusic; + } + } + + let shortLink: string | null = null; + [spotifyLink, shortLink] = await Promise.all([ + spotifyLink + ? spotifyLink + : searchParser.type !== Parser.Spotify + ? getSpotifyLink(query, metadata) + : null, + shortenLink(`${ENV.app.url}?id=${id}`), + ]); + + [youtubeLink, appleMusicLink, deezerLink, soundCloudLink] = await Promise.all([ + youtubeLink + ? youtubeLink + : searchParser.type !== Parser.YouTube && searchAdapters.includes(Adapter.YouTube) + ? getYouTubeLink(query, metadata) + : null, + appleMusicLink + ? appleMusicLink + : searchParser.type !== Parser.AppleMusic && + searchAdapters.includes(Adapter.AppleMusic) + ? getAppleMusicLink(query, metadata) + : null, searchParser.type !== Parser.Deezer && searchAdapters.includes(Adapter.Deezer) ? getDeezerLink(query, metadata) : null, searchParser.type !== Parser.SoundCloud && searchAdapters.includes(Adapter.SoundCloud) ? getSoundCloudLink(query, metadata) : null, - searchParser.type !== Parser.Tidal && searchAdapters.includes(Adapter.Tidal) - ? getTidalLink(query, metadata) - : null, - shortenLink(`${ENV.app.url}?id=${id}`), ]); if (searchParser.type !== Parser.Spotify && spotifyLink) { diff --git a/src/utils/url-shortener.ts b/src/utils/url-shortener.ts index 7c48191..275c39a 100644 --- a/src/utils/url-shortener.ts +++ b/src/utils/url-shortener.ts @@ -1,5 +1,4 @@ import { ENV } from '~/config/env'; - import { cacheShortenLink, getCachedShortenLink } from '~/services/cache'; import HttpClient from './http-client'; From 4363dba42d57be87e07a6ccb73c1f60db73baa7c Mon Sep 17 00:00:00 2001 From: sjdonado Date: Sun, 24 Nov 2024 18:33:22 +0100 Subject: [PATCH 05/14] chore: update README + bump version --- README.md | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bba52aa..373b018 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,13 @@ Copy a link from your favorite streaming service, paste it into the search bar, Adapters represent the streaming services supported by the Web App and the Raycast Extension. Each adapter allows the app to convert links from one platform to others. The table below shows which features are available for each one: | Adapter | Inverted Search | Official API | Verified Links | -| ---------------- | --------------- | ----------------------- | -------------- | +| ---------------- | --------------- | ---------------------- | -------------- | | Spotify | Yes | Yes | Yes | +| Tidal | Yes | Yes | Yes | | YouTube Music | Yes | No | Yes | | Apple Music | Yes | No | Yes | | Deezer | Yes | Yes | Yes | | SoundCloud | Yes | No | Yes | -| Tidal | No | No (coming soon) | No | ## Web App diff --git a/package.json b/package.json index 098affe..d7bc9b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "idonthavespotify", - "version": "1.4.1", + "version": "1.5.0", "scripts": { "dev": "concurrently \"bun run build:dev\" \"bun run --watch www/bin.ts\"", "build:dev": "vite build --mode=development --watch", From 7380c3602c69b7aa9877bd2afd6cc8127ced95d8 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Sun, 24 Nov 2024 21:32:43 +0100 Subject: [PATCH 06/14] refactor: search replace switch with mappings + fix tidal regex --- src/config/enum.ts | 25 ++++-- src/parsers/link.ts | 4 +- src/services/search.ts | 193 +++++++++++++++++++++-------------------- src/utils/scraper.ts | 4 +- 4 files changed, 122 insertions(+), 104 deletions(-) diff --git a/src/config/enum.ts b/src/config/enum.ts index eee3a0c..8d6665d 100644 --- a/src/config/enum.ts +++ b/src/config/enum.ts @@ -1,4 +1,4 @@ -export enum Adapter { +export enum StreamingService { Spotify = 'spotify', YouTube = 'youTube', AppleMusic = 'appleMusic', @@ -7,15 +7,26 @@ export enum Adapter { Tidal = 'tidal', } +export enum Adapter { + Spotify = StreamingService.Spotify, + YouTube = StreamingService.YouTube, + AppleMusic = StreamingService.AppleMusic, + Deezer = StreamingService.Deezer, + SoundCloud = StreamingService.SoundCloud, + Tidal = StreamingService.Tidal, +} + export enum Parser { - Spotify = 'spotify', - YouTube = 'youTube', - AppleMusic = 'appleMusic', - Deezer = 'deezer', - SoundCloud = 'soundCloud', - Tidal = 'tidal', + Spotify = StreamingService.Spotify, + YouTube = StreamingService.YouTube, + AppleMusic = StreamingService.AppleMusic, + Deezer = StreamingService.Deezer, + SoundCloud = StreamingService.SoundCloud, + Tidal = StreamingService.Tidal, } +export type StreamingServiceType = Adapter & Parser; + export enum MetadataType { Song = 'song', Album = 'album', diff --git a/src/parsers/link.ts b/src/parsers/link.ts index 67eaa20..42414e2 100644 --- a/src/parsers/link.ts +++ b/src/parsers/link.ts @@ -14,7 +14,7 @@ import { logger } from '~/utils/logger'; export type SearchParser = { id: string; - type: string; + type: Parser; source: string; }; @@ -73,7 +73,7 @@ export const getSearchParser = (link?: string, searchId?: string) => { type = Parser.SoundCloud; } - const tidalId = source.match(TIDAL_LINK_REGEX)?.[1]; + const tidalId = source.match(TIDAL_LINK_REGEX)?.[2]; if (tidalId) { id = tidalId; type = Parser.Tidal; diff --git a/src/services/search.ts b/src/services/search.ts index 4752249..b648e37 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -4,7 +4,7 @@ import { getSoundCloudLink } from '~/adapters/sound-cloud'; import { getSpotifyLink } from '~/adapters/spotify'; import { getTidalLink } from '~/adapters/tidal'; import { getYouTubeLink } from '~/adapters/youtube'; -import { Adapter, MetadataType, Parser } from '~/config/enum'; +import { Adapter, MetadataType, Parser, StreamingServiceType } from '~/config/enum'; import { ENV } from '~/config/env'; import { getAppleMusicMetadata, @@ -73,47 +73,61 @@ export const search = async ({ const searchParser = getSearchParser(link, searchId); - let metadata, query; - - switch (searchParser.type) { - case Parser.Spotify: - metadata = await getSpotifyMetadata(searchParser.id, searchParser.source); - query = getSpotifyQueryFromMetadata(metadata); - break; - case Parser.YouTube: - metadata = await getYouTubeMetadata(searchParser.id, searchParser.source); - query = getYouTubeQueryFromMetadata(metadata); - break; - case Parser.AppleMusic: - metadata = await getAppleMusicMetadata(searchParser.id, searchParser.source); - query = getAppleMusicQueryFromMetadata(metadata); - break; - case Parser.Deezer: - metadata = await getDeezerMetadata(searchParser.id, searchParser.source); - query = getDeezerQueryFromMetadata(metadata); - break; - case Parser.SoundCloud: - metadata = await getSoundCloudMetadata(searchParser.id, searchParser.source); - query = getSoundCloudQueryFromMetadata(metadata); - break; - case Parser.Tidal: - metadata = await getTidalMetadata(searchParser.id, searchParser.source); - query = getTidalQueryFromMetadata(metadata); - break; - } + const metadataFetchers = { + [Parser.Spotify]: getSpotifyMetadata, + [Parser.YouTube]: getYouTubeMetadata, + [Parser.AppleMusic]: getAppleMusicMetadata, + [Parser.Deezer]: getDeezerMetadata, + [Parser.SoundCloud]: getSoundCloudMetadata, + [Parser.Tidal]: getTidalMetadata, + }; - if (!metadata || !query) { + const queryExtractors = { + [Parser.Spotify]: getSpotifyQueryFromMetadata, + [Parser.YouTube]: getYouTubeQueryFromMetadata, + [Parser.AppleMusic]: getAppleMusicQueryFromMetadata, + [Parser.Deezer]: getDeezerQueryFromMetadata, + [Parser.SoundCloud]: getSoundCloudQueryFromMetadata, + [Parser.Tidal]: getTidalQueryFromMetadata, + }; + + const linkGetters = { + [Adapter.Spotify]: getSpotifyLink, + [Adapter.YouTube]: getYouTubeLink, + [Adapter.AppleMusic]: getAppleMusicLink, + [Adapter.Deezer]: getDeezerLink, + [Adapter.SoundCloud]: getSoundCloudLink, + [Adapter.Tidal]: getTidalLink, + }; + + const fetchMetadata = metadataFetchers[searchParser.type]; + const extractQuery = queryExtractors[searchParser.type]; + + if (!fetchMetadata || !extractQuery) { throw new Error('Parser not implemented yet'); } + let metadata = await fetchMetadata(searchParser.id, searchParser.source); + const query = extractQuery(metadata); + const parserType = searchParser.type as StreamingServiceType; + logger.info( `[${search.name}] (params) ${JSON.stringify({ searchParser, metadata, query }, null, 2)}` ); const id = generateId(searchParser.source); const universalLink = `${ENV.app.url}?id=${id}`; + const linkSearchResult: SearchResultLink = { + type: parserType, + url: link as string, + isVerified: true, + }; - if (searchAdapters.length === 1 && searchAdapters[0] === searchParser.type) { + // Early return if only one adapter matches the parser type + if ( + searchAdapters.length === 1 && + searchParser.type === (searchAdapters[0] as StreamingServiceType) + ) { logger.info(`[${search.name}] early return - adapter is equal to parser type`); return { @@ -125,84 +139,79 @@ export const search = async ({ audio: metadata.audio, source: searchParser.source, universalLink, - links: [ - { - type: searchParser.type, - url: link, - isVerified: true, - }, - ] as SearchResultLink[], + links: [linkSearchResult], }; } - let spotifyLink: SearchResultLink | null = null; - let youtubeLink: SearchResultLink | null = null; - let appleMusicLink: SearchResultLink | null = null; - let deezerLink: SearchResultLink | null = null; - let soundCloudLink: SearchResultLink | null = null; - let tidalLink: SearchResultLink | null = null; + const links: SearchResultLink[] = []; + const existingAdapters = new Set(links.map(link => link.type)); - if (searchParser.type !== Parser.Tidal) { + // Fetch from Tidal first + let tidalLink: SearchResultLink | null = linkSearchResult; + if (parserType !== Adapter.Tidal) { tidalLink = await getTidalLink(query, metadata); + existingAdapters.add(Adapter.Tidal); + } + + if (tidalLink) { + links.push({ type: Adapter.Tidal, url: tidalLink.url, isVerified: true }); - const fromUniversalLink = await getUniversalMetadataFromTidal(`${tidalLink?.url}/u`); + // Fetch universal links from Tidal + const fromTidalULink = await getUniversalMetadataFromTidal(`${tidalLink.url}/u`); logger.info( - `[${search.name}] (universalLink results) ${Object.values(fromUniversalLink ?? {}) + `[${search.name}] (tidal universalLink results) ${Object.values( + fromTidalULink ?? {} + ) .map(link => link?.url) .filter(Boolean)}` ); - if (fromUniversalLink) { - spotifyLink = fromUniversalLink.spotify; - youtubeLink = fromUniversalLink.youTube; - appleMusicLink = fromUniversalLink.appleMusic; + if (fromTidalULink) { + for (const adapterKey in fromTidalULink) { + const adapter = adapterKey as Adapter; + if (parserType !== adapter && fromTidalULink[adapter]) { + links.push(fromTidalULink[adapter]); + existingAdapters.add(adapter); + } + } } } - let shortLink: string | null = null; - [spotifyLink, shortLink] = await Promise.all([ - spotifyLink - ? spotifyLink - : searchParser.type !== Parser.Spotify - ? getSpotifyLink(query, metadata) - : null, - shortenLink(`${ENV.app.url}?id=${id}`), - ]); + // Prepare promises for remaining adapters + const remainingAdapters = searchAdapters.filter( + adapter => !existingAdapters.has(adapter) && parserType !== adapter + ); - [youtubeLink, appleMusicLink, deezerLink, soundCloudLink] = await Promise.all([ - youtubeLink - ? youtubeLink - : searchParser.type !== Parser.YouTube && searchAdapters.includes(Adapter.YouTube) - ? getYouTubeLink(query, metadata) - : null, - appleMusicLink - ? appleMusicLink - : searchParser.type !== Parser.AppleMusic && - searchAdapters.includes(Adapter.AppleMusic) - ? getAppleMusicLink(query, metadata) - : null, - searchParser.type !== Parser.Deezer && searchAdapters.includes(Adapter.Deezer) - ? getDeezerLink(query, metadata) - : null, - searchParser.type !== Parser.SoundCloud && searchAdapters.includes(Adapter.SoundCloud) - ? getSoundCloudLink(query, metadata) - : null, - ]); + await Promise.all( + remainingAdapters + .map(adapter => { + const getLink = linkGetters[adapter]; + if (!getLink) return null; + + return getLink(query, metadata).then(link => { + if (link) { + links.push({ type: adapter, url: link.url, isVerified: true }); + existingAdapters.add(adapter); + } + }); + }) + .filter(Boolean) + ); - if (searchParser.type !== Parser.Spotify && spotifyLink) { - const spotifySearchParser = getSearchParser(spotifyLink.url); - metadata = await getSpotifyMetadata(spotifySearchParser.id, spotifyLink.url); - } + // Fetch metadata audio from spotify and universal link from bit + const spotifyLink = links.find(link => link.type === Adapter.Spotify); + const [updatedMetadata, shortLink] = await Promise.all([ + parserType !== Adapter.Spotify && spotifyLink + ? (async () => { + const spotifySearchParser = getSearchParser(spotifyLink.url); + return getSpotifyMetadata(spotifySearchParser.id, spotifyLink.url); + })() + : metadata, + shortenLink(universalLink), + ]); - const links = [ - spotifyLink, - youtubeLink, - appleMusicLink, - deezerLink, - soundCloudLink, - tidalLink, - ].filter(Boolean); + metadata = updatedMetadata; logger.info(`[${search.name}] (results) ${links.map(link => link?.url)}`); @@ -215,7 +224,7 @@ export const search = async ({ audio: metadata.audio, source: searchParser.source, universalLink: shortLink, - links: links as SearchResultLink[], + links, }; return searchResult; diff --git a/src/utils/scraper.ts b/src/utils/scraper.ts index dc20bf6..2e5b69f 100644 --- a/src/utils/scraper.ts +++ b/src/utils/scraper.ts @@ -1,5 +1,5 @@ import * as cheerio from 'cheerio'; -import puppeteer, { Browser, Page, CookieParam } from 'puppeteer'; +import puppeteer, { Browser, CookieParam, Page } from 'puppeteer'; let browser: Browser | null = null; @@ -46,10 +46,8 @@ export async function getLinkWithPuppeteer( await page.setViewport({ width: 768, height: 600 }); - // Use timeout for the page.goto operation await withTimeout(page.goto(url, { waitUntil: 'networkidle0' }), timeout); - // Use timeout for the page.evaluate operation const href = await withTimeout( page.evaluate( // eslint-disable-next-line From 7423fd1309070add1df8ca4d0e2e849792874593 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Sun, 24 Nov 2024 21:56:59 +0100 Subject: [PATCH 07/14] fix: sort search result links by verified and type --- src/adapters/sound-cloud.ts | 6 +----- src/parsers/tidal.ts | 16 +++++++++------- src/services/search.ts | 14 ++++++++++++-- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/adapters/sound-cloud.ts b/src/adapters/sound-cloud.ts index 64911cf..662f05d 100644 --- a/src/adapters/sound-cloud.ts +++ b/src/adapters/sound-cloud.ts @@ -38,14 +38,10 @@ export async function getSoundCloudLink(query: string, metadata: SearchMetadata) const { href, score } = getResultWithBestScore(noscriptDoc, listElements, query); - if (score <= RESPONSE_COMPARE_MIN_SCORE) { - return null; - } - const searchResultLink = { type: Adapter.SoundCloud, url: `${ENV.adapters.soundCloud.baseUrl}${href}`, - isVerified: true, + isVerified: score > RESPONSE_COMPARE_MIN_SCORE, } as SearchResultLink; await cacheSearchResultLink(url, searchResultLink); diff --git a/src/parsers/tidal.ts b/src/parsers/tidal.ts index 089c1a1..99f4adc 100644 --- a/src/parsers/tidal.ts +++ b/src/parsers/tidal.ts @@ -92,7 +92,8 @@ export const getTidalQueryFromMetadata = (metadata: SearchMetadata) => { }; export const getUniversalMetadataFromTidal = async ( - link: string + link: string, + isVerified: boolean ): Promise | undefined> => { const cached = await getCachedTidalUniversalLinkResponse(link); if (cached) { @@ -110,7 +111,7 @@ export const getUniversalMetadataFromTidal = async ( ? { type, url, - isVerified: true, + isVerified, } : null; }; @@ -126,11 +127,12 @@ export const getUniversalMetadataFromTidal = async ( 'a[href*="music.youtube.com"]', Adapter.YouTube ), - [Adapter.AppleMusic]: extractLink( - doc, - 'a[href*="music.apple.com"]', - Adapter.AppleMusic - ), + // [Adapter.AppleMusic]: extractLink( + // doc, + // 'a[href*="music.apple.com"]', + // Adapter.AppleMusic + // ), + [Adapter.AppleMusic]: null, [Adapter.Deezer]: null, [Adapter.SoundCloud]: null, [Adapter.Tidal]: null, diff --git a/src/services/search.ts b/src/services/search.ts index b648e37..3484ba9 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -154,10 +154,13 @@ export const search = async ({ } if (tidalLink) { - links.push({ type: Adapter.Tidal, url: tidalLink.url, isVerified: true }); + links.push(tidalLink); // Fetch universal links from Tidal - const fromTidalULink = await getUniversalMetadataFromTidal(`${tidalLink.url}/u`); + const fromTidalULink = await getUniversalMetadataFromTidal( + `${tidalLink.url}/u`, + tidalLink.isVerified as boolean + ); logger.info( `[${search.name}] (tidal universalLink results) ${Object.values( @@ -212,6 +215,13 @@ export const search = async ({ ]); metadata = updatedMetadata; + links.sort((a, b) => { + // Prioritize verified links + if (a.isVerified && !b.isVerified) return -1; + if (!a.isVerified && b.isVerified) return 1; + + return a.type.localeCompare(b.type); + }); logger.info(`[${search.name}] (results) ${links.map(link => link?.url)}`); From f86c9c7128297194bb3de5c6f703d1637b17e7bd Mon Sep 17 00:00:00 2001 From: sjdonado Date: Sun, 24 Nov 2024 22:31:05 +0100 Subject: [PATCH 08/14] fix: tidal return results by score --- src/adapters/tidal.ts | 46 ++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/adapters/tidal.ts b/src/adapters/tidal.ts index 4644727..8562213 100644 --- a/src/adapters/tidal.ts +++ b/src/adapters/tidal.ts @@ -1,3 +1,6 @@ +import { compareTwoStrings } from 'string-similarity'; + +import { RESPONSE_COMPARE_MIN_SCORE } from '~/config/constants'; import { Adapter, MetadataType } from '~/config/enum'; import { ENV } from '~/config/env'; import { @@ -21,6 +24,11 @@ interface TidalSearchResponse { id: string; type: string; }>; + included: Array<{ + attributes: { + title: string; + }; + }>; } const TIDAL_SEARCH_TYPES = { @@ -28,8 +36,8 @@ const TIDAL_SEARCH_TYPES = { [MetadataType.Album]: 'albums', [MetadataType.Playlist]: 'playlists', [MetadataType.Artist]: 'artists', - [MetadataType.Show]: '', - [MetadataType.Podcast]: '', + [MetadataType.Show]: null, + [MetadataType.Podcast]: null, }; export async function getTidalLink(query: string, metadata: SearchMetadata) { @@ -61,23 +69,37 @@ export async function getTidalLink(query: string, metadata: SearchMetadata) { }, }); - const data = response.data; + const { data, included } = response; - if (data.length === 0) { + if (!data || data.length === 0) { throw new Error(`No results found: ${JSON.stringify(response)}`); } - const { id, type } = data[0]; + const parsedQuery = query.toLowerCase(); + let bestMatch: SearchResultLink | null = null; + let highestScore = 0; + + for (const item of included) { + const title = item.attributes.title; + const score = compareTwoStrings(title.toLowerCase(), parsedQuery); + + if (score > highestScore) { + highestScore = score; + bestMatch = { + type: Adapter.Tidal, + url: `${ENV.adapters.tidal.baseUrl}/${searchType.slice(0, -1)}/${data[0].id}`, + isVerified: score > RESPONSE_COMPARE_MIN_SCORE, + }; + } + } - const searchResultLink = { - type: Adapter.Tidal, - url: `${ENV.adapters.tidal.baseUrl}/${type.slice(0, -1)}/${id}`, - isVerified: type === searchType, - } as SearchResultLink; + if (!bestMatch) { + throw new Error('No valid matches found.'); + } - await cacheSearchResultLink(url, searchResultLink); + await cacheSearchResultLink(url, bestMatch); - return searchResultLink; + return bestMatch; } catch (error) { logger.error(`[Tidal] (${url}) ${error}`); return null; From 1b0fa1e31ac4a22a90bc301b8a7ad89f526a0666 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Sun, 24 Nov 2024 22:45:02 +0100 Subject: [PATCH 09/14] fix: spotify extract artist from metadata desc --- src/parsers/spotify.ts | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/parsers/spotify.ts b/src/parsers/spotify.ts index 3049713..22b2e64 100644 --- a/src/parsers/spotify.ts +++ b/src/parsers/spotify.ts @@ -89,35 +89,27 @@ export const getSpotifyMetadata = async (id: string, link: string) => { }; export const getSpotifyQueryFromMetadata = (metadata: SearchMetadata) => { + // Remove emojis and clean extra whitespace or symbols const parsedTitle = metadata.title .replace( - /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F900}-\u{1F9FF}\u{1F1E0}-\u{1F1FF}]/gu, + /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F900}-\u{1F9FF}\u{1F1E0}-\u{1F1FF}·]/gu, '' ) + .replace(/\s+/g, ' ') .trim(); - let query = parsedTitle; + let artist = ''; + // Extract the artist from the description based on the metadata type if (metadata.type === MetadataType.Song) { - const [, artist] = metadata.description.match(/^([^·]+) · Song · \d+$/) ?? []; - query = artist ? `${query} ${artist}` : query; + [, artist] = metadata.description.match(/^([^·]+)\s+·/) ?? []; + } else if (metadata.type === MetadataType.Album) { + [, artist] = metadata.description.match(/^([^·]+)\s+·/) ?? []; + } else if (metadata.type === MetadataType.Podcast) { + [, artist] = metadata.description.match(/from\s(.+?)\son\sSpotify\./) ?? []; } - if (metadata.type === MetadataType.Album) { - const [, artist] = metadata.description.match(/(.+?) · Album ·/) ?? []; - - query = artist ? `${query} ${artist}` : query; - } - - if (metadata.type === MetadataType.Playlist) { - query = `${query.replace(/This is /, '')} Playlist`; - } - - if (metadata.type === MetadataType.Podcast) { - const [, artist] = metadata.description.match(/from (.+?) on Spotify\./) ?? []; - - query = artist ? `${query} ${artist}` : query; - } + const query = artist ? `${parsedTitle} ${artist.trim()}` : parsedTitle; return query; }; From d5da433a41c181cf1e9497c06559868e1d0ed4b8 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Sun, 24 Nov 2024 22:45:20 +0100 Subject: [PATCH 10/14] fix: tidal include searchtype --- src/adapters/tidal.ts | 4 ++-- src/config/constants.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/adapters/tidal.ts b/src/adapters/tidal.ts index 8562213..a79941d 100644 --- a/src/adapters/tidal.ts +++ b/src/adapters/tidal.ts @@ -49,6 +49,7 @@ export async function getTidalLink(query: string, metadata: SearchMetadata) { const params = new URLSearchParams({ countryCode: 'US', + include: searchType, }); const url = new URL( @@ -75,13 +76,12 @@ export async function getTidalLink(query: string, metadata: SearchMetadata) { throw new Error(`No results found: ${JSON.stringify(response)}`); } - const parsedQuery = query.toLowerCase(); let bestMatch: SearchResultLink | null = null; let highestScore = 0; for (const item of included) { const title = item.attributes.title; - const score = compareTwoStrings(title.toLowerCase(), parsedQuery); + const score = compareTwoStrings(title.toLowerCase(), query.toLowerCase()); if (score > highestScore) { highestScore = score; diff --git a/src/config/constants.ts b/src/config/constants.ts index 09f6128..28971c0 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -22,7 +22,7 @@ export const TIDAL_LINK_REGEX = export const ALLOWED_LINKS_REGEX = `${SPOTIFY_LINK_REGEX.source}|${YOUTUBE_LINK_REGEX.source}|${APPLE_MUSIC_LINK_REGEX.source}|${DEEZER_LINK_REGEX.source}|${SOUNDCLOUD_LINK_REGEX.source}|${TIDAL_LINK_REGEX.source}`; -export const ADAPTERS_QUERY_LIMIT = 1; +export const ADAPTERS_QUERY_LIMIT = 4; export const RESPONSE_COMPARE_MIN_SCORE = 0.4; export const DEFAULT_TIMEOUT = 3000; From 8b205071af1ac9f7a7dabdc29b6e0cd01c0f45d5 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Sun, 24 Nov 2024 22:47:12 +0100 Subject: [PATCH 11/14] refactor: score from multiple responses deezer --- src/adapters/deezer.ts | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/adapters/deezer.ts b/src/adapters/deezer.ts index 983e785..df8c65d 100644 --- a/src/adapters/deezer.ts +++ b/src/adapters/deezer.ts @@ -1,4 +1,6 @@ -import { ADAPTERS_QUERY_LIMIT } from '~/config/constants'; +import { compareTwoStrings } from 'string-similarity'; + +import { ADAPTERS_QUERY_LIMIT, RESPONSE_COMPARE_MIN_SCORE } from '~/config/constants'; import { Adapter, MetadataType } from '~/config/enum'; import { ENV } from '~/config/env'; import { cacheSearchResultLink, getCachedSearchResultLink } from '~/services/cache'; @@ -55,17 +57,30 @@ export async function getDeezerLink(query: string, metadata: SearchMetadata) { throw new Error(`No results found: ${JSON.stringify(response)}`); } - const [{ title, name, link }] = response.data; + let bestMatch: SearchResultLink | null = null; + let highestScore = 0; + + for (const item of response.data) { + const title = item.title || item.name || ''; + const score = compareTwoStrings(title.toLowerCase(), query.toLowerCase()); - const searchResultLink = { - type: Adapter.Deezer, - url: link, - isVerified: responseMatchesQuery(title ?? name ?? '', query), - } as SearchResultLink; + if (score > highestScore) { + highestScore = score; + bestMatch = { + type: Adapter.Deezer, + url: item.link, + isVerified: score > RESPONSE_COMPARE_MIN_SCORE, + }; + } + } + + if (!bestMatch) { + throw new Error('No valid matches found.'); + } - await cacheSearchResultLink(url, searchResultLink); + await cacheSearchResultLink(url, bestMatch); - return searchResultLink; + return bestMatch; } catch (error) { logger.error(`[Deezer] (${url}) ${error}`); return null; From e8056a278c2e2e36382384f8b34d79cea9d76214 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Wed, 27 Nov 2024 22:28:10 +0100 Subject: [PATCH 12/14] fix: refresh access token in background 3h before --- src/adapters/spotify.ts | 58 ++++++++++++++++++++------------------- src/adapters/tidal.ts | 58 ++++++++++++++++++++------------------- src/services/cache.ts | 21 ++++++++------ src/utils/access-token.ts | 46 +++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 64 deletions(-) create mode 100644 src/utils/access-token.ts diff --git a/src/adapters/spotify.ts b/src/adapters/spotify.ts index 0159068..43b83e8 100644 --- a/src/adapters/spotify.ts +++ b/src/adapters/spotify.ts @@ -8,6 +8,7 @@ import { getCachedSpotifyAccessToken, } from '~/services/cache'; import { SearchMetadata, SearchResultLink } from '~/services/search'; +import { getOrUpdateAccessToken } from '~/utils/access-token'; import { responseMatchesQuery } from '~/utils/compare'; import HttpClient from '~/utils/http-client'; import { logger } from '~/utils/logger'; @@ -94,33 +95,34 @@ export async function getSpotifyLink(query: string, metadata: SearchMetadata) { } export async function getOrUpdateSpotifyAccessToken() { - const cache = await getCachedSpotifyAccessToken(); - - if (cache) { - return cache; - } - - const data = new URLSearchParams({ - grant_type: 'client_credentials', - }); - - const response = await HttpClient.post( - ENV.adapters.spotify.authUrl, - data, - { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: - 'Basic ' + - Buffer.from( - ENV.adapters.spotify.clientId + ':' + ENV.adapters.spotify.clientSecret - ).toString('base64'), - }, - } + return getOrUpdateAccessToken( + getCachedSpotifyAccessToken, + async () => { + const data = new URLSearchParams({ + grant_type: 'client_credentials', + }); + + const response = await HttpClient.post( + ENV.adapters.spotify.authUrl, + data, + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: + 'Basic ' + + Buffer.from( + ENV.adapters.spotify.clientId + ':' + ENV.adapters.spotify.clientSecret + ).toString('base64'), + }, + } + ); + + return { + accessToken: response.access_token, + expiresIn: response.expires_in, + }; + }, + cacheSpotifyAccessToken ); - - await cacheSpotifyAccessToken(response.access_token, response.expires_in); - - return response.access_token; } diff --git a/src/adapters/tidal.ts b/src/adapters/tidal.ts index a79941d..c73cf1e 100644 --- a/src/adapters/tidal.ts +++ b/src/adapters/tidal.ts @@ -10,6 +10,7 @@ import { getCachedTidalAccessToken, } from '~/services/cache'; import { SearchMetadata, SearchResultLink } from '~/services/search'; +import { getOrUpdateAccessToken } from '~/utils/access-token'; import HttpClient from '~/utils/http-client'; import { logger } from '~/utils/logger'; @@ -107,33 +108,34 @@ export async function getTidalLink(query: string, metadata: SearchMetadata) { } export async function getOrUpdateTidalAccessToken() { - const cache = await getCachedTidalAccessToken(); - - if (cache) { - return cache; - } - - const data = new URLSearchParams({ - grant_type: 'client_credentials', - }); - - const response = await HttpClient.post( - ENV.adapters.tidal.authUrl, - data, - { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: - 'Basic ' + - Buffer.from( - ENV.adapters.tidal.clientId + ':' + ENV.adapters.tidal.clientSecret - ).toString('base64'), - }, - } + return getOrUpdateAccessToken( + getCachedTidalAccessToken, + async () => { + const data = new URLSearchParams({ + grant_type: 'client_credentials', + }); + + const response = await HttpClient.post( + ENV.adapters.tidal.authUrl, + data, + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: + 'Basic ' + + Buffer.from( + ENV.adapters.tidal.clientId + ':' + ENV.adapters.tidal.clientSecret + ).toString('base64'), + }, + } + ); + + return { + accessToken: response.access_token, + expiresIn: response.expires_in, + }; + }, + cacheTidalAccessToken ); - - await cacheTidalAccessToken(response.access_token, response.expires_in); - - return response.access_token; } diff --git a/src/services/cache.ts b/src/services/cache.ts index d3b0292..9729b0e 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -6,6 +6,11 @@ import { ENV } from '~/config/env'; import { SearchMetadata, SearchResultLink } from './search'; +export type AccessToken = { + accessToken: string; + expiresAt: number; +}; + export const cacheStore = await caching(bunSqliteStore, { name: 'cache', path: ENV.cache.databasePath, @@ -39,20 +44,20 @@ export const getCachedSearchMetadata = async (id: string, parser: Parser) => { return data; }; -export const cacheSpotifyAccessToken = async (accessToken: string, expTime: number) => { - await cacheStore.set('spotify:accessToken', accessToken, expTime); +export const cacheSpotifyAccessToken = async (token: AccessToken, expTime: number) => { + await cacheStore.set('spotify:accessToken', token, expTime); }; -export const getCachedSpotifyAccessToken = async (): Promise => { - return cacheStore.get('spotify:accessToken'); +export const getCachedSpotifyAccessToken = async (): Promise => { + return await cacheStore.get('spotify:accessToken'); }; -export const cacheTidalAccessToken = async (accessToken: string, expTime: number) => { - await cacheStore.set('tidal:accessToken', accessToken, expTime); +export const cacheTidalAccessToken = async (token: AccessToken, expTime: number) => { + await cacheStore.set('tidal:accessToken', token, expTime); }; -export const getCachedTidalAccessToken = async (): Promise => { - return cacheStore.get('tidal:accessToken'); +export const getCachedTidalAccessToken = async (): Promise => { + return await cacheStore.get('tidal:accessToken'); }; export const cacheTidalUniversalLinkResponse = async ( diff --git a/src/utils/access-token.ts b/src/utils/access-token.ts new file mode 100644 index 0000000..0dadccd --- /dev/null +++ b/src/utils/access-token.ts @@ -0,0 +1,46 @@ +import { AccessToken } from '~/services/cache'; + +export async function getOrUpdateAccessToken( + getCachedAccessToken: () => Promise, + fetchNewAccessToken: () => Promise<{ accessToken: string; expiresIn: number }>, + cacheAccessToken: (token: AccessToken, expiresAt: number) => Promise +) { + const cache = await getCachedAccessToken(); + + if (cache) { + const { accessToken, expiresAt } = cache; + + const timeRemaining = expiresAt - Math.floor(Date.now() / 1000); + + if (timeRemaining > 3 * 60 * 60) { + return accessToken; + } + + // If the token is still valid but less than 3 hours, refresh in the background + if (timeRemaining > 0) { + refreshAccessTokenInBackground(fetchNewAccessToken, cacheAccessToken); + return accessToken; + } + } + + const { accessToken, expiresIn } = await fetchNewAccessToken(); + const expiresAt = Math.floor(Date.now() / 1000) + expiresIn; + + await cacheAccessToken({ accessToken, expiresAt }, expiresIn); + + return accessToken; +} + +async function refreshAccessTokenInBackground( + fetchNewAccessToken: () => Promise<{ accessToken: string; expiresIn: number }>, + cacheAccessToken: (token: AccessToken, expiresAt: number) => Promise +) { + try { + const { accessToken, expiresIn } = await fetchNewAccessToken(); + const expiresAt = Math.floor(Date.now() / 1000) + expiresIn; + + await cacheAccessToken({ accessToken, expiresAt }, expiresIn); + } catch (err) { + console.error('Error refreshing access token:', err); + } +} From 255942d0478888caa99dedaf0e9044c19baf4981 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Wed, 27 Nov 2024 22:45:10 +0100 Subject: [PATCH 13/14] chore: update docker-compose --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 893fc42..45e334a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: image: sjdonado/bit environment: APP_URL: http://localhost:4001 + ADMIN_NAME: 'Admin' + ADMIN_API_KEY: 'E7gWaEu8JOIGKR/jTmOOgbmBCup2h48jmux2YvIzpxk=' volumes: - sqlite_data:/app/sqlite ports: From c5124b92740a7721600edb07b3ff6abf2e2049b3 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Wed, 27 Nov 2024 23:34:30 +0100 Subject: [PATCH 14/14] fix: getUniversalMetadataFromTidal return apple music too --- src/adapters/deezer.ts | 1 - src/parsers/tidal.ts | 11 +++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/adapters/deezer.ts b/src/adapters/deezer.ts index df8c65d..e6b84ed 100644 --- a/src/adapters/deezer.ts +++ b/src/adapters/deezer.ts @@ -5,7 +5,6 @@ import { Adapter, MetadataType } from '~/config/enum'; import { ENV } from '~/config/env'; import { cacheSearchResultLink, getCachedSearchResultLink } from '~/services/cache'; import { SearchMetadata, SearchResultLink } from '~/services/search'; -import { responseMatchesQuery } from '~/utils/compare'; import HttpClient from '~/utils/http-client'; import { logger } from '~/utils/logger'; diff --git a/src/parsers/tidal.ts b/src/parsers/tidal.ts index 99f4adc..b257035 100644 --- a/src/parsers/tidal.ts +++ b/src/parsers/tidal.ts @@ -127,12 +127,11 @@ export const getUniversalMetadataFromTidal = async ( 'a[href*="music.youtube.com"]', Adapter.YouTube ), - // [Adapter.AppleMusic]: extractLink( - // doc, - // 'a[href*="music.apple.com"]', - // Adapter.AppleMusic - // ), - [Adapter.AppleMusic]: null, + [Adapter.AppleMusic]: extractLink( + doc, + 'a[href*="music.apple.com"]', + Adapter.AppleMusic + ), [Adapter.Deezer]: null, [Adapter.SoundCloud]: null, [Adapter.Tidal]: null,