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,36 @@
1
+ const ILLEGAL_CHARS = /[<>:"/\\|?*\x00-\x1F]/g;
2
+ const RESERVED_NAMES = new Set([
3
+ "CON", "PRN", "AUX", "NUL",
4
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
5
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
6
+ ]);
7
+
8
+ export function sanitizeFilename(filename: string): string {
9
+ let sanitized = filename.replace(ILLEGAL_CHARS, "_");
10
+ sanitized = sanitized.replace(/\.+$/, "");
11
+ sanitized = sanitized.replace(/\s+/g, " ").trim();
12
+
13
+ if (sanitized.length === 0) {
14
+ sanitized = "download";
15
+ }
16
+
17
+ const parts = sanitized.split(".");
18
+ if (parts.length > 1) {
19
+ const name = parts.slice(0, -1).join(".");
20
+ const ext = parts[parts.length - 1];
21
+ if (RESERVED_NAMES.has(name.toUpperCase())) {
22
+ return `_${name}.${ext}`;
23
+ }
24
+ return `${name}.${ext}`;
25
+ }
26
+
27
+ if (RESERVED_NAMES.has(sanitized.toUpperCase())) {
28
+ return `_${sanitized}`;
29
+ }
30
+
31
+ return sanitized;
32
+ }
33
+
34
+ export function sanitizePath(path: string): string {
35
+ return path.split("/").map(sanitizeFilename).join("/");
36
+ }
@@ -0,0 +1,68 @@
1
+ type PathSegment = string | number | ((val: unknown) => boolean);
2
+ type TraversePath = PathSegment[];
3
+
4
+ export function traverse_obj(
5
+ obj: unknown,
6
+ ...paths: TraversePath[]
7
+ ): unknown {
8
+ for (const path of paths) {
9
+ const result = walkPath(obj, path);
10
+ if (result !== undefined) return result;
11
+ }
12
+ return undefined;
13
+ }
14
+
15
+ function walkPath(obj: unknown, path: TraversePath): unknown {
16
+ let current: unknown = obj;
17
+
18
+ for (const segment of path) {
19
+ if (current === null || current === undefined) return undefined;
20
+
21
+ if (typeof segment === "function") {
22
+ if (!segment(current)) return undefined;
23
+ continue;
24
+ }
25
+
26
+ if (typeof segment === "number") {
27
+ if (Array.isArray(current)) {
28
+ current = current[segment];
29
+ } else {
30
+ return undefined;
31
+ }
32
+ continue;
33
+ }
34
+
35
+ if (typeof segment === "string") {
36
+ if (segment === "...") {
37
+ return deepSearch(current, path.slice(path.indexOf(segment) + 1));
38
+ }
39
+
40
+ if (typeof current === "object" && current !== null) {
41
+ current = (current as Record<string, unknown>)[segment];
42
+ } else {
43
+ return undefined;
44
+ }
45
+ continue;
46
+ }
47
+ }
48
+
49
+ return current;
50
+ }
51
+
52
+ function deepSearch(obj: unknown, remainingPath: TraversePath): unknown {
53
+ if (obj === null || obj === undefined) return undefined;
54
+ if (typeof obj !== "object") return undefined;
55
+
56
+ const result = walkPath(obj, remainingPath);
57
+ if (result !== undefined) return result;
58
+
59
+ const values = Array.isArray(obj) ? obj : Object.values(obj);
60
+ for (const value of values) {
61
+ if (typeof value === "object" && value !== null) {
62
+ const found = deepSearch(value, remainingPath);
63
+ if (found !== undefined) return found;
64
+ }
65
+ }
66
+
67
+ return undefined;
68
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { selectFormats, sortFormats, parseFormatString, formatFormatTable } from "../../src/core/format-sorter";
3
+ import type { Format } from "../../src/core/types";
4
+
5
+ const FORMATS: Format[] = [
6
+ { format_id: "251", url: "https://example.com/audio", ext: "webm", acodec: "opus", abr: 160, vcodec: "none" },
7
+ { format_id: "140", url: "https://example.com/audio2", ext: "m4a", acodec: "aac", abr: 128, vcodec: "none" },
8
+ { format_id: "244", url: "https://example.com/480", ext: "webm", vcodec: "vp9", acodec: "none", height: 480, width: 854, tbr: 750 },
9
+ { format_id: "247", url: "https://example.com/720", ext: "webm", vcodec: "vp9", acodec: "none", height: 720, width: 1280, tbr: 1500 },
10
+ { format_id: "248", url: "https://example.com/1080", ext: "webm", vcodec: "vp9", acodec: "none", height: 1080, width: 1920, tbr: 2500 },
11
+ { format_id: "22", url: "https://example.com/720av", ext: "mp4", vcodec: "h264", acodec: "aac", height: 720, width: 1280, tbr: 2000, abr: 192 },
12
+ ];
13
+
14
+ describe("parseFormatString", () => {
15
+ test("parses simple best", () => {
16
+ const spec = parseFormatString("best");
17
+ expect(spec.type).toBe("single");
18
+ expect(spec.video?.best).toBe(true);
19
+ });
20
+
21
+ test("parses merge format", () => {
22
+ const spec = parseFormatString("bv*+ba");
23
+ expect(spec.type).toBe("merge");
24
+ expect(spec.video?.best).toBe(true);
25
+ expect(spec.audio?.best).toBe(true);
26
+ expect(spec.audio?.audioOnly).toBe(true);
27
+ });
28
+
29
+ test("parses fallback chain", () => {
30
+ const spec = parseFormatString("bv*+ba/b");
31
+ expect(spec.type).toBe("merge");
32
+ expect(spec.fallback).toBeDefined();
33
+ expect(spec.fallback?.type).toBe("single");
34
+ });
35
+
36
+ test("parses height filter", () => {
37
+ const spec = parseFormatString("1080p");
38
+ expect(spec.video?.height).toBe(1080);
39
+ });
40
+ });
41
+
42
+ describe("selectFormats", () => {
43
+ test("selects best video + best audio for bv*+ba", () => {
44
+ const selected = selectFormats(FORMATS, "bv*+ba");
45
+ expect(selected).toHaveLength(2);
46
+ });
47
+
48
+ test("selects single best format for 'best'", () => {
49
+ const selected = selectFormats(FORMATS, "best");
50
+ expect(selected).toHaveLength(1);
51
+ });
52
+
53
+ test("selects by format ID", () => {
54
+ const selected = selectFormats(FORMATS, "22");
55
+ expect(selected).toHaveLength(1);
56
+ expect(selected[0].format_id).toBe("22");
57
+ });
58
+
59
+ test("selects by height", () => {
60
+ const selected = selectFormats(FORMATS, "1080p");
61
+ expect(selected).toHaveLength(1);
62
+ expect(selected[0].height).toBe(1080);
63
+ });
64
+
65
+ test("falls back when primary fails", () => {
66
+ const selected = selectFormats(FORMATS, "4320p/best");
67
+ expect(selected).toHaveLength(1);
68
+ });
69
+
70
+ test("returns empty for impossible format", () => {
71
+ const selected = selectFormats(FORMATS, "nonexistent");
72
+ expect(selected).toHaveLength(0);
73
+ });
74
+ });
75
+
76
+ describe("sortFormats", () => {
77
+ test("sorts by quality ascending", () => {
78
+ const sorted = sortFormats(FORMATS);
79
+ const heights = sorted
80
+ .filter((f) => f.height !== undefined)
81
+ .map((f) => f.height);
82
+ for (let i = 1; i < heights.length; i++) {
83
+ expect(heights[i]!).toBeGreaterThanOrEqual(heights[i - 1]!);
84
+ }
85
+ });
86
+ });
87
+
88
+ describe("formatFormatTable", () => {
89
+ test("returns a formatted table string", () => {
90
+ const table = formatFormatTable(FORMATS);
91
+ expect(table).toContain("ID");
92
+ expect(table).toContain("EXT");
93
+ expect(table).toContain("251");
94
+ expect(table).toContain("248");
95
+ });
96
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { renderTemplate, buildFilename } from "../../src/core/output-template";
3
+ import type { InfoDict } from "../../src/core/types";
4
+
5
+ const INFO: InfoDict = {
6
+ id: "abc123",
7
+ title: "Test Video - Special Edition",
8
+ ext: "mp4",
9
+ uploader: "TestChannel",
10
+ uploader_id: "UC12345",
11
+ duration: 300,
12
+ view_count: 1000000,
13
+ upload_date: "20240115",
14
+ };
15
+
16
+ describe("renderTemplate", () => {
17
+ test("renders basic title and id", () => {
18
+ const result = renderTemplate("%(title)s [%(id)s].%(ext)s", INFO);
19
+ expect(result).toBe("Test Video - Special Edition [abc123].mp4");
20
+ });
21
+
22
+ test("renders numeric fields", () => {
23
+ const result = renderTemplate("%(duration)d seconds", INFO);
24
+ expect(result).toBe("300 seconds");
25
+ });
26
+
27
+ test("handles missing fields", () => {
28
+ const result = renderTemplate("%(channel)s - %(title)s", INFO);
29
+ expect(result).toContain("NA");
30
+ expect(result).toContain("Test Video");
31
+ });
32
+
33
+ test("renders uploader", () => {
34
+ const result = renderTemplate("%(uploader)s - %(title)s", INFO);
35
+ expect(result).toBe("TestChannel - Test Video - Special Edition");
36
+ });
37
+ });
38
+
39
+ describe("buildFilename", () => {
40
+ test("sanitizes illegal characters", () => {
41
+ const infoWithBadTitle: InfoDict = {
42
+ ...INFO,
43
+ title: 'Video: "Test" <Special>',
44
+ };
45
+ const result = buildFilename("%(title)s.%(ext)s", infoWithBadTitle);
46
+ expect(result).not.toContain(":");
47
+ expect(result).not.toContain('"');
48
+ expect(result).not.toContain("<");
49
+ expect(result).not.toContain(">");
50
+ });
51
+
52
+ test("produces valid filename", () => {
53
+ const result = buildFilename("%(title)s [%(id)s].%(ext)s", INFO);
54
+ expect(result).toBe("Test Video - Special Edition [abc123].mp4");
55
+ });
56
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ BaseExtractor,
4
+ ExtractorError,
5
+ DownloadError,
6
+ PostProcessError,
7
+ DEFAULT_OPTIONS,
8
+ } from "../../src/core/types";
9
+ import type { InfoDict } from "../../src/core/types";
10
+
11
+ class TestExtractor extends BaseExtractor {
12
+ readonly _VALID_URL = /^https:\/\/example\.com\/watch\?v=[\w-]+/;
13
+ readonly _NAME = "test";
14
+
15
+ protected async _real_extract(url: string): Promise<InfoDict> {
16
+ const id = new URL(url).searchParams.get("v") ?? "unknown";
17
+ return {
18
+ id,
19
+ title: "Test Video",
20
+ formats: [
21
+ { format_id: "720p", url: "https://example.com/720.mp4", ext: "mp4", height: 720 },
22
+ { format_id: "1080p", url: "https://example.com/1080.mp4", ext: "mp4", height: 1080 },
23
+ ],
24
+ };
25
+ }
26
+ }
27
+
28
+ describe("BaseExtractor", () => {
29
+ const extractor = new TestExtractor();
30
+
31
+ test("canHandle returns true for matching URLs", () => {
32
+ expect(extractor.canHandle("https://example.com/watch?v=abc123")).toBe(true);
33
+ });
34
+
35
+ test("canHandle returns false for non-matching URLs", () => {
36
+ expect(extractor.canHandle("https://other.com/video")).toBe(false);
37
+ });
38
+
39
+ test("extract returns InfoDict with extractor metadata", async () => {
40
+ const info = await extractor.extract("https://example.com/watch?v=test1");
41
+ expect(info.id).toBe("test1");
42
+ expect(info.title).toBe("Test Video");
43
+ expect(info.extractor).toBe("test");
44
+ expect(info.extractor_key).toBe("TestExtractor");
45
+ expect(info.formats).toHaveLength(2);
46
+ });
47
+
48
+ test("extract throws ExtractorError for non-matching URL", async () => {
49
+ await expect(extractor.extract("https://bad.com/video")).rejects.toThrow(ExtractorError);
50
+ });
51
+ });
52
+
53
+ describe("Error classes", () => {
54
+ test("ExtractorError has correct name", () => {
55
+ const err = new ExtractorError("test");
56
+ expect(err.name).toBe("ExtractorError");
57
+ expect(err.message).toBe("test");
58
+ });
59
+
60
+ test("DownloadError has correct name", () => {
61
+ const err = new DownloadError("test");
62
+ expect(err.name).toBe("DownloadError");
63
+ });
64
+
65
+ test("PostProcessError has correct name", () => {
66
+ const err = new PostProcessError("test");
67
+ expect(err.name).toBe("PostProcessError");
68
+ });
69
+ });
70
+
71
+ describe("DEFAULT_OPTIONS", () => {
72
+ test("has sensible defaults", () => {
73
+ expect(DEFAULT_OPTIONS.format).toBe("bv*+ba/b");
74
+ expect(DEFAULT_OPTIONS.retries).toBe(3);
75
+ expect(DEFAULT_OPTIONS.quiet).toBe(false);
76
+ expect(DEFAULT_OPTIONS.verbose).toBe(false);
77
+ expect(DEFAULT_OPTIONS.urls).toEqual([]);
78
+ });
79
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { DashDownloader } from "../../../src/downloaders/dash";
3
+ import { DownloadError } from "../../../src/core/types";
4
+
5
+ describe("DashDownloader", () => {
6
+ const downloader = new DashDownloader();
7
+
8
+ it("canHandle dash and mpd protocols", () => {
9
+ expect(downloader.canHandle("dash")).toBe(true);
10
+ expect(downloader.canHandle("mpd")).toBe(true);
11
+ expect(downloader.canHandle("http")).toBe(false);
12
+ expect(downloader.canHandle("m3u8")).toBe(false);
13
+ });
14
+
15
+ it("has protocol set to dash", () => {
16
+ expect(downloader.protocol).toBe("dash");
17
+ });
18
+
19
+ it("throws DownloadError when MPD fetch fails", async () => {
20
+ const originalFetch = globalThis.fetch;
21
+ globalThis.fetch = async () => new Response(null, { status: 404, statusText: "Not Found" });
22
+
23
+ await expect(
24
+ downloader.download("https://example.com/manifest.mpd", "/tmp/test.mp4", { retries: 1 }),
25
+ ).rejects.toThrow(DownloadError);
26
+
27
+ globalThis.fetch = originalFetch;
28
+ });
29
+
30
+ it("uses exponential backoff on retry", async () => {
31
+ const originalFetch = globalThis.fetch;
32
+
33
+ globalThis.fetch = async () =>
34
+ new Response(null, { status: 503, statusText: "Service Unavailable" });
35
+
36
+ const start = Date.now();
37
+ await expect(
38
+ downloader.download("https://example.com/manifest.mpd", "/tmp/test.mp4", { retries: 2 }),
39
+ ).rejects.toThrow(DownloadError);
40
+ const elapsed = Date.now() - start;
41
+
42
+ expect(elapsed).toBeGreaterThanOrEqual(1000);
43
+ globalThis.fetch = originalFetch;
44
+ });
45
+
46
+ it("throws DownloadError on non-ok MPD response", async () => {
47
+ const originalFetch = globalThis.fetch;
48
+ globalThis.fetch = async () =>
49
+ new Response("Forbidden", { status: 403, statusText: "Forbidden" });
50
+
51
+ await expect(
52
+ downloader.download("https://example.com/manifest.mpd", "/tmp/test.mp4", { retries: 1 }),
53
+ ).rejects.toThrow(DownloadError);
54
+
55
+ globalThis.fetch = originalFetch;
56
+ });
57
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { HlsDownloader } from "../../../src/downloaders/hls";
3
+ import { DownloadError } from "../../../src/core/types";
4
+
5
+ const MEDIA_PLAYLIST = `#EXTM3U
6
+ #EXT-X-VERSION:3
7
+ #EXT-X-TARGETDURATION:10
8
+ #EXTINF:10.0,
9
+ seg0.ts
10
+ #EXTINF:10.0,
11
+ seg1.ts
12
+ #EXTINF:10.0,
13
+ seg2.ts
14
+ #EXT-X-ENDLIST
15
+ `;
16
+
17
+ const MASTER_PLAYLIST = `#EXTM3U
18
+ #EXT-X-STREAM-INF:BANDWIDTH=1500000,RESOLUTION=1280x720
19
+ high/index.m3u8
20
+ #EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=640x360
21
+ low/index.m3u8
22
+ `;
23
+
24
+ describe("HlsDownloader", () => {
25
+ const downloader = new HlsDownloader();
26
+
27
+ it("canHandle m3u8 and hls protocols", () => {
28
+ expect(downloader.canHandle("m3u8")).toBe(true);
29
+ expect(downloader.canHandle("hls")).toBe(true);
30
+ expect(downloader.canHandle("http")).toBe(false);
31
+ expect(downloader.canHandle("dash")).toBe(false);
32
+ });
33
+
34
+ it("has protocol set to m3u8", () => {
35
+ expect(downloader.protocol).toBe("m3u8");
36
+ });
37
+
38
+ it("throws DownloadError when manifest fetch fails", async () => {
39
+ const originalFetch = globalThis.fetch;
40
+ globalThis.fetch = async () => new Response(null, { status: 404, statusText: "Not Found" });
41
+
42
+ await expect(
43
+ downloader.download("https://example.com/stream.m3u8", "/tmp/test.ts", { retries: 1 }),
44
+ ).rejects.toThrow(DownloadError);
45
+
46
+ globalThis.fetch = originalFetch;
47
+ });
48
+
49
+ it("throws DownloadError when segment download fails", async () => {
50
+ const originalFetch = globalThis.fetch;
51
+ let callCount = 0;
52
+
53
+ globalThis.fetch = async (url: RequestInfo | URL) => {
54
+ callCount++;
55
+ const urlStr = String(url);
56
+ if (urlStr.includes("stream.m3u8")) {
57
+ return new Response(MEDIA_PLAYLIST, { status: 200 });
58
+ }
59
+ return new Response(null, { status: 500, statusText: "Server Error" });
60
+ };
61
+
62
+ await expect(
63
+ downloader.download("https://example.com/stream.m3u8", "/tmp/test.ts", { retries: 1 }),
64
+ ).rejects.toThrow(DownloadError);
65
+
66
+ globalThis.fetch = originalFetch;
67
+ });
68
+
69
+ it("selects highest bandwidth variant from master playlist", async () => {
70
+ const originalFetch = globalThis.fetch;
71
+ const fetchedUrls: string[] = [];
72
+
73
+ globalThis.fetch = async (url: RequestInfo | URL) => {
74
+ const urlStr = String(url);
75
+ fetchedUrls.push(urlStr);
76
+
77
+ if (urlStr.endsWith("master.m3u8")) {
78
+ return new Response(MASTER_PLAYLIST, { status: 200 });
79
+ }
80
+ if (urlStr.includes("high/index.m3u8")) {
81
+ return new Response(MEDIA_PLAYLIST, { status: 200 });
82
+ }
83
+ if (urlStr.includes("seg")) {
84
+ return new Response(new Uint8Array(100).fill(0), { status: 200 });
85
+ }
86
+ return new Response(null, { status: 404 });
87
+ };
88
+
89
+ await downloader.download("https://example.com/master.m3u8", "/tmp/hls-out.ts", {
90
+ retries: 1,
91
+ }).catch(() => {});
92
+
93
+ const fetchedHighVariant = fetchedUrls.some((u) => u.includes("high/index.m3u8"));
94
+ expect(fetchedHighVariant).toBe(true);
95
+
96
+ const fetchedLowVariant = fetchedUrls.some((u) => u.includes("low/index.m3u8"));
97
+ expect(fetchedLowVariant).toBe(false);
98
+
99
+ globalThis.fetch = originalFetch;
100
+ });
101
+
102
+ it("uses exponential backoff on retry", async () => {
103
+ const originalFetch = globalThis.fetch;
104
+ let callCount = 0;
105
+
106
+ globalThis.fetch = async () => {
107
+ callCount++;
108
+ return new Response(null, { status: 503, statusText: "Service Unavailable" });
109
+ };
110
+
111
+ const start = Date.now();
112
+ await expect(
113
+ downloader.download("https://example.com/stream.m3u8", "/tmp/test.ts", { retries: 2 }),
114
+ ).rejects.toThrow(DownloadError);
115
+ const elapsed = Date.now() - start;
116
+
117
+ expect(elapsed).toBeGreaterThanOrEqual(1000);
118
+ globalThis.fetch = originalFetch;
119
+ });
120
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { HttpDownloader } from "../../../src/downloaders/http";
3
+ import { DownloadError } from "../../../src/core/types";
4
+
5
+ describe("HttpDownloader", () => {
6
+ const downloader = new HttpDownloader();
7
+
8
+ it("canHandle http and https protocols", () => {
9
+ expect(downloader.canHandle("http")).toBe(true);
10
+ expect(downloader.canHandle("https")).toBe(true);
11
+ expect(downloader.canHandle("m3u8")).toBe(false);
12
+ expect(downloader.canHandle("dash")).toBe(false);
13
+ expect(downloader.canHandle("ftp")).toBe(false);
14
+ });
15
+
16
+ it("has protocol set to https", () => {
17
+ expect(downloader.protocol).toBe("https");
18
+ });
19
+
20
+ it("throws DownloadError on HTTP error after retries", async () => {
21
+ const originalFetch = globalThis.fetch;
22
+ let calls = 0;
23
+ globalThis.fetch = async () => {
24
+ calls++;
25
+ return new Response(null, { status: 404, statusText: "Not Found" });
26
+ };
27
+
28
+ await expect(
29
+ downloader.download("https://example.com/file.mp4", "/tmp/test-http.mp4", {
30
+ retries: 2,
31
+ }),
32
+ ).rejects.toThrow(DownloadError);
33
+
34
+ expect(calls).toBeGreaterThan(0);
35
+ globalThis.fetch = originalFetch;
36
+ });
37
+
38
+ it("reports progress during download", async () => {
39
+ const originalFetch = globalThis.fetch;
40
+ const chunkData = new Uint8Array(1024).fill(1);
41
+
42
+ globalThis.fetch = async (_url: RequestInfo | URL, init?: RequestInit) => {
43
+ if (init?.method === "HEAD") {
44
+ return new Response(null, {
45
+ status: 200,
46
+ headers: { "content-length": "1024", "accept-ranges": "bytes" },
47
+ });
48
+ }
49
+ return new Response(chunkData, {
50
+ status: 200,
51
+ headers: { "content-length": "1024" },
52
+ });
53
+ };
54
+
55
+ const progressEvents: number[] = [];
56
+ await downloader.download("https://example.com/file.mp4", "/tmp/test-progress.mp4", {
57
+ retries: 1,
58
+ onProgress: (p) => {
59
+ if (p.percent !== null) progressEvents.push(p.percent);
60
+ },
61
+ });
62
+
63
+ expect(progressEvents.length).toBeGreaterThan(0);
64
+ expect(progressEvents[progressEvents.length - 1]).toBe(100);
65
+ globalThis.fetch = originalFetch;
66
+ });
67
+
68
+ it("sends Range header on second attempt (resume)", async () => {
69
+ const originalFetch = globalThis.fetch;
70
+ const rangeRequests: string[] = [];
71
+ let attempt = 0;
72
+
73
+ globalThis.fetch = async (_url: RequestInfo | URL, init?: RequestInit) => {
74
+ attempt++;
75
+ const headers = (init?.headers ?? {}) as Record<string, string>;
76
+ if (headers["Range"]) rangeRequests.push(headers["Range"]);
77
+ if (attempt === 1) {
78
+ throw new Error("Simulated network failure");
79
+ }
80
+ return new Response(new Uint8Array(512).fill(2), {
81
+ status: 206,
82
+ headers: { "content-length": "512" },
83
+ });
84
+ };
85
+
86
+ await downloader.download("https://example.com/file.mp4", "/tmp/resume-test.mp4", {
87
+ retries: 2,
88
+ }).catch(() => {});
89
+
90
+ globalThis.fetch = originalFetch;
91
+
92
+ expect(attempt).toBeGreaterThan(1);
93
+ });
94
+
95
+ it("accepts a rateLimit option without throwing", async () => {
96
+ const originalFetch = globalThis.fetch;
97
+ const data = new Uint8Array(128).fill(3);
98
+
99
+ globalThis.fetch = async () =>
100
+ new Response(data, {
101
+ status: 200,
102
+ headers: { "content-length": "128" },
103
+ });
104
+
105
+ await expect(
106
+ downloader.download("https://example.com/file.mp4", "/tmp/rate-test.mp4", {
107
+ retries: 1,
108
+ rateLimit: 1024 * 1024,
109
+ }),
110
+ ).resolves.toBeUndefined();
111
+
112
+ globalThis.fetch = originalFetch;
113
+ });
114
+ });