Add split command (#363)

Co-authored-by: Max Isom <hi@maxisom.me>
Co-authored-by: Max Isom <codetheweb@users.noreply.github.com>
This commit is contained in:
Hellyson Rodrigo Parteka 2022-03-09 23:47:52 -03:00 committed by GitHub
parent d438d46c09
commit 3dd1f21945
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 188 additions and 61 deletions

View file

@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- `/play` has a new `split` option that will split queued YouTube videos into chapters, if the video has them
- `/resume` command to resume playback - `/resume` command to resume playback
### Changed ### Changed

View file

@ -24,7 +24,10 @@ export default class implements Command {
.setDescription('add track to the front of the queue')) .setDescription('add track to the front of the queue'))
.addBooleanOption(option => option .addBooleanOption(option => option
.setName('shuffle') .setName('shuffle')
.setDescription('shuffle the input if you\'re adding multiple tracks'))) .setDescription('shuffle the input if you\'re adding multiple tracks'))
.addBooleanOption(option => option
.setName('split')
.setDescription('if a track has chapters, split it')))
.addSubcommand(subcommand => subcommand .addSubcommand(subcommand => subcommand
.setName('list') .setName('list')
.setDescription('list all favorites')) .setDescription('list all favorites'))
@ -116,6 +119,7 @@ export default class implements Command {
query: favorite.query, query: favorite.query,
shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false, shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false,
addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false, addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false,
shouldSplitChapters: interaction.options.getBoolean('split') ?? false,
}); });
} }

View file

@ -26,7 +26,10 @@ export default class implements Command {
.setDescription('add track to the front of the queue')) .setDescription('add track to the front of the queue'))
.addBooleanOption(option => option .addBooleanOption(option => option
.setName('shuffle') .setName('shuffle')
.setDescription('shuffle the input if you\'re adding multiple tracks')); .setDescription('shuffle the input if you\'re adding multiple tracks'))
.addBooleanOption(option => option
.setName('split')
.setDescription('if a track has chapters, split it'));
public requiresVC = true; public requiresVC = true;
@ -49,6 +52,7 @@ export default class implements Command {
query: query.trim(), query: query.trim(),
addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false, addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false,
shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false, shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false,
shouldSplitChapters: interaction.options.getBoolean('split') ?? false,
}); });
} }

View file

