mirror of
https://github.com/BluemediaGER/muse.git
synced 2024-11-23 01:05:30 +01:00
Remove package youtube.ts (#902)
Co-authored-by: Max Isom <codetheweb@users.noreply.github.com>
This commit is contained in:
parent
3940ca7da0
commit
5de8b036cb
|
@ -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]
|
||||||
|
### Changed
|
||||||
|
- Removed youtube.ts package
|
||||||
|
|
||||||
## [2.2.1] - 2023-03-04
|
## [2.2.1] - 2023-03-04
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue