Merge branch 'codetheweb:master' into master

This commit is contained in:
Oliver Traber 2023-03-25 13:27:38 +01:00 committed by GitHub
commit d063f5c115
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 114 additions and 79 deletions

View file

@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.2.2] - 2023-03-18
### Changed
- Removed youtube.ts package
## [2.2.1] - 2023-03-04 ## [2.2.1] - 2023-03-04
### Fixed ### Fixed
- Fixed all lint errors - Fixed all lint errors
@ -225,7 +229,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Initial release - Initial release
[unreleased]: https://github.com/codetheweb/muse/compare/v2.2.1...HEAD [unreleased]: https://github.com/codetheweb/muse/compare/v2.2.2...HEAD
[2.2.2]: https://github.com/codetheweb/muse/compare/v2.2.1...v2.2.2
[2.2.1]: https://github.com/codetheweb/muse/compare/v2.2.0...v2.2.1 [2.2.1]: https://github.com/codetheweb/muse/compare/v2.2.0...v2.2.1
[2.2.0]: https://github.com/codetheweb/muse/compare/v2.1.9...v2.2.0 [2.2.0]: https://github.com/codetheweb/muse/compare/v2.1.9...v2.2.0
[2.1.9]: https://github.com/codetheweb/muse/compare/v2.1.8...v2.1.9 [2.1.9]: https://github.com/codetheweb/muse/compare/v2.1.8...v2.1.9

View file

@ -1,6 +1,6 @@
{ {
"name": "muse", "name": "muse",
"version": "2.2.1", "version": "2.2.2",
"description": "🎧 a self-hosted Discord music bot that doesn't suck ", "description": "🎧 a self-hosted Discord music bot that doesn't suck ",
"repository": "git@github.com:codetheweb/muse.git", "repository": "git@github.com:codetheweb/muse.git",
"author": "Max Isom <hi@maxisom.me>", "author": "Max Isom <hi@maxisom.me>",
@ -111,7 +111,6 @@
"sync-fetch": "^0.3.1", "sync-fetch": "^0.3.1",
"tsx": "3.8.2", "tsx": "3.8.2",
"xbytes": "^1.7.0", "xbytes": "^1.7.0",
"youtube.ts": "^0.2.9",
"ytdl-core": "^4.11.2", "ytdl-core": "^4.11.2",
"ytsr": "^3.8.0" "ytsr": "^3.8.0"
} }

View file

