mediatuna 1.21.11

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.
package/index.js ADDED
@@ -0,0 +1,804 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import readline from 'readline/promises';
5
+ import { fileURLToPath } from 'url';
6
+ import { stdin as input, stdout as output } from 'process';
7
+ import { parseArgs } from 'node:util';
8
+ import cliProgress from 'cli-progress';
9
+ import { buildCliConfig, CLI_PARSE_OPTIONS, CliConfigError } from './lib/cli-config.js';
10
+ import {
11
+ buildCleanupCandidates,
12
+ deleteOriginalFiles,
13
+ formatDeletionPlanLines,
14
+ } from './lib/cleanup.js';
15
+ import { detectNvenc } from './lib/encode.js';
16
+ import { resolveEffectiveJobs } from './lib/jobs.js';
17
+ import { createLogger, ensureLogDir } from './lib/log.js';
18
+ import { buildPreflightEntries, buildPreflightTableLines } from './lib/preflight.js';
19
+ import {
20
+ buildModeParts,
21
+ loadInputFiles,
22
+ mediaModeHint,
23
+ resolveInputFiles,
24
+ formatUnknownExtensionError,
25
+ warnUnknownExtension,
26
+ } from './lib/resolve-inputs.js';
27
+ import { runConversion } from './lib/run.js';
28
+ import {
29
+ applyResumeToPreflight,
30
+ buildRunKey,
31
+ createResumeState,
32
+ defaultStatePath,
33
+ loadResumeState,
34
+ markCompleted,
35
+ saveResumeState,
36
+ } from './lib/resume-state.js';
37
+ import { runDupeReport } from './lib/dupe-report.js';
38
+ import { archiveDestPath, formatArchivePlanLines, moveOriginalsToArchive } from './lib/archive.js';
39
+ import { collectRecupCleanup, discoverRecupFiles, parseExtList, runRecupMap } from './lib/recup-map.js';
40
+ import { runStampDates } from './lib/stamp-dates.js';
41
+ import { isConvertStatus } from './lib/status.js';
42
+ import { requireTools as missingTools } from './lib/tools.js';
43
+ import { getMetadata } from './lib/probe.js';
44
+ import { verifyOutput } from './lib/verify.js';
45
+ import {
46
+ LARGE_BATCH_THRESHOLD,
47
+ STAMP_BACKUP_WARN_THRESHOLD,
48
+ LOSSY_BANNER,
49
+ checkFreeSpace,
50
+ countOverwriteTargets,
51
+ estimateNeededBytes,
52
+ formatDiskSpaceError,
53
+ formatForceOverwritePrompt,
54
+ formatLargeBatchPrompt,
55
+ formatStampBackupWarn,
56
+ formatWriteLocationBanner,
57
+ shouldShowLossyBanner,
58
+ } from './lib/safety.js';
59
+
60
+ const HELP = `MediaTuna — batch-convert a home media archive to MP4 and MP3
61
+ (keeps dates, tags, and already-finished work)
62
+
63
+ Usage: mediatuna [path] [options]
64
+
65
+ Arguments:
66
+ path File or folder to convert (default: current directory)
67
+
68
+ Options:
69
+ -h, --help Show this help
70
+ -V, --version Show version and script path
71
+ --dry-run Preview actions without encoding
72
+ --recursive Scan subfolders
73
+ --flat Scan top-level folder only (default)
74
+ --video-only Process video files only
75
+ --audio-only Process audio files only
76
+ --output <folder> Write outputs here, keeping source subfolders
77
+ --include <glob> Only files matching this glob (repeatable; basename or relative path)
78
+ --exclude <glob> Skip files matching this glob (repeatable)
79
+ --archive <folder> After verify: move sources here (safer than delete)
80
+ --sample <seconds> Encode only the first N seconds to *.sample.mp4 / *.sample.mp3
81
+ --log <file> Append log to this file (default: ./mediatuna-log.txt)
82
+ --no-master-log Do not mirror log to ~/.mediatuna/history.log
83
+ --master-log <file> Custom master log path (still mirrors run log)
84
+ --delete-originals After conversion: trash sources that converted successfully (interactive)
85
+ --cleanup-originals Trash sources whose outputs already exist (convert, or recup-map after --apply)
86
+ --delete-permanent With delete/cleanup: unlink instead of Recycle Bin / trash (type DELETE)
87
+ --yes Skip large-batch, --force overwrite, disk-space, and stamp-backup prompts
88
+ --quality <preset> high | medium | fast (default: medium; video + default audio)
89
+ --audio-quality <preset> Audio LAME preset (default: same as --quality)
90
+ --reencode-audio Always re-encode video audio to AAC 192k (default: copy when source is AAC LC)
91
+ --extract-audio Also write MP3 from video files (audio track only)
92
+ --deinterlace <mode> auto | on | off (default: auto; video only)
93
+ --no-verify Skip post-encode output verification
94
+ --keep-partial Keep incomplete output on encode failure
95
+ --force Overwrite existing outputs
96
+ --resume Skip files completed in a prior run (uses .mediatuna-state.json)
97
+ --jobs <N> Encode up to N files in parallel (default: 1; NVENC: try 3–4)
98
+ --verbose Show per-file details on console (default: quiet)
99
+ --prefer-mtime Use file mtime when tags have no date (MP3 tag, or MTIME_ filename stamp)
100
+ --embed-art Embed album cover in MP3 when present (default)
101
+ --no-embed-art Skip embedding album cover in MP3
102
+ --stamp-dates Rename sources: metadata date, or normalize to YYYY-MM-DD_HH-MM-SS
103
+ --no-stamp-dates Do not prefix video MP4 names with creation date
104
+ --backup <folder> With --stamp-dates: copy originals here before renaming
105
+ --dupe-report Ask Everything where else each file exists (name+size, then size-only)
106
+ --hash With --dupe-report: confirm size-only hits. With --recup-map --apply: copy only SHA-256 matches
107
+ --recup-map Map a PhotoRec-style dump to folders using copies found elsewhere
108
+ --ext <list> With --recup-map: extensions (default: audio + phone video)
109
+ --apply With --recup-map: copy placed files into proposed-tree/
110
+
111
+ Video formats: AVI, MOV, MOD, VOB, MTS, M2TS, MPG, MPEG, WMV, 3GP, 3G2 → MP4
112
+ Audio formats: MP3, FLAC, WAV, AIFF, M4A, AAC, OGG, Opus, WMA, AC3, DTS, AMR, QCP → MP3
113
+
114
+ Requires ffmpeg and ffprobe on PATH. --dupe-report and --recup-map need Everything (es.exe) running.
115
+
116
+ First archive: --dry-run, then --output to a separate folder. Try --sample 20 before a full run. Deletes go to Recycle Bin unless --delete-permanent. --archive moves sources after verify.
117
+ Size/name matches are not byte-identical. --hash is SHA-256 of the whole file. Convert --verify is duration only.
118
+
119
+ Exit codes: 0 success, 1 encode/read failures, 2 usage or missing dependencies, 130 interrupted
120
+ `;
121
+
122
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
123
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
124
+
125
+ function parseCli() {
126
+ try {
127
+ const { values, positionals } = parseArgs({
128
+ args: process.argv.slice(2),
129
+ options: CLI_PARSE_OPTIONS,
130
+ allowPositionals: true,
131
+ strict: true,
132
+ });
133
+
134
+ if (values.help) {
135
+ console.log(HELP);
136
+ process.exit(0);
137
+ }
138
+
139
+ if (values.version) {
140
+ console.log(`mediatuna ${pkg.version}`);
141
+ console.log(`Script: ${fileURLToPath(import.meta.url)}`);
142
+ process.exit(0);
143
+ }
144
+
145
+ return buildCliConfig(values, positionals);
146
+ } catch (err) {
147
+ if (err.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
148
+ console.error(`Error: ${err.message}`);
149
+ console.error('Run mediatuna --help for usage.');
150
+ process.exit(2);
151
+ }
152
+ if (err instanceof CliConfigError) {
153
+ console.error(`Error: ${err.message}`);
154
+ process.exit(2);
155
+ }
156
+ throw err;
157
+ }
158
+ }
159
+
160
+ function requireFfmpeg() {
161
+ const missing = missingTools();
162
+ if (missing.length > 0) {
163
+ console.error(`Error: required tools not found on PATH: ${missing.join(', ')}`);
164
+ console.error('Install ffmpeg (includes ffprobe) and ensure it is on PATH.');
165
+ process.exit(2);
166
+ }
167
+ }
168
+
169
+ const cli = parseCli();
170
+ if (!cli.dupeReport && !cli.recupMap) requireFfmpeg();
171
+
172
+ const {
173
+ target: arg, recursive, dryRun, force, outputDir, quality, deinterlace,
174
+ verify, keepPartial, verbose, mediaMode, preferMtime, embedArt,
175
+ deleteOriginals, cleanupOriginals, audioQuality, extractAudio, resume, jobs: requestedJobs,
176
+ stampDates, stampVideo, backupDir, dupeReport, dupeHash, recupMap, recupExt, recupApply,
177
+ yes, deletePermanent, include, exclude, archiveDir, sampleSeconds, reencodeAudio,
178
+ } = cli;
179
+
180
+ const LOG_FILE = cli.logFile;
181
+ const MASTER_LOG_FILE = cli.masterLogFile;
182
+ const MASTER_LOG_ENABLED = cli.masterLogEnabled;
183
+ const STATE_PATH = defaultStatePath(LOG_FILE);
184
+ const runKey = buildRunKey({
185
+ outputDir, quality, audioQuality, deinterlace, mediaMode, extractAudio, verify, stampVideo, reencodeAudio,
186
+ });
187
+ let resumeState = createResumeState(runKey);
188
+ const FAILED_REPORT = path.join(path.dirname(LOG_FILE), 'mediatuna-failed.txt');
189
+
190
+ ensureLogDir(LOG_FILE);
191
+ if (MASTER_LOG_ENABLED) ensureLogDir(MASTER_LOG_FILE);
192
+
193
+ let multibar = null;
194
+ const activeProcs = new Set();
195
+ let shuttingDown = false;
196
+
197
+ const logger = createLogger({
198
+ logFile: LOG_FILE,
199
+ masterLogFile: MASTER_LOG_FILE,
200
+ masterLogEnabled: MASTER_LOG_ENABLED,
201
+ verbose,
202
+ getMultibar: () => multibar,
203
+ });
204
+
205
+ const { logFile, logConsole, logVerbose } = logger;
206
+
207
+ async function promptLine(question) {
208
+ const rl = readline.createInterface({ input, output });
209
+ try {
210
+ return (await rl.question(question)).trim();
211
+ } finally {
212
+ rl.close();
213
+ }
214
+ }
215
+
216
+ function printDeletionPlan(candidates, opts) {
217
+ logger.printLines(formatDeletionPlanLines(candidates, opts));
218
+ }
219
+
220
+ function isTty() {
221
+ return Boolean(input.isTTY && output.isTTY);
222
+ }
223
+
224
+ function deleteFate() {
225
+ return deletePermanent
226
+ ? 'PERMANENTLY DELETED (not sent to Recycle Bin / trash)'
227
+ : 'moved to the Recycle Bin / trash';
228
+ }
229
+
230
+ async function confirmContinue(promptText, { flagLabel, allowEnter = false } = {}) {
231
+ if (yes) return true;
232
+ if (!isTty()) {
233
+ console.error(`Error: ${flagLabel} requires an interactive terminal or --yes.`);
234
+ process.exit(2);
235
+ }
236
+ const line = await promptLine(promptText);
237
+ if (allowEnter && line === '') return true;
238
+ return /^y(es)?$/i.test(line);
239
+ }
240
+
241
+ async function confirmDeletion(candidates, { flagLabel, intro, countLabel, firstPrompt, confirmedMessage }) {
242
+ if (!isTty()) {
243
+ console.error(`Error: ${flagLabel} requires an interactive terminal.`);
244
+ process.exit(2);
245
+ }
246
+
247
+ printDeletionPlan(candidates, { intro, countLabel });
248
+
249
+ const first = await promptLine(firstPrompt);
250
+ if (!/^y(es)?$/i.test(first)) return false;
251
+
252
+ if (deletePermanent) {
253
+ const second = await promptLine('Type DELETE to confirm permanent deletion: ');
254
+ if (second !== 'DELETE') return false;
255
+ }
256
+
257
+ logConsole(confirmedMessage);
258
+ return true;
259
+ }
260
+
261
+ function printDeletePlan(candidates, dryRunFlag = false) {
262
+ const intro = dryRunFlag
263
+ ? `--delete-originals: sources below would be converted and then ${deleteFate()}.`
264
+ : `Conversion finished. Sources below were converted successfully and will be ${deleteFate()}.`;
265
+ printDeletionPlan(candidates, {
266
+ intro,
267
+ countLabel: dryRunFlag ? 'eligible for deletion after success' : 'ready to delete',
268
+ dryRun: dryRunFlag,
269
+ });
270
+ }
271
+
272
+ async function runCleanupOriginals(preflight, dryRunFlag) {
273
+ const start = Date.now();
274
+ logConsole('Verifying existing outputs before cleanup...');
275
+
276
+ const { eligible, skipped } = buildCleanupCandidates(preflight, verifyOutput);
277
+
278
+ if (skipped.length > 0) {
279
+ logConsole(`${skipped.length} file(s) not eligible for cleanup (no output or verification failed).`);
280
+ for (const { entry, reason } of skipped) {
281
+ logFile(`Cleanup skip: ${entry.input} (${reason})`);
282
+ if (verbose) logConsole(`Cleanup skip: ${path.basename(entry.input)} (${reason})`);
283
+ }
284
+ }
285
+
286
+ if (eligible.length === 0) {
287
+ logConsole('No verified outputs found; --cleanup-originals has nothing to delete.');
288
+ process.exit(0);
289
+ }
290
+
291
+ if (archiveDir) {
292
+ const archiveCandidates = eligible.map(entry => ({
293
+ input: entry.input,
294
+ dest: archiveDestPath(entry.input, archiveDir, { rootDir: resolveSourceRoot() }),
295
+ }));
296
+ if (dryRunFlag) {
297
+ logger.printLines(formatArchivePlanLines(archiveCandidates, { archiveDir, dryRun: true }));
298
+ logConsole(`=== Would archive ${eligible.length} original(s) (dry-run) ===`);
299
+ process.exit(0);
300
+ }
301
+ const confirmed = await confirmDeletion(eligible, {
302
+ flagLabel: '--archive',
303
+ intro: `--cleanup-originals --archive: sources below will be moved to ${archiveDir} because their output already exists and passed duration verification.`,
304
+ countLabel: 'ready to archive',
305
+ firstPrompt: 'Move these originals to the archive folder now? [y/N]: ',
306
+ confirmedMessage: 'Archive confirmed.',
307
+ });
308
+ if (!confirmed) {
309
+ logConsole('Archive cancelled.');
310
+ process.exit(0);
311
+ }
312
+ const { moved, failed } = moveOriginalsToArchive(eligible.map(e => e.input), archiveDir, {
313
+ rootDir: resolveSourceRoot(),
314
+ logConsole,
315
+ });
316
+ const mins = ((Date.now() - start) / 1000 / 60).toFixed(1);
317
+ logConsole(`=== Cleanup archive complete: ${moved} moved, ${failed} error(s), ${mins} minutes ===`);
318
+ process.exit(failed > 0 ? 1 : 0);
319
+ }
320
+
321
+ const intro = `--cleanup-originals: sources below will be ${deleteFate()} because their output already exists and passed duration verification.\nOutputs are kept; only sources are removed.`;
322
+
323
+ if (dryRunFlag) {
324
+ printDeletionPlan(eligible, { intro, countLabel: 'eligible for cleanup', dryRun: true });
325
+ logConsole(`=== Would delete ${eligible.length} original(s) (dry-run) ===`);
326
+ process.exit(0);
327
+ }
328
+
329
+ const confirmed = await confirmDeletion(eligible, {
330
+ flagLabel: '--cleanup-originals',
331
+ intro,
332
+ countLabel: 'eligible for cleanup',
333
+ firstPrompt: deletePermanent ? 'Delete these originals now? [y/N]: ' : 'Move these originals to trash now? [y/N]: ',
334
+ confirmedMessage: 'Cleanup confirmed.',
335
+ });
336
+
337
+ if (!confirmed) {
338
+ logConsole('Cleanup cancelled.');
339
+ process.exit(0);
340
+ }
341
+
342
+ const { deleted, deleteFailed } = await deleteOriginalFiles(eligible.map(e => e.input), logConsole, {
343
+ permanent: deletePermanent,
344
+ });
345
+ const mins = ((Date.now() - start) / 1000 / 60).toFixed(1);
346
+ logConsole(`=== Cleanup complete: ${deleted} deleted, ${deleteFailed} error(s), ${mins} minutes ===`);
347
+ process.exit(deleteFailed > 0 ? 1 : 0);
348
+ }
349
+
350
+ function resolveSourceRoot() {
351
+ if (resolved.mode === 'folder') return resolved.targetPath;
352
+ return path.dirname(resolved.files[0]);
353
+ }
354
+
355
+ function cleanupProgress() {
356
+ if (multibar?.isActive) multibar.stop();
357
+ }
358
+
359
+ process.on('SIGINT', () => {
360
+ if (shuttingDown) process.exit(130);
361
+ shuttingDown = true;
362
+ console.error('\nInterrupted.');
363
+ for (const proc of activeProcs) proc.kill('SIGTERM');
364
+ cleanupProgress();
365
+ process.exit(130);
366
+ });
367
+
368
+ function printPreflightTable(entries, dryRunFlag) {
369
+ const { lines } = buildPreflightTableLines(entries, dryRunFlag);
370
+ logger.printLines(lines);
371
+ }
372
+
373
+ const resolved = resolveInputFiles({ target: arg, recursive, mediaMode });
374
+ if (resolved.error) {
375
+ console.error(`Error: ${resolved.error}`);
376
+ process.exit(2);
377
+ }
378
+
379
+ if (recupMap) {
380
+ const rootDir = resolved.mode === 'folder' ? resolved.targetPath : path.dirname(resolved.files[0]);
381
+ const extSet = parseExtList(recupExt);
382
+ const startRecup = Date.now();
383
+ logConsole(`MediaTuna: recup-map | ${[...extSet].sort().join(',')}${recupApply ? ' | apply' : ''}${dupeHash ? ' | hash' : ''}${cleanupOriginals ? ' | cleanup-originals' : ''}${dryRun ? ' | dry-run' : ''}`);
384
+ const recupFiles = discoverRecupFiles(rootDir, extSet);
385
+ if ((recupApply || cleanupOriginals) && recupFiles.length > LARGE_BATCH_THRESHOLD && !dryRun) {
386
+ logConsole(`Large batch: ${recupFiles.length} recup files under ${rootDir}.`);
387
+ if (!await confirmContinue(formatLargeBatchPrompt({ count: recupFiles.length, root: rootDir }), {
388
+ flagLabel: 'large batch',
389
+ allowEnter: true,
390
+ })) {
391
+ logConsole('Cancelled.');
392
+ process.exit(0);
393
+ }
394
+ }
395
+ try {
396
+ const { stats, reportPath, treeDir, rows } = await runRecupMap({
397
+ rootDir,
398
+ extSet,
399
+ dryRun,
400
+ apply: recupApply,
401
+ hash: dupeHash,
402
+ onProgress: (i, total) => {
403
+ if (i === 1 || i === total || i % 100 === 0) {
404
+ logConsole(` mapped ${i}/${total}`);
405
+ }
406
+ },
407
+ });
408
+ if (reportPath) logConsole(`Recup map: ${reportPath}`);
409
+ if (treeDir) logConsole(`Proposed tree: ${treeDir}`);
410
+ let deleted = 0;
411
+ let deleteFailed = 0;
412
+ if (cleanupOriginals) {
413
+ const cleanupTree = treeDir ?? path.join(rootDir, 'proposed-tree');
414
+ const eligible = await collectRecupCleanup(rows, cleanupTree, { rootDir });
415
+ const intro = `--cleanup-originals: recup sources below will be ${deleteFate()} because a SHA-256 match already exists in proposed-tree/.\nSize-only is not enough. The tree and gold copies are kept; only recup_dir files are removed.`;
416
+ if (eligible.length === 0) {
417
+ logConsole('No placed recup files have a SHA-256 match in proposed-tree; nothing to delete.');
418
+ } else if (dryRun) {
419
+ printDeletionPlan(eligible, { intro, countLabel: 'eligible for cleanup', dryRun: true });
420
+ logConsole(`=== Would delete ${eligible.length} recup original(s) (dry-run) ===`);
421
+ } else {
422
+ const confirmed = await confirmDeletion(eligible, {
423
+ flagLabel: '--cleanup-originals',
424
+ intro,
425
+ countLabel: 'eligible for cleanup',
426
+ firstPrompt: deletePermanent ? 'Delete these recup originals now? [y/N]: ' : 'Move these recup originals to trash now? [y/N]: ',
427
+ confirmedMessage: 'Cleanup confirmed.',
428
+ });
429
+ if (!confirmed) {
430
+ logConsole('Cleanup cancelled.');
431
+ } else {
432
+ ({ deleted, deleteFailed } = await deleteOriginalFiles(eligible.map(e => e.input), logConsole, {
433
+ permanent: deletePermanent,
434
+ }));
435
+ }
436
+ }
437
+ }
438
+ const mins = ((Date.now() - startRecup) / 1000 / 60).toFixed(1);
439
+ const deleteNote = cleanupOriginals ? `, ${deleted} deleted, ${deleteFailed} delete-failed` : '';
440
+ logConsole(`=== Recup map complete: ${stats.placed} placed, ${stats.ambiguous} ambiguous, ${stats.unmatched} unmatched, ${stats.copied ?? 0} copied, ${stats.skipped ?? 0} skipped${deleteNote}, ${stats.errors} errors, ${mins} minutes ===`);
441
+ process.exit(stats.errors > 0 || deleteFailed > 0 ? 1 : 0);
442
+ } catch (err) {
443
+ console.error(`Error: ${err.message}`);
444
+ process.exit(2);
445
+ }
446
+ }
447
+
448
+ let files = await loadInputFiles(resolved, mediaMode, {
449
+ stampDates: stampDates || dupeReport,
450
+ include,
451
+ exclude,
452
+ });
453
+
454
+ const extWarn = warnUnknownExtension({ ...resolved, files }, mediaMode, { stampDates: stampDates || dupeReport });
455
+ if (extWarn) {
456
+ console.error(`Error: ${formatUnknownExtensionError(extWarn)}`);
457
+ process.exit(2);
458
+ }
459
+ if (resolved.mode === 'single') {
460
+ logFile(`Single file mode: ${path.basename(resolved.files[0])}`);
461
+ } else {
462
+ logFile(`Scanning folder: ${resolved.targetPath}`);
463
+ if (recursive) logFile(' (recursive mode)');
464
+ }
465
+
466
+ if (files.length === 0) {
467
+ logConsole(`No ${mediaModeHint(resolved)} found. Try --recursive.`);
468
+ process.exit(0);
469
+ }
470
+
471
+ const readOnlyHelper = dupeReport || (recupMap && !recupApply && !cleanupOriginals);
472
+ if (!readOnlyHelper && files.length > LARGE_BATCH_THRESHOLD) {
473
+ const root = resolved.mode === 'folder' ? resolved.targetPath : path.dirname(files[0]);
474
+ logConsole(`Large batch: ${files.length} files under ${root}.`);
475
+ if (!dryRun && !await confirmContinue(formatLargeBatchPrompt({ count: files.length, root }), {
476
+ flagLabel: 'large batch',
477
+ allowEnter: true,
478
+ })) {
479
+ logConsole('Cancelled.');
480
+ process.exit(0);
481
+ }
482
+ }
483
+
484
+ if (dupeReport) {
485
+ const startDupe = Date.now();
486
+ logConsole(`MediaTuna: ${files.length} files | dupe-report${dupeHash ? ' | hash' : ''}${dryRun ? ' | dry-run' : ''}`);
487
+ try {
488
+ const { lines, stats, reportPath } = await runDupeReport({
489
+ files,
490
+ rootDir: resolved.mode === 'folder' ? resolved.targetPath : path.dirname(files[0]),
491
+ hash: dupeHash,
492
+ dryRun,
493
+ });
494
+ logger.printLines(lines);
495
+ if (reportPath) logConsole(`Dupe report: ${reportPath}`);
496
+ const mins = ((Date.now() - startDupe) / 1000 / 60).toFixed(1);
497
+ logConsole(`=== Dupe report complete: ${stats.nameCopies} name+size, ${stats.sizeOnly} size-only, ${stats.unique} unique, ${stats.errors} errors, ${mins} minutes ===`);
498
+ process.exit(stats.errors > 0 ? 1 : 0);
499
+ } catch (err) {
500
+ console.error(`Error: ${err.message}`);
501
+ process.exit(2);
502
+ }
503
+ }
504
+
505
+ if (stampDates) {
506
+ const startStamp = Date.now();
507
+ const stampMode = ['stamp-dates'];
508
+ if (preferMtime) stampMode.push('prefer-mtime');
509
+ if (dryRun) stampMode.push('dry-run');
510
+ logConsole(`MediaTuna: ${files.length} files | ${stampMode.join(' | ')}`);
511
+ if (backupDir) {
512
+ logConsole(`Backup folder: ${backupDir}`);
513
+ } else if (!dryRun && files.length > STAMP_BACKUP_WARN_THRESHOLD) {
514
+ if (!await confirmContinue(formatStampBackupWarn(files.length), {
515
+ flagLabel: '--stamp-dates',
516
+ allowEnter: true,
517
+ })) {
518
+ logConsole('Cancelled.');
519
+ process.exit(0);
520
+ }
521
+ }
522
+
523
+ const { result } = runStampDates({
524
+ files,
525
+ probeFn: getMetadata,
526
+ preferMtime,
527
+ dryRun,
528
+ backupDir,
529
+ rootDir: resolved.mode === 'folder' ? resolved.targetPath : path.dirname(files[0]),
530
+ logger,
531
+ });
532
+
533
+ const mins = ((Date.now() - startStamp) / 1000 / 60).toFixed(1);
534
+ const dryLabel = dryRun ? ' (dry-run)' : '';
535
+ const doneLabel = dryRun ? 'would rename' : 'renamed';
536
+ logConsole(`=== Stamp dates complete${dryLabel}: ${result.renamed} ${doneLabel}, ${result.skipped} skipped, ${result.failed} failed, ${mins} minutes ===`);
537
+ process.exit(result.failed > 0 ? 1 : 0);
538
+ }
539
+
540
+ const nvenc = mediaMode.video && !cleanupOriginals ? detectNvenc() : false;
541
+ const jobs = resolveEffectiveJobs(requestedJobs, { nvenc, mediaMode });
542
+
543
+ logFile(`Log file: ${LOG_FILE}`);
544
+ if (MASTER_LOG_ENABLED) logFile(`Master log: ${MASTER_LOG_FILE}`);
545
+ if (jobs > 1 && !dryRun && !cleanupOriginals) {
546
+ logConsole(`Parallel: ${jobs} concurrent file encode(s)${nvenc ? ' (NVENC)' : ' (CPU)'}.`);
547
+ if (nvenc && requestedJobs > jobs) {
548
+ logFile(`Note: --jobs ${requestedJobs} capped to ${jobs} for CPU encode path.`);
549
+ }
550
+ }
551
+
552
+ const modeParts = buildModeParts({
553
+ cleanupOriginals, stampVideo, combinedMode: resolved.combinedMode, audioOnlyMode: resolved.audioOnlyMode,
554
+ nvenc, quality, deinterlace, mediaMode, preferMtime, embedArt, extractAudio, audioQuality,
555
+ verify, deleteOriginals, deletePermanent, dryRun, resume, jobs,
556
+ archiveDir, sampleSeconds, reencodeAudio,
557
+ });
558
+ logConsole(`MediaTuna: ${files.length} files | ${modeParts.join(' | ')}`);
559
+ const sourceDir = resolved.mode === 'folder' ? resolved.targetPath : path.dirname(files[0]);
560
+ logConsole(formatWriteLocationBanner({
561
+ count: files.length,
562
+ outputDir,
563
+ sourceDir,
564
+ }));
565
+
566
+ let preflight = await buildPreflightEntries(files, outputDir, force, mediaMode, {
567
+ audioQuality,
568
+ extractAudio,
569
+ stampVideo,
570
+ preferMtime,
571
+ rootDir: resolved.mode === 'folder' ? resolved.targetPath : null,
572
+ sampleSeconds,
573
+ onProbeProgress: (i, total) => {
574
+ if (total > 1) process.stderr.write(`\rProbing ${i}/${total}...`);
575
+ },
576
+ });
577
+ if (files.length > 1) process.stderr.write('\r' + ' '.repeat(40) + '\r');
578
+
579
+ if (resume) {
580
+ const loaded = loadResumeState(STATE_PATH);
581
+ if (!loaded) {
582
+ logConsole('Resume: no saved state found; converting all files.');
583
+ } else if (loaded.runKey !== runKey) {
584
+ logConsole('Resume: saved state does not match current options; ignoring prior progress.');
585
+ } else {
586
+ resumeState = loaded;
587
+ const applied = applyResumeToPreflight(preflight, resumeState, {
588
+ resume: true,
589
+ force,
590
+ mediaMode,
591
+ extractAudio,
592
+ verifyOutputFn: verifyOutput,
593
+ });
594
+ preflight = applied.entries;
595
+ if (applied.resumed > 0) {
596
+ logConsole(`Resume: skipping ${applied.resumed} file(s) already completed (state: ${STATE_PATH}).`);
597
+ }
598
+ }
599
+ } else if (!dryRun && !cleanupOriginals) {
600
+ logFile(`Resume state: ${STATE_PATH}`);
601
+ }
602
+
603
+ printPreflightTable(preflight, dryRun);
604
+
605
+ if (shouldShowLossyBanner(preflight) && !cleanupOriginals) {
606
+ logConsole(LOSSY_BANNER);
607
+ }
608
+ if (sampleSeconds) {
609
+ logConsole(`Sample mode: encoding the first ${sampleSeconds}s to *.sample.mp4 / *.sample.mp3 (full outputs are not written).`);
610
+ }
611
+
612
+ if (!verify && !cleanupOriginals) {
613
+ logConsole('Warning: --no-verify is on. Outputs are unchecked; resume and deletes stay blocked.');
614
+ }
615
+
616
+ if (!dryRun && !cleanupOriginals) {
617
+ const spaceDir = outputDir && fs.existsSync(outputDir)
618
+ ? outputDir
619
+ : outputDir
620
+ ? path.dirname(outputDir)
621
+ : sourceDir;
622
+ try {
623
+ const space = checkFreeSpace(spaceDir, estimateNeededBytes(preflight, { sampleSeconds }));
624
+ if (!space.ok) {
625
+ const message = formatDiskSpaceError(space);
626
+ if (yes) logConsole(`Warning: ${message}`);
627
+ else {
628
+ console.error(`Error: ${message}`);
629
+ process.exit(2);
630
+ }
631
+ }
632
+ } catch (err) {
633
+ logConsole(`Warning: could not check free space (${err.message}).`);
634
+ }
635
+ }
636
+
637
+ if (force && !dryRun && !cleanupOriginals) {
638
+ const overwriteCount = countOverwriteTargets(preflight);
639
+ if (overwriteCount > 1) {
640
+ logConsole(`--force will overwrite ${overwriteCount} existing output(s):`);
641
+ for (const entry of preflight) {
642
+ if (isConvertStatus(entry.videoStatus) && fs.existsSync(entry.out)) {
643
+ logFile(` overwrite ${entry.out}`);
644
+ }
645
+ if (entry.extractStatus && isConvertStatus(entry.extractStatus) && entry.audioOut && fs.existsSync(entry.audioOut)) {
646
+ logFile(` overwrite ${entry.audioOut}`);
647
+ }
648
+ }
649
+ if (!await confirmContinue(formatForceOverwritePrompt(overwriteCount), { flagLabel: '--force' })) {
650
+ logConsole('Cancelled.');
651
+ process.exit(0);
652
+ }
653
+ } else if (overwriteCount === 1) {
654
+ for (const entry of preflight) {
655
+ if (isConvertStatus(entry.videoStatus) && fs.existsSync(entry.out)) {
656
+ logConsole(`--force will overwrite ${entry.out}`);
657
+ }
658
+ }
659
+ }
660
+ }
661
+
662
+ if (cleanupOriginals) {
663
+ await runCleanupOriginals(preflight, dryRun);
664
+ }
665
+
666
+ multibar = dryRun ? null : new cliProgress.MultiBar({
667
+ barCompleteChar: '█',
668
+ barIncompleteChar: '░',
669
+ hideCursor: true,
670
+ clearOnComplete: false,
671
+ stopOnComplete: false,
672
+ forceRedraw: true,
673
+ });
674
+
675
+ const overallBar = !dryRun && files.length > 1
676
+ ? multibar.create(files.length, 0, {}, {
677
+ format: 'Overall [{bar}] {percentage}% | {value}/{total} files',
678
+ clearOnComplete: false,
679
+ })
680
+ : null;
681
+
682
+ const start = Date.now();
683
+ let resumeSaveChain = Promise.resolve();
684
+
685
+ const { stats, failedPaths, convertedInputs } = await runConversion({
686
+ preflight,
687
+ config: {
688
+ dryRun, verify, keepPartial, quality, audioQuality, deinterlace, nvenc,
689
+ preferMtime, embedArt, extractAudio, mediaMode, verbose, jobs, sampleSeconds, reencodeAudio,
690
+ },
691
+ logger,
692
+ progress: {
693
+ overallBar,
694
+ registerProc(proc) {
695
+ if (proc) activeProcs.add(proc);
696
+ },
697
+ unregisterProc(proc) {
698
+ if (proc) activeProcs.delete(proc);
699
+ },
700
+ isShuttingDown: () => shuttingDown,
701
+ createFileBar: (barTotal, passName, barOptions) =>
702
+ multibar.create(barTotal, 0, { filename: passName }, {
703
+ clearOnComplete: true,
704
+ stopOnComplete: true,
705
+ ...barOptions,
706
+ }),
707
+ },
708
+ resumeState: !dryRun && !cleanupOriginals && !sampleSeconds
709
+ ? {
710
+ markCompleted(input) {
711
+ markCompleted(resumeState, input);
712
+ resumeSaveChain = resumeSaveChain.then(() => {
713
+ saveResumeState(STATE_PATH, resumeState);
714
+ });
715
+ },
716
+ }
717
+ : null,
718
+ });
719
+
720
+ await resumeSaveChain;
721
+
722
+ cleanupProgress();
723
+
724
+ if (failedPaths.length > 0) {
725
+ fs.writeFileSync(FAILED_REPORT, failedPaths.map(p => path.resolve(p)).join('\n') + '\n');
726
+ logConsole(`Failed files list: ${FAILED_REPORT}`);
727
+ }
728
+
729
+ const mins = ((Date.now() - start) / 1000 / 60).toFixed(1);
730
+ const dryLabel = dryRun ? ' (dry-run)' : '';
731
+ const doneLabel = dryRun ? 'would convert' : 'converted';
732
+ const resumedNote = stats.resumed > 0 ? `, ${stats.resumed} resumed` : '';
733
+ logConsole(`=== MediaTuna Complete${dryLabel}: ${stats.done} ${doneLabel}, ${stats.skipped} skipped${resumedNote}, ${stats.failed} failed, ${mins} minutes ===`);
734
+
735
+ let deleteFailed = 0;
736
+ if (deleteOriginals) {
737
+ const deleteCandidates = dryRun
738
+ ? preflight.filter(e =>
739
+ isConvertStatus(e.videoStatus) || (e.extractStatus && isConvertStatus(e.extractStatus)))
740
+ : preflight.filter(e => convertedInputs.includes(e.input));
741
+
742
+ if (deleteCandidates.length === 0) {
743
+ logConsole('No files converted successfully; --delete-originals has nothing to delete.');
744
+ } else if (dryRun) {
745
+ printDeletePlan(deleteCandidates, true);
746
+ logConsole(`=== Would delete up to ${deleteCandidates.length} original(s) after successful conversion (dry-run) ===`);
747
+ } else {
748
+ const confirmed = await confirmDeletion(deleteCandidates, {
749
+ flagLabel: '--delete-originals',
750
+ intro: `Conversion finished. Sources below were converted successfully and will be ${deleteFate()}.`,
751
+ countLabel: 'ready to delete',
752
+ firstPrompt: deletePermanent ? 'Delete these originals now? [y/N]: ' : 'Move these originals to trash now? [y/N]: ',
753
+ confirmedMessage: 'Delete originals confirmed.',
754
+ });
755
+ if (confirmed) {
756
+ const { deleted, deleteFailed: delFail } = await deleteOriginalFiles(convertedInputs, logConsole, {
757
+ permanent: deletePermanent,
758
+ });
759
+ deleteFailed = delFail;
760
+ logConsole(`=== Deleted ${deleted} original(s), ${delFail} delete error(s) ===`);
761
+ } else {
762
+ logConsole('Delete originals cancelled.');
763
+ }
764
+ }
765
+ }
766
+
767
+ let archiveFailed = 0;
768
+ if (archiveDir && !cleanupOriginals) {
769
+ const archiveCandidates = dryRun
770
+ ? preflight.filter(e =>
771
+ isConvertStatus(e.videoStatus) || (e.extractStatus && isConvertStatus(e.extractStatus)))
772
+ : preflight.filter(e => convertedInputs.includes(e.input));
773
+ const planned = archiveCandidates.map(entry => ({
774
+ input: entry.input,
775
+ dest: archiveDestPath(entry.input, archiveDir, { rootDir: resolveSourceRoot() }),
776
+ }));
777
+
778
+ if (planned.length === 0) {
779
+ logConsole('No files converted successfully; --archive has nothing to move.');
780
+ } else if (dryRun) {
781
+ logger.printLines(formatArchivePlanLines(planned, { archiveDir, dryRun: true }));
782
+ logConsole(`=== Would archive ${planned.length} original(s) after successful conversion (dry-run) ===`);
783
+ } else {
784
+ const confirmed = await confirmDeletion(archiveCandidates, {
785
+ flagLabel: '--archive',
786
+ intro: `Conversion finished. Sources below were converted successfully and will be moved to ${archiveDir}.`,
787
+ countLabel: 'ready to archive',
788
+ firstPrompt: 'Move these originals to the archive folder now? [y/N]: ',
789
+ confirmedMessage: 'Archive confirmed.',
790
+ });
791
+ if (confirmed) {
792
+ const { moved, failed } = moveOriginalsToArchive(convertedInputs, archiveDir, {
793
+ rootDir: resolveSourceRoot(),
794
+ logConsole,
795
+ });
796
+ archiveFailed = failed;
797
+ logConsole(`=== Archived ${moved} original(s), ${failed} archive error(s) ===`);
798
+ } else {
799
+ logConsole('Archive cancelled.');
800
+ }
801
+ }
802
+ }
803
+
804
+ process.exit(stats.failed > 0 || deleteFailed > 0 || archiveFailed > 0 ? 1 : 0);