getraw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +4 -0
- package/CLAUDE.md +57 -0
- package/README.md +166 -0
- package/RESEARCH.md +109 -0
- package/STATUS.md +23 -0
- package/bun.lock +50 -0
- package/bunfig.toml +3 -0
- package/docs/plugin-guide.md +166 -0
- package/docs/supported-sites.md +41 -0
- package/package.json +30 -0
- package/src/cli/index.ts +52 -0
- package/src/cli/options.ts +97 -0
- package/src/core/format-sorter.ts +208 -0
- package/src/core/logger.ts +101 -0
- package/src/core/orchestrator.ts +140 -0
- package/src/core/output-template.ts +58 -0
- package/src/core/types.ts +237 -0
- package/src/downloaders/base.ts +25 -0
- package/src/downloaders/dash.ts +287 -0
- package/src/downloaders/fragment.ts +226 -0
- package/src/downloaders/hls.ts +170 -0
- package/src/downloaders/http.ts +260 -0
- package/src/extractors/archive-org.ts +126 -0
- package/src/extractors/bandcamp.ts +130 -0
- package/src/extractors/base.ts +29 -0
- package/src/extractors/bilibili/bangumi.ts +205 -0
- package/src/extractors/bilibili/index.ts +233 -0
- package/src/extractors/bilibili/wbi.ts +60 -0
- package/src/extractors/coub.ts +137 -0
- package/src/extractors/dailymotion.ts +99 -0
- package/src/extractors/dropbox.ts +52 -0
- package/src/extractors/generic.ts +118 -0
- package/src/extractors/google-drive.ts +106 -0
- package/src/extractors/imgur.ts +156 -0
- package/src/extractors/instagram/index.ts +263 -0
- package/src/extractors/instagram/reels.ts +166 -0
- package/src/extractors/kick/clips.ts +91 -0
- package/src/extractors/kick/index.ts +118 -0
- package/src/extractors/kick/live.ts +89 -0
- package/src/extractors/niconico/index.ts +209 -0
- package/src/extractors/odysee.ts +126 -0
- package/src/extractors/peertube.ts +143 -0
- package/src/extractors/reddit/gallery.ts +124 -0
- package/src/extractors/reddit/index.ts +203 -0
- package/src/extractors/rumble.ts +127 -0
- package/src/extractors/soundcloud/index.ts +161 -0
- package/src/extractors/soundcloud/playlist.ts +129 -0
- package/src/extractors/spotify.ts +97 -0
- package/src/extractors/streamable.ts +121 -0
- package/src/extractors/ted.ts +151 -0
- package/src/extractors/tiktok/index.ts +207 -0
- package/src/extractors/tiktok/user.ts +176 -0
- package/src/extractors/twitch/clips.ts +125 -0
- package/src/extractors/twitch/index.ts +136 -0
- package/src/extractors/twitch/live.ts +132 -0
- package/src/extractors/twitter/index.ts +140 -0
- package/src/extractors/twitter/spaces.ts +200 -0
- package/src/extractors/vimeo/index.ts +187 -0
- package/src/extractors/youtube/captions.ts +111 -0
- package/src/extractors/youtube/index.ts +252 -0
- package/src/extractors/youtube/innertube.ts +364 -0
- package/src/extractors/youtube/nsig.ts +105 -0
- package/src/extractors/youtube/playlist.ts +227 -0
- package/src/extractors/youtube/signature.ts +163 -0
- package/src/networking/client.ts +311 -0
- package/src/networking/cookies.ts +138 -0
- package/src/networking/proxy.ts +132 -0
- package/src/networking/tls.ts +67 -0
- package/src/networking/user-agents.ts +88 -0
- package/src/postprocessors/base.ts +44 -0
- package/src/postprocessors/extract-audio.ts +98 -0
- package/src/postprocessors/ffmpeg.ts +146 -0
- package/src/postprocessors/merge.ts +102 -0
- package/src/postprocessors/metadata.ts +73 -0
- package/src/postprocessors/sponsorblock.ts +162 -0
- package/src/postprocessors/subtitles.ts +285 -0
- package/src/postprocessors/thumbnails.ts +194 -0
- package/src/utils/sanitize.ts +36 -0
- package/src/utils/traverse.ts +68 -0
- package/tests/core/format-sorter.test.ts +96 -0
- package/tests/core/output-template.test.ts +56 -0
- package/tests/core/types.test.ts +79 -0
- package/tests/unit/downloaders/dash.test.ts +57 -0
- package/tests/unit/downloaders/hls.test.ts +120 -0
- package/tests/unit/downloaders/http.test.ts +114 -0
- package/tests/unit/extractors/bilibili.test.ts +83 -0
- package/tests/unit/extractors/instagram.test.ts +273 -0
- package/tests/unit/extractors/kick.test.ts +85 -0
- package/tests/unit/extractors/misc.test.ts +942 -0
- package/tests/unit/extractors/niconico.test.ts +61 -0
- package/tests/unit/extractors/reddit.test.ts +222 -0
- package/tests/unit/extractors/soundcloud.test.ts +299 -0
- package/tests/unit/extractors/tiktok.test.ts +260 -0
- package/tests/unit/extractors/twitch.test.ts +250 -0
- package/tests/unit/extractors/twitter.test.ts +181 -0
- package/tests/unit/extractors/vimeo.test.ts +253 -0
- package/tests/unit/extractors/youtube.test.ts +259 -0
- package/tests/unit/networking/client.test.ts +272 -0
- package/tests/unit/networking/cookies.test.ts +256 -0
- package/tests/unit/networking/proxy.test.ts +137 -0
- package/tests/unit/postprocessors/extract-audio.test.ts +63 -0
- package/tests/unit/postprocessors/merge.test.ts +61 -0
- package/tests/unit/postprocessors/subtitles.test.ts +89 -0
- package/tools/dashboard.ts +112 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from "bun:test";
|
|
2
|
+
import { VimeoExtractor } from "../../../src/extractors/vimeo/index";
|
|
3
|
+
import { ExtractorError } from "../../../src/core/types";
|
|
4
|
+
|
|
5
|
+
const extractor = new VimeoExtractor();
|
|
6
|
+
|
|
7
|
+
const mockVimeoConfig = {
|
|
8
|
+
video: {
|
|
9
|
+
id: 123456789,
|
|
10
|
+
title: "My Vimeo Video",
|
|
11
|
+
description: "A test video",
|
|
12
|
+
duration: 120,
|
|
13
|
+
owner: {
|
|
14
|
+
name: "Video Creator",
|
|
15
|
+
url: "https://vimeo.com/creator",
|
|
16
|
+
},
|
|
17
|
+
thumbs: {
|
|
18
|
+
"640": "https://i.vimeocdn.com/video/thumb_640.jpg",
|
|
19
|
+
"1280": "https://i.vimeocdn.com/video/thumb_1280.jpg",
|
|
20
|
+
},
|
|
21
|
+
width: 1920,
|
|
22
|
+
height: 1080,
|
|
23
|
+
},
|
|
24
|
+
request: {
|
|
25
|
+
files: {
|
|
26
|
+
hls: {
|
|
27
|
+
default_cdn: "fastly_skyfire",
|
|
28
|
+
cdns: {
|
|
29
|
+
fastly_skyfire: {
|
|
30
|
+
url: "https://skyfire.vimeocdn.com/playlist.m3u8",
|
|
31
|
+
avc_url: "https://skyfire.vimeocdn.com/playlist_avc.m3u8",
|
|
32
|
+
},
|
|
33
|
+
akfire_interconnect_quic: {
|
|
34
|
+
url: "https://akfire.vimeocdn.com/playlist.m3u8",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
dash: {
|
|
39
|
+
default_cdn: "fastly_skyfire",
|
|
40
|
+
cdns: {
|
|
41
|
+
fastly_skyfire: {
|
|
42
|
+
url: "https://skyfire.vimeocdn.com/manifest.mpd",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
progressive: [
|
|
47
|
+
{
|
|
48
|
+
quality: "1080p",
|
|
49
|
+
mime: "video/mp4",
|
|
50
|
+
width: 1920,
|
|
51
|
+
height: 1080,
|
|
52
|
+
fps: 30,
|
|
53
|
+
url: "https://vod-progressive.akamaized.net/video1080.mp4",
|
|
54
|
+
size: 524288000,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
quality: "720p",
|
|
58
|
+
mime: "video/mp4",
|
|
59
|
+
width: 1280,
|
|
60
|
+
height: 720,
|
|
61
|
+
fps: 30,
|
|
62
|
+
url: "https://vod-progressive.akamaized.net/video720.mp4",
|
|
63
|
+
size: 262144000,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
quality: "360p",
|
|
67
|
+
mime: "video/mp4",
|
|
68
|
+
width: 640,
|
|
69
|
+
height: 360,
|
|
70
|
+
fps: 30,
|
|
71
|
+
url: "https://vod-progressive.akamaized.net/video360.mp4",
|
|
72
|
+
size: 52428800,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
describe("VimeoExtractor", () => {
|
|
80
|
+
test("canHandle matches standard vimeo.com URL", () => {
|
|
81
|
+
expect(extractor.canHandle("https://vimeo.com/123456789")).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("canHandle matches player.vimeo.com URL", () => {
|
|
85
|
+
expect(extractor.canHandle("https://player.vimeo.com/video/123456789")).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("canHandle matches channel video URL", () => {
|
|
89
|
+
expect(extractor.canHandle("https://vimeo.com/channels/mychannel/123456789")).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("canHandle rejects non-vimeo URL", () => {
|
|
93
|
+
expect(extractor.canHandle("https://youtube.com/watch?v=abc")).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("canHandle rejects vimeo homepage", () => {
|
|
97
|
+
expect(extractor.canHandle("https://vimeo.com/")).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("_NAME is vimeo", () => {
|
|
101
|
+
expect(extractor._NAME).toBe("vimeo");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("extract parses all format types from config", async () => {
|
|
105
|
+
const originalFetch = globalThis.fetch;
|
|
106
|
+
|
|
107
|
+
globalThis.fetch = mock(() =>
|
|
108
|
+
Promise.resolve(
|
|
109
|
+
new Response(JSON.stringify(mockVimeoConfig), {
|
|
110
|
+
status: 200,
|
|
111
|
+
headers: { "Content-Type": "application/json" },
|
|
112
|
+
})
|
|
113
|
+
)
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const info = await extractor.extract("https://vimeo.com/123456789");
|
|
117
|
+
|
|
118
|
+
expect(info.id).toBe("123456789");
|
|
119
|
+
expect(info.title).toBe("My Vimeo Video");
|
|
120
|
+
expect(info.description).toBe("A test video");
|
|
121
|
+
expect(info.duration).toBe(120);
|
|
122
|
+
expect(info.uploader).toBe("Video Creator");
|
|
123
|
+
expect(info.formats).toBeDefined();
|
|
124
|
+
|
|
125
|
+
const progressive = info.formats!.filter((f) => f.format_id.startsWith("http-"));
|
|
126
|
+
const hls = info.formats!.filter((f) => f.protocol === "m3u8");
|
|
127
|
+
const dash = info.formats!.filter((f) => f.protocol === "dash");
|
|
128
|
+
|
|
129
|
+
expect(progressive.length).toBe(3);
|
|
130
|
+
expect(hls.length).toBeGreaterThan(0);
|
|
131
|
+
expect(dash.length).toBeGreaterThan(0);
|
|
132
|
+
|
|
133
|
+
const p1080 = progressive.find((f) => f.height === 1080);
|
|
134
|
+
expect(p1080).toBeDefined();
|
|
135
|
+
expect(p1080!.width).toBe(1920);
|
|
136
|
+
expect(p1080!.filesize).toBe(524288000);
|
|
137
|
+
expect(p1080!.vcodec).toBe("h264");
|
|
138
|
+
|
|
139
|
+
const hlsDefault = hls.find((f) => f.source_preference === 1);
|
|
140
|
+
expect(hlsDefault).toBeDefined();
|
|
141
|
+
expect(hlsDefault!.url).toContain("skyfire");
|
|
142
|
+
expect(hlsDefault!.url).toContain("_avc.m3u8");
|
|
143
|
+
|
|
144
|
+
globalThis.fetch = originalFetch;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("extract parses thumbnails from config", async () => {
|
|
148
|
+
const originalFetch = globalThis.fetch;
|
|
149
|
+
|
|
150
|
+
globalThis.fetch = mock(() =>
|
|
151
|
+
Promise.resolve(
|
|
152
|
+
new Response(JSON.stringify(mockVimeoConfig), {
|
|
153
|
+
status: 200,
|
|
154
|
+
headers: { "Content-Type": "application/json" },
|
|
155
|
+
})
|
|
156
|
+
)
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const info = await extractor.extract("https://vimeo.com/123456789");
|
|
160
|
+
|
|
161
|
+
expect(info.thumbnails).toBeDefined();
|
|
162
|
+
expect(info.thumbnails!.length).toBe(2);
|
|
163
|
+
const thumb640 = info.thumbnails!.find((t) => t.id === "640");
|
|
164
|
+
expect(thumb640).toBeDefined();
|
|
165
|
+
expect(thumb640!.width).toBe(640);
|
|
166
|
+
|
|
167
|
+
globalThis.fetch = originalFetch;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("extract throws ExtractorError when config fetch fails", async () => {
|
|
171
|
+
const originalFetch = globalThis.fetch;
|
|
172
|
+
|
|
173
|
+
globalThis.fetch = mock(() =>
|
|
174
|
+
Promise.resolve(new Response(null, { status: 403 }))
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await expect(
|
|
178
|
+
extractor.extract("https://vimeo.com/999999999")
|
|
179
|
+
).rejects.toThrow(ExtractorError);
|
|
180
|
+
|
|
181
|
+
globalThis.fetch = originalFetch;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("extract throws ExtractorError when no files in config", async () => {
|
|
185
|
+
const originalFetch = globalThis.fetch;
|
|
186
|
+
|
|
187
|
+
const emptyConfig = {
|
|
188
|
+
video: { id: 1, title: "Test" },
|
|
189
|
+
request: {},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
globalThis.fetch = mock(() =>
|
|
193
|
+
Promise.resolve(
|
|
194
|
+
new Response(JSON.stringify(emptyConfig), {
|
|
195
|
+
status: 200,
|
|
196
|
+
headers: { "Content-Type": "application/json" },
|
|
197
|
+
})
|
|
198
|
+
)
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
await expect(
|
|
202
|
+
extractor.extract("https://vimeo.com/123456789")
|
|
203
|
+
).rejects.toThrow(ExtractorError);
|
|
204
|
+
|
|
205
|
+
globalThis.fetch = originalFetch;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("extract handles HLS only (no progressive)", async () => {
|
|
209
|
+
const originalFetch = globalThis.fetch;
|
|
210
|
+
|
|
211
|
+
const hlsOnlyConfig = {
|
|
212
|
+
...mockVimeoConfig,
|
|
213
|
+
request: {
|
|
214
|
+
files: {
|
|
215
|
+
hls: mockVimeoConfig.request.files.hls,
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
globalThis.fetch = mock(() =>
|
|
221
|
+
Promise.resolve(
|
|
222
|
+
new Response(JSON.stringify(hlsOnlyConfig), {
|
|
223
|
+
status: 200,
|
|
224
|
+
headers: { "Content-Type": "application/json" },
|
|
225
|
+
})
|
|
226
|
+
)
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const info = await extractor.extract("https://vimeo.com/123456789");
|
|
230
|
+
expect(info.formats!.length).toBeGreaterThan(0);
|
|
231
|
+
expect(info.formats!.every((f) => f.protocol === "m3u8")).toBe(true);
|
|
232
|
+
|
|
233
|
+
globalThis.fetch = originalFetch;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("webpage_url is always canonical vimeo URL", async () => {
|
|
237
|
+
const originalFetch = globalThis.fetch;
|
|
238
|
+
|
|
239
|
+
globalThis.fetch = mock(() =>
|
|
240
|
+
Promise.resolve(
|
|
241
|
+
new Response(JSON.stringify(mockVimeoConfig), {
|
|
242
|
+
status: 200,
|
|
243
|
+
headers: { "Content-Type": "application/json" },
|
|
244
|
+
})
|
|
245
|
+
)
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const info = await extractor.extract("https://player.vimeo.com/video/123456789");
|
|
249
|
+
expect(info.webpage_url).toBe("https://vimeo.com/123456789");
|
|
250
|
+
|
|
251
|
+
globalThis.fetch = originalFetch;
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { YouTubeExtractor } from "../../../src/extractors/youtube/index";
|
|
3
|
+
import { InnerTubeClient } from "../../../src/extractors/youtube/innertube";
|
|
4
|
+
import type { StreamingData, CaptionTrack } from "../../../src/extractors/youtube/innertube";
|
|
5
|
+
import { parseCaptionTracks, convertToSrt, convertToVtt } from "../../../src/extractors/youtube/captions";
|
|
6
|
+
import type { TimedTextEvent } from "../../../src/extractors/youtube/captions";
|
|
7
|
+
|
|
8
|
+
describe("YouTubeExtractor URL matching", () => {
|
|
9
|
+
const extractor = new YouTubeExtractor();
|
|
10
|
+
|
|
11
|
+
const validUrls = [
|
|
12
|
+
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
13
|
+
"https://youtube.com/watch?v=dQw4w9WgXcQ",
|
|
14
|
+
"https://m.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
15
|
+
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLtest",
|
|
16
|
+
"https://youtu.be/dQw4w9WgXcQ",
|
|
17
|
+
"https://www.youtube.com/shorts/dQw4w9WgXcQ",
|
|
18
|
+
"https://www.youtube.com/live/dQw4w9WgXcQ",
|
|
19
|
+
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
|
20
|
+
"https://www.youtube.com/v/dQw4w9WgXcQ",
|
|
21
|
+
"https://music.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
22
|
+
"https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf",
|
|
23
|
+
"https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw",
|
|
24
|
+
"https://www.youtube.com/@username",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const invalidUrls = [
|
|
28
|
+
"https://www.example.com/watch?v=dQw4w9WgXcQ",
|
|
29
|
+
"https://vimeo.com/123456",
|
|
30
|
+
"https://www.youtube.com/",
|
|
31
|
+
"https://www.youtube.com/results?search_query=test",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
for (const url of validUrls) {
|
|
35
|
+
test(`matches: ${url}`, () => {
|
|
36
|
+
expect(extractor.canHandle(url)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const url of invalidUrls) {
|
|
41
|
+
test(`rejects: ${url}`, () => {
|
|
42
|
+
expect(extractor.canHandle(url)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("InnerTubeClient format parsing", () => {
|
|
48
|
+
const client = new InnerTubeClient();
|
|
49
|
+
|
|
50
|
+
test("parses muxed and adaptive formats", () => {
|
|
51
|
+
const streamingData: StreamingData = {
|
|
52
|
+
formats: [
|
|
53
|
+
{
|
|
54
|
+
itag: 18,
|
|
55
|
+
url: "https://rr.googlevideo.com/videoplayback?itag=18",
|
|
56
|
+
mimeType: 'video/mp4; codecs="avc1.42001E, mp4a.40.2"',
|
|
57
|
+
bitrate: 500000,
|
|
58
|
+
width: 640,
|
|
59
|
+
height: 360,
|
|
60
|
+
contentLength: "15000000",
|
|
61
|
+
quality: "medium",
|
|
62
|
+
qualityLabel: "360p",
|
|
63
|
+
fps: 30,
|
|
64
|
+
audioChannels: 2,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
adaptiveFormats: [
|
|
68
|
+
{
|
|
69
|
+
itag: 137,
|
|
70
|
+
url: "https://rr.googlevideo.com/videoplayback?itag=137",
|
|
71
|
+
mimeType: 'video/mp4; codecs="avc1.640028"',
|
|
72
|
+
bitrate: 4000000,
|
|
73
|
+
width: 1920,
|
|
74
|
+
height: 1080,
|
|
75
|
+
contentLength: "50000000",
|
|
76
|
+
quality: "hd1080",
|
|
77
|
+
qualityLabel: "1080p",
|
|
78
|
+
fps: 30,
|
|
79
|
+
averageBitrate: 3500000,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
itag: 251,
|
|
83
|
+
url: "https://rr.googlevideo.com/videoplayback?itag=251",
|
|
84
|
+
mimeType: 'audio/webm; codecs="opus"',
|
|
85
|
+
bitrate: 160000,
|
|
86
|
+
contentLength: "5000000",
|
|
87
|
+
audioQuality: "AUDIO_QUALITY_HIGH",
|
|
88
|
+
audioSampleRate: "48000",
|
|
89
|
+
audioChannels: 2,
|
|
90
|
+
averageBitrate: 130000,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const formats = client.parseFormats(streamingData);
|
|
96
|
+
|
|
97
|
+
expect(formats).toHaveLength(3);
|
|
98
|
+
|
|
99
|
+
const muxed = formats[0];
|
|
100
|
+
expect(muxed.format_id).toBe("18");
|
|
101
|
+
expect(muxed.ext).toBe("mp4");
|
|
102
|
+
expect(muxed.width).toBe(640);
|
|
103
|
+
expect(muxed.height).toBe(360);
|
|
104
|
+
expect(muxed.vcodec).toBe("avc1.42001E");
|
|
105
|
+
expect(muxed.acodec).toBe("mp4a.40.2");
|
|
106
|
+
expect(muxed.resolution).toBe("640x360");
|
|
107
|
+
|
|
108
|
+
const videoOnly = formats[1];
|
|
109
|
+
expect(videoOnly.format_id).toBe("137");
|
|
110
|
+
expect(videoOnly.ext).toBe("mp4");
|
|
111
|
+
expect(videoOnly.width).toBe(1920);
|
|
112
|
+
expect(videoOnly.height).toBe(1080);
|
|
113
|
+
expect(videoOnly.vbr).toBe(3500);
|
|
114
|
+
|
|
115
|
+
const audioOnly = formats[2];
|
|
116
|
+
expect(audioOnly.format_id).toBe("251");
|
|
117
|
+
expect(audioOnly.ext).toBe("webm");
|
|
118
|
+
expect(audioOnly.vcodec).toBe("none");
|
|
119
|
+
expect(audioOnly.acodec).toBe("opus");
|
|
120
|
+
expect(audioOnly.abr).toBe(130);
|
|
121
|
+
expect(audioOnly.audio_channels).toBe(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("skips formats without url or signatureCipher", () => {
|
|
125
|
+
const streamingData: StreamingData = {
|
|
126
|
+
formats: [
|
|
127
|
+
{
|
|
128
|
+
itag: 18,
|
|
129
|
+
mimeType: 'video/mp4; codecs="avc1.42001E"',
|
|
130
|
+
bitrate: 500000,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
adaptiveFormats: [],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const formats = client.parseFormats(streamingData);
|
|
137
|
+
expect(formats).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("parses format with signatureCipher as having empty url", () => {
|
|
141
|
+
const streamingData: StreamingData = {
|
|
142
|
+
formats: [
|
|
143
|
+
{
|
|
144
|
+
itag: 18,
|
|
145
|
+
signatureCipher: "s=test&sp=sig&url=https%3A%2F%2Fexample.com",
|
|
146
|
+
mimeType: 'video/mp4; codecs="avc1.42001E"',
|
|
147
|
+
bitrate: 500000,
|
|
148
|
+
width: 640,
|
|
149
|
+
height: 360,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
adaptiveFormats: [],
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const formats = client.parseFormats(streamingData);
|
|
156
|
+
expect(formats).toHaveLength(1);
|
|
157
|
+
expect(formats[0].url).toBe("");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("Caption parsing", () => {
|
|
162
|
+
test("separates manual and auto-generated captions", () => {
|
|
163
|
+
const tracks: CaptionTrack[] = [
|
|
164
|
+
{
|
|
165
|
+
baseUrl: "https://www.youtube.com/api/timedtext?v=test&lang=en",
|
|
166
|
+
name: { simpleText: "English" },
|
|
167
|
+
vssId: ".en",
|
|
168
|
+
languageCode: "en",
|
|
169
|
+
isTranslatable: true,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
baseUrl: "https://www.youtube.com/api/timedtext?v=test&lang=en&kind=asr",
|
|
173
|
+
name: { simpleText: "English (auto-generated)" },
|
|
174
|
+
vssId: "a.en",
|
|
175
|
+
languageCode: "en",
|
|
176
|
+
kind: "asr",
|
|
177
|
+
isTranslatable: true,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
baseUrl: "https://www.youtube.com/api/timedtext?v=test&lang=es",
|
|
181
|
+
name: { simpleText: "Spanish" },
|
|
182
|
+
vssId: ".es",
|
|
183
|
+
languageCode: "es",
|
|
184
|
+
isTranslatable: true,
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
const { subtitles, automatic_captions } = parseCaptionTracks(tracks);
|
|
189
|
+
|
|
190
|
+
expect(Object.keys(subtitles)).toEqual(["en", "es"]);
|
|
191
|
+
expect(Object.keys(automatic_captions)).toEqual(["en"]);
|
|
192
|
+
|
|
193
|
+
expect(subtitles.en).toHaveLength(3);
|
|
194
|
+
expect(subtitles.en[0].ext).toBe("json3");
|
|
195
|
+
expect(subtitles.en[1].ext).toBe("vtt");
|
|
196
|
+
expect(subtitles.en[2].ext).toBe("srv1");
|
|
197
|
+
|
|
198
|
+
expect(automatic_captions.en).toHaveLength(3);
|
|
199
|
+
expect(automatic_captions.en[0].name).toBe("English (auto-generated)");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("Caption format conversion", () => {
|
|
204
|
+
const events: TimedTextEvent[] = [
|
|
205
|
+
{
|
|
206
|
+
tStartMs: 0,
|
|
207
|
+
dDurationMs: 2000,
|
|
208
|
+
segs: [{ utf8: "Hello " }, { utf8: "world" }],
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
tStartMs: 2500,
|
|
212
|
+
dDurationMs: 3000,
|
|
213
|
+
segs: [{ utf8: "This is a test" }],
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
tStartMs: 6000,
|
|
217
|
+
dDurationMs: 1000,
|
|
218
|
+
segs: [],
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
test("converts to SRT format", () => {
|
|
223
|
+
const srt = convertToSrt(events);
|
|
224
|
+
const lines = srt.split("\n");
|
|
225
|
+
|
|
226
|
+
expect(lines[0]).toBe("1");
|
|
227
|
+
expect(lines[1]).toBe("00:00:00,000 --> 00:00:02,000");
|
|
228
|
+
expect(lines[2]).toBe("Hello world");
|
|
229
|
+
expect(lines[3]).toBe("");
|
|
230
|
+
expect(lines[4]).toBe("2");
|
|
231
|
+
expect(lines[5]).toBe("00:00:02,500 --> 00:00:05,500");
|
|
232
|
+
expect(lines[6]).toBe("This is a test");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("converts to VTT format", () => {
|
|
236
|
+
const vtt = convertToVtt(events);
|
|
237
|
+
const lines = vtt.split("\n");
|
|
238
|
+
|
|
239
|
+
expect(lines[0]).toBe("WEBVTT");
|
|
240
|
+
expect(lines[1]).toBe("");
|
|
241
|
+
expect(lines[2]).toBe("00:00:00.000 --> 00:00:02.000");
|
|
242
|
+
expect(lines[3]).toBe("Hello world");
|
|
243
|
+
expect(lines[4]).toBe("");
|
|
244
|
+
expect(lines[5]).toBe("00:00:02.500 --> 00:00:05.500");
|
|
245
|
+
expect(lines[6]).toBe("This is a test");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("skips events with empty segments", () => {
|
|
249
|
+
const srt = convertToSrt(events);
|
|
250
|
+
expect(srt).not.toContain("3\n");
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("YouTubeExtractor metadata", () => {
|
|
255
|
+
test("has correct extractor name", () => {
|
|
256
|
+
const extractor = new YouTubeExtractor();
|
|
257
|
+
expect(extractor._NAME).toBe("youtube");
|
|
258
|
+
});
|
|
259
|
+
});
|