mirror of
https://github.com/BluemediaDev/muse.git
synced 2025-06-27 17:22:42 +02:00
Revert "Merge branch 'master' into dependencies/ytdl-core/upgrade/4-15-9"
This reverts commitb1a3ae3aaa
, reversing changes made toebb5803653
.
This commit is contained in:
parent
b1a3ae3aaa
commit
0fcd6d0a40
1 changed files with 141 additions and 293 deletions
|
@ -1,10 +1,8 @@
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import {toSeconds, parse} from 'iso8601-duration';
|
import {toSeconds, parse} from 'iso8601-duration';
|
||||||
import got, {Got, HTTPError, RequestError} from 'got';
|
import got, {Got} from 'got';
|
||||||
import ytsr, {Video} from '@distube/ytsr';
|
import ytsr, {Video} from '@distube/ytsr';
|
||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import pRetry from 'p-retry';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
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 Config from './config.js';
|
import Config from './config.js';
|
||||||
|
@ -12,19 +10,6 @@ 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';
|
import getYouTubeID from 'get-youtube-id';
|
||||||
import debug from '../utils/debug.js';
|
|
||||||
|
|
||||||
// Define structured error type for better error handling
|
|
||||||
interface YouTubeError extends Error {
|
|
||||||
code: 'QUOTA_EXCEEDED' | 'RATE_LIMITED' | 'NOT_FOUND' | 'NETWORK_ERROR' | 'UNKNOWN';
|
|
||||||
status?: number;
|
|
||||||
retryable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const YOUTUBE_MAX_RETRY_COUNT = 3;
|
|
||||||
const YOUTUBE_BASE_RETRY_DELAY_MS = 1000;
|
|
||||||
const YOUTUBE_SEARCH_CONCURRENCY = 4;
|
|
||||||
const MAX_CACHE_KEY_LENGTH = 250;
|
|
||||||
|
|
||||||
interface VideoDetailsResponse {
|
interface VideoDetailsResponse {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -74,13 +59,10 @@ export default class {
|
||||||
private readonly ytsrQueue: PQueue;
|
private readonly ytsrQueue: PQueue;
|
||||||
private readonly got: Got;
|
private readonly got: Got;
|
||||||
|
|
||||||
constructor(
|
constructor(@inject(TYPES.Config) config: Config, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) {
|
||||||
@inject(TYPES.Config) config: Config,
|
|
||||||
@inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider,
|
|
||||||
) {
|
|
||||||
this.youtubeKey = config.YOUTUBE_API_KEY;
|
this.youtubeKey = config.YOUTUBE_API_KEY;
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
this.ytsrQueue = new PQueue({concurrency: YOUTUBE_SEARCH_CONCURRENCY});
|
this.ytsrQueue = new PQueue({concurrency: 4});
|
||||||
|
|
||||||
this.got = got.extend({
|
this.got = got.extend({
|
||||||
prefixUrl: 'https://www.googleapis.com/youtube/v3/',
|
prefixUrl: 'https://www.googleapis.com/youtube/v3/',
|
||||||
|
@ -91,10 +73,8 @@ export default class {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
|
async search(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
|
||||||
try {
|
const result = await this.ytsrQueue.add<ytsr.VideoResult>(async () => this.cache.wrap(
|
||||||
const {items} = await this.ytsrQueue.add(async () =>
|
|
||||||
this.cache.wrap(
|
|
||||||
ytsr,
|
ytsr,
|
||||||
query,
|
query,
|
||||||
{
|
{
|
||||||
|
@ -102,13 +82,16 @@ export default class {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
expiresIn: ONE_HOUR_IN_SECONDS,
|
expiresIn: ONE_HOUR_IN_SECONDS,
|
||||||
key: this.createCacheKey('youtube-search', query),
|
|
||||||
},
|
},
|
||||||
),
|
));
|
||||||
);
|
|
||||||
|
if (result === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
let firstVideo: Video | undefined;
|
let firstVideo: Video | undefined;
|
||||||
for (const item of items) {
|
|
||||||
|
for (const item of result.items) {
|
||||||
if (item.type === 'video') {
|
if (item.type === 'video') {
|
||||||
firstVideo = item;
|
firstVideo = item;
|
||||||
break;
|
break;
|
||||||
|
@ -116,258 +99,100 @@ export default class {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!firstVideo) {
|
if (!firstVideo) {
|
||||||
throw new Error('No matching videos found.');
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.getVideo(firstVideo.url, shouldSplitChapters);
|
return this.getVideo(firstVideo.url, shouldSplitChapters);
|
||||||
} catch (error) {
|
|
||||||
debug('YouTube search error:', error);
|
|
||||||
throw new Error('Failed to search YouTube. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
|
async getVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
|
||||||
const videoId = getYouTubeID(url);
|
const result = await this.getVideosByID([String(getYouTubeID(url))]);
|
||||||
if (!videoId) {
|
|
||||||
throw new Error('Invalid YouTube URL.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.getVideosByID([videoId]);
|
|
||||||
const video = result.at(0);
|
const video = result.at(0);
|
||||||
|
|
||||||
if (!video) {
|
if (!video) {
|
||||||
throw new Error('Video could not be found or is unavailable.');
|
throw new Error('Video could not be found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getMetadataFromVideo({video, shouldSplitChapters});
|
return this.getMetadataFromVideo({video, shouldSplitChapters});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
|
async getPlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
|
||||||
try {
|
|
||||||
const playlistParams = {
|
const playlistParams = {
|
||||||
searchParams: {
|
searchParams: {
|
||||||
part: 'id, snippet, contentDetails',
|
part: 'id, snippet, contentDetails',
|
||||||
id: listId,
|
id: listId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const {items: playlists} = await this.cache.wrap(
|
const {items: playlists} = await this.cache.wrap(
|
||||||
async () => this.executeYouTubeRequest<{items: PlaylistResponse[]}>('playlists', playlistParams),
|
async () => this.got('playlists', playlistParams).json() as Promise<{items: PlaylistResponse[]}>,
|
||||||
playlistParams,
|
playlistParams,
|
||||||
{
|
{
|
||||||
expiresIn: ONE_MINUTE_IN_SECONDS,
|
expiresIn: ONE_MINUTE_IN_SECONDS,
|
||||||
key: this.createCacheKey('youtube-playlist', listId),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const playlist = playlists.at(0);
|
const playlist = playlists.at(0)!;
|
||||||
|
|
||||||
if (!playlist) {
|
if (!playlist) {
|
||||||
throw new Error('Playlist could not be found.');
|
throw new Error('Playlist could not be found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to fetch a single page of playlist items
|
const playlistVideos: PlaylistItem[] = [];
|
||||||
const fetchPlaylistPage = async (token?: string) => {
|
const videoDetailsPromises: Array<Promise<void>> = [];
|
||||||
|
const videoDetails: VideoDetailsResponse[] = [];
|
||||||
|
|
||||||
|
let nextToken: string | undefined;
|
||||||
|
|
||||||
|
while (playlistVideos.length < playlist.contentDetails.itemCount) {
|
||||||
const playlistItemsParams = {
|
const playlistItemsParams = {
|
||||||
searchParams: {
|
searchParams: {
|
||||||
part: 'id, contentDetails',
|
part: 'id, contentDetails',
|
||||||
playlistId: listId,
|
playlistId: listId,
|
||||||
maxResults: '50',
|
maxResults: '50',
|
||||||
pageToken: token,
|
pageToken: nextToken,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.cache.wrap(
|
// eslint-disable-next-line no-await-in-loop
|
||||||
async () => this.executeYouTubeRequest<PlaylistItemsResponse>('playlistItems', playlistItemsParams),
|
const {items, nextPageToken} = await this.cache.wrap(
|
||||||
|
async () => this.got('playlistItems', playlistItemsParams).json() as Promise<PlaylistItemsResponse>,
|
||||||
playlistItemsParams,
|
playlistItemsParams,
|
||||||
{
|
{
|
||||||
expiresIn: ONE_MINUTE_IN_SECONDS,
|
expiresIn: ONE_MINUTE_IN_SECONDS,
|
||||||
key: this.createCacheKey('youtube-playlist-items', `${listId}-${token ?? 'initial'}`),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
// Recursively fetch all playlist pages
|
nextToken = nextPageToken;
|
||||||
const fetchAllPages = async (token?: string): Promise<PlaylistItem[]> => {
|
playlistVideos.push(...items);
|
||||||
const {items, nextPageToken} = await fetchPlaylistPage(token);
|
|
||||||
if (!nextPageToken || items.length >= playlist.contentDetails.itemCount) {
|
// Start fetching extra details about videos
|
||||||
return items;
|
// PlaylistItem misses some details, eg. if the video is a livestream
|
||||||
|
videoDetailsPromises.push((async () => {
|
||||||
|
const videoDetailItems = await this.getVideosByID(items.map(item => item.contentDetails.videoId));
|
||||||
|
videoDetails.push(...videoDetailItems);
|
||||||
|
})());
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextItems = await fetchAllPages(nextPageToken);
|
await Promise.all(videoDetailsPromises);
|
||||||
return [...items, ...nextItems];
|
|
||||||
};
|
|
||||||
|
|
||||||
const playlistVideos = await fetchAllPages();
|
|
||||||
|
|
||||||
const videoDetailPromises = playlistVideos.map(async item =>
|
|
||||||
this.getVideosByID([item.contentDetails.videoId]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoDetailChunks = await Promise.all(videoDetailPromises);
|
|
||||||
const videoDetails = videoDetailChunks.flat();
|
|
||||||
|
|
||||||
const queuedPlaylist = {title: playlist.snippet.title, source: playlist.id};
|
const queuedPlaylist = {title: playlist.snippet.title, source: playlist.id};
|
||||||
|
|
||||||
const songsToReturn: SongMetadata[] = [];
|
const songsToReturn: SongMetadata[] = [];
|
||||||
|
|
||||||
for (const video of playlistVideos) {
|
for (const video of playlistVideos) {
|
||||||
try {
|
try {
|
||||||
const videoDetail = videoDetails.find(i => i.id === video.contentDetails.videoId);
|
songsToReturn.push(...this.getMetadataFromVideo({
|
||||||
if (videoDetail) {
|
video: videoDetails.find((i: {id: string}) => i.id === video.contentDetails.videoId)!,
|
||||||
songsToReturn.push(
|
|
||||||
...this.getMetadataFromVideo({
|
|
||||||
video: videoDetail,
|
|
||||||
queuedPlaylist,
|
queuedPlaylist,
|
||||||
shouldSplitChapters,
|
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.
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
debug(`Skipping unavailable video in playlist: ${video.contentDetails.videoId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (songsToReturn.length === 0) {
|
|
||||||
throw new Error('No playable videos found in this playlist.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return songsToReturn;
|
return songsToReturn;
|
||||||
} catch (error) {
|
|
||||||
debug('Playlist processing error:', error);
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Failed to process playlist. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createYouTubeError(message: string, code: YouTubeError['code'], status?: number): YouTubeError {
|
|
||||||
const error = new Error(message) as YouTubeError;
|
|
||||||
error.code = code;
|
|
||||||
error.status = status;
|
|
||||||
error.retryable = code === 'NETWORK_ERROR' || (status ? status >= 500 : false);
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createCacheKey(prefix: string, key: string): string {
|
|
||||||
const fullKey = `${prefix}-${key}`;
|
|
||||||
if (fullKey.length <= MAX_CACHE_KEY_LENGTH) {
|
|
||||||
return fullKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hash = crypto.createHash('sha1').update(key).digest('hex');
|
|
||||||
return `${prefix}-${key.slice(0, MAX_CACHE_KEY_LENGTH - prefix.length - 41)}-${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async executeYouTubeRequest<T>(endpoint: string, params: any): Promise<T> {
|
|
||||||
return pRetry(
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const response = (await this.got(endpoint, params).json()) as T;
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw this.createYouTubeError('Empty response from YouTube API', 'NETWORK_ERROR');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof HTTPError) {
|
|
||||||
const status = error.response.statusCode;
|
|
||||||
|
|
||||||
switch (status) {
|
|
||||||
case 403:
|
|
||||||
throw this.createYouTubeError(
|
|
||||||
'YouTube API quota exceeded. Please try again later.',
|
|
||||||
'QUOTA_EXCEEDED',
|
|
||||||
status,
|
|
||||||
);
|
|
||||||
case 429:
|
|
||||||
throw this.createYouTubeError(
|
|
||||||
'YouTube API rate limit reached. Please try again later.',
|
|
||||||
'RATE_LIMITED',
|
|
||||||
status,
|
|
||||||
);
|
|
||||||
case 404:
|
|
||||||
throw this.createYouTubeError('Resource not found on YouTube.', 'NOT_FOUND', status);
|
|
||||||
default:
|
|
||||||
if (status >= 500) {
|
|
||||||
throw this.createYouTubeError('YouTube API is temporarily unavailable.', 'NETWORK_ERROR', status);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw this.createYouTubeError('YouTube API request failed.', 'UNKNOWN', status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof RequestError && error.code === 'ETIMEDOUT') {
|
|
||||||
throw this.createYouTubeError('YouTube API request timed out.', 'NETWORK_ERROR');
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
retries: YOUTUBE_MAX_RETRY_COUNT,
|
|
||||||
minTimeout: YOUTUBE_BASE_RETRY_DELAY_MS,
|
|
||||||
factor: 2,
|
|
||||||
randomize: true,
|
|
||||||
onFailedAttempt: error => {
|
|
||||||
const youTubeError
|
|
||||||
= error.message && typeof error.message === 'object' && 'code' in error.message
|
|
||||||
? (error.message as YouTubeError)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
debug(
|
|
||||||
[
|
|
||||||
`YouTube API request failed (attempt ${error.attemptNumber}/${YOUTUBE_MAX_RETRY_COUNT + 1})`,
|
|
||||||
`Error code: ${youTubeError?.code ?? 'UNKNOWN'}`,
|
|
||||||
`Status: ${youTubeError?.status ?? 'N/A'}`,
|
|
||||||
`Message: ${error.message}`,
|
|
||||||
`Retries left: ${error.retriesLeft}`,
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (youTubeError && !youTubeError.retryable) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getVideosByID(videoIDs: string[]): Promise<VideoDetailsResponse[]> {
|
|
||||||
if (videoIDs.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
searchParams: {
|
|
||||||
part: 'id, snippet, contentDetails',
|
|
||||||
id: videoIDs.join(','),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {items: videos} = await this.cache.wrap(
|
|
||||||
async () => this.executeYouTubeRequest<{items: VideoDetailsResponse[]}>('videos', params),
|
|
||||||
params,
|
|
||||||
{
|
|
||||||
expiresIn: ONE_HOUR_IN_SECONDS,
|
|
||||||
key: this.createCacheKey('youtube-videos', videoIDs.join(',')),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return videos;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && 'code' in error) {
|
|
||||||
const youTubeError = error as YouTubeError;
|
|
||||||
if (youTubeError.code === 'QUOTA_EXCEEDED' || youTubeError.code === 'RATE_LIMITED') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Failed to fetch video information. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMetadataFromVideo({
|
private getMetadataFromVideo({
|
||||||
|
@ -375,7 +200,7 @@ export default class {
|
||||||
queuedPlaylist,
|
queuedPlaylist,
|
||||||
shouldSplitChapters,
|
shouldSplitChapters,
|
||||||
}: {
|
}: {
|
||||||
video: VideoDetailsResponse;
|
video: VideoDetailsResponse; // | YoutubePlaylistItem;
|
||||||
queuedPlaylist?: QueuedPlaylist;
|
queuedPlaylist?: QueuedPlaylist;
|
||||||
shouldSplitChapters?: boolean;
|
shouldSplitChapters?: boolean;
|
||||||
}): SongMetadata[] {
|
}): SongMetadata[] {
|
||||||
|
@ -396,22 +221,26 @@ export default class {
|
||||||
}
|
}
|
||||||
|
|
||||||
const chapters = this.parseChaptersFromDescription(video.snippet.description, base.length);
|
const chapters = this.parseChaptersFromDescription(video.snippet.description, base.length);
|
||||||
|
|
||||||
if (!chapters) {
|
if (!chapters) {
|
||||||
return [base];
|
return [base];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(chapters.entries()).map(([label, {offset, length}]) => ({
|
const tracks: SongMetadata[] = [];
|
||||||
|
|
||||||
|
for (const [label, {offset, length}] of chapters) {
|
||||||
|
tracks.push({
|
||||||
...base,
|
...base,
|
||||||
offset,
|
offset,
|
||||||
length,
|
length,
|
||||||
title: `${label} (${base.title})`,
|
title: `${label} (${base.title})`,
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseChaptersFromDescription(
|
return tracks;
|
||||||
description: string,
|
}
|
||||||
videoDurationSeconds: number,
|
|
||||||
) {
|
private parseChaptersFromDescription(description: string, videoDurationSeconds: number) {
|
||||||
const map = new Map<string, {offset: number; length: number}>();
|
const map = new Map<string, {offset: number; length: number}>();
|
||||||
let foundFirstTimestamp = false;
|
let foundFirstTimestamp = false;
|
||||||
|
|
||||||
|
@ -423,7 +252,6 @@ export default class {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!foundFirstTimestamp) {
|
if (!foundFirstTimestamp) {
|
||||||
// We expect the first timestamp to match something like "0:00" or "00:00"
|
|
||||||
if (/0{1,2}:00/.test(timestamps[0][0])) {
|
if (/0{1,2}:00/.test(timestamps[0][0])) {
|
||||||
foundFirstTimestamp = true;
|
foundFirstTimestamp = true;
|
||||||
} else {
|
} else {
|
||||||
|
@ -441,14 +269,34 @@ export default class {
|
||||||
for (const [i, {name, offset}] of foundTimestamps.entries()) {
|
for (const [i, {name, offset}] of foundTimestamps.entries()) {
|
||||||
map.set(name, {
|
map.set(name, {
|
||||||
offset,
|
offset,
|
||||||
length:
|
length: i === foundTimestamps.length - 1
|
||||||
i === foundTimestamps.length - 1
|
|
||||||
? videoDurationSeconds - offset
|
? videoDurationSeconds - offset
|
||||||
: foundTimestamps[i + 1].offset - offset,
|
: foundTimestamps[i + 1].offset - offset,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return map.size > 0 ? map : null;
|
if (!map.size) {
|
||||||
}
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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…
Add table
Add a link
Reference in a new issue