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,208 @@
1
+ import type { Format } from "./types";
2
+
3
+ interface FormatSpec {
4
+ type: "merge" | "single";
5
+ video?: FormatFilter;
6
+ audio?: FormatFilter;
7
+ fallback?: FormatSpec;
8
+ }
9
+
10
+ interface FormatFilter {
11
+ id?: string;
12
+ best: boolean;
13
+ worst: boolean;
14
+ videoOnly: boolean;
15
+ audioOnly: boolean;
16
+ height?: number;
17
+ ext?: string;
18
+ }
19
+
20
+ function parseFilter(token: string): FormatFilter {
21
+ const filter: FormatFilter = {
22
+ best: false,
23
+ worst: false,
24
+ videoOnly: false,
25
+ audioOnly: false,
26
+ };
27
+
28
+ if (token === "best" || token === "b") {
29
+ filter.best = true;
30
+ return filter;
31
+ }
32
+ if (token === "worst" || token === "w") {
33
+ filter.worst = true;
34
+ return filter;
35
+ }
36
+ if (token === "bv" || token === "bestvideo") {
37
+ filter.best = true;
38
+ filter.videoOnly = true;
39
+ return filter;
40
+ }
41
+ if (token === "ba" || token === "bestaudio") {
42
+ filter.best = true;
43
+ filter.audioOnly = true;
44
+ return filter;
45
+ }
46
+ if (token === "wv" || token === "worstvideo") {
47
+ filter.worst = true;
48
+ filter.videoOnly = true;
49
+ return filter;
50
+ }
51
+ if (token === "wa" || token === "worstaudio") {
52
+ filter.worst = true;
53
+ filter.audioOnly = true;
54
+ return filter;
55
+ }
56
+ if (token.startsWith("bv*")) {
57
+ filter.best = true;
58
+ filter.videoOnly = false;
59
+ return filter;
60
+ }
61
+
62
+ const heightMatch = token.match(/^(\d+)p$/);
63
+ if (heightMatch) {
64
+ filter.height = parseInt(heightMatch[1], 10);
65
+ filter.best = true;
66
+ return filter;
67
+ }
68
+
69
+ filter.id = token;
70
+ return filter;
71
+ }
72
+
73
+ export function parseFormatString(formatStr: string): FormatSpec {
74
+ const alternatives = formatStr.split("/");
75
+
76
+ const specs: FormatSpec[] = alternatives.map((alt) => {
77
+ const parts = alt.split("+");
78
+ if (parts.length === 2) {
79
+ return {
80
+ type: "merge" as const,
81
+ video: parseFilter(parts[0].trim()),
82
+ audio: parseFilter(parts[1].trim()),
83
+ };
84
+ }
85
+ return {
86
+ type: "single" as const,
87
+ video: parseFilter(parts[0].trim()),
88
+ };
89
+ });
90
+
91
+ let result = specs[specs.length - 1];
92
+ for (let i = specs.length - 2; i >= 0; i--) {
93
+ specs[i].fallback = result;
94
+ result = specs[i];
95
+ }
96
+ return result;
97
+ }
98
+
99
+ function formatQualityScore(f: Format): number {
100
+ let score = 0;
101
+ if (f.height) score += f.height;
102
+ if (f.tbr) score += f.tbr / 100;
103
+ if (f.vbr) score += f.vbr / 200;
104
+ if (f.abr) score += f.abr / 200;
105
+ if (f.fps && f.fps > 30) score += 10;
106
+ if (f.source_preference) score += f.source_preference;
107
+ if (f.quality) score += f.quality * 100;
108
+ return score;
109
+ }
110
+
111
+ function hasVideo(f: Format): boolean {
112
+ return f.vcodec !== undefined && f.vcodec !== "none";
113
+ }
114
+
115
+ function hasAudio(f: Format): boolean {
116
+ return f.acodec !== undefined && f.acodec !== "none";
117
+ }
118
+
119
+ function filterMatches(f: Format, filter: FormatFilter): boolean {
120
+ if (filter.id && f.format_id !== filter.id) return false;
121
+ if (filter.videoOnly && !hasVideo(f)) return false;
122
+ if (filter.audioOnly && !hasAudio(f)) return false;
123
+ if (filter.height && f.height !== filter.height) return false;
124
+ if (filter.ext && f.ext !== filter.ext) return false;
125
+ return true;
126
+ }
127
+
128
+ function selectFromList(formats: Format[], filter: FormatFilter): Format | null {
129
+ const candidates = formats.filter((f) => filterMatches(f, filter));
130
+ if (candidates.length === 0) return null;
131
+
132
+ const sorted = [...candidates].sort(
133
+ (a, b) => formatQualityScore(b) - formatQualityScore(a),
134
+ );
135
+
136
+ return filter.worst ? sorted[sorted.length - 1] : sorted[0];
137
+ }
138
+
139
+ export function selectFormats(formats: Format[], formatStr: string): Format[] {
140
+ const spec = parseFormatString(formatStr);
141
+ return applySpec(formats, spec);
142
+ }
143
+
144
+ function applySpec(formats: Format[], spec: FormatSpec): Format[] {
145
+ if (spec.type === "merge" && spec.video && spec.audio) {
146
+ const video = selectFromList(formats, spec.video);
147
+ const audio = selectFromList(formats, spec.audio);
148
+ if (video && audio) return [video, audio];
149
+ }
150
+
151
+ if (spec.type === "single" && spec.video) {
152
+ const result = selectFromList(formats, spec.video);
153
+ if (result) return [result];
154
+ }
155
+
156
+ if (spec.fallback) {
157
+ return applySpec(formats, spec.fallback);
158
+ }
159
+
160
+ return [];
161
+ }
162
+
163
+ export function sortFormats(formats: Format[]): Format[] {
164
+ return [...formats].sort(
165
+ (a, b) => formatQualityScore(a) - formatQualityScore(b),
166
+ );
167
+ }
168
+
169
+ export function formatFormatTable(formats: Format[]): string {
170
+ const header = "ID".padEnd(12) +
171
+ "EXT".padEnd(6) +
172
+ "RESOLUTION".padEnd(14) +
173
+ "FPS".padEnd(6) +
174
+ " VCODEC".padEnd(12) +
175
+ "ACODEC".padEnd(12) +
176
+ "SIZE".padEnd(12) +
177
+ "NOTE";
178
+ const separator = "-".repeat(80);
179
+
180
+ const rows = sortFormats(formats).map((f) => {
181
+ const res = f.width && f.height ? `${f.width}x${f.height}` : (f.resolution ?? "audio");
182
+ const fps = f.fps ? String(f.fps) : "";
183
+ const size = f.filesize
184
+ ? formatSize(f.filesize)
185
+ : f.filesize_approx
186
+ ? `~${formatSize(f.filesize_approx)}`
187
+ : "";
188
+ return (
189
+ f.format_id.padEnd(12) +
190
+ (f.ext ?? "").padEnd(6) +
191
+ res.padEnd(14) +
192
+ fps.padEnd(6) +
193
+ (f.vcodec ?? "none").padEnd(12) +
194
+ (f.acodec ?? "none").padEnd(12) +
195
+ size.padEnd(12) +
196
+ (f.format_note ?? "")
197
+ );
198
+ });
199
+
200
+ return [header, separator, ...rows].join("\n");
201
+ }
202
+
203
+ function formatSize(bytes: number): string {
204
+ if (bytes < 1024) return `${bytes}B`;
205
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KiB`;
206
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MiB`;
207
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GiB`;
208
+ }
@@ -0,0 +1,101 @@
1
+ type LogLevel = "debug" | "info" | "warn" | "error";
2
+
3
+ const LEVEL_ORDER: Record<LogLevel, number> = {
4
+ debug: 0,
5
+ info: 1,
6
+ warn: 2,
7
+ error: 3,
8
+ };
9
+
10
+ const LEVEL_COLORS: Record<LogLevel, string> = {
11
+ debug: "\x1b[90m",
12
+ info: "\x1b[36m",
13
+ warn: "\x1b[33m",
14
+ error: "\x1b[31m",
15
+ };
16
+
17
+ const RESET = "\x1b[0m";
18
+
19
+ class Logger {
20
+ private level: LogLevel = "info";
21
+ private quiet = false;
22
+ private progressLine = "";
23
+
24
+ setLevel(level: LogLevel): void {
25
+ this.level = level;
26
+ }
27
+
28
+ setQuiet(quiet: boolean): void {
29
+ this.quiet = quiet;
30
+ }
31
+
32
+ debug(msg: string): void {
33
+ this.log("debug", msg);
34
+ }
35
+
36
+ info(msg: string): void {
37
+ this.log("info", msg);
38
+ }
39
+
40
+ warn(msg: string): void {
41
+ this.log("warn", msg);
42
+ }
43
+
44
+ error(msg: string): void {
45
+ this.log("error", msg);
46
+ }
47
+
48
+ progress(percent: number | null, speed: number | null, eta: number | null, filename: string): void {
49
+ if (this.quiet) return;
50
+
51
+ const pct = percent !== null ? `${percent.toFixed(1)}%` : "???%";
52
+ const spd = speed !== null ? formatBytes(speed) + "/s" : "N/A";
53
+ const etaStr = eta !== null ? formatEta(eta) : "N/A";
54
+ const bar = percent !== null ? renderBar(percent, 30) : "[" + " ".repeat(30) + "]";
55
+
56
+ this.progressLine = `\r${bar} ${pct} of ${filename} at ${spd} ETA ${etaStr}`;
57
+ process.stderr.write(this.progressLine);
58
+ }
59
+
60
+ clearProgress(): void {
61
+ if (this.progressLine) {
62
+ process.stderr.write("\r" + " ".repeat(this.progressLine.length) + "\r");
63
+ this.progressLine = "";
64
+ }
65
+ }
66
+
67
+ private log(level: LogLevel, msg: string): void {
68
+ if (this.quiet && level !== "error") return;
69
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[this.level]) return;
70
+
71
+ if (this.progressLine) {
72
+ this.clearProgress();
73
+ }
74
+
75
+ const color = LEVEL_COLORS[level];
76
+ const prefix = level === "info" ? "" : `${color}[${level}]${RESET} `;
77
+ process.stderr.write(`${prefix}${msg}\n`);
78
+ }
79
+ }
80
+
81
+ function renderBar(percent: number, width: number): string {
82
+ const filled = Math.round((percent / 100) * width);
83
+ const empty = width - filled;
84
+ return `[${"█".repeat(filled)}${" ".repeat(empty)}]`;
85
+ }
86
+
87
+ function formatBytes(bytes: number): string {
88
+ if (bytes < 1024) return `${bytes}B`;
89
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KiB`;
90
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MiB`;
91
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GiB`;
92
+ }
93
+
94
+ function formatEta(seconds: number): string {
95
+ if (seconds < 60) return `${Math.round(seconds)}s`;
96
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m${Math.round(seconds % 60)}s`;
97
+ return `${Math.floor(seconds / 3600)}h${Math.floor((seconds % 3600) / 60)}m`;
98
+ }
99
+
100
+ export const logger = new Logger();
101
+ export { Logger, formatBytes, formatEta };
@@ -0,0 +1,140 @@
1
+ import type { InfoDict, Options } from "./types";
2
+ import { DownloadError } from "./types";
3
+ import { findExtractor } from "../extractors/base";
4
+ import { getDownloader } from "../downloaders/base";
5
+ import { runPostProcessors } from "../postprocessors/base";
6
+ import { selectFormats, formatFormatTable } from "./format-sorter";
7
+ import { buildFilename } from "./output-template";
8
+ import { logger } from "./logger";
9
+
10
+ export class Orchestrator {
11
+ async process(url: string, options: Options): Promise<void> {
12
+ logger.info(`Processing: ${url}`);
13
+
14
+ const extractor = findExtractor(url);
15
+ if (!extractor) {
16
+ throw new DownloadError(`No extractor found for URL: ${url}`);
17
+ }
18
+
19
+ logger.debug(`Using extractor: ${extractor._NAME}`);
20
+ const info = await extractor.extract(url);
21
+
22
+ if (info._type === "playlist" && info.entries) {
23
+ logger.info(`Playlist: ${info.title} (${info.entries.length} entries)`);
24
+ for (let i = 0; i < info.entries.length; i++) {
25
+ const entry = info.entries[i];
26
+ entry.playlist = info.title;
27
+ entry.playlist_index = i + 1;
28
+ entry.playlist_count = info.entries.length;
29
+ if (entry.webpage_url) {
30
+ await this.processEntry(entry.webpage_url, entry, options);
31
+ }
32
+ }
33
+ return;
34
+ }
35
+
36
+ await this.processEntry(url, info, options);
37
+ }
38
+
39
+ private async processEntry(
40
+ url: string,
41
+ info: InfoDict,
42
+ options: Options,
43
+ ): Promise<void> {
44
+ if (options.dumpJson) {
45
+ process.stdout.write(JSON.stringify(info, null, 2) + "\n");
46
+ return;
47
+ }
48
+
49
+ if (options.listFormats) {
50
+ if (info.formats && info.formats.length > 0) {
51
+ process.stdout.write(formatFormatTable(info.formats) + "\n");
52
+ } else {
53
+ logger.warn("No formats available");
54
+ }
55
+ return;
56
+ }
57
+
58
+ if (!info.formats || info.formats.length === 0) {
59
+ if (info.url) {
60
+ info.formats = [
61
+ {
62
+ format_id: "direct",
63
+ url: info.url,
64
+ ext: info.ext ?? "mp4",
65
+ },
66
+ ];
67
+ } else {
68
+ throw new DownloadError("No formats found and no direct URL available");
69
+ }
70
+ }
71
+
72
+ const selectedFormats = selectFormats(info.formats, options.format);
73
+ if (selectedFormats.length === 0) {
74
+ throw new DownloadError(
75
+ `No matching formats for: ${options.format}`,
76
+ );
77
+ }
78
+
79
+ info.requested_formats = selectedFormats;
80
+ info.ext = selectedFormats[0].ext;
81
+
82
+ const filename = buildFilename(options.output, info);
83
+ const filepath = options.paths.home
84
+ ? `${options.paths.home}/${filename}`
85
+ : filename;
86
+ info.filename = filepath;
87
+
88
+ logger.info(`Downloading: ${info.title}`);
89
+ logger.debug(`Saving to: ${filepath}`);
90
+
91
+ for (const format of selectedFormats) {
92
+ const protocol = detectProtocol(format.url, format.protocol);
93
+ const downloader = getDownloader(protocol);
94
+ if (!downloader) {
95
+ throw new DownloadError(
96
+ `No downloader for protocol: ${protocol}`,
97
+ );
98
+ }
99
+
100
+ const targetPath =
101
+ selectedFormats.length > 1
102
+ ? `${filepath}.f${format.format_id}.${format.ext}`
103
+ : filepath;
104
+
105
+ await downloader.download(targetPath, format.url, {
106
+ headers: { ...info.http_headers, ...format.http_headers },
107
+ rateLimit: options.rateLimit,
108
+ retries: options.retries,
109
+ onProgress: options.quiet
110
+ ? undefined
111
+ : (progress) => {
112
+ logger.progress(
113
+ progress.percent,
114
+ progress.speed,
115
+ progress.eta,
116
+ filename,
117
+ );
118
+ },
119
+ });
120
+ }
121
+
122
+ logger.clearProgress();
123
+
124
+ if (selectedFormats.length > 1) {
125
+ logger.info("Merging formats requires ffmpeg (post-processor)");
126
+ }
127
+
128
+ await runPostProcessors(info, filepath, options);
129
+
130
+ logger.info(`Done: ${filepath}`);
131
+ }
132
+ }
133
+
134
+ function detectProtocol(url: string, hint?: string): string {
135
+ if (hint) return hint;
136
+ if (url.includes(".m3u8")) return "m3u8";
137
+ if (url.includes(".mpd")) return "dash";
138
+ if (url.startsWith("rtmp")) return "rtmp";
139
+ return "https";
140
+ }
@@ -0,0 +1,58 @@
1
+ import type { InfoDict } from "./types";
2
+ import { sanitizeFilename } from "../utils/sanitize";
3
+
4
+ const TEMPLATE_RE = /%\((\w+)\)([#0\- +]*)(\d*)(?:\.(\d+))?([sdiouxXeEfFgGcr%])/g;
5
+
6
+ export function renderTemplate(template: string, info: InfoDict): string {
7
+ return template.replace(TEMPLATE_RE, (_match, key: string, _flags: string, _width: string, _precision: string, conversion: string) => {
8
+ const value = getField(info, key);
9
+
10
+ if (value === undefined || value === null) {
11
+ return conversion === "d" ? "NA" : "NA";
12
+ }
13
+
14
+ if (conversion === "d" || conversion === "i") {
15
+ return String(Math.floor(Number(value)));
16
+ }
17
+
18
+ if (conversion === "f" || conversion === "F") {
19
+ return String(Number(value));
20
+ }
21
+
22
+ return String(value);
23
+ });
24
+ }
25
+
26
+ function getField(info: InfoDict, key: string): string | number | undefined {
27
+ const fieldMap: Record<string, unknown> = {
28
+ id: info.id,
29
+ title: info.title,
30
+ ext: info.ext ?? info.formats?.[0]?.ext ?? "unknown",
31
+ uploader: info.uploader,
32
+ uploader_id: info.uploader_id,
33
+ channel: info.channel,
34
+ channel_id: info.channel_id,
35
+ upload_date: info.upload_date,
36
+ duration: info.duration,
37
+ view_count: info.view_count,
38
+ like_count: info.like_count,
39
+ description: info.description,
40
+ webpage_url: info.webpage_url,
41
+ playlist: info.playlist,
42
+ playlist_index: info.playlist_index,
43
+ playlist_count: info.playlist_count,
44
+ timestamp: info.timestamp,
45
+ age_limit: info.age_limit,
46
+ extractor: info.extractor,
47
+ };
48
+
49
+ const val = fieldMap[key];
50
+ if (val === undefined || val === null) return undefined;
51
+ if (typeof val === "number") return val;
52
+ return String(val);
53
+ }
54
+
55
+ export function buildFilename(template: string, info: InfoDict): string {
56
+ const rendered = renderTemplate(template, info);
57
+ return sanitizeFilename(rendered);
58
+ }