@casatwy/deyo 0.1.7 → 0.1.9

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 (3) hide show
  1. package/README.md +75 -8
  2. package/deyo.mjs +1182 -93
  3. package/package.json +1 -1
package/deyo.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { chmod, mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises'
4
+ import { createHash } from 'node:crypto'
4
5
  import { createRequire } from 'node:module'
5
- import { dirname, extname, resolve } from 'node:path'
6
+ import { basename, dirname, extname, isAbsolute, resolve } from 'node:path'
6
7
  import { homedir } from 'node:os'
7
8
  import process from 'node:process'
8
9
 
@@ -14,8 +15,52 @@ const POLL_INTERVAL_MS = 1500
14
15
  const PROGRESS_FORMATS = new Set(['auto', 'text', 'jsonl'])
15
16
  const RESULT_FORMATS = new Set(['text', 'srt', 'vtt', 'json', 'verbose_json'])
16
17
  const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled'])
18
+ const MAX_UPLOAD_CONCURRENCY = 3
19
+ const HASH_CHUNK_SIZE = 4 * 1024 * 1024
20
+ const UPLOAD_REDACTED_SOURCE_LABEL = 'upload:file'
21
+ const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504])
22
+ const URL_SCHEME_PATTERN = /^[a-z][a-z\d+\-.]*:\/\//i
23
+ const DOMAIN_LIKE_PATTERN = /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:[/?#].*)?$/i
24
+ const SENSITIVE_UPLOAD_RESULT_KEY_PATTERN = /(?:^|_)(?:objectKey|object_key|audioUrl|audio_url|downloadUrl|download_url|signedUrl|signed_url|uploadUrl|upload_url|sourceUrl|source_url|url)$/i
25
+ const SIGNED_URL_PARAM_PATTERN = /(?:OSSAccessKeyId|Signature|Expires|x-oss-signature|x-oss-credential|x-oss-expires|x-amz-signature)=/i
26
+ const MIME_TYPES_BY_EXTENSION = new Map([
27
+ ['.aac', 'audio/aac'],
28
+ ['.aif', 'audio/aiff'],
29
+ ['.aiff', 'audio/aiff'],
30
+ ['.amr', 'audio/amr'],
31
+ ['.flac', 'audio/flac'],
32
+ ['.m4a', 'audio/mp4'],
33
+ ['.mp3', 'audio/mpeg'],
34
+ ['.oga', 'audio/ogg'],
35
+ ['.ogg', 'audio/ogg'],
36
+ ['.opus', 'audio/opus'],
37
+ ['.wav', 'audio/wav'],
38
+ ['.weba', 'audio/webm'],
39
+ ['.wma', 'audio/x-ms-wma'],
40
+ ['.3gp', 'video/3gpp'],
41
+ ['.avi', 'video/x-msvideo'],
42
+ ['.m4v', 'video/x-m4v'],
43
+ ['.mkv', 'video/x-matroska'],
44
+ ['.mov', 'video/quicktime'],
45
+ ['.mp4', 'video/mp4'],
46
+ ['.mpeg', 'video/mpeg'],
47
+ ['.mpg', 'video/mpeg'],
48
+ ['.webm', 'video/webm'],
49
+ ])
50
+
51
+ class ReportedCliError extends Error {
52
+ constructor(message = '', exitCode = 1) {
53
+ super(message)
54
+ this.exitCode = exitCode
55
+ }
56
+ }
17
57
 
18
- class ReportedCliError extends Error {}
58
+ class HttpCliError extends Error {
59
+ constructor(message, status) {
60
+ super(message)
61
+ this.status = status
62
+ }
63
+ }
19
64
 
20
65
  function safeJsonParse(value) {
21
66
  try {
@@ -32,13 +77,16 @@ function fail(message, exitCode = 1) {
32
77
 
33
78
  function printUsage() {
34
79
  process.stderr.write(`用法:
35
- deyo [选项] <url>
80
+ deyo [选项] <url|file>
81
+ deyo [选项] --file <file>
36
82
  deyo auth <login|logout|status> [选项]
37
83
 
38
84
  转写选项:
39
85
  --api-key <key> 可选,Deyo API key
40
86
  -O, --output <path> 可选,输出文件路径
41
87
  --source <name> 可选,显式指定来源:xiaoyuzhou | ximalaya | bilibili | douyin | xiaohongshu | youtube | apple-podcasts | twitter
88
+ --file <path> 可选,显式指定本地单个音视频文件
89
+ --mime-type <type> 可选,上传本地文件时显式指定 MIME,例如 audio/mpeg 或 video/mp4
42
90
  --language <value> 可选,转写语言,默认 ${DEFAULT_LANGUAGE}
43
91
  --format <value> 可选,text | srt | vtt | json | verbose_json
44
92
  --progress-format <v> 可选,auto | text | jsonl,默认 auto
@@ -55,6 +103,10 @@ API key 读取优先级:
55
103
  1. --api-key
56
104
  2. 环境变量 DEYO_API_KEY
57
105
  3. 本地配置文件(先执行 deyo auth login)
106
+
107
+ 本地文件说明:
108
+ v1 只支持单文件上传;不支持目录、glob、stdin、批量和断点续传。
109
+ 可以用 deyo -- ./audio.mp3 明确把 -- 后的单个参数当作路径或链接。
58
110
  `)
59
111
  }
60
112
 
@@ -76,11 +128,14 @@ function parseTranscriptionArgs(argv) {
76
128
  apiKey: '',
77
129
  output: '',
78
130
  source: '',
131
+ file: '',
132
+ mimeType: '',
79
133
  language: '',
80
134
  format: '',
81
135
  progressFormat: 'auto',
82
136
  baseUrl: '',
83
137
  url: '',
138
+ positional: [],
84
139
  }
85
140
 
86
141
  for (let index = 0; index < argv.length; index += 1) {
@@ -91,6 +146,17 @@ function parseTranscriptionArgs(argv) {
91
146
  process.exit(0)
92
147
  }
93
148
 
149
+ if (current === '--') {
150
+ const rest = argv.slice(index + 1)
151
+
152
+ if (rest.length !== 1) {
153
+ fail('-- 后只能提供一个本地文件路径或内容链接')
154
+ }
155
+
156
+ args.positional.push(rest[0])
157
+ break
158
+ }
159
+
94
160
  if (current === '-O' || current === '--output') {
95
161
  args.output = argv[index + 1] || ''
96
162
  index += 1
@@ -109,6 +175,18 @@ function parseTranscriptionArgs(argv) {
109
175
  continue
110
176
  }
111
177
 
178
+ if (current === '--file') {
179
+ args.file = argv[index + 1] || ''
180
+ index += 1
181
+ continue
182
+ }
183
+
184
+ if (current === '--mime-type') {
185
+ args.mimeType = argv[index + 1] || ''
186
+ index += 1
187
+ continue
188
+ }
189
+
112
190
  if (current === '--language') {
113
191
  args.language = argv[index + 1] || ''
114
192
  index += 1
@@ -137,13 +215,18 @@ function parseTranscriptionArgs(argv) {
137
215
  fail(`未知参数:${current}`)
138
216
  }
139
217
 
140
- if (args.url) {
141
- fail('只能提供一个内容链接')
142
- }
218
+ args.positional.push(current)
219
+ }
220
+
221
+ if (args.positional.length > 1) {
222
+ fail('v1 只支持提交一个链接或一个本地文件,不支持批量转写')
223
+ }
143
224
 
144
- args.url = current
225
+ if (args.file && args.positional.length) {
226
+ fail('--file 不能和额外的路径或 URL 同时使用')
145
227
  }
146
228
 
229
+ args.url = args.positional[0] || ''
147
230
  return args
148
231
  }
149
232
 
@@ -216,6 +299,295 @@ function inferFormat(outputPath, explicitFormat) {
216
299
  return 'text'
217
300
  }
218
301
 
302
+ function isAbortError(error) {
303
+ return error?.name === 'AbortError' || error?.code === 'ABORT_ERR'
304
+ }
305
+
306
+ function createAbortError() {
307
+ const error = new Error('操作已中断')
308
+ error.name = 'AbortError'
309
+ return error
310
+ }
311
+
312
+ function isRetryableRequestError(error) {
313
+ if (isAbortError(error)) {
314
+ return false
315
+ }
316
+
317
+ if (error instanceof HttpCliError) {
318
+ return RETRYABLE_STATUS_CODES.has(error.status) || error.status >= 500
319
+ }
320
+
321
+ return true
322
+ }
323
+
324
+ function getRetryDelayMs(attempt) {
325
+ return Math.min(4000, 350 * (2 ** Math.max(0, attempt - 1)))
326
+ }
327
+
328
+ async function withRetry(operation, options = {}) {
329
+ const maxAttempts = options.maxAttempts || 4
330
+ let lastError = null
331
+
332
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
333
+ try {
334
+ return await operation(attempt)
335
+ } catch (error) {
336
+ lastError = error
337
+
338
+ if (options.signal?.aborted || isAbortError(error)) {
339
+ throw error
340
+ }
341
+
342
+ const shouldRetry = options.shouldRetry
343
+ ? options.shouldRetry(error)
344
+ : isRetryableRequestError(error)
345
+
346
+ if (!shouldRetry || attempt >= maxAttempts) {
347
+ throw error
348
+ }
349
+
350
+ await sleep(getRetryDelayMs(attempt))
351
+ }
352
+ }
353
+
354
+ throw lastError || new Error('请求失败')
355
+ }
356
+
357
+ function isLikelyUrlInput(value) {
358
+ const normalized = String(value || '').trim()
359
+
360
+ return URL_SCHEME_PATTERN.test(normalized) || DOMAIN_LIKE_PATTERN.test(normalized)
361
+ }
362
+
363
+ function isStdinInput(value) {
364
+ return String(value || '').trim() === '-'
365
+ }
366
+
367
+ function hasGlobPattern(value) {
368
+ const normalized = String(value || '')
369
+ return /[*[\]{}]/.test(normalized) || (!isLikelyUrlInput(normalized) && normalized.includes('?'))
370
+ }
371
+
372
+ function expandHomePath(value) {
373
+ const normalized = String(value || '').trim()
374
+
375
+ if (normalized === '~') {
376
+ return homedir() || normalized
377
+ }
378
+
379
+ if (normalized.startsWith('~/')) {
380
+ const home = homedir()
381
+ return home ? resolve(home, normalized.slice(2)) : normalized
382
+ }
383
+
384
+ return normalized
385
+ }
386
+
387
+ function resolveLocalPath(value) {
388
+ const expanded = expandHomePath(value)
389
+ return isAbsolute(expanded) ? expanded : resolve(process.cwd(), expanded)
390
+ }
391
+
392
+ function isAllowedLocalMimeType(value) {
393
+ const normalized = String(value || '').trim().toLowerCase()
394
+ return normalized.startsWith('audio/') || normalized.startsWith('video/')
395
+ }
396
+
397
+ function normalizeExplicitMimeType(value) {
398
+ const normalized = String(value || '').trim().toLowerCase()
399
+
400
+ if (!normalized) {
401
+ return ''
402
+ }
403
+
404
+ if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*(?:\s*;.*)?$/i.test(normalized)) {
405
+ fail(`--mime-type 不是合法 MIME:${value}`)
406
+ }
407
+
408
+ if (!isAllowedLocalMimeType(normalized)) {
409
+ fail('--mime-type 只支持 audio/* 或 video/*')
410
+ }
411
+
412
+ return normalized.split(';')[0].trim()
413
+ }
414
+
415
+ function inferMimeTypeFromPath(filePath) {
416
+ return MIME_TYPES_BY_EXTENSION.get(extname(filePath).toLowerCase()) || ''
417
+ }
418
+
419
+ function normalizeEtag(value) {
420
+ return String(value || '').trim().replace(/^"+|"+$/g, '')
421
+ }
422
+
423
+ function formatBytes(value) {
424
+ const bytes = Number(value)
425
+
426
+ if (!Number.isFinite(bytes) || bytes <= 0) {
427
+ return '0 B'
428
+ }
429
+
430
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
431
+ let size = bytes
432
+ let unitIndex = 0
433
+
434
+ while (size >= 1024 && unitIndex < units.length - 1) {
435
+ size /= 1024
436
+ unitIndex += 1
437
+ }
438
+
439
+ const precision = size >= 10 || unitIndex === 0 ? 0 : 1
440
+ return `${size.toFixed(precision)} ${units[unitIndex]}`
441
+ }
442
+
443
+ function redactUploadSourceUrl(sourceType, sourceUrl) {
444
+ if (sourceType === 'upload' || String(sourceUrl || '').startsWith('upload:sha256:')) {
445
+ return UPLOAD_REDACTED_SOURCE_LABEL
446
+ }
447
+
448
+ return sourceUrl || null
449
+ }
450
+
451
+ function isSensitiveUploadResultString(key, value, sourceType = null) {
452
+ if (value.startsWith('upload:sha256:')) {
453
+ return true
454
+ }
455
+
456
+ if (SIGNED_URL_PARAM_PATTERN.test(value)) {
457
+ return true
458
+ }
459
+
460
+ return sourceType === 'upload' && !!key && SENSITIVE_UPLOAD_RESULT_KEY_PATTERN.test(key)
461
+ }
462
+
463
+ function sanitizeTaskForReporting(task) {
464
+ if (!task || typeof task !== 'object') {
465
+ return task
466
+ }
467
+
468
+ return {
469
+ ...task,
470
+ sourceUrl: redactUploadSourceUrl(task.sourceType, task.sourceUrl),
471
+ }
472
+ }
473
+
474
+ function sanitizeResultPayload(value, inheritedSourceType = null, inheritedKey = null) {
475
+ if (Array.isArray(value)) {
476
+ return value.map(item => sanitizeResultPayload(item, inheritedSourceType, inheritedKey))
477
+ }
478
+
479
+ if (!value || typeof value !== 'object') {
480
+ if (typeof value === 'string' && isSensitiveUploadResultString(inheritedKey, value, inheritedSourceType)) {
481
+ return UPLOAD_REDACTED_SOURCE_LABEL
482
+ }
483
+
484
+ return value
485
+ }
486
+
487
+ const sourceType = typeof value.sourceType === 'string' ? value.sourceType : inheritedSourceType
488
+ const next = {}
489
+
490
+ for (const [key, item] of Object.entries(value)) {
491
+ if (key === 'sourceUrl') {
492
+ next[key] = redactUploadSourceUrl(sourceType, item)
493
+ continue
494
+ }
495
+
496
+ next[key] = sanitizeResultPayload(item, sourceType, key)
497
+ }
498
+
499
+ return next
500
+ }
501
+
502
+ async function inspectLocalFile(filePath, explicitMimeType) {
503
+ const trimmedPath = String(filePath || '').trim()
504
+
505
+ if (!trimmedPath) {
506
+ fail('缺少本地文件路径')
507
+ }
508
+
509
+ if (isStdinInput(trimmedPath)) {
510
+ fail('v1 不支持从 stdin 读取,请提供本地单个音视频文件路径')
511
+ }
512
+
513
+ if (hasGlobPattern(trimmedPath)) {
514
+ fail('v1 不支持 glob,请提供一个明确的本地文件路径')
515
+ }
516
+
517
+ const resolvedPath = resolveLocalPath(trimmedPath)
518
+ let fileStat
519
+
520
+ try {
521
+ fileStat = await stat(resolvedPath)
522
+ } catch (error) {
523
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
524
+ fail(`本地文件不存在:${trimmedPath}`)
525
+ }
526
+
527
+ throw error
528
+ }
529
+
530
+ if (fileStat.isDirectory()) {
531
+ fail('v1 不支持目录,请提供一个本地音视频文件')
532
+ }
533
+
534
+ if (!fileStat.isFile()) {
535
+ fail('v1 只支持普通本地文件,不支持管道、设备或其他特殊文件')
536
+ }
537
+
538
+ if (fileStat.size <= 0) {
539
+ fail('本地文件为空,无法转写')
540
+ }
541
+
542
+ const explicit = normalizeExplicitMimeType(explicitMimeType)
543
+ const mimeType = explicit || inferMimeTypeFromPath(resolvedPath)
544
+
545
+ if (!mimeType || !isAllowedLocalMimeType(mimeType)) {
546
+ fail('无法识别本地文件 MIME,请使用 --mime-type 指定 audio/* 或 video/*')
547
+ }
548
+
549
+ return {
550
+ kind: 'file',
551
+ path: resolvedPath,
552
+ filename: basename(resolvedPath),
553
+ sizeBytes: fileStat.size,
554
+ mimeType,
555
+ }
556
+ }
557
+
558
+ async function resolveTranscriptionInput(args) {
559
+ if (args.file) {
560
+ return await inspectLocalFile(args.file, args.mimeType)
561
+ }
562
+
563
+ const rawInput = String(args.url || '').trim()
564
+
565
+ if (!rawInput) {
566
+ return null
567
+ }
568
+
569
+ if (isStdinInput(rawInput)) {
570
+ fail('v1 不支持从 stdin 读取,请提供内容链接或本地单个音视频文件')
571
+ }
572
+
573
+ if (!isLikelyUrlInput(rawInput) && hasGlobPattern(rawInput)) {
574
+ fail('v1 不支持 glob,请提供一个明确的本地文件路径或内容链接')
575
+ }
576
+
577
+ if (!isLikelyUrlInput(rawInput)) {
578
+ return await inspectLocalFile(rawInput, args.mimeType)
579
+ }
580
+
581
+ if (args.mimeType) {
582
+ fail('--mime-type 只能用于本地文件上传')
583
+ }
584
+
585
+ return {
586
+ kind: 'url',
587
+ url: rawInput,
588
+ }
589
+ }
590
+
219
591
  function getConfigFileCandidates() {
220
592
  const home = homedir()
221
593
  const xdgConfigHome = String(process.env.XDG_CONFIG_HOME || '').trim()
@@ -408,26 +780,33 @@ async function readErrorMessage(response) {
408
780
  }
409
781
 
410
782
  async function requestJson(baseUrl, apiKey, path, options = {}) {
411
- const response = await fetch(`${baseUrl}${path}`, {
412
- method: options.method || 'GET',
413
- headers: {
414
- Authorization: `Bearer ${apiKey}`,
415
- 'Content-Type': 'application/json',
416
- ...(options.headers || {}),
417
- },
418
- body: typeof options.body === 'undefined' ? undefined : JSON.stringify(options.body),
419
- })
783
+ return await withRetry(async () => {
784
+ const response = await fetch(`${baseUrl}${path}`, {
785
+ method: options.method || 'GET',
786
+ signal: options.signal,
787
+ headers: {
788
+ Authorization: `Bearer ${apiKey}`,
789
+ 'Content-Type': 'application/json',
790
+ ...(options.headers || {}),
791
+ },
792
+ body: typeof options.body === 'undefined' ? undefined : JSON.stringify(options.body),
793
+ })
420
794
 
421
- if (!response.ok) {
422
- throw new Error(await readErrorMessage(response))
423
- }
795
+ if (!response.ok) {
796
+ throw new HttpCliError(await readErrorMessage(response), response.status)
797
+ }
424
798
 
425
- return response.json()
799
+ return response.json()
800
+ }, {
801
+ signal: options.signal,
802
+ maxAttempts: options.retry ? 4 : 1,
803
+ })
426
804
  }
427
805
 
428
- async function requestResult(baseUrl, apiKey, taskId, format) {
806
+ async function requestResult(baseUrl, apiKey, taskId, format, options = {}) {
429
807
  const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/result?format=${encodeURIComponent(format)}`, {
430
808
  method: 'GET',
809
+ signal: options.signal,
431
810
  headers: {
432
811
  Authorization: `Bearer ${apiKey}`,
433
812
  },
@@ -438,7 +817,7 @@ async function requestResult(baseUrl, apiKey, taskId, format) {
438
817
  }
439
818
 
440
819
  if (format === 'json' || format === 'verbose_json') {
441
- return JSON.stringify(await response.json(), null, 2)
820
+ return JSON.stringify(sanitizeResultPayload(await response.json()), null, 2)
442
821
  }
443
822
 
444
823
  return response.text()
@@ -566,6 +945,77 @@ function buildTranscribingProgressMessage(input = {}) {
566
945
  return `转写中 ${percent}%${chunksPart}`
567
946
  }
568
947
 
948
+ function normalizeUploadPercent(uploadedBytes, totalBytes) {
949
+ if (!Number.isFinite(uploadedBytes) || !Number.isFinite(totalBytes) || totalBytes <= 0) {
950
+ return 0
951
+ }
952
+
953
+ return Math.max(0, Math.min(100, Math.round((uploadedBytes / totalBytes) * 100)))
954
+ }
955
+
956
+ function buildUploadProgressMessage(input = {}) {
957
+ const uploadedBytes = Number(input.uploadedBytes) || 0
958
+ const totalBytes = Number(input.totalBytes) || 0
959
+ const percent = normalizeUploadPercent(uploadedBytes, totalBytes)
960
+
961
+ return `上传中 ${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)} (${percent}%)`
962
+ }
963
+
964
+ function buildHashingMessage(input = {}) {
965
+ const hashedBytes = Number(input.hashedBytes) || 0
966
+ const totalBytes = Number(input.totalBytes) || 0
967
+ const percent = normalizeUploadPercent(hashedBytes, totalBytes)
968
+
969
+ return `正在计算文件哈希 ${percent}%`
970
+ }
971
+
972
+ function getUploadReadyMessage(input = {}) {
973
+ const title = input.title || input.filename || '上传文件'
974
+ const duration = Number(input.durationSeconds)
975
+ const durationPart = Number.isFinite(duration) && duration > 0
976
+ ? `,时长约 ${Math.ceil(duration / 60)} 分钟`
977
+ : ''
978
+
979
+ return `媒体检查通过:${title}${durationPart}`
980
+ }
981
+
982
+ function getUploadTaskFromCheckResult(checkResult) {
983
+ if (checkResult?.task?.taskId) {
984
+ return checkResult.task
985
+ }
986
+
987
+ if (checkResult?.upload?.task?.taskId) {
988
+ return checkResult.upload.task
989
+ }
990
+
991
+ return null
992
+ }
993
+
994
+ function getUploadReadySummary(checkResult, fileInput, uploadId) {
995
+ const task = getUploadTaskFromCheckResult(checkResult)
996
+ const upload = checkResult?.upload || {}
997
+ const durationMs = Number(upload.ffprobeDurationMs || upload.clientDurationMs)
998
+ const durationSeconds = Number(checkResult?.durationSeconds)
999
+ const normalizedDurationSeconds = Number.isFinite(durationSeconds) && durationSeconds > 0
1000
+ ? durationSeconds
1001
+ : Number.isFinite(durationMs) && durationMs > 0
1002
+ ? Math.round(durationMs / 1000)
1003
+ : null
1004
+ const requiredMinutes = Number(checkResult?.requiredMinutes)
1005
+
1006
+ return {
1007
+ uploadId,
1008
+ filename: upload.filename || fileInput.filename,
1009
+ title: task?.title || checkResult?.title || upload.filename || fileInput.filename,
1010
+ durationSeconds: normalizedDurationSeconds,
1011
+ requiredMinutes: Number.isFinite(requiredMinutes) && requiredMinutes > 0
1012
+ ? requiredMinutes
1013
+ : normalizedDurationSeconds
1014
+ ? Math.max(1, Math.floor(normalizedDurationSeconds / 60))
1015
+ : null,
1016
+ }
1017
+ }
1018
+
569
1019
  function createProgressReporter(progressFormat) {
570
1020
  const resolvedMode = resolveProgressFormat(progressFormat)
571
1021
  const outputState = createOutputState()
@@ -596,29 +1046,157 @@ function createProgressReporter(progressFormat) {
596
1046
  return {
597
1047
  mode: resolvedMode,
598
1048
  reportTaskCreated(task) {
1049
+ const safeTask = sanitizeTaskForReporting(task)
1050
+
599
1051
  if (resolvedMode === 'jsonl') {
600
1052
  writeJsonEvent({
601
1053
  event: 'task.created',
602
- message: `任务已创建:${task.taskId}`,
603
- taskId: task.taskId,
604
- title: task.title,
605
- sourceType: task.sourceType,
606
- status: task.status,
607
- statusLabel: formatStatus(task.status),
608
- consumedMinutes: task.consumedMinutes,
609
- remainingMinutes: task.remainingMinutes,
610
- mode: task.mode,
611
- resultReady: task.resultReady,
1054
+ message: `任务已创建:${safeTask.taskId}`,
1055
+ taskId: safeTask.taskId,
1056
+ title: safeTask.title,
1057
+ sourceType: safeTask.sourceType,
1058
+ sourceUrl: safeTask.sourceUrl || null,
1059
+ status: safeTask.status,
1060
+ statusLabel: formatStatus(safeTask.status),
1061
+ consumedMinutes: safeTask.consumedMinutes,
1062
+ remainingMinutes: safeTask.remainingMinutes,
1063
+ mode: safeTask.mode,
1064
+ resultReady: safeTask.resultReady,
612
1065
  })
613
1066
  return
614
1067
  }
615
1068
 
616
- writeTextMessage(`任务已创建:${task.taskId}`)
617
- writeTextMessage(`标题:${task.title}`)
618
- writeTextMessage(`来源:${task.sourceType}`)
619
- writeTextMessage(`状态:${formatStatus(task.status)}`)
620
- writeTextMessage(`本次消耗分钟:${task.consumedMinutes}`)
621
- writeTextMessage(`剩余分钟:${task.remainingMinutes}`)
1069
+ writeTextMessage(`任务已创建:${safeTask.taskId}`)
1070
+ writeTextMessage(`标题:${safeTask.title}`)
1071
+ writeTextMessage(`来源:${safeTask.sourceType}`)
1072
+ writeTextMessage(`状态:${formatStatus(safeTask.status)}`)
1073
+ writeTextMessage(`本次消耗分钟:${safeTask.consumedMinutes}`)
1074
+ writeTextMessage(`剩余分钟:${safeTask.remainingMinutes}`)
1075
+ },
1076
+ reportUploadHashing(input = {}) {
1077
+ const message = buildHashingMessage(input)
1078
+ const hashedBytes = Number(input.hashedBytes) || 0
1079
+ const totalBytes = Number(input.totalBytes) || 0
1080
+ const percent = normalizeUploadPercent(hashedBytes, totalBytes)
1081
+
1082
+ if (resolvedMode === 'interactive') {
1083
+ writeInline(outputState, message)
1084
+ return
1085
+ }
1086
+
1087
+ if (resolvedMode === 'jsonl') {
1088
+ writeJsonEvent({
1089
+ event: 'upload.hashing',
1090
+ message,
1091
+ filename: input.filename || null,
1092
+ hashedBytes,
1093
+ totalBytes,
1094
+ percent,
1095
+ })
1096
+ return
1097
+ }
1098
+
1099
+ if (message === lastTextProgressMessage) {
1100
+ return
1101
+ }
1102
+
1103
+ lastTextProgressMessage = message
1104
+ writeTextMessage(message)
1105
+ },
1106
+ reportUploadStarted(input = {}) {
1107
+ lastTextProgressMessage = ''
1108
+ const message = `上传已开始:${input.filename || '上传文件'}(${formatBytes(input.totalBytes)})`
1109
+
1110
+ emitTextOrJson(message, {
1111
+ event: 'upload.started',
1112
+ filename: input.filename || null,
1113
+ mimeType: input.mimeType || null,
1114
+ totalBytes: Number(input.totalBytes) || 0,
1115
+ uploadId: input.uploadId || null,
1116
+ partSize: Number(input.partSize) || null,
1117
+ totalParts: Number(input.totalParts) || null,
1118
+ })
1119
+ },
1120
+ reportUploadProgress(input = {}) {
1121
+ const uploadedBytes = Number(input.uploadedBytes) || 0
1122
+ const totalBytes = Number(input.totalBytes) || 0
1123
+ const percent = normalizeUploadPercent(uploadedBytes, totalBytes)
1124
+ const message = buildUploadProgressMessage({ uploadedBytes, totalBytes })
1125
+
1126
+ if (resolvedMode === 'interactive') {
1127
+ writeInline(outputState, message)
1128
+ return
1129
+ }
1130
+
1131
+ if (resolvedMode === 'jsonl') {
1132
+ writeJsonEvent({
1133
+ event: 'upload.progress',
1134
+ message,
1135
+ uploadedBytes,
1136
+ totalBytes,
1137
+ percent,
1138
+ completedParts: Number(input.completedParts) || null,
1139
+ totalParts: Number(input.totalParts) || null,
1140
+ })
1141
+ return
1142
+ }
1143
+
1144
+ if (message === lastTextProgressMessage) {
1145
+ return
1146
+ }
1147
+
1148
+ lastTextProgressMessage = message
1149
+ writeTextMessage(message)
1150
+ },
1151
+ reportUploadCompleted(input = {}) {
1152
+ finishInline(outputState)
1153
+ lastTextProgressMessage = ''
1154
+ const message = '上传完成,正在合并并准备媒体检查'
1155
+
1156
+ emitTextOrJson(message, {
1157
+ event: 'upload.completed',
1158
+ uploadId: input.uploadId || null,
1159
+ filename: input.filename || null,
1160
+ totalBytes: Number(input.totalBytes) || 0,
1161
+ })
1162
+ },
1163
+ reportUploadChecking(input = {}) {
1164
+ const message = '正在检查媒体信息'
1165
+
1166
+ emitTextOrJson(message, {
1167
+ event: 'upload.checking',
1168
+ uploadId: input.uploadId || null,
1169
+ })
1170
+ },
1171
+ reportUploadReady(input = {}) {
1172
+ const message = getUploadReadyMessage(input)
1173
+
1174
+ emitTextOrJson(message, {
1175
+ event: 'upload.ready',
1176
+ uploadId: input.uploadId || null,
1177
+ sourceType: 'upload',
1178
+ sourceUrl: UPLOAD_REDACTED_SOURCE_LABEL,
1179
+ title: input.title || input.filename || null,
1180
+ durationSeconds: Number(input.durationSeconds) || null,
1181
+ requiredMinutes: Number(input.requiredMinutes) || null,
1182
+ })
1183
+ },
1184
+ reportUploadAborted(input = {}) {
1185
+ finishInline(outputState)
1186
+ const message = '上传已取消'
1187
+
1188
+ emitTextOrJson(message, {
1189
+ event: 'upload.aborted',
1190
+ uploadId: input.uploadId || null,
1191
+ })
1192
+ },
1193
+ reportUploadFailed(message, input = {}) {
1194
+ finishInline(outputState)
1195
+ emitTextOrJson(message, {
1196
+ event: 'upload.failed',
1197
+ uploadId: input.uploadId || null,
1198
+ error: message,
1199
+ })
622
1200
  },
623
1201
  reportStatusChanged(status) {
624
1202
  emitTextOrJson(buildStatusChangedMessage(status), {
@@ -728,9 +1306,10 @@ function parseSseBlock(block) {
728
1306
  }
729
1307
  }
730
1308
 
731
- async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter) {
1309
+ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter, signal) {
732
1310
  const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/events`, {
733
1311
  method: 'GET',
1312
+ signal,
734
1313
  headers: {
735
1314
  Authorization: `Bearer ${apiKey}`,
736
1315
  },
@@ -858,6 +1437,423 @@ async function writeOutput(outputPath, content) {
858
1437
  return resolvedPath
859
1438
  }
860
1439
 
1440
+ function assertNotAborted(signal) {
1441
+ if (signal?.aborted) {
1442
+ throw createAbortError()
1443
+ }
1444
+ }
1445
+
1446
+ async function calculateFileSha256(fileInput, reporter, signal) {
1447
+ const hash = createHash('sha256')
1448
+ const fileHandle = await open(fileInput.path, 'r')
1449
+ let hashedBytes = 0
1450
+
1451
+ reporter.reportUploadHashing({
1452
+ filename: fileInput.filename,
1453
+ hashedBytes,
1454
+ totalBytes: fileInput.sizeBytes,
1455
+ })
1456
+
1457
+ try {
1458
+ while (hashedBytes < fileInput.sizeBytes) {
1459
+ assertNotAborted(signal)
1460
+
1461
+ const length = Math.min(HASH_CHUNK_SIZE, fileInput.sizeBytes - hashedBytes)
1462
+ const buffer = Buffer.allocUnsafe(length)
1463
+ const { bytesRead } = await fileHandle.read(buffer, 0, length, hashedBytes)
1464
+
1465
+ if (bytesRead <= 0) {
1466
+ break
1467
+ }
1468
+
1469
+ hash.update(bytesRead === buffer.length ? buffer : buffer.subarray(0, bytesRead))
1470
+ hashedBytes += bytesRead
1471
+ reporter.reportUploadHashing({
1472
+ filename: fileInput.filename,
1473
+ hashedBytes,
1474
+ totalBytes: fileInput.sizeBytes,
1475
+ })
1476
+ }
1477
+ } finally {
1478
+ await fileHandle.close().catch(() => {})
1479
+ }
1480
+
1481
+ if (hashedBytes !== fileInput.sizeBytes) {
1482
+ throw new Error('读取本地文件计算哈希时大小不一致,请重新选择文件')
1483
+ }
1484
+
1485
+ return hash.digest('hex')
1486
+ }
1487
+
1488
+ async function readFileChunk(filePath, start, length, signal) {
1489
+ assertNotAborted(signal)
1490
+
1491
+ const fileHandle = await open(filePath, 'r')
1492
+ const buffer = Buffer.allocUnsafe(length)
1493
+ let offset = 0
1494
+
1495
+ try {
1496
+ while (offset < length) {
1497
+ assertNotAborted(signal)
1498
+
1499
+ const { bytesRead } = await fileHandle.read(
1500
+ buffer,
1501
+ offset,
1502
+ length - offset,
1503
+ start + offset,
1504
+ )
1505
+
1506
+ if (bytesRead <= 0) {
1507
+ break
1508
+ }
1509
+
1510
+ offset += bytesRead
1511
+ }
1512
+ } finally {
1513
+ await fileHandle.close().catch(() => {})
1514
+ }
1515
+
1516
+ if (offset !== length) {
1517
+ throw new Error('读取本地文件分片时大小不一致,请重新选择文件')
1518
+ }
1519
+
1520
+ return buffer
1521
+ }
1522
+
1523
+ async function abortUploadOnServer(baseUrl, apiKey, uploadId) {
1524
+ if (!uploadId) {
1525
+ return null
1526
+ }
1527
+
1528
+ return await requestJson(baseUrl, apiKey, '/api/cli/uploads/abort', {
1529
+ method: 'POST',
1530
+ retry: true,
1531
+ body: {
1532
+ uploadId,
1533
+ },
1534
+ })
1535
+ }
1536
+
1537
+ function createRunContext(baseUrl, apiKey, reporter) {
1538
+ const abortController = new AbortController()
1539
+ const state = {
1540
+ uploadId: '',
1541
+ taskId: '',
1542
+ taskCreated: false,
1543
+ uploadAbortAllowed: true,
1544
+ interrupting: false,
1545
+ interruptPromise: null,
1546
+ }
1547
+
1548
+ const handleSigint = () => {
1549
+ if (state.interrupting) {
1550
+ return
1551
+ }
1552
+
1553
+ state.interrupting = true
1554
+ abortController.abort()
1555
+
1556
+ state.interruptPromise = (async () => {
1557
+ if (state.taskCreated && state.taskId) {
1558
+ reporter.reportNotice(`已停止本地等待,服务端转写仍在继续:${state.taskId}`)
1559
+ reporter.close()
1560
+ return
1561
+ }
1562
+
1563
+ if (!state.uploadAbortAllowed && state.uploadId) {
1564
+ const taskPart = state.taskId ? `,taskId=${state.taskId}` : ''
1565
+ reporter.reportNotice(`已停止本地等待,服务端正在处理上传:uploadId=${state.uploadId}${taskPart}`)
1566
+ reporter.close()
1567
+ return
1568
+ }
1569
+
1570
+ if (state.uploadId) {
1571
+ try {
1572
+ await abortUploadOnServer(baseUrl, apiKey, state.uploadId)
1573
+ reporter.reportUploadAborted({ uploadId: state.uploadId })
1574
+ } catch (error) {
1575
+ reporter.reportUploadFailed(
1576
+ `已中断本地上传,但服务端上传清理失败:${error instanceof Error ? error.message : String(error)}`,
1577
+ { uploadId: state.uploadId },
1578
+ )
1579
+ }
1580
+ }
1581
+
1582
+ reporter.close()
1583
+ })()
1584
+
1585
+ void state.interruptPromise.finally(() => {
1586
+ process.exit(130)
1587
+ })
1588
+ }
1589
+
1590
+ process.once('SIGINT', handleSigint)
1591
+
1592
+ return {
1593
+ signal: abortController.signal,
1594
+ state,
1595
+ dispose() {
1596
+ process.off('SIGINT', handleSigint)
1597
+ },
1598
+ }
1599
+ }
1600
+
1601
+ async function signUploadPart(baseUrl, apiKey, uploadId, partNumber, signal) {
1602
+ const response = await requestJson(baseUrl, apiKey, '/api/cli/uploads/parts', {
1603
+ method: 'POST',
1604
+ signal,
1605
+ retry: true,
1606
+ body: {
1607
+ uploadId,
1608
+ partNumbers: [partNumber],
1609
+ },
1610
+ })
1611
+ const signedPart = Array.isArray(response?.parts)
1612
+ ? response.parts.find(part => Number(part?.partNumber) === partNumber)
1613
+ : null
1614
+
1615
+ if (!signedPart?.url) {
1616
+ throw new Error(`第 ${partNumber} 个分片上传地址生成失败`)
1617
+ }
1618
+
1619
+ return signedPart
1620
+ }
1621
+
1622
+ async function putSignedPartBuffer(signedUrl, buffer, signal) {
1623
+ const response = await fetch(signedUrl, {
1624
+ method: 'PUT',
1625
+ signal,
1626
+ body: buffer,
1627
+ })
1628
+
1629
+ if (!response.ok) {
1630
+ const message = response.status === 403
1631
+ ? '分片上传签名已过期'
1632
+ : `分片上传失败:${response.status} ${response.statusText}`
1633
+ throw new HttpCliError(message, response.status)
1634
+ }
1635
+
1636
+ const etag = normalizeEtag(response.headers.get('ETag') || response.headers.get('etag'))
1637
+
1638
+ if (!etag) {
1639
+ throw new Error('OSS 未返回分片 ETag,请检查服务端签名与跨域响应头配置')
1640
+ }
1641
+
1642
+ return etag
1643
+ }
1644
+
1645
+ async function uploadSinglePart(input) {
1646
+ const {
1647
+ baseUrl,
1648
+ apiKey,
1649
+ uploadId,
1650
+ fileInput,
1651
+ partNumber,
1652
+ partSize,
1653
+ totalParts,
1654
+ signal,
1655
+ } = input
1656
+ const start = (partNumber - 1) * partSize
1657
+ const length = Math.min(fileInput.sizeBytes - start, partSize)
1658
+ const buffer = await readFileChunk(fileInput.path, start, length, signal)
1659
+ let signedPart = await signUploadPart(baseUrl, apiKey, uploadId, partNumber, signal)
1660
+
1661
+ for (let attempt = 1; attempt <= 4; attempt += 1) {
1662
+ assertNotAborted(signal)
1663
+
1664
+ try {
1665
+ const etag = await putSignedPartBuffer(signedPart.url, buffer, signal)
1666
+
1667
+ return {
1668
+ partNumber,
1669
+ etag,
1670
+ bytes: length,
1671
+ }
1672
+ } catch (error) {
1673
+ if (signal?.aborted || isAbortError(error)) {
1674
+ throw error
1675
+ }
1676
+
1677
+ if (error instanceof HttpCliError && error.status === 403 && attempt < 4) {
1678
+ signedPart = await signUploadPart(baseUrl, apiKey, uploadId, partNumber, signal)
1679
+ await sleep(getRetryDelayMs(attempt))
1680
+ continue
1681
+ }
1682
+
1683
+ if (isRetryableRequestError(error) && attempt < 4) {
1684
+ await sleep(getRetryDelayMs(attempt))
1685
+ continue
1686
+ }
1687
+
1688
+ const message = error instanceof Error ? error.message : String(error)
1689
+ throw new Error(`第 ${partNumber}/${totalParts} 个分片上传失败:${message}`)
1690
+ }
1691
+ }
1692
+
1693
+ throw new Error(`第 ${partNumber}/${totalParts} 个分片上传失败`)
1694
+ }
1695
+
1696
+ async function uploadFileParts(input) {
1697
+ const totalParts = Math.ceil(input.fileInput.sizeBytes / input.partSize)
1698
+ const workerCount = Math.min(MAX_UPLOAD_CONCURRENCY, totalParts)
1699
+ const completedParts = []
1700
+ let nextPartNumber = 1
1701
+ let completedPartCount = 0
1702
+ let uploadedBytes = 0
1703
+ let firstError = null
1704
+
1705
+ input.reporter.reportUploadProgress({
1706
+ uploadedBytes,
1707
+ totalBytes: input.fileInput.sizeBytes,
1708
+ completedParts: completedPartCount,
1709
+ totalParts,
1710
+ })
1711
+
1712
+ async function uploadNextPart() {
1713
+ while (nextPartNumber <= totalParts) {
1714
+ assertNotAborted(input.signal)
1715
+
1716
+ if (firstError) {
1717
+ throw firstError
1718
+ }
1719
+
1720
+ const partNumber = nextPartNumber
1721
+ nextPartNumber += 1
1722
+
1723
+ try {
1724
+ const completed = await uploadSinglePart({
1725
+ ...input,
1726
+ partNumber,
1727
+ totalParts,
1728
+ })
1729
+
1730
+ completedParts.push({
1731
+ partNumber: completed.partNumber,
1732
+ etag: completed.etag,
1733
+ })
1734
+ uploadedBytes += completed.bytes
1735
+ completedPartCount += 1
1736
+ input.reporter.reportUploadProgress({
1737
+ uploadedBytes,
1738
+ totalBytes: input.fileInput.sizeBytes,
1739
+ completedParts: completedPartCount,
1740
+ totalParts,
1741
+ })
1742
+ } catch (error) {
1743
+ firstError = error
1744
+ throw error
1745
+ }
1746
+ }
1747
+ }
1748
+
1749
+ await Promise.all(Array.from({ length: workerCount }, () => uploadNextPart()))
1750
+
1751
+ return completedParts.sort((a, b) => a.partNumber - b.partNumber)
1752
+ }
1753
+
1754
+ async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runContext) {
1755
+ let uploadId = ''
1756
+
1757
+ try {
1758
+ const fileHash = await calculateFileSha256(fileInput, reporter, runContext.signal)
1759
+ const startResponse = await requestJson(baseUrl, apiKey, '/api/cli/uploads/start', {
1760
+ method: 'POST',
1761
+ signal: runContext.signal,
1762
+ retry: true,
1763
+ body: {
1764
+ filename: fileInput.filename,
1765
+ mimeType: fileInput.mimeType,
1766
+ sizeBytes: fileInput.sizeBytes,
1767
+ fileHash,
1768
+ },
1769
+ })
1770
+
1771
+ uploadId = String(startResponse?.uploadId || '')
1772
+ runContext.state.uploadId = uploadId
1773
+
1774
+ const partSize = Number(startResponse?.partSize)
1775
+
1776
+ if (!uploadId || !Number.isFinite(partSize) || partSize <= 0) {
1777
+ throw new Error('上传初始化失败,请稍后重试')
1778
+ }
1779
+
1780
+ const totalParts = Math.ceil(fileInput.sizeBytes / partSize)
1781
+
1782
+ reporter.reportUploadStarted({
1783
+ uploadId,
1784
+ filename: fileInput.filename,
1785
+ mimeType: fileInput.mimeType,
1786
+ totalBytes: fileInput.sizeBytes,
1787
+ partSize,
1788
+ totalParts,
1789
+ })
1790
+
1791
+ const parts = await uploadFileParts({
1792
+ baseUrl,
1793
+ apiKey,
1794
+ uploadId,
1795
+ fileInput,
1796
+ partSize,
1797
+ signal: runContext.signal,
1798
+ reporter,
1799
+ })
1800
+
1801
+ await requestJson(baseUrl, apiKey, '/api/cli/uploads/complete', {
1802
+ method: 'POST',
1803
+ signal: runContext.signal,
1804
+ retry: true,
1805
+ body: {
1806
+ uploadId,
1807
+ parts,
1808
+ },
1809
+ })
1810
+
1811
+ reporter.reportUploadCompleted({
1812
+ uploadId,
1813
+ filename: fileInput.filename,
1814
+ totalBytes: fileInput.sizeBytes,
1815
+ })
1816
+ runContext.state.uploadAbortAllowed = false
1817
+ reporter.reportUploadChecking({ uploadId })
1818
+
1819
+ const checkResult = await requestJson(baseUrl, apiKey, '/api/cli/uploads/check', {
1820
+ method: 'POST',
1821
+ signal: runContext.signal,
1822
+ retry: true,
1823
+ body: {
1824
+ uploadId,
1825
+ language: args.language || DEFAULT_LANGUAGE,
1826
+ },
1827
+ })
1828
+ const checkedTask = getUploadTaskFromCheckResult(checkResult)
1829
+
1830
+ if (checkedTask) {
1831
+ runContext.state.taskId = checkedTask.taskId || ''
1832
+ runContext.state.taskCreated = true
1833
+ }
1834
+
1835
+ reporter.reportUploadReady(getUploadReadySummary(checkResult, fileInput, uploadId))
1836
+
1837
+ return {
1838
+ ...checkResult,
1839
+ uploadId,
1840
+ }
1841
+ } catch (error) {
1842
+ if (runContext.state.interrupting || isAbortError(error)) {
1843
+ throw new ReportedCliError('已中断', 130)
1844
+ }
1845
+
1846
+ const message = error instanceof Error ? error.message : String(error)
1847
+ reporter.reportUploadFailed(`上传失败:${message}`, { uploadId })
1848
+
1849
+ if (uploadId && runContext.state.uploadAbortAllowed) {
1850
+ await abortUploadOnServer(baseUrl, apiKey, uploadId).catch(() => null)
1851
+ }
1852
+
1853
+ throw error
1854
+ }
1855
+ }
1856
+
861
1857
  async function handleAuthLogin(args) {
862
1858
  if (!args.apiKey) {
863
1859
  printAuthUsage()
@@ -930,9 +1926,113 @@ async function handleAuthCommand(argv) {
930
1926
  fail(`不支持的 auth 子命令:${args.action}`)
931
1927
  }
932
1928
 
1929
+ function buildUrlTaskCreatePayload(args, input) {
1930
+ const createPayload = {
1931
+ url: input.url,
1932
+ language: args.language || DEFAULT_LANGUAGE,
1933
+ }
1934
+
1935
+ if (args.source) {
1936
+ createPayload.source = args.source
1937
+ }
1938
+
1939
+ return createPayload
1940
+ }
1941
+
1942
+ async function createCliTask(baseUrl, apiKey, createPayload, reporter, runContext) {
1943
+ const created = await requestJson(baseUrl, apiKey, '/api/cli/transcriptions', {
1944
+ method: 'POST',
1945
+ signal: runContext.signal,
1946
+ body: createPayload,
1947
+ })
1948
+
1949
+ runContext.state.taskId = created.taskId || ''
1950
+ runContext.state.taskCreated = true
1951
+ reporter.reportTaskCreated(created)
1952
+
1953
+ return created
1954
+ }
1955
+
1956
+ async function waitForTaskAndOutput(baseUrl, apiKey, task, args, format, reporter, runContext) {
1957
+ let currentTask = task
1958
+ let lastStatus = currentTask.status
1959
+
1960
+ if (currentTask.mode === 'transcription' && !TERMINAL_STATUSES.has(currentTask.status)) {
1961
+ try {
1962
+ currentTask = await consumeTaskEvents(
1963
+ baseUrl,
1964
+ apiKey,
1965
+ currentTask.taskId,
1966
+ currentTask,
1967
+ reporter,
1968
+ runContext.signal,
1969
+ )
1970
+ lastStatus = currentTask.status
1971
+ } catch (error) {
1972
+ if (runContext.state.interrupting || isAbortError(error)) {
1973
+ throw new ReportedCliError('已中断', 130)
1974
+ }
1975
+
1976
+ reporter.reportNotice(`事件流中断,回退到轮询状态:${error instanceof Error ? error.message : String(error)}`)
1977
+ }
1978
+ }
1979
+
1980
+ while (!TERMINAL_STATUSES.has(currentTask.status)) {
1981
+ await sleep(POLL_INTERVAL_MS)
1982
+
1983
+ if (runContext.state.interrupting) {
1984
+ throw new ReportedCliError('已中断', 130)
1985
+ }
1986
+
1987
+ currentTask = await requestJson(
1988
+ baseUrl,
1989
+ apiKey,
1990
+ `/api/cli/transcriptions/${encodeURIComponent(currentTask.taskId)}`,
1991
+ {
1992
+ signal: runContext.signal,
1993
+ retry: true,
1994
+ },
1995
+ )
1996
+
1997
+ if (currentTask.status !== lastStatus) {
1998
+ if (TERMINAL_STATUSES.has(currentTask.status)) {
1999
+ reporter.reportTerminal(currentTask.status, currentTask.error || null)
2000
+ } else {
2001
+ reporter.reportStatusChanged(currentTask.status)
2002
+ }
2003
+ lastStatus = currentTask.status
2004
+ }
2005
+ }
2006
+
2007
+ reporter.reportTerminal(currentTask.status, currentTask.error || null)
2008
+
2009
+ if (currentTask.status !== 'completed') {
2010
+ throw new ReportedCliError(currentTask.error || `任务未成功完成:${formatStatus(currentTask.status)}`)
2011
+ }
2012
+
2013
+ const result = await requestResult(baseUrl, apiKey, currentTask.taskId, format, {
2014
+ signal: runContext.signal,
2015
+ })
2016
+
2017
+ if (args.output) {
2018
+ const outputPath = await writeOutput(args.output, result)
2019
+ reporter.reportResultWritten(outputPath, format)
2020
+ return
2021
+ }
2022
+
2023
+ reporter.close()
2024
+ process.stdout.write(result)
2025
+
2026
+ if (!result.endsWith('\n')) {
2027
+ process.stdout.write('\n')
2028
+ }
2029
+ }
2030
+
933
2031
  async function handleTranscription(argv) {
934
2032
  const args = parseTranscriptionArgs(argv)
935
2033
  const reporter = createProgressReporter(args.progressFormat)
2034
+ let runContext = null
2035
+
936
2036
  try {
937
2037
  const storedConfig = await readStoredConfig()
938
2038
  const resolvedApiKey = resolveApiKey(args.apiKey, storedConfig)
@@ -942,75 +2042,62 @@ async function handleTranscription(argv) {
942
2042
  fail('缺少 API key。请传 --api-key、设置 DEYO_API_KEY,或先执行 deyo auth login')
943
2043
  }
944
2044
 
945
- if (!args.url) {
2045
+ if (!args.url && !args.file) {
946
2046
  printUsage()
947
- fail('缺少内容链接')
2047
+ fail('缺少内容链接或本地文件')
948
2048
  }
949
2049
 
950
2050
  const { baseUrl } = resolveBaseUrl(args.baseUrl, storedConfig)
951
- const format = inferFormat(args.output, args.format)
952
- const createPayload = {
953
- url: args.url,
954
- language: args.language || DEFAULT_LANGUAGE,
955
- }
2051
+ const input = await resolveTranscriptionInput(args)
956
2052
 
957
- if (args.source) {
958
- createPayload.source = args.source
2053
+ if (!input) {
2054
+ printUsage()
2055
+ fail('缺少内容链接或本地文件')
959
2056
  }
960
2057
 
961
- const created = await requestJson(baseUrl, resolvedApiKey.apiKey, '/api/cli/transcriptions', {
962
- method: 'POST',
963
- body: createPayload,
964
- })
965
- reporter.reportTaskCreated(created)
966
-
967
- let task = created
968
- let lastStatus = created.status
2058
+ const format = inferFormat(args.output, args.format)
2059
+ runContext = createRunContext(baseUrl, resolvedApiKey.apiKey, reporter)
2060
+ let createPayload
2061
+ let created
969
2062
 
970
- if (task.mode === 'transcription' && !TERMINAL_STATUSES.has(task.status)) {
971
- try {
972
- task = await consumeTaskEvents(baseUrl, resolvedApiKey.apiKey, task.taskId, task, reporter)
973
- lastStatus = task.status
974
- } catch (error) {
975
- reporter.reportNotice(`事件流中断,回退到轮询状态:${error instanceof Error ? error.message : String(error)}`)
2063
+ if (input.kind === 'file') {
2064
+ if (args.source && args.source !== 'upload') {
2065
+ fail('本地文件转写不支持 --source;上传来源会固定为 upload')
976
2066
  }
977
- }
978
2067
 
979
- while (!TERMINAL_STATUSES.has(task.status)) {
980
- await sleep(POLL_INTERVAL_MS)
981
- task = await requestJson(baseUrl, resolvedApiKey.apiKey, `/api/cli/transcriptions/${encodeURIComponent(task.taskId)}`)
982
-
983
- if (task.status !== lastStatus) {
984
- if (TERMINAL_STATUSES.has(task.status)) {
985
- reporter.reportTerminal(task.status, task.error || null)
986
- } else {
987
- reporter.reportStatusChanged(task.status)
988
- }
989
- lastStatus = task.status
2068
+ const checkResult = await uploadLocalFile(
2069
+ input,
2070
+ args,
2071
+ baseUrl,
2072
+ resolvedApiKey.apiKey,
2073
+ reporter,
2074
+ runContext,
2075
+ )
2076
+ const checkedTask = getUploadTaskFromCheckResult(checkResult)
2077
+
2078
+ if (checkedTask) {
2079
+ runContext.state.taskId = checkedTask.taskId || ''
2080
+ runContext.state.taskCreated = true
2081
+ reporter.reportTaskCreated(checkedTask)
2082
+ created = checkedTask
2083
+ } else {
2084
+ throw new Error('媒体检查通过但服务端没有返回上传转写任务')
990
2085
  }
2086
+ } else {
2087
+ createPayload = buildUrlTaskCreatePayload(args, input)
991
2088
  }
992
2089
 
993
- reporter.reportTerminal(task.status, task.error || null)
994
-
995
- if (task.status !== 'completed') {
996
- throw new ReportedCliError(task.error || `任务未成功完成:${formatStatus(task.status)}`)
2090
+ if (!created) {
2091
+ created = await createCliTask(baseUrl, resolvedApiKey.apiKey, createPayload, reporter, runContext)
997
2092
  }
998
2093
 
999
- const result = await requestResult(baseUrl, resolvedApiKey.apiKey, task.taskId, format)
1000
-
1001
- if (args.output) {
1002
- const outputPath = await writeOutput(args.output, result)
1003
- reporter.reportResultWritten(outputPath, format)
1004
- return
2094
+ await waitForTaskAndOutput(baseUrl, resolvedApiKey.apiKey, created, args, format, reporter, runContext)
2095
+ } catch (error) {
2096
+ if (runContext?.state.interrupting || isAbortError(error)) {
2097
+ await runContext?.state.interruptPromise?.catch(() => {})
2098
+ throw new ReportedCliError('已中断', 130)
1005
2099
  }
1006
2100
 
1007
- reporter.close()
1008
- process.stdout.write(result)
1009
-
1010
- if (!result.endsWith('\n')) {
1011
- process.stdout.write('\n')
1012
- }
1013
- } catch (error) {
1014
2101
  if (error instanceof ReportedCliError) {
1015
2102
  throw error
1016
2103
  }
@@ -1021,6 +2108,8 @@ async function handleTranscription(argv) {
1021
2108
  }
1022
2109
 
1023
2110
  throw error
2111
+ } finally {
2112
+ runContext?.dispose()
1024
2113
  }
1025
2114
  }
1026
2115
 
@@ -1048,7 +2137,7 @@ async function main() {
1048
2137
 
1049
2138
  main().catch((error) => {
1050
2139
  if (error instanceof ReportedCliError) {
1051
- process.exit(1)
2140
+ process.exit(error.exitCode || 1)
1052
2141
  }
1053
2142
 
1054
2143
  const message = error instanceof Error ? error.message : String(error)