mirror of
https://github.com/BluemediaGER/muse.git
synced 2024-11-23 01:05:30 +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).
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- Muse can now HTTP stream live audio files (see #396)
|
||||
|
||||
## [1.3.0] - 2022-03-09
|
||||
### Added
|
||||
|
|
|
@ -11,6 +11,8 @@ import PlayerManager from './managers/player.js';
|
|||
// Services
|
||||
import AddQueryToQueue from './services/add-query-to-queue.js';
|
||||
import GetSongs from './services/get-songs.js';
|
||||
import YoutubeAPI from './services/youtube-api.js';
|
||||
import SpotifyAPI from './services/spotify-api.js';
|
||||
|
||||
// Comands
|
||||
import Command from './commands';
|
||||
|
@ -53,6 +55,8 @@ container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSinglet
|
|||
// Services
|
||||
container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).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
|
||||
[
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
/* eslint-disable complexity */
|
||||
import {CommandInteraction, GuildMember} from 'discord.js';
|
||||
import {inject, injectable} from 'inversify';
|
||||
import {Except} from 'type-fest';
|
||||
import shuffle from 'array-shuffle';
|
||||
import {TYPES} from '../types.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 {prisma} from '../utils/db.js';
|
||||
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
|
||||
|
@ -44,7 +43,7 @@ export default class AddQueryToQueue {
|
|||
|
||||
await interaction.deferReply();
|
||||
|
||||
let newSongs: Array<Except<QueuedSong, 'addedInChannelId' | 'requestedBy'>> = [];
|
||||
let newSongs: SongMetadata[] = [];
|
||||
let extraMsg = '';
|
||||
|
||||
// Test if it's a complete URL
|
||||
|
@ -93,6 +92,14 @@ export default class AddQueryToQueue {
|
|||
}
|
||||
|
||||
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) {
|
||||
// Not a URL, must search YouTube
|
||||
|
|
|
@ -1,247 +1,90 @@
|
|||
import {URL} from 'url';
|
||||
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 Spotify from 'spotify-web-api-node';
|
||||
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 {SongMetadata, QueuedPlaylist, MediaSource} from '../services/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';
|
||||
|
||||
type SongMetadata = Except<QueuedSong, 'addedInChannelId' | 'requestedBy'>;
|
||||
|
||||
interface VideoDetailsResponse {
|
||||
id: string;
|
||||
contentDetails: {
|
||||
videoId: string;
|
||||
duration: string;
|
||||
};
|
||||
}
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import YoutubeAPI from './youtube-api.js';
|
||||
import SpotifyAPI, {SpotifyTrack} from './spotify-api.js';
|
||||
|
||||
@injectable()
|
||||
export default class {
|
||||
private readonly youtube: YouTube;
|
||||
private readonly youtubeKey: string;
|
||||
private readonly spotify: Spotify;
|
||||
private readonly cache: KeyValueCacheProvider;
|
||||
|
||||
private readonly ytsrQueue: PQueue;
|
||||
private readonly youtubeAPI: YoutubeAPI;
|
||||
private readonly spotifyAPI: SpotifyAPI;
|
||||
|
||||
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.spotify = thirdParty.spotify;
|
||||
this.cache = cache;
|
||||
|
||||
this.ytsrQueue = new PQueue({concurrency: 4});
|
||||
@inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI,
|
||||
@inject(TYPES.Services.SpotifyAPI) spotifyAPI: SpotifyAPI) {
|
||||
this.youtubeAPI = youtubeAPI;
|
||||
this.spotifyAPI = spotifyAPI;
|
||||
}
|
||||
|
||||
async youtubeVideoSearch(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.youtubeVideo(firstVideo.id, shouldSplitChapters);
|
||||
return this.youtubeAPI.search(query, shouldSplitChapters);
|
||||
}
|
||||
|
||||
async youtubeVideo(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});
|
||||
return this.youtubeAPI.getVideo(url, shouldSplitChapters);
|
||||
}
|
||||
|
||||
async youtubePlaylist(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;
|
||||
return this.youtubeAPI.getPlaylist(listId, shouldSplitChapters);
|
||||
}
|
||||
|
||||
async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> {
|
||||
const parsed = spotifyURI.parse(url);
|
||||
|
||||
let tracks: SpotifyApi.TrackObjectSimplified[] = [];
|
||||
|
||||
let playlist: QueuedPlaylist | null = null;
|
||||
|
||||
switch (parsed.type) {
|
||||
case 'album': {
|
||||
const uri = parsed as spotifyURI.Album;
|
||||
|
||||
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;
|
||||
const [tracks, playlist] = await this.spotifyAPI.getAlbum(url, playlistLimit);
|
||||
return this.spotifyToYouTube(tracks, shouldSplitChapters, playlist);
|
||||
}
|
||||
|
||||
case 'playlist': {
|
||||
const uri = parsed as spotifyURI.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;
|
||||
const [tracks, playlist] = await this.spotifyAPI.getPlaylist(url, playlistLimit);
|
||||
return this.spotifyToYouTube(tracks, shouldSplitChapters, playlist);
|
||||
}
|
||||
|
||||
case 'track': {
|
||||
const uri = parsed as spotifyURI.Track;
|
||||
|
||||
const {body} = await this.spotify.getTrack(uri.id);
|
||||
|
||||
tracks.push(body);
|
||||
break;
|
||||
const tracks = [await this.spotifyAPI.getTrack(url)];
|
||||
return this.spotifyToYouTube(tracks, shouldSplitChapters);
|
||||
}
|
||||
|
||||
case 'artist': {
|
||||
const uri = parsed as spotifyURI.Artist;
|
||||
|
||||
const {body} = await this.spotify.getArtistTopTracks(uri.id, 'US');
|
||||
|
||||
tracks.push(...body.tracks);
|
||||
break;
|
||||
const tracks = await this.spotifyAPI.getArtist(url, playlistLimit);
|
||||
return this.spotifyToYouTube(tracks, shouldSplitChapters);
|
||||
}
|
||||
|
||||
default: {
|
||||
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;
|
||||
|
||||
|
@ -261,113 +104,6 @@ export default class {
|
|||
return accum;
|
||||
}, []);
|
||||
|
||||
return [songs, nSongsNotFound, originalNSongs];
|
||||
}
|
||||
|
||||
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;
|
||||
return [songs, nSongsNotFound, tracks.length];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,12 +10,17 @@ import FileCacheProvider from './file-cache.js';
|
|||
import debug from '../utils/debug.js';
|
||||
import {prisma} from '../utils/db.js';
|
||||
|
||||
export enum MediaSource {
|
||||
Youtube,
|
||||
HLS,
|
||||
}
|
||||
|
||||
export interface QueuedPlaylist {
|
||||
title: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface QueuedSong {
|
||||
export interface SongMetadata {
|
||||
title: string;
|
||||
artist: string;
|
||||
url: string;
|
||||
|
@ -23,8 +28,11 @@ export interface QueuedSong {
|
|||
offset: number;
|
||||
playlist: QueuedPlaylist | null;
|
||||
isLive: boolean;
|
||||
addedInChannelId: Snowflake;
|
||||
thumbnailUrl: string | null;
|
||||
source: MediaSource;
|
||||
}
|
||||
export interface QueuedSong extends SongMetadata {
|
||||
addedInChannelId: Snowflake;
|
||||
requestedBy: string;
|
||||
}
|
||||
|
||||
|
@ -106,7 +114,7 @@ export default class {
|
|||
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({
|
||||
behaviors: {
|
||||
// Needs to be somewhat high for livestreams
|
||||
|
@ -171,7 +179,7 @@ export default class {
|
|||
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({
|
||||
behaviors: {
|
||||
// Needs to be somewhat high for livestreams
|
||||
|
@ -365,7 +373,11 @@ export default class {
|
|||
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 = '';
|
||||
const ffmpegInputOptions: string[] = [];
|
||||
let shouldCacheVideo = false;
|
||||
|
@ -373,7 +385,7 @@ export default class {
|
|||
let format: ytdl.videoFormat | undefined;
|
||||
|
||||
try {
|
||||
ffmpegInput = await this.fileCache.getPathFor(this.getHashForCache(url));
|
||||
ffmpegInput = await this.fileCache.getPathFor(this.getHashForCache(song.url));
|
||||
|
||||
if (options.seek) {
|
||||
ffmpegInputOptions.push('-ss', options.seek.toString());
|
||||
|
@ -384,7 +396,7 @@ export default class {
|
|||
}
|
||||
} catch {
|
||||
// Not yet cached, must download
|
||||
const info = await ytdl.getInfo(url);
|
||||
const info = await ytdl.getInfo(song.url);
|
||||
|
||||
const {formats} = info;
|
||||
|
||||
|
@ -444,36 +456,7 @@ export default class {
|
|||
}
|
||||
}
|
||||
|
||||
// Create stream and pipe to capacitor
|
||||
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());
|
||||
});
|
||||
return this.createReadStream(ffmpegInput, {ffmpegInputOptions, cache: shouldCacheVideo});
|
||||
}
|
||||
|
||||
private startTrackingPosition(initalPosition?: number): void {
|
||||
|
@ -524,4 +507,32 @@ export default class {
|
|||
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: {
|
||||
AddQueryToQueue: Symbol('AddQueryToQueue'),
|
||||
GetSongs: Symbol('GetSongs'),
|
||||
YoutubeAPI: Symbol('YoutubeAPI'),
|
||||
SpotifyAPI: Symbol('SpotifyAPI'),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import getYouTubeID from 'get-youtube-id';
|
||||
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 {prettyTime} from './time.js';
|
||||
import {truncate} from './string.js';
|
||||
|
@ -13,7 +13,11 @@ const getMaxSongTitleLength = (title: string) => {
|
|||
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 songTitle = shouldTruncate ? truncate(cleanSongTitle, getMaxSongTitleLength(cleanSongTitle)) : cleanSongTitle;
|
||||
|
@ -92,7 +96,12 @@ export const buildQueueEmbed = (player: Player, page: number): MessageEmbed => {
|
|||
const queuedSongs = player
|
||||
.getQueue()
|
||||
.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');
|
||||
|
||||
const {artist, thumbnailUrl, playlist, requestedBy} = currentlyPlaying;
|
||||
|
|
Loading…
Reference in a new issue