aboutsummaryrefslogtreecommitdiff
path: root/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'src/services')
-rw-r--r--src/services/add-query-to-queue.ts13
-rw-r--r--src/services/get-songs.ts356
-rw-r--r--src/services/player.ts85
-rw-r--r--src/services/spotify-api.ts79
-rw-r--r--src/services/youtube-api.ts265
5 files changed, 448 insertions, 350 deletions
diff --git a/src/services/add-query-to-queue.ts b/src/services/add-query-to-queue.ts
index 0ad6206..7980ca0 100644
--- a/src/services/add-query-to-queue.ts
+++ b/src/services/add-query-to-queue.ts
@@ -1,11 +1,10 @@
/* eslint-disable complexity */
import {CommandInteraction, GuildMember} from 'discord.js';
import {inject, injectable} from 'inversify';
-import {Except} from 'type-fest';
import shuffle from 'array-shuffle';
import {TYPES} from '../types.js';
import GetSongs from '../services/get-songs.js';
-import {QueuedSong, STATUS} from './player.js';
+import {SongMetadata, STATUS} from './player.js';
import PlayerManager from '../managers/player.js';
import {prisma} from '../utils/db.js';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
@@ -44,7 +43,7 @@ export default class AddQueryToQueue {
await interaction.deferReply();
- let newSongs: Array<Except<QueuedSong, 'addedInChannelId' | 'requestedBy'>> = [];
+ let newSongs: SongMetadata[] = [];
let extraMsg = '';
// Test if it's a complete URL
@@ -93,6 +92,14 @@ export default class AddQueryToQueue {
}
newSongs.push(...convertedSongs);
+ } else {
+ const song = await this.getSongs.httpLiveStream(query);
+
+ if (song) {
+ newSongs.push(song);
+ } else {
+ throw new Error('that doesn\'t exist');
+ }
}
} catch (_: unknown) {
// Not a URL, must search YouTube
diff --git a/src/services/get-songs.ts b/src/services/get-songs.ts
index 55a4215..e7f9b45 100644
--- a/src/services/get-songs.ts
+++ b/src/services/get-songs.ts
@@ -1,247 +1,90 @@
-import {URL} from 'url';
import {inject, injectable} from 'inversify';
-import {toSeconds, parse} from 'iso8601-duration';
-import got from 'got';
-import ytsr, {Video} from 'ytsr';
import spotifyURI from 'spotify-uri';
-import Spotify from 'spotify-web-api-node';
-import YouTube, {YoutubePlaylistItem, YoutubeVideo} 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 {SongMetadata, QueuedPlaylist, MediaSource} 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 KeyValueCacheProvider from './key-value-cache.js';
-import {ONE_HOUR_IN_SECONDS, ONE_MINUTE_IN_SECONDS} from '../utils/constants.js';
-import {parseTime} from '../utils/time.js';
-
-type SongMetadata = Except<QueuedSong, 'addedInChannelId' | 'requestedBy'>;
-
-interface VideoDetailsResponse {
- id: string;
- contentDetails: {
- videoId: string;
- duration: string;
- };
-}
+import ffmpeg from 'fluent-ffmpeg';
+import YoutubeAPI from './youtube-api.js';
+import SpotifyAPI, {SpotifyTrack} from './spotify-api.js';
@injectable()
export default class {
- private readonly youtube: YouTube;
- private readonly youtubeKey: string;
- private readonly spotify: Spotify;
- private readonly cache: KeyValueCacheProvider;
-
- private readonly ytsrQueue: PQueue;
+ private readonly youtubeAPI: YoutubeAPI;
+ private readonly spotifyAPI: SpotifyAPI;
constructor(
- @inject(TYPES.ThirdParty) thirdParty: ThirdParty,
- @inject(TYPES.Config) config: Config,
- @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) {
- this.youtube = thirdParty.youtube;
- this.youtubeKey = config.YOUTUBE_API_KEY;
- this.spotify = thirdParty.spotify;
- this.cache = cache;
-
- this.ytsrQueue = new PQueue({concurrency: 4});
+ @inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI,
+ @inject(TYPES.Services.SpotifyAPI) spotifyAPI: SpotifyAPI) {
+ this.youtubeAPI = youtubeAPI;
+ this.spotifyAPI = spotifyAPI;
}
async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
- const {items} = await this.ytsrQueue.add(async () => this.cache.wrap(
- ytsr,
- query,
- {
- limit: 10,
- },
- {
- expiresIn: ONE_HOUR_IN_SECONDS,
- },
- ));
-
- let firstVideo: Video | undefined;
-
- for (const item of items) {
- if (item.type === 'video') {
- firstVideo = item;
- break;
- }
- }
-
- if (!firstVideo) {
- throw new Error('No video found.');
- }
-
- return this.youtubeVideo(firstVideo.id, shouldSplitChapters);
+ return this.youtubeAPI.search(query, shouldSplitChapters);
}
async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
- const video = await this.cache.wrap(
- this.youtube.videos.get,
- cleanUrl(url),
- {
- expiresIn: ONE_HOUR_IN_SECONDS,
- },
- );
-
- return this.getMetadataFromVideo({video, shouldSplitChapters});
+ return this.youtubeAPI.getVideo(url, shouldSplitChapters);
}
async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
- // YouTube playlist
- const playlist = await this.cache.wrap(
- this.youtube.playlists.get,
- listId,
- {
- expiresIn: ONE_MINUTE_IN_SECONDS,
- },
- );
-
- 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},
- {
- 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 p = {
- searchParams: {
- part: 'contentDetails',
- id: items.map(item => item.contentDetails.videoId).join(','),
- key: this.youtubeKey,
- responseType: 'json',
- },
- };
- const {items: videoDetailItems} = await this.cache.wrap(
- async () => got(
- 'https://www.googleapis.com/youtube/v3/videos',
- p,
- ).json() as Promise<{items: VideoDetailsResponse[]}>,
- p,
- {
- expiresIn: ONE_MINUTE_IN_SECONDS,
- },
- );
-
- videoDetails.push(...videoDetailItems);
- })());
- }
-
- await Promise.all(videoDetailsPromises);
-
- const queuedPlaylist = {title: playlist.snippet.title, source: playlist.id};
-
- const songsToReturn: SongMetadata[] = [];
-
- for (const video of playlistVideos) {
- try {
- songsToReturn.push(...this.getMetadataFromVideo({
- video,
- queuedPlaylist,
- videoDetails: videoDetails.find((i: {id: string}) => i.id === video.contentDetails.videoId),
- shouldSplitChapters,
- }));
- } 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.
- }
- }
-
- return songsToReturn;
+ return this.youtubeAPI.getPlaylist(listId, shouldSplitChapters);
}
async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> {
const parsed = spotifyURI.parse(url);
- let tracks: SpotifyApi.TrackObjectSimplified[] = [];
-
- 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;
+ const [tracks, playlist] = await this.spotifyAPI.getAlbum(url, playlistLimit);
+ return this.spotifyToYouTube(tracks, shouldSplitChapters, playlist);
}
case 'playlist': {
- const uri = parsed as spotifyURI.Playlist;
-
- let [{body: playlistResponse}, {body: tracksResponse}] = await Promise.all([this.spotify.getPlaylist(uri.id), this.spotify.getPlaylistTracks(uri.id, {limit: 50})]);
-
- 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, {
- limit: parseInt(new URL(tracksResponse.next).searchParams.get('limit') ?? '50', 10),
- offset: parseInt(new URL(tracksResponse.next).searchParams.get('offset') ?? '0', 10),
- }));
-
- tracks.push(...tracksResponse.items.map(playlistItem => playlistItem.track));
- }
-
- break;
+ const [tracks, playlist] = await this.spotifyAPI.getPlaylist(url, playlistLimit);
+ return this.spotifyToYouTube(tracks, shouldSplitChapters, playlist);
}
case 'track': {
- const uri = parsed as spotifyURI.Track;
-
- const {body} = await this.spotify.getTrack(uri.id);
-
- tracks.push(body);
- break;
+ const tracks = [await this.spotifyAPI.getTrack(url)];
+ return this.spotifyToYouTube(tracks, shouldSplitChapters);
}
case 'artist': {
- const uri = parsed as spotifyURI.Artist;
-
- const {body} = await this.spotify.getArtistTopTracks(uri.id, 'US');
-
- tracks.push(...body.tracks);
- break;
+ const tracks = await this.spotifyAPI.getArtist(url, playlistLimit);
+ return this.spotifyToYouTube(tracks, shouldSplitChapters);
}
default: {
return [[], 0, 0];
}
}
+ }
- // Get random songs if the playlist is larger than limit
- const originalNSongs = tracks.length;
-
- if (tracks.length > playlistLimit) {
- const shuffled = shuffle(tracks);
+ async httpLiveStream(url: string): Promise<SongMetadata> {
+ return new Promise((resolve, reject) => {
+ ffmpeg(url).ffprobe((err, _) => {
+ if (err) {
+ reject();
+ }
- tracks = shuffled.slice(0, playlistLimit);
- }
+ resolve({
+ url,
+ source: MediaSource.HLS,
+ isLive: true,
+ title: url,
+ artist: url,
+ length: 0,
+ offset: 0,
+ playlist: null,
+ thumbnailUrl: null,
+ });
+ });
+ });
+ }
- const searchResults = await Promise.allSettled(tracks.map(async track => this.spotifyToYouTube(track, shouldSplitChapters)));
+ private async spotifyToYouTube(tracks: SpotifyTrack[], shouldSplitChapters: boolean, playlist?: QueuedPlaylist | undefined): Promise<[SongMetadata[], number, number]> {
+ const promisedResults = tracks.map(async track => this.youtubeAPI.search(`"${track.name}" "${track.artist}"`, shouldSplitChapters));
+ const searchResults = await Promise.allSettled(promisedResults);
let nSongsNotFound = 0;
@@ -261,113 +104,6 @@ export default class {
return accum;
}, []);
- return [songs, nSongsNotFound, originalNSongs];
- }
-
- 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;
+ return [songs, nSongsNotFound, tracks.length];
}
}
diff --git a/src/services/player.ts b/src/services/player.ts
index 138edfc..6e99364 100644
--- a/src/services/player.ts
+++ b/src/services/player.ts
@@ -10,12 +10,17 @@ import FileCacheProvider from './file-cache.js';
import debug from '../utils/debug.js';
import {prisma} from '../utils/db.js';
+export enum MediaSource {
+ Youtube,
+ HLS,
+}
+
export interface QueuedPlaylist {
title: string;
source: string;
}
-export interface QueuedSong {
+export interface SongMetadata {
title: string;
artist: string;
url: string;
@@ -23,8 +28,11 @@ export interface QueuedSong {
offset: number;
playlist: QueuedPlaylist | null;
isLive: boolean;
- addedInChannelId: Snowflake;
thumbnailUrl: string | null;
+ source: MediaSource;
+}
+export interface QueuedSong extends SongMetadata {
+ addedInChannelId: Snowflake;
requestedBy: string;
}
@@ -106,7 +114,7 @@ export default class {
to = currentSong.length + currentSong.offset;
}
- const stream = await this.getStream(currentSong.url, {seek: realPositionSeconds, to});
+ const stream = await this.getStream(currentSong, {seek: realPositionSeconds, to});
this.audioPlayer = createAudioPlayer({
behaviors: {
// Needs to be somewhat high for livestreams
@@ -171,7 +179,7 @@ export default class {
to = currentSong.length + currentSong.offset;
}
- const stream = await this.getStream(currentSong.url, {seek: positionSeconds, to});
+ const stream = await this.getStream(currentSong, {seek: positionSeconds, to});
this.audioPlayer = createAudioPlayer({
behaviors: {
// Needs to be somewhat high for livestreams
@@ -365,7 +373,11 @@ export default class {
return hasha(url);
}
- private async getStream(url: string, options: {seek?: number; to?: number} = {}): Promise<Readable> {
+ private async getStream(song: QueuedSong, options: {seek?: number; to?: number} = {}): Promise<Readable> {
+ if (song.source === MediaSource.HLS) {
+ return this.createReadStream(song.url);
+ }
+
let ffmpegInput = '';
const ffmpegInputOptions: string[] = [];
let shouldCacheVideo = false;
@@ -373,7 +385,7 @@ export default class {
let format: ytdl.videoFormat | undefined;
try {
- ffmpegInput = await this.fileCache.getPathFor(this.getHashForCache(url));
+ ffmpegInput = await this.fileCache.getPathFor(this.getHashForCache(song.url));
if (options.seek) {
ffmpegInputOptions.push('-ss', options.seek.toString());
@@ -384,7 +396,7 @@ export default class {
}
} catch {
// Not yet cached, must download
- const info = await ytdl.getInfo(url);
+ const info = await ytdl.getInfo(song.url);
const {formats} = info;
@@ -444,36 +456,7 @@ export default class {
}
}
- // Create stream and pipe to capacitor
- return new Promise((resolve, reject) => {
- const capacitor = new WriteStream();
-
- // Cache video if necessary
- if (shouldCacheVideo) {
- const cacheStream = this.fileCache.createWriteStream(this.getHashForCache(url));
-
- capacitor.createReadStream().pipe(cacheStream);
- } else {
- ffmpegInputOptions.push('-re');
- }
-
- const youtubeStream = ffmpeg(ffmpegInput)
- .inputOptions(ffmpegInputOptions)
- .noVideo()
- .audioCodec('libopus')
- .outputFormat('webm')
- .on('error', error => {
- console.error(error);
- reject(error);
- })
- .on('start', command => {
- debug(`Spawned ffmpeg with ${command as string}`);
- });
-
- youtubeStream.pipe(capacitor);
-
- resolve(capacitor.createReadStream());
- });
+ return this.createReadStream(ffmpegInput, {ffmpegInputOptions, cache: shouldCacheVideo});
}
private startTrackingPosition(initalPosition?: number): void {
@@ -524,4 +507,32 @@ export default class {
await this.forward(1);
}
}
+
+ private async createReadStream(url: string, options: {ffmpegInputOptions?: string[]; cache?: boolean} = {}): Promise<Readable> {
+ return new Promise((resolve, reject) => {
+ const capacitor = new WriteStream();
+
+ if (options?.cache) {
+ const cacheStream = this.fileCache.createWriteStream(this.getHashForCache(url));
+ capacitor.createReadStream().pipe(cacheStream);
+ }
+
+ const stream = ffmpeg(url)
+ .inputOptions(options?.ffmpegInputOptions ?? ['-re'])
+ .noVideo()
+ .audioCodec('libopus')
+ .outputFormat('webm')
+ .on('error', error => {
+ console.error(error);
+ reject(error);
+ })
+ .on('start', command => {
+ debug(`Spawned ffmpeg with ${command as string}`);
+ });
+
+ stream.pipe(capacitor);
+
+ resolve(capacitor.createReadStream());
+ });
+ }
}
diff --git a/src/services/spotify-api.ts b/src/services/spotify-api.ts
new file mode 100644
index 0000000..7f90aff
--- /dev/null
+++ b/src/services/spotify-api.ts
@@ -0,0 +1,79 @@
+import {URL} from 'url';
+import {inject, injectable} from 'inversify';
+import spotifyURI from 'spotify-uri';
+import Spotify from 'spotify-web-api-node';
+import {TYPES} from '../types.js';
+import ThirdParty from './third-party.js';
+import shuffle from 'array-shuffle';
+import {QueuedPlaylist} from './player.js';
+
+export interface SpotifyTrack {
+ name: string;
+ artist: string;
+}
+
+@injectable()
+export default class {
+ private readonly spotify: Spotify;
+
+ constructor(@inject(TYPES.ThirdParty) thirdParty: ThirdParty) {
+ this.spotify = thirdParty.spotify;
+ }
+
+ async getAlbum(url: string, playlistLimit: number): Promise<[SpotifyTrack[], QueuedPlaylist]> {
+ const uri = spotifyURI.parse(url) as spotifyURI.Album;
+ const [{body: album}, {body: {items}}] = await Promise.all([this.spotify.getAlbum(uri.id), this.spotify.getAlbumTracks(uri.id, {limit: 50})]);
+ const tracks = this.limitTracks(items, playlistLimit).map(this.toSpotifyTrack);
+ const playlist = {title: album.name, source: album.href};
+
+ return [tracks, playlist];
+ }
+
+ async getPlaylist(url: string, playlistLimit: number): Promise<[SpotifyTrack[], QueuedPlaylist]> {
+ const uri = spotifyURI.parse(url) as spotifyURI.Playlist;
+
+ let [{body: playlistResponse}, {body: tracksResponse}] = await Promise.all([this.spotify.getPlaylist(uri.id), this.spotify.getPlaylistTracks(uri.id, {limit: 50})]);
+
+ const items = tracksResponse.items.map(playlistItem => playlistItem.track);
+ const playlist = {title: playlistResponse.name, source: playlistResponse.href};
+
+ while (tracksResponse.next) {
+ // eslint-disable-next-line no-await-in-loop
+ ({body: tracksResponse} = await this.spotify.getPlaylistTracks(uri.id, {
+ limit: parseInt(new URL(tracksResponse.next).searchParams.get('limit') ?? '50', 10),
+ offset: parseInt(new URL(tracksResponse.next).searchParams.get('offset') ?? '0', 10),
+ }));
+
+ items.push(...tracksResponse.items.map(playlistItem => playlistItem.track));
+ }
+
+ const tracks = this.limitTracks(items, playlistLimit).map(this.toSpotifyTrack);
+
+ return [tracks, playlist];
+ }
+
+ async getTrack(url: string): Promise<SpotifyTrack> {
+ const uri = spotifyURI.parse(url) as spotifyURI.Track;
+ const {body} = await this.spotify.getTrack(uri.id);
+
+ return this.toSpotifyTrack(body);
+ }
+
+ async getArtist(url: string, playlistLimit: number): Promise<SpotifyTrack[]> {
+ const uri = spotifyURI.parse(url) as spotifyURI.Artist;
+ const {body} = await this.spotify.getArtistTopTracks(uri.id, 'US');
+
+ return this.limitTracks(body.tracks, playlistLimit).map(this.toSpotifyTrack);
+ }
+
+ private toSpotifyTrack(track: SpotifyApi.TrackObjectSimplified): SpotifyTrack {
+ return {
+ name: track.name,
+ artist: track.artists[0].name,
+ };
+ }
+
+ private limitTracks(tracks: SpotifyApi.TrackObjectSimplified[], limit: number) {
+ return tracks.length > limit ? shuffle(tracks).slice(0, limit) : tracks;
+ }
+}
diff --git a/src/services/youtube-api.ts b/src/services/youtube-api.ts
new file mode 100644
index 0000000..577e2fa
--- /dev/null
+++ b/src/services/youtube-api.ts
@@ -0,0 +1,265 @@
+import {inject, injectable} from 'inversify';
+import {toSeconds, parse} from 'iso8601-duration';
+import got from 'got';
+import ytsr, {Video} from 'ytsr';
+import YouTube, {YoutubePlaylistItem, YoutubeVideo} from 'youtube.ts';
+import PQueue from 'p-queue';
+import {SongMetadata, QueuedPlaylist, MediaSource} from './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 KeyValueCacheProvider from './key-value-cache.js';
+import {ONE_HOUR_IN_SECONDS, ONE_MINUTE_IN_SECONDS} from '../utils/constants.js';
+import {parseTime} from '../utils/time.js';
+
+interface VideoDetailsResponse {
+ id: string;
+ contentDetails: {
+ videoId: string;
+ duration: string;
+ };
+}
+
+@injectable()
+export default class {
+ private readonly youtube: YouTube;
+ private readonly youtubeKey: string;
+ private readonly cache: KeyValueCacheProvider;
+
+ private readonly ytsrQueue: PQueue;
+
+ constructor(
+ @inject(TYPES.ThirdParty) thirdParty: ThirdParty,
+ @inject(TYPES.Config) config: Config,
+ @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) {
+ this.youtube = thirdParty.youtube;
+ this.youtubeKey = config.YOUTUBE_API_KEY;
+ this.cache = cache;
+
+ this.ytsrQueue = new PQueue({concurrency: 4});
+ }
+
+ async search(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
+ const {items} = await this.ytsrQueue.add(async () => this.cache.wrap(
+ ytsr,
+ query,
+ {
+ limit: 10,
+ },
+ {
+ expiresIn: ONE_HOUR_IN_SECONDS,
+ },
+ ));
+
+ let firstVideo: Video | undefined;
+
+ for (const item of items) {
+ if (item.type === 'video') {
+ firstVideo = item;
+ break;
+ }
+ }
+
+ if (!firstVideo) {
+ throw new Error('No video found.');
+ }
+
+ return this.getVideo(firstVideo.id, shouldSplitChapters);
+ }
+
+ async getVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
+ const video = await this.cache.wrap(
+ this.youtube.videos.get,
+ cleanUrl(url),
+ {
+ expiresIn: ONE_HOUR_IN_SECONDS,
+ },
+ );
+
+ return this.getMetadataFromVideo({video, shouldSplitChapters});
+ }
+
+ async getPlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
+ // YouTube playlist
+ const playlist = await this.cache.wrap(
+ this.youtube.playlists.get,
+ listId,
+ {
+ expiresIn: ONE_MINUTE_IN_SECONDS,
+ },
+ );
+
+ 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},
+ {
+ 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 p = {
+ searchParams: {
+ part: 'contentDetails',
+ id: items.map(item => item.contentDetails.videoId).join(','),
+ key: this.youtubeKey,
+ responseType: 'json',
+ },
+ };
+ const {items: videoDetailItems} = await this.cache.wrap(
+ async () => got(
+ 'https://www.googleapis.com/youtube/v3/videos',
+ p,
+ ).json() as Promise<{items: VideoDetailsResponse[]}>,
+ p,
+ {
+ expiresIn: ONE_MINUTE_IN_SECONDS,
+ },
+ );
+
+ videoDetails.push(...videoDetailItems);
+ })());
+ }
+
+ await Promise.all(videoDetailsPromises);
+
+ const queuedPlaylist = {title: playlist.snippet.title, source: playlist.id};
+
+ const songsToReturn: SongMetadata[] = [];
+
+ for (const video of playlistVideos) {
+ try {
+ songsToReturn.push(...this.getMetadataFromVideo({
+ video,
+ queuedPlaylist,
+ videoDetails: videoDetails.find((i: {id: string}) => i.id === video.contentDetails.videoId),
+ shouldSplitChapters,
+ }));
+ } 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.
+ }
+ }
+
+ return songsToReturn;
+ }
+
+ // 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 = {
+ source: MediaSource.Youtube,
+ 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;
+ }
+}