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/6 apple music inverted search #25

Merged
merged 8 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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

| Adapters | Inverted Search | Official API | Verified Link |
| ------------- | --------------- | ------------ | ------------- |
| Spotify | Yes | Yes | Yes |
| Youtube Music | Yes | No | Yes |
| Apple Music | No | No | Yes |
| Apple Music | Yes | No | Yes |
| Deezer | No | Yes | Yes |
| SoundCloud | No | No | Yes |
| Tidal | No | No | No |
Expand Down
34 changes: 0 additions & 34 deletions src/adapters/spotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,40 +99,6 @@ export async function getSpotifyLink(query: string, metadata: SearchMetadata) {
}
}

export async function fetchSpotifyMetadata(spotifyLink: string) {
let url = spotifyLink;

const spotifyHeaders = {
'User-Agent': `${ENV.adapters.spotify.clientVersion} (Macintosh; Apple Silicon)`,
};

let html = await HttpClient.get<string>(url, {
headers: spotifyHeaders,
});

logger.info(`[${fetchSpotifyMetadata.name}] parse metadata: ${url}`);

if (SPOTIFY_LINK_MOBILE_REGEX.test(spotifyLink)) {
url = html.match(SPOTIFY_LINK_DESKTOP_REGEX)?.[0] ?? '';

if (!url) {
throw new Error('Invalid mobile spotify link');
}

// wait a random amount of time to avoid rate limiting
await new Promise(res => setTimeout(res, Math.random() * 1000));

logger.info(`[${fetchSpotifyMetadata.name}] parse metadata (desktop): ${url}`);

html = await HttpClient.get<string>(url, {
headers: spotifyHeaders,
retries: 2,
});
}

return html;
}

export async function getOrUpdateSpotifyAccessToken() {
const cache = await getCachedSpotifyAccessToken();

Expand Down
5 changes: 5 additions & 0 deletions src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export const SPOTIFY_LINK_DESKTOP_REGEX =
export const YOUTUBE_LINK_REGEX =
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be|music\.youtube\.com)\/(?:watch\?v=|embed\/|v\/|shorts\/|playlist\?list=|channel\/)?([\w-]{11,})(?:\S+)?(?:&si=\S+)?/;

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 ADAPTERS_QUERY_LIMIT = 1;
export const RESPONSE_COMPARE_MIN_SCORE = 0.4;

Expand Down
6 changes: 6 additions & 0 deletions src/config/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ export enum Adapter {
Deezer = 'deezer',
}

export enum Parser {
Spotify = 'spotify',
YouTube = 'youTube',
AppleMusic = 'appleMusic',
}

