From 46961ed37721eb23e9792dec548ac85bcd782231 Mon Sep 17 00:00:00 2001 From: Elbarae Date: Wed, 21 Feb 2024 23:19:29 +0100 Subject: [PATCH] feat: support album conversion --- bot/src/utils/isYoutubeOrSpotify.ts | 76 +++++++++++++++++----- server/src/controllers/convert.ts | 14 +++- server/src/interfaces/i_spotify_service.ts | 2 + server/src/interfaces/i_youtube_service.ts | 4 ++ server/src/services/conversion_service.ts | 71 +++++++++++++++++++- server/src/services/spotify_service.ts | 12 ++++ server/src/services/youtube_service.ts | 35 ++++++++++ server/src/utils/is_youtube_or_spotify.ts | 76 +++++++++++++++++----- 8 files changed, 252 insertions(+), 38 deletions(-) diff --git a/bot/src/utils/isYoutubeOrSpotify.ts b/bot/src/utils/isYoutubeOrSpotify.ts index 40042ff..6e5204e 100644 --- a/bot/src/utils/isYoutubeOrSpotify.ts +++ b/bot/src/utils/isYoutubeOrSpotify.ts @@ -1,25 +1,67 @@ -export const youtubeRegexes = [/.*(youtu\.be|youtube.com|music.youtube.com)\/watch\?v=((\w|-)+)?(?=(\&|$))/, /.*(youtu\.be|youtube.com)\/((\w|-)+)?(?=(\?|$))/] -export const spotifyRegex = /.*open.spotify.com\/track\/(\w+)?(?=\?|$)/ +export interface Match { + type: 'spotify' | 'youtube' + id: string + kind: 'track' | 'album' +} -interface Match { - type: 'spotify' | 'youtube'; - id: string; +interface Matcher { + regex: RegExp[] + idIndex: number + match: Omit } -export const isYoutubeOrSpotify = (url: string): Match | null => { - const spotifyMatch = url.match(spotifyRegex) - if (spotifyMatch != null && spotifyMatch.length > 1) { - return { - type: 'spotify', - id: spotifyMatch[1] - } - } - let youtubeMatch = url.match(youtubeRegexes[0]) ?? url.match(youtubeRegexes[1]) - if (youtubeMatch != null && youtubeMatch.length > 1) { - return { +const matches: Matcher[] = [ + { + regex: [ + /.*(youtu\.be|youtube.com|music.youtube.com)\/watch\?v=((\w|-)+)?(?=(\&|$))/, + /.*(youtu\.be|youtube.com)\/((?!playlist)(\w|-)+)?(?=(\?|$))/, + ], + idIndex: 2, + match: { + type: 'youtube', + kind: 'track', + }, + }, + { + regex: [ + /.*(youtube.com|music.youtube.com)\/playlist\?list=((\w|-)+)?(?=(\&|$))/, + ], + idIndex: 2, + match: { type: 'youtube', - id: youtubeMatch[2] + kind: 'album', + }, + }, + { + regex: [/.*open.spotify.com\/track\/(\w+)?(?=\?|$)/], + idIndex: 1, + match: { + type: 'spotify', + kind: 'track', + }, + }, + { + regex: [/.*open.spotify.com\/album\/(\w+)?(?=\?|$)/], + idIndex: 1, + match: { + type: 'spotify', + kind: 'album', + }, + }, +] + +export const isYoutubeOrSpotify = (url: string): Match | null => { + for (const matcher of matches) { + const match = matcher.regex + .map((regex) => url.match(regex)) + .find((v) => v != null) + if (!!match && match.length > 1) { + return { + ...matcher.match, + id: match[matcher.idIndex], + } } } + return null } diff --git a/server/src/controllers/convert.ts b/server/src/controllers/convert.ts index c296269..1a17781 100644 --- a/server/src/controllers/convert.ts +++ b/server/src/controllers/convert.ts @@ -34,10 +34,20 @@ export default class ConvertController extends BaseController { let result: string + const id = matchResult.id + if (matchResult.type === 'spotify') { - result = await this.conversionService.convertSpotifyUrl(matchResult.id) + if (matchResult.kind === 'album') { + result = await this.conversionService.convertSpotifyAlbum(id) + } else { + result = await this.conversionService.convertSpotifyTrack(id) + } } else { - result = await this.conversionService.convertYoutubeUrl(matchResult.id) + if (matchResult.kind === 'album') { + result = await this.conversionService.convertYoutubeAlbum(id) + } else { + result = await this.conversionService.convertYoutubeTrack(id) + } } JSONResponse.success(res, { url: result, diff --git a/server/src/interfaces/i_spotify_service.ts b/server/src/interfaces/i_spotify_service.ts index 24d9bac..9a75aba 100644 --- a/server/src/interfaces/i_spotify_service.ts +++ b/server/src/interfaces/i_spotify_service.ts @@ -1,4 +1,6 @@ export interface ISpotifyService { searchVideoId(query: string): Promise + searchAlbumId(query: string): Promise getTrackInfo(id: string): Promise + getAlbumInfo(id: string): Promise } diff --git a/server/src/interfaces/i_youtube_service.ts b/server/src/interfaces/i_youtube_service.ts index ddafacc..a816702 100644 --- a/server/src/interfaces/i_youtube_service.ts +++ b/server/src/interfaces/i_youtube_service.ts @@ -4,5 +4,9 @@ export interface IYoutubeService { searchVideoId( query: string ): Promise + searchAlbumId( + query: string + ): Promise getTrackInfo(id: string): Promise + getAlbumInfo(id: string): Promise } diff --git a/server/src/services/conversion_service.ts b/server/src/services/conversion_service.ts index 71fbc1f..627ef64 100644 --- a/server/src/services/conversion_service.ts +++ b/server/src/services/conversion_service.ts @@ -4,6 +4,7 @@ import { ISpotifyService } from '../interfaces/i_spotify_service' import { IYoutubeService } from '../interfaces/i_youtube_service' import { TYPES } from '../utils/constants' import { Logger } from '../utils/logger' +import { Match } from '../utils/is_youtube_or_spotify' @injectable() export class ConversionService { @@ -16,7 +17,7 @@ export class ConversionService { private readonly youtubeService: IYoutubeService ) {} - async convertYoutubeUrl(id: string) { + async convertYoutubeTrack(id: string) { if (!id) { throw new HttpException(400, 'Invalid Youtube track url') } @@ -56,7 +57,47 @@ export class ConversionService { return spotifyTrackUrl } - async convertSpotifyUrl(id: string) { + async convertYoutubeAlbum(id: string) { + if (!id) { + throw new HttpException(400, 'Invalid Youtube album url') + } + + const youtubeAlbum = await this.youtubeService.getAlbumInfo(id) + + if (youtubeAlbum === undefined) { + throw new HttpException(400, 'Invalid Youtube album url') + } + + const albumTitle = youtubeAlbum.snippet?.title + + if (!albumTitle) { + throw new HttpException(400, 'Invalid Youtube album url') + } + + const channelTitle = youtubeAlbum.snippet?.channelTitle + + if (!channelTitle) { + throw new HttpException(400, 'Invalid Youtube album url') + } + + const spotifyResult = await this.spotifyService.searchAlbumId( + `${albumTitle} ${channelTitle}` + ) + + const spotifyAlbum = spotifyResult.albums?.items[0] + + if (!spotifyAlbum) { + throw new HttpException(404, 'Album not found') + } + + const spotifyAlbumId = spotifyAlbum.id + + const spotifyAlbumUrl = `https://open.spotify.com/album/${spotifyAlbumId}` + + return spotifyAlbumUrl + } + + async convertSpotifyTrack(id: string) { if (!id) { throw new HttpException(400, 'Invalid Spotify track url') } @@ -81,4 +122,30 @@ export class ConversionService { return youtubeVideoUrl } + + async convertSpotifyAlbum(id: string) { + if (!id) { + throw new HttpException(400, 'Invalid Spotify album url') + } + + const spotifyAlbum = await this.spotifyService.getAlbumInfo(id) + + const artistsString = spotifyAlbum.artists + .map((artist) => artist.name) + .join(' ') + + const youtubePlaylist = await this.youtubeService.searchAlbumId( + `${spotifyAlbum.name} ${artistsString}` + ) + + const youtubePlaylistId = youtubePlaylist?.playlistId + + if (youtubePlaylistId === undefined || youtubePlaylistId === null) { + throw new HttpException(404, 'Youtube playlist not found') + } + + const youtubePlaylistUrl = `https://www.youtube.com/playlist?list=${youtubePlaylistId}` + + return youtubePlaylistUrl + } } diff --git a/server/src/services/spotify_service.ts b/server/src/services/spotify_service.ts index a474742..7d23dfa 100644 --- a/server/src/services/spotify_service.ts +++ b/server/src/services/spotify_service.ts @@ -37,9 +37,21 @@ export class SpotifyService implements ISpotifyService { return res.body } + public readonly searchAlbumId = async (query: string) => { + const res = await this.spotifyApi.search(query, ['album'], { limit: 1 }) + + return res.body + } + public readonly getTrackInfo = async (id: string) => { const res = await this.spotifyApi.getTrack(id) return res.body } + + public readonly getAlbumInfo = async (id: string) => { + const res = await this.spotifyApi.getAlbum(id) + + return res.body + } } diff --git a/server/src/services/youtube_service.ts b/server/src/services/youtube_service.ts index 73bf8f0..8db215b 100644 --- a/server/src/services/youtube_service.ts +++ b/server/src/services/youtube_service.ts @@ -48,4 +48,39 @@ export class YoutubeService implements IYoutubeService { return res[0] } + + public readonly searchAlbumId = async (query: string) => { + const res = await this.youtubeApi.search + .list({ + auth: process.env.YOUTUBE_API_KEY, + part: ['snippet'], + q: query, + maxResults: 1, + type: ['playlist'], + }) + .then((v) => v.data.items ?? []) + + if (res.length === 0) { + return undefined + } + + return res[0].id + } + + public readonly getAlbumInfo = async (id: string) => { + const res = await this.youtubeApi.playlists + .list({ + id: [id], + part: ['snippet'], + auth: process.env.YOUTUBE_API_KEY, + maxResults: 1, + }) + .then((v) => v.data.items ?? []) + + if (res.length === 0) { + return undefined + } + + return res[0] + } } diff --git a/server/src/utils/is_youtube_or_spotify.ts b/server/src/utils/is_youtube_or_spotify.ts index 40042ff..6e5204e 100644 --- a/server/src/utils/is_youtube_or_spotify.ts +++ b/server/src/utils/is_youtube_or_spotify.ts @@ -1,25 +1,67 @@ -export const youtubeRegexes = [/.*(youtu\.be|youtube.com|music.youtube.com)\/watch\?v=((\w|-)+)?(?=(\&|$))/, /.*(youtu\.be|youtube.com)\/((\w|-)+)?(?=(\?|$))/] -export const spotifyRegex = /.*open.spotify.com\/track\/(\w+)?(?=\?|$)/ +export interface Match { + type: 'spotify' | 'youtube' + id: string + kind: 'track' | 'album' +} -interface Match { - type: 'spotify' | 'youtube'; - id: string; +interface Matcher { + regex: RegExp[] + idIndex: number + match: Omit } -export const isYoutubeOrSpotify = (url: string): Match | null => { - const spotifyMatch = url.match(spotifyRegex) - if (spotifyMatch != null && spotifyMatch.length > 1) { - return { - type: 'spotify', - id: spotifyMatch[1] - } - } - let youtubeMatch = url.match(youtubeRegexes[0]) ?? url.match(youtubeRegexes[1]) - if (youtubeMatch != null && youtubeMatch.length > 1) { - return { +const matches: Matcher[] = [ + { + regex: [ + /.*(youtu\.be|youtube.com|music.youtube.com)\/watch\?v=((\w|-)+)?(?=(\&|$))/, + /.*(youtu\.be|youtube.com)\/((?!playlist)(\w|-)+)?(?=(\?|$))/, + ], + idIndex: 2, + match: { + type: 'youtube', + kind: 'track', + }, + }, + { + regex: [ + /.*(youtube.com|music.youtube.com)\/playlist\?list=((\w|-)+)?(?=(\&|$))/, + ], + idIndex: 2, + match: { type: 'youtube', - id: youtubeMatch[2] + kind: 'album', + }, + }, + { + regex: [/.*open.spotify.com\/track\/(\w+)?(?=\?|$)/], + idIndex: 1, + match: { + type: 'spotify', + kind: 'track', + }, + }, + { + regex: [/.*open.spotify.com\/album\/(\w+)?(?=\?|$)/], + idIndex: 1, + match: { + type: 'spotify', + kind: 'album', + }, + }, +] + +export const isYoutubeOrSpotify = (url: string): Match | null => { + for (const matcher of matches) { + const match = matcher.regex + .map((regex) => url.match(regex)) + .find((v) => v != null) + if (!!match && match.length > 1) { + return { + ...matcher.match, + id: match[matcher.idIndex], + } } } + return null }