aboutsummaryrefslogtreecommitdiff
path: root/src/services
diff options
context:
space:
mode:
authorHellyson Rodrigo Parteka <[email protected]>2022-03-09 23:47:52 -0300
committerGitHub <[email protected]>2022-03-09 20:47:52 -0600
commit3dd1f219457c56a3b182fa1919cf182ab780426c (patch)
tree7ab8bfdf96e94e66d8c556b196346e19ae210f33 /src/services
parentd438d46c09c236b34d5cecf75662ae94e7eca9cf (diff)
downloadmuse-3dd1f219457c56a3b182fa1919cf182ab780426c.tar.xz
muse-3dd1f219457c56a3b182fa1919cf182ab780426c.zip
Add `split` command (#363)
Co-authored-by: Max Isom <[email protected]> Co-authored-by: Max Isom <[email protected]>
Diffstat (limited to 'src/services')
-rw-r--r--src/services/add-query-to-queue.ts19
-rw-r--r--src/services/get-songs.ts177
-rw-r--r--src/services/player.ts34
3 files changed, 174 insertions, 56 deletions
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<void> {
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<QueuedSong, 'addedInChannelId' | 'requestedBy'>;
+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<SongMetadata> {
+ async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
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<SongMetadata> {
- const videoDetails = await this.cache.wrap(
+ async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
+ 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<SongMetadata[]> {
+ async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
// 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<Promise<void>> = [];
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<SongMetadata> {
- return this.youtubeVideoSearch(`"${track.name}" "${track.artists[0].name}"`);
+ 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;
}
}
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<Readable> {
+ private async getStream(url: string, options: {seek?: number; to?: number} = {}): Promise<Readable> {
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());
}
}