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/lib/run.js ADDED
@@ -0,0 +1,288 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { lameQuality } from './audio-policy.js';
4
+ import { NVENC_MIN_HEIGHT, NVENC_MIN_WIDTH } from './constants.js';
5
+ import {
6
+ applyOutputTimestamps,
7
+ buildAudioFfmpegArgs,
8
+ buildExtractAudioFfmpegArgs,
9
+ buildFfmpegArgs,
10
+ canCopyAacAudio,
11
+ removePartialOutput,
12
+ nvencSupportsFrame,
13
+ runFfmpeg,
14
+ sampleDuration,
15
+ shouldDeinterlace,
16
+ } from './encode.js';
17
+ import { formatMtimeDate } from './paths.js';
18
+ import { isSkippableStatus } from './status.js';
19
+ import { hasDateTag } from './tags.js';
20
+ import { formatDryRunProgress, shellQuote } from './format.js';
21
+ import { secondsToHMS, formatHMSValue, formatTimeHMS, timeToSeconds } from './time.js';
22
+ import { verifyOutput } from './verify.js';
23
+ import { runWithConcurrency } from './jobs.js';
24
+
25
+ export async function runConversion({
26
+ preflight,
27
+ config,
28
+ logger,
29
+ progress,
30
+ resumeState = null,
31
+ }) {
32
+ const {
33
+ dryRun, verify, keepPartial, quality, audioQuality, deinterlace, nvenc,
34
+ preferMtime, embedArt, extractAudio, mediaMode, verbose, jobs = 1,
35
+ sampleSeconds = null, reencodeAudio = false,
36
+ } = config;
37
+
38
+ const stats = { done: 0, skipped: 0, failed: 0, resumed: 0 };
39
+ const failedPaths = [];
40
+ const convertedInputs = [];
41
+
42
+ function encodeWithFfmpeg(args, { activeFileBar, knownDuration } = {}) {
43
+ let activeProc = null;
44
+ return runFfmpeg(args, {
45
+ setActiveProc: (proc) => {
46
+ if (activeProc && !proc) {
47
+ progress.unregisterProc?.(activeProc);
48
+ activeProc = null;
49
+ } else if (proc) {
50
+ activeProc = proc;
51
+ progress.registerProc?.(proc);
52
+ }
53
+ },
54
+ onProgress: (chunk) => {
55
+ if (!activeFileBar || !chunk.includes('time=')) return;
56
+ const timeMatch = chunk.match(/time=([\d:.]+)/);
57
+ if (!timeMatch) return;
58
+ const current = timeToSeconds(timeMatch[1]);
59
+ if (Number.isNaN(current)) return;
60
+ if (!knownDuration && current > activeFileBar.getTotal()) {
61
+ activeFileBar.setTotal(current + 60);
62
+ }
63
+ activeFileBar.update(current);
64
+ },
65
+ });
66
+ }
67
+
68
+ async function runEncodePass({
69
+ input, out, meta, jobStatus, jobLabel, mode, lossyWarn, index, total,
70
+ }) {
71
+ const base = path.basename(input);
72
+ const passName = jobLabel ? `${base} [${jobLabel}]` : base;
73
+ const isAudioJob = mode === 'audio' || mode === 'extract';
74
+
75
+ if (jobStatus === 'skip (normalized)' || jobStatus === 'skip (exists)' || jobStatus === 'skip (resumed)') {
76
+ stats.skipped++;
77
+ logger.logFile(`Skipped${jobStatus.includes('normalized') ? ' (normalized)' : jobStatus.includes('resumed') ? ' (resumed)' : ''}: ${passName}`);
78
+ return true;
79
+ }
80
+
81
+ if (dryRun) {
82
+ if (jobStatus.startsWith('convert')) {
83
+ stats.done++;
84
+ if (verbose) {
85
+ const suffix = mode === 'video'
86
+ ? ` (deinterlace: ${shouldDeinterlace(deinterlace, meta) ? 'yadif' : 'none'})`
87
+ : mode === 'extract' ? ' (extract)' : lossyWarn ? ' (lossy)' : '';
88
+ logger.fileLog(`Would convert: ${passName} → ${path.basename(out)}${suffix}`, index, total);
89
+ }
90
+ }
91
+ return true;
92
+ }
93
+
94
+ if (lossyWarn) {
95
+ const msg = `[WARN] ${passName} → ${path.basename(out)} (lossy; source cannot be recovered from MP3)`;
96
+ logger.logFile(msg);
97
+ if (verbose) logger.logConsole(msg);
98
+ }
99
+
100
+ if (meta.duration <= 0) {
101
+ logger.logFile(`[WARN] ${passName}: zero duration reported; progress may be approximate`);
102
+ }
103
+
104
+ if (!fs.existsSync(path.dirname(out))) fs.mkdirSync(path.dirname(out), { recursive: true });
105
+ if (fs.existsSync(out)) {
106
+ fs.unlinkSync(out);
107
+ logger.logFile(`Removed existing output before encode: ${out}`);
108
+ }
109
+
110
+ const expectedDuration = sampleDuration(meta.duration, sampleSeconds);
111
+ const knownDuration = expectedDuration > 0;
112
+ const barTotal = knownDuration ? Math.floor(expectedDuration) : 3600;
113
+ let activeFileBar = null;
114
+ let encodeStart = Date.now();
115
+
116
+ try {
117
+ activeFileBar = progress.createFileBar(barTotal, passName, {
118
+ format: 'Current [{bar}] {percentage}% | {value} / {total} | ETA {eta_formatted} | {filename}',
119
+ formatValue: formatHMSValue,
120
+ formatTime: formatTimeHMS,
121
+ });
122
+ activeFileBar.start(barTotal, 0, { filename: passName });
123
+
124
+ const args = mode === 'video'
125
+ ? buildFfmpegArgs(input, out, meta, {
126
+ quality, nvenc, deinterlaceMode: deinterlace, sampleSeconds, reencodeAudio,
127
+ })
128
+ : mode === 'extract'
129
+ ? buildExtractAudioFfmpegArgs(input, out, { audioQuality, preferMtime, meta, sampleSeconds })
130
+ : buildAudioFfmpegArgs(input, out, { audioQuality, embedArt, preferMtime, meta, sampleSeconds });
131
+
132
+ logger.logFile(`--- ${passName} ---`);
133
+ if (mode === 'video') {
134
+ const deinterlaceApplied = shouldDeinterlace(deinterlace, meta);
135
+ logger.logFile(`Deinterlace: ${deinterlaceApplied ? 'yadif' : 'off'} (mode=${deinterlace}, field_order=${meta.field_order})`);
136
+ if (nvenc && !nvencSupportsFrame(meta)) {
137
+ logger.logFile(`NVENC skipped: ${meta.width}x${meta.height} is below the ${NVENC_MIN_WIDTH}x${NVENC_MIN_HEIGHT} minimum; using libx264`);
138
+ }
139
+ if (canCopyAacAudio(meta, { reencodeAudio })) {
140
+ logger.logFile('Audio: copy (source AAC is compatible)');
141
+ } else {
142
+ logger.logFile(reencodeAudio
143
+ ? 'Audio: aac 192k (--reencode-audio)'
144
+ : 'Audio: aac 192k');
145
+ }
146
+ } else {
147
+ const artNote = embedArt ? (meta.hasCoverArt ? 'embed cover' : 'embed cover if present') : 'no cover';
148
+ const dateNote = preferMtime && !hasDateTag(meta.tags) ? `date=${formatMtimeDate(input)} from mtime` : 'tags as-is';
149
+ logger.logFile(`Encode: libmp3lame -q:a ${lameQuality(audioQuality)} | ${artNote} | ${dateNote}${mode === 'extract' ? ' | extract from video' : ''}`);
150
+ }
151
+ logger.logFile(`Command: ffmpeg ${args.map(shellQuote).join(' ')}`);
152
+
153
+ encodeStart = Date.now();
154
+ const expectedType = mode === 'video' ? 'video' : 'audio';
155
+ await encodeWithFfmpeg(args, { activeFileBar, knownDuration });
156
+ const elapsedSec = (Date.now() - encodeStart) / 1000;
157
+ const elapsedStr = elapsedSec >= 60
158
+ ? `${Math.floor(elapsedSec / 60)}m ${Math.round(elapsedSec % 60)}s`
159
+ : `${elapsedSec.toFixed(1)}s`;
160
+
161
+ if (verify) {
162
+ const verifyContext = isAudioJob ? { sourceMeta: meta, sourceSize: meta.size } : null;
163
+ const check = verifyOutput(out, expectedDuration, expectedType, verifyContext);
164
+ if (!check.ok) throw new Error(`verification failed: ${check.reason}`);
165
+ for (const warning of check.warnings ?? []) {
166
+ logger.logFile(`[WARN] ${passName}: ${warning}`);
167
+ if (verbose) logger.logConsole(`[WARN] ${passName}: ${warning}`);
168
+ }
169
+ try {
170
+ applyOutputTimestamps(input, out);
171
+ } catch { }
172
+ const outBase = path.basename(out);
173
+ const detail = `✓ ${outBase} | Verified (${secondsToHMS(check.duration)}) | Metadata copied`;
174
+ logger.logFile(`${detail} | --- end ${passName} (${elapsedStr}) ---`);
175
+ logger.logToConsole(verbose ? detail : `✓ ${outBase}`);
176
+ } else {
177
+ try {
178
+ applyOutputTimestamps(input, out);
179
+ } catch { }
180
+ const outBase = path.basename(out);
181
+ logger.logFile(`✓ ${outBase} | --- end ${passName} (${elapsedStr}) ---`);
182
+ logger.logToConsole(verbose ? `✓ ${outBase}` : `✓ ${outBase}`);
183
+ }
184
+
185
+ stats.done++;
186
+ activeFileBar.update(knownDuration ? Math.floor(expectedDuration) : activeFileBar.value);
187
+ return true;
188
+ } catch (err) {
189
+ const elapsedSec = (Date.now() - encodeStart) / 1000;
190
+ logger.logConsole(`Error: ${passName} - ${err.message}`);
191
+ logger.logFile(`--- end ${passName} (${elapsedSec.toFixed(1)}s, failed) ---`);
192
+ removePartialOutput(out, keepPartial, (msg) => logger.logFile(msg));
193
+ stats.failed++;
194
+ if (!failedPaths.includes(input)) failedPaths.push(input);
195
+ return false;
196
+ } finally {
197
+ activeFileBar?.stop();
198
+ }
199
+ }
200
+
201
+ async function processFile(entry, index) {
202
+ if (progress.isShuttingDown()) return;
203
+
204
+ const { input, out, audioOut, meta, videoStatus, extractStatus, lossy } = entry;
205
+ const total = preflight.length;
206
+ const base = path.basename(input);
207
+
208
+ logger.logFile(`Processing: ${base} | Type: ${meta.mediaType} | Duration: ${secondsToHMS(meta.duration)} | Created: ${meta.creation_time} | Updated: ${meta.modified_time}`);
209
+
210
+ if (videoStatus === 'unreadable') {
211
+ stats.failed++;
212
+ failedPaths.push(input);
213
+ logger.logConsole(`Unreadable: ${base}`);
214
+ progress.overallBar?.increment();
215
+ return;
216
+ }
217
+
218
+ if (videoStatus === 'skip (wrong type)' && !extractStatus) {
219
+ stats.skipped++;
220
+ logger.logFile(`Skipped (wrong type): ${base}`);
221
+ progress.overallBar?.increment();
222
+ return;
223
+ }
224
+
225
+ if (dryRun) {
226
+ if (isSkippableStatus(videoStatus)) {
227
+ stats.skipped++;
228
+ } else if (videoStatus.startsWith('convert')) stats.done++;
229
+ if (extractStatus) {
230
+ if (isSkippableStatus(extractStatus)) {
231
+ stats.skipped++;
232
+ } else if (extractStatus.startsWith('convert')) stats.done++;
233
+ }
234
+ if (videoStatus === 'skip (resumed)' || extractStatus === 'skip (resumed)') {
235
+ stats.resumed++;
236
+ convertedInputs.push(input);
237
+ }
238
+ if (total > 1) {
239
+ logger.logConsole(formatDryRunProgress(index, total, entry.status, base));
240
+ } else if (verbose && (videoStatus.startsWith('convert') || extractStatus?.startsWith('convert'))) {
241
+ logger.fileLog(`Would process: ${base} (${entry.status})`, index, total);
242
+ }
243
+ progress.overallBar?.increment();
244
+ return;
245
+ }
246
+
247
+ let primaryConverted = false;
248
+ const encodeJobs = [];
249
+
250
+ if (meta.mediaType === 'video' && mediaMode.video && videoStatus !== 'skip (wrong type)') {
251
+ encodeJobs.push({ out, jobStatus: videoStatus, jobLabel: null, mode: 'video', lossyWarn: false });
252
+ } else if (meta.mediaType === 'audio' && mediaMode.audio) {
253
+ encodeJobs.push({ out, jobStatus: videoStatus, jobLabel: null, mode: 'audio', lossyWarn: lossy });
254
+ }
255
+ if (extractStatus && extractAudio && audioOut) {
256
+ encodeJobs.push({ out: audioOut, jobStatus: extractStatus, jobLabel: 'extract', mode: 'extract', lossyWarn: false });
257
+ }
258
+
259
+ let allOk = true;
260
+ for (const job of encodeJobs) {
261
+ const ok = await runEncodePass({ input, meta, index, total, ...job });
262
+ if (!ok) allOk = false;
263
+ if (ok && job.jobStatus.startsWith('convert') && job.mode !== 'extract') {
264
+ primaryConverted = true;
265
+ }
266
+ }
267
+
268
+ if (allOk && encodeJobs.length > 0) {
269
+ resumeState?.markCompleted?.(input);
270
+ }
271
+ if (videoStatus === 'skip (resumed)' || extractStatus === 'skip (resumed)') {
272
+ stats.resumed++;
273
+ }
274
+ if (primaryConverted || (allOk && encodeJobs.some(j => j.jobStatus === 'skip (resumed)'))) {
275
+ convertedInputs.push(input);
276
+ }
277
+ progress.overallBar?.increment();
278
+ }
279
+
280
+ await runWithConcurrency(
281
+ preflight.length,
282
+ dryRun ? 1 : jobs,
283
+ (i) => processFile(preflight[i], i),
284
+ { shouldStop: () => progress.isShuttingDown() },
285
+ );
286
+
287
+ return { stats, failedPaths, convertedInputs };
288
+ }
package/lib/safety.js ADDED
@@ -0,0 +1,84 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { isConvertStatus } from './status.js';
4
+
5
+ export const LARGE_BATCH_THRESHOLD = 50;
6
+ export const STAMP_BACKUP_WARN_THRESHOLD = 10;
7
+ export const DISK_RESERVE_BYTES = 200 * 1024 * 1024;
8
+ export const VIDEO_OUTPUT_FACTOR = 1.2;
9
+ export const AUDIO_OUTPUT_FACTOR = 0.3;
10
+ export const EXTRACT_OUTPUT_FACTOR = 0.15;
11
+
12
+ export const LOSSY_BANNER = 'Outputs are lossy (H.264/AAC MP4 or LAME MP3). Keep sources until you have checked playback.';
13
+
14
+ export function formatWriteLocationBanner({ count, outputDir, sourceDir }) {
15
+ if (outputDir) return `Writing ${count} output(s) under ${outputDir} (source subfolders kept).`;
16
+ return `Writing ${count} file(s) beside sources in ${sourceDir}.`;
17
+ }
18
+
19
+ export function formatLargeBatchPrompt({ count, root, threshold = LARGE_BATCH_THRESHOLD }) {
20
+ return `${count} files under ${root} (more than ${threshold}). Press Enter to continue, or Ctrl+C to stop: `;
21
+ }
22
+
23
+ export function formatStampBackupWarn(count, threshold = STAMP_BACKUP_WARN_THRESHOLD) {
24
+ return `--stamp-dates will rename ${count} sources in place with no --backup (more than ${threshold}). Press Enter to continue, or Ctrl+C to stop: `;
25
+ }
26
+
27
+ export function formatForceOverwritePrompt(count) {
28
+ return `--force will overwrite ${count} existing output(s). Continue? [y/N]: `;
29
+ }
30
+
31
+ export function shouldShowLossyBanner(entries) {
32
+ return entries.some(entry =>
33
+ isConvertStatus(entry.videoStatus) || (entry.extractStatus && isConvertStatus(entry.extractStatus)));
34
+ }
35
+
36
+ export function countOverwriteTargets(entries, { existsFn = fs.existsSync } = {}) {
37
+ let count = 0;
38
+ for (const entry of entries) {
39
+ if (isConvertStatus(entry.videoStatus) && existsFn(entry.out)) count++;
40
+ if (entry.extractStatus && isConvertStatus(entry.extractStatus) && entry.audioOut && existsFn(entry.audioOut)) {
41
+ count++;
42
+ }
43
+ }
44
+ return count;
45
+ }
46
+
47
+ export function estimateNeededBytes(entries, { sampleSeconds = null } = {}) {
48
+ let bytes = 0;
49
+ for (const entry of entries) {
50
+ const size = Number(entry.meta?.size) || 0;
51
+ const duration = Number(entry.meta?.duration) || 0;
52
+ const sampleFactor = sampleSeconds && duration > 0 ? Math.min(1, sampleSeconds / duration) : 1;
53
+ if (isConvertStatus(entry.videoStatus)) {
54
+ bytes += (entry.meta?.mediaType === 'audio'
55
+ ? size * AUDIO_OUTPUT_FACTOR
56
+ : size * VIDEO_OUTPUT_FACTOR) * sampleFactor;
57
+ }
58
+ if (entry.extractStatus && isConvertStatus(entry.extractStatus)) {
59
+ bytes += size * EXTRACT_OUTPUT_FACTOR * sampleFactor;
60
+ }
61
+ }
62
+ return Math.ceil(bytes);
63
+ }
64
+
65
+ export function checkFreeSpace(destDir, neededBytes, {
66
+ statfsFn = fs.statfsSync,
67
+ reserveBytes = DISK_RESERVE_BYTES,
68
+ } = {}) {
69
+ const resolved = path.resolve(destDir);
70
+ const stats = statfsFn(resolved);
71
+ const free = Number(stats.bavail) * Number(stats.bsize);
72
+ const needed = neededBytes + reserveBytes;
73
+ return {
74
+ ok: free >= needed,
75
+ free,
76
+ needed,
77
+ destDir: resolved,
78
+ };
79
+ }
80
+
81
+ export function formatDiskSpaceError(check) {
82
+ const gb = n => `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`;
83
+ return `Not enough free space on ${check.destDir}: need about ${gb(check.needed)} (including reserve), ${gb(check.free)} available. Free disk space, use --output on another volume, or pass --yes to continue anyway.`;
84
+ }