mirror of
https://github.com/BluemediaGER/muse.git
synced 2024-11-10 03:55:29 +01:00
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:
parent
d438d46c09
commit
3dd1f21945
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
Loading…
Reference in a new issue