2020-03-18 03:36:48 +01:00
import { URL } from 'url' ;
import { inject , injectable } from 'inversify' ;
import { toSeconds , parse } from 'iso8601-duration' ;
import got from 'got' ;
2021-09-20 04:04:34 +02:00
import ytsr , { Video } from 'ytsr' ;
2020-03-18 03:36:48 +01:00
import spotifyURI from 'spotify-uri' ;
import Spotify from 'spotify-web-api-node' ;
2022-03-10 03:47:52 +01:00
import YouTube , { YoutubePlaylistItem , YoutubeVideo } from 'youtube.ts' ;
2021-09-20 04:04:34 +02:00
import PQueue from 'p-queue' ;
2021-05-30 22:01:11 +02:00
import shuffle from 'array-shuffle' ;
2021-09-18 22:55:50 +02:00
import { Except } from 'type-fest' ;
2021-09-20 04:04:34 +02:00
import { QueuedSong , QueuedPlaylist } from '../services/player.js' ;
import { TYPES } from '../types.js' ;
import { cleanUrl } from '../utils/url.js' ;
import ThirdParty from './third-party.js' ;
import Config from './config.js' ;
2021-11-19 02:55:57 +01:00
import KeyValueCacheProvider from './key-value-cache.js' ;
2022-02-05 23:16:17 +01:00
import { ONE_HOUR_IN_SECONDS , ONE_MINUTE_IN_SECONDS } from '../utils/constants.js' ;
2022-03-10 03:47:52 +01:00
import { parseTime } from '../utils/time.js' ;
2020-03-18 03:36:48 +01:00
2022-01-21 19:50:57 +01:00
type SongMetadata = Except < QueuedSong , ' addedInChannelId ' | ' requestedBy ' > ;
2021-09-18 22:55:50 +02:00
2022-03-10 03:47:52 +01:00
interface VideoDetailsResponse {
id : string ;
contentDetails : {
videoId : string ;
duration : string ;
} ;
}
2020-03-18 03:36:48 +01:00
@injectable ( )
export default class {
private readonly youtube : YouTube ;
private readonly youtubeKey : string ;
private readonly spotify : Spotify ;
2021-11-19 02:55:57 +01:00
private readonly cache : KeyValueCacheProvider ;
2021-09-20 04:04:34 +02:00
private readonly ytsrQueue : PQueue ;
2020-03-18 03:36:48 +01:00
2021-09-20 04:04:34 +02:00
constructor (
@inject ( TYPES . ThirdParty ) thirdParty : ThirdParty ,
@inject ( TYPES . Config ) config : Config ,
2021-11-19 02:55:57 +01:00
@inject ( TYPES . KeyValueCache ) cache : KeyValueCacheProvider ) {
2021-09-20 01:50:25 +02:00
this . youtube = thirdParty . youtube ;
this . youtubeKey = config . YOUTUBE_API_KEY ;
this . spotify = thirdParty . spotify ;
2021-09-20 04:04:34 +02:00
this . cache = cache ;
this . ytsrQueue = new PQueue ( { concurrency : 4 } ) ;
2020-03-18 03:36:48 +01:00
}
2022-03-10 03:47:52 +01:00
async youtubeVideoSearch ( query : string , shouldSplitChapters : boolean ) : Promise < SongMetadata [ ] > {
2022-01-06 23:25:59 +01:00
const { items } = await this . ytsrQueue . add ( async ( ) = > this . cache . wrap (
ytsr ,
query ,
{
limit : 10 ,
} ,
{
expiresIn : ONE_HOUR_IN_SECONDS ,
} ,
) ) ;
2021-09-20 04:04:34 +02:00
2022-01-06 23:25:59 +01:00
let firstVideo : Video | undefined ;
2020-03-18 03:36:48 +01:00
2022-01-06 23:25:59 +01:00
for ( const item of items ) {
if ( item . type === 'video' ) {
firstVideo = item ;
break ;
2021-09-20 04:04:34 +02:00
}
2022-01-06 23:25:59 +01:00
}
2021-09-20 04:04:34 +02:00
2022-01-06 23:25:59 +01:00
if ( ! firstVideo ) {
throw new Error ( 'No video found.' ) ;
2020-03-18 03:36:48 +01:00
}
2022-01-06 23:25:59 +01:00
2022-03-10 03:47:52 +01:00
return this . youtubeVideo ( firstVideo . id , shouldSplitChapters ) ;
2020-03-18 03:36:48 +01:00
}
2022-03-10 03:47:52 +01:00
async youtubeVideo ( url : string , shouldSplitChapters : boolean ) : Promise < SongMetadata [ ] > {
const video = await this . cache . wrap (
2022-01-06 23:25:59 +01:00
this . youtube . videos . get ,
cleanUrl ( url ) ,
{
expiresIn : ONE_HOUR_IN_SECONDS ,
} ,
) ;
2020-03-18 03:36:48 +01:00
2022-03-10 03:47:52 +01:00
return this . getMetadataFromVideo ( { video , shouldSplitChapters } ) ;
2020-03-18 03:36:48 +01:00
}
2022-03-10 03:47:52 +01:00
async youtubePlaylist ( listId : string , shouldSplitChapters : boolean ) : Promise < SongMetadata [ ] > {
2020-03-18 03:36:48 +01:00
// YouTube playlist
2021-09-20 04:04:34 +02:00
const playlist = await this . cache . wrap (
this . youtube . playlists . get ,
listId ,
{
2021-09-20 04:24:46 +02:00
expiresIn : ONE_MINUTE_IN_SECONDS ,
} ,
2021-09-20 04:04:34 +02:00
) ;
2020-03-18 03:36:48 +01:00
2021-04-01 21:20:18 +02:00
const playlistVideos : YoutubePlaylistItem [ ] = [ ] ;
const videoDetailsPromises : Array < Promise < void > > = [ ] ;
const videoDetails : VideoDetailsResponse [ ] = [ ] ;
let nextToken : string | undefined ;
while ( playlistVideos . length !== playlist . contentDetails . itemCount ) {
// eslint-disable-next-line no-await-in-loop
2021-09-20 04:04:34 +02:00
const { items , nextPageToken } = await this . cache . wrap (
this . youtube . playlists . items ,
listId ,
2021-12-18 08:53:00 +01:00
{ maxResults : '50' , pageToken : nextToken } ,
2021-09-20 04:04:34 +02:00
{
2021-09-20 04:24:46 +02:00
expiresIn : ONE_MINUTE_IN_SECONDS ,
} ,
2021-09-20 04:04:34 +02:00
) ;
2021-04-01 21:20:18 +02:00
nextToken = nextPageToken ;
playlistVideos . push ( . . . items ) ;
// Start fetching extra details about videos
videoDetailsPromises . push ( ( async ( ) = > {
// Unfortunately, package doesn't provide a method for this
2021-09-23 05:41:26 +02:00
const p = {
searchParams : {
part : 'contentDetails' ,
id : items.map ( item = > item . contentDetails . videoId ) . join ( ',' ) ,
key : this.youtubeKey ,
responseType : 'json' ,
} ,
} ;
2021-09-20 04:04:34 +02:00
const { items : videoDetailItems } = await this . cache . wrap (
2021-09-20 04:24:46 +02:00
async ( ) = > got (
'https://www.googleapis.com/youtube/v3/videos' ,
2021-09-23 05:41:26 +02:00
p ,
2021-09-20 04:24:46 +02:00
) . json ( ) as Promise < { items : VideoDetailsResponse [ ] } > ,
2021-09-23 05:41:26 +02:00
p ,
2021-09-20 04:04:34 +02:00
{
2021-09-20 04:24:46 +02:00
expiresIn : ONE_MINUTE_IN_SECONDS ,
} ,
2021-09-20 04:04:34 +02:00
) ;
2021-04-01 21:20:18 +02:00
videoDetails . push ( . . . videoDetailItems ) ;
} ) ( ) ) ;
}
await Promise . all ( videoDetailsPromises ) ;
2020-03-18 03:36:48 +01:00
const queuedPlaylist = { title : playlist.snippet.title , source : playlist.id } ;
2022-01-21 19:50:57 +01:00
const songsToReturn : SongMetadata [ ] = [ ] ;
2021-04-01 21:20:18 +02:00
2021-09-20 04:24:46 +02:00
for ( const video of playlistVideos ) {
2021-04-01 21:20:18 +02:00
try {
2022-03-10 03:47:52 +01:00
songsToReturn . push ( . . . this . getMetadataFromVideo ( {
video ,
queuedPlaylist ,
videoDetails : videoDetails.find ( ( i : { id : string } ) = > i . id === video . contentDetails . videoId ) ,
shouldSplitChapters ,
} ) ) ;
2021-04-01 21:20:18 +02:00
} catch ( _ : unknown ) {
// Private and deleted videos are sometimes in playlists, duration of these is not returned and they should not be added to the queue.
}
}
2020-03-18 03:36:48 +01:00
2021-04-01 21:20:18 +02:00
return songsToReturn ;
2020-03-18 03:36:48 +01:00
}
2022-03-10 03:47:52 +01:00
async spotifySource ( url : string , playlistLimit : number , shouldSplitChapters : boolean ) : Promise < [ SongMetadata [ ] , number , number ] > {
2020-03-18 03:36:48 +01:00
const parsed = spotifyURI . parse ( url ) ;
2020-03-19 01:17:47 +01:00
let tracks : SpotifyApi.TrackObjectSimplified [ ] = [ ] ;
2020-03-18 03:36:48 +01:00
let playlist : QueuedPlaylist | null = null ;
switch ( parsed . type ) {
case 'album' : {
const uri = parsed as spotifyURI . Album ;
2021-12-18 08:53:00 +01:00
const [ { body : album } , { body : { items } } ] = await Promise . all ( [ this . spotify . getAlbum ( uri . id ) , this . spotify . getAlbumTracks ( uri . id , { limit : 50 } ) ] ) ;
2020-03-18 03:36:48 +01:00
tracks . push ( . . . items ) ;
playlist = { title : album.name , source : album.href } ;
break ;
}
case 'playlist' : {
const uri = parsed as spotifyURI . Playlist ;
2021-12-18 08:53:00 +01:00
let [ { body : playlistResponse } , { body : tracksResponse } ] = await Promise . all ( [ this . spotify . getPlaylist ( uri . id ) , this . spotify . getPlaylistTracks ( uri . id , { limit : 50 } ) ] ) ;
2020-03-18 03:36:48 +01:00
playlist = { title : playlistResponse.name , source : playlistResponse.href } ;
tracks . push ( . . . tracksResponse . items . map ( playlistItem = > playlistItem . track ) ) ;
while ( tracksResponse . next ) {
// eslint-disable-next-line no-await-in-loop
( { body : tracksResponse } = await this . spotify . getPlaylistTracks ( uri . id , {
2021-12-18 08:53:00 +01:00
limit : parseInt ( new URL ( tracksResponse . next ) . searchParams . get ( 'limit' ) ? ? '50' , 10 ) ,
2021-09-20 04:24:46 +02:00
offset : parseInt ( new URL ( tracksResponse . next ) . searchParams . get ( 'offset' ) ? ? '0' , 10 ) ,
2020-03-18 03:36:48 +01:00
} ) ) ;
tracks . push ( . . . tracksResponse . items . map ( playlistItem = > playlistItem . track ) ) ;
}
break ;
}
case 'track' : {
const uri = parsed as spotifyURI . Track ;
const { body } = await this . spotify . getTrack ( uri . id ) ;
tracks . push ( body ) ;
break ;
}
case 'artist' : {
const uri = parsed as spotifyURI . Artist ;
const { body } = await this . spotify . getArtistTopTracks ( uri . id , 'US' ) ;
tracks . push ( . . . body . tracks ) ;
break ;
}
default : {
2020-03-19 01:17:47 +01:00
return [ [ ] , 0 , 0 ] ;
}
}
2021-10-01 16:11:43 +02:00
// Get random songs if the playlist is larger than limit
2020-03-19 01:17:47 +01:00
const originalNSongs = tracks . length ;
2021-12-18 08:53:00 +01:00
if ( tracks . length > playlistLimit ) {
2021-05-30 22:01:11 +02:00
const shuffled = shuffle ( tracks ) ;
2020-03-19 01:17:47 +01:00
2021-12-18 08:53:00 +01:00
tracks = shuffled . slice ( 0 , playlistLimit ) ;
2020-03-18 03:36:48 +01:00
}
2022-03-10 03:47:52 +01:00
const searchResults = await Promise . allSettled ( tracks . map ( async track = > this . spotifyToYouTube ( track , shouldSplitChapters ) ) ) ;
2020-03-18 03:36:48 +01:00
let nSongsNotFound = 0 ;
2022-01-26 02:18:01 +01:00
// Count songs that couldn't be found
const songs : SongMetadata [ ] = searchResults . reduce ( ( accum : SongMetadata [ ] , result ) = > {
if ( result . status === 'fulfilled' ) {
2022-03-10 03:47:52 +01:00
for ( const v of result . value ) {
accum . push ( {
. . . v ,
. . . ( playlist ? { playlist } : { } ) ,
} ) ;
}
2020-03-18 03:36:48 +01:00
} else {
nSongsNotFound ++ ;
}
return accum ;
} , [ ] ) ;
2022-01-26 02:18:01 +01:00
return [ songs , nSongsNotFound , originalNSongs ] ;
2020-03-18 03:36:48 +01:00
}
2022-03-10 03:47:52 +01:00
private async spotifyToYouTube ( track : SpotifyApi.TrackObjectSimplified , shouldSplitChapters : boolean ) : Promise < SongMetadata [ ] > {
return this . youtubeVideoSearch ( ` " ${ track . name } " " ${ track . artists [ 0 ] . name } " ` , shouldSplitChapters ) ;
}
// TODO: we should convert YouTube videos (from both single videos and playlists) to an intermediate representation so we don't have to check if it's from a playlist
private getMetadataFromVideo ( {
video ,
queuedPlaylist ,
videoDetails ,
shouldSplitChapters ,
} : {
video : YoutubeVideo | YoutubePlaylistItem ;
queuedPlaylist? : QueuedPlaylist ;
videoDetails? : VideoDetailsResponse ;
shouldSplitChapters? : boolean ;
} ) : SongMetadata [ ] {
let url : string ;
let videoDurationSeconds : number ;
// Dirty hack
if ( queuedPlaylist ) {
// Is playlist item
video = video as YoutubePlaylistItem ;
url = video . contentDetails . videoId ;
videoDurationSeconds = toSeconds ( parse ( videoDetails ! . contentDetails . duration ) ) ;
} else {
video = video as YoutubeVideo ;
videoDurationSeconds = toSeconds ( parse ( video . contentDetails . duration ) ) ;
url = video . id ;
}
const base : SongMetadata = {
title : video.snippet.title ,
artist : video.snippet.channelTitle ,
length : videoDurationSeconds ,
offset : 0 ,
url ,
playlist : queuedPlaylist ? ? null ,
isLive : false ,
thumbnailUrl : video.snippet.thumbnails.medium.url ,
} ;
if ( ! shouldSplitChapters ) {
return [ base ] ;
}
const chapters = this . parseChaptersFromDescription ( video . snippet . description , videoDurationSeconds ) ;
if ( ! chapters ) {
return [ base ] ;
}
const tracks : SongMetadata [ ] = [ ] ;
for ( const [ label , { offset , length } ] of chapters ) {
tracks . push ( {
. . . base ,
offset ,
length ,
title : ` ${ label } ( ${ base . title } ) ` ,
} ) ;
}
return tracks ;
}
private parseChaptersFromDescription ( description : string , videoDurationSeconds : number ) {
const map = new Map < string , { offset : number ; length : number } > ( ) ;
let foundFirstTimestamp = false ;
const foundTimestamps : Array < { name : string ; offset : number } > = [ ] ;
for ( const line of description . split ( '\n' ) ) {
const timestamps = Array . from ( line . matchAll ( /(?:\d+:)+\d+/g ) ) ;
if ( timestamps ? . length !== 1 ) {
continue ;
}
if ( ! foundFirstTimestamp ) {
if ( /0{1,2}:00/ . test ( timestamps [ 0 ] [ 0 ] ) ) {
foundFirstTimestamp = true ;
} else {
continue ;
}
}
const timestamp = timestamps [ 0 ] [ 0 ] ;
const seconds = parseTime ( timestamp ) ;
const chapterName = line . split ( timestamp ) [ 1 ] . trim ( ) ;
foundTimestamps . push ( { name : chapterName , offset : seconds } ) ;
}
for ( const [ i , { name , offset } ] of foundTimestamps . entries ( ) ) {
map . set ( name , {
offset ,
length : i === foundTimestamps . length - 1
? videoDurationSeconds - offset
: foundTimestamps [ i + 1 ] . offset - offset ,
} ) ;
}
if ( ! map . size ) {
return null ;
}
return map ;
2020-03-18 03:36:48 +01:00
}
}