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,73 @@
1
+ import { PostProcessor } from "../core/types";
2
+ import type { InfoDict, PostProcessResult } from "../core/types";
3
+ import { FFmpegRunner } from "./ffmpeg";
4
+ import { dirname, basename, extname, join } from "node:path";
5
+
6
+ export interface MetadataOptions {
7
+ title?: string;
8
+ artist?: string;
9
+ date?: string;
10
+ description?: string;
11
+ comment?: string;
12
+ ffmpegLocation?: string | null;
13
+ }
14
+
15
+ export class MetadataPostProcessor extends PostProcessor {
16
+ readonly _NAME = "EmbedMetadata";
17
+
18
+ private opts: MetadataOptions;
19
+
20
+ constructor(opts: MetadataOptions = {}) {
21
+ super();
22
+ this.opts = opts;
23
+ }
24
+
25
+ async run(info: InfoDict, filepath: string): Promise<PostProcessResult> {
26
+ const ext = extname(filepath).slice(1).toLowerCase();
27
+ const dir = dirname(filepath);
28
+ const stem = basename(filepath, extname(filepath));
29
+ const tmpPath = join(dir, `${stem}.meta_tmp.${ext}`);
30
+
31
+ const runner = await FFmpegRunner.detect(this.opts.ffmpegLocation);
32
+
33
+ const title = this.opts.title ?? info.title;
34
+ const artist = this.opts.artist ?? info.uploader ?? info.channel;
35
+ const date = this.opts.date ?? info.upload_date;
36
+ const description = this.opts.description ?? info.description;
37
+ const comment = this.opts.comment;
38
+
39
+ const args: string[] = ["-i", filepath, "-map", "0", "-c", "copy", "-map_metadata", "0"];
40
+
41
+ const isMp4 = ext === "mp4" || ext === "m4a" || ext === "mov";
42
+ const isMkv = ext === "mkv" || ext === "mka" || ext === "webm";
43
+
44
+ if (isMp4) {
45
+ if (title) args.push("-metadata", `title=${title}`);
46
+ if (artist) args.push("-metadata", `artist=${artist}`);
47
+ if (date) args.push("-metadata", `date=${date}`);
48
+ if (description) args.push("-metadata", `description=${description}`);
49
+ if (comment) args.push("-metadata", `comment=${comment}`);
50
+ } else if (isMkv) {
51
+ if (title) args.push("-metadata", `TITLE=${title}`);
52
+ if (artist) args.push("-metadata", `ARTIST=${artist}`);
53
+ if (date) args.push("-metadata", `DATE=${date}`);
54
+ if (description) args.push("-metadata", `DESCRIPTION=${description}`);
55
+ if (comment) args.push("-metadata", `COMMENT=${comment}`);
56
+ } else {
57
+ if (title) args.push("-metadata", `title=${title}`);
58
+ if (artist) args.push("-metadata", `artist=${artist}`);
59
+ if (date) args.push("-metadata", `date=${date}`);
60
+ if (description) args.push("-metadata", `description=${description}`);
61
+ if (comment) args.push("-metadata", `comment=${comment}`);
62
+ }
63
+
64
+ args.push(tmpPath);
65
+
66
+ await runner.run(args);
67
+
68
+ const { rename } = await import("node:fs/promises");
69
+ await rename(tmpPath, filepath);
70
+
71
+ return { filepath, info, files_to_delete: [] };
72
+ }
73
+ }
@@ -0,0 +1,162 @@
1
+ import { PostProcessor, PostProcessError } from "../core/types";
2
+ import type { InfoDict, PostProcessResult, Chapter } from "../core/types";
3
+ import { FFmpegRunner } from "./ffmpeg";
4
+ import { dirname, basename, extname, join } from "node:path";
5
+ import { writeFile, unlink } from "node:fs/promises";
6
+
7
+ const SPONSORBLOCK_API = "https://sponsor.ajay.app/api/skipSegments?videoID=";
8
+
9
+ export type SponsorCategory =
10
+ | "sponsor"
11
+ | "selfpromo"
12
+ | "interaction"
13
+ | "intro"
14
+ | "outro"
15
+ | "preview"
16
+ | "filler"
17
+ | "music_offtopic";
18
+
19
+ interface SponsorSegment {
20
+ segment: [number, number];
21
+ category: SponsorCategory;
22
+ UUID: string;
23
+ videoDuration: number;
24
+ }
25
+
26
+ export interface SponsorBlockOptions {
27
+ categories?: SponsorCategory[];
28
+ ffmpegLocation?: string | null;
29
+ onProgress?: (percent: number) => void;
30
+ }
31
+
32
+ export class SponsorBlockPostProcessor extends PostProcessor {
33
+ readonly _NAME = "SponsorBlock";
34
+
35
+ private opts: SponsorBlockOptions;
36
+
37
+ constructor(opts: SponsorBlockOptions = {}) {
38
+ super();
39
+ this.opts = opts;
40
+ }
41
+
42
+ async run(info: InfoDict, filepath: string): Promise<PostProcessResult> {
43
+ const videoId = info.id;
44
+ if (!videoId) return { filepath, info, files_to_delete: [] };
45
+
46
+ const categories = this.opts.categories ?? ["sponsor"];
47
+ const segments = await fetchSegments(videoId, categories);
48
+
49
+ if (segments.length === 0) {
50
+ return { filepath, info, files_to_delete: [] };
51
+ }
52
+
53
+ const duration = info.duration ?? segments[0]?.videoDuration ?? 0;
54
+ const keepSegments = invertSegments(segments.map((s) => s.segment), duration);
55
+
56
+ if (keepSegments.length === 0) {
57
+ return { filepath, info, files_to_delete: [] };
58
+ }
59
+
60
+ const runner = await FFmpegRunner.detect(this.opts.ffmpegLocation);
61
+ const dir = dirname(filepath);
62
+ const ext = extname(filepath).slice(1).toLowerCase();
63
+ const stem = basename(filepath, extname(filepath));
64
+ const tmpPath = join(dir, `${stem}.sb_tmp.${ext}`);
65
+
66
+ const segmentListPath = join(dir, `${stem}.segments.txt`);
67
+ const segmentListContent = keepSegments
68
+ .map(([s, e]) => `inpoint ${s}\noutpoint ${e}`)
69
+ .join("\n");
70
+
71
+ await writeFile(segmentListPath, segmentListContent, "utf8");
72
+
73
+ const args = [
74
+ "-i", filepath,
75
+ "-f", "concat",
76
+ "-safe", "0",
77
+ "-i", segmentListPath,
78
+ "-c", "copy",
79
+ tmpPath,
80
+ ];
81
+
82
+ try {
83
+ await runner.run(
84
+ args,
85
+ this.opts.onProgress
86
+ ? (p) => { if (p.percent !== undefined) this.opts.onProgress!(p.percent); }
87
+ : undefined,
88
+ duration,
89
+ );
90
+ } finally {
91
+ await unlink(segmentListPath).catch(() => undefined);
92
+ }
93
+
94
+ const updatedChapters = rewriteChapters(info.chapters ?? [], segments.map((s) => s.segment));
95
+ const updatedInfo: InfoDict = { ...info, chapters: updatedChapters };
96
+
97
+ const { rename } = await import("node:fs/promises");
98
+ await rename(tmpPath, filepath);
99
+
100
+ return { filepath, info: updatedInfo, files_to_delete: [] };
101
+ }
102
+ }
103
+
104
+ async function fetchSegments(
105
+ videoId: string,
106
+ categories: SponsorCategory[],
107
+ ): Promise<SponsorSegment[]> {
108
+ const catParam = categories.map((c) => `categories[]=${encodeURIComponent(c)}`).join("&");
109
+ const url = `${SPONSORBLOCK_API}${encodeURIComponent(videoId)}&${catParam}`;
110
+
111
+ const res = await fetch(url);
112
+ if (res.status === 404) return [];
113
+ if (!res.ok) throw new PostProcessError(`SponsorBlock API error: HTTP ${res.status}`);
114
+
115
+ return (await res.json()) as SponsorSegment[];
116
+ }
117
+
118
+ function invertSegments(
119
+ skipSegments: [number, number][],
120
+ duration: number,
121
+ ): [number, number][] {
122
+ const sorted = skipSegments.slice().sort((a, b) => a[0] - b[0]);
123
+ const keep: [number, number][] = [];
124
+ let cursor = 0;
125
+
126
+ for (const [start, end] of sorted) {
127
+ if (cursor < start) keep.push([cursor, start]);
128
+ cursor = Math.max(cursor, end);
129
+ }
130
+
131
+ if (cursor < duration) keep.push([cursor, duration]);
132
+
133
+ return keep.filter(([s, e]) => e - s > 0.1);
134
+ }
135
+
136
+ function rewriteChapters(
137
+ chapters: Chapter[],
138
+ removedSegments: [number, number][],
139
+ ): Chapter[] {
140
+ if (chapters.length === 0) return chapters;
141
+
142
+ const kept = chapters.filter((ch) => {
143
+ for (const [s, e] of removedSegments) {
144
+ if (ch.start_time >= s && ch.end_time <= e) return false;
145
+ }
146
+ return true;
147
+ });
148
+
149
+ const removedSorted = removedSegments.slice().sort((a, b) => a[0] - b[0]);
150
+
151
+ return kept.map((ch) => {
152
+ let removedBefore = 0;
153
+ for (const [s, e] of removedSorted) {
154
+ if (e <= ch.start_time) removedBefore += e - s;
155
+ }
156
+ return {
157
+ ...ch,
158
+ start_time: ch.start_time - removedBefore,
159
+ end_time: ch.end_time - removedBefore,
160
+ };
161
+ });
162
+ }
@@ -0,0 +1,285 @@
1
+ import { PostProcessor, PostProcessError } from "../core/types";
2
+ import type { InfoDict, PostProcessResult, Subtitle } from "../core/types";
3
+ import { FFmpegRunner } from "./ffmpeg";
4
+ import { dirname, basename, extname, join } from "node:path";
5
+
6
+ export type SubtitleFormat = "srt" | "ass" | "vtt" | "json3" | "lrc";
7
+ export type EmbedMode = "soft" | "burn";
8
+
9
+ export interface SubtitleOptions {
10
+ convertTo?: SubtitleFormat;
11
+ embed?: EmbedMode;
12
+ language?: string;
13
+ ffmpegLocation?: string | null;
14
+ onProgress?: (percent: number) => void;
15
+ }
16
+
17
+ export class SubtitlePostProcessor extends PostProcessor {
18
+ readonly _NAME = "Subtitles";
19
+
20
+ private opts: SubtitleOptions;
21
+
22
+ constructor(opts: SubtitleOptions = {}) {
23
+ super();
24
+ this.opts = opts;
25
+ }
26
+
27
+ async run(info: InfoDict, filepath: string): Promise<PostProcessResult> {
28
+ const lang = this.opts.language ?? "en";
29
+ const subsMap = info.subtitles ?? info.automatic_captions ?? {};
30
+ const subs: Subtitle[] | undefined = subsMap[lang] ?? Object.values(subsMap)[0];
31
+
32
+ if (!subs || subs.length === 0) {
33
+ return { filepath, info, files_to_delete: [] };
34
+ }
35
+
36
+ const filesToDelete: string[] = [];
37
+ const runner = await FFmpegRunner.detect(this.opts.ffmpegLocation);
38
+ const dir = dirname(filepath);
39
+ const stem = basename(filepath, extname(filepath));
40
+ const videoExt = extname(filepath).slice(1).toLowerCase();
41
+
42
+ let subPath: string | null = null;
43
+ let subData: string | null = null;
44
+
45
+ for (const sub of subs) {
46
+ if (sub.data) {
47
+ subData = sub.data;
48
+ break;
49
+ }
50
+ if (sub.url) {
51
+ const res = await fetch(sub.url);
52
+ if (res.ok) {
53
+ subData = await res.text();
54
+ break;
55
+ }
56
+ }
57
+ }
58
+
59
+ if (!subData) {
60
+ return { filepath, info, files_to_delete: [] };
61
+ }
62
+
63
+ const sourceSub = subs[0];
64
+ const sourceExt = (sourceSub?.ext ?? "vtt") as SubtitleFormat;
65
+
66
+ if (this.opts.convertTo && this.opts.convertTo !== sourceExt) {
67
+ const convertedPath = join(dir, `${stem}.${lang}.${this.opts.convertTo}`);
68
+ await convertSubtitle(runner, subData, sourceExt, this.opts.convertTo, convertedPath);
69
+ subPath = convertedPath;
70
+ filesToDelete.push(convertedPath);
71
+ } else {
72
+ subPath = join(dir, `${stem}.${lang}.${sourceExt}`);
73
+ await Bun.write(subPath, subData);
74
+ filesToDelete.push(subPath);
75
+ }
76
+
77
+ if (!subPath) {
78
+ return { filepath, info, files_to_delete: filesToDelete };
79
+ }
80
+
81
+ const embed = this.opts.embed;
82
+
83
+ if (embed === "soft") {
84
+ const outputPath = await softEmbed(runner, filepath, subPath, lang, videoExt, info);
85
+ return {
86
+ filepath: outputPath,
87
+ info,
88
+ files_to_delete: outputPath !== filepath
89
+ ? [filepath, ...filesToDelete]
90
+ : filesToDelete,
91
+ };
92
+ }
93
+
94
+ if (embed === "burn") {
95
+ const outputPath = await burnInSubtitle(runner, filepath, subPath, videoExt, info, this.opts.onProgress);
96
+ return {
97
+ filepath: outputPath,
98
+ info,
99
+ files_to_delete: outputPath !== filepath
100
+ ? [filepath, ...filesToDelete]
101
+ : filesToDelete,
102
+ };
103
+ }
104
+
105
+ return { filepath, info, files_to_delete: filesToDelete };
106
+ }
107
+ }
108
+
109
+ async function convertSubtitle(
110
+ runner: FFmpegRunner,
111
+ data: string,
112
+ fromExt: SubtitleFormat,
113
+ toExt: SubtitleFormat,
114
+ outputPath: string,
115
+ ): Promise<void> {
116
+ if ((fromExt === "json3" || toExt === "json3") || fromExt === "lrc" || toExt === "lrc") {
117
+ const converted = convertNative(data, fromExt, toExt);
118
+ await Bun.write(outputPath, converted);
119
+ return;
120
+ }
121
+
122
+ const tmpInput = outputPath + ".tmp_in." + fromExt;
123
+ await Bun.write(tmpInput, data);
124
+
125
+ await runner.run(["-i", tmpInput, outputPath]);
126
+
127
+ const { unlink } = await import("node:fs/promises");
128
+ await unlink(tmpInput);
129
+ }
130
+
131
+ function convertNative(data: string, from: SubtitleFormat, to: SubtitleFormat): string {
132
+ if (from === "json3") {
133
+ const srtData = json3ToSrt(data);
134
+ if (to === "srt") return srtData;
135
+ if (to === "vtt") return srtToVtt(srtData);
136
+ if (to === "lrc") return srtToLrc(srtData);
137
+ return srtData;
138
+ }
139
+ if (from === "vtt" && to === "srt") return vttToSrt(data);
140
+ if (from === "srt" && to === "vtt") return srtToVtt(data);
141
+ if (from === "srt" && to === "lrc") return srtToLrc(data);
142
+ return data;
143
+ }
144
+
145
+ function json3ToSrt(json: string): string {
146
+ interface Json3Event {
147
+ tStartMs: number;
148
+ dDurationMs: number;
149
+ segs?: Array<{ utf8: string }>;
150
+ }
151
+ const parsed = JSON.parse(json) as { events?: Json3Event[] };
152
+ const events = parsed.events ?? [];
153
+ const lines: string[] = [];
154
+ let index = 1;
155
+
156
+ for (const ev of events) {
157
+ if (!ev.segs) continue;
158
+ const text = ev.segs.map((s) => s.utf8).join("").replace(/\n$/, "");
159
+ if (!text.trim()) continue;
160
+ const start = msToSrtTime(ev.tStartMs);
161
+ const end = msToSrtTime(ev.tStartMs + ev.dDurationMs);
162
+ lines.push(`${index}\n${start} --> ${end}\n${text}\n`);
163
+ index++;
164
+ }
165
+
166
+ return lines.join("\n");
167
+ }
168
+
169
+ function msToSrtTime(ms: number): string {
170
+ const h = Math.floor(ms / 3600000);
171
+ const m = Math.floor((ms % 3600000) / 60000);
172
+ const s = Math.floor((ms % 60000) / 1000);
173
+ const msPart = ms % 1000;
174
+ return `${pad(h)}:${pad(m)}:${pad(s)},${pad(msPart, 3)}`;
175
+ }
176
+
177
+ function vttToSrt(vtt: string): string {
178
+ return vtt
179
+ .replace(/^WEBVTT.*\n?/m, "")
180
+ .replace(/^\d+:\d+:\d+\.\d+ --> \d+:\d+:\d+\.\d+.*$/gm, (m) =>
181
+ m.replace(/\./g, ","),
182
+ )
183
+ .trim();
184
+ }
185
+
186
+ function srtToVtt(srt: string): string {
187
+ return "WEBVTT\n\n" + srt.replace(/,(\d{3})/g, ".$1");
188
+ }
189
+
190
+ function srtToLrc(srt: string): string {
191
+ const lines: string[] = [];
192
+ const blocks = srt.split(/\n\n+/);
193
+ for (const block of blocks) {
194
+ const blockLines = block.trim().split("\n");
195
+ if (blockLines.length < 3) continue;
196
+ const timeLine = blockLines[1] ?? "";
197
+ const startMatch = timeLine.match(/^(\d{2}:\d{2}:\d{2}),(\d{3})/);
198
+ if (!startMatch) continue;
199
+ const [, time, ms] = startMatch;
200
+ const [h, m, s] = (time ?? "").split(":").map(Number);
201
+ const totalMin = (h ?? 0) * 60 + (m ?? 0);
202
+ const sec = s ?? 0;
203
+ const text = blockLines.slice(2).join(" ");
204
+ lines.push(`[${pad(totalMin)}:${pad(sec)}.${(ms ?? "00").slice(0, 2)}]${text}`);
205
+ }
206
+ return lines.join("\n");
207
+ }
208
+
209
+ function pad(n: number, len = 2): string {
210
+ return String(n).padStart(len, "0");
211
+ }
212
+
213
+ async function softEmbed(
214
+ runner: FFmpegRunner,
215
+ videoPath: string,
216
+ subPath: string,
217
+ lang: string,
218
+ ext: string,
219
+ info: InfoDict,
220
+ ): Promise<string> {
221
+ const dir = dirname(videoPath);
222
+ const stem = basename(videoPath, extname(videoPath));
223
+ const outPath = join(dir, `${stem}.${ext}`);
224
+ const tmpPath = join(dir, `${stem}.sub_tmp.${ext}`);
225
+
226
+ let args: string[];
227
+
228
+ if (ext === "mkv") {
229
+ args = [
230
+ "-i", videoPath,
231
+ "-i", subPath,
232
+ "-c", "copy",
233
+ "-metadata:s:s:0", `language=${lang}`,
234
+ tmpPath,
235
+ ];
236
+ } else if (ext === "mp4") {
237
+ args = [
238
+ "-i", videoPath,
239
+ "-i", subPath,
240
+ "-c", "copy",
241
+ "-c:s", "mov_text",
242
+ "-metadata:s:s:0", `language=${lang}`,
243
+ tmpPath,
244
+ ];
245
+ } else {
246
+ return videoPath;
247
+ }
248
+
249
+ await runner.run(args, undefined, info.duration);
250
+
251
+ const { rename } = await import("node:fs/promises");
252
+ await rename(tmpPath, outPath);
253
+ return outPath;
254
+ }
255
+
256
+ async function burnInSubtitle(
257
+ runner: FFmpegRunner,
258
+ videoPath: string,
259
+ subPath: string,
260
+ ext: string,
261
+ info: InfoDict,
262
+ onProgress?: (p: number) => void,
263
+ ): Promise<string> {
264
+ const dir = dirname(videoPath);
265
+ const stem = basename(videoPath, extname(videoPath));
266
+ const outPath = join(dir, `${stem}_hardsub.${ext}`);
267
+
268
+ const subExt = extname(subPath).slice(1).toLowerCase();
269
+ const filter = subExt === "ass" ? `ass=${subPath}` : `subtitles=${subPath}`;
270
+
271
+ const args = [
272
+ "-i", videoPath,
273
+ "-vf", filter,
274
+ "-c:a", "copy",
275
+ outPath,
276
+ ];
277
+
278
+ await runner.run(
279
+ args,
280
+ onProgress ? (p) => { if (p.percent !== undefined) onProgress(p.percent); } : undefined,
281
+ info.duration,
282
+ );
283
+
284
+ return outPath;
285
+ }
@@ -0,0 +1,194 @@
1
+ import { PostProcessor, PostProcessError } from "../core/types";
2
+ import type { InfoDict, PostProcessResult } from "../core/types";
3
+ import { FFmpegRunner } from "./ffmpeg";
4
+ import { dirname, basename, extname, join } from "node:path";
5
+
6
+ export type ThumbnailFormat = "jpg" | "png" | "webp";
7
+
8
+ export interface ThumbnailOptions {
9
+ format?: ThumbnailFormat;
10
+ embedInAudio?: boolean;
11
+ embedInVideo?: boolean;
12
+ ffmpegLocation?: string | null;
13
+ }
14
+
15
+ export class ThumbnailPostProcessor extends PostProcessor {
16
+ readonly _NAME = "EmbedThumbnail";
17
+
18
+ private opts: ThumbnailOptions;
19
+
20
+ constructor(opts: ThumbnailOptions = {}) {
21
+ super();
22
+ this.opts = opts;
23
+ }
24
+
25
+ async run(info: InfoDict, filepath: string): Promise<PostProcessResult> {
26
+ const thumbnails = info.thumbnails;
27
+ if (!thumbnails || thumbnails.length === 0) {
28
+ return { filepath, info, files_to_delete: [] };
29
+ }
30
+
31
+ const best = thumbnails
32
+ .slice()
33
+ .sort((a, b) => (b.preference ?? 0) - (a.preference ?? 0))[0];
34
+
35
+ if (!best?.url) {
36
+ return { filepath, info, files_to_delete: [] };
37
+ }
38
+
39
+ const runner = await FFmpegRunner.detect(this.opts.ffmpegLocation);
40
+ const dir = dirname(filepath);
41
+ const ext = extname(filepath).slice(1).toLowerCase();
42
+
43
+ const thumbExt = this.opts.format ?? "jpg";
44
+ const thumbPath = join(dir, `${basename(filepath, extname(filepath))}.${thumbExt}`);
45
+ const filesToDelete: string[] = [];
46
+
47
+ await downloadThumbnail(best.url, thumbPath);
48
+ filesToDelete.push(thumbPath);
49
+
50
+ if (this.opts.embedInAudio && isAudio(ext)) {
51
+ const result = await embedInAudio(runner, filepath, thumbPath, ext, info);
52
+ return { ...result, files_to_delete: [...result.files_to_delete, ...filesToDelete] };
53
+ }
54
+
55
+ if (this.opts.embedInVideo !== false && isVideo(ext)) {
56
+ const result = await embedInVideo(runner, filepath, thumbPath, ext, info);
57
+ return { ...result, files_to_delete: [...result.files_to_delete, ...filesToDelete] };
58
+ }
59
+
60
+ return { filepath, info, files_to_delete: filesToDelete };
61
+ }
62
+ }
63
+
64
+ async function downloadThumbnail(url: string, dest: string): Promise<void> {
65
+ const res = await fetch(url);
66
+ if (!res.ok) throw new PostProcessError(`Failed to download thumbnail: HTTP ${res.status}`);
67
+ const buffer = await res.arrayBuffer();
68
+ await Bun.file(dest).writer().write(new Uint8Array(buffer));
69
+ }
70
+
71
+ async function embedInAudio(
72
+ runner: FFmpegRunner,
73
+ audioPath: string,
74
+ thumbPath: string,
75
+ ext: string,
76
+ info: InfoDict,
77
+ ): Promise<PostProcessResult> {
78
+ const dir = dirname(audioPath);
79
+ const stem = basename(audioPath, extname(audioPath));
80
+ const tmpPath = join(dir, `${stem}.thumb_tmp.${ext}`);
81
+
82
+ let args: string[];
83
+
84
+ if (ext === "mp3") {
85
+ args = [
86
+ "-i", audioPath,
87
+ "-i", thumbPath,
88
+ "-map", "0:a",
89
+ "-map", "1:v",
90
+ "-c:a", "copy",
91
+ "-c:v", "mjpeg",
92
+ "-id3v2_version", "3",
93
+ "-metadata:s:v", "title=Album cover",
94
+ "-metadata:s:v", "comment=Cover (front)",
95
+ tmpPath,
96
+ ];
97
+ } else if (ext === "m4a") {
98
+ args = [
99
+ "-i", audioPath,
100
+ "-i", thumbPath,
101
+ "-map", "0:a",
102
+ "-map", "1:v",
103
+ "-c:a", "copy",
104
+ "-c:v", "copy",
105
+ "-disposition:v:0", "attached_pic",
106
+ tmpPath,
107
+ ];
108
+ } else if (ext === "flac") {
109
+ args = [
110
+ "-i", audioPath,
111
+ "-i", thumbPath,
112
+ "-map", "0",
113
+ "-map", "1:v",
114
+ "-c", "copy",
115
+ "-metadata:s:v:0", "comment=Cover (front)",
116
+ tmpPath,
117
+ ];
118
+ } else {
119
+ args = [
120
+ "-i", audioPath,
121
+ "-i", thumbPath,
122
+ "-map", "0:a",
123
+ "-map", "1:v",
124
+ "-c", "copy",
125
+ tmpPath,
126
+ ];
127
+ }
128
+
129
+ await runner.run(args, undefined, info.duration);
130
+
131
+ const { rename } = await import("node:fs/promises");
132
+ await rename(tmpPath, audioPath);
133
+
134
+ return { filepath: audioPath, info, files_to_delete: [] };
135
+ }
136
+
137
+ async function embedInVideo(
138
+ runner: FFmpegRunner,
139
+ videoPath: string,
140
+ thumbPath: string,
141
+ ext: string,
142
+ info: InfoDict,
143
+ ): Promise<PostProcessResult> {
144
+ const dir = dirname(videoPath);
145
+ const stem = basename(videoPath, extname(videoPath));
146
+ const tmpPath = join(dir, `${stem}.thumb_tmp.${ext}`);
147
+
148
+ let args: string[];
149
+
150
+ if (ext === "mkv" || ext === "webm") {
151
+ args = [
152
+ "-i", videoPath,
153
+ "-attach", thumbPath,
154
+ "-metadata:s:t", "mimetype=image/jpeg",
155
+ "-c", "copy",
156
+ tmpPath,
157
+ ];
158
+ } else if (ext === "mp4") {
159
+ args = [
160
+ "-i", videoPath,
161
+ "-i", thumbPath,
162
+ "-map", "0",
163
+ "-map", "1",
164
+ "-c", "copy",
165
+ "-c:v:1", "png",
166
+ "-disposition:v:1", "attached_pic",
167
+ tmpPath,
168
+ ];
169
+ } else {
170
+ args = [
171
+ "-i", videoPath,
172
+ "-i", thumbPath,
173
+ "-map", "0",
174
+ "-map", "1:v",
175
+ "-c", "copy",
176
+ tmpPath,
177
+ ];
178
+ }
179
+
180
+ await runner.run(args, undefined, info.duration);
181
+
182
+ const { rename } = await import("node:fs/promises");
183
+ await rename(tmpPath, videoPath);
184
+
185
+ return { filepath: videoPath, info, files_to_delete: [] };
186
+ }
187
+
188
+ function isAudio(ext: string): boolean {
189
+ return ["mp3", "m4a", "flac", "ogg", "wav", "aac", "opus"].includes(ext);
190
+ }
191
+
192
+ function isVideo(ext: string): boolean {
193
+ return ["mp4", "mkv", "webm", "avi", "mov"].includes(ext);
194
+ }