diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa99cb..663cf1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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 ### Changed diff --git a/src/commands/favorites.ts b/src/commands/favorites.ts index 4dbf943..31b1f79 100644 --- a/src/commands/favorites.ts +++ b/src/commands/favorites.ts @@ -24,7 +24,10 @@ export default class implements Command { .setDescription('add track to the front of the queue')) .addBooleanOption(option => option .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 .setName('list') .setDescription('list all favorites')) @@ -116,6 +119,7 @@ export default class implements Command { query: favorite.query, shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false, addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false, + shouldSplitChapters: interaction.options.getBoolean('split') ?? false, }); } diff --git a/src/commands/play.ts b/src/commands/play.ts index a1dd84b..ca23d61 100644 --- a/src/commands/play.ts +++ b/src/commands/play.ts @@ -26,7 +26,10 @@ export default class implements Command { .setDescription('add track to the front of the queue')) .addBooleanOption(option => option .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; @@ -49,6 +52,7 @@ export default class implements Command { query: query.trim(), addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false, shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false, + shouldSplitChapters: interaction.options.getBoolean('split') ?? false, }); } diff --git a/src/commands/skip.ts b/src/commands/skip.ts index a580775..eb2746a 100644 --- a/src/commands/skip.ts +++ b/src/commands/skip.ts @@ -25,7 +25,7 @@ export default class implements Command { } public async execute(interaction: CommandInteraction): Promise { - const numToSkip = interaction.options.getInteger('skip') ?? 1; + const numToSkip = interaction.options.getInteger('number') ?? 1; if (numToSkip < 1) { throw new Error('invalid number of songs to skip'); diff --git a/src/services/add-query-to-queue.ts b/src/services/add-query-to-queue.ts index 16d2f43..0ad6206 100644 --- a/src/services/add-query-to-queue.ts +++ b/src/services/add-query-to-queue.ts @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ import {CommandInteraction, GuildMember} from 'discord.js'; import {inject, injectable} from 'inversify'; import {Except} from 'type-fest'; @@ -18,11 +19,13 @@ export default class AddQueryToQueue { query, addToFrontOfQueue, shuffleAdditions, + shouldSplitChapters, interaction, }: { query: string; addToFrontOfQueue: boolean; shuffleAdditions: boolean; + shouldSplitChapters: boolean; interaction: CommandInteraction; }): Promise { const guildId = interaction.guild!.id; @@ -60,18 +63,18 @@ export default class AddQueryToQueue { // YouTube source if (url.searchParams.get('list')) { // YouTube playlist - newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list')!)); + newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list')!, shouldSplitChapters)); } else { - const song = await this.getSongs.youtubeVideo(url.href); + const songs = await this.getSongs.youtubeVideo(url.href, shouldSplitChapters); - if (song) { - newSongs.push(song); + if (songs) { + newSongs.push(...songs); } else { throw new Error('that doesn\'t exist'); } } } 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) { extraMsg = `a random sample of ${playlistLimit} songs was taken`; @@ -93,10 +96,10 @@ export default class AddQueryToQueue { } } catch (_: unknown) { // Not a URL, must search YouTube - const song = await this.getSongs.youtubeVideoSearch(query); + const songs = await this.getSongs.youtubeVideoSearch(query, shouldSplitChapters); - if (song) { - newSongs.push(song); + if (songs) { + newSongs.push(...songs); } else { throw new Error('that doesn\'t exist'); } diff --git a/src/services/get-songs.ts b/src/services/get-songs.ts index 33c3173..55a4215 100644 --- a/src/services/get-songs.ts +++ b/src/services/get-songs.ts @@ -5,7 +5,7 @@ import got from 'got'; import ytsr, {Video} from 'ytsr'; import spotifyURI from 'spotify-uri'; 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 shuffle from 'array-shuffle'; import {Except} from 'type-fest'; @@ -16,9 +16,18 @@ 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; + }; +} + @injectable() export default class { private readonly youtube: YouTube; @@ -40,7 +49,7 @@ export default class { this.ytsrQueue = new PQueue({concurrency: 4}); } - async youtubeVideoSearch(query: string): Promise { + async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise { const {items} = await this.ytsrQueue.add(async () => this.cache.wrap( ytsr, query, @@ -65,11 +74,11 @@ export default class { throw new Error('No video found.'); } - return this.youtubeVideo(firstVideo.id); + return this.youtubeVideo(firstVideo.id, shouldSplitChapters); } - async youtubeVideo(url: string): Promise { - const videoDetails = await this.cache.wrap( + async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise { + const video = await this.cache.wrap( this.youtube.videos.get, cleanUrl(url), { @@ -77,18 +86,10 @@ export default class { }, ); - return { - 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, - }; + return this.getMetadataFromVideo({video, shouldSplitChapters}); } - async youtubePlaylist(listId: string): Promise { + async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise { // YouTube playlist const playlist = await this.cache.wrap( this.youtube.playlists.get, @@ -98,14 +99,6 @@ export default class { }, ); - interface VideoDetailsResponse { - id: string; - contentDetails: { - videoId: string; - duration: string; - }; - } - const playlistVideos: YoutubePlaylistItem[] = []; const videoDetailsPromises: Array> = []; const videoDetails: VideoDetailsResponse[] = []; @@ -161,17 +154,12 @@ export default class { for (const video of playlistVideos) { try { - const length = toSeconds(parse(videoDetails.find((i: {id: string}) => i.id === video.contentDetails.videoId)!.contentDetails.duration)); - - songsToReturn.push({ - title: video.snippet.title, - artist: video.snippet.channelTitle, - length, - url: video.contentDetails.videoId, - playlist: queuedPlaylist, - isLive: false, - thumbnailUrl: video.snippet.thumbnails.medium.url, - }); + 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. } @@ -180,7 +168,7 @@ export default class { 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); let tracks: SpotifyApi.TrackObjectSimplified[] = []; @@ -253,17 +241,19 @@ export default class { 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; // Count songs that couldn't be found const songs: SongMetadata[] = searchResults.reduce((accum: SongMetadata[], result) => { if (result.status === 'fulfilled') { - accum.push({ - ...result.value, - ...(playlist ? {playlist} : {}), - }); + for (const v of result.value) { + accum.push({ + ...v, + ...(playlist ? {playlist} : {}), + }); + } } else { nSongsNotFound++; } @@ -274,7 +264,110 @@ export default class { return [songs, nSongsNotFound, originalNSongs]; } - private async spotifyToYouTube(track: SpotifyApi.TrackObjectSimplified): Promise { - return this.youtubeVideoSearch(`"${track.name}" "${track.artists[0].name}"`); + 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; } } diff --git a/src/services/player.ts b/src/services/player.ts index a18d753..138edfc 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -20,6 +20,7 @@ export interface QueuedSong { artist: string; url: string; length: number; + offset: number; playlist: QueuedPlaylist | null; isLive: boolean; addedInChannelId: Snowflake; @@ -98,7 +99,14 @@ export default class { 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({ behaviors: { // Needs to be somewhat high for livestreams @@ -156,7 +164,14 @@ export default class { } 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({ behaviors: { // Needs to be somewhat high for livestreams @@ -350,7 +365,7 @@ export default class { return hasha(url); } - private async getStream(url: string, options: {seek?: number} = {}): Promise { + private async getStream(url: string, options: {seek?: number; to?: number} = {}): Promise { let ffmpegInput = ''; const ffmpegInputOptions: string[] = []; let shouldCacheVideo = false; @@ -363,6 +378,10 @@ export default class { if (options.seek) { ffmpegInputOptions.push('-ss', options.seek.toString()); } + + if (options.to) { + ffmpegInputOptions.push('-to', options.to.toString()); + } } catch { // Not yet cached, must download const info = await ytdl.getInfo(url); @@ -405,7 +424,7 @@ export default class { // Don't cache livestreams or long videos 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(...[ '-reconnect', @@ -417,8 +436,11 @@ export default class { ]); if (options.seek) { - // Fudge seek position since FFMPEG doesn't do a great job - ffmpegInputOptions.push('-ss', (options.seek + 7).toString()); + ffmpegInputOptions.push('-ss', options.seek.toString()); + } + + if (options.to) { + ffmpegInputOptions.push('-to', options.to.toString()); } } diff --git a/src/utils/build-embed.ts b/src/utils/build-embed.ts index 5a2f758..cb9e634 100644 --- a/src/utils/build-embed.ts +++ b/src/utils/build-embed.ts @@ -13,13 +13,13 @@ const getMaxSongTitleLength = (title: string) => { 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 songTitle = shouldTruncate ? truncate(cleanSongTitle, getMaxSongTitleLength(cleanSongTitle)) : cleanSongTitle; 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) => {