Support for web streaming audio files (#550)

Co-authored-by: Max Isom <hi@maxisom.me>
This commit is contained in:
Thongrapee Panyapatiphan 2022-03-12 22:50:29 +07:00 committed by GitHub
parent eac12eaade
commit 6c118dc965
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 468 additions and 353 deletions

View file

@ -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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
- Muse can now HTTP stream live audio files (see #396)
## [1.3.0] - 2022-03-09 ## [1.3.0] - 2022-03-09
### Added ### Added

View file

@ -11,6 +11,8 @@ import PlayerManager from './managers/player.js';
// Services // Services
import AddQueryToQueue from './services/add-query-to-queue.js'; import AddQueryToQueue from './services/add-query-to-queue.js';
import GetSongs from './services/get-songs.js'; import GetSongs from './services/get-songs.js';
import YoutubeAPI from './services/youtube-api.js';
import SpotifyAPI from './services/spotify-api.js';
// Comands // Comands
import Command from './commands'; import Command from './commands';
@ -53,6 +55,8 @@ container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSinglet
// Services // Services
container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope(); container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope();
container.bind<AddQueryToQueue>(TYPES.Services.AddQueryToQueue).to(AddQueryToQueue).inSingletonScope(); container.bind<AddQueryToQueue>(TYPES.Services.AddQueryToQueue).to(AddQueryToQueue).inSingletonScope();
container.bind<YoutubeAPI>(TYPES.Services.YoutubeAPI).to(YoutubeAPI).inSingletonScope();
container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingletonScope();
// Commands // Commands
[ [

View file

@ -1,11 +1,10 @@
/* eslint-disable complexity */ /* eslint-disable complexity */
import {CommandInteraction, GuildMember} from 'discord.js'; import {CommandInteraction, GuildMember} from 'discord.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import {Except} from 'type-fest';
import shuffle from 'array-shuffle'; import shuffle from 'array-shuffle';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import GetSongs from '../services/get-songs.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 PlayerManager from '../managers/player.js';
import {prisma} from '../utils/db.js'; import {prisma} from '../utils/db.js';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js'; import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
@ -44,7 +43,7 @@ export default class AddQueryToQueue {
await interaction.deferReply(); await interaction.deferReply();
let newSongs: Array<Except<QueuedSong, 'addedInChannelId' | 'requestedBy'>> = []; let newSongs: SongMetadata[] = [];
let extraMsg = ''; let extraMsg = '';
// Test if it's a complete URL // Test if it's a complete URL
@ -93,6 +92,14 @@ export default class AddQueryToQueue {
} }
newSongs.push(...convertedSongs); 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) { } catch (_: unknown) {
// Not a URL, must search YouTube // Not a URL, must search YouTube

View file

@ -1,247 +1,90 @@
import {URL} from 'url';
import {inject, injectable} from 'inversify'; 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 spotifyURI from 'spotify-uri';
import Spotify from 'spotify-web-api-node'; import {SongMetadata, QueuedPlaylist, MediaSource} from '../services/player.js';
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 {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import {cleanUrl} from '../utils/url.js'; import ffmpeg from 'fluent-ffmpeg';
import ThirdParty from './third-party.js'; import YoutubeAPI from './youtube-api.js';
import Config from './config.js'; import SpotifyAPI, {SpotifyTrack} from './spotify-api.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;
};
}
@injectable() @injectable()
export default class { export default class {
private readonly youtube: YouTube; private readonly youtubeAPI: YoutubeAPI;
private readonly youtubeKey: string; private readonly spotifyAPI: SpotifyAPI;
private readonly spotify: Spotify;
private readonly cache: KeyValueCacheProvider;
private readonly ytsrQueue: PQueue;
constructor( constructor(
@inject(TYPES.ThirdParty) thirdParty: ThirdParty, @inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI,
@inject(TYPES.Config) config: Config, @inject(TYPES.Services.SpotifyAPI) spotifyAPI: SpotifyAPI) {
@inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) { this.youtubeAPI = youtubeAPI;
this.youtube = thirdParty.youtube; this.spotifyAPI = spotifyAPI;
this.youtubeKey = config.YOUTUBE_API_KEY;
this.spotify = thirdParty.spotify;
this.cache = cache;
this.ytsrQueue = new PQueue({concurrency: 4});
} }
async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> { async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
const {items} = await this.ytsrQueue.add(async () => this.cache.wrap( return this.youtubeAPI.search(query, shouldSplitChapters);
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);
} }
async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> { async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
const video = await this.cache.wrap( return this.youtubeAPI.getVideo(url, shouldSplitChapters);
this.youtube.videos.get,
cleanUrl(url),
{
expiresIn: ONE_HOUR_IN_SECONDS,
},
);
return this.getMetadataFromVideo({video, shouldSplitChapters});
} }
async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> { async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
// YouTube playlist return this.youtubeAPI.getPlaylist(listId, shouldSplitChapters);
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;
} }
async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> { async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> {
const parsed = spotifyURI.parse(url); const parsed = spotifyURI.parse(url);
let tracks: SpotifyApi.TrackObjectSimplified[] = [];
let playlist: QueuedPlaylist | null = null;
switch (parsed.type) { switch (parsed.type) {
case 'album': { case 'album': {
const uri = parsed as spotifyURI.Album; const [tracks, playlist] = await this.spotifyAPI.getAlbum(url, playlistLimit);
return this.spotifyToYouTube(tracks, shouldSplitChapters, playlist);
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': { case 'playlist': {
const uri = parsed as spotifyURI.Playlist; const [tracks, playlist] = await this.spotifyAPI.getPlaylist(url, playlistLimit);
return this.spotifyToYouTube(tracks, shouldSplitChapters, 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;
} }
case 'track': { case 'track': {
const uri = parsed as spotifyURI.Track; const tracks = [await this.spotifyAPI.getTrack(url)];
return this.spotifyToYouTube(tracks, shouldSplitChapters);
const {body} = await this.spotify.getTrack(uri.id);
tracks.push(body);
break;
} }
case 'artist': { case 'artist': {
const uri = parsed as spotifyURI.Artist; const tracks = await this.spotifyAPI.getArtist(url, playlistLimit);
return this.spotifyToYouTube(tracks, shouldSplitChapters);
const {body} = await this.spotify.getArtistTopTracks(uri.id, 'US');
tracks.push(...body.tracks);
break;
} }
default: { default: {
return [[], 0, 0]; 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);
tracks = shuffled.slice(0, playlistLimit);
} }
const searchResults = await Promise.allSettled(tracks.map(async track => this.spotifyToYouTube(track, shouldSplitChapters))); async httpLiveStream(url: string): Promise<SongMetadata> {
return new Promise((resolve, reject) => {
ffmpeg(url).ffprobe((err, _) => {
if (err) {
reject();
}
resolve({
url,
source: MediaSource.HLS,
isLive: true,
title: url,
artist: url,
length: 0,
offset: 0,
playlist: null,
thumbnailUrl: null,
});
});
});
}
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; let nSongsNotFound = 0;
@ -261,113 +104,6 @@ export default class {
return accum; return accum;
}, []); }, []);
return [songs, nSongsNotFound, originalNSongs]; return [songs, nSongsNotFound, tracks.length];
}
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;
} }
} }

