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.
@@ -0,0 +1,468 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { padEnd } from './format.js';
4
+ import { isStandardDatedName, normalizeDatedBasename, parseFilenameDate, filenameStampPrefix } from './filename-dates.js';
5
+ import { readFileTimes, applyFileTimes, repairCreatedIfMoved } from './timestamps.js';
6
+
7
+ export const STAMP_PREFIX_RE = /^((?:MTIME_)?\d{4}-\d{2}-\d{2}(?:_\d{2}-\d{2}-\d{2}Z?|_\d{6}Z?)?)(?:_|\.|$)/;
8
+
9
+ const ISO_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?$/i;
10
+
11
+ export function parseCreationDate(value) {
12
+ if (value == null) return null;
13
+ const raw = String(value).trim();
14
+ if (!raw || raw === 'N/A') return null;
15
+
16
+ const match = raw.match(ISO_RE);
17
+ if (match) {
18
+ const [, year, month, day, hour, minute, second] = match;
19
+ return {
20
+ year,
21
+ month,
22
+ day,
23
+ hour: hour ?? '00',
24
+ minute: minute ?? '00',
25
+ second: second ?? '00',
26
+ hasTime: hour != null,
27
+ };
28
+ }
29
+
30
+ if (/^\d{4}$/.test(raw)) return null;
31
+
32
+ const parsed = new Date(raw);
33
+ if (Number.isNaN(parsed.getTime())) return null;
34
+ const hasTime = /T\d{2}:/.test(raw) || /\d{2}:\d{2}:\d{2}/.test(raw);
35
+ if (!hasTime && !/^\d{4}[-/]\d{2}[-/]\d{2}/.test(raw)) return null;
36
+ return fromUtcDate(parsed, hasTime);
37
+ }
38
+
39
+ function fromUtcDate(date, hasTime) {
40
+ return {
41
+ year: String(date.getUTCFullYear()).padStart(4, '0'),
42
+ month: String(date.getUTCMonth() + 1).padStart(2, '0'),
43
+ day: String(date.getUTCDate()).padStart(2, '0'),
44
+ hour: String(date.getUTCHours()).padStart(2, '0'),
45
+ minute: String(date.getUTCMinutes()).padStart(2, '0'),
46
+ second: String(date.getUTCSeconds()).padStart(2, '0'),
47
+ hasTime,
48
+ };
49
+ }
50
+
51
+ function fromLocalDate(date, hasTime) {
52
+ return {
53
+ year: String(date.getFullYear()).padStart(4, '0'),
54
+ month: String(date.getMonth() + 1).padStart(2, '0'),
55
+ day: String(date.getDate()).padStart(2, '0'),
56
+ hour: String(date.getHours()).padStart(2, '0'),
57
+ minute: String(date.getMinutes()).padStart(2, '0'),
58
+ second: String(date.getSeconds()).padStart(2, '0'),
59
+ hasTime,
60
+ };
61
+ }
62
+
63
+ export function formatStampPrefix(parsed, { source = 'creation_time' } = {}) {
64
+ if (!parsed) return null;
65
+ const date = `${parsed.year}-${parsed.month}-${parsed.day}`;
66
+ const clock = parsed.hasTime
67
+ ? `_${parsed.hour}-${parsed.minute}-${parsed.second}${source === 'creation_time' ? 'Z' : ''}`
68
+ : '';
69
+ const tag = source === 'mtime' ? 'MTIME_' : '';
70
+ return `${tag}${date}${clock}`;
71
+ }
72
+
73
+ export function existingStampPrefix(basename) {
74
+ const match = basename.match(STAMP_PREFIX_RE);
75
+ return match ? match[1] : null;
76
+ }
77
+
78
+ export function buildStampedName(basename, prefix) {
79
+ const current = existingStampPrefix(basename);
80
+ if (current === prefix) return basename;
81
+ if (current) return null;
82
+ return `${prefix}_${basename}`;
83
+ }
84
+
85
+ /** Keep an existing date prefix; add one only when the name has none and metadata has a date. */
86
+ export function stampedOutputStem(basename, meta, { preferMtime = false } = {}) {
87
+ const normalized = normalizeDatedBasename(basename);
88
+ if (normalized !== basename) {
89
+ return path.basename(normalized, path.extname(normalized));
90
+ }
91
+ const stem = path.basename(basename, path.extname(basename));
92
+ if (isStandardDatedName(basename)) return stem;
93
+ const resolved = resolveStampDate(meta, { preferMtime });
94
+ if (!resolved.parsed) return stem;
95
+ const prefix = formatStampPrefix(resolved.parsed, { source: resolved.source });
96
+ const stamped = buildStampedName(basename, prefix);
97
+ if (!stamped) return stem;
98
+ return path.basename(stamped, path.extname(stamped));
99
+ }
100
+
101
+ export function resolveStampDate(meta, { preferMtime = false } = {}) {
102
+ const fromTag = parseCreationDate(meta?.creation_time);
103
+ if (fromTag) {
104
+ return { parsed: fromTag, source: 'creation_time', raw: meta.creation_time };
105
+ }
106
+
107
+ if (preferMtime && meta?.modified_time) {
108
+ const dt = new Date(meta.modified_time);
109
+ if (!Number.isNaN(dt.getTime())) {
110
+ return { parsed: fromLocalDate(dt, true), source: 'mtime', raw: meta.modified_time };
111
+ }
112
+ }
113
+
114
+ return { parsed: null, source: null, raw: meta?.creation_time ?? null };
115
+ }
116
+
117
+ function backupRelPath(input, rootDir) {
118
+ if (rootDir) {
119
+ const rel = path.relative(rootDir, input);
120
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel;
121
+ }
122
+ return path.basename(input);
123
+ }
124
+
125
+ export function planStampRenames(files, {
126
+ probeFn,
127
+ preferMtime = false,
128
+ rootDir = null,
129
+ } = {}) {
130
+ if (typeof probeFn !== 'function') {
131
+ throw new Error('planStampRenames requires probeFn');
132
+ }
133
+
134
+ const plannedDest = new Map();
135
+ const plans = [];
136
+
137
+ for (const input of files) {
138
+ const basename = path.basename(input);
139
+ const dir = path.dirname(input);
140
+ let meta;
141
+ try {
142
+ meta = probeFn(input);
143
+ } catch (err) {
144
+ plans.push({
145
+ input,
146
+ output: null,
147
+ action: 'error',
148
+ reason: `probe failed: ${err.message}`,
149
+ created: null,
150
+ source: null,
151
+ stamp: null,
152
+ backupRel: backupRelPath(input, rootDir),
153
+ });
154
+ continue;
155
+ }
156
+
157
+ const fromName = parseFilenameDate(basename);
158
+ const normalized = normalizeDatedBasename(basename);
159
+ if (fromName && normalized !== basename) {
160
+ const stamp = filenameStampPrefix(fromName.parsed, {
161
+ z: fromName.hasZ,
162
+ mtime: fromName.source === 'mtime',
163
+ });
164
+ const output = path.join(dir, normalized);
165
+ const destKey = path.resolve(output);
166
+ if (plannedDest.has(destKey)) {
167
+ plans.push({
168
+ input, output, action: 'error',
169
+ reason: `would collide with ${path.basename(plannedDest.get(destKey))}`,
170
+ created: null, source: 'filename', stamp,
171
+ backupRel: backupRelPath(input, rootDir),
172
+ });
173
+ continue;
174
+ }
175
+ if (fs.existsSync(output) && destKey !== path.resolve(input)) {
176
+ plans.push({
177
+ input, output, action: 'error',
178
+ reason: `destination already exists: ${normalized}`,
179
+ created: null, source: 'filename', stamp,
180
+ backupRel: backupRelPath(input, rootDir),
181
+ });
182
+ continue;
183
+ }
184
+ plannedDest.set(destKey, input);
185
+ plans.push({
186
+ input, output, action: 'rename', reason: 'filename',
187
+ created: null, source: 'filename', stamp,
188
+ backupRel: backupRelPath(input, rootDir),
189
+ });
190
+ continue;
191
+ }
192
+
193
+ if (isStandardDatedName(basename)) {
194
+ const stamp = fromName
195
+ ? filenameStampPrefix(fromName.parsed, {
196
+ z: fromName.hasZ,
197
+ mtime: fromName.source === 'mtime',
198
+ })
199
+ : null;
200
+ plans.push({
201
+ input, output: path.join(dir, basename), action: 'skip',
202
+ reason: 'already stamped',
203
+ created: stamp,
204
+ source: fromName?.source ?? null, stamp,
205
+ backupRel: backupRelPath(input, rootDir),
206
+ });
207
+ continue;
208
+ }
209
+
210
+ const resolved = resolveStampDate(meta, { preferMtime });
211
+ if (!resolved.parsed) {
212
+ plans.push({
213
+ input,
214
+ output: null,
215
+ action: 'skip',
216
+ reason: preferMtime
217
+ ? 'no parseable creation_time or mtime'
218
+ : 'no parseable creation_time (use --prefer-mtime to fall back to file date)',
219
+ created: resolved.raw ?? null,
220
+ source: null,
221
+ stamp: null,
222
+ backupRel: backupRelPath(input, rootDir),
223
+ });
224
+ continue;
225
+ }
226
+
227
+ const stamp = formatStampPrefix(resolved.parsed, { source: resolved.source });
228
+ const nextName = buildStampedName(basename, stamp);
229
+ if (nextName === null) {
230
+ plans.push({
231
+ input,
232
+ output: null,
233
+ action: 'skip',
234
+ reason: `already has a different date prefix (${existingStampPrefix(basename)})`,
235
+ created: resolved.raw,
236
+ source: resolved.source,
237
+ stamp,
238
+ backupRel: backupRelPath(input, rootDir),
239
+ });
240
+ continue;
241
+ }
242
+
243
+ const output = path.join(dir, nextName);
244
+ if (nextName === basename) {
245
+ plans.push({
246
+ input,
247
+ output,
248
+ action: 'skip',
249
+ reason: 'already stamped',
250
+ created: resolved.raw,
251
+ source: resolved.source,
252
+ stamp,
253
+ backupRel: backupRelPath(input, rootDir),
254
+ });
255
+ continue;
256
+ }
257
+
258
+ const destKey = path.resolve(output);
259
+ if (plannedDest.has(destKey)) {
260
+ plans.push({
261
+ input,
262
+ output,
263
+ action: 'error',
264
+ reason: `would collide with ${path.basename(plannedDest.get(destKey))}`,
265
+ created: resolved.raw,
266
+ source: resolved.source,
267
+ stamp,
268
+ backupRel: backupRelPath(input, rootDir),
269
+ });
270
+ continue;
271
+ }
272
+
273
+ if (fs.existsSync(output) && path.resolve(output) !== path.resolve(input)) {
274
+ plans.push({
275
+ input,
276
+ output,
277
+ action: 'error',
278
+ reason: `destination already exists: ${nextName}`,
279
+ created: resolved.raw,
280
+ source: resolved.source,
281
+ stamp,
282
+ backupRel: backupRelPath(input, rootDir),
283
+ });
284
+ continue;
285
+ }
286
+
287
+ plannedDest.set(destKey, input);
288
+ plans.push({
289
+ input,
290
+ output,
291
+ action: 'rename',
292
+ reason: resolved.source,
293
+ created: resolved.raw,
294
+ source: resolved.source,
295
+ stamp,
296
+ backupRel: backupRelPath(input, rootDir),
297
+ });
298
+ }
299
+
300
+ return {
301
+ plans,
302
+ stats: {
303
+ rename: plans.filter(p => p.action === 'rename').length,
304
+ skip: plans.filter(p => p.action === 'skip').length,
305
+ error: plans.filter(p => p.action === 'error').length,
306
+ },
307
+ };
308
+ }
309
+
310
+ export function formatStampPlanLines(plans, { dryRun = false, backupDir = null } = {}) {
311
+ const rows = plans.map(p => ({
312
+ from: path.basename(p.input),
313
+ to: p.output ? path.basename(p.output) : '—',
314
+ status: p.action === 'rename'
315
+ ? (dryRun ? 'would rename' : 'rename')
316
+ : p.action,
317
+ note: p.action === 'rename' ? (p.source || '') : (p.reason || ''),
318
+ }));
319
+
320
+ const fromW = Math.min(48, Math.max(18, ...rows.map(r => r.from.length)));
321
+ const toW = Math.min(56, Math.max(18, ...rows.map(r => r.to.length)));
322
+ const header = ` ${padEnd('File', fromW)} ${padEnd('Stamped name', toW)} Status`;
323
+ const rule = ' ' + '─'.repeat(fromW + toW + 12);
324
+ const lines = ['', rule, header, rule];
325
+
326
+ for (const row of rows) {
327
+ const extra = row.note ? ` ${row.note}` : '';
328
+ lines.push(` ${padEnd(row.from, fromW)} ${padEnd(row.to, toW)} ${row.status}${extra}`);
329
+ }
330
+
331
+ lines.push(rule);
332
+ const action = dryRun ? 'would rename' : 'to rename';
333
+ const renameCount = plans.filter(p => p.action === 'rename').length;
334
+ const skipCount = plans.filter(p => p.action === 'skip').length;
335
+ const errorCount = plans.filter(p => p.action === 'error').length;
336
+ lines.push(` ${plans.length} file(s): ${renameCount} ${action}, ${skipCount} skip, ${errorCount} error`);
337
+ if (backupDir) {
338
+ lines.push(` Backup: ${backupDir}`);
339
+ }
340
+ lines.push('');
341
+ return lines;
342
+ }
343
+
344
+ function copyBackup(plan, backupDir) {
345
+ const dest = path.join(backupDir, plan.backupRel);
346
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
347
+ if (fs.existsSync(dest)) return { dest, copied: false };
348
+ fs.copyFileSync(plan.input, dest);
349
+ return { dest, copied: true };
350
+ }
351
+
352
+ export function writeStampManifest(filePath, payload) {
353
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
354
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n');
355
+ }
356
+
357
+ export function applyStampPlan(plans, {
358
+ dryRun = false,
359
+ backupDir = null,
360
+ manifestPath = null,
361
+ } = {}) {
362
+ const renames = plans.filter(p => p.action === 'rename');
363
+ const result = {
364
+ renamed: 0,
365
+ skipped: plans.filter(p => p.action === 'skip').length,
366
+ failed: plans.filter(p => p.action === 'error').length,
367
+ backedUp: 0,
368
+ errors: [],
369
+ };
370
+
371
+ if (dryRun) {
372
+ result.renamed = renames.length;
373
+ return result;
374
+ }
375
+
376
+ const manifest = {
377
+ createdAt: new Date().toISOString(),
378
+ backupDir,
379
+ renames: renames.map(p => ({
380
+ from: path.basename(p.input),
381
+ to: path.basename(p.output),
382
+ fromPath: path.resolve(p.input),
383
+ toPath: path.resolve(p.output),
384
+ created: p.created,
385
+ source: p.source,
386
+ })),
387
+ };
388
+
389
+ if (backupDir) {
390
+ fs.mkdirSync(backupDir, { recursive: true });
391
+ for (const plan of renames) {
392
+ try {
393
+ const { copied } = copyBackup(plan, backupDir);
394
+ if (copied) result.backedUp++;
395
+ } catch (err) {
396
+ result.failed++;
397
+ result.errors.push(`${path.basename(plan.input)}: backup failed (${err.message})`);
398
+ if (manifestPath) writeStampManifest(manifestPath, { ...manifest, aborted: 'backup failed', errors: result.errors });
399
+ return result;
400
+ }
401
+ }
402
+ }
403
+
404
+ if (manifestPath) writeStampManifest(manifestPath, manifest);
405
+
406
+ for (const plan of renames) {
407
+ try {
408
+ if (fs.existsSync(plan.output) && path.resolve(plan.output) !== path.resolve(plan.input)) {
409
+ result.failed++;
410
+ result.errors.push(`${path.basename(plan.input)}: destination already exists`);
411
+ continue;
412
+ }
413
+ const times = readFileTimes(plan.input);
414
+ fs.renameSync(plan.input, plan.output);
415
+ applyFileTimes(plan.output, times);
416
+ repairCreatedIfMoved(plan.output);
417
+ result.renamed++;
418
+ } catch (err) {
419
+ result.failed++;
420
+ result.errors.push(`${path.basename(plan.input)}: rename failed (${err.message})`);
421
+ }
422
+ }
423
+
424
+ return result;
425
+ }
426
+
427
+ export function defaultManifestPath({ backupDir, targetPath, cwd = process.cwd() }) {
428
+ if (backupDir) return path.join(backupDir, 'mediatuna-stamp-manifest.json');
429
+ if (targetPath && fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()) {
430
+ return path.join(targetPath, 'mediatuna-stamp-manifest.json');
431
+ }
432
+ return path.join(cwd, 'mediatuna-stamp-manifest.json');
433
+ }
434
+
435
+ export function runStampDates({
436
+ files,
437
+ probeFn,
438
+ preferMtime = false,
439
+ dryRun = false,
440
+ backupDir = null,
441
+ rootDir = null,
442
+ logger,
443
+ }) {
444
+ const { plans, stats } = planStampRenames(files, { probeFn, preferMtime, rootDir });
445
+ const lines = formatStampPlanLines(plans, { dryRun, backupDir });
446
+ logger?.printLines?.(lines);
447
+
448
+ const manifestPath = dryRun ? null : defaultManifestPath({ backupDir, targetPath: rootDir });
449
+ const result = applyStampPlan(plans, { dryRun, backupDir, manifestPath });
450
+
451
+ if (!dryRun) {
452
+ for (const plan of plans) {
453
+ const target = plan.action === 'rename' && fs.existsSync(plan.output) ? plan.output : plan.input;
454
+ if (fs.existsSync(target)) {
455
+ try { repairCreatedIfMoved(target); } catch { /* keep going */ }
456
+ }
457
+ }
458
+ }
459
+
460
+ for (const err of result.errors) {
461
+ logger?.logConsole?.(`Stamp error: ${err}`);
462
+ }
463
+ if (!dryRun && manifestPath && fs.existsSync(manifestPath)) {
464
+ logger?.logConsole?.(`Stamp manifest: ${manifestPath}`);
465
+ }
466
+
467
+ return { plans, stats, result, manifestPath };
468
+ }
package/lib/status.js ADDED
@@ -0,0 +1,78 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { isNormalizedMp3, isSourceMp3 } from './audio-policy.js';
4
+ import { isLossyAudioSource } from './extensions.js';
5
+ import { getMetadata } from './probe.js';
6
+
7
+ export function existingOutputUsable(outPath, expectedType, {
8
+ existsFn = fs.existsSync,
9
+ statFn = fs.statSync,
10
+ probeFn = getMetadata,
11
+ } = {}) {
12
+ if (!existsFn(outPath)) return false;
13
+ let size;
14
+ try {
15
+ size = statFn(outPath).size;
16
+ } catch {
17
+ return false;
18
+ }
19
+ if (size <= 0) return false;
20
+ const meta = probeFn(outPath);
21
+ if (!meta?.valid) return false;
22
+ if (expectedType && meta.mediaType !== expectedType) return false;
23
+ return true;
24
+ }
25
+
26
+ export function isConvertStatus(status) {
27
+ return status.startsWith('convert') || status.includes('+ mp3') || status.includes('→ mp3');
28
+ }
29
+
30
+ export function isSkippableStatus(status) {
31
+ return status.includes('skip (exists)') || status.includes('skip (normalized)') || status.includes('skip (resumed)');
32
+ }
33
+
34
+ export function matchesMediaMode(meta, mediaMode) {
35
+ if (mediaMode.audio && !mediaMode.video) return meta.mediaType === 'audio';
36
+ if (mediaMode.video && !mediaMode.audio) return meta.mediaType === 'video';
37
+ return meta.mediaType === 'video' || meta.mediaType === 'audio';
38
+ }
39
+
40
+ export function classifyStatus(input, meta, out, force, mediaMode, audioQuality, {
41
+ outputUsableFn = existingOutputUsable,
42
+ } = {}) {
43
+ if (!meta.valid) return 'unreadable';
44
+ if (!matchesMediaMode(meta, mediaMode)) return 'skip (wrong type)';
45
+ if (meta.mediaType === 'audio') {
46
+ if (!force && isSourceMp3(input, meta) && isNormalizedMp3(input, meta, audioQuality)) {
47
+ if (path.resolve(input) === path.resolve(out) || outputUsableFn(out, 'audio')) {
48
+ return 'skip (normalized)';
49
+ }
50
+ }
51
+ }
52
+ const expected = meta.mediaType === 'audio' ? 'audio' : 'video';
53
+ if (!force && outputUsableFn(out, expected)) return 'skip (exists)';
54
+ if (meta.mediaType === 'audio') {
55
+ return isLossyAudioSource(input) ? 'convert → mp3 [lossy]' : 'convert → mp3';
56
+ }
57
+ return 'convert → mp4';
58
+ }
59
+
60
+ export function classifyExtractStatus(_input, _meta, audioOut, force, _audioQuality, {
61
+ outputUsableFn = existingOutputUsable,
62
+ } = {}) {
63
+ if (!force && outputUsableFn(audioOut, 'audio')) return 'skip (exists)';
64
+ return 'convert → mp3 [extract]';
65
+ }
66
+
67
+ export function formatEntryStatus(videoStatus, extractStatus) {
68
+ if (!extractStatus) return videoStatus;
69
+ if (extractStatus.startsWith('convert')) {
70
+ return videoStatus.startsWith('convert') || videoStatus.startsWith('would convert')
71
+ ? `${videoStatus} + mp3 extract`
72
+ : `${videoStatus}; ${extractStatus}`;
73
+ }
74
+ if (videoStatus.startsWith('skip') && extractStatus.startsWith('skip')) {
75
+ return `${videoStatus}; mp3 ${extractStatus.replace('skip ', '')}`;
76
+ }
77
+ return `${videoStatus}; ${extractStatus}`;
78
+ }
package/lib/tags.js ADDED
@@ -0,0 +1,35 @@
1
+ export const DATE_TAG_KEYS = new Set(['date', 'creation_time', 'year', 'tdrc', 'tdor', 'originaldate']);
2
+ export const IMPORTANT_TAG_NAMES = ['title', 'artist', 'album', 'date', 'genre'];
3
+
4
+ export function normalizeTagKey(key) {
5
+ const lower = key.toLowerCase();
6
+ const colon = lower.lastIndexOf(':');
7
+ return colon >= 0 ? lower.slice(colon + 1) : lower;
8
+ }
9
+
10
+ export function extractTagInfo(tags = {}) {
11
+ const entries = Object.entries(tags).filter(([, v]) => v != null && String(v).trim() !== '');
12
+ return {
13
+ tags,
14
+ tagKeys: entries.map(([k]) => k),
15
+ tagCount: entries.length,
16
+ };
17
+ }
18
+
19
+ export function findTagValue(tags, name) {
20
+ const target = name.toLowerCase();
21
+ for (const [key, value] of Object.entries(tags)) {
22
+ if (normalizeTagKey(key) === target) return String(value).trim();
23
+ }
24
+ return null;
25
+ }
26
+
27
+ export function hasDateTag(tags) {
28
+ for (const key of Object.keys(tags)) {
29
+ if (DATE_TAG_KEYS.has(normalizeTagKey(key))) {
30
+ const value = String(tags[key]).trim();
31
+ if (value && value !== 'N/A') return true;
32
+ }
33
+ }
34
+ return false;
35
+ }
package/lib/time.js ADDED
@@ -0,0 +1,29 @@
1
+ export function secondsToHMS(seconds) {
2
+ const s = Math.floor(Math.max(0, Number(seconds) || 0));
3
+ const h = Math.floor(s / 3600);
4
+ const m = Math.floor((s % 3600) / 60);
5
+ const sec = s % 60;
6
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
7
+ }
8
+
9
+ export function formatHMSValue(v, _options, type) {
10
+ if (type === 'value' || type === 'total') return secondsToHMS(v);
11
+ return v;
12
+ }
13
+
14
+ export function formatTimeHMS(t, _options, _round) {
15
+ if (t === 'NULL' || t === 'INF' || t == null || Number.isNaN(Number(t))) return '--:--:--';
16
+ return secondsToHMS(t);
17
+ }
18
+
19
+ export function timeToSeconds(timeStr) {
20
+ const parts = timeStr.split(':');
21
+ const last = parseFloat(parts[parts.length - 1]) || 0;
22
+ if (parts.length === 3) {
23
+ return (parseInt(parts[0], 10) || 0) * 3600 + (parseInt(parts[1], 10) || 0) * 60 + Math.floor(last);
24
+ }
25
+ if (parts.length === 2) {
26
+ return (parseInt(parts[0], 10) || 0) * 60 + Math.floor(last);
27
+ }
28
+ return Math.floor(last);
29
+ }
@@ -0,0 +1,46 @@
1
+ import fs from 'fs';
2
+ import { execFileSync } from 'child_process';
3
+
4
+ export const CREATED_REPAIR_DAYS = 30;
5
+
6
+ export function readFileTimes(filePath) {
7
+ const stats = fs.statSync(filePath);
8
+ return {
9
+ atime: stats.atime,
10
+ mtime: stats.mtime,
11
+ birthtime: stats.birthtime,
12
+ ctime: stats.ctime,
13
+ };
14
+ }
15
+
16
+ export function createdIsMovedCopy(times, { days = CREATED_REPAIR_DAYS } = {}) {
17
+ const created = times.birthtime?.getTime?.() || times.ctime.getTime();
18
+ const modified = times.mtime.getTime();
19
+ return created - modified > days * 24 * 60 * 60 * 1000;
20
+ }
21
+
22
+ export function applyFileTimes(filePath, times) {
23
+ fs.utimesSync(filePath, times.atime, times.mtime);
24
+ if (process.platform === 'win32' && times.birthtime) {
25
+ execFileSync('powershell', [
26
+ '-NoProfile', '-Command',
27
+ `$p = ${JSON.stringify(filePath)}; $c = [datetime]${JSON.stringify(times.birthtime.toISOString())}; `
28
+ + '$f = Get-Item -LiteralPath $p; $f.CreationTime = $c; $f.LastWriteTime = [datetime]'
29
+ + JSON.stringify(times.mtime.toISOString()),
30
+ ], { stdio: 'ignore' });
31
+ }
32
+ }
33
+
34
+ export function repairCreatedIfMoved(filePath, { days = CREATED_REPAIR_DAYS } = {}) {
35
+ const times = readFileTimes(filePath);
36
+ if (!createdIsMovedCopy(times, { days })) return false;
37
+ const fixed = { ...times, birthtime: times.mtime, ctime: times.mtime };
38
+ applyFileTimes(filePath, fixed);
39
+ return true;
40
+ }
41
+
42
+ export function copyAndRepairTimestamps(input, output, { repairCreated = true } = {}) {
43
+ const times = readFileTimes(input);
44
+ applyFileTimes(output, times);
45
+ if (repairCreated) repairCreatedIfMoved(output);
46
+ }
package/lib/tools.js ADDED
@@ -0,0 +1,13 @@
1
+ import { execFileSync } from 'child_process';
2
+
3
+ export function requireTools(tools = ['ffmpeg', 'ffprobe']) {
4
+ const missing = [];
5
+ for (const tool of tools) {
6
+ try {
7
+ execFileSync(tool, ['-version'], { stdio: 'pipe' });
8
+ } catch {
9
+ missing.push(tool);
10
+ }
11
+ }
12
+ return missing;
13
+ }