-
-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathVideoLoader.ts
More file actions
217 lines (186 loc) · 7.57 KB
/
Copy pathVideoLoader.ts
File metadata and controls
217 lines (186 loc) · 7.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import Innertube, * as InnertubeLib from 'youtubei.js';
import { type Video } from '../dist';
interface BasicInfo {
src: 'yt' | 'ytmusic';
title: string;
channel?: string;
artist?: string;
album?: string;
}
export interface VideoInfo extends BasicInfo {
duration: number;
streamUrl?: any;
}
/**
* Notes:
*
* `VideoLoader` uses [YouTube.js](https://github.com/LuanRT/YouTube.js) to retrieve video metadata.
*
* ⚠️ Stream URL fetching is disabled because doing so requires executing arbitrary JavaScript.
* For details, see: https://ytjs.dev/guide/getting-started.html#providing-a-custom-javascript-interpreter
* The example still functions because `FakePlayer` doesn't actually play anything so it doesn't need
* the stream URL.
*
* You can enable stream URL fetching in this example by setting `allowArbitraryCodeExecution` below to `true`.
* If you do so, keep in mind that there is no guard against malicious code execution here.
* In a real-world application, you should consider sandboxing the executed code. One way would be to use
* `isolated-vm` as suggested by @BlackCetha: https://github.com/patrickkfkan/yt-cast-receiver/issues/9.
*/
const allowArbitraryCodeExecution = false;
if (allowArbitraryCodeExecution) {
// Called by Innertube when deciphering stream URLs
InnertubeLib.Platform.shim.eval = (data: InnertubeLib.Types.BuildScriptResult, env: Record<string, InnertubeLib.Types.VMPrimative>) => {
const properties = [];
if(env.n) {
properties.push(`n: exportedVars.nFunction("${env.n}")`)
}
if (env.sig) {
properties.push(`sig: exportedVars.sigFunction("${env.sig}")`)
}
const code = `${data.output}\nreturn { ${properties.join(', ')} }`;
// eslint-disable-next-line @typescript-eslint/no-implied-eval
return new Function(code)();
}
}
async function getStreamUrl(innertube: Innertube, videoInfo: InnertubeLib.YT.VideoInfo) {
if (!allowArbitraryCodeExecution) {
return null;
}
let streamUrl;
if (videoInfo.basic_info.is_live) {
streamUrl = videoInfo.streaming_data?.hls_manifest_url ||
videoInfo.streaming_data?.dash_manifest_url ||
null;
}
else {
// Fetch stream URL with `chooseFormat()`:
const allFormats = [
...videoInfo.streaming_data?.formats || [],
...videoInfo.streaming_data?.adaptive_formats || []
];
const hasAudioFormat = allFormats.some((f) => f.has_audio) ? 'audio' : false;
const hasVideoFormat = allFormats.some((f) => f.has_video) ? 'video' : false;
const hasAudioAndVideoFormat = allFormats.some((f) => f.has_audio && f.has_video) ? 'video+audio' : false;
const formatType = hasAudioAndVideoFormat || hasVideoFormat || hasAudioFormat || undefined;
try {
const format = videoInfo?.chooseFormat({ type: formatType, quality: 'best' });
streamUrl = format ? await format.decipher(innertube.session.player) : null;
}
catch (_error: unknown) {
streamUrl = null;
}
}
return streamUrl;
}
export default class VideoLoader {
#innertube: Innertube | null;
#innertubeInitialClient: InnertubeLib.Context['client'] | null;
#innertubeTVClient: InnertubeLib.Context['client'] | null;
constructor() {
this.#innertube = null;
this.#innertubeInitialClient = null;
this.#innertubeTVClient = null;
}
async #init() {
if (!this.#innertube) {
this.#innertube = await Innertube.create();
this.#innertubeInitialClient = { ...this.#innertube.session.context.client };
this.#innertubeTVClient = {
...this.#innertube.session.context.client,
clientName: 'TVHTML5',
clientVersion: '7.20230405.08.01',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36; SMART-TV; Tizen 4.0,gzip(gfe)'
};
}
}
async getInfo(video: Video): Promise<VideoInfo | null> {
if (!this.#innertube) {
await this.#init();
}
if (!this.#innertube || !this.#innertubeTVClient || !this.#innertubeInitialClient) {
throw Error('VideoLoader not initialized');
}
const cpn = InnertubeLib.Utils.generateRandomString(16);
// Prepare request payload
const payload = {
cpn,
videoId: video.id,
enableMdxAutoplay: true,
isMdxPlayback: true,
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#innertube.session.player?.signature_timestamp || 0
}
}
} as any;
if (video.context?.playlistId) {
payload.playlistId = video.context.playlistId;
}
if (video.context?.params) {
payload.params = video.context.params;
}
if (video.context?.index !== undefined) {
payload.index = video.context.index;
}
// Modify innertube session context to indicate we are requesting data as a 'TV' client.
this.#innertube.session.context.client = this.#innertubeTVClient;
// Remember to include `ctt` param if available - this is required particularly for private videos.
if (video.context?.ctt) {
this.#innertube.session.context.user = {
enableSafetyMode: false,
lockedSafetyMode: false,
credentialTransferTokens: [
{
'scope': 'VIDEO',
'token': video.context?.ctt
}
]
} as any;
}
else {
delete (this.#innertube.session.context.user as any)?.credentialTransferTokens;
}
// There are two endpoints we need to fetch data from:
// 1. '/next': for metadata (title, channel for video, artist / album for music...)
// 2. '/player': for streaming data
const nextResponse = await this.#innertube.actions.execute('/next', payload) as any;
let basicInfo: BasicInfo | null = null;
// We cannot use innertube to parse `nextResponse`, because it doesn't
// Have `SingleColumnWatchNextResults` parser class. We would have to do it ourselves.
const singleColumnContents = nextResponse.data?.contents?.singleColumnWatchNextResults?.
results?.results?.contents?.[0]?.itemSectionRenderer?.contents?.[0];
const videoMetadata = singleColumnContents?.videoMetadataRenderer;
const songMetadata = singleColumnContents?.musicWatchMetadataRenderer;
if (videoMetadata) {
basicInfo = {
src: 'yt',
title: new InnertubeLib.Misc.Text(videoMetadata.title).toString(),
channel: new InnertubeLib.Misc.Text(videoMetadata.owner?.videoOwnerRenderer?.title).toString()
};
}
else if (songMetadata) {
basicInfo = {
src: 'ytmusic',
title: new InnertubeLib.Misc.Text(songMetadata.title).toString(),
artist: new InnertubeLib.Misc.Text(songMetadata.byline).toString(),
album: new InnertubeLib.Misc.Text(songMetadata.albumName).toString()
};
}
if (basicInfo) {
// Fetch response from '/player' endpoint.
// But first revert to initial client in innertube context, otherwise livestreams will only have DASH manifest URL
// - we would like to fetch complete streaming data.
this.#innertube.session.context.client = { ...this.#innertubeInitialClient };
const playerResponse = await this.#innertube.actions.execute('/player', payload) as any;
// Wrap it in innertube VideoInfo. Why? Because it offers many useful methods like `chooseFormat()`.
const innertubeVideoInfo = new InnertubeLib.YT.VideoInfo([ playerResponse ], this.#innertube.actions, cpn);
const result = {
...basicInfo,
duration: innertubeVideoInfo.basic_info.duration || 0,
streamUrl: await getStreamUrl(this.#innertube, innertubeVideoInfo)
} as VideoInfo;
return result;
}
return null;
}
}