@ -1,21 +1,16 @@
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import SpotifyWebApi from 'spotify-web-api-node'; import SpotifyWebApi from 'spotify-web-api-node';
import Youtube from 'youtube.ts';
import pRetry from 'p-retry'; import pRetry from 'p-retry';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import Config from './config.js'; import Config from './config.js';
@injectable() @injectable()
export default class ThirdParty { export default class ThirdParty {
readonly youtube: Youtube;
readonly spotify: SpotifyWebApi; readonly spotify: SpotifyWebApi;
private spotifyTokenTimerId?: NodeJS.Timeout; private spotifyTokenTimerId?: NodeJS.Timeout;
constructor(@inject(TYPES.Config) config: Config) { constructor(@inject(TYPES.Config) config: Config) {
// Library is transpiled incorrectly
// eslint-disable-next-line
this.youtube = new ((Youtube as any).default)(config.YOUTUBE_API_KEY);
this.spotify = new SpotifyWebApi({ this.spotify = new SpotifyWebApi({
clientId: config.SPOTIFY_CLIENT_ID, clientId: config.SPOTIFY_CLIENT_ID,
clientSecret: config.SPOTIFY_CLIENT_SECRET, clientSecret: config.SPOTIFY_CLIENT_SECRET,

View file

@ -1,17 +1,15 @@
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import {toSeconds, parse} from 'iso8601-duration'; import {toSeconds, parse} from 'iso8601-duration';
import got from 'got'; import got, {Got} from 'got';
import ytsr, {Video} from 'ytsr'; import ytsr, {Video} from 'ytsr';
import YouTube, {YoutubePlaylistItem, YoutubeVideo} from 'youtube.ts';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js'; import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import {cleanUrl} from '../utils/url.js';
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'; import {parseTime} from '../utils/time.js';
import getYouTubeID from 'get-youtube-id';
interface VideoDetailsResponse { interface VideoDetailsResponse {
id: string; id: string;
@ -19,22 +17,60 @@ interface VideoDetailsResponse {
videoId: string; videoId: string;
duration: string; duration: string;
}; };
snippet: {
title: string;
channelTitle: string;
liveBroadcastContent: string;
description: string;
thumbnails: {
medium: {
url: string;
};
};
};
}
interface PlaylistResponse {
id: string;
contentDetails: {
itemCount: number;
};
snippet: {
title: string;
};
}
interface PlaylistItemsResponse {
items: PlaylistItem[];
nextPageToken?: string;
}
interface PlaylistItem {
id: string;
contentDetails: {
videoId: string;
};
} }
@injectable() @injectable()
export default class { export default class {
private readonly youtube: YouTube;
private readonly youtubeKey: string; private readonly youtubeKey: string;
private readonly cache: KeyValueCacheProvider; private readonly cache: KeyValueCacheProvider;
private readonly ytsrQueue: PQueue; private readonly ytsrQueue: PQueue;
private readonly got: Got;
constructor(@inject(TYPES.ThirdParty) thirdParty: ThirdParty, @inject(TYPES.Config) config: Config, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) { constructor(@inject(TYPES.Config) config: Config, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) {
this.youtube = thirdParty.youtube;
this.youtubeKey = config.YOUTUBE_API_KEY; this.youtubeKey = config.YOUTUBE_API_KEY;
this.cache = cache; this.cache = cache;
this.ytsrQueue = new PQueue({concurrency: 4}); this.ytsrQueue = new PQueue({concurrency: 4});
this.got = got.extend({
prefixUrl: 'https://www.googleapis.com/youtube/v3/',
searchParams: {
key: this.youtubeKey,
responseType: 'json',
},
});
} }
async search(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> { async search(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
@ -62,74 +98,73 @@ export default class {
throw new Error('No video found.'); throw new Error('No video found.');
} }
return this.getVideo(firstVideo.id, shouldSplitChapters); return this.getVideo(firstVideo.url, shouldSplitChapters);
} }
async getVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> { async getVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
const video = await this.cache.wrap( const result = await this.getVideosByID([String(getYouTubeID(url))]);
this.youtube.videos.get, const video = result.at(0);
cleanUrl(url),
{ if (!video) {
expiresIn: ONE_HOUR_IN_SECONDS, throw new Error('Video could not be found.');
}, }
);
return this.getMetadataFromVideo({video, shouldSplitChapters}); return this.getMetadataFromVideo({video, shouldSplitChapters});
} }
async getPlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> { async getPlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
// YouTube playlist const playlistParams = {
const playlist = await this.cache.wrap( searchParams: {
this.youtube.playlists.get, part: 'id, snippet, contentDetails',
listId, id: listId,
},
};
const {items: playlists} = await this.cache.wrap(
async () => this.got('playlists', playlistParams).json() as Promise<{items: PlaylistResponse[]}>,
playlistParams,
{ {
expiresIn: ONE_MINUTE_IN_SECONDS, expiresIn: ONE_MINUTE_IN_SECONDS,
}, },
); );
const playlistVideos: YoutubePlaylistItem[] = []; const playlist = playlists.at(0)!;
if (!playlist) {
throw new Error('Playlist could not be found.');
}
const playlistVideos: PlaylistItem[] = [];
const videoDetailsPromises: Array<Promise<void>> = []; const videoDetailsPromises: Array<Promise<void>> = [];
const videoDetails: VideoDetailsResponse[] = []; const videoDetails: VideoDetailsResponse[] = [];
let nextToken: string | undefined; let nextToken: string | undefined;
while (playlistVideos.length < playlist.contentDetails.itemCount) { while (playlistVideos.length < playlist.contentDetails.itemCount) {
const playlistItemsParams = {
searchParams: {
part: 'id, contentDetails',
playlistId: listId,
maxResults: '50',
pageToken: nextToken,
},
};
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const {items, nextPageToken} = await this.cache.wrap( const {items, nextPageToken} = await this.cache.wrap(
this.youtube.playlists.items, async () => this.got('playlistItems', playlistItemsParams).json() as Promise<PlaylistItemsResponse>,
listId, playlistItemsParams,
{maxResults: '50', pageToken: nextToken},
{ {
expiresIn: ONE_MINUTE_IN_SECONDS, expiresIn: ONE_MINUTE_IN_SECONDS,
}, },
); );
nextToken = nextPageToken; nextToken = nextPageToken;
playlistVideos.push(...items); playlistVideos.push(...items);
// Start fetching extra details about videos // Start fetching extra details about videos
// PlaylistItem misses some details, eg. if the video is a livestream
videoDetailsPromises.push((async () => { videoDetailsPromises.push((async () => {
// Unfortunately, package doesn't provide a method for this const videoDetailItems = await this.getVideosByID(items.map(item => item.contentDetails.videoId));
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); videoDetails.push(...videoDetailItems);
})()); })());
} }
@ -143,54 +178,37 @@ export default class {
for (const video of playlistVideos) { for (const video of playlistVideos) {
try { try {
songsToReturn.push(...this.getMetadataFromVideo({ songsToReturn.push(...this.getMetadataFromVideo({
video, video: videoDetails.find((i: {id: string}) => i.id === video.contentDetails.videoId)!,
queuedPlaylist, queuedPlaylist,
videoDetails: videoDetails.find((i: {id: string}) => i.id === video.contentDetails.videoId),
shouldSplitChapters, shouldSplitChapters,
})); }));
} 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.
} }
} }
return songsToReturn; 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({ private getMetadataFromVideo({
video, video,
queuedPlaylist, queuedPlaylist,
videoDetails,
shouldSplitChapters, shouldSplitChapters,
}: { }: {
video: YoutubeVideo | YoutubePlaylistItem; video: VideoDetailsResponse; // | YoutubePlaylistItem;
queuedPlaylist?: QueuedPlaylist; queuedPlaylist?: QueuedPlaylist;
videoDetails?: VideoDetailsResponse;
shouldSplitChapters?: boolean; shouldSplitChapters?: boolean;
}): SongMetadata[] { }): 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 = { const base: SongMetadata = {
source: MediaSource.Youtube, source: MediaSource.Youtube,
title: video.snippet.title, title: video.snippet.title,
artist: video.snippet.channelTitle, artist: video.snippet.channelTitle,
length: videoDurationSeconds, length: toSeconds(parse(video.contentDetails.duration)),
offset: 0, offset: 0,
url, url: video.id,
playlist: queuedPlaylist ?? null, playlist: queuedPlaylist ?? null,
isLive: (video as YoutubeVideo).snippet.liveBroadcastContent === 'live', isLive: video.snippet.liveBroadcastContent === 'live',
thumbnailUrl: video.snippet.thumbnails.medium.url, thumbnailUrl: video.snippet.thumbnails.medium.url,
}; };
@ -198,7 +216,7 @@ export default class {
return [base]; return [base];
} }
const chapters = this.parseChaptersFromDescription(video.snippet.description, videoDurationSeconds); const chapters = this.parseChaptersFromDescription(video.snippet.description, base.length);
if (!chapters) { if (!chapters) {
return [base]; return [base];
@ -259,4 +277,22 @@ export default class {
return map; return map;
} }
private async getVideosByID(videoIDs: string[]): Promise<VideoDetailsResponse[]> {
const p = {
searchParams: {
part: 'id, snippet, contentDetails',
id: videoIDs.join(','),
},
};
const {items: videos} = await this.cache.wrap(
async () => this.got('videos', p).json() as Promise<{items: VideoDetailsResponse[]}>,
p,
{
expiresIn: ONE_HOUR_IN_SECONDS,
},
);
return videos;
}
} }