1
1
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' ;
4
3
import ytsr , { Video } from '@distube/ytsr' ;
5
4
import PQueue from 'p-queue' ;
6
- import { SongMetadata , QueuedPlaylist , MediaSource } from './player.js' ;
5
+ import { MediaSource , QueuedPlaylist , SongMetadata } from './player.js' ;
7
6
import { TYPES } from '../types.js' ;
8
7
import Config from './config.js' ;
9
8
import KeyValueCacheProvider from './key-value-cache.js' ;
10
9
import { ONE_HOUR_IN_SECONDS , ONE_MINUTE_IN_SECONDS } from '../utils/constants.js' ;
11
10
import { parseTime } from '../utils/time.js' ;
12
11
import getYouTubeID from 'get-youtube-id' ;
12
+ import { youtube , youtube_v3 } from '@googleapis/youtube' ;
13
13
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 ;
54
17
55
18
@injectable ( )
56
19
export default class {
57
20
private readonly youtubeKey : string ;
58
21
private readonly cache : KeyValueCacheProvider ;
59
22
private readonly ytsrQueue : PQueue ;
60
- private readonly got : Got ;
23
+ private readonly youtube : youtube_v3 . Youtube ;
61
24
62
25
constructor ( @inject ( TYPES . Config ) config : Config , @inject ( TYPES . KeyValueCache ) cache : KeyValueCacheProvider ) {
63
26
this . youtubeKey = config . YOUTUBE_API_KEY ;
64
27
this . cache = cache ;
65
28
this . ytsrQueue = new PQueue ( { concurrency : 4 } ) ;
66
29
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' ,
73
34
} ) ;
74
35
}
75
36
@@ -103,7 +64,7 @@ export default class {
103
64
104
65
async getVideo ( url : string , shouldSplitChapters : boolean ) : Promise < SongMetadata [ ] > {
105
66
const result = await this . getVideosByID ( [ String ( getYouTubeID ( url ) ) ] ) ;
106
- const video = result . at ( 0 ) ;
67
+ const video = result ? .at ( 0 ) ;
107
68
108
69
if ( ! video ) {
109
70
throw new Error ( 'Video could not be found.' ) ;
@@ -114,71 +75,74 @@ export default class {
114
75
115
76
async getPlaylist ( listId : string , shouldSplitChapters : boolean ) : Promise < SongMetadata [ ] > {
116
77
const playlistParams = {
117
- searchParams : {
118
- part : 'id, snippet, contentDetails' ,
119
- id : listId ,
120
- } ,
78
+ part : [ 'id' , 'snippet' , 'contentDetails' ] ,
79
+ id : [ listId ] ,
121
80
} ;
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 ) ,
124
83
playlistParams ,
125
84
{
126
85
expiresIn : ONE_MINUTE_IN_SECONDS ,
127
86
} ,
128
87
) ;
129
88
130
- const playlist = playlists . at ( 0 ) ! ;
89
+ const playlist = playlists ? .at ( 0 ) ;
131
90
132
91
if ( ! playlist ) {
133
92
throw new Error ( 'Playlist could not be found.' ) ;
134
93
}
135
94
136
- const playlistVideos : PlaylistItem [ ] = [ ] ;
95
+ const playlistVideos : Schema$ PlaylistItem[ ] = [ ] ;
137
96
const videoDetailsPromises : Array < Promise < void > > = [ ] ;
138
- const videoDetails : VideoDetailsResponse [ ] = [ ] ;
97
+ const videoDetails : Schema$Video [ ] = [ ] ;
139
98
140
- let nextToken : string | undefined ;
99
+ let nextToken : string | null | undefined ;
141
100
142
- while ( playlistVideos . length < playlist . contentDetails . itemCount ) {
101
+ while ( playlistVideos . length < ( playlist ? .contentDetails ? .itemCount ?? 0 ) ) {
143
102
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 ,
150
107
} ;
151
108
152
109
// 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 ) ,
155
112
playlistItemsParams ,
156
113
{
157
114
expiresIn : ONE_MINUTE_IN_SECONDS ,
158
115
} ,
159
116
) ;
160
117
161
118
nextToken = nextPageToken ;
119
+
120
+ if ( ! items ) {
121
+ break ;
122
+ }
123
+
162
124
playlistVideos . push ( ...items ) ;
163
125
164
126
// Start fetching extra details about videos
165
127
// PlaylistItem misses some details, eg. if the video is a livestream
166
128
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
+ }
169
133
} ) ( ) ) ;
170
134
}
171
135
172
136
await Promise . all ( videoDetailsPromises ) ;
173
137
174
- const queuedPlaylist = { title : playlist . snippet . title , source : playlist . id } ;
138
+ const queuedPlaylist = { title : playlist . snippet ? .title ?? '' , source : playlist . id ?? '' } ;
175
139
176
140
const songsToReturn : SongMetadata [ ] = [ ] ;
177
141
178
142
for ( const video of playlistVideos ) {
179
143
try {
180
144
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 ) ! ,
182
146
queuedPlaylist,
183
147
shouldSplitChapters,
184
148
} ) ) ;
@@ -196,27 +160,27 @@ export default class {
196
160
queuedPlaylist,
197
161
shouldSplitChapters,
198
162
} : {
199
- video : VideoDetailsResponse ; // | YoutubePlaylistItem;
163
+ video : Schema$Video ; // | YoutubePlaylistItem;
200
164
queuedPlaylist ?: QueuedPlaylist ;
201
165
shouldSplitChapters ?: boolean ;
202
166
} ) : SongMetadata [ ] {
203
167
const base : SongMetadata = {
204
168
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' ) ) ,
208
172
offset : 0 ,
209
- url : video . id ,
173
+ url : video ? .id ?? '' ,
210
174
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 ?? '' ,
213
177
} ;
214
178
215
179
if ( ! shouldSplitChapters ) {
216
180
return [ base ] ;
217
181
}
218
182
219
- const chapters = this . parseChaptersFromDescription ( video . snippet . description , base . length ) ;
183
+ const chapters = this . parseChaptersFromDescription ( video . snippet ? .description , base . length ) ;
220
184
221
185
if ( ! chapters ) {
222
186
return [ base ] ;
@@ -236,7 +200,11 @@ export default class {
236
200
return tracks ;
237
201
}
238
202
239
- private parseChaptersFromDescription ( description : string , videoDurationSeconds : number ) {
203
+ private parseChaptersFromDescription ( description : string | undefined | null , videoDurationSeconds : number ) {
204
+ if ( ! description ) {
205
+ return null ;
206
+ }
207
+
240
208
const map = new Map < string , { offset : number ; length : number } > ( ) ;
241
209
let foundFirstTimestamp = false ;
242
210
@@ -278,16 +246,14 @@ export default class {
278
246
return map ;
279
247
}
280
248
281
- private async getVideosByID ( videoIDs : string [ ] ) : Promise < VideoDetailsResponse [ ] > {
249
+ private async getVideosByID ( videoIDs : string [ ] ) : Promise < youtube_v3 . Schema$Video [ ] | undefined > {
282
250
const p = {
283
- searchParams : {
284
- part : 'id, snippet, contentDetails' ,
285
- id : videoIDs . join ( ',' ) ,
286
- } ,
251
+ part : [ 'id' , 'snippet' , 'contentDetails' ] ,
252
+ id : videoIDs ,
287
253
} ;
288
254
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 ) ,
291
257
p ,
292
258
{
293
259
expiresIn : ONE_HOUR_IN_SECONDS ,
0 commit comments