@ -25,7 +25,7 @@ export default class implements Command {
} }
public async execute(interaction: CommandInteraction): Promise<void> { public async execute(interaction: CommandInteraction): Promise<void> {
const numToSkip = interaction.options.getInteger('skip') ?? 1; const numToSkip = interaction.options.getInteger('number') ?? 1;
if (numToSkip < 1) { if (numToSkip < 1) {
throw new Error('invalid number of songs to skip'); throw new Error('invalid number of songs to skip');

View file

@ -1,3 +1,4 @@
/* 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 {Except} from 'type-fest';
@ -18,11 +19,13 @@ export default class AddQueryToQueue {
query, query,
addToFrontOfQueue, addToFrontOfQueue,
shuffleAdditions, shuffleAdditions,
shouldSplitChapters,
interaction, interaction,
}: { }: {
query: string; query: string;
addToFrontOfQueue: boolean; addToFrontOfQueue: boolean;
shuffleAdditions: boolean; shuffleAdditions: boolean;
shouldSplitChapters: boolean;
interaction: CommandInteraction; interaction: CommandInteraction;
}): Promise<void> { }): Promise<void> {
const guildId = interaction.guild!.id; const guildId = interaction.guild!.id;
@ -60,18 +63,18 @@ export default class AddQueryToQueue {
// YouTube source // YouTube source
if (url.searchParams.get('list')) { if (url.searchParams.get('list')) {
// YouTube playlist // YouTube playlist
newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list')!)); newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list')!, shouldSplitChapters));
} else { } else {
const song = await this.getSongs.youtubeVideo(url.href); const songs = await this.getSongs.youtubeVideo(url.href, shouldSplitChapters);
if (song) { if (songs) {
newSongs.push(song); newSongs.push(...songs);
} else { } else {
throw new Error('that doesn\'t exist'); throw new Error('that doesn\'t exist');
} }
} }
} else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') { } else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
const [convertedSongs, nSongsNotFound, totalSongs] = await this.getSongs.spotifySource(query, playlistLimit); const [convertedSongs, nSongsNotFound, totalSongs] = await this.getSongs.spotifySource(query, playlistLimit, shouldSplitChapters);
if (totalSongs > playlistLimit) { if (totalSongs > playlistLimit) {
extraMsg = `a random sample of ${playlistLimit} songs was taken`; extraMsg = `a random sample of ${playlistLimit} songs was taken`;
@ -93,10 +96,10 @@ export default class AddQueryToQueue {
} }
} catch (_: unknown) { } catch (_: unknown) {
// Not a URL, must search YouTube // Not a URL, must search YouTube
const song = await this.getSongs.youtubeVideoSearch(query); const songs = await this.getSongs.youtubeVideoSearch(query, shouldSplitChapters);
if (song) { if (songs) {
newSongs.push(song); newSongs.push(...songs);
} else { } else {
throw new Error('that doesn\'t exist'); throw new Error('that doesn\'t exist');
} }

View file

@ -5,7 +5,7 @@ import got from 'got';
import ytsr, {Video} from 'ytsr'; import ytsr, {Video} from 'ytsr';
import spotifyURI from 'spotify-uri'; import spotifyURI from 'spotify-uri';
import Spotify from 'spotify-web-api-node'; import Spotify from 'spotify-web-api-node';
import YouTube, {YoutubePlaylistItem} from 'youtube.ts'; import YouTube, {YoutubePlaylistItem, YoutubeVideo} from 'youtube.ts';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import shuffle from 'array-shuffle'; import shuffle from 'array-shuffle';
import {Except} from 'type-fest'; import {Except} from 'type-fest';
@ -16,9 +16,18 @@ import ThirdParty from './third-party.js';
import Config from './config.js'; import Config from './config.js';
import KeyValueCacheProvider from './key-value-cache.js'; import KeyValueCacheProvider from './key-value-cache.js';
import {ONE_HOUR_IN_SECONDS, ONE_MINUTE_IN_SECONDS} from '../utils/constants.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'>; 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 youtube: YouTube;
@ -40,7 +49,7 @@ export default class {
this.ytsrQueue = new PQueue({concurrency: 4}); this.ytsrQueue = new PQueue({concurrency: 4});
} }
async youtubeVideoSearch(query: string): Promise<SongMetadata> { async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
const {items} = await this.ytsrQueue.add(async () => this.cache.wrap( const {items} = await this.ytsrQueue.add(async () => this.cache.wrap(
ytsr, ytsr,
query, query,
@ -65,11 +74,11 @@ export default class {
throw new Error('No video found.'); throw new Error('No video found.');
} }
return this.youtubeVideo(firstVideo.id); return this.youtubeVideo(firstVideo.id, shouldSplitChapters);
} }
async youtubeVideo(url: string): Promise<SongMetadata> { async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
const videoDetails = await this.cache.wrap( const video = await this.cache.wrap(
this.youtube.videos.get, this.youtube.videos.get,
cleanUrl(url), cleanUrl(url),
{ {
@ -77,18 +86,10 @@ export default class {
}, },
); );
return { return this.getMetadataFromVideo({video, shouldSplitChapters});
title: videoDetails.snippet.title,
artist: videoDetails.snippet.channelTitle,
length: toSeconds(parse(videoDetails.contentDetails.duration)),
url: videoDetails.id,
playlist: null,
isLive: videoDetails.snippet.liveBroadcastContent === 'live',
thumbnailUrl: videoDetails.snippet.thumbnails.medium.url,
};
} }
async youtubePlaylist(listId: string): Promise<SongMetadata[]> { async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
// YouTube playlist // YouTube playlist
const playlist = await this.cache.wrap( const playlist = await this.cache.wrap(
this.youtube.playlists.get, this.youtube.playlists.get,
@ -98,14 +99,6 @@ export default class {
}, },
); );
interface VideoDetailsResponse {
id: string;
contentDetails: {
videoId: string;
duration: string;
};
}
const playlistVideos: YoutubePlaylistItem[] = []; const playlistVideos: YoutubePlaylistItem[] = [];
const videoDetailsPromises: Array<Promise<void>> = []; const videoDetailsPromises: Array<Promise<void>> = [];
const videoDetails: VideoDetailsResponse[] = []; const videoDetails: VideoDetailsResponse[] = [];
@ -161,17 +154,12 @@ export default class {
for (const video of playlistVideos) { for (const video of playlistVideos) {
try { try {
const length = toSeconds(parse(videoDetails.find((i: {id: string}) => i.id === video.contentDetails.videoId)!.contentDetails.duration)); songsToReturn.push(...this.getMetadataFromVideo({
video,
songsToReturn.push({ queuedPlaylist,
title: video.snippet.title, videoDetails: videoDetails.find((i: {id: string}) => i.id === video.contentDetails.videoId),
artist: video.snippet.channelTitle, shouldSplitChapters,
length, }));
url: video.contentDetails.videoId,
playlist: queuedPlaylist,
isLive: false,
thumbnailUrl: video.snippet.thumbnails.medium.url,
});
} catch (_: unknown) { } 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. // Private and deleted videos are sometimes in playlists, duration of these is not returned and they should not be added to the queue.
} }
@ -180,7 +168,7 @@ export default class {
return songsToReturn; return songsToReturn;
} }
async spotifySource(url: string, playlistLimit: number): 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 tracks: SpotifyApi.TrackObjectSimplified[] = [];
@ -253,17 +241,19 @@ export default class {
tracks = shuffled.slice(0, playlistLimit); tracks = shuffled.slice(0, playlistLimit);
} }
const searchResults = await Promise.allSettled(tracks.map(async track => this.spotifyToYouTube(track))); const searchResults = await Promise.allSettled(tracks.map(async track => this.spotifyToYouTube(track, shouldSplitChapters)));
let nSongsNotFound = 0; let nSongsNotFound = 0;
// Count songs that couldn't be found // Count songs that couldn't be found
const songs: SongMetadata[] = searchResults.reduce((accum: SongMetadata[], result) => { const songs: SongMetadata[] = searchResults.reduce((accum: SongMetadata[], result) => {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
accum.push({ for (const v of result.value) {
...result.value, accum.push({
...(playlist ? {playlist} : {}), ...v,
}); ...(playlist ? {playlist} : {}),
});
}
} else { } else {
nSongsNotFound++; nSongsNotFound++;
} }
@ -274,7 +264,110 @@ export default class {
return [songs, nSongsNotFound, originalNSongs]; return [songs, nSongsNotFound, originalNSongs];
} }
private async spotifyToYouTube(track: SpotifyApi.TrackObjectSimplified): Promise<SongMetadata> { private async spotifyToYouTube(track: SpotifyApi.TrackObjectSimplified, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
return this.youtubeVideoSearch(`"${track.name}" "${track.artists[0].name}"`); 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

@ -20,6 +20,7 @@ export interface QueuedSong {
artist: string; artist: string;
url: string; url: string;
length: number; length: number;
offset: number;
playlist: QueuedPlaylist | null; playlist: QueuedPlaylist | null;
isLive: boolean; isLive: boolean;
addedInChannelId: Snowflake; addedInChannelId: Snowflake;
@ -98,7 +99,14 @@ export default class {
throw new Error('Seek position is outside the range of the song.'); throw new Error('Seek position is outside the range of the song.');
} }
const stream = await this.getStream(currentSong.url, {seek: positionSeconds}); let realPositionSeconds = positionSeconds;
let to: number | undefined;
if (currentSong.offset !== undefined) {
realPositionSeconds += currentSong.offset;
to = currentSong.length + currentSong.offset;
}
const stream = await this.getStream(currentSong.url, {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
@ -156,7 +164,14 @@ export default class {
} }
try { try {
const stream = await this.getStream(currentSong.url); let positionSeconds: number | undefined;
let to: number | undefined;
if (currentSong.offset !== undefined) {
positionSeconds = currentSong.offset;
to = currentSong.length + currentSong.offset;
}
const stream = await this.getStream(currentSong.url, {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
@ -350,7 +365,7 @@ export default class {
return hasha(url); return hasha(url);
} }
private async getStream(url: string, options: {seek?: number} = {}): Promise<Readable> { private async getStream(url: string, options: {seek?: number; to?: number} = {}): Promise<Readable> {
let ffmpegInput = ''; let ffmpegInput = '';
const ffmpegInputOptions: string[] = []; const ffmpegInputOptions: string[] = [];
let shouldCacheVideo = false; let shouldCacheVideo = false;
@ -363,6 +378,10 @@ export default class {
if (options.seek) { if (options.seek) {
ffmpegInputOptions.push('-ss', options.seek.toString()); ffmpegInputOptions.push('-ss', options.seek.toString());
} }
if (options.to) {
ffmpegInputOptions.push('-to', options.to.toString());
}
} catch { } catch {
// Not yet cached, must download // Not yet cached, must download
const info = await ytdl.getInfo(url); const info = await ytdl.getInfo(url);
@ -405,7 +424,7 @@ export default class {
// Don't cache livestreams or long videos // Don't cache livestreams or long videos
const MAX_CACHE_LENGTH_SECONDS = 30 * 60; // 30 minutes const MAX_CACHE_LENGTH_SECONDS = 30 * 60; // 30 minutes
shouldCacheVideo = !info.player_response.videoDetails.isLiveContent && parseInt(info.videoDetails.lengthSeconds, 10) < MAX_CACHE_LENGTH_SECONDS && !options.seek; shouldCacheVideo = !info.player_response.videoDetails.isLiveContent && parseInt(info.videoDetails.lengthSeconds, 10) < MAX_CACHE_LENGTH_SECONDS && !options.seek && !options.to;
ffmpegInputOptions.push(...[ ffmpegInputOptions.push(...[
'-reconnect', '-reconnect',
@ -417,8 +436,11 @@ export default class {
]); ]);
if (options.seek) { if (options.seek) {
// Fudge seek position since FFMPEG doesn't do a great job ffmpegInputOptions.push('-ss', options.seek.toString());
ffmpegInputOptions.push('-ss', (options.seek + 7).toString()); }
if (options.to) {
ffmpegInputOptions.push('-to', options.to.toString());
} }
} }

View file

@ -13,13 +13,13 @@ const getMaxSongTitleLength = (title: string) => {
return nonASCII.test(title) ? 28 : 48; return nonASCII.test(title) ? 28 : 48;
}; };
const getSongTitle = ({title, url}: QueuedSong, shouldTruncate = false) => { const getSongTitle = ({title, url, offset}: QueuedSong, shouldTruncate = false) => {
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;
const youtubeId = url.length === 11 ? url : getYouTubeID(url) ?? ''; const youtubeId = url.length === 11 ? url : getYouTubeID(url) ?? '';
return `[${songTitle}](https://www.youtube.com/watch?v=${youtubeId})`; return `[${songTitle}](https://www.youtube.com/watch?v=${youtubeId}${offset === 0 ? '' : '&t=' + String(offset)})`;
}; };
const getQueueInfo = (player: Player) => { const getQueueInfo = (player: Player) => {