Skip to content

Commit 83b6ded

Browse files
committed
feat: change got with youtube api
1 parent 94c2426 commit 83b6ded

File tree

4 files changed

+214
-94
lines changed

4 files changed

+214
-94
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,5 @@ dist
107107

108108
# TernJS port file
109109
.tern-port
110+
111+
.idea/

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@
8989
"@discordjs/opus": "^0.8.0",
9090
"@discordjs/rest": "1.0.1",
9191
"@discordjs/voice": "0.11.0",
92-
"@distube/ytsr": "^2.0.0",
9392
"@distube/ytdl-core": "^4.13.5",
93+
"@distube/ytsr": "^2.0.0",
94+
"@googleapis/youtube": "^19.0.0",
9495
"@prisma/client": "4.16.0",
9596
"@types/libsodium-wrappers": "^0.7.9",
9697
"array-shuffle": "^3.0.0",

src/services/youtube-api.ts

Lines changed: 56 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,36 @@
11
import {inject, injectable} from 'inversify';
2-
import {toSeconds, parse} from 'iso8601-duration';
3-
import got, {Got} from 'got';
2+
import {parse, toSeconds} from 'iso8601-duration';
43
import ytsr, {Video} from '@distube/ytsr';
54
import PQueue from 'p-queue';
6-
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
5+
import {MediaSource, QueuedPlaylist, SongMetadata} from './player.js';
76
import {TYPES} from '../types.js';
87
import Config from './config.js';
98
import KeyValueCacheProvider from './key-value-cache.js';
109
import {ONE_HOUR_IN_SECONDS, ONE_MINUTE_IN_SECONDS} from '../utils/constants.js';
1110
import {parseTime} from '../utils/time.js';
1211
import getYouTubeID from 'get-youtube-id';
12+
import {youtube, youtube_v3} from '@googleapis/youtube';
1313

14-
interface VideoDetailsResponse {
15-
id: string;
16-
contentDetails: {
17-
videoId: string;
18-
duration: string;
19-
};
20-
snippet: {
21-
title: string;
22-
channelTitle: string;
23-
liveBroadcastContent: string;
24-
description: string;
25-
thumbnails: {
26-
medium: {
27-
url: string;
28-
};
29-
};
30-
};
31-
}
32-
33-
interface PlaylistResponse {
34-
id: string;
35-
contentDetails: {
36-
itemCount: number;
37-
};
38-
snippet: {
39-
title: string;
40-
};
41-
}
42-
43-
interface PlaylistItemsResponse {
44-
items: PlaylistItem[];
45-
nextPageToken?: string;
46-
}
47-
48-
interface PlaylistItem {
49-
id: string;
50-
contentDetails: {
51-
videoId: string;
52-
};
53-
}
14+
// Youtube API v3 types
15+
import Schema$Video = youtube_v3.Schema$Video;
16+
import Schema$PlaylistItem = youtube_v3.Schema$PlaylistItem;
5417

