muse/src/services/get-songs.ts

288 lines
8.2 KiB
TypeScript
Raw Normal View History

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';
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';
2021-04-01 21:28:46 +02:00
import YouTube, {YoutubePlaylistItem} from 'youtube.ts';
import PQueue from 'p-queue';
import shuffle from 'array-shuffle';
import {Except} from 'type-fest';
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';
import CacheProvider from './cache.js';
2020-03-18 03:36:48 +01:00
type QueuedSongWithoutChannel = Except<QueuedSong, 'addedInChannelId'>;
const ONE_HOUR_IN_SECONDS = 60 * 60;
const ONE_MINUTE_IN_SECONDS = 1 * 60;
2020-03-18 03:36:48 +01:00
@injectable()
export default class {
private readonly youtube: YouTube;
private readonly youtubeKey: string;
private readonly spotify: Spotify;
private readonly cache: CacheProvider;
private readonly ytsrQueue: PQueue;
2020-03-18 03:36:48 +01:00
constructor(
@inject(TYPES.ThirdParty) thirdParty: ThirdParty,
@inject(TYPES.Config) config: Config,
@inject(TYPES.Cache) cache: CacheProvider) {
2021-09-20 01:50:25 +02:00
this.youtube = thirdParty.youtube;
this.youtubeKey = config.YOUTUBE_API_KEY;
this.spotify = thirdParty.spotify;
this.cache = cache;
this.ytsrQueue = new PQueue({concurrency: 4});
2020-03-18 03:36:48 +01:00
}
2021-09-20 04:24:46 +02:00
async youtubeVideoSearch(query: string): Promise<QueuedSongWithoutChannel | null> {
2020-03-18 03:36:48 +01:00
try {
const {items} = await this.ytsrQueue.add(async () => this.cache.wrap(
ytsr,
query,
{
2021-09-20 04:24:46 +02:00
limit: 10,
},
{
2021-09-20 04:24:46 +02:00
expiresIn: ONE_HOUR_IN_SECONDS,
},
));
let firstVideo: Video | undefined;
for (const item of items) {
if (item.type === 'video') {
firstVideo = item;
break;
}
}
2020-03-18 03:36:48 +01:00
if (!firstVideo) {
throw new Error('No video found.');
}
return await this.youtubeVideo(firstVideo.id);
2020-10-24 18:32:43 +02:00
} catch (_: unknown) {
2020-03-18 03:36:48 +01:00
return null;
}
}
2021-09-20 04:24:46 +02:00
async youtubeVideo(url: string): Promise<QueuedSongWithoutChannel | null> {
2020-03-18 03:36:48 +01:00
try {
const videoDetails = await this.cache.wrap(
this.youtube.videos.get,
cleanUrl(url),
{
2021-09-20 04:24:46 +02:00
expiresIn: ONE_HOUR_IN_SECONDS,
},
);
2020-03-18 03:36:48 +01:00
return {
title: videoDetails.snippet.title,
artist: videoDetails.snippet.channelTitle,
length: toSeconds(parse(videoDetails.contentDetails.duration)),
url: videoDetails.id,
playlist: null,
2021-09-20 04:24:46 +02:00
isLive: videoDetails.snippet.liveBroadcastContent === 'live',
2020-03-18 03:36:48 +01:00
};
2020-10-24 18:32:43 +02:00
} catch (_: unknown) {
2020-03-18 03:36:48 +01:00
return null;
}
}
async youtubePlaylist(listId: string): Promise<QueuedSongWithoutChannel[]> {
2020-03-18 03:36:48 +01:00
// YouTube playlist
const playlist = await this.cache.wrap(
this.youtube.playlists.get,
listId,
{
2021-09-20 04:24:46 +02:00
expiresIn: ONE_MINUTE_IN_SECONDS,
},
);
2020-03-18 03:36:48 +01:00
interface VideoDetailsResponse {
2020-03-25 23:59:09 +01:00
id: string;
contentDetails: {
videoId: string;
duration: string;
};
}
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
const {items, nextPageToken} = await this.cache.wrap(
this.youtube.playlists.items,
listId,
{maxResults: '50', pageToken: nextToken},
{
2021-09-20 04:24:46 +02:00
expiresIn: ONE_MINUTE_IN_SECONDS,
},
);
nextToken = nextPageToken;
playlistVideos.push(...items);
// Start fetching extra details about videos
videoDetailsPromises.push((async () => {
// Unfortunately, package doesn't provide a method for this
const {items: videoDetailItems} = await this.cache.wrap(
2021-09-20 04:24:46 +02:00
async () => got(
'https://www.googleapis.com/youtube/v3/videos',
{
searchParams: {
part: 'contentDetails',
id: items.map(item => item.contentDetails.videoId).join(','),
key: this.youtubeKey,
responseType: 'json',
},
},
).json() as Promise<{items: VideoDetailsResponse[]}>,
{
2021-09-20 04:24:46 +02:00
expiresIn: ONE_MINUTE_IN_SECONDS,
},
);
videoDetails.push(...videoDetailItems);
})());
}
await Promise.all(videoDetailsPromises);
2020-03-18 03:36:48 +01:00
const queuedPlaylist = {title: playlist.snippet.title, source: playlist.id};
const songsToReturn: QueuedSongWithoutChannel[] = [];
2021-09-20 04:24:46 +02:00
for (const video of playlistVideos) {
try {
2021-09-20 04:24:46 +02:00
const length = toSeconds(parse(videoDetails.find((i: {id: string}) => i.id === video.contentDetails.videoId)!.contentDetails.duration));
songsToReturn.push({
title: video.snippet.title,
artist: video.snippet.channelTitle,
length,
url: video.contentDetails.videoId,
playlist: queuedPlaylist,
2021-09-20 04:24:46 +02:00
isLive: false,
});
} 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
return songsToReturn;
2020-03-18 03:36:48 +01:00
}
async spotifySource(url: string): Promise<[QueuedSongWithoutChannel[], 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;
const [{body: album}, {body: {items}}] = await Promise.all([this.spotify.getAlbum(uri.id), this.spotify.getAlbumTracks(uri.id, {limit: 50})]);
tracks.push(...items);
playlist = {title: album.name, source: album.href};
break;
}
case 'playlist': {
const uri = parsed as spotifyURI.Playlist;
2020-03-19 04:29:43 +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, {
2020-03-19 04:29:43 +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];
}
}
// Get 50 random songs if many
const originalNSongs = tracks.length;
if (tracks.length > 50) {
const shuffled = shuffle(tracks);
2020-03-19 01:17:47 +01:00
tracks = shuffled.slice(0, 50);
2020-03-18 03:36:48 +01:00
}
let songs = await Promise.all(tracks.map(async track => this.spotifyToYouTube(track, playlist)));
2020-03-18 03:36:48 +01:00
let nSongsNotFound = 0;
// Get rid of null values
songs = songs.reduce((accum: QueuedSongWithoutChannel[], song) => {
2020-03-18 03:36:48 +01:00
if (song) {
accum.push(song);
} else {
nSongsNotFound++;
}
return accum;
}, []);
return [songs as QueuedSongWithoutChannel[], nSongsNotFound, originalNSongs];
2020-03-18 03:36:48 +01:00
}
private async spotifyToYouTube(track: SpotifyApi.TrackObjectSimplified, _: QueuedPlaylist | null): Promise<QueuedSongWithoutChannel | null> {
2020-03-18 03:36:48 +01:00
try {
return await this.youtubeVideoSearch(`"${track.name}" "${track.artists[0].name}"`);
2020-10-24 18:32:43 +02:00
} catch (_: unknown) {
2020-03-18 03:36:48 +01:00
return null;
}
}
}