View file

@ -10,12 +10,17 @@ import FileCacheProvider from './file-cache.js';
import debug from '../utils/debug.js'; import debug from '../utils/debug.js';
import {prisma} from '../utils/db.js'; import {prisma} from '../utils/db.js';
export enum MediaSource {
Youtube,
HLS,
}
export interface QueuedPlaylist { export interface QueuedPlaylist {
title: string; title: string;
source: string; source: string;
} }
export interface QueuedSong { export interface SongMetadata {
title: string; title: string;
artist: string; artist: string;
url: string; url: string;
@ -23,8 +28,11 @@ export interface QueuedSong {
offset: number; offset: number;
playlist: QueuedPlaylist | null; playlist: QueuedPlaylist | null;
isLive: boolean; isLive: boolean;
addedInChannelId: Snowflake;
thumbnailUrl: string | null; thumbnailUrl: string | null;
source: MediaSource;
}
export interface QueuedSong extends SongMetadata {
addedInChannelId: Snowflake;
requestedBy: string; requestedBy: string;
} }
@ -106,7 +114,7 @@ export default class {
to = currentSong.length + currentSong.offset; 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({ this.audioPlayer = createAudioPlayer({
behaviors: { behaviors: {
// Needs to be somewhat high for livestreams // Needs to be somewhat high for livestreams
@ -171,7 +179,7 @@ export default class {
to = currentSong.length + currentSong.offset; 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({ this.audioPlayer = createAudioPlayer({
behaviors: { behaviors: {
// Needs to be somewhat high for livestreams // Needs to be somewhat high for livestreams
@ -365,7 +373,11 @@ export default class {
return hasha(url); 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 = ''; let ffmpegInput = '';
const ffmpegInputOptions: string[] = []; const ffmpegInputOptions: string[] = [];
let shouldCacheVideo = false; let shouldCacheVideo = false;
@ -373,7 +385,7 @@ export default class {
let format: ytdl.videoFormat | undefined; let format: ytdl.videoFormat | undefined;
try { try {
ffmpegInput = await this.fileCache.getPathFor(this.getHashForCache(url)); ffmpegInput = await this.fileCache.getPathFor(this.getHashForCache(song.url));
if (options.seek) { if (options.seek) {
ffmpegInputOptions.push('-ss', options.seek.toString()); ffmpegInputOptions.push('-ss', options.seek.toString());
@ -384,7 +396,7 @@ export default class {
} }
} catch { } catch {
// Not yet cached, must download // Not yet cached, must download
const info = await ytdl.getInfo(url); const info = await ytdl.getInfo(song.url);
const {formats} = info; const {formats} = info;
@ -444,36 +456,7 @@ export default class {
} }
} }
// Create stream and pipe to capacitor return this.createReadStream(ffmpegInput, {ffmpegInputOptions, cache: shouldCacheVideo});
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());
});
} }
private startTrackingPosition(initalPosition?: number): void { private startTrackingPosition(initalPosition?: number): void {
@ -524,4 +507,32 @@ export default class {
await this.forward(1); 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());
});
}
} }

View file

@ -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;
}
}

265
src/services/youtube-api.ts Normal file
View file

@ -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;
}
}

View file

@ -13,5 +13,7 @@ export const TYPES = {
Services: { Services: {
AddQueryToQueue: Symbol('AddQueryToQueue'), AddQueryToQueue: Symbol('AddQueryToQueue'),
GetSongs: Symbol('GetSongs'), GetSongs: Symbol('GetSongs'),
YoutubeAPI: Symbol('YoutubeAPI'),
SpotifyAPI: Symbol('SpotifyAPI'),
}, },
}; };

View file

@ -1,6 +1,6 @@
import getYouTubeID from 'get-youtube-id'; import getYouTubeID from 'get-youtube-id';
import {MessageEmbed} from 'discord.js'; 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 getProgressBar from './get-progress-bar.js';
import {prettyTime} from './time.js'; import {prettyTime} from './time.js';
import {truncate} from './string.js'; import {truncate} from './string.js';
@ -13,7 +13,11 @@ const getMaxSongTitleLength = (title: string) => {
return nonASCII.test(title) ? 28 : 48; 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 cleanSongTitle = title.replace(/\[.*\]/, '').trim();
const songTitle = shouldTruncate ? truncate(cleanSongTitle, getMaxSongTitleLength(cleanSongTitle)) : cleanSongTitle; const songTitle = shouldTruncate ? truncate(cleanSongTitle, getMaxSongTitleLength(cleanSongTitle)) : cleanSongTitle;
@ -92,7 +96,12 @@ export const buildQueueEmbed = (player: Player, page: number): MessageEmbed => {
const queuedSongs = player const queuedSongs = player
.getQueue() .getQueue()
.slice(queuePageBegin, queuePageEnd) .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'); .join('\n');
const {artist, thumbnailUrl, playlist, requestedBy} = currentlyPlaying; const {artist, thumbnailUrl, playlist, requestedBy} = currentlyPlaying;