5518
@injectable()
5619
export default class {
5720
private readonly youtubeKey: string;
5821
private readonly cache: KeyValueCacheProvider;
5922
private readonly ytsrQueue: PQueue;
60-
private readonly got: Got;
23+
private readonly youtube: youtube_v3.Youtube;
6124

6225
constructor(@inject(TYPES.Config) config: Config, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) {
6326
this.youtubeKey = config.YOUTUBE_API_KEY;
6427
this.cache = cache;
6528
this.ytsrQueue = new PQueue({concurrency: 4});
6629

67-
this.got = got.extend({
68-
prefixUrl: 'https://www.googleapis.com/youtube/v3/',
69-
searchParams: {
70-
key: this.youtubeKey,
71-
responseType: 'json',
72-
},
30+
this.youtube = youtube({
31+
version: 'v3',
32+
auth: this.youtubeKey,
33+
responseType: 'json',
7334
});
7435
}
7536

@@ -103,7 +64,7 @@ export default class {
10364

10465
async getVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
10566
const result = await this.getVideosByID([String(getYouTubeID(url))]);
106-
const video = result.at(0);
67+
const video = result?.at(0);
10768

10869
if (!video) {
10970
throw new Error('Video could not be found.');
@@ -114,71 +75,74 @@ export default class {
11475

11576
async getPlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
11677
const playlistParams = {
117-
searchParams: {
118-
part: 'id, snippet, contentDetails',
119-
id: listId,
120-
},
78+
part: ['id', 'snippet', 'contentDetails'],
79+
id: [listId],
12180
};
122-
const {items: playlists} = await this.cache.wrap(
123-
async () => this.got('playlists', playlistParams).json() as Promise<{items: PlaylistResponse[]}>,
81+
const {data: {items: playlists}} = await this.cache.wrap(
82+
async () => this.youtube.playlists.list(playlistParams),
12483
playlistParams,
12584
{
12685
expiresIn: ONE_MINUTE_IN_SECONDS,
12786
},
12887
);
12988

130-
const playlist = playlists.at(0)!;
89+
const playlist = playlists?.at(0);
13190

13291
if (!playlist) {
13392
throw new Error('Playlist could not be found.');
13493
}
13594

136-
const playlistVideos: PlaylistItem[] = [];
95+
const playlistVideos: Schema$PlaylistItem[] = [];
13796
const videoDetailsPromises: Array<Promise<void>> = [];
138-
const videoDetails: VideoDetailsResponse[] = [];
97+
const videoDetails: Schema$Video[] = [];
13998

140-
let nextToken: string | undefined;
99+
let nextToken: string | null | undefined;
141100

142-
while (playlistVideos.length < playlist.contentDetails.itemCount) {
101+
while (playlistVideos.length < (playlist?.contentDetails?.itemCount ?? 0)) {
143102
const playlistItemsParams = {
144-
searchParams: {
145-
part: 'id, contentDetails',
146-
playlistId: listId,
147-
maxResults: '50',
148-
pageToken: nextToken,
149-
},
103+
part: ['id', 'contentDetails'],
104+
playlistId: listId,
105+
maxResults: 50,
106+
pageToken: nextToken === null ? undefined : nextToken,
150107
};
151108

152109
// eslint-disable-next-line no-await-in-loop
153-
const {items, nextPageToken} = await this.cache.wrap(
154-
async () => this.got('playlistItems', playlistItemsParams).json() as Promise<PlaylistItemsResponse>,
110+
const {data: {items, nextPageToken}} = await this.cache.wrap(
111+
async () => this.youtube.playlistItems.list(playlistItemsParams),
155112
playlistItemsParams,
156113
{
157114
expiresIn: ONE_MINUTE_IN_SECONDS,
158115
},
159116
);
160117

161118
nextToken = nextPageToken;
119+
120+
if (!items) {
121+
break;
122+
}
123+
162124
playlistVideos.push(...items);
163125

164126
// Start fetching extra details about videos
165127
// PlaylistItem misses some details, eg. if the video is a livestream
166128
videoDetailsPromises.push((async () => {
167-
const videoDetailItems = await this.getVideosByID(items.map(item => item.contentDetails.videoId));
168-
videoDetails.push(...videoDetailItems);
129+
const videoDetailItems = await this.getVideosByID(items.map(item => item.contentDetails?.videoId ?? ''));
130+
if (videoDetailItems) {
131+
videoDetails.push(...videoDetailItems);
132+
}
169133
})());
170134
}
171135

172136
await Promise.all(videoDetailsPromises);
173137

174-
const queuedPlaylist = {title: playlist.snippet.title, source: playlist.id};
138+
const queuedPlaylist = {title: playlist.snippet?.title ?? '', source: playlist.id ?? ''};
175139

176140
const songsToReturn: SongMetadata[] = [];
177141

178142
for (const video of playlistVideos) {
179143
try {
180144
songsToReturn.push(...this.getMetadataFromVideo({
181-
video: videoDetails.find((i: {id: string}) => i.id === video.contentDetails.videoId)!,
145+
video: videoDetails.find(i => i.id === video.contentDetails?.videoId)!,
182146
queuedPlaylist,
183147
shouldSplitChapters,
184148
}));
@@ -196,27 +160,27 @@ export default class {
196160
queuedPlaylist,
197161
shouldSplitChapters,
198162
}: {
199-
video: VideoDetailsResponse; // | YoutubePlaylistItem;
163+
video: Schema$Video; // | YoutubePlaylistItem;
200164
queuedPlaylist?: QueuedPlaylist;
201165
shouldSplitChapters?: boolean;
202166
}): SongMetadata[] {
203167
const base: SongMetadata = {
204168
source: MediaSource.Youtube,
205-
title: video.snippet.title,
206-
artist: video.snippet.channelTitle,
207-
length: toSeconds(parse(video.contentDetails.duration)),
169+
title: video.snippet?.title ?? 'Unknown title',
170+
artist: video.snippet?.channelTitle ?? 'Unknown artist',
171+
length: toSeconds(parse(video.contentDetails?.duration ?? 'PT0S')),
208172
offset: 0,
209-
url: video.id,
173+
url: video?.id ?? '',
210174
playlist: queuedPlaylist ?? null,
211-
isLive: video.snippet.liveBroadcastContent === 'live',
212-
thumbnailUrl: video.snippet.thumbnails.medium.url,
175+
isLive: video.snippet?.liveBroadcastContent === 'live',
176+
thumbnailUrl: video.snippet?.thumbnails?.medium?.url ?? '',
213177
};
214178

215179
if (!shouldSplitChapters) {
216180
return [base];
217181
}
218182

219-
const chapters = this.parseChaptersFromDescription(video.snippet.description, base.length);
183+
const chapters = this.parseChaptersFromDescription(video.snippet?.description, base.length);
220184

221185
if (!chapters) {
222186
return [base];
@@ -236,7 +200,11 @@ export default class {
236200
return tracks;
237201
}
238202

239-
private parseChaptersFromDescription(description: string, videoDurationSeconds: number) {
203+
private parseChaptersFromDescription(description: string | undefined | null, videoDurationSeconds: number) {
204+
if (!description) {
205+
return null;
206+
}
207+
240208
const map = new Map<string, {offset: number; length: number}>();
241209
let foundFirstTimestamp = false;
242210

@@ -278,16 +246,14 @@ export default class {
278246
return map;
279247
}
280248

281-
private async getVideosByID(videoIDs: string[]): Promise<VideoDetailsResponse[]> {
249+
private async getVideosByID(videoIDs: string[]): Promise<youtube_v3.Schema$Video[] | undefined> {
282250
const p = {
283-
searchParams: {
284-
part: 'id, snippet, contentDetails',
285-
id: videoIDs.join(','),
286-
},
251+
part: ['id', 'snippet', 'contentDetails'],
252+
id: videoIDs,
287253
};
288254

289-
const {items: videos} = await this.cache.wrap(
290-
async () => this.got('videos', p).json() as Promise<{items: VideoDetailsResponse[]}>,
255+
const {data: {items: videos}} = await this.cache.wrap(
256+
async () => this.youtube.videos.list(p),
291257
p,
292258
{
293259
expiresIn: ONE_HOUR_IN_SECONDS,

0 commit comments

Comments
 (0)