From 6c118dc965a282f55d838c555d9e6216be2f472c Mon Sep 17 00:00:00 2001 From: Thongrapee Panyapatiphan Date: Sat, 12 Mar 2022 22:50:29 +0700 Subject: [PATCH] Support for web streaming audio files (#550) Co-authored-by: Max Isom --- CHANGELOG.md | 2 + src/inversify.config.ts | 4 + src/services/add-query-to-queue.ts | 13 +- src/services/get-songs.ts | 356 ++++------------------------- src/services/player.ts | 85 ++++--- src/services/spotify-api.ts | 79 +++++++ src/services/youtube-api.ts | 265 +++++++++++++++++++++ src/types.ts | 2 + src/utils/build-embed.ts | 15 +- 9 files changed, 468 insertions(+), 353 deletions(-) create mode 100644 src/services/spotify-api.ts create mode 100644 src/services/youtube-api.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b2fffee..9f4f3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Muse can now HTTP stream live audio files (see #396) ## [1.3.0] - 2022-03-09 ### Added diff --git a/src/inversify.config.ts b/src/inversify.config.ts index daf4e68..b6a6f51 100644 --- a/src/inversify.config.ts +++ b/src/inversify.config.ts @@ -11,6 +11,8 @@ import PlayerManager from './managers/player.js'; // Services import AddQueryToQueue from './services/add-query-to-queue.js'; import GetSongs from './services/get-songs.js'; +import YoutubeAPI from './services/youtube-api.js'; +import SpotifyAPI from './services/spotify-api.js'; // Comands import Command from './commands'; @@ -53,6 +55,8 @@ container.bind(TYPES.Managers.Player).to(PlayerManager).inSinglet // Services container.bind(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope(); container.bind(TYPES.Services.AddQueryToQueue).to(AddQueryToQueue).inSingletonScope(); +container.bind(TYPES.Services.YoutubeAPI).to(YoutubeAPI).inSingletonScope(); +container.bind(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingletonScope(); // Commands [ 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> = []; + 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; - -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 { - 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 { - 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 { - // YouTube playlist - const playlist = await this.cache.wrap( - this.youtube.playlists.get, - listId, - { - expiresIn: ONE_MINUTE_IN_SECONDS, - }, - ); - - const playlistVideos: YoutubePlaylistItem[] = []; - const videoDetailsPromises: Array> = []; - 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; + async httpLiveStream(url: string): Promise { + return new Promise((resolve, reject) => { + ffmpeg(url).ffprobe((err, _) => { + if (err) { + reject(); + } - if (tracks.length > playlistLimit) { - const shuffled = shuffle(tracks); + resolve({ + url, + source: MediaSource.HLS, + isLive: true, + title: url, + artist: url, + length: 0, + offset: 0, + playlist: null, + thumbnailUrl: null, + }); + }); + }); + } - tracks = shuffled.slice(0, playlistLimit); - } - - 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 { - 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(); - 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 { + private async getStream(song: QueuedSong, options: {seek?: number; to?: number} = {}): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // YouTube playlist + const playlist = await this.cache.wrap( + this.youtube.playlists.get, + listId, + { + expiresIn: ONE_MINUTE_IN_SECONDS, + }, + ); + + const playlistVideos: YoutubePlaylistItem[] = []; + const videoDetailsPromises: Array> = []; + 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(); + 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; + } +} diff --git a/src/types.ts b/src/types.ts index 47108b9..fc1b5e2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,5 +13,7 @@ export const TYPES = { Services: { AddQueryToQueue: Symbol('AddQueryToQueue'), GetSongs: Symbol('GetSongs'), + YoutubeAPI: Symbol('YoutubeAPI'), + SpotifyAPI: Symbol('SpotifyAPI'), }, }; diff --git a/src/utils/build-embed.ts b/src/utils/build-embed.ts index cb9e634..1979f7d 100644 --- a/src/utils/build-embed.ts +++ b/src/utils/build-embed.ts @@ -1,6 +1,6 @@ import getYouTubeID from 'get-youtube-id'; import {MessageEmbed} from 'discord.js'; -import Player, {QueuedSong, STATUS} from '../services/player.js'; +import Player, {MediaSource, QueuedSong, STATUS} from '../services/player.js'; import getProgressBar from './get-progress-bar.js'; import {prettyTime} from './time.js'; import {truncate} from './string.js'; @@ -13,7 +13,11 @@ const getMaxSongTitleLength = (title: string) => { return nonASCII.test(title) ? 28 : 48; }; -const getSongTitle = ({title, url, offset}: QueuedSong, shouldTruncate = false) => { +const getSongTitle = ({title, url, offset, source}: QueuedSong, shouldTruncate = false) => { + if (source === MediaSource.HLS) { + return `[${title}](${url})`; + } + const cleanSongTitle = title.replace(/\[.*\]/, '').trim(); const songTitle = shouldTruncate ? truncate(cleanSongTitle, getMaxSongTitleLength(cleanSongTitle)) : cleanSongTitle; @@ -92,7 +96,12 @@ export const buildQueueEmbed = (player: Player, page: number): MessageEmbed => { const queuedSongs = player .getQueue() .slice(queuePageBegin, queuePageEnd) - .map((song, index) => `\`${index + 1 + queuePageBegin}.\` ${getSongTitle(song, true)} \`[${prettyTime(song.length)}]\``) + .map((song, index) => { + const songNumber = index + 1 + queuePageBegin; + const duration = song.isLive ? 'live' : prettyTime(song.length); + + return `\`${songNumber}.\` ${getSongTitle(song, true)} \`[${duration}]\``; + }) .join('\n'); const {artist, thumbnailUrl, playlist, requestedBy} = currentlyPlaying;