mirror of
https://github.com/BluemediaGER/muse.git
synced 2024-11-23 09:15:29 +01:00
Support for web streaming audio files (#550)
Co-authored-by: Max Isom <hi@maxisom.me>
This commit is contained in:
parent
eac12eaade
commit
6c118dc965
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
[
|
[
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
79
src/services/spotify-api.ts
Normal file
79
src/services/spotify-api.ts
Normal 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
265
src/services/youtube-api.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue