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.
Files changed (105) hide show
  1. package/.gitattributes +4 -0
  2. package/CLAUDE.md +57 -0
  3. package/README.md +166 -0
  4. package/RESEARCH.md +109 -0
  5. package/STATUS.md +23 -0
  6. package/bun.lock +50 -0
  7. package/bunfig.toml +3 -0
  8. package/docs/plugin-guide.md +166 -0
  9. package/docs/supported-sites.md +41 -0
  10. package/package.json +30 -0
  11. package/src/cli/index.ts +52 -0
  12. package/src/cli/options.ts +97 -0
  13. package/src/core/format-sorter.ts +208 -0
  14. package/src/core/logger.ts +101 -0
  15. package/src/core/orchestrator.ts +140 -0
  16. package/src/core/output-template.ts +58 -0
  17. package/src/core/types.ts +237 -0
  18. package/src/downloaders/base.ts +25 -0
  19. package/src/downloaders/dash.ts +287 -0
  20. package/src/downloaders/fragment.ts +226 -0
  21. package/src/downloaders/hls.ts +170 -0
  22. package/src/downloaders/http.ts +260 -0
  23. package/src/extractors/archive-org.ts +126 -0
  24. package/src/extractors/bandcamp.ts +130 -0
  25. package/src/extractors/base.ts +29 -0
  26. package/src/extractors/bilibili/bangumi.ts +205 -0
  27. package/src/extractors/bilibili/index.ts +233 -0
  28. package/src/extractors/bilibili/wbi.ts +60 -0
  29. package/src/extractors/coub.ts +137 -0
  30. package/src/extractors/dailymotion.ts +99 -0
  31. package/src/extractors/dropbox.ts +52 -0
  32. package/src/extractors/generic.ts +118 -0
  33. package/src/extractors/google-drive.ts +106 -0
  34. package/src/extractors/imgur.ts +156 -0
  35. package/src/extractors/instagram/index.ts +263 -0
  36. package/src/extractors/instagram/reels.ts +166 -0
  37. package/src/extractors/kick/clips.ts +91 -0
  38. package/src/extractors/kick/index.ts +118 -0
  39. package/src/extractors/kick/live.ts +89 -0
  40. package/src/extractors/niconico/index.ts +209 -0
  41. package/src/extractors/odysee.ts +126 -0
  42. package/src/extractors/peertube.ts +143 -0
  43. package/src/extractors/reddit/gallery.ts +124 -0
  44. package/src/extractors/reddit/index.ts +203 -0
  45. package/src/extractors/rumble.ts +127 -0
  46. package/src/extractors/soundcloud/index.ts +161 -0
  47. package/src/extractors/soundcloud/playlist.ts +129 -0
  48. package/src/extractors/spotify.ts +97 -0
  49. package/src/extractors/streamable.ts +121 -0
  50. package/src/extractors/ted.ts +151 -0
  51. package/src/extractors/tiktok/index.ts +207 -0
  52. package/src/extractors/tiktok/user.ts +176 -0
  53. package/src/extractors/twitch/clips.ts +125 -0
  54. package/src/extractors/twitch/index.ts +136 -0
  55. package/src/extractors/twitch/live.ts +132 -0
  56. package/src/extractors/twitter/index.ts +140 -0
  57. package/src/extractors/twitter/spaces.ts +200 -0
  58. package/src/extractors/vimeo/index.ts +187 -0
  59. package/src/extractors/youtube/captions.ts +111 -0
  60. package/src/extractors/youtube/index.ts +252 -0
  61. package/src/extractors/youtube/innertube.ts +364 -0
  62. package/src/extractors/youtube/nsig.ts +105 -0
  63. package/src/extractors/youtube/playlist.ts +227 -0
  64. package/src/extractors/youtube/signature.ts +163 -0
  65. package/src/networking/client.ts +311 -0
  66. package/src/networking/cookies.ts +138 -0
  67. package/src/networking/proxy.ts +132 -0
  68. package/src/networking/tls.ts +67 -0
  69. package/src/networking/user-agents.ts +88 -0
  70. package/src/postprocessors/base.ts +44 -0
  71. package/src/postprocessors/extract-audio.ts +98 -0
  72. package/src/postprocessors/ffmpeg.ts +146 -0
  73. package/src/postprocessors/merge.ts +102 -0
  74. package/src/postprocessors/metadata.ts +73 -0
  75. package/src/postprocessors/sponsorblock.ts +162 -0
  76. package/src/postprocessors/subtitles.ts +285 -0
  77. package/src/postprocessors/thumbnails.ts +194 -0
  78. package/src/utils/sanitize.ts +36 -0
  79. package/src/utils/traverse.ts +68 -0
  80. package/tests/core/format-sorter.test.ts +96 -0
  81. package/tests/core/output-template.test.ts +56 -0
  82. package/tests/core/types.test.ts +79 -0
  83. package/tests/unit/downloaders/dash.test.ts +57 -0
  84. package/tests/unit/downloaders/hls.test.ts +120 -0
  85. package/tests/unit/downloaders/http.test.ts +114 -0
  86. package/tests/unit/extractors/bilibili.test.ts +83 -0
  87. package/tests/unit/extractors/instagram.test.ts +273 -0
  88. package/tests/unit/extractors/kick.test.ts +85 -0
  89. package/tests/unit/extractors/misc.test.ts +942 -0
  90. package/tests/unit/extractors/niconico.test.ts +61 -0
  91. package/tests/unit/extractors/reddit.test.ts +222 -0
  92. package/tests/unit/extractors/soundcloud.test.ts +299 -0
  93. package/tests/unit/extractors/tiktok.test.ts +260 -0
  94. package/tests/unit/extractors/twitch.test.ts +250 -0
  95. package/tests/unit/extractors/twitter.test.ts +181 -0
  96. package/tests/unit/extractors/vimeo.test.ts +253 -0
  97. package/tests/unit/extractors/youtube.test.ts +259 -0
  98. package/tests/unit/networking/client.test.ts +272 -0
  99. package/tests/unit/networking/cookies.test.ts +256 -0
  100. package/tests/unit/networking/proxy.test.ts +137 -0
  101. package/tests/unit/postprocessors/extract-audio.test.ts +63 -0
  102. package/tests/unit/postprocessors/merge.test.ts +61 -0
  103. package/tests/unit/postprocessors/subtitles.test.ts +89 -0
  104. package/tools/dashboard.ts +112 -0
  105. package/tsconfig.json +17 -0
@@ -0,0 +1,61 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { NiconicoExtractor } from "../../../src/extractors/niconico/index";
3
+ import { ExtractorError } from "../../../src/core/types";
4
+
5
+ describe("NiconicoExtractor", () => {
6
+ const extractor = new NiconicoExtractor();
7
+
8
+ test("canHandle nicovideo.jp/watch/sm* URLs", () => {
9
+ expect(extractor.canHandle("https://www.nicovideo.jp/watch/sm9")).toBe(true);
10
+ expect(extractor.canHandle("https://nicovideo.jp/watch/sm12345678")).toBe(true);
11
+ });
12
+
13
+ test("canHandle nicovideo.jp/watch/nm* URLs", () => {
14
+ expect(extractor.canHandle("https://www.nicovideo.jp/watch/nm1234")).toBe(true);
15
+ expect(extractor.canHandle("https://nicovideo.jp/watch/nm5678")).toBe(true);
16
+ });
17
+
18
+ test("rejects non-niconico URLs", () => {
19
+ expect(extractor.canHandle("https://youtube.com/watch?v=sm9")).toBe(false);
20
+ expect(extractor.canHandle("https://nicovideo.jp/user/12345")).toBe(false);
21
+ expect(extractor.canHandle("https://nicovideo.jp/watch/lv12345")).toBe(false);
22
+ });
23
+
24
+ test("has correct extractor name", () => {
25
+ expect(extractor._NAME).toBe("niconico");
26
+ });
27
+
28
+ test("throws ExtractorError for unsupported URL", async () => {
29
+ await expect(extractor.extract("https://example.com/watch/sm1")).rejects.toThrow(ExtractorError);
30
+ });
31
+ });
32
+
33
+ describe("Niconico URL pattern matching", () => {
34
+ const extractor = new NiconicoExtractor();
35
+
36
+ const validUrls = [
37
+ "https://www.nicovideo.jp/watch/sm9",
38
+ "https://nicovideo.jp/watch/sm12345678",
39
+ "https://www.nicovideo.jp/watch/nm1234",
40
+ "https://nicovideo.jp/watch/nm9999999",
41
+ ];
42
+
43
+ const invalidUrls = [
44
+ "https://www.nicovideo.jp/watch/lv12345",
45
+ "https://www.nicovideo.jp/user/12345",
46
+ "https://nicovideo.jp/",
47
+ "https://youtube.com/watch/sm9",
48
+ ];
49
+
50
+ for (const url of validUrls) {
51
+ test(`matches: ${url}`, () => {
52
+ expect(extractor.canHandle(url)).toBe(true);
53
+ });
54
+ }
55
+
56
+ for (const url of invalidUrls) {
57
+ test(`rejects: ${url}`, () => {
58
+ expect(extractor.canHandle(url)).toBe(false);
59
+ });
60
+ }
61
+ });
@@ -0,0 +1,222 @@
1
+ import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
2
+ import { RedditExtractor } from "../../../src/extractors/reddit/index";
3
+ import { RedditGalleryExtractor } from "../../../src/extractors/reddit/gallery";
4
+ import { ExtractorError } from "../../../src/core/types";
5
+
6
+ const redditExtractor = new RedditExtractor();
7
+ const galleryExtractor = new RedditGalleryExtractor();
8
+
9
+ describe("RedditExtractor", () => {
10
+ test("canHandle matches standard reddit video URL", () => {
11
+ expect(redditExtractor.canHandle("https://www.reddit.com/r/videos/comments/abc123/my_video/")).toBe(true);
12
+ });
13
+
14
+ test("canHandle matches old.reddit.com URL", () => {
15
+ expect(redditExtractor.canHandle("https://old.reddit.com/r/funny/comments/xyz789/title/")).toBe(true);
16
+ });
17
+
18
+ test("canHandle matches v.redd.it URL", () => {
19
+ expect(redditExtractor.canHandle("https://v.redd.it/abc123def")).toBe(true);
20
+ });
21
+
22
+ test("canHandle rejects non-reddit URL", () => {
23
+ expect(redditExtractor.canHandle("https://youtube.com/watch?v=abc")).toBe(false);
24
+ });
25
+
26
+ test("canHandle rejects reddit non-video URL", () => {
27
+ expect(redditExtractor.canHandle("https://www.reddit.com/r/pics/")).toBe(false);
28
+ });
29
+
30
+ test("_NAME is reddit", () => {
31
+ expect(redditExtractor._NAME).toBe("reddit");
32
+ });
33
+
34
+ test("extract throws ExtractorError on failed fetch", async () => {
35
+ const originalFetch = globalThis.fetch;
36
+ globalThis.fetch = mock(() => Promise.resolve(new Response(null, { status: 404 })));
37
+
38
+ await expect(
39
+ redditExtractor.extract("https://www.reddit.com/r/videos/comments/abc123/title/")
40
+ ).rejects.toThrow(ExtractorError);
41
+
42
+ globalThis.fetch = originalFetch;
43
+ });
44
+
45
+ test("extract parses reddit video JSON response", async () => {
46
+ const originalFetch = globalThis.fetch;
47
+
48
+ const mockPostData = {
49
+ id: "abc123",
50
+ title: "Test Video",
51
+ author: "testuser",
52
+ url: "https://v.redd.it/xyz",
53
+ score: 1000,
54
+ created_utc: 1700000000,
55
+ secure_media: {
56
+ reddit_video: {
57
+ dash_url: "https://v.redd.it/xyz/DASHPlaylist.mpd",
58
+ fallback_url: "https://v.redd.it/xyz/DASH_720.mp4",
59
+ width: 1280,
60
+ height: 720,
61
+ duration: 30,
62
+ },
63
+ },
64
+ };
65
+
66
+ globalThis.fetch = mock(() =>
67
+ Promise.resolve(
68
+ new Response(
69
+ JSON.stringify([
70
+ { data: { children: [{ data: mockPostData }] } },
71
+ { data: { children: [] } },
72
+ ]),
73
+ { status: 200, headers: { "Content-Type": "application/json" } }
74
+ )
75
+ )
76
+ );
77
+
78
+ const info = await redditExtractor.extract(
79
+ "https://www.reddit.com/r/videos/comments/abc123/title/"
80
+ );
81
+
82
+ expect(info.id).toBe("abc123");
83
+ expect(info.title).toBe("Test Video");
84
+ expect(info.uploader).toBe("testuser");
85
+ expect(info.duration).toBe(30);
86
+ expect(info.formats).toBeDefined();
87
+ expect(info.formats!.length).toBeGreaterThan(0);
88
+
89
+ const hasVideoFormat = info.formats!.some((f) => f.format_id === "mp4-video-only");
90
+ const hasDashFormat = info.formats!.some((f) => f.format_id === "dash");
91
+ expect(hasVideoFormat).toBe(true);
92
+ expect(hasDashFormat).toBe(true);
93
+
94
+ globalThis.fetch = originalFetch;
95
+ });
96
+
97
+ test("extract throws ExtractorError when no video data", async () => {
98
+ const originalFetch = globalThis.fetch;
99
+
100
+ const mockPostData = {
101
+ id: "abc123",
102
+ title: "Text Post",
103
+ author: "testuser",
104
+ url: "https://www.reddit.com/r/text/comments/abc123/",
105
+ };
106
+
107
+ globalThis.fetch = mock(() =>
108
+ Promise.resolve(
109
+ new Response(
110
+ JSON.stringify([{ data: { children: [{ data: mockPostData }] } }]),
111
+ { status: 200, headers: { "Content-Type": "application/json" } }
112
+ )
113
+ )
114
+ );
115
+
116
+ await expect(
117
+ redditExtractor.extract("https://www.reddit.com/r/text/comments/abc123/title/")
118
+ ).rejects.toThrow(ExtractorError);
119
+
120
+ globalThis.fetch = originalFetch;
121
+ });
122
+ });
123
+
124
+ describe("RedditGalleryExtractor", () => {
125
+ test("canHandle matches gallery URL", () => {
126
+ expect(galleryExtractor.canHandle("https://www.reddit.com/gallery/abc123")).toBe(true);
127
+ });
128
+
129
+ test("canHandle matches gallery post URL", () => {
130
+ expect(
131
+ galleryExtractor.canHandle(
132
+ "https://www.reddit.com/r/aww/comments/abc123/my_gallery/"
133
+ )
134
+ ).toBe(true);
135
+ });
136
+
137
+ test("_NAME is reddit:gallery", () => {
138
+ expect(galleryExtractor._NAME).toBe("reddit:gallery");
139
+ });
140
+
141
+ test("extract returns playlist type for gallery post", async () => {
142
+ const originalFetch = globalThis.fetch;
143
+
144
+ const mockPostData = {
145
+ id: "gal123",
146
+ title: "My Gallery",
147
+ author: "galleryuser",
148
+ score: 500,
149
+ created_utc: 1700000000,
150
+ gallery_data: {
151
+ items: [
152
+ { media_id: "img1", id: 1, caption: "First image" },
153
+ { media_id: "img2", id: 2 },
154
+ ],
155
+ },
156
+ media_metadata: {
157
+ img1: {
158
+ e: "Image",
159
+ m: "image/jpg",
160
+ id: "img1",
161
+ s: { u: "https://i.redd.it/img1.jpg", x: 1920, y: 1080 },
162
+ p: [{ u: "https://i.redd.it/img1_preview.jpg", x: 640, y: 360 }],
163
+ },
164
+ img2: {
165
+ e: "Image",
166
+ m: "image/png",
167
+ id: "img2",
168
+ s: { u: "https://i.redd.it/img2.png", x: 800, y: 600 },
169
+ p: [],
170
+ },
171
+ },
172
+ };
173
+
174
+ globalThis.fetch = mock(() =>
175
+ Promise.resolve(
176
+ new Response(
177
+ JSON.stringify([{ data: { children: [{ data: mockPostData }] } }]),
178
+ { status: 200, headers: { "Content-Type": "application/json" } }
179
+ )
180
+ )
181
+ );
182
+
183
+ const info = await galleryExtractor.extract(
184
+ "https://www.reddit.com/r/aww/comments/gal123/my_gallery/"
185
+ );
186
+
187
+ expect(info._type).toBe("playlist");
188
+ expect(info.id).toBe("gal123");
189
+ expect(info.title).toBe("My Gallery");
190
+ expect(info.entries).toBeDefined();
191
+ expect(info.entries!.length).toBe(2);
192
+ expect(info.playlist_count).toBe(2);
193
+ expect(info.entries![0].title).toBe("First image");
194
+
195
+ globalThis.fetch = originalFetch;
196
+ });
197
+
198
+ test("extract throws ExtractorError when no gallery data", async () => {
199
+ const originalFetch = globalThis.fetch;
200
+
201
+ const mockPostData = {
202
+ id: "txt123",
203
+ title: "Text Post",
204
+ author: "user",
205
+ };
206
+
207
+ globalThis.fetch = mock(() =>
208
+ Promise.resolve(
209
+ new Response(
210
+ JSON.stringify([{ data: { children: [{ data: mockPostData }] } }]),
211
+ { status: 200, headers: { "Content-Type": "application/json" } }
212
+ )
213
+ )
214
+ );
215
+
216
+ await expect(
217
+ galleryExtractor.extract("https://www.reddit.com/r/text/comments/txt123/title/")
218
+ ).rejects.toThrow(ExtractorError);
219
+
220
+ globalThis.fetch = originalFetch;
221
+ });
222
+ });
@@ -0,0 +1,299 @@
1
+ import { describe, test, expect, mock } from "bun:test";
2
+ import { SoundCloudExtractor } from "../../../src/extractors/soundcloud/index";
3
+ import { SoundCloudPlaylistExtractor } from "../../../src/extractors/soundcloud/playlist";
4
+ import { ExtractorError } from "../../../src/core/types";
5
+
6
+ const trackExtractor = new SoundCloudExtractor();
7
+ const playlistExtractor = new SoundCloudPlaylistExtractor();
8
+
9
+ const FAKE_CLIENT_ID = "abc123def456ghi789jkl012mno345pq";
10
+
11
+ function makeHTMLWithClientId(clientId: string): string {
12
+ return `
13
+ <html>
14
+ <head><title>SoundCloud</title></head>
15
+ <body>
16
+ <script src="https://a-v2.sndcdn.com/assets/50-abc123.js"></script>
17
+ </body>
18
+ </html>
19
+ `.trim();
20
+ }
21
+
22
+ function makeJSBundle(clientId: string): string {
23
+ return `(function(){var t=1,client_id:"${clientId}",exports={}}())`;
24
+ }
25
+
26
+ const mockTrackData = {
27
+ id: 987654321,
28
+ title: "My Track",
29
+ description: "A great track",
30
+ duration: 210000,
31
+ playback_count: 50000,
32
+ likes_count: 2000,
33
+ comment_count: 100,
34
+ created_at: "2024-01-20T10:00:00Z",
35
+ genre: "Electronic",
36
+ tag_list: '"deep house" techno ambient',
37
+ permalink_url: "https://soundcloud.com/artist/my-track",
38
+ user: {
39
+ id: 111222333,
40
+ username: "Artist Name",
41
+ permalink_url: "https://soundcloud.com/artist",
42
+ },
43
+ artwork_url: "https://i1.sndcdn.com/artworks-large.jpg",
44
+ media: {
45
+ transcodings: [
46
+ {
47
+ url: "https://api-v2.soundcloud.com/media/soundcloud:tracks:987654321/hls",
48
+ preset: "mp3_0_0",
49
+ duration: 210000,
50
+ format: { protocol: "hls", mime_type: "audio/mpeg" },
51
+ quality: "sq",
52
+ },
53
+ {
54
+ url: "https://api-v2.soundcloud.com/media/soundcloud:tracks:987654321/hls_aac",
55
+ preset: "aac_1_0",
56
+ duration: 210000,
57
+ format: { protocol: "hls", mime_type: "audio/mp4" },
58
+ quality: "sq",
59
+ },
60
+ {
61
+ url: "https://api-v2.soundcloud.com/media/soundcloud:tracks:987654321/progressive",
62
+ preset: "mp3_0_0",
63
+ duration: 210000,
64
+ format: { protocol: "progressive", mime_type: "audio/mpeg" },
65
+ quality: "sq",
66
+ },
67
+ ],
68
+ },
69
+ };
70
+
71
+ describe("SoundCloudExtractor", () => {
72
+ test("canHandle matches track URL", () => {
73
+ expect(trackExtractor.canHandle("https://soundcloud.com/artist/my-track")).toBe(true);
74
+ expect(trackExtractor.canHandle("https://www.soundcloud.com/artist/track-name")).toBe(true);
75
+ expect(trackExtractor.canHandle("https://m.soundcloud.com/artist/track-name")).toBe(true);
76
+ });
77
+
78
+ test("canHandle rejects playlist URL", () => {
79
+ expect(trackExtractor.canHandle("https://soundcloud.com/artist/sets/my-playlist")).toBe(false);
80
+ });
81
+
82
+ test("canHandle rejects non-soundcloud URL", () => {
83
+ expect(trackExtractor.canHandle("https://spotify.com/track/abc")).toBe(false);
84
+ });
85
+
86
+ test("_NAME is soundcloud", () => {
87
+ expect(trackExtractor._NAME).toBe("soundcloud");
88
+ });
89
+
90
+ test("extract fetches client_id and resolves track", async () => {
91
+ const originalFetch = globalThis.fetch;
92
+
93
+ const streamUrls: Record<string, string> = {
94
+ "https://api-v2.soundcloud.com/media/soundcloud:tracks:987654321/hls": "https://cf-hls-media.sndcdn.com/playlist.m3u8",
95
+ "https://api-v2.soundcloud.com/media/soundcloud:tracks:987654321/hls_aac": "https://cf-hls-media.sndcdn.com/aac_playlist.m3u8",
96
+ "https://api-v2.soundcloud.com/media/soundcloud:tracks:987654321/progressive": "https://cf-media.sndcdn.com/track.mp3",
97
+ };
98
+
99
+ globalThis.fetch = mock((input: string | URL | Request) => {
100
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
101
+
102
+ if (url.includes("soundcloud.com/artist/my-track") && !url.includes("api-v2")) {
103
+ return Promise.resolve(
104
+ new Response(makeHTMLWithClientId(FAKE_CLIENT_ID), {
105
+ status: 200,
106
+ headers: { "Content-Type": "text/html" },
107
+ })
108
+ );
109
+ }
110
+ if (url.includes("a-v2.sndcdn.com/assets")) {
111
+ return Promise.resolve(
112
+ new Response(makeJSBundle(FAKE_CLIENT_ID), {
113
+ status: 200,
114
+ headers: { "Content-Type": "application/javascript" },
115
+ })
116
+ );
117
+ }
118
+ if (url.includes("api-v2.soundcloud.com/resolve")) {
119
+ return Promise.resolve(
120
+ new Response(JSON.stringify(mockTrackData), {
121
+ status: 200,
122
+ headers: { "Content-Type": "application/json" },
123
+ })
124
+ );
125
+ }
126
+ for (const [transcodeUrl, streamUrl] of Object.entries(streamUrls)) {
127
+ if (url.startsWith(transcodeUrl)) {
128
+ return Promise.resolve(
129
+ new Response(JSON.stringify({ url: streamUrl }), {
130
+ status: 200,
131
+ headers: { "Content-Type": "application/json" },
132
+ })
133
+ );
134
+ }
135
+ }
136
+ return Promise.resolve(new Response(null, { status: 404 }));
137
+ });
138
+
139
+ const info = await trackExtractor.extract("https://soundcloud.com/artist/my-track");
140
+
141
+ expect(info.id).toBe("987654321");
142
+ expect(info.title).toBe("My Track");
143
+ expect(info.description).toBe("A great track");
144
+ expect(info.uploader).toBe("Artist Name");
145
+ expect(info.duration).toBe(210);
146
+ expect(info.view_count).toBe(50000);
147
+ expect(info.like_count).toBe(2000);
148
+ expect(info.upload_date).toBe("20240120");
149
+ expect(info.categories).toEqual(["Electronic"]);
150
+ expect(info.formats).toBeDefined();
151
+ expect(info.formats!.length).toBeGreaterThan(0);
152
+
153
+ const hlsFormats = info.formats!.filter((f) => f.protocol === "m3u8");
154
+ expect(hlsFormats.length).toBeGreaterThan(0);
155
+
156
+ expect(info.thumbnails).toBeDefined();
157
+ expect(info.thumbnails!.some((t) => t.url.includes("t500x500"))).toBe(true);
158
+
159
+ globalThis.fetch = originalFetch;
160
+ });
161
+
162
+ test("extract throws ExtractorError when page fetch fails", async () => {
163
+ const originalFetch = globalThis.fetch;
164
+
165
+ globalThis.fetch = mock(() => Promise.resolve(new Response(null, { status: 500 })));
166
+
167
+ await expect(
168
+ trackExtractor.extract("https://soundcloud.com/artist/track")
169
+ ).rejects.toThrow(ExtractorError);
170
+
171
+ globalThis.fetch = originalFetch;
172
+ });
173
+
174
+ test("extract throws ExtractorError when no JS bundle found", async () => {
175
+ const originalFetch = globalThis.fetch;
176
+
177
+ globalThis.fetch = mock((input: string | URL | Request) => {
178
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
179
+ if (url.includes("soundcloud.com") && !url.includes("a-v2.sndcdn")) {
180
+ return Promise.resolve(
181
+ new Response("<html><body>no scripts here</body></html>", {
182
+ status: 200,
183
+ headers: { "Content-Type": "text/html" },
184
+ })
185
+ );
186
+ }
187
+ return Promise.resolve(new Response(null, { status: 404 }));
188
+ });
189
+
190
+ await expect(
191
+ trackExtractor.extract("https://soundcloud.com/artist/track")
192
+ ).rejects.toThrow(ExtractorError);
193
+
194
+ globalThis.fetch = originalFetch;
195
+ });
196
+ });
197
+
198
+ describe("SoundCloudPlaylistExtractor", () => {
199
+ test("canHandle matches sets URL", () => {
200
+ expect(
201
+ playlistExtractor.canHandle("https://soundcloud.com/artist/sets/my-playlist")
202
+ ).toBe(true);
203
+ });
204
+
205
+ test("canHandle rejects track URL", () => {
206
+ expect(
207
+ playlistExtractor.canHandle("https://soundcloud.com/artist/my-track")
208
+ ).toBe(false);
209
+ });
210
+
211
+ test("_NAME is soundcloud:playlist", () => {
212
+ expect(playlistExtractor._NAME).toBe("soundcloud:playlist");
213
+ });
214
+
215
+ test("extract returns playlist with tracks", async () => {
216
+ const originalFetch = globalThis.fetch;
217
+
218
+ const mockPlaylistData = {
219
+ id: 111111111,
220
+ title: "My Playlist",
221
+ description: "A playlist",
222
+ duration: 600000,
223
+ track_count: 3,
224
+ likes_count: 500,
225
+ created_at: "2024-02-01T12:00:00Z",
226
+ permalink_url: "https://soundcloud.com/artist/sets/my-playlist",
227
+ user: { id: 111222333, username: "Artist Name", permalink_url: "https://soundcloud.com/artist" },
228
+ };
229
+
230
+ const mockTracksPage = {
231
+ collection: [
232
+ { id: 111, title: "Track One", permalink_url: "https://soundcloud.com/artist/track-one" },
233
+ { id: 222, title: "Track Two", permalink_url: "https://soundcloud.com/artist/track-two" },
234
+ { id: 333, title: "Track Three", permalink_url: "https://soundcloud.com/artist/track-three" },
235
+ ],
236
+ next_href: null,
237
+ };
238
+
239
+ let callCount = 0;
240
+ globalThis.fetch = mock((input: string | URL | Request) => {
241
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
242
+ callCount++;
243
+
244
+ if (url.includes("soundcloud.com/artist/sets") && !url.includes("api-v2")) {
245
+ return Promise.resolve(
246
+ new Response(makeHTMLWithClientId(FAKE_CLIENT_ID), {
247
+ status: 200,
248
+ headers: { "Content-Type": "text/html" },
249
+ })
250
+ );
251
+ }
252
+ if (url.includes("a-v2.sndcdn.com/assets")) {
253
+ return Promise.resolve(
254
+ new Response(makeJSBundle(FAKE_CLIENT_ID), {
255
+ status: 200,
256
+ headers: { "Content-Type": "application/javascript" },
257
+ })
258
+ );
259
+ }
260
+ if (url.includes("api-v2.soundcloud.com/resolve")) {
261
+ return Promise.resolve(
262
+ new Response(JSON.stringify(mockPlaylistData), {
263
+ status: 200,
264
+ headers: { "Content-Type": "application/json" },
265
+ })
266
+ );
267
+ }
268
+ if (url.includes("api-v2.soundcloud.com/playlists")) {
269
+ return Promise.resolve(
270
+ new Response(JSON.stringify(mockTracksPage), {
271
+ status: 200,
272
+ headers: { "Content-Type": "application/json" },
273
+ })
274
+ );
275
+ }
276
+ return Promise.resolve(new Response(null, { status: 404 }));
277
+ });
278
+
279
+ const info = await playlistExtractor.extract(
280
+ "https://soundcloud.com/artist/sets/my-playlist"
281
+ );
282
+
283
+ expect(info._type).toBe("playlist");
284
+ expect(info.id).toBe("111111111");
285
+ expect(info.title).toBe("My Playlist");
286
+ expect(info.uploader).toBe("Artist Name");
287
+ expect(info.entries).toBeDefined();
288
+ expect(info.entries!.length).toBe(3);
289
+ expect(info.playlist_count).toBe(3);
290
+
291
+ const firstEntry = info.entries![0];
292
+ expect(firstEntry.id).toBe("111");
293
+ expect(firstEntry.title).toBe("Track One");
294
+ expect(firstEntry._type).toBe("url");
295
+ expect(firstEntry.playlist_index).toBe(1);
296
+
297
+ globalThis.fetch = originalFetch;
298
+ });
299
+ });