export enum MetadataType {
Song = 'song',
Album = 'album',
Expand Down
75 changes: 75 additions & 0 deletions src/parsers/appleMusic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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 AppleMusicMetadataType {
Song = 'music.song',
Album = 'music.album',
Playlist = 'music.playlist',
Artist = 'music.musician',
}

const APPLE_MUSIC_METADATA_TO_METADATA_TYPE = {
[AppleMusicMetadataType.Song]: MetadataType.Song,
[AppleMusicMetadataType.Album]: MetadataType.Album,
[AppleMusicMetadataType.Playlist]: MetadataType.Playlist,
[AppleMusicMetadataType.Artist]: MetadataType.Artist,
};

export const getAppleMusicMetadata = async (id: string, link: string) => {
const cached = await getCachedSearchMetadata(id);
if (cached) {
logger.info(`[AppleMusic] (${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');

if (!title || !type || !image) {
throw new Error('AppleMusic metadata not found');
}

const parsedTitle = title?.replace(/on\sApple\sMusic/i, '').trim();

const metadata = {
id,
title: parsedTitle,
description,
type: APPLE_MUSIC_METADATA_TO_METADATA_TYPE[type as AppleMusicMetadataType],
image,
} as SearchMetadata;

await cacheSearchMetadata(id, metadata);

return metadata;
} catch (err) {
throw new Error(`[${getAppleMusicMetadata.name}] (${link}) ${err}`);
}
};

export const getAppleMusicQueryFromMetadata = (metadata: SearchMetadata) => {
let query = metadata.title;

if (metadata.type === MetadataType.Album) {
query = `${query} album`;
}

if (metadata.type === MetadataType.Playlist) {
query = `${query} playlist`;
}

return query;
};
23 changes: 19 additions & 4 deletions src/parsers/link.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { ParseError } from 'elysia';

import { SPOTIFY_LINK_REGEX, YOUTUBE_LINK_REGEX } from '~/config/constants';
import {
APPLE_MUSIC_LINK_REGEX,
SPOTIFY_LINK_REGEX,
YOUTUBE_LINK_REGEX,
} from '~/config/constants';
import { Adapter } from '~/config/enum';
import { getSourceFromId } from '~/utils/encoding';

Expand All @@ -12,7 +16,7 @@ export type SearchParser = {
source: string;
};

export const getSearchParser = async (link?: string, searchId?: string) => {
export const getSearchParser = (link?: string, searchId?: string) => {
const decodedSource = searchId ? getSourceFromId(searchId) : undefined;

let source = link;
Expand All @@ -24,20 +28,31 @@ export const getSearchParser = async (link?: string, searchId?: string) => {
source = decodedSource;
}

if (!source) {
throw new ParseError('Source not found');
}

let id, type;

const spotifyId = source!.match(SPOTIFY_LINK_REGEX)?.[3];
const spotifyId = source.match(SPOTIFY_LINK_REGEX)?.[3];
if (spotifyId) {
id = spotifyId;
type = Adapter.Spotify;
}

const youtubeId = source!.match(YOUTUBE_LINK_REGEX)?.[1];
const youtubeId = source.match(YOUTUBE_LINK_REGEX)?.[1];
if (youtubeId) {
id = youtubeId;
type = Adapter.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;
}

if (!id || !type) {
throw new ParseError('Service id could not be extracted from source.');
}
Expand Down
27 changes: 25 additions & 2 deletions src/parsers/spotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { getCheerioDoc, metaTagContent } from '~/utils/scraper';
import { SearchMetadata } from '~/services/search';
import { cacheSearchMetadata, getCachedSearchMetadata } from '~/services/cache';

import { fetchSpotifyMetadata } from '~/adapters/spotify';
import {
SPOTIFY_LINK_DESKTOP_REGEX,
SPOTIFY_LINK_MOBILE_REGEX,
} from '~/config/constants';
import { defaultHeaders, fetchMetadata } from '~/services/metadata';
import HttpClient from '~/utils/http-client';

enum SpotifyMetadataType {
Song = 'music.song',
Expand Down Expand Up @@ -34,7 +39,25 @@ export const getSpotifyMetadata = async (id: string, link: string) => {
}

try {
const html = await fetchSpotifyMetadata(link);
let html = await fetchMetadata(link);

if (SPOTIFY_LINK_MOBILE_REGEX.test(link)) {
link = html.match(SPOTIFY_LINK_DESKTOP_REGEX)?.[0] ?? '';

if (!link) {
throw new Error('Invalid mobile spotify link');
}

// wait a random amount of time to avoid rate limiting
await new Promise(res => setTimeout(res, Math.random() * 1000));

logger.info(`[${getSpotifyMetadata.name}] parse metadata (desktop): ${link}`);

html = await HttpClient.get<string>(link, {
headers: defaultHeaders,
retries: 2,
});
}

const doc = getCheerioDoc(html);

Expand Down
28 changes: 15 additions & 13 deletions src/parsers/youtube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ import { getCheerioDoc, metaTagContent } from '~/utils/scraper';

import { SearchMetadata } from '~/services/search';
import { cacheSearchMetadata, getCachedSearchMetadata } from '~/services/cache';
import { fetchMetadata } from '~/services/metadata';

import { fetchSpotifyMetadata } from '~/adapters/spotify';

enum YoutubeMetadataType {
enum YouTubeMetadataType {
Song = 'video.other',
Album = 'album',
Playlist = 'playlist',
Expand All @@ -18,12 +17,12 @@ enum YoutubeMetadataType {
}

const YOUTUBE_METADATA_TO_METADATA_TYPE = {
[YoutubeMetadataType.Song]: MetadataType.Song,
[YoutubeMetadataType.Album]: MetadataType.Album,
[YoutubeMetadataType.Playlist]: MetadataType.Playlist,
[YoutubeMetadataType.Artist]: MetadataType.Artist,
[YoutubeMetadataType.Podcast]: MetadataType.Podcast,
[YoutubeMetadataType.Show]: MetadataType.Show,
[YouTubeMetadataType.Song]: MetadataType.Song,
[YouTubeMetadataType.Album]: MetadataType.Album,
[YouTubeMetadataType.Playlist]: MetadataType.Playlist,
[YouTubeMetadataType.Artist]: MetadataType.Artist,
[YouTubeMetadataType.Podcast]: MetadataType.Podcast,
[YouTubeMetadataType.Show]: MetadataType.Show,
};

export const getYouTubeMetadata = async (id: string, link: string) => {
Expand All @@ -34,7 +33,7 @@ export const getYouTubeMetadata = async (id: string, link: string) => {
}

try {
const html = await fetchSpotifyMetadata(link);
const html = await fetchMetadata(link, {});

const doc = getCheerioDoc(html);

Expand All @@ -44,16 +43,19 @@ export const getYouTubeMetadata = async (id: string, link: string) => {
const type = metaTagContent(doc, 'og:type', 'property');

if (!title || !type || !image) {
throw new Error('Youtube metadata not found');
throw new Error('YouTube metadata not found');
}

const parsedTitle = title?.replace('- YouTube Music', '').trim();
const parsedTitle = title
?.replace(/-?\s*on\sApple\sMusic/i, '')
.replace(/-?\s*YouTube\sMusic/i, '')
.trim();

const metadata = {
id,
title: parsedTitle,
description,
type: YOUTUBE_METADATA_TO_METADATA_TYPE[type as YoutubeMetadataType],
type: YOUTUBE_METADATA_TO_METADATA_TYPE[type as YouTubeMetadataType],
image,
} as SearchMetadata;

Expand Down
4 changes: 2 additions & 2 deletions src/services/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ export const cacheSpotifyAccessToken = async (accessToken: string, expTime: numb
await cacheStore.set('spotify:accessToken', accessToken, expTime);
};

export const getCachedSpotifyAccessToken = async () => {
export const getCachedSpotifyAccessToken = async (): Promise<string | undefined> => {
return cacheStore.get('spotify:accessToken');
};

export const cacheShortenLink = async (link: string, refer: string) => {
await cacheStore.set(`url-shortener:${link}`, refer);
};

export const getCachedShortenLink = async (link: string) => {
export const getCachedShortenLink = async (link: string): Promise<string | undefined> => {
return cacheStore.get(`url-shortener:${link}`);
};
20 changes: 20 additions & 0 deletions src/services/metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ENV } from '~/config/env';
import HttpClient from '~/utils/http-client';
import { logger } from '~/utils/logger';

export const defaultHeaders = {
'User-Agent': `${ENV.adapters.spotify.clientVersion} (Macintosh; Apple Silicon)`,
};

export async function fetchMetadata(
link: string,
headers: Record<string, string> = defaultHeaders
) {
const url = link;

const html = await HttpClient.get<string>(url, { headers });

logger.info(`[${fetchMetadata.name}] parse metadata: ${url}`);

return html;
}
Loading
Loading