@transcribe-api/sdk 0.1.3 → 0.1.6

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 (4) hide show
  1. package/README.md +392 -54
  2. package/index.js +1143 -1354
  3. package/package.json +2 -2
  4. package/worker.js +543 -296
package/index.js CHANGED
@@ -1,20 +1,19 @@
1
- const DEFAULT_BASE_URL = "https://api.transcribeapi.com/v1";
2
- const MAX_BATCH_FILES = 10000;
3
- const MAX_BATCH_TOTAL_SIZE_BYTES = 10 * 1024 * 1024 * 1024;
4
- const MAX_SYNC_AUDIO_BYTES = 30 * 1024 * 1024;
5
- const MAX_SYNC_AUDIO_SECONDS = 10 * 60;
6
- const MULTIPART_UPLOAD_THRESHOLD_BYTES = 100 * 1024 * 1024;
7
- const DEFAULT_MULTIPART_CONCURRENCY = 8;
8
- const MAX_MULTIPART_CONCURRENCY = 32;
1
+ const DEFAULT_BASE_URL = "https://api.transcribeapi.com/v1";
2
+ const MAX_BATCH_FILES = 10000;
3
+ const MAX_BATCH_TOTAL_SIZE_BYTES = 10 * 1024 * 1024 * 1024;
4
+ const MAX_SYNC_AUDIO_BYTES = 30 * 1024 * 1024;
5
+ const MAX_SYNC_AUDIO_SECONDS = 10 * 60;
6
+ const MULTIPART_UPLOAD_THRESHOLD_BYTES = 128 * 1024 * 1024;
7
+ const DEFAULT_UPLOAD_CONCURRENCY = 1;
8
+ const MAX_UPLOAD_CONCURRENCY = 32;
9
9
  const MAX_MULTIPART_ADAPTIVE_ATTEMPTS = 12;
10
10
  const MULTIPART_IDLE_WAIT_MS = 50;
11
- const MULTIPART_RESUME_STATE_VERSION = 1;
12
11
  const MIN_POLLING_INTERVAL_SECONDS = 10;
13
12
  const DEFAULT_POLLING_SPINNER_INTERVAL_MS = 150;
14
13
  const TERMINAL_JOB_STATUSES = new Set(["completed", "failed", "insufficient_funds"]);
15
14
  const BATCH_MP4_UNSUPPORTED_MESSAGE = "Batch uploads do not support .mp4 for MVP. Supported batch audio formats: mp3, mpeg, mpga, m4a, wav, webm.";
16
15
  const BATCH_UNSUPPORTED_MESSAGE = "Unsupported batch audio format. Supported batch audio formats: mp3, mpeg, mpga, m4a, wav, webm.";
17
-
16
+
18
17
  export class TranscribeAPIError extends Error {
19
18
  constructor(message, { status = null, code = null, extra = null, response = null } = {}) {
20
19
  super(message);
@@ -43,68 +42,68 @@ export class TranscribeAPIError extends Error {
43
42
  };
44
43
  }
45
44
  }
