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/LICENSE +21 -0
- package/README.md +65 -0
- package/index.js +804 -0
- package/lib/archive.js +66 -0
- package/lib/audio-policy.js +53 -0
- package/lib/cleanup.js +77 -0
- package/lib/cli-config.js +336 -0
- package/lib/constants.js +17 -0
- package/lib/discover.js +38 -0
- package/lib/dupe-report.js +223 -0
- package/lib/encode.js +194 -0
- package/lib/everything.js +154 -0
- package/lib/extensions.js +22 -0
- package/lib/filename-dates.js +155 -0
- package/lib/format.js +33 -0
- package/lib/globs.js +58 -0
- package/lib/hash.js +12 -0
- package/lib/jobs.js +51 -0
- package/lib/log.js +61 -0
- package/lib/paths.js +38 -0
- package/lib/preflight.js +75 -0
- package/lib/probe.js +115 -0
- package/lib/recup-map.js +366 -0
- package/lib/resolve-inputs.js +125 -0
- package/lib/resume-state.js +136 -0
- package/lib/run.js +288 -0
- package/lib/safety.js +84 -0
- package/lib/stamp-dates.js +468 -0
- package/lib/status.js +78 -0
- package/lib/tags.js +35 -0
- package/lib/time.js +29 -0
- package/lib/timestamps.js +46 -0
- package/lib/tools.js +13 -0
- package/lib/trash.js +31 -0
- package/lib/verify.js +60 -0
- package/package.json +47 -0
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);
|