Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/deezer inverted search #26

Merged
merged 4 commits into from
Jul 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/config/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 8 additions & 5 deletions src/parsers/appleMusic.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -43,16 +43,19 @@ 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;

await cacheSearchMetadata(id, metadata);
await cacheSearchMetadata(id, Parser.AppleMusic, metadata);

return metadata;
} catch (err) {
Expand All @@ -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) {
Expand Down
83 changes: 83 additions & 0 deletions src/parsers/deezer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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';

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, Parser.Deezer);
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, Parser.Deezer, 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;
};
17 changes: 12 additions & 5 deletions src/parsers/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions src/parsers/spotify.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 3 additions & 4 deletions src/parsers/youtube.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 10 additions & 5 deletions src/services/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -23,20 +25,23 @@ 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;
};

export const cacheSpotifyAccessToken = async (accessToken: string, expTime: number) => {
await cacheStore.set('spotify:accessToken', accessToken, expTime);
};

export const getCachedSpotifyAccessToken = async (): Promise<string | undefined> => {
return cacheStore.get('spotify:accessToken');
};
Expand Down
37 changes: 22 additions & 15 deletions src/services/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
getAppleMusicMetadata,
getAppleMusicQueryFromMetadata,
} from '~/parsers/appleMusic';
import { getDeezerMetadata, getDeezerQueryFromMetadata } from '~/parsers/deezer';

export type SearchMetadata = {
title: string;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/views/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ export default function Home({
<div class="flex h-svh flex-col gap-2 p-2">
<LoadingIndicator />
<main class="flex flex-1 flex-col items-center justify-start">
<div class="my-8 flex flex-col gap-4 p-2 text-center sm:my-12">
<div class="mb-4 mt-8 flex flex-col gap-4 p-2 text-center sm:mt-12">
<h1 class="text-4xl uppercase md:text-5xl lg:text-6xl">I Don't Have Spotify</h1>
<p class="text-justify text-sm text-zinc-400 lg:text-base">
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.
</p>
</div>
<div
Expand Down
Loading
Loading