46
-
47
- function sleep(ms) {
48
- return new Promise((resolve) => setTimeout(resolve, ms));
49
- }
50
-
51
- function extractErrorInfo(error) {
52
- return {
53
- name: error?.name || null,
54
- message: error?.message || String(error),
55
- code: error?.code || error?.cause?.code || null,
56
- status: error?.status || null,
57
- cause_message: error?.cause?.message || null,
58
- };
59
- }
60
-
61
- function isRetryableError(error) {
62
- const code = String(error?.code || error?.cause?.code || "");
63
- if (
64
- code === "ECONNRESET"
65
- || code === "ETIMEDOUT"
66
- || code === "ECONNREFUSED"
67
- || code === "EPIPE"
68
- || code === "UND_ERR_SOCKET"
69
- || code === "UND_ERR_CONNECT_TIMEOUT"
70
- || code === "ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC"
71
- ) {
72
- return true;
73
- }
74
- if (String(error?.message || "").includes("fetch failed")) {
75
- return true;
76
- }
77
- return !error?.status || error.status === 429 || error.status >= 500;
78
- }
79
-
80
- async function retry(operation, { attempts = 3, baseDelayMs = 250 } = {}) {
81
- let lastError;
82
- for (let index = 0; index < attempts; index += 1) {
83
- try {
84
- return await operation(index);
85
- } catch (error) {
86
- lastError = error;
87
- const retryable = isRetryableError(error);
88
- if (!retryable || index === attempts - 1) {
89
- throw error;
90
- }
91
- await sleep(baseDelayMs * (2 ** index));
92
- }
93
- }
94
- throw lastError;
95
- }
96
-
97
- function normalizeMultipartConcurrency(value) {
98
- if (value === undefined || value === null || value === "") {
99
- return DEFAULT_MULTIPART_CONCURRENCY;
100
- }
101
- const parsed = Number.parseInt(String(value), 10);
102
- if (!Number.isInteger(parsed) || parsed < 1) {
103
- throw new TranscribeAPIError("`multipartConcurrency` must be an integer >= 1.", {
104
- code: "invalid_multipart_concurrency",
105
- });
106
- }
107
- return Math.min(parsed, MAX_MULTIPART_CONCURRENCY);
45
+
46
+ function sleep(ms) {
47
+ return new Promise((resolve) => setTimeout(resolve, ms));
48
+ }
49
+
50
+ function extractErrorInfo(error) {
51
+ return {
52
+ name: error?.name || null,
53
+ message: error?.message || String(error),
54
+ code: error?.code || error?.cause?.code || null,
55
+ status: error?.status || null,
56
+ cause_message: error?.cause?.message || null,
57
+ };
58
+ }
59
+
60
+ function isRetryableError(error) {
61
+ const code = String(error?.code || error?.cause?.code || "");
62
+ if (
63
+ code === "ECONNRESET"
64
+ || code === "ETIMEDOUT"
65
+ || code === "ECONNREFUSED"
66
+ || code === "EPIPE"
67
+ || code === "UND_ERR_SOCKET"
68
+ || code === "UND_ERR_CONNECT_TIMEOUT"
69
+ || code === "ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC"
70
+ ) {
71
+ return true;
72
+ }
73
+ if (String(error?.message || "").includes("fetch failed")) {
74
+ return true;
75
+ }
76
+ return !error?.status || error.status === 429 || error.status >= 500;
77
+ }
78
+
79
+ async function retry(operation, { attempts = 3, baseDelayMs = 250 } = {}) {
80
+ let lastError;
81
+ for (let index = 0; index < attempts; index += 1) {
82
+ try {
83
+ return await operation(index);
84
+ } catch (error) {
85
+ lastError = error;
86
+ const retryable = isRetryableError(error);
87
+ if (!retryable || index === attempts - 1) {
88
+ throw error;
89
+ }
90
+ await sleep(baseDelayMs * (2 ** index));
91
+ }
92
+ }
93
+ throw lastError;
94
+ }
95
+
96
+ function normalizeUploadConcurrency(value) {
97
+ if (value === undefined || value === null || value === "") {
98
+ return DEFAULT_UPLOAD_CONCURRENCY;
99
+ }
100
+ const parsed = Number.parseInt(String(value), 10);
101
+ if (!Number.isInteger(parsed) || parsed < 1) {
102
+ throw new TranscribeAPIError("`uploadConcurrency` must be an integer >= 1.", {
103
+ code: "invalid_upload_concurrency",
104
+ });
105
+ }
106
+ return Math.min(parsed, MAX_UPLOAD_CONCURRENCY);
108
107
  }
109
108
 
110
109
  function normalizePollingConfig(polling) {
@@ -143,212 +142,97 @@ function normalizePollingConfig(polling) {
143
142
  timeout,
144
143
  };
145
144
  }
146
-
147
- function isNodePathFile(file) {
148
- return Boolean(file?.path && typeof file.path === "string");
149
- }
150
-
151
- function multipartResumeStatePath(filePath) {
152
- return `${filePath}.transcribe-upload.json`;
153
- }
154
-
155
- function normalizeCompletedParts(parts) {
156
- if (!Array.isArray(parts)) {
157
- return [];
158
- }
159
- const deduped = new Map();
160
- for (const part of parts) {
161
- const partNumber = Number(part?.part_number ?? part?.partNumber);
162
- const etag = String(part?.etag || "").trim();
163
- if (!Number.isInteger(partNumber) || partNumber < 1 || !etag) {
164
- continue;
165
- }
166
- deduped.set(partNumber, {
167
- part_number: partNumber,
168
- etag,
169
- });
170
- }
171
- return Array.from(deduped.values()).sort((a, b) => a.part_number - b.part_number);
172
- }
173
-
174
- function serializeUploadForResume(upload) {
175
- if (!upload || typeof upload !== "object") {
176
- return null;
177
- }
178
- if (upload.type === "multipart") {
179
- return {
180
- type: "multipart",
181
- key: upload.key || null,
182
- upload_id: upload.upload_id || null,
183
- part_size: Number(upload.part_size || 0),
184
- };
185
- }
186
- if (upload.type === "put") {
187
- return {
188
- type: "put",
189
- key: upload.key || null,
190
- headers: upload.headers || {},
191
- };
192
- }
193
- return null;
194
- }
195
-
196
- async function readMultipartResumeState(file, { baseUrl } = {}) {
197
- if (!isNodePathFile(file)) {
198
- return null;
199
- }
200
- try {
201
- const fs = await import("node:fs/promises");
202
- const raw = await fs.readFile(multipartResumeStatePath(file.path), "utf8");
203
- const parsed = JSON.parse(raw);
204
- if (
205
- Number(parsed?.version) !== MULTIPART_RESUME_STATE_VERSION
206
- || String(parsed?.file_path || "") !== String(file.path)
207
- || Number(parsed?.file_size || 0) !== Number(file.size || 0)
208
- || String(parsed?.file_name || "") !== String(file.name || "")
209
- ) {
210
- return null;
211
- }
212
- if (baseUrl && String(parsed?.api_base_url || "") !== String(baseUrl)) {
213
- return null;
214
- }
215
- const upload = serializeUploadForResume(parsed.upload);
216
- if (!upload) {
217
- return null;
218
- }
219
- return {
220
- version: MULTIPART_RESUME_STATE_VERSION,
221
- api_base_url: String(parsed.api_base_url || ""),
222
- job_id: String(parsed.job_id || ""),
223
- model: parsed.model || null,
224
- file_path: String(parsed.file_path || ""),
225
- file_name: String(parsed.file_name || ""),
226
- file_size: Number(parsed.file_size || 0),
227
- upload,
228
- completed_parts: normalizeCompletedParts(parsed.completed_parts),
229
- };
230
- } catch {
231
- return null;
232
- }
233
- }
234
-
235
- async function writeMultipartResumeState(file, state) {
236
- if (!isNodePathFile(file) || !state) {
237
- return;
238
- }
239
- const fs = await import("node:fs/promises");
240
- await fs.writeFile(
241
- multipartResumeStatePath(file.path),
242
- JSON.stringify(state, null, 2),
243
- "utf8",
244
- );
245
- }
246
-
247
- async function deleteMultipartResumeState(file) {
248
- if (!isNodePathFile(file)) {
249
- return;
250
- }
251
- try {
252
- const fs = await import("node:fs/promises");
253
- await fs.unlink(multipartResumeStatePath(file.path));
254
- } catch {
255
- // Ignore missing or inaccessible state files.
256
- }
257
- }
258
-
259
- function buildMultipartResumeState({ baseUrl, jobId, model, file, upload, completedParts }) {
260
- return {
261
- version: MULTIPART_RESUME_STATE_VERSION,
262
- api_base_url: baseUrl,
263
- job_id: jobId,
264
- model: model || null,
265
- file_path: file.path,
266
- file_name: file.name,
267
- file_size: file.size,
268
- upload: serializeUploadForResume(upload),
269
- completed_parts: normalizeCompletedParts(completedParts),
270
- };
271
- }
272
-
273
- function contentTypeFromName(name = "") {
274
- const lower = String(name).toLowerCase();
275
- if (lower.endsWith(".mp3")) return "audio/mpeg";
276
- if (lower.endsWith(".mpeg")) return "audio/mpeg";
277
- if (lower.endsWith(".mpga")) return "audio/mpeg";
278
- if (lower.endsWith(".wav")) return "audio/wav";
279
- if (lower.endsWith(".m4a")) return "audio/mp4";
280
- if (lower.endsWith(".webm")) return "audio/webm";
281
- return "application/octet-stream";
282
- }
283
-
284
- function isRemoteFileInput(input) {
285
- return Boolean(input && typeof input === "object" && typeof input.url === "string");
286
- }
287
-
288
- function isRemoteBatchItem(input) {
289
- return Boolean(input && typeof input === "object" && typeof input.url === "string");
290
- }
291
-
292
- function defaultReferenceId(index) {
293
- return `file_${String(index + 1).padStart(6, "0")}`;
294
- }
295
-
296
- function normalizeBatchInputItem(item, index) {
297
- if (!item || typeof item !== "object" || Array.isArray(item)) {
298
- throw new TranscribeAPIError("Each batch item must be an object with `reference_id` and either `file` or `url`.", {
299
- code: "invalid_batch_item",
300
- });
301
- }
302
-
303
- const referenceId = String(item.reference_id || "").trim();
304
- if (!referenceId) {
305
- throw new TranscribeAPIError(`files[${index}].reference_id is required.`, {
306
- code: "missing_reference_id",
307
- });
308
- }
309
-
310
- const hasFile = Object.prototype.hasOwnProperty.call(item, "file");
311
- const hasUrl = typeof item.url === "string" && item.url.trim();
312
- if (hasFile && hasUrl) {
313
- throw new TranscribeAPIError(`files[${index}] must include either \`file\` or \`url\`, not both.`, {
314
- code: "invalid_batch_item",
315
- });
316
- }
317
- if (!hasFile && !hasUrl) {
318
- throw new TranscribeAPIError(`files[${index}] must include either \`file\` or \`url\`.`, {
319
- code: "invalid_batch_item",
320
- });
321
- }
322
-
323
- return {
324
- referenceId,
325
- file: hasFile ? item.file : null,
326
- url: hasUrl ? item.url.trim() : null,
327
- durationEstimateSec: item.durationEstimateSec || item.duration_estimate_sec || null,
328
- };
329
- }
330
-
331
- function uploadDescriptorForFile(referenceId, file) {
332
- const descriptor = { reference_id: referenceId };
333
- if (Number(file?.size || 0) >= MULTIPART_UPLOAD_THRESHOLD_BYTES) {
334
- descriptor.size_bytes = Number(file.size || 0);
335
- }
336
- return descriptor;
337
- }
338
-
339
- function normalizeResponseUploads(response) {
340
- if (Array.isArray(response?.uploads)) {
341
- return response.uploads;
342
- }
343
- if (response?.upload) {
344
- return [{
345
- reference_id: response.reference_id || defaultReferenceId(0),
346
- upload: response.upload,
347
- }];
348
- }
349
- return [];
350
- }
351
-
145
+
146
+ function contentTypeFromName(name = "") {
147
+ const lower = String(name).toLowerCase();
148
+ if (lower.endsWith(".mp3")) return "audio/mpeg";
149
+ if (lower.endsWith(".mpeg")) return "audio/mpeg";
150
+ if (lower.endsWith(".mpga")) return "audio/mpeg";
151
+ if (lower.endsWith(".wav")) return "audio/wav";
152
+ if (lower.endsWith(".m4a")) return "audio/mp4";
153
+ if (lower.endsWith(".webm")) return "audio/webm";
154
+ return "application/octet-stream";
155
+ }
156
+
157
+ function isRemoteBatchItem(input) {
158
+ return Boolean(input && typeof input === "object" && typeof input.url === "string");
159
+ }
160
+
161
+ function isFilesInput(input) {
162
+ return Array.isArray(input);
163
+ }
164
+
165
+ function normalizeLanguageCode(language, fieldName = "`language`") {
166
+ const value = String(language || "").trim().toLowerCase();
167
+ if (!value || value === "auto") {
168
+ return null;
169
+ }
170
+ if (!/^[a-z]{2}$/i.test(value)) {
171
+ throw new TranscribeAPIError(`${fieldName} must be a two-letter language code such as \`en\` or \`fr\`.`, {
172
+ code: "invalid_language",
173
+ });
174
+ }
175
+ return value;
176
+ }
177
+
178
+ function defaultReferenceId(index) {
179
+ return String(index + 1).padStart(5, "0");
180
+ }
181
+
182
+ function normalizeBatchInputItem(item, index) {
183
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
184
+ throw new TranscribeAPIError("Each batch item must be an object with either `file` or `url`.", {
185
+ code: "invalid_batch_item",
186
+ });
187
+ }
188
+
189
+ const referenceId = String(item.reference_id || "").trim() || null;
190
+
191
+ const hasFile = Object.prototype.hasOwnProperty.call(item, "file");
192
+ const hasUrl = typeof item.url === "string" && item.url.trim();
193
+ if (hasFile && hasUrl) {
194
+ throw new TranscribeAPIError(`files[${index}] must include either \`file\` or \`url\`, not both.`, {
195
+ code: "invalid_batch_item",
196
+ });
197
+ }
198
+ if (!hasFile && !hasUrl) {
199
+ throw new TranscribeAPIError(`files[${index}] must include either \`file\` or \`url\`.`, {
200
+ code: "invalid_batch_item",
201
+ });
202
+ }
203
+
204
+ return {
205
+ referenceId,
206
+ file: hasFile ? item.file : null,
207
+ url: hasUrl ? item.url.trim() : null,
208
+ durationEstimateSec: item.durationEstimateSec || item.duration_estimate_sec || null,
209
+ hasLanguage: Object.prototype.hasOwnProperty.call(item, "language"),
210
+ language: normalizeLanguageCode(item.language, `files[${index}].language`),
211
+ };
212
+ }
213
+
214
+ function uploadDescriptorForFile(referenceId, file) {
215
+ const descriptor = {};
216
+ if (referenceId) descriptor.reference_id = referenceId;
217
+ if (Number(file?.size || 0) >= MULTIPART_UPLOAD_THRESHOLD_BYTES) {
218
+ descriptor.size_bytes = Number(file.size || 0);
219
+ }
220
+ return descriptor;
221
+ }
222
+
223
+ function normalizeResponseUploads(response) {
224
+ if (Array.isArray(response?.uploads)) {
225
+ return response.uploads;
226
+ }
227
+ if (response?.upload) {
228
+ return [{
229
+ reference_id: response.reference_id || defaultReferenceId(0),
230
+ upload: response.upload,
231
+ }];
232
+ }
233
+ return [];
234
+ }
235
+
352
236
  function uploadFromResponse(response, referenceId) {
353
237
  if (response?.upload) {
354
238
  return response.upload;
@@ -384,11 +268,14 @@ function formatBytes(bytes) {
384
268
 
385
269
  function createSdkLoggerProgressHandler(logger = console) {
386
270
  let activeProgressLine = false;
271
+ let lastRenderedLength = 0;
387
272
 
388
273
  const writeLine = (line) => {
389
274
  if (activeProgressLine && typeof process !== "undefined" && process?.stdout?.write) {
275
+ process.stdout.write("\r".padEnd(lastRenderedLength + 1, " "));
390
276
  process.stdout.write("\n");
391
277
  activeProgressLine = false;
278
+ lastRenderedLength = 0;
392
279
  }
393
280
  if (typeof logger?.log === "function") {
394
281
  logger.log(line);
@@ -397,7 +284,9 @@ function createSdkLoggerProgressHandler(logger = console) {
397
284
 
398
285
  const writeProgress = (line) => {
399
286
  if (typeof process !== "undefined" && process?.stdout?.write) {
400
- process.stdout.write(`\r${line}`);
287
+ const paddedLine = line.padEnd(lastRenderedLength, " ");
288
+ lastRenderedLength = paddedLine.length;
289
+ process.stdout.write(`\r${paddedLine}`);
401
290
  activeProgressLine = true;
402
291
  return;
403
292
  }
@@ -411,7 +300,7 @@ function createSdkLoggerProgressHandler(logger = console) {
411
300
  return;
412
301
  }
413
302
  if (event.event === "upload_started") {
414
- writeLine(`Uploading ${event.uploadFiles}/${event.totalFiles} local file(s) for ${event.jobId}, ${formatBytes(event.totalBytes)} total`);
303
+ writeLine(`Uploading ${event.uploadFiles} file(s) for ${event.jobId}`);
415
304
  return;
416
305
  }
417
306
  if (event.event === "upload_progress") {
@@ -422,11 +311,17 @@ function createSdkLoggerProgressHandler(logger = console) {
422
311
  if (total && loaded >= total && activeProgressLine && typeof process !== "undefined" && process?.stdout?.write) {
423
312
  process.stdout.write("\n");
424
313
  activeProgressLine = false;
314
+ lastRenderedLength = 0;
425
315
  }
426
316
  return;
427
317
  }
428
318
  if (event.event === "upload_completed") {
429
319
  if (event.suppressLog) {
320
+ if (activeProgressLine && typeof process !== "undefined" && process?.stdout?.write) {
321
+ process.stdout.write("\n");
322
+ activeProgressLine = false;
323
+ lastRenderedLength = 0;
324
+ }
430
325
  return;
431
326
  }
432
327
  writeLine(`Uploaded completed: ${JSON.stringify(event.response, null, 2)}`);
@@ -497,9 +392,7 @@ function createSdkPollingLogger(logger = console) {
497
392
  },
498
393
  finish({ jobStatus, resultUrl }) {
499
394
  currentStatus = jobStatus || currentStatus;
500
- writeLine(resultUrl
501
- ? `Polling complete: ${currentStatus} - ${resultUrl}`
502
- : `Polling complete: ${currentStatus}`);
395
+ writeLine(resultUrl || `Polling complete: ${currentStatus}`);
503
396
  },
504
397
  timeout({ timeoutSeconds, jobStatus }) {
505
398
  currentStatus = jobStatus || currentStatus;
@@ -508,6 +401,17 @@ function createSdkPollingLogger(logger = console) {
508
401
  };
509
402
  }
510
403
 
404
+ function logTerminalAsyncResult(result, logger = console) {
405
+ if (typeof logger?.log !== "function" || !result || typeof result !== "object") {
406
+ return;
407
+ }
408
+ if (result.result_url) {
409
+ logger.log(String(result.result_url));
410
+ return;
411
+ }
412
+ logger.log(JSON.stringify(result, null, 2));
413
+ }
414
+
511
415
  function composeProgressHandler({ onProgress, showLogs = false, logger = console } = {}) {
512
416
  const logHandler = showLogs ? createSdkLoggerProgressHandler(logger) : null;
513
417
  if (!logHandler) {
@@ -518,845 +422,742 @@ function composeProgressHandler({ onProgress, showLogs = false, logger = console
518
422
  emitProgress(onProgress, event);
519
423
  };
520
424
  }
521
-
522
- function assertSupportedBatchFormat(file) {
523
- const lowerName = String(file?.name || "").toLowerCase();
524
- const lowerType = String(file?.type || contentTypeFromName(file?.name)).toLowerCase();
525
-
526
- if (lowerName.endsWith(".mp4") || lowerType === "video/mp4") {
527
- throw new TranscribeAPIError(BATCH_MP4_UNSUPPORTED_MESSAGE, { code: "unsupported_batch_format" });
528
- }
529
-
530
- if (
531
- lowerName.endsWith(".mp3")
532
- || lowerName.endsWith(".mpeg")
533
- || lowerName.endsWith(".mpga")
534
- || lowerName.endsWith(".m4a")
535
- || lowerName.endsWith(".wav")
536
- || lowerName.endsWith(".webm")
537
- || lowerType.includes("audio/mpeg")
538
- || lowerType.includes("mpga")
539
- || lowerType.includes("audio/mp4")
540
- || lowerType.includes("audio/x-m4a")
541
- || lowerType.includes("audio/wav")
542
- || lowerType.includes("audio/wave")
543
- || lowerType.includes("audio/webm")
544
- || lowerType.includes("video/webm")
545
- ) {
546
- return;
547
- }
548
-
549
- throw new TranscribeAPIError(BATCH_UNSUPPORTED_MESSAGE, { code: "unsupported_batch_format" });
550
- }
551
-
552
- function estimateDurationFromSize(sizeBytes) {
553
- return Math.max(1, Math.ceil(Number(sizeBytes || 0) / 16000));
554
- }
555
-
556
- function ascii(bytes, offset, length) {
557
- return Array.from(bytes.slice(offset, offset + length), (byte) => String.fromCharCode(byte)).join("");
558
- }
559
-
560
- function readUint32(bytes, offset, littleEndian = false) {
561
- return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, littleEndian);
562
- }
563
-
564
- function readUint64(bytes, offset) {
565
- const high = readUint32(bytes, offset);
566
- const low = readUint32(bytes, offset + 4);
567
- return high * 2 ** 32 + low;
568
- }
569
-
570
- function syncSafeInteger(bytes, offset) {
571
- return ((bytes[offset] & 0x7f) << 21)
572
- | ((bytes[offset + 1] & 0x7f) << 14)
573
- | ((bytes[offset + 2] & 0x7f) << 7)
574
- | (bytes[offset + 3] & 0x7f);
575
- }
576
-
577
- function id3Offset(bytes) {
578
- if (bytes.length >= 10 && ascii(bytes, 0, 3) === "ID3") {
579
- return 10 + syncSafeInteger(bytes, 6);
580
- }
581
- return 0;
582
- }
583
-
584
- const MP3_BITRATES = {
585
- V1L3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
586
- V2L3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
587
- };
588
-
589
- const MP3_SAMPLE_RATES = {
590
- 3: [44100, 48000, 32000],
591
- 2: [22050, 24000, 16000],
592
- 0: [11025, 12000, 8000],
593
- };
594
-
595
- function mp3FrameInfoAt(bytes, offset) {
596
- if (offset + 4 > bytes.length || bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
597
- return null;
598
- }
599
-
600
- const versionBits = (bytes[offset + 1] >> 3) & 0x03;
601
- const layerBits = (bytes[offset + 1] >> 1) & 0x03;
602
- const bitrateIndex = (bytes[offset + 2] >> 4) & 0x0f;
603
- const sampleRateIndex = (bytes[offset + 2] >> 2) & 0x03;
604
- const padding = (bytes[offset + 2] >> 1) & 0x01;
605
- if (versionBits === 1 || layerBits !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleRateIndex === 3) {
606
- return null;
607
- }
608
-
609
- const sampleRate = MP3_SAMPLE_RATES[versionBits]?.[sampleRateIndex];
610
- const bitrateKbps = versionBits === 3
611
- ? MP3_BITRATES.V1L3[bitrateIndex]
612
- : MP3_BITRATES.V2L3[bitrateIndex];
613
- if (!sampleRate || !bitrateKbps) {
614
- return null;
615
- }
616
-
617
- const bitrate = bitrateKbps * 1000;
618
- const frameLength = Math.floor(((versionBits === 3 ? 144 : 72) * bitrate) / sampleRate + padding);
619
- if (frameLength <= 0) {
620
- return null;
621
- }
622
-
623
- return {
624
- bitrate,
625
- sampleRate,
626
- frameLength,
627
- };
628
- }
629
-
630
- function findMp3Frame(bytes, startOffset = 0) {
631
- for (let offset = Math.max(0, startOffset); offset < bytes.length - 4; offset += 1) {
632
- const info = mp3FrameInfoAt(bytes, offset);
633
- if (info) {
634
- return { offset, ...info };
635
- }
636
- }
637
- return null;
638
- }
639
-
640
- function parseMp3Duration(bytes, objectSize) {
641
- const offset = id3Offset(bytes);
642
- const firstFrame = findMp3Frame(bytes, offset);
643
- if (!firstFrame) {
644
- return null;
645
- }
646
- const audioBytes = Math.max(0, Number(objectSize || bytes.length) - firstFrame.offset);
647
- const duration = (audioBytes * 8) / firstFrame.bitrate;
648
- return Number.isFinite(duration) && duration > 0 ? duration : null;
649
- }
650
-
651
- function parseWavDuration(bytes) {
652
- if (bytes.length < 44 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WAVE") {
653
- return null;
654
- }
655
-
656
- let offset = 12;
657
- let byteRate = 0;
658
- let dataSize = 0;
659
- while (offset + 8 <= bytes.length) {
660
- const chunkId = ascii(bytes, offset, 4);
661
- const chunkSize = readUint32(bytes, offset + 4, true);
662
- if (chunkId === "fmt " && offset + 20 <= bytes.length) {
663
- byteRate = readUint32(bytes, offset + 16, true);
664
- } else if (chunkId === "data") {
665
- dataSize = chunkSize;
666
- break;
667
- }
668
- offset += 8 + chunkSize + (chunkSize % 2);
669
- }
670
-
671
- if (!byteRate || !dataSize) {
672
- return null;
673
- }
674
- return dataSize / byteRate;
675
- }
676
-
677
- function parseMp4Duration(bytes) {
678
- for (let offset = 0; offset + 32 < bytes.length; offset += 1) {
679
- if (ascii(bytes, offset + 4, 4) !== "mvhd") {
680
- continue;
681
- }
682
- const size = readUint32(bytes, offset);
683
- if (size < 32 || offset + size > bytes.length + 8) {
684
- continue;
685
- }
686
- const version = bytes[offset + 8];
687
- if (version === 0 && offset + 28 <= bytes.length) {
688
- const timescale = readUint32(bytes, offset + 20);
689
- const duration = readUint32(bytes, offset + 24);
690
- return timescale > 0 ? duration / timescale : null;
691
- }
692
- if (version === 1 && offset + 40 <= bytes.length) {
693
- const timescale = readUint32(bytes, offset + 28);
694
- const duration = readUint64(bytes, offset + 32);
695
- return timescale > 0 ? duration / timescale : null;
696
- }
697
- }
698
- return null;
699
- }
700
-
701
- function typeFromNameOrContentType(name = "", contentType = "") {
702
- const lowerName = String(name || "").toLowerCase();
703
- const lowerType = String(contentType || "").toLowerCase();
704
- if (lowerType.includes("wav") || lowerName.endsWith(".wav")) {
705
- return "wav";
706
- }
707
- if (
708
- lowerType.includes("mp4")
709
- || lowerType.includes("m4a")
710
- || lowerType.includes("audio/x-m4a")
711
- || lowerName.endsWith(".m4a")
712
- ) {
713
- return "m4a";
714
- }
715
- return "mp3";
716
- }
717
-
718
- async function estimateDurationFromFile(file) {
719
- const headerLength = Math.min(Number(file?.size || 0), 2 * 1024 * 1024);
720
- if (!headerLength) {
721
- return estimateDurationFromSize(file?.size);
722
- }
723
-
724
- try {
725
- let bytes;
726
- if (typeof file?.readSlice === "function") {
727
- bytes = new Uint8Array(await file.readSlice(0, headerLength));
728
- } else if (typeof file?.slice === "function") {
729
- bytes = new Uint8Array(await file.slice(0, headerLength).arrayBuffer());
730
- } else {
731
- return estimateDurationFromSize(file?.size);
732
- }
733
- const type = typeFromNameOrContentType(file?.name, file?.type || contentTypeFromName(file?.name));
734
- const duration = type === "wav"
735
- ? parseWavDuration(bytes)
736
- : type === "m4a"
737
- ? parseMp4Duration(bytes)
738
- : parseMp3Duration(bytes, file.size);
739
- if (Number.isFinite(duration) && duration > 0) {
740
- return Math.max(1, Math.ceil(duration));
741
- }
742
- } catch {
743
- // Fall back to the coarse size heuristic when metadata parsing fails.
744
- }
745
-
746
- return estimateDurationFromSize(file?.size);
747
- }
748
-
749
- async function fileFromPath(pathValue) {
750
- const fs = await import("node:fs/promises");
751
- const path = await import("node:path");
752
- const stats = await fs.stat(pathValue);
753
- const name = path.basename(pathValue);
754
- return {
755
- name,
756
- size: stats.size,
757
- type: contentTypeFromName(name),
758
- path: pathValue,
759
- async readSlice(start, end) {
760
- const handle = await fs.open(pathValue, "r");
761
- try {
762
- const length = Math.max(0, end - start);
763
- const buffer = Buffer.alloc(length);
764
- const { bytesRead } = await handle.read(buffer, 0, length, start);
765
- return buffer.subarray(0, bytesRead);
766
- } finally {
767
- await handle.close();
768
- }
769
- },
770
- };
771
- }
772
-
773
- function makeFile(parts, name, type) {
774
- if (typeof File !== "undefined") {
775
- return new File(parts, name, { type });
776
- }
777
- const blob = new Blob(parts, { type });
778
- Object.defineProperty(blob, "name", { value: name });
779
- return blob;
780
- }
781
-
782
- async function normalizeFile(input, fallbackName = "audio.mp3") {
783
- if (
784
- input
785
- && typeof input === "object"
786
- && typeof input.name === "string"
787
- && Number.isFinite(Number(input.size))
788
- && typeof input.type === "string"
789
- && (typeof input.readSlice === "function" || typeof input.slice === "function")
790
- ) {
791
- return input;
792
- }
793
- if (typeof input === "string") {
794
- return fileFromPath(input);
795
- }
796
- if (typeof File !== "undefined" && input instanceof File) {
797
- return input;
798
- }
799
- if (input instanceof Blob) {
800
- return input.name ? input : makeFile([input], fallbackName, input.type || contentTypeFromName(fallbackName));
801
- }
802
- if (input?.data instanceof Uint8Array || input?.data instanceof ArrayBuffer) {
803
- return makeFile([input.data], input.name || fallbackName, input.type || contentTypeFromName(input.name || fallbackName));
804
- }
805
- throw new TranscribeAPIError("Invalid file. Provide a path, File, Blob, or { data, name, type }.");
806
- }
807
-
808
- async function parseApiResponse(response) {
809
- const text = await response.text();
810
- let data = {};
811
- try {
812
- data = text ? JSON.parse(text) : {};
813
- } catch {
814
- data = { raw: text };
815
- }
425
+
426
+ function assertSupportedBatchFormat(file) {
427
+ const lowerName = String(file?.name || "").toLowerCase();
428
+ const lowerType = String(file?.type || contentTypeFromName(file?.name)).toLowerCase();
429
+
430
+ if (lowerName.endsWith(".mp4") || lowerType === "video/mp4") {
431
+ throw new TranscribeAPIError(BATCH_MP4_UNSUPPORTED_MESSAGE, { code: "unsupported_batch_format" });
432
+ }
433
+
434
+ if (
435
+ lowerName.endsWith(".mp3")
436
+ || lowerName.endsWith(".mpeg")
437
+ || lowerName.endsWith(".mpga")
438
+ || lowerName.endsWith(".m4a")
439
+ || lowerName.endsWith(".wav")
440
+ || lowerName.endsWith(".webm")
441
+ || lowerType.includes("audio/mpeg")
442
+ || lowerType.includes("mpga")
443
+ || lowerType.includes("audio/mp4")
444
+ || lowerType.includes("audio/x-m4a")
445
+ || lowerType.includes("audio/wav")
446
+ || lowerType.includes("audio/wave")
447
+ || lowerType.includes("audio/webm")
448
+ || lowerType.includes("video/webm")
449
+ ) {
450
+ return;
451
+ }
452
+
453
+ throw new TranscribeAPIError(BATCH_UNSUPPORTED_MESSAGE, { code: "unsupported_batch_format" });
454
+ }
455
+
456
+ function estimateDurationFromSize(sizeBytes) {
457
+ return Math.max(1, Math.ceil(Number(sizeBytes || 0) / 16000));
458
+ }
459
+
460
+ function ascii(bytes, offset, length) {
461
+ return Array.from(bytes.slice(offset, offset + length), (byte) => String.fromCharCode(byte)).join("");
462
+ }
463
+
464
+ function readUint32(bytes, offset, littleEndian = false) {
465
+ return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, littleEndian);
466
+ }
467
+
468
+ function readUint64(bytes, offset) {
469
+ const high = readUint32(bytes, offset);
470
+ const low = readUint32(bytes, offset + 4);
471
+ return high * 2 ** 32 + low;
472
+ }
473
+
474
+ function syncSafeInteger(bytes, offset) {
475
+ return ((bytes[offset] & 0x7f) << 21)
476
+ | ((bytes[offset + 1] & 0x7f) << 14)
477
+ | ((bytes[offset + 2] & 0x7f) << 7)
478
+ | (bytes[offset + 3] & 0x7f);
479
+ }
480
+
481
+ function id3Offset(bytes) {
482
+ if (bytes.length >= 10 && ascii(bytes, 0, 3) === "ID3") {
483
+ return 10 + syncSafeInteger(bytes, 6);
484
+ }
485
+ return 0;
486
+ }
487
+
488
+ const MP3_BITRATES = {
489
+ V1L3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
490
+ V2L3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
491
+ };
492
+
493
+ const MP3_SAMPLE_RATES = {
494
+ 3: [44100, 48000, 32000],
495
+ 2: [22050, 24000, 16000],
496
+ 0: [11025, 12000, 8000],
497
+ };
498
+
499
+ function mp3FrameInfoAt(bytes, offset) {
500
+ if (offset + 4 > bytes.length || bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
501
+ return null;
502
+ }
503
+
504
+ const versionBits = (bytes[offset + 1] >> 3) & 0x03;
505
+ const layerBits = (bytes[offset + 1] >> 1) & 0x03;
506
+ const bitrateIndex = (bytes[offset + 2] >> 4) & 0x0f;
507
+ const sampleRateIndex = (bytes[offset + 2] >> 2) & 0x03;
508
+ const padding = (bytes[offset + 2] >> 1) & 0x01;
509
+ if (versionBits === 1 || layerBits !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleRateIndex === 3) {
510
+ return null;
511
+ }
512
+
513
+ const sampleRate = MP3_SAMPLE_RATES[versionBits]?.[sampleRateIndex];
514
+ const bitrateKbps = versionBits === 3
515
+ ? MP3_BITRATES.V1L3[bitrateIndex]
516
+ : MP3_BITRATES.V2L3[bitrateIndex];
517
+ if (!sampleRate || !bitrateKbps) {
518
+ return null;
519
+ }
520
+
521
+ const bitrate = bitrateKbps * 1000;
522
+ const frameLength = Math.floor(((versionBits === 3 ? 144 : 72) * bitrate) / sampleRate + padding);
523
+ if (frameLength <= 0) {
524
+ return null;
525
+ }
526
+
527
+ return {
528
+ bitrate,
529
+ sampleRate,
530
+ frameLength,
531
+ };
532
+ }
533
+
534
+ function findMp3Frame(bytes, startOffset = 0) {
535
+ for (let offset = Math.max(0, startOffset); offset < bytes.length - 4; offset += 1) {
536
+ const info = mp3FrameInfoAt(bytes, offset);
537
+ if (info) {
538
+ return { offset, ...info };
539
+ }
540
+ }
541
+ return null;
542
+ }
543
+
544
+ function parseMp3Duration(bytes, objectSize) {
545
+ const offset = id3Offset(bytes);
546
+ const firstFrame = findMp3Frame(bytes, offset);
547
+ if (!firstFrame) {
548
+ return null;
549
+ }
550
+ const audioBytes = Math.max(0, Number(objectSize || bytes.length) - firstFrame.offset);
551
+ const duration = (audioBytes * 8) / firstFrame.bitrate;
552
+ return Number.isFinite(duration) && duration > 0 ? duration : null;
553
+ }
554
+
555
+ function parseWavDuration(bytes) {
556
+ if (bytes.length < 44 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WAVE") {
557
+ return null;
558
+ }
559
+
560
+ let offset = 12;
561
+ let byteRate = 0;
562
+ let dataSize = 0;
563
+ while (offset + 8 <= bytes.length) {
564
+ const chunkId = ascii(bytes, offset, 4);
565
+ const chunkSize = readUint32(bytes, offset + 4, true);
566
+ if (chunkId === "fmt " && offset + 20 <= bytes.length) {
567
+ byteRate = readUint32(bytes, offset + 16, true);
568
+ } else if (chunkId === "data") {
569
+ dataSize = chunkSize;
570
+ break;
571
+ }
572
+ offset += 8 + chunkSize + (chunkSize % 2);
573
+ }
574
+
575
+ if (!byteRate || !dataSize) {
576
+ return null;
577
+ }
578
+ return dataSize / byteRate;
579
+ }
580
+
581
+ function parseMp4Duration(bytes) {
582
+ for (let offset = 0; offset + 32 < bytes.length; offset += 1) {
583
+ if (ascii(bytes, offset + 4, 4) !== "mvhd") {
584
+ continue;
585
+ }
586
+ const size = readUint32(bytes, offset);
587
+ if (size < 32 || offset + size > bytes.length + 8) {
588
+ continue;
589
+ }
590
+ const version = bytes[offset + 8];
591
+ if (version === 0 && offset + 28 <= bytes.length) {
592
+ const timescale = readUint32(bytes, offset + 20);
593
+ const duration = readUint32(bytes, offset + 24);
594
+ return timescale > 0 ? duration / timescale : null;
595
+ }
596
+ if (version === 1 && offset + 40 <= bytes.length) {
597
+ const timescale = readUint32(bytes, offset + 28);
598
+ const duration = readUint64(bytes, offset + 32);
599
+ return timescale > 0 ? duration / timescale : null;
600
+ }
601
+ }
602
+ return null;
603
+ }
604
+
605
+ function typeFromNameOrContentType(name = "", contentType = "") {
606
+ const lowerName = String(name || "").toLowerCase();
607
+ const lowerType = String(contentType || "").toLowerCase();
608
+ if (lowerType.includes("wav") || lowerName.endsWith(".wav")) {
609
+ return "wav";
610
+ }
611
+ if (
612
+ lowerType.includes("mp4")
613
+ || lowerType.includes("m4a")
614
+ || lowerType.includes("audio/x-m4a")
615
+ || lowerName.endsWith(".m4a")
616
+ ) {
617
+ return "m4a";
618
+ }
619
+ return "mp3";
620
+ }
621
+
622
+ async function estimateDurationFromFile(file) {
623
+ const headerLength = Math.min(Number(file?.size || 0), 2 * 1024 * 1024);
624
+ if (!headerLength) {
625
+ return estimateDurationFromSize(file?.size);
626
+ }
627
+
628
+ try {
629
+ let bytes;
630
+ if (typeof file?.readSlice === "function") {
631
+ bytes = new Uint8Array(await file.readSlice(0, headerLength));
632
+ } else if (typeof file?.slice === "function") {
633
+ bytes = new Uint8Array(await file.slice(0, headerLength).arrayBuffer());
634
+ } else {
635
+ return estimateDurationFromSize(file?.size);
636
+ }
637
+ const type = typeFromNameOrContentType(file?.name, file?.type || contentTypeFromName(file?.name));
638
+ const duration = type === "wav"
639
+ ? parseWavDuration(bytes)
640
+ : type === "m4a"
641
+ ? parseMp4Duration(bytes)
642
+ : parseMp3Duration(bytes, file.size);
643
+ if (Number.isFinite(duration) && duration > 0) {
644
+ return Math.max(1, Math.ceil(duration));
645
+ }
646
+ } catch {
647
+ // Fall back to the coarse size heuristic when metadata parsing fails.
648
+ }
649
+
650
+ return estimateDurationFromSize(file?.size);
651
+ }
652
+
653
+ async function fileFromPath(pathValue) {
654
+ const fs = await import("node:fs/promises");
655
+ const path = await import("node:path");
656
+ const stats = await fs.stat(pathValue);
657
+ const name = path.basename(pathValue);
658
+ return {
659
+ name,
660
+ size: stats.size,
661
+ type: contentTypeFromName(name),
662
+ path: pathValue,
663
+ async readSlice(start, end) {
664
+ const handle = await fs.open(pathValue, "r");
665
+ try {
666
+ const length = Math.max(0, end - start);
667
+ const buffer = Buffer.alloc(length);
668
+ const { bytesRead } = await handle.read(buffer, 0, length, start);
669
+ return buffer.subarray(0, bytesRead);
670
+ } finally {
671
+ await handle.close();
672
+ }
673
+ },
674
+ };
675
+ }
676
+
677
+ function makeFile(parts, name, type) {
678
+ if (typeof File !== "undefined") {
679
+ return new File(parts, name, { type });
680
+ }
681
+ const blob = new Blob(parts, { type });
682
+ Object.defineProperty(blob, "name", { value: name });
683
+ return blob;
684
+ }
685
+
686
+ async function normalizeFile(input, fallbackName = "audio.mp3") {
687
+ if (
688
+ input
689
+ && typeof input === "object"
690
+ && typeof input.name === "string"
691
+ && Number.isFinite(Number(input.size))
692
+ && typeof input.type === "string"
693
+ && (typeof input.readSlice === "function" || typeof input.slice === "function")
694
+ ) {
695
+ return input;
696
+ }
697
+ if (typeof input === "string") {
698
+ return fileFromPath(input);
699
+ }
700
+ if (typeof File !== "undefined" && input instanceof File) {
701
+ return input;
702
+ }
703
+ if (input instanceof Blob) {
704
+ return input.name ? input : makeFile([input], fallbackName, input.type || contentTypeFromName(fallbackName));
705
+ }
706
+ if (input?.data instanceof Uint8Array || input?.data instanceof ArrayBuffer) {
707
+ return makeFile([input.data], input.name || fallbackName, input.type || contentTypeFromName(input.name || fallbackName));
708
+ }
709
+ throw new TranscribeAPIError("Invalid file. Provide a path, File, Blob, or { data, name, type }.");
710
+ }
711
+
712
+ async function parseApiResponse(response) {
713
+ const text = await response.text();
714
+ let data = {};
715
+ try {
716
+ data = text ? JSON.parse(text) : {};
717
+ } catch {
718
+ data = text;
719
+ }
816
720
  if (!response.ok) {
817
- const message = data.message || data.error || text || `HTTP ${response.status}`;
818
- const extra = { ...data };
721
+ const body = data && typeof data === "object" ? data : {};
722
+ const message = body.message || body.error || text || `HTTP ${response.status}`;
723
+ const extra = { ...body };
819
724
  delete extra.message;
820
725
  delete extra.error;
821
726
  delete extra.code;
822
727
  throw new TranscribeAPIError(message, {
823
728
  status: response.status,
824
- code: data.code || null,
729
+ code: body.code || null,
825
730
  extra,
826
731
  response: {
827
732
  message,
828
- ...(data.code ? { code: data.code } : {}),
733
+ ...(body.code ? { code: body.code } : {}),
829
734
  ...extra,
830
735
  },
831
736
  });
832
737
  }
833
738
  return data;
834
739
  }
835
-
836
- function normalizeHeaders(headers = {}) {
837
- return Object.fromEntries(
838
- Object.entries(headers)
839
- .filter(([, value]) => value !== undefined && value !== null)
840
- .map(([key, value]) => [key, String(value)]),
841
- );
842
- }
843
-
844
- function isNodeStreamBody(body) {
845
- return Boolean(body && typeof body === "object" && typeof body.pipe === "function");
846
- }
847
-
848
- async function putNodeStream(upload, streamBody, headers) {
849
- const http = await import("node:http");
850
- const https = await import("node:https");
851
- const { pipeline } = await import("node:stream/promises");
852
- const target = new URL(upload.url || upload);
853
- const transport = target.protocol === "https:" ? https : http;
854
-
855
- return new Promise((resolve, reject) => {
856
- let settled = false;
857
- const finishResolve = (value) => {
858
- if (settled) {
859
- return;
860
- }
861
- settled = true;
862
- resolve(value);
863
- };
864
- const finishReject = (error) => {
865
- if (settled) {
866
- return;
867
- }
868
- settled = true;
869
- reject(error);
870
- };
871
- const request = transport.request(target, {
872
- method: "PUT",
873
- headers,
874
- agent: false,
875
- }, (response) => {
876
- response.resume();
877
- response.on("error", finishReject);
878
- response.on("end", () => {
879
- finishResolve({
880
- ok: response.statusCode >= 200 && response.statusCode < 300,
881
- status: response.statusCode || 0,
882
- headers: {
883
- get(name) {
884
- const value = response.headers[String(name || "").toLowerCase()];
885
- return Array.isArray(value) ? value[0] : value || null;
886
- },
887
- },
888
- });
889
- });
890
- });
891
-
892
- request.on("error", finishReject);
893
- request.on("socket", (socket) => {
894
- socket.on("error", finishReject);
895
- });
896
- request.setTimeout(120000, () => {
897
- request.destroy(new Error("stream_upload_timeout"));
898
- });
899
- if (typeof streamBody.on === "function") {
900
- streamBody.on("error", finishReject);
901
- }
902
- pipeline(streamBody, request).catch(finishReject);
903
- });
904
- }
905
-
906
- async function putObjectWithRetry(upload, body, {
907
- onProgress,
908
- loadedOffset = 0,
909
- totalBytes = 0,
910
- contentLength = null,
911
- progressMeta = null,
912
- debugContext = null,
913
- } = {}) {
914
- const response = await retry(async (attemptIndex) => {
915
- const resolvedBody = typeof body === "function" ? await body() : body;
916
- const headers = normalizeHeaders({
917
- ...(upload.headers || {}),
918
- ...(contentLength !== null ? { "Content-Length": contentLength } : {}),
919
- ...(typeof body === "function" ? { Connection: "close" } : {}),
920
- });
921
- const putResponse = isNodeStreamBody(resolvedBody)
922
- ? await putNodeStream(upload, resolvedBody, headers)
923
- : await fetch(upload.url || upload, {
924
- method: "PUT",
925
- headers,
926
- body: resolvedBody,
927
- });
928
- if (!putResponse.ok) {
929
- throw new TranscribeAPIError(`R2 upload failed with HTTP ${putResponse.status}.`, {
930
- status: putResponse.status,
931
- code: "upload_failed",
932
- extra: {
933
- ...(progressMeta || {}),
934
- ...(debugContext || {}),
935
- content_length: contentLength,
936
- },
937
- });
938
- }
939
- return putResponse;
940
- }, {
941
- attempts: 5,
942
- baseDelayMs: 1000,
943
- });
944
- const transferredBytes = Number(
945
- contentLength
946
- ?? body?.size
947
- ?? body?.byteLength
948
- ?? 0,
949
- );
950
- const loaded = loadedOffset + transferredBytes;
951
- if (onProgress) {
952
- onProgress({
953
- loaded,
954
- total: totalBytes || loaded,
955
- ...(progressMeta || {}),
956
- });
957
- }
958
- return response;
959
- }
960
-
961
- async function openFileBody(file, start = 0, end = null) {
962
- if (!file?.path) {
963
- return file;
964
- }
965
- const fs = await import("node:fs");
966
- return fs.createReadStream(file.path, end === null ? { start } : { start, end: end - 1 });
967
- }
968
-
969
- async function runWithConcurrency(items, concurrency, worker) {
970
- let index = 0;
971
- const workerCount = Math.max(1, Math.min(items.length || 1, concurrency));
972
- const workers = Array.from({ length: workerCount }, async () => {
973
- while (true) {
974
- const current = index;
975
- index += 1;
976
- if (current >= items.length) {
977
- return;
978
- }
979
- await worker(items[current], current);
980
- }
981
- });
982
- await Promise.all(workers);
983
- }
984
-
985
- function multipartCompleteXml(parts) {
986
- const rows = parts
987
- .sort((a, b) => a.partNumber - b.partNumber)
988
- .map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${part.etag}</ETag></Part>`)
989
- .join("");
990
- return `<CompleteMultipartUpload>${rows}</CompleteMultipartUpload>`;
991
- }
992
-
993
- async function uploadMultipart(upload, file, {
994
- onProgress,
995
- multipartConcurrency = DEFAULT_MULTIPART_CONCURRENCY,
996
- resumeState = null,
997
- onPartComplete = null,
998
- onConcurrencyChange = null,
999
- } = {}) {
1000
- const completed = normalizeCompletedParts(resumeState?.completed_parts).map((part) => ({
1001
- partNumber: part.part_number,
1002
- etag: part.etag,
1003
- }));
1004
- const totalParts = upload.parts.length;
1005
- const completedPartNumbers = new Set(completed.map((part) => part.partNumber));
1006
- let completedBytes = completed.reduce((total, part) => {
1007
- const start = (part.partNumber - 1) * upload.part_size;
1008
- const end = Math.min(file.size, start + upload.part_size);
1009
- return total + Math.max(0, end - start);
1010
- }, 0);
1011
- const concurrency = normalizeMultipartConcurrency(multipartConcurrency);
1012
- let targetConcurrency = concurrency;
1013
- let activeWorkers = 0;
1014
- let fatalError = null;
1015
- const pendingParts = upload.parts.filter((part) => !completedPartNumbers.has(part.part_number));
1016
- const partAttempts = new Map();
1017
-
1018
- if (onProgress && completedBytes > 0) {
1019
- onProgress({
1020
- loaded: completedBytes,
1021
- total: file.size,
1022
- uploadType: "multipart",
1023
- totalParts,
1024
- multipartConcurrency: targetConcurrency,
1025
- resumed: true,
1026
- });
1027
- }
1028
-
1029
- const workers = Array.from({ length: concurrency }, async (_, workerIndex) => {
1030
- while (true) {
1031
- if (fatalError) {
1032
- return;
1033
- }
1034
- if (completedPartNumbers.size >= totalParts) {
1035
- return;
1036
- }
1037
- if (workerIndex >= targetConcurrency) {
1038
- await sleep(MULTIPART_IDLE_WAIT_MS);
1039
- continue;
1040
- }
1041
- const part = pendingParts.shift();
1042
- if (!part) {
1043
- if (!activeWorkers) {
1044
- return;
1045
- }
1046
- await sleep(MULTIPART_IDLE_WAIT_MS);
1047
- continue;
1048
- }
1049
-
1050
- activeWorkers += 1;
1051
- const start = (part.part_number - 1) * upload.part_size;
1052
- const end = Math.min(file.size, start + upload.part_size);
1053
- const chunkSize = end - start;
1054
- try {
1055
- const response = await putObjectWithRetry(
1056
- { url: part.url },
1057
- file.path ? (() => openFileBody(file, start, end)) : file.slice(start, end),
1058
- {
1059
- onProgress: null,
1060
- loadedOffset: 0,
1061
- totalBytes: file.size,
1062
- contentLength: chunkSize,
1063
- progressMeta: {
1064
- uploadType: "multipart",
1065
- partNumber: part.part_number,
1066
- totalParts,
1067
- chunkBytes: chunkSize,
1068
- },
1069
- debugContext: {
1070
- file_name: file?.name || null,
1071
- file_size: file?.size || null,
1072
- range_start: start,
1073
- range_end_exclusive: end,
1074
- },
1075
- },
1076
- );
1077
- const etag = response.headers.get("ETag") || response.headers.get("etag");
1078
- completed.push({
1079
- partNumber: part.part_number,
1080
- etag,
1081
- });
1082
- completedPartNumbers.add(part.part_number);
1083
- completedBytes += chunkSize;
1084
- if (onPartComplete) {
1085
- try {
1086
- await onPartComplete({
1087
- partNumber: part.part_number,
1088
- etag,
1089
- completedParts: completed,
1090
- });
1091
- } catch {
1092
- // Resume-state persistence is best-effort.
1093
- }
1094
- }
1095
- if (onProgress) {
1096
- onProgress({
1097
- loaded: completedBytes,
1098
- total: file.size,
1099
- uploadType: "multipart",
1100
- partNumber: part.part_number,
1101
- totalParts,
1102
- chunkBytes: chunkSize,
1103
- multipartConcurrency: targetConcurrency,
1104
- });
1105
- }
1106
- } catch (error) {
1107
- const attempts = (partAttempts.get(part.part_number) || 0) + 1;
1108
- partAttempts.set(part.part_number, attempts);
1109
- const retryable = isRetryableError(error);
1110
- if (retryable && attempts < MAX_MULTIPART_ADAPTIVE_ATTEMPTS) {
1111
- const previousConcurrency = targetConcurrency;
1112
- targetConcurrency = Math.max(1, Math.floor(targetConcurrency / 2));
1113
- pendingParts.push(part);
1114
- if (onConcurrencyChange && targetConcurrency !== previousConcurrency) {
1115
- try {
1116
- onConcurrencyChange({
1117
- previousConcurrency,
1118
- nextConcurrency: targetConcurrency,
1119
- partNumber: part.part_number,
1120
- attempts,
1121
- error: extractErrorInfo(error),
1122
- });
1123
- } catch {
1124
- // Ignore observer failures.
1125
- }
1126
- }
1127
- activeWorkers -= 1;
1128
- await sleep(Math.min(10000, 1000 * (2 ** Math.min(attempts - 1, 3))));
1129
- continue;
1130
- }
1131
- fatalError = new TranscribeAPIError(
1132
- `Multipart upload failed for part ${part.part_number}/${totalParts}.`,
1133
- {
1134
- status: error?.status || null,
1135
- code: error?.code || "multipart_part_upload_failed",
1136
- extra: {
1137
- cause_error: error?.error || error?.message || String(error),
1138
- cause_code: error?.code || error?.cause?.code || null,
1139
- cause_status: error?.status || null,
1140
- upload_type: "multipart",
1141
- part_number: part.part_number,
1142
- total_parts: totalParts,
1143
- chunk_bytes: chunkSize,
1144
- completed_bytes_before_failure: completedBytes,
1145
- multipart_concurrency: targetConcurrency,
1146
- file_name: file?.name || null,
1147
- file_size: file?.size || null,
1148
- range_start: start,
1149
- range_end_exclusive: end,
1150
- attempts,
1151
- },
1152
- },
1153
- );
1154
- activeWorkers -= 1;
1155
- return;
1156
- }
1157
- activeWorkers -= 1;
1158
- }
1159
- });
1160
-
1161
- await Promise.all(workers);
1162
- if (fatalError) {
1163
- throw fatalError;
1164
- }
1165
-
1166
- const completeResponse = await retry(async () => {
1167
- const response = await fetch(upload.complete_url, {
1168
- method: "POST",
1169
- headers: { "Content-Type": "application/xml" },
1170
- body: multipartCompleteXml(completed),
1171
- });
1172
- if (!response.ok) {
1173
- throw new TranscribeAPIError(`R2 multipart complete failed with HTTP ${response.status}.`, {
1174
- status: response.status,
1175
- code: "multipart_complete_failed",
1176
- });
1177
- }
1178
- return response;
1179
- });
1180
- return completeResponse;
1181
- }
1182
-
1183
- async function uploadUsingInstructions(upload, file, options = {}) {
1184
- if (upload.type === "multipart") {
1185
- return uploadMultipart(upload, file, options);
1186
- }
1187
- const body = file.path ? (() => openFileBody(file)) : file;
1188
- return putObjectWithRetry(upload, body, {
1189
- onProgress: options.onProgress,
1190
- loadedOffset: 0,
1191
- totalBytes: file.size,
1192
- contentLength: file.size,
1193
- progressMeta: {
1194
- uploadType: "single_put",
1195
- chunkBytes: file.size,
1196
- },
1197
- debugContext: {
1198
- file_name: file?.name || null,
1199
- file_size: file?.size || null,
1200
- },
1201
- });
1202
- }
1203
-
1204
- function assertBatchLimits(totalFiles, totalSizeBytes) {
1205
- if (totalFiles > MAX_BATCH_FILES) {
1206
- throw new TranscribeAPIError(`Batch jobs support up to ${MAX_BATCH_FILES} files.`, {
1207
- code: "too_many_files",
1208
- });
1209
- }
1210
- if (totalSizeBytes > MAX_BATCH_TOTAL_SIZE_BYTES) {
1211
- throw new TranscribeAPIError("Batch jobs support up to 10GB total.", {
1212
- code: "batch_too_large",
1213
- });
1214
- }
1215
- }
1216
-
1217
- class BigFileJob {
1218
- constructor(client, file, createResponse, options) {
1219
- this.client = client;
1220
- this.file = file;
1221
- this.createResponse = createResponse;
1222
- this.referenceId = options.referenceId || defaultReferenceId(0);
1223
- this.jobId = createResponse.job_id;
1224
- this.jobStatus = createResponse.job_status;
1225
- this.model = createResponse.model || options.model || null;
1226
- this.uploadInfo = uploadFromResponse(createResponse, this.referenceId);
1227
- this.options = options;
1228
- this.resumeState = options.resumeState || null;
1229
- this.resumeWriteChain = Promise.resolve();
1230
- }
1231
-
1232
- async persistResumeState(completedParts = this.resumeState?.completed_parts || []) {
1233
- if (!isNodePathFile(this.file) || this.uploadInfo?.type !== "multipart") {
1234
- return;
1235
- }
1236
- const state = buildMultipartResumeState({
1237
- baseUrl: this.client.baseUrl,
1238
- jobId: this.jobId,
1239
- model: this.model,
1240
- file: this.file,
1241
- upload: this.uploadInfo,
1242
- completedParts,
1243
- });
1244
- this.resumeState = state;
1245
- this.resumeWriteChain = this.resumeWriteChain
1246
- .catch(() => {})
1247
- .then(() => writeMultipartResumeState(this.file, state))
1248
- .catch(() => {});
1249
- await this.resumeWriteChain;
1250
- }
1251
-
1252
- async clearResumeState() {
1253
- await this.resumeWriteChain.catch(() => {});
1254
- await deleteMultipartResumeState(this.file);
1255
- this.resumeState = null;
1256
- }
1257
-
1258
- async upload({ onProgress } = {}) {
1259
- const progress = onProgress || this.options.onProgress;
1260
- if (!this.uploadInfo) {
1261
- return this.createResponse;
740
+
741
+ function normalizeHeaders(headers = {}) {
742
+ return Object.fromEntries(
743
+ Object.entries(headers)
744
+ .filter(([, value]) => value !== undefined && value !== null)
745
+ .map(([key, value]) => [key, String(value)]),
746
+ );
747
+ }
748
+
749
+ function isNodeStreamBody(body) {
750
+ return Boolean(body && typeof body === "object" && typeof body.pipe === "function");
751
+ }
752
+
753
+ async function putNodeStream(upload, streamBody, headers) {
754
+ const http = await import("node:http");
755
+ const https = await import("node:https");
756
+ const { pipeline } = await import("node:stream/promises");
757
+ const target = new URL(upload.url || upload);
758
+ const transport = target.protocol === "https:" ? https : http;
759
+
760
+ return new Promise((resolve, reject) => {
761
+ let settled = false;
762
+ const finishResolve = (value) => {
763
+ if (settled) {
764
+ return;
765
+ }
766
+ settled = true;
767
+ resolve(value);
768
+ };
769
+ const finishReject = (error) => {
770
+ if (settled) {
771
+ return;
772
+ }
773
+ settled = true;
774
+ reject(error);
775
+ };
776
+ const request = transport.request(target, {
777
+ method: "PUT",
778
+ headers,
779
+ agent: false,
780
+ }, (response) => {
781
+ response.resume();
782
+ response.on("error", finishReject);
783
+ response.on("end", () => {
784
+ finishResolve({
785
+ ok: response.statusCode >= 200 && response.statusCode < 300,
786
+ status: response.statusCode || 0,
787
+ headers: {
788
+ get(name) {
789
+ const value = response.headers[String(name || "").toLowerCase()];
790
+ return Array.isArray(value) ? value[0] : value || null;
791
+ },
792
+ },
793
+ });
794
+ });
795
+ });
796
+
797
+ request.on("error", finishReject);
798
+ request.on("socket", (socket) => {
799
+ socket.on("error", finishReject);
800
+ });
801
+ request.setTimeout(120000, () => {
802
+ request.destroy(new Error("stream_upload_timeout"));
803
+ });
804
+ if (typeof streamBody.on === "function") {
805
+ streamBody.on("error", finishReject);
1262
806
  }
1263
- if (this.uploadInfo.type === "multipart" && isNodePathFile(this.file)) {
1264
- const persisted = this.resumeState || await readMultipartResumeState(this.file, {
1265
- baseUrl: this.client.baseUrl,
1266
- });
1267
- if (persisted?.job_id === this.jobId && persisted.upload?.type === "multipart") {
1268
- this.resumeState = persisted;
1269
- try {
1270
- const refreshed = await this.client.refreshBigFileUpload(this.jobId, {
1271
- upload: persisted.upload,
1272
- });
1273
- this.uploadInfo = refreshed;
1274
- } catch (error) {
1275
- if (error?.status === 404 || error?.code === "not_found") {
1276
- await this.clearResumeState();
1277
- }
1278
- if (error?.status === 409) {
1279
- const currentJob = await this.client.jobs.get(this.jobId);
1280
- this.jobStatus = currentJob?.job_status || this.jobStatus;
1281
- this.uploadInfo = null;
1282
- await this.clearResumeState();
1283
- return currentJob;
1284
- }
1285
- if (!(error?.status === 404 || error?.code === "not_found")) {
1286
- throw error;
1287
- }
1288
- }
1289
- }
1290
- await this.persistResumeState(this.resumeState?.completed_parts || []);
807
+ pipeline(streamBody, request).catch(finishReject);
808
+ });
809
+ }
810
+
811
+ async function putObjectWithRetry(upload, body, {
812
+ onProgress,
813
+ loadedOffset = 0,
814
+ totalBytes = 0,
815
+ contentLength = null,
816
+ progressMeta = null,
817
+ debugContext = null,
818
+ uploadLimiter = null,
819
+ } = {}) {
820
+ const response = await retry(async (attemptIndex) => {
821
+ const executePut = async () => {
822
+ const resolvedBody = typeof body === "function" ? await body() : body;
823
+ const headers = normalizeHeaders({
824
+ ...(upload.headers || {}),
825
+ ...(contentLength !== null ? { "Content-Length": contentLength } : {}),
826
+ ...(typeof body === "function" ? { Connection: "close" } : {}),
827
+ });
828
+ const putResponse = isNodeStreamBody(resolvedBody)
829
+ ? await putNodeStream(upload, resolvedBody, headers)
830
+ : await fetch(upload.url || upload, {
831
+ method: "PUT",
832
+ headers,
833
+ body: resolvedBody,
834
+ });
835
+ if (!putResponse.ok) {
836
+ throw new TranscribeAPIError(`R2 upload failed with HTTP ${putResponse.status}.`, {
837
+ status: putResponse.status,
838
+ code: "upload_failed",
839
+ extra: {
840
+ ...(progressMeta || {}),
841
+ ...(debugContext || {}),
842
+ content_length: contentLength,
843
+ },
844
+ });
845
+ }
846
+ return putResponse;
847
+ };
848
+ const putResponse = uploadLimiter?.withToken
849
+ ? await uploadLimiter.withToken(executePut)
850
+ : await executePut();
851
+ if (!putResponse.ok) {
852
+ throw new TranscribeAPIError(`R2 upload failed with HTTP ${putResponse.status}.`, {
853
+ status: putResponse.status,
854
+ code: "upload_failed",
855
+ extra: {
856
+ ...(progressMeta || {}),
857
+ ...(debugContext || {}),
858
+ content_length: contentLength,
859
+ },
860
+ });
1291
861
  }
1292
- await uploadUsingInstructions(this.uploadInfo, this.file, {
1293
- onProgress: progress,
1294
- multipartConcurrency: this.options.multipartConcurrency,
1295
- resumeState: this.resumeState,
1296
- onPartComplete: async ({ completedParts }) => {
1297
- if (!isNodePathFile(this.file) || this.uploadInfo?.type !== "multipart") {
1298
- return;
1299
- }
1300
- await this.persistResumeState(completedParts.map((part) => ({
1301
- part_number: part.partNumber,
1302
- etag: part.etag,
1303
- })));
1304
- },
1305
- onConcurrencyChange: (event) => {
1306
- if (typeof onProgress === "function" || typeof this.options.onProgress === "function") {
1307
- (onProgress || this.options.onProgress)({
1308
- uploadType: "multipart",
1309
- event: "adaptive_concurrency",
1310
- previousConcurrency: event.previousConcurrency,
1311
- multipartConcurrency: event.nextConcurrency,
1312
- partNumber: event.partNumber,
1313
- attempts: event.attempts,
1314
- error: event.error,
1315
- });
1316
- }
1317
- },
862
+ return putResponse;
863
+ }, {
864
+ attempts: 5,
865
+ baseDelayMs: 1000,
866
+ });
867
+ const transferredBytes = Number(
868
+ contentLength
869
+ ?? body?.size
870
+ ?? body?.byteLength
871
+ ?? 0,
872
+ );
873
+ const loaded = loadedOffset + transferredBytes;
874
+ if (onProgress) {
875
+ onProgress({
876
+ loaded,
877
+ total: totalBytes || loaded,
878
+ ...(progressMeta || {}),
1318
879
  });
1319
- await this.clearResumeState();
1320
- const completion = await this.client.jobs.complete(this.jobId);
1321
- emitProgress(progress, {
1322
- event: "upload_completed",
1323
- jobId: this.jobId,
1324
- response: completion,
1325
- suppressLog: Boolean(this.client.polling),
880
+ }
881
+ return response;
882
+ }
883
+
884
+ async function openFileBody(file, start = 0, end = null) {
885
+ if (!file?.path) {
886
+ return file;
887
+ }
888
+ const fs = await import("node:fs");
889
+ return fs.createReadStream(file.path, end === null ? { start } : { start, end: end - 1 });
890
+ }
891
+
892
+ async function runWithConcurrency(items, concurrency, worker) {
893
+ let index = 0;
894
+ const workerCount = Math.max(1, Math.min(items.length || 1, concurrency));
895
+ const workers = Array.from({ length: workerCount }, async () => {
896
+ while (true) {
897
+ const current = index;
898
+ index += 1;
899
+ if (current >= items.length) {
900
+ return;
901
+ }
902
+ await worker(items[current], current);
903
+ }
904
+ });
905
+ await Promise.all(workers);
906
+ }
907
+
908
+ function createUploadConcurrencyLimiter(concurrency) {
909
+ const maxConcurrency = Math.max(1, normalizeUploadConcurrency(concurrency));
910
+ let active = 0;
911
+ const waiters = [];
912
+
913
+ const acquire = async () => {
914
+ if (active < maxConcurrency) {
915
+ active += 1;
916
+ return;
917
+ }
918
+ await new Promise((resolve) => waiters.push(resolve));
919
+ active += 1;
920
+ };
921
+
922
+ const release = () => {
923
+ active = Math.max(0, active - 1);
924
+ const next = waiters.shift();
925
+ if (next) {
926
+ next();
927
+ }
928
+ };
929
+
930
+ return {
931
+ concurrency: maxConcurrency,
932
+ async withToken(operation) {
933
+ await acquire();
934
+ try {
935
+ return await operation();
936
+ } finally {
937
+ release();
938
+ }
939
+ },
940
+ };
941
+ }
942
+
943
+ function multipartCompleteXml(parts) {
944
+ const rows = parts
945
+ .sort((a, b) => a.partNumber - b.partNumber)
946
+ .map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${part.etag}</ETag></Part>`)
947
+ .join("");
948
+ return `<CompleteMultipartUpload>${rows}</CompleteMultipartUpload>`;
949
+ }
950
+
951
+ async function uploadMultipart(upload, file, {
952
+ onProgress,
953
+ uploadConcurrency = DEFAULT_UPLOAD_CONCURRENCY,
954
+ onConcurrencyChange = null,
955
+ uploadLimiter = null,
956
+ } = {}) {
957
+ const completed = [];
958
+ const totalParts = upload.parts.length;
959
+ const completedPartNumbers = new Set(completed.map((part) => part.partNumber));
960
+ let completedBytes = 0;
961
+ const concurrency = normalizeUploadConcurrency(uploadConcurrency);
962
+ let targetConcurrency = concurrency;
963
+ let activeWorkers = 0;
964
+ let fatalError = null;
965
+ const pendingParts = upload.parts.filter((part) => !completedPartNumbers.has(part.part_number));
966
+ const partAttempts = new Map();
967
+
968
+ const workers = Array.from({ length: concurrency }, async (_, workerIndex) => {
969
+ while (true) {
970
+ if (fatalError) {
971
+ return;
972
+ }
973
+ if (completedPartNumbers.size >= totalParts) {
974
+ return;
975
+ }
976
+ if (workerIndex >= targetConcurrency) {
977
+ await sleep(MULTIPART_IDLE_WAIT_MS);
978
+ continue;
979
+ }
980
+ const part = pendingParts.shift();
981
+ if (!part) {
982
+ if (!activeWorkers) {
983
+ return;
984
+ }
985
+ await sleep(MULTIPART_IDLE_WAIT_MS);
986
+ continue;
987
+ }
988
+
989
+ activeWorkers += 1;
990
+ const start = (part.part_number - 1) * upload.part_size;
991
+ const end = Math.min(file.size, start + upload.part_size);
992
+ const chunkSize = end - start;
993
+ try {
994
+ const response = await putObjectWithRetry(
995
+ { url: part.url },
996
+ file.path ? (() => openFileBody(file, start, end)) : file.slice(start, end),
997
+ {
998
+ onProgress: null,
999
+ loadedOffset: 0,
1000
+ totalBytes: file.size,
1001
+ contentLength: chunkSize,
1002
+ progressMeta: {
1003
+ uploadType: "multipart",
1004
+ partNumber: part.part_number,
1005
+ totalParts,
1006
+ chunkBytes: chunkSize,
1007
+ },
1008
+ debugContext: {
1009
+ file_name: file?.name || null,
1010
+ file_size: file?.size || null,
1011
+ range_start: start,
1012
+ range_end_exclusive: end,
1013
+ },
1014
+ uploadLimiter,
1015
+ },
1016
+ );
1017
+ const etag = response.headers.get("ETag") || response.headers.get("etag");
1018
+ completed.push({
1019
+ partNumber: part.part_number,
1020
+ etag,
1021
+ });
1022
+ completedPartNumbers.add(part.part_number);
1023
+ completedBytes += chunkSize;
1024
+ if (onProgress) {
1025
+ onProgress({
1026
+ loaded: completedBytes,
1027
+ total: file.size,
1028
+ uploadType: "multipart",
1029
+ partNumber: part.part_number,
1030
+ totalParts,
1031
+ chunkBytes: chunkSize,
1032
+ multipartConcurrency: targetConcurrency,
1033
+ });
1034
+ }
1035
+ } catch (error) {
1036
+ const attempts = (partAttempts.get(part.part_number) || 0) + 1;
1037
+ partAttempts.set(part.part_number, attempts);
1038
+ const retryable = isRetryableError(error);
1039
+ if (retryable && attempts < MAX_MULTIPART_ADAPTIVE_ATTEMPTS) {
1040
+ const previousConcurrency = targetConcurrency;
1041
+ targetConcurrency = Math.max(1, Math.floor(targetConcurrency / 2));
1042
+ pendingParts.push(part);
1043
+ if (onConcurrencyChange && targetConcurrency !== previousConcurrency) {
1044
+ try {
1045
+ onConcurrencyChange({
1046
+ previousConcurrency,
1047
+ nextConcurrency: targetConcurrency,
1048
+ partNumber: part.part_number,
1049
+ attempts,
1050
+ error: extractErrorInfo(error),
1051
+ });
1052
+ } catch {
1053
+ // Ignore observer failures.
1054
+ }
1055
+ }
1056
+ activeWorkers -= 1;
1057
+ await sleep(Math.min(10000, 1000 * (2 ** Math.min(attempts - 1, 3))));
1058
+ continue;
1059
+ }
1060
+ fatalError = new TranscribeAPIError(
1061
+ `Multipart upload failed for part ${part.part_number}/${totalParts}.`,
1062
+ {
1063
+ status: error?.status || null,
1064
+ code: error?.code || "multipart_part_upload_failed",
1065
+ extra: {
1066
+ cause_error: error?.error || error?.message || String(error),
1067
+ cause_code: error?.code || error?.cause?.code || null,
1068
+ cause_status: error?.status || null,
1069
+ upload_type: "multipart",
1070
+ part_number: part.part_number,
1071
+ total_parts: totalParts,
1072
+ chunk_bytes: chunkSize,
1073
+ completed_bytes_before_failure: completedBytes,
1074
+ multipart_concurrency: targetConcurrency,
1075
+ file_name: file?.name || null,
1076
+ file_size: file?.size || null,
1077
+ range_start: start,
1078
+ range_end_exclusive: end,
1079
+ attempts,
1080
+ },
1081
+ },
1082
+ );
1083
+ activeWorkers -= 1;
1084
+ return;
1085
+ }
1086
+ activeWorkers -= 1;
1087
+ }
1088
+ });
1089
+
1090
+ await Promise.all(workers);
1091
+ if (fatalError) {
1092
+ throw fatalError;
1093
+ }
1094
+
1095
+ const completeResponse = await retry(async () => {
1096
+ const response = await fetch(upload.complete_url, {
1097
+ method: "POST",
1098
+ headers: { "Content-Type": "application/xml" },
1099
+ body: multipartCompleteXml(completed),
1326
1100
  });
1327
- return completion;
1101
+ if (!response.ok) {
1102
+ throw new TranscribeAPIError(`R2 multipart complete failed with HTTP ${response.status}.`, {
1103
+ status: response.status,
1104
+ code: "multipart_complete_failed",
1105
+ });
1106
+ }
1107
+ return response;
1108
+ });
1109
+ return completeResponse;
1110
+ }
1111
+
1112
+ async function uploadUsingInstructions(upload, file, options = {}) {
1113
+ if (upload.type === "multipart") {
1114
+ return uploadMultipart(upload, file, options);
1328
1115
  }
1116
+ const body = file.path ? (() => openFileBody(file)) : file;
1117
+ return putObjectWithRetry(upload, body, {
1118
+ onProgress: options.onProgress,
1119
+ loadedOffset: 0,
1120
+ totalBytes: file.size,
1121
+ contentLength: file.size,
1122
+ progressMeta: {
1123
+ uploadType: "single_put",
1124
+ chunkBytes: file.size,
1125
+ },
1126
+ debugContext: {
1127
+ file_name: file?.name || null,
1128
+ file_size: file?.size || null,
1129
+ },
1130
+ uploadLimiter: options.uploadLimiter || null,
1131
+ });
1329
1132
  }
1330
-
1331
- class RemoteBigFileJob {
1332
- constructor(client, createResponse, options) {
1333
- this.client = client;
1334
- this.createResponse = createResponse;
1335
- this.jobId = createResponse.job_id;
1336
- this.jobStatus = createResponse.job_status;
1337
- this.model = createResponse.model || options.model || null;
1338
- this.options = options;
1339
- }
1340
-
1341
- async upload() {
1342
- return this.createResponse;
1343
- }
1344
- }
1345
-
1346
- class BatchJob {
1347
- constructor(client, files, createResponse, options) {
1348
- this.client = client;
1349
- this.files = files;
1350
- this.createResponse = createResponse;
1351
- this.jobId = createResponse.job_id;
1352
- this.jobStatus = createResponse.job_status;
1353
- this.model = createResponse.model || options.model || null;
1354
- this.uploadsByReferenceId = new Map(
1355
- normalizeResponseUploads(createResponse).map((entry) => [entry.reference_id, entry.upload]),
1356
- );
1357
- this.options = options;
1358
- }
1359
-
1133
+
1134
+ function assertBatchLimits(totalFiles, totalSizeBytes) {
1135
+ if (totalFiles > MAX_BATCH_FILES) {
1136
+ throw new TranscribeAPIError(`Batch jobs support up to ${MAX_BATCH_FILES} files.`, {
1137
+ code: "too_many_files",
1138
+ });
1139
+ }
1140
+ if (totalSizeBytes > MAX_BATCH_TOTAL_SIZE_BYTES) {
1141
+ throw new TranscribeAPIError("Batch jobs support up to 10GB total.", {
1142
+ code: "batch_too_large",
1143
+ });
1144
+ }
1145
+ }
1146
+
1147
+ class BatchJob {
1148
+ constructor(client, files, createResponse, options) {
1149
+ this.client = client;
1150
+ this.files = files;
1151
+ this.createResponse = createResponse;
1152
+ this.jobId = createResponse.job_id;
1153
+ this.jobStatus = createResponse.job_status;
1154
+ this.model = createResponse.model || options.model || null;
1155
+ this.uploadsByReferenceId = new Map(
1156
+ normalizeResponseUploads(createResponse).map((entry) => [entry.reference_id, entry.upload]),
1157
+ );
1158
+ this.options = options;
1159
+ }
1160
+
1360
1161
  async upload({ onProgress } = {}) {
1361
1162
  if (!this.uploadsByReferenceId.size) {
1362
1163
  return this.createResponse;
@@ -1366,6 +1167,7 @@ class BatchJob {
1366
1167
  const totalBytes = this.files.reduce((sum, item) => sum + Number(item?.file?.size || 0), 0);
1367
1168
  const loadedByReferenceId = new Map();
1368
1169
  const uploadableFiles = this.files.filter((item) => item.file);
1170
+ const uploadLimiter = this.options.uploadLimiter || createUploadConcurrencyLimiter(this.options.uploadConcurrency);
1369
1171
 
1370
1172
  emitProgress(progress, {
1371
1173
  event: "upload_started",
@@ -1375,17 +1177,16 @@ class BatchJob {
1375
1177
  totalBytes,
1376
1178
  });
1377
1179
 
1378
- for (const item of this.files) {
1379
- if (!item.file) {
1380
- continue;
1381
- }
1180
+ await runWithConcurrency(uploadableFiles, this.options.uploadConcurrency, async (item) => {
1382
1181
  const upload = this.uploadsByReferenceId.get(item.referenceId);
1383
- if (!upload) {
1384
- throw new TranscribeAPIError(`Missing upload instructions for \`${item.referenceId}\`.`, {
1385
- code: "missing_upload_instructions",
1386
- });
1182
+ if (!upload) {
1183
+ throw new TranscribeAPIError(`Missing upload instructions for \`${item.referenceId}\`.`, {
1184
+ code: "missing_upload_instructions",
1185
+ });
1387
1186
  }
1388
1187
  await uploadUsingInstructions(upload, item.file, {
1188
+ uploadConcurrency: this.options.uploadConcurrency,
1189
+ uploadLimiter,
1389
1190
  onProgress: (event) => {
1390
1191
  const fileLoaded = Math.max(0, Math.min(Number(event?.loaded || 0), Number(item.file.size || 0)));
1391
1192
  loadedByReferenceId.set(item.referenceId, fileLoaded);
@@ -1404,7 +1205,7 @@ class BatchJob {
1404
1205
  });
1405
1206
  },
1406
1207
  });
1407
- }
1208
+ });
1408
1209
  const completion = await this.client.jobs.complete(this.jobId);
1409
1210
  emitProgress(progress, {
1410
1211
  event: "upload_completed",
@@ -1414,17 +1215,17 @@ class BatchJob {
1414
1215
  batchTotal: totalBytes,
1415
1216
  totalFiles: this.files.length,
1416
1217
  uploadFiles: uploadableFiles.length,
1417
- suppressLog: Boolean(this.client.polling),
1218
+ suppressLog: Boolean(this.client.polling) && !TERMINAL_JOB_STATUSES.has(String(completion?.job_status || "")),
1418
1219
  });
1419
1220
  return completion;
1420
1221
  }
1421
1222
  }
1422
-
1223
+
1423
1224
  export class TranscribeAPI {
1424
1225
  constructor({
1425
1226
  apiKey,
1426
1227
  baseUrl = DEFAULT_BASE_URL,
1427
- multipartConcurrency = DEFAULT_MULTIPART_CONCURRENCY,
1228
+ uploadConcurrency = DEFAULT_UPLOAD_CONCURRENCY,
1428
1229
  showLogs = false,
1429
1230
  logger = console,
1430
1231
  polling = null,
@@ -1435,13 +1236,10 @@ export class TranscribeAPI {
1435
1236
 
1436
1237
  this.apiKey = apiKey;
1437
1238
  this.baseUrl = baseUrl.replace(/\/+$/, "");
1438
- this.multipartConcurrency = normalizeMultipartConcurrency(multipartConcurrency);
1239
+ this.uploadConcurrency = normalizeUploadConcurrency(uploadConcurrency);
1439
1240
  this.showLogs = Boolean(showLogs);
1440
1241
  this.logger = logger || console;
1441
1242
  this.polling = normalizePollingConfig(polling);
1442
- this.batch = {
1443
- transcribe: (options) => this.transcribeMany(options),
1444
- };
1445
1243
  this.jobs = {
1446
1244
  createBigFile: (options) => this.createBigFileJob(options),
1447
1245
  createBatch: (options) => this.createBatchJob(options),
@@ -1451,29 +1249,20 @@ export class TranscribeAPI {
1451
1249
  result: (jobId) => this.requestJson(`/transcribe/${jobId}`),
1452
1250
  };
1453
1251
  }
1454
-
1455
- async requestJson(path, { method = "GET", body = null, retryable = false } = {}) {
1456
- const run = async () => {
1457
- const response = await fetch(`${this.baseUrl}${path}`, {
1458
- method,
1459
- headers: {
1460
- Authorization: `Bearer ${this.apiKey}`,
1461
- ...(body ? { "Content-Type": "application/json" } : {}),
1462
- },
1463
- body: body ? JSON.stringify(body) : null,
1464
- });
1465
- return parseApiResponse(response);
1466
- };
1467
- return retryable ? retry(run) : run();
1468
- }
1469
-
1470
- async refreshBigFileUpload(jobId, { upload } = {}) {
1471
- const response = await this.requestJson(`/jobs/${jobId}/resume-upload`, {
1472
- method: "POST",
1473
- body: { upload },
1474
- retryable: true,
1475
- });
1476
- return response.upload;
1252
+
1253
+ async requestJson(path, { method = "GET", body = null, retryable = false } = {}) {
1254
+ const run = async () => {
1255
+ const response = await fetch(`${this.baseUrl}${path}`, {
1256
+ method,
1257
+ headers: {
1258
+ Authorization: `Bearer ${this.apiKey}`,
1259
+ ...(body ? { "Content-Type": "application/json" } : {}),
1260
+ },
1261
+ body: body ? JSON.stringify(body) : null,
1262
+ });
1263
+ return parseApiResponse(response);
1264
+ };
1265
+ return retryable ? retry(run) : run();
1477
1266
  }
1478
1267
 
1479
1268
  async waitForJobCompletion(jobId, {
@@ -1525,120 +1314,104 @@ export class TranscribeAPI {
1525
1314
  });
1526
1315
  return lastJob;
1527
1316
  }
1528
-
1317
+
1529
1318
  async transcribe({
1530
- file,
1319
+ files,
1531
1320
  webhookUrl,
1532
1321
  onProgress,
1533
1322
  showLogs,
1534
1323
  logger,
1535
1324
  language,
1536
- task,
1537
- vadFilter,
1538
- initialPrompt,
1539
- vttGranularity,
1540
- exclude,
1541
- multipartConcurrency,
1325
+ exclude,
1326
+ uploadConcurrency,
1542
1327
  } = {}) {
1543
1328
  const progress = composeProgressHandler({
1544
1329
  onProgress,
1545
1330
  showLogs: showLogs ?? this.showLogs,
1546
1331
  logger: logger ?? this.logger,
1547
1332
  });
1548
- if (isRemoteFileInput(file)) {
1549
- const job = await this.createBigFileJob({
1550
- file,
1551
- webhookUrl,
1552
- onProgress: progress,
1553
- showLogs: false,
1554
- multipartConcurrency,
1555
- });
1556
- const result = await job.upload({ onProgress: progress });
1557
- if (this.polling && !TERMINAL_JOB_STATUSES.has(String(result?.job_status || ""))) {
1558
- return this.waitForJobCompletion(result.job_id, {
1559
- polling: this.polling,
1560
- showLogs: showLogs ?? this.showLogs,
1333
+ if (isFilesInput(files)) {
1334
+ if (files.length === 0) {
1335
+ throw new TranscribeAPIError("Transcribe requires at least one file.", {
1336
+ code: "invalid_files",
1337
+ });
1338
+ }
1339
+ if (files.length > 1) {
1340
+ for (let index = 0; index < files.length; index += 1) {
1341
+ if (!String(files[index]?.reference_id || "").trim()) {
1342
+ throw new TranscribeAPIError(`files[${index}].reference_id is required when sending multiple files.`, {
1343
+ code: "missing_reference_id",
1344
+ });
1345
+ }
1346
+ }
1347
+ return this._transcribeAsync({
1348
+ files,
1349
+ webhookUrl,
1350
+ durationEstimateSec: undefined,
1351
+ onProgress,
1352
+ showLogs,
1561
1353
  logger: logger ?? this.logger,
1562
- initialJob: result,
1354
+ uploadConcurrency,
1355
+ language,
1356
+ exclude,
1563
1357
  });
1564
1358
  }
1565
- return result;
1566
- }
1567
-
1568
- const normalizedFile = await normalizeFile(file);
1569
- const estimatedDurationSec = await estimateDurationFromFile(normalizedFile);
1570
- if (normalizedFile.size > MAX_SYNC_AUDIO_BYTES || estimatedDurationSec > MAX_SYNC_AUDIO_SECONDS) {
1571
- const job = await this.createBigFileJob({
1572
- file: normalizedFile,
1573
- webhookUrl,
1574
- durationEstimateSec: estimatedDurationSec,
1575
- onProgress: progress,
1576
- showLogs: false,
1577
- multipartConcurrency,
1578
- });
1579
- const result = await job.upload({ onProgress: progress });
1580
- if (this.polling && !TERMINAL_JOB_STATUSES.has(String(result?.job_status || ""))) {
1581
- return this.waitForJobCompletion(result.job_id, {
1582
- polling: this.polling,
1583
- showLogs: showLogs ?? this.showLogs,
1359
+
1360
+ const normalizedItem = normalizeBatchInputItem(files[0], 0);
1361
+ const normalizedSingleFile = normalizedItem.url ? null : await normalizeFile(normalizedItem.file);
1362
+ const estimatedDurationSec = normalizedItem.url
1363
+ ? normalizedItem.durationEstimateSec
1364
+ : (normalizedItem.durationEstimateSec || await estimateDurationFromFile(normalizedSingleFile));
1365
+ const effectiveLanguage = normalizedItem.hasLanguage ? normalizedItem.language : normalizeLanguageCode(language);
1366
+ const isAsync = files.length > 1
1367
+ || Boolean(normalizedItem.url)
1368
+ || Boolean(webhookUrl)
1369
+ || (
1370
+ normalizedSingleFile
1371
+ && (
1372
+ normalizedSingleFile.size > MAX_SYNC_AUDIO_BYTES
1373
+ || estimatedDurationSec > MAX_SYNC_AUDIO_SECONDS
1374
+ )
1375
+ );
1376
+ if (isAsync) {
1377
+ return this._transcribeAsync({
1378
+ files,
1379
+ webhookUrl,
1380
+ durationEstimateSec: estimatedDurationSec,
1381
+ onProgress,
1382
+ showLogs,
1584
1383
  logger: logger ?? this.logger,
1585
- initialJob: result,
1384
+ uploadConcurrency,
1385
+ language,
1386
+ exclude,
1586
1387
  });
1587
1388
  }
1588
- return result;
1389
+
1390
+ return this.transcribeDirect({
1391
+ file: normalizedSingleFile,
1392
+ referenceId: normalizedItem.referenceId,
1393
+ language: effectiveLanguage,
1394
+ exclude,
1395
+ webhookUrl,
1396
+ showLogs: showLogs ?? this.showLogs,
1397
+ logger: logger ?? this.logger,
1398
+ });
1589
1399
  }
1590
-
1591
- return this.transcribeDirect({
1592
- file: normalizedFile,
1593
- language,
1594
- task,
1595
- vadFilter,
1596
- initialPrompt,
1597
- vttGranularity,
1598
- exclude,
1599
- });
1600
- }
1601
-
1602
- async transcribeDirect({
1603
- file,
1604
- language,
1605
- task,
1606
- vadFilter,
1607
- initialPrompt,
1608
- vttGranularity,
1609
- exclude,
1610
- } = {}) {
1611
- const normalizedFile = await normalizeFile(file);
1612
- const directFile = normalizedFile.path
1613
- ? makeFile([await normalizedFile.readSlice(0, normalizedFile.size)], normalizedFile.name, normalizedFile.type)
1614
- : normalizedFile;
1615
- const form = new FormData();
1616
- form.set("file", directFile, directFile.name);
1617
- if (language) form.set("language", language);
1618
- if (task) form.set("task", task);
1619
- if (vadFilter !== undefined) form.set("vad_filter", String(Boolean(vadFilter)));
1620
- if (initialPrompt) form.set("initial_prompt", initialPrompt);
1621
- if (vttGranularity) form.set("vtt_granularity", vttGranularity);
1622
- if (exclude) form.set("exclude", Array.isArray(exclude) ? exclude.join(",") : exclude);
1623
-
1624
- const response = await fetch(`${this.baseUrl}/transcribe`, {
1625
- method: "POST",
1626
- headers: {
1627
- Authorization: `Bearer ${this.apiKey}`,
1628
- },
1629
- body: form,
1630
- });
1631
- return parseApiResponse(response);
1632
- }
1633
-
1634
- async transcribeMany({
1400
+ throw new TranscribeAPIError("`transcribe` requires a `files` array.", {
1401
+ code: "invalid_files",
1402
+ });
1403
+ }
1404
+
1405
+ async _transcribeAsync({
1635
1406
  files,
1636
1407
  webhookUrl,
1637
1408
  durationEstimateSec,
1638
1409
  onProgress,
1639
1410
  showLogs,
1640
1411
  logger,
1641
- multipartConcurrency,
1412
+ uploadConcurrency,
1413
+ language,
1414
+ exclude,
1642
1415
  } = {}) {
1643
1416
  const progress = composeProgressHandler({
1644
1417
  onProgress,
@@ -1651,7 +1424,9 @@ export class TranscribeAPI {
1651
1424
  durationEstimateSec,
1652
1425
  onProgress: progress,
1653
1426
  showLogs: false,
1654
- multipartConcurrency,
1427
+ uploadConcurrency,
1428
+ language,
1429
+ exclude,
1655
1430
  });
1656
1431
  const result = await job.upload({ onProgress: progress });
1657
1432
  if (this.polling && !TERMINAL_JOB_STATUSES.has(String(result?.job_status || ""))) {
@@ -1662,101 +1437,102 @@ export class TranscribeAPI {
1662
1437
  initialJob: result,
1663
1438
  });
1664
1439
  }
1440
+ if ((showLogs ?? this.showLogs) && TERMINAL_JOB_STATUSES.has(String(result?.job_status || ""))) {
1441
+ logTerminalAsyncResult(result, logger ?? this.logger);
1442
+ }
1665
1443
  return result;
1666
1444
  }
1667
1445
 
1668
- async createBigFileJob({
1446
+ async transcribeDirect({
1669
1447
  file,
1448
+ referenceId,
1449
+ language,
1450
+ exclude,
1451
+ webhookUrl,
1452
+ showLogs = false,
1453
+ logger = console,
1454
+ } = {}) {
1455
+ const normalizedFile = await normalizeFile(file);
1456
+ const directFile = normalizedFile.path
1457
+ ? makeFile([await normalizedFile.readSlice(0, normalizedFile.size)], normalizedFile.name, normalizedFile.type)
1458
+ : normalizedFile;
1459
+ const form = new FormData();
1460
+ if (String(referenceId || "").trim()) form.set("reference_id", String(referenceId).trim());
1461
+ form.set("file", directFile, directFile.name);
1462
+ if (language) form.set("language", language);
1463
+ if (exclude) form.set("exclude", Array.isArray(exclude) ? exclude.join(",") : exclude);
1464
+ if (webhookUrl) form.set("webhook_url", webhookUrl);
1465
+
1466
+ const response = await fetch(`${this.baseUrl}/transcribe`, {
1467
+ method: "POST",
1468
+ headers: {
1469
+ Authorization: `Bearer ${this.apiKey}`,
1470
+ },
1471
+ body: form,
1472
+ });
1473
+ const result = await parseApiResponse(response);
1474
+ if (this.polling && result && typeof result === "object" && result.job_status && !TERMINAL_JOB_STATUSES.has(String(result.job_status))) {
1475
+ return this.waitForJobCompletion(result.job_id, {
1476
+ polling: this.polling,
1477
+ showLogs,
1478
+ logger,
1479
+ initialJob: result,
1480
+ });
1481
+ }
1482
+ if (showLogs && typeof logger?.log === "function") {
1483
+ logger.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
1484
+ }
1485
+ return result;
1486
+ }
1487
+
1488
+ async transcribeMany({
1489
+ files,
1670
1490
  webhookUrl,
1671
1491
  durationEstimateSec,
1672
1492
  onProgress,
1673
1493
  showLogs,
1674
1494
  logger,
1675
- multipartConcurrency,
1495
+ uploadConcurrency,
1496
+ language,
1497
+ exclude,
1676
1498
  } = {}) {
1677
- const progress = composeProgressHandler({
1499
+ return this._transcribeAsync({
1500
+ files,
1501
+ webhookUrl,
1502
+ durationEstimateSec,
1678
1503
  onProgress,
1679
- showLogs: showLogs ?? this.showLogs,
1504
+ showLogs,
1680
1505
  logger: logger ?? this.logger,
1506
+ uploadConcurrency,
1507
+ language,
1508
+ exclude,
1681
1509
  });
1682
- const normalizedConcurrency = normalizeMultipartConcurrency(multipartConcurrency ?? this.multipartConcurrency);
1683
- if (isRemoteFileInput(file)) {
1684
- const response = await this.requestJson("/transcribe", {
1685
- method: "POST",
1686
- body: {
1687
- files: [{
1688
- reference_id: defaultReferenceId(0),
1689
- url: file.url,
1690
- }],
1691
- ...(webhookUrl ? { webhook_url: webhookUrl } : {}),
1692
- },
1693
- retryable: true,
1694
- });
1695
- return new RemoteBigFileJob(this, response, {
1696
- onProgress: progress,
1697
- multipartConcurrency: normalizedConcurrency,
1698
- });
1699
- }
1700
-
1701
- const normalizedFile = await normalizeFile(file);
1702
- const resumeState = await readMultipartResumeState(normalizedFile, { baseUrl: this.baseUrl });
1703
- if (resumeState?.job_id && resumeState.upload?.type === "multipart") {
1704
- try {
1705
- const refreshedUpload = await this.refreshBigFileUpload(resumeState.job_id, {
1706
- upload: resumeState.upload,
1707
- });
1708
- const existingJob = await this.requestJson(`/transcribe/${resumeState.job_id}`, { retryable: true });
1709
- const currentStatus = existingJob?.job_status || "uploading";
1710
- return new BigFileJob(this, normalizedFile, {
1711
- job_id: resumeState.job_id,
1712
- job_status: currentStatus,
1713
- model: existingJob?.model || resumeState.model || null,
1714
- upload: currentStatus === "uploading" ? refreshedUpload : null,
1715
- }, {
1716
- onProgress: progress,
1717
- multipartConcurrency: normalizedConcurrency,
1718
- referenceId: defaultReferenceId(0),
1719
- resumeState,
1720
- });
1721
- } catch (error) {
1722
- if (error?.status === 404 || error?.code === "not_found") {
1723
- await deleteMultipartResumeState(normalizedFile);
1724
- } else {
1725
- throw error;
1726
- }
1727
- }
1728
- }
1729
- const estimate = durationEstimateSec || await estimateDurationFromFile(normalizedFile);
1730
- const response = await this.requestJson("/transcribe", {
1731
- method: "POST",
1732
- body: {
1733
- files: [
1734
- estimate > MAX_SYNC_AUDIO_SECONDS || normalizedFile.size > MAX_SYNC_AUDIO_BYTES
1735
- ? uploadDescriptorForFile(defaultReferenceId(0), normalizedFile)
1736
- : { reference_id: defaultReferenceId(0) },
1737
- ],
1738
- ...(webhookUrl ? { webhook_url: webhookUrl } : {}),
1739
- },
1740
- retryable: true,
1741
- });
1742
- emitProgress(progress, {
1743
- event: "upload_urls_received",
1744
- jobId: response.job_id,
1745
- jobStatus: response.job_status,
1746
- uploadCount: normalizeResponseUploads(response).length,
1747
- totalFiles: 1,
1510
+ }
1511
+
1512
+ async createBigFileJob({
1513
+ file,
1514
+ webhookUrl,
1515
+ durationEstimateSec,
1516
+ onProgress,
1517
+ showLogs,
1518
+ logger,
1519
+ uploadConcurrency,
1520
+ language,
1521
+ exclude,
1522
+ } = {}) {
1523
+ return this.createBatchJob({
1524
+ files: [file],
1525
+ webhookUrl,
1526
+ durationEstimateSec,
1527
+ onProgress,
1528
+ showLogs,
1529
+ logger: logger ?? this.logger,
1530
+ uploadConcurrency,
1531
+ language,
1532
+ exclude,
1748
1533
  });
1749
- const job = new BigFileJob(this, normalizedFile, response, {
1750
- onProgress: progress,
1751
- multipartConcurrency: normalizedConcurrency,
1752
- referenceId: defaultReferenceId(0),
1753
- });
1754
- if (uploadFromResponse(response, defaultReferenceId(0))?.type === "multipart" && isNodePathFile(normalizedFile)) {
1755
- await job.persistResumeState([]);
1756
- }
1757
- return job;
1758
- }
1759
-
1534
+ }
1535
+
1760
1536
  async createBatchJob({
1761
1537
  files,
1762
1538
  webhookUrl,
@@ -1764,56 +1540,69 @@ export class TranscribeAPI {
1764
1540
  onProgress,
1765
1541
  showLogs,
1766
1542
  logger,
1767
- multipartConcurrency,
1543
+ uploadConcurrency,
1544
+ language,
1545
+ exclude,
1768
1546
  } = {}) {
1769
1547
  const progress = composeProgressHandler({
1770
1548
  onProgress,
1771
1549
  showLogs: showLogs ?? this.showLogs,
1772
1550
  logger: logger ?? this.logger,
1773
1551
  });
1552
+ const normalizedLanguage = normalizeLanguageCode(language);
1774
1553
  if (!Array.isArray(files) || files.length === 0) {
1775
1554
  throw new TranscribeAPIError("Batch upload requires at least one file.", { code: "invalid_files" });
1776
1555
  }
1777
- assertBatchLimits(files.length, 0);
1778
- const normalizedItems = files.map((item, index) => normalizeBatchInputItem(item, index));
1779
-
1780
- const normalizedBatchItems = [];
1781
- for (let index = 0; index < normalizedItems.length; index += 1) {
1782
- const item = normalizedItems[index];
1783
- if (isRemoteBatchItem(item)) {
1784
- normalizedBatchItems.push({
1785
- file: null,
1786
- referenceId: item.referenceId,
1787
- url: item.url,
1788
- durationEstimateSec: item.durationEstimateSec || durationEstimateSec || null,
1789
- });
1790
- continue;
1791
- }
1792
-
1793
- const file = await normalizeFile(item.file, `file_${String(index + 1).padStart(6, "0")}.mp3`);
1794
- assertSupportedBatchFormat(file);
1795
- normalizedBatchItems.push({
1796
- file,
1797
- referenceId: item.referenceId,
1798
- url: null,
1799
- durationEstimateSec: item.durationEstimateSec || await estimateDurationFromFile(file),
1800
- });
1801
- }
1802
-
1803
- const totalSizeBytes = normalizedBatchItems.reduce((total, item) => total + Number(item.file?.size || 0), 0);
1804
- assertBatchLimits(normalizedBatchItems.length, totalSizeBytes);
1556
+ assertBatchLimits(files.length, 0);
1557
+ const normalizedItems = files.map((item, index) => normalizeBatchInputItem(item, index));
1558
+
1559
+ const normalizedBatchItems = [];
1560
+ for (let index = 0; index < normalizedItems.length; index += 1) {
1561
+ const item = normalizedItems[index];
1562
+ if (isRemoteBatchItem(item)) {
1563
+ normalizedBatchItems.push({
1564
+ file: null,
1565
+ referenceId: item.referenceId,
1566
+ url: item.url,
1567
+ durationEstimateSec: item.durationEstimateSec || durationEstimateSec || null,
1568
+ hasLanguage: item.hasLanguage,
1569
+ language: item.language,
1570
+ });
1571
+ continue;
1572
+ }
1573
+
1574
+ const file = await normalizeFile(item.file, `file_${String(index + 1).padStart(6, "0")}.mp3`);
1575
+ assertSupportedBatchFormat(file);
1576
+ normalizedBatchItems.push({
1577
+ file,
1578
+ referenceId: item.referenceId || defaultReferenceId(index),
1579
+ url: null,
1580
+ durationEstimateSec: item.durationEstimateSec || await estimateDurationFromFile(file),
1581
+ hasLanguage: item.hasLanguage,
1582
+ language: item.language,
1583
+ });
1584
+ }
1585
+
1586
+ const totalSizeBytes = normalizedBatchItems.reduce((total, item) => total + Number(item.file?.size || 0), 0);
1587
+ assertBatchLimits(normalizedBatchItems.length, totalSizeBytes);
1805
1588
  const response = await this.requestJson("/transcribe", {
1806
1589
  method: "POST",
1807
1590
  body: {
1808
- files: normalizedBatchItems.map((item) => (
1809
- item.url
1810
- ? {
1811
- reference_id: item.referenceId,
1812
- url: item.url,
1813
- }
1814
- : uploadDescriptorForFile(item.referenceId, item.file)
1815
- )),
1816
- ...(webhookUrl ? { webhook_url: webhookUrl } : {}),
1591
+ files: normalizedBatchItems.map((item) => (
1592
+ item.url
1593
+ ? {
1594
+ ...(item.referenceId ? { reference_id: item.referenceId } : {}),
1595
+ url: item.url,
1596
+ ...(item.hasLanguage ? { language: item.language } : {}),
1597
+ }
1598
+ : {
1599
+ ...uploadDescriptorForFile(item.referenceId, item.file),
1600
+ ...(item.hasLanguage ? { language: item.language } : {}),
1601
+ }
1602
+ )),
1603
+ ...(normalizedLanguage ? { language: normalizedLanguage } : {}),
1604
+ ...(webhookUrl ? { webhook_url: webhookUrl } : {}),
1605
+ ...(exclude ? { exclude: Array.isArray(exclude) ? exclude.join(",") : exclude } : {}),
1817
1606
  },
1818
1607
  retryable: true,
1819
1608
  });
@@ -1827,9 +1616,9 @@ export class TranscribeAPI {
1827
1616
  return new BatchJob(this, normalizedBatchItems, response, {
1828
1617
  onProgress: progress,
1829
1618
  webhookUrl,
1830
- multipartConcurrency: normalizeMultipartConcurrency(multipartConcurrency ?? this.multipartConcurrency),
1619
+ uploadConcurrency: normalizeUploadConcurrency(uploadConcurrency ?? this.uploadConcurrency),
1831
1620
  });
1832
- }
1833
- }
1834
-
1835
- export default TranscribeAPI;
1621
+ }
1622
+ }
1623
+
1624
+ export default TranscribeAPI;