From ff0a615bd3b3bd61532206214a30c6553d8bf3d4 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Sun, 28 Jul 2024 19:43:02 +0200 Subject: [PATCH 1/4] fix: apple music album search metadata --- src/parsers/appleMusic.ts | 7 +++++-- src/views/pages/home.tsx | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/parsers/appleMusic.ts b/src/parsers/appleMusic.ts index 4a7a217..6371bd4 100644 --- a/src/parsers/appleMusic.ts +++ b/src/parsers/appleMusic.ts @@ -43,11 +43,14 @@ export const getAppleMusicMetadata = async (id: string, link: string) => { } const parsedTitle = title?.replace(/on\sApple\sMusic/i, '').trim(); + const parsedDescription = description + ?.replace(/(Listen to\s|on\sApple\sMusic|[.,!?])/gi, '') + .trim(); const metadata = { id, title: parsedTitle, - description, + description: parsedDescription, type: APPLE_MUSIC_METADATA_TO_METADATA_TYPE[type as AppleMusicMetadataType], image, } as SearchMetadata; @@ -64,7 +67,7 @@ export const getAppleMusicQueryFromMetadata = (metadata: SearchMetadata) => { let query = metadata.title; if (metadata.type === MetadataType.Album) { - query = `${query} album`; + query = metadata.description; } if (metadata.type === MetadataType.Playlist) { diff --git a/src/views/pages/home.tsx b/src/views/pages/home.tsx index d316f9b..7141cc7 100644 --- a/src/views/pages/home.tsx +++ b/src/views/pages/home.tsx @@ -12,7 +12,7 @@ export default function Home({
-
+

I Don't Have Spotify

Paste a Spotify, YouTube, or Apple Music link to enjoy music across different From f4f4e0004be2e857ccb0a331634336a89070bf4c Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Sun, 28 Jul 2024 19:59:58 +0200 Subject: [PATCH 2/4] feat: deezer parser --- src/config/constants.ts | 5 ++- src/config/enum.ts | 5 ++- src/parsers/deezer.ts | 83 +++++++++++++++++++++++++++++++++++++++++ src/parsers/link.ts | 17 ++++++--- src/services/search.ts | 37 ++++++++++-------- 5 files changed, 124 insertions(+), 23 deletions(-) create mode 100644 src/parsers/deezer.ts diff --git a/src/config/constants.ts b/src/config/constants.ts index 9a85203..b425f13 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -11,7 +11,10 @@ export const YOUTUBE_LINK_REGEX = export const APPLE_MUSIC_LINK_REGEX = /^https:\/\/music\.apple\.com\/(?:[a-z]{2}\/)?(?:album|playlist|station|artist|music-video|video-playlist|show)\/([\w-]+)(?:\/([\w-]+))?(?:\?i=(\d+))?(?:\?.*)?$/; -export const ALLOWED_LINKS_REGEX = `${SPOTIFY_LINK_REGEX.source}|${YOUTUBE_LINK_REGEX.source}|${APPLE_MUSIC_LINK_REGEX.source}`; +export const DEEZER_LINK_REGEX = + /^https:\/\/www\.deezer\.com\/(?:[a-z]{2}\/)?(?:track|album|playlist|artist|episode|show)\/(\d+)$/; + +export const ALLOWED_LINKS_REGEX = `${SPOTIFY_LINK_REGEX.source}|${YOUTUBE_LINK_REGEX.source}|${APPLE_MUSIC_LINK_REGEX.source}|${DEEZER_LINK_REGEX.source}`; export const ADAPTERS_QUERY_LIMIT = 1; export const RESPONSE_COMPARE_MIN_SCORE = 0.4; diff --git a/src/config/enum.ts b/src/config/enum.ts index 3d74ac5..cca8f5e 100644 --- a/src/config/enum.ts +++ b/src/config/enum.ts @@ -2,15 +2,16 @@ export enum Adapter { Spotify = 'spotify', YouTube = 'youTube', AppleMusic = 'appleMusic', - Tidal = 'tidal', - SoundCloud = 'soundCloud', Deezer = 'deezer', + SoundCloud = 'soundCloud', + Tidal = 'tidal', } export enum Parser { Spotify = 'spotify', YouTube = 'youTube', AppleMusic = 'appleMusic', + Deezer = 'deezer', } export enum MetadataType { diff --git a/src/parsers/deezer.ts b/src/parsers/deezer.ts new file mode 100644 index 0000000..fe35be7 --- /dev/null +++ b/src/parsers/deezer.ts @@ -0,0 +1,83 @@ +import { MetadataType } 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'; + +enum DeezerMetadataType { + Song = 'music.song', + Album = 'music.album', + Playlist = 'music.playlist', + Artist = 'music.musician', +} + +const DEEZER_METADATA_TO_METADATA_TYPE = { + [DeezerMetadataType.Song]: MetadataType.Song, + [DeezerMetadataType.Album]: MetadataType.Album, + [DeezerMetadataType.Playlist]: MetadataType.Playlist, + [DeezerMetadataType.Artist]: MetadataType.Artist, +}; + +export const getDeezerMetadata = async (id: string, link: string) => { + const cached = await getCachedSearchMetadata(id); + if (cached) { + logger.info(`[Deezer] (${id}) metadata cache hit`); + return cached; + } + + try { + const html = await fetchMetadata(link, {}); + + const doc = getCheerioDoc(html); + + const title = metaTagContent(doc, 'og:title', 'property'); + const description = metaTagContent(doc, 'og:description', 'property'); + const image = metaTagContent(doc, 'og:image', 'property'); + const type = metaTagContent(doc, 'og:type', 'property'); + const audio = metaTagContent(doc, 'og:audio', 'property'); + + if (!title || !type || !image) { + throw new Error('Deezer metadata not found'); + } + + const parsedTitle = title?.trim(); + + const metadata = { + id, + title: parsedTitle, + description, + type: DEEZER_METADATA_TO_METADATA_TYPE[type as DeezerMetadataType], + image, + audio, + } as SearchMetadata; + + await cacheSearchMetadata(id, metadata); + + return metadata; + } catch (err) { + throw new Error(`[${getDeezerMetadata.name}] (${link}) ${err}`); + } +}; + +export const getDeezerQueryFromMetadata = (metadata: SearchMetadata) => { + let query = metadata.title; + + const artists = metadata.description.match(/^([^ -]+(?: [^ -]+)*)/)?.[1]; + + if (metadata.type === MetadataType.Song) { + query = [query, artists].join(' '); + } + + if (metadata.type === MetadataType.Album) { + query = [query, artists].join(' '); + } + + if (metadata.type === MetadataType.Playlist) { + query = `${query} playlist`; + } + + return query; +}; diff --git a/src/parsers/link.ts b/src/parsers/link.ts index fae20f9..d5c69a2 100644 --- a/src/parsers/link.ts +++ b/src/parsers/link.ts @@ -2,12 +2,13 @@ import { ParseError } from 'elysia'; import { APPLE_MUSIC_LINK_REGEX, + DEEZER_LINK_REGEX, SPOTIFY_LINK_REGEX, YOUTUBE_LINK_REGEX, } from '~/config/constants'; -import { Adapter } from '~/config/enum'; -import { getSourceFromId } from '~/utils/encoding'; +import { Parser } from '~/config/enum'; +import { getSourceFromId } from '~/utils/encoding'; import { logger } from '~/utils/logger'; export type SearchParser = { @@ -37,20 +38,26 @@ export const getSearchParser = (link?: string, searchId?: string) => { const spotifyId = source.match(SPOTIFY_LINK_REGEX)?.[3]; if (spotifyId) { id = spotifyId; - type = Adapter.Spotify; + type = Parser.Spotify; } const youtubeId = source.match(YOUTUBE_LINK_REGEX)?.[1]; if (youtubeId) { id = youtubeId; - type = Adapter.YouTube; + type = Parser.YouTube; } const match = source.match(APPLE_MUSIC_LINK_REGEX); const appleMusicId = match ? match[3] || match[2] || match[1] : null; if (appleMusicId) { id = appleMusicId; - type = Adapter.AppleMusic; + type = Parser.AppleMusic; + } + + const deezerId = source.match(DEEZER_LINK_REGEX)?.[1]; + if (deezerId) { + id = deezerId; + type = Parser.Deezer; } if (!id || !type) { diff --git a/src/services/search.ts b/src/services/search.ts index 9c9dd67..1152385 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -19,6 +19,7 @@ import { getAppleMusicMetadata, getAppleMusicQueryFromMetadata, } from '~/parsers/appleMusic'; +import { getDeezerMetadata, getDeezerQueryFromMetadata } from '~/parsers/deezer'; export type SearchMetadata = { title: string; @@ -68,23 +69,27 @@ export const search = async ({ let metadata, query; - if (searchParser.type === Parser.Spotify) { - metadata = await getSpotifyMetadata(searchParser.id, searchParser.source); - query = getSpotifyQueryFromMetadata(metadata); - } - - if (searchParser.type === Parser.YouTube) { - metadata = await getYouTubeMetadata(searchParser.id, searchParser.source); - query = getYouTubeQueryFromMetadata(metadata); - } - - if (searchParser.type === Parser.AppleMusic) { - metadata = await getAppleMusicMetadata(searchParser.id, searchParser.source); - query = getAppleMusicQueryFromMetadata(metadata); + 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; } if (!metadata || !query) { - throw new Error('Adapter not implemented yet'); + throw new Error('Parser not implemented yet'); } logger.info( @@ -131,7 +136,9 @@ export const search = async ({ searchParser.type !== Parser.AppleMusic && searchAdapters.includes(Adapter.AppleMusic) ? getAppleMusicLink(query, metadata) : null, - searchAdapters.includes(Adapter.Deezer) ? getDeezerLink(query, metadata) : null, + searchParser.type !== Parser.Deezer && searchAdapters.includes(Adapter.Deezer) + ? getDeezerLink(query, metadata) + : null, searchAdapters.includes(Adapter.SoundCloud) ? getSoundCloudLink(query, metadata) : null, From 6d7403b382b18b6a7a855e915daf14053d0e9b06 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Sun, 28 Jul 2024 20:02:00 +0200 Subject: [PATCH 3/4] refactor: cache parser namespace - prevents collitions with ids extracted from different parsers --- package.json | 4 +++- src/parsers/appleMusic.ts | 8 ++++---- src/parsers/deezer.ts | 6 +++--- src/parsers/spotify.ts | 6 +++--- src/parsers/youtube.ts | 7 +++---- src/services/cache.ts | 14 ++++++++++---- tests/integration/page.test.ts | 4 ++-- 7 files changed, 28 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 84773d5..613a318 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "dev": "concurrently \"bun run build:dev\" \"bun run --watch www/bin.ts\"", "build:dev": "vite build --mode=development --watch", "build": "vite build", - "test": "NODE_ENV=test bun test" + "test": "NODE_ENV=test bun test", + "lint": "eslint --fix", + "tscheck": "tsc --noEmit" }, "dependencies": { "@elysiajs/html": "^1.0.2", diff --git a/src/parsers/appleMusic.ts b/src/parsers/appleMusic.ts index 6371bd4..8f76044 100644 --- a/src/parsers/appleMusic.ts +++ b/src/parsers/appleMusic.ts @@ -1,4 +1,4 @@ -import { MetadataType } from '~/config/enum'; +import { MetadataType, Parser } from '~/config/enum'; import { logger } from '~/utils/logger'; import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; @@ -22,7 +22,7 @@ const APPLE_MUSIC_METADATA_TO_METADATA_TYPE = { }; export const getAppleMusicMetadata = async (id: string, link: string) => { - const cached = await getCachedSearchMetadata(id); + const cached = await getCachedSearchMetadata(id, Parser.AppleMusic); if (cached) { logger.info(`[AppleMusic] (${id}) metadata cache hit`); return cached; @@ -44,7 +44,7 @@ export const getAppleMusicMetadata = async (id: string, link: string) => { const parsedTitle = title?.replace(/on\sApple\sMusic/i, '').trim(); const parsedDescription = description - ?.replace(/(Listen to\s|on\sApple\sMusic|[.,!?])/gi, '') + ?.replace(/(Listen to\s|on\sApple\sMusic)/gi, '') .trim(); const metadata = { @@ -55,7 +55,7 @@ export const getAppleMusicMetadata = async (id: string, link: string) => { image, } as SearchMetadata; - await cacheSearchMetadata(id, metadata); + await cacheSearchMetadata(id, Parser.AppleMusic, metadata); return metadata; } catch (err) { diff --git a/src/parsers/deezer.ts b/src/parsers/deezer.ts index fe35be7..140f437 100644 --- a/src/parsers/deezer.ts +++ b/src/parsers/deezer.ts @@ -1,4 +1,4 @@ -import { MetadataType } from '~/config/enum'; +import { MetadataType, Parser } from '~/config/enum'; import { logger } from '~/utils/logger'; import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; @@ -22,7 +22,7 @@ const DEEZER_METADATA_TO_METADATA_TYPE = { }; export const getDeezerMetadata = async (id: string, link: string) => { - const cached = await getCachedSearchMetadata(id); + const cached = await getCachedSearchMetadata(id, Parser.Deezer); if (cached) { logger.info(`[Deezer] (${id}) metadata cache hit`); return cached; @@ -54,7 +54,7 @@ export const getDeezerMetadata = async (id: string, link: string) => { audio, } as SearchMetadata; - await cacheSearchMetadata(id, metadata); + await cacheSearchMetadata(id, Parser.Deezer, metadata); return metadata; } catch (err) { diff --git a/src/parsers/spotify.ts b/src/parsers/spotify.ts index 6eb2e51..480f933 100644 --- a/src/parsers/spotify.ts +++ b/src/parsers/spotify.ts @@ -1,4 +1,4 @@ -import { MetadataType } from '~/config/enum'; +import { MetadataType, Parser } from '~/config/enum'; import { logger } from '~/utils/logger'; import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; @@ -32,7 +32,7 @@ const SPOTIFY_METADATA_TO_METADATA_TYPE = { }; export const getSpotifyMetadata = async (id: string, link: string) => { - const cached = await getCachedSearchMetadata(id); + const cached = await getCachedSearchMetadata(id, Parser.Spotify); if (cached) { logger.info(`[Spotify] (${id}) metadata cache hit`); return cached; @@ -83,7 +83,7 @@ export const getSpotifyMetadata = async (id: string, link: string) => { audio, } as SearchMetadata; - await cacheSearchMetadata(id, metadata); + await cacheSearchMetadata(id, Parser.Spotify, metadata); return metadata; } catch (err) { diff --git a/src/parsers/youtube.ts b/src/parsers/youtube.ts index 251984e..eea971a 100644 --- a/src/parsers/youtube.ts +++ b/src/parsers/youtube.ts @@ -1,4 +1,4 @@ -import { MetadataType } from '~/config/enum'; +import { MetadataType, Parser } from '~/config/enum'; import { logger } from '~/utils/logger'; import { getCheerioDoc, metaTagContent } from '~/utils/scraper'; @@ -24,9 +24,8 @@ const YOUTUBE_METADATA_TO_METADATA_TYPE = { [YouTubeMetadataType.Podcast]: MetadataType.Podcast, [YouTubeMetadataType.Show]: MetadataType.Show, }; - export const getYouTubeMetadata = async (id: string, link: string) => { - const cached = await getCachedSearchMetadata(id); + const cached = await getCachedSearchMetadata(id, Parser.YouTube); if (cached) { logger.info(`[YouTube] (${id}) metadata cache hit`); return cached; @@ -59,7 +58,7 @@ export const getYouTubeMetadata = async (id: string, link: string) => { image, } as SearchMetadata; - await cacheSearchMetadata(id, metadata); + await cacheSearchMetadata(id, Parser.YouTube, metadata); return metadata; } catch (err) { diff --git a/src/services/cache.ts b/src/services/cache.ts index a266a67..c6f92b6 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -2,6 +2,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 { SearchMetadata, SearchResultLink } from './search'; export const cacheStore = await caching(bunSqliteStore, { @@ -23,12 +25,16 @@ export const getCachedSearchResultLink = async (url: URL) => { return data; }; -export const cacheSearchMetadata = async (id: string, searchMetadata: SearchMetadata) => { - await cacheStore.set(`metadata:${id}`, searchMetadata); +export const cacheSearchMetadata = async ( + id: string, + parser: Parser, + searchMetadata: SearchMetadata +) => { + await cacheStore.set(`metadata:${parser}:${id}`, searchMetadata); }; -export const getCachedSearchMetadata = async (id: string) => { - const data = (await cacheStore.get(`metadata:${id}`)) as SearchMetadata; +export const getCachedSearchMetadata = async (id: string, parser: Parser) => { + const data = (await cacheStore.get(`metadata:${parser}:${id}`)) as SearchMetadata; return data; }; diff --git a/tests/integration/page.test.ts b/tests/integration/page.test.ts index 119be1c..fcf0143 100644 --- a/tests/integration/page.test.ts +++ b/tests/integration/page.test.ts @@ -6,7 +6,7 @@ import { formDataRequest } from '../utils/request'; import { app } from '~/index'; -import { MetadataType, Adapter } from '~/config/enum'; +import { MetadataType, Adapter, Parser } from '~/config/enum'; import { cacheSearchMetadata, @@ -25,7 +25,7 @@ describe('Page router', () => { cacheStore.reset(); await Promise.all([ - cacheSearchMetadata('2KvHC9z14GSl4YpkNMX384', { + cacheSearchMetadata('2KvHC9z14GSl4YpkNMX384', Parser.Spotify, { title: 'Do Not Disturb', description: 'Drake · Song · 2017', type: MetadataType.Song, From b27a64c82663cfa44fb59cdcc07e9449aa07f1a3 Mon Sep 17 00:00:00 2001 From: Juan Rodriguez Date: Sun, 28 Jul 2024 20:07:19 +0200 Subject: [PATCH 4/4] chore: update README + subtitle --- README.md | 2 +- src/services/cache.ts | 1 - src/views/pages/home.tsx | 4 ++-- tests/integration/page.test.ts | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9a967bf..2a3af5b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ | Spotify | Yes | Yes | Yes | | Youtube Music | Yes | No | Yes | | Apple Music | Yes | No | Yes | -| Deezer | No | Yes | Yes | +| Deezer | Yes | Yes | Yes | | SoundCloud | No | No | Yes | | Tidal | No | No | No | diff --git a/src/services/cache.ts b/src/services/cache.ts index c6f92b6..f9ebfec 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -42,7 +42,6 @@ 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'); }; diff --git a/src/views/pages/home.tsx b/src/views/pages/home.tsx index 7141cc7..372ce08 100644 --- a/src/views/pages/home.tsx +++ b/src/views/pages/home.tsx @@ -15,8 +15,8 @@ export default function Home({

I Don't Have Spotify

- Paste a Spotify, YouTube, or Apple Music link to enjoy music across different - platforms. + Paste a link from Spotify, YouTube, Apple Music, or Deezer to enjoy your music + across multiple platforms.

{ expect(doc('h1').text()).toEqual("I Don't Have Spotify"); expect(doc('p').text()).toContain( - 'Paste a Spotify, YouTube, or Apple Music link to enjoy music across different platforms.' + 'Paste a link from Spotify, YouTube, Apple Music, or Deezer to enjoy your music across multiple platforms.' ); const footerText = doc('footer').text();