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/lib/recup-map.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { AUDIO_EXTS, VIDEO_EXTS } from './constants.js';
|
|
4
|
+
import {
|
|
5
|
+
buildSizeQuery,
|
|
6
|
+
detectEverything,
|
|
7
|
+
searchEverything,
|
|
8
|
+
} from './everything.js';
|
|
9
|
+
import { formatSize } from './format.js';
|
|
10
|
+
import { sha256File } from './hash.js';
|
|
11
|
+
import { uniqueDestPath } from './paths.js';
|
|
12
|
+
|
|
13
|
+
export { uniqueDestPath };
|
|
14
|
+
|
|
15
|
+
const JUNK_PATH = [
|
|
16
|
+
/\\appdata\\/i,
|
|
17
|
+
/\\photostructure\\/i,
|
|
18
|
+
/\\node_modules\\/i,
|
|
19
|
+
/\\windows\\/i,
|
|
20
|
+
/\\program files/i,
|
|
21
|
+
/\\\$recycle/i,
|
|
22
|
+
/\\recup_dir\.\d+/i,
|
|
23
|
+
/\\qmlcache\\/i,
|
|
24
|
+
/\\\.svn\\/i,
|
|
25
|
+
/\\previews\\/i,
|
|
26
|
+
/\\huggingface_hub\\/i,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const GOOD_PATH = [
|
|
30
|
+
/\\diskimages\\/i,
|
|
31
|
+
/\\google drive\\/i,
|
|
32
|
+
/\\voicenotes\\/i,
|
|
33
|
+
/\\documents\\/i,
|
|
34
|
+
/\\memories\\/i,
|
|
35
|
+
/\\phonevoicenotes\\/i,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const ANCHORS = [
|
|
39
|
+
'voicenotes', 'memories', 'documents', 'my documents', 'desktop',
|
|
40
|
+
'pictures', 'music', 'google drive', 'phonevoicenotes', 'data',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_RECUP_EXTS = new Set([
|
|
44
|
+
...[...AUDIO_EXTS].map(e => e.slice(1)),
|
|
45
|
+
...[...VIDEO_EXTS].map(e => e.slice(1)),
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export function parseExtList(value) {
|
|
49
|
+
if (!value) return new Set(DEFAULT_RECUP_EXTS);
|
|
50
|
+
return new Set(String(value).split(/[,;\s]+/).map(e => e.replace(/^\./, '').toLowerCase()).filter(Boolean));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function scoreHit(filePath) {
|
|
54
|
+
let score = 0;
|
|
55
|
+
for (const re of JUNK_PATH) {
|
|
56
|
+
if (re.test(filePath)) score -= 50;
|
|
57
|
+
}
|
|
58
|
+
for (const re of GOOD_PATH) {
|
|
59
|
+
if (re.test(filePath)) score += 20;
|
|
60
|
+
}
|
|
61
|
+
if (!/^f\d+/i.test(path.basename(filePath))) score += 10;
|
|
62
|
+
return score;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function proposedRelPath(filePath) {
|
|
66
|
+
const parts = path.normalize(filePath).split(/[/\\]/).filter(Boolean);
|
|
67
|
+
const idx = parts.findIndex(seg => ANCHORS.includes(seg.toLowerCase()));
|
|
68
|
+
if (idx >= 0) return parts.slice(idx).join('/');
|
|
69
|
+
return parts.slice(Math.max(1, parts.length - 3)).join('/');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function pickPlacement(hits) {
|
|
73
|
+
const ranked = hits
|
|
74
|
+
.map(hit => ({ ...hit, score: scoreHit(hit.path), proposed: proposedRelPath(hit.path) }))
|
|
75
|
+
.filter(hit => hit.score > 0)
|
|
76
|
+
.sort((a, b) => b.score - a.score);
|
|
77
|
+
if (ranked.length === 0) return null;
|
|
78
|
+
const best = ranked[0];
|
|
79
|
+
const tied = ranked.filter(hit => hit.proposed !== best.proposed && hit.score >= best.score - 5);
|
|
80
|
+
return {
|
|
81
|
+
proposed: best.proposed,
|
|
82
|
+
score: best.score,
|
|
83
|
+
source: best.path,
|
|
84
|
+
ambiguous: tied.length > 0,
|
|
85
|
+
alts: tied.map(hit => hit.proposed),
|
|
86
|
+
hits: ranked,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function discoverRecupFiles(root, extSet) {
|
|
91
|
+
const files = [];
|
|
92
|
+
if (!fs.existsSync(root)) return files;
|
|
93
|
+
for (const name of fs.readdirSync(root)) {
|
|
94
|
+
const full = path.join(root, name);
|
|
95
|
+
let stat;
|
|
96
|
+
try { stat = fs.statSync(full); } catch { continue; }
|
|
97
|
+
if (stat.isFile()) {
|
|
98
|
+
const ext = path.extname(name).slice(1).toLowerCase();
|
|
99
|
+
if (!extSet.size || extSet.has(ext)) files.push(full);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!stat.isDirectory()) continue;
|
|
103
|
+
for (const file of fs.readdirSync(full)) {
|
|
104
|
+
const child = path.join(full, file);
|
|
105
|
+
try {
|
|
106
|
+
if (!fs.statSync(child).isFile()) continue;
|
|
107
|
+
} catch { continue; }
|
|
108
|
+
const ext = path.extname(file).slice(1).toLowerCase();
|
|
109
|
+
if (!extSet.size || extSet.has(ext)) files.push(child);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return files.sort();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function formatRecupMapLines({ folder, everythingVersion, rows, extList }) {
|
|
116
|
+
const placed = rows.filter(row => row.placement && !row.placement.ambiguous);
|
|
117
|
+
const ambiguous = rows.filter(row => row.placement?.ambiguous);
|
|
118
|
+
const unmatched = rows.filter(row => !row.placement);
|
|
119
|
+
const errors = rows.filter(row => row.error);
|
|
120
|
+
|
|
121
|
+
const folders = new Map();
|
|
122
|
+
for (const row of placed) {
|
|
123
|
+
const dir = path.posix.dirname(row.placement.proposed).replace(/^\.$/, '(root)');
|
|
124
|
+
if (!folders.has(dir)) folders.set(dir, []);
|
|
125
|
+
folders.get(dir).push(row);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const lines = [
|
|
129
|
+
'MediaTuna recuperated-folder map (Everything)',
|
|
130
|
+
`Folder: ${folder}`,
|
|
131
|
+
`Everything: ${everythingVersion}`,
|
|
132
|
+
`Extensions: ${[...extList].sort().join(', ')}`,
|
|
133
|
+
'Match: size + extension + path score (not byte-identical unless marked SHA-256).',
|
|
134
|
+
`Files: ${rows.length} placed: ${placed.length} ambiguous: ${ambiguous.length} unmatched: ${unmatched.length}${errors.length ? ` errors: ${errors.length}` : ''}`,
|
|
135
|
+
'',
|
|
136
|
+
'## Proposed folders',
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
for (const dir of [...folders.keys()].sort()) {
|
|
140
|
+
lines.push(`${dir}/ (${folders.get(dir).length})`);
|
|
141
|
+
}
|
|
142
|
+
if (folders.size === 0) lines.push('(none)');
|
|
143
|
+
lines.push('');
|
|
144
|
+
|
|
145
|
+
lines.push('## Placed (copy found elsewhere)');
|
|
146
|
+
if (placed.length === 0) lines.push('(none)');
|
|
147
|
+
for (const row of placed) {
|
|
148
|
+
const matchNote = row.hashMatch === true
|
|
149
|
+
? 'SHA-256 match'
|
|
150
|
+
: row.hashMatch === false
|
|
151
|
+
? 'size + path (hash differs — not copied if --hash)'
|
|
152
|
+
: 'size + path (not hashed)';
|
|
153
|
+
lines.push(`${row.placement.proposed} (${formatSize(row.size)})`);
|
|
154
|
+
lines.push(` from ${row.input}`);
|
|
155
|
+
lines.push(` copy ${row.placement.source}`);
|
|
156
|
+
lines.push(` match ${matchNote}`);
|
|
157
|
+
lines.push('');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (ambiguous.length > 0) {
|
|
161
|
+
lines.push('## Ambiguous (several plausible homes)');
|
|
162
|
+
for (const row of ambiguous) {
|
|
163
|
+
lines.push(`${path.basename(row.input)} (${formatSize(row.size)})`);
|
|
164
|
+
lines.push(` from ${row.input}`);
|
|
165
|
+
lines.push(` best ${row.placement.proposed}`);
|
|
166
|
+
for (const alt of row.placement.alts) lines.push(` alt ${alt}`);
|
|
167
|
+
lines.push('');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
lines.push('## Unmatched (no useful copy outside this dump)');
|
|
172
|
+
if (unmatched.length === 0) lines.push('(none)');
|
|
173
|
+
for (const row of unmatched) {
|
|
174
|
+
lines.push(`${row.input} (${formatSize(row.size)})`);
|
|
175
|
+
}
|
|
176
|
+
lines.push('');
|
|
177
|
+
|
|
178
|
+
if (errors.length > 0) {
|
|
179
|
+
lines.push('## Errors');
|
|
180
|
+
for (const row of errors) lines.push(`${row.input}: ${row.error}`);
|
|
181
|
+
lines.push('');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return lines;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function buildRecupRows(files, {
|
|
188
|
+
rootDir,
|
|
189
|
+
searchFn,
|
|
190
|
+
onProgress = null,
|
|
191
|
+
} = {}) {
|
|
192
|
+
const rows = [];
|
|
193
|
+
for (let i = 0; i < files.length; i++) {
|
|
194
|
+
const input = files[i];
|
|
195
|
+
onProgress?.(i + 1, files.length, input);
|
|
196
|
+
let size;
|
|
197
|
+
try {
|
|
198
|
+
size = fs.statSync(input).size;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
rows.push({ input, size: 0, hits: [], placement: null, error: `stat failed: ${err.message}` });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const ext = path.extname(input).slice(1).toLowerCase();
|
|
205
|
+
const hits = searchFn(buildSizeQuery(size, ext), rootDir)
|
|
206
|
+
.filter(hit => path.extname(hit.path).toLowerCase() === `.${ext}`);
|
|
207
|
+
rows.push({
|
|
208
|
+
input,
|
|
209
|
+
size,
|
|
210
|
+
hits,
|
|
211
|
+
placement: pickPlacement(hits),
|
|
212
|
+
error: null,
|
|
213
|
+
});
|
|
214
|
+
} catch (err) {
|
|
215
|
+
rows.push({ input, size, hits: [], placement: null, error: err.message });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return rows;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function annotateRecupHashes(rows, { hashFn = sha256File } = {}) {
|
|
222
|
+
for (const row of rows) {
|
|
223
|
+
if (!row.placement?.source || row.placement.ambiguous) continue;
|
|
224
|
+
try {
|
|
225
|
+
const [sourceDigest, goldDigest] = await Promise.all([
|
|
226
|
+
hashFn(row.input),
|
|
227
|
+
hashFn(row.placement.source),
|
|
228
|
+
]);
|
|
229
|
+
row.hashMatch = sourceDigest === goldDigest;
|
|
230
|
+
} catch (err) {
|
|
231
|
+
row.hashMatch = null;
|
|
232
|
+
row.hashError = err.message;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return rows;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function runRecupMap({
|
|
239
|
+
rootDir,
|
|
240
|
+
extSet = DEFAULT_RECUP_EXTS,
|
|
241
|
+
dryRun = false,
|
|
242
|
+
apply = false,
|
|
243
|
+
hash = false,
|
|
244
|
+
detectFn = detectEverything,
|
|
245
|
+
searchFn = null,
|
|
246
|
+
hashFn = sha256File,
|
|
247
|
+
writeFileFn = fs.writeFileSync,
|
|
248
|
+
copyFileFn = fs.copyFileSync,
|
|
249
|
+
onProgress = null,
|
|
250
|
+
} = {}) {
|
|
251
|
+
const everything = detectFn();
|
|
252
|
+
const query = searchFn ?? ((terms, excludeRoot) => searchEverything(
|
|
253
|
+
[...terms, `!path:${excludeRoot}`],
|
|
254
|
+
{ esPath: everything.esPath, instance: everything.instance, maxResults: 40 },
|
|
255
|
+
));
|
|
256
|
+
|
|
257
|
+
const files = discoverRecupFiles(rootDir, extSet);
|
|
258
|
+
const rows = await buildRecupRows(files, { rootDir, searchFn: query, onProgress });
|
|
259
|
+
if (hash) await annotateRecupHashes(rows, { hashFn });
|
|
260
|
+
const lines = formatRecupMapLines({
|
|
261
|
+
folder: rootDir,
|
|
262
|
+
everythingVersion: everything.version.raw,
|
|
263
|
+
rows,
|
|
264
|
+
extList: extSet,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const reportPath = path.join(rootDir, 'mediatuna-recup-map.txt');
|
|
268
|
+
if (!dryRun) writeFileFn(reportPath, lines.join('\n') + '\n');
|
|
269
|
+
|
|
270
|
+
const treeDir = path.join(rootDir, 'proposed-tree');
|
|
271
|
+
const tree = apply
|
|
272
|
+
? applyRecupTree(rows, treeDir, { dryRun, copyFileFn, requireHash: hash })
|
|
273
|
+
: { copied: 0, skipped: 0, errors: [], treeDir: null };
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
lines,
|
|
277
|
+
rows,
|
|
278
|
+
reportPath: dryRun ? null : reportPath,
|
|
279
|
+
treeDir: apply && !dryRun ? treeDir : null,
|
|
280
|
+
everything,
|
|
281
|
+
stats: {
|
|
282
|
+
files: rows.length,
|
|
283
|
+
placed: rows.filter(r => r.placement && !r.placement.ambiguous).length,
|
|
284
|
+
ambiguous: rows.filter(r => r.placement?.ambiguous).length,
|
|
285
|
+
unmatched: rows.filter(r => !r.placement && !r.error).length,
|
|
286
|
+
errors: rows.filter(r => r.error).length,
|
|
287
|
+
copied: tree.copied,
|
|
288
|
+
skipped: tree.skipped,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function isInside(parent, child) {
|
|
294
|
+
const root = path.resolve(parent).toLowerCase();
|
|
295
|
+
const target = path.resolve(child).toLowerCase();
|
|
296
|
+
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
|
|
297
|
+
return target === root || target.startsWith(prefix);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function collectRecupCleanup(rows, treeDir, {
|
|
301
|
+
rootDir,
|
|
302
|
+
existsFn = fs.existsSync,
|
|
303
|
+
statFn = fs.statSync,
|
|
304
|
+
hashFn = sha256File,
|
|
305
|
+
} = {}) {
|
|
306
|
+
const resolvedTree = path.resolve(treeDir);
|
|
307
|
+
const resolvedRoot = path.resolve(rootDir ?? path.dirname(treeDir));
|
|
308
|
+
const eligible = [];
|
|
309
|
+
for (const row of rows) {
|
|
310
|
+
if (!row.placement || row.placement.ambiguous) continue;
|
|
311
|
+
const dest = path.resolve(resolvedTree, row.placement.proposed);
|
|
312
|
+
const src = path.resolve(row.input);
|
|
313
|
+
if (!isInside(resolvedTree, dest)) continue;
|
|
314
|
+
if (!isInside(resolvedRoot, src)) continue;
|
|
315
|
+
if (isInside(resolvedTree, src)) continue;
|
|
316
|
+
if (src.toLowerCase() === dest.toLowerCase()) continue;
|
|
317
|
+
if (!existsFn(dest) || !existsFn(src)) continue;
|
|
318
|
+
try {
|
|
319
|
+
if (statFn(dest).size !== statFn(src).size) continue;
|
|
320
|
+
const [srcHash, destHash] = await Promise.all([hashFn(src), hashFn(dest)]);
|
|
321
|
+
if (srcHash !== destHash) continue;
|
|
322
|
+
} catch {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
eligible.push({ input: src, out: dest, hashMatch: true });
|
|
326
|
+
}
|
|
327
|
+
return eligible;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function applyRecupTree(rows, treeDir, {
|
|
331
|
+
dryRun = false,
|
|
332
|
+
copyFileFn = fs.copyFileSync,
|
|
333
|
+
mkdirFn = fs.mkdirSync,
|
|
334
|
+
existsFn = fs.existsSync,
|
|
335
|
+
requireHash = false,
|
|
336
|
+
} = {}) {
|
|
337
|
+
const result = { copied: 0, skipped: 0, errors: [], treeDir };
|
|
338
|
+
const placeable = rows.filter(row => {
|
|
339
|
+
if (!row.placement || row.placement.ambiguous) return false;
|
|
340
|
+
if (requireHash && row.hashMatch !== true) return false;
|
|
341
|
+
return true;
|
|
342
|
+
});
|
|
343
|
+
if (dryRun) {
|
|
344
|
+
result.copied = placeable.length;
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
mkdirFn(treeDir, { recursive: true });
|
|
348
|
+
for (const row of placeable) {
|
|
349
|
+
try {
|
|
350
|
+
const destBase = path.join(treeDir, row.placement.proposed);
|
|
351
|
+
if (existsFn(destBase) && fs.statSync(destBase).size === fs.statSync(row.input).size) {
|
|
352
|
+
result.skipped++;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const dest = uniqueDestPath(destBase, existsFn);
|
|
356
|
+
mkdirFn(path.dirname(dest), { recursive: true });
|
|
357
|
+
copyFileFn(row.input, dest);
|
|
358
|
+
const stat = fs.statSync(row.input);
|
|
359
|
+
fs.utimesSync(dest, stat.atime, stat.mtime);
|
|
360
|
+
result.copied++;
|
|
361
|
+
} catch (err) {
|
|
362
|
+
result.errors.push(`${row.input}: ${err.message}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { hasMediaExt } from './extensions.js';
|
|
4
|
+
import { discoverFiles } from './discover.js';
|
|
5
|
+
import { filterByGlobs } from './globs.js';
|
|
6
|
+
|
|
7
|
+
export function resolveInputFiles({ target, recursive, mediaMode, cwd = process.cwd() }) {
|
|
8
|
+
const combinedMode = mediaMode.video && mediaMode.audio;
|
|
9
|
+
const audioOnlyMode = mediaMode.audio && !mediaMode.video;
|
|
10
|
+
|
|
11
|
+
if (target && fs.existsSync(target) && !fs.statSync(target).isDirectory()) {
|
|
12
|
+
return {
|
|
13
|
+
files: [path.resolve(target)],
|
|
14
|
+
mode: 'single',
|
|
15
|
+
targetPath: path.resolve(target),
|
|
16
|
+
combinedMode,
|
|
17
|
+
audioOnlyMode,
|
|
18
|
+
videoOnlyMode: mediaMode.video && !mediaMode.audio,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const targetPath = path.resolve(target || cwd);
|
|
23
|
+
if (!fs.existsSync(targetPath)) {
|
|
24
|
+
return { error: `path not found: ${targetPath}` };
|
|
25
|
+
}
|
|
26
|
+
if (!fs.statSync(targetPath).isDirectory()) {
|
|
27
|
+
return { error: `not a file or folder: ${targetPath}` };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
files: null,
|
|
32
|
+
mode: 'folder',
|
|
33
|
+
targetPath,
|
|
34
|
+
recursive,
|
|
35
|
+
combinedMode,
|
|
36
|
+
audioOnlyMode,
|
|
37
|
+
videoOnlyMode: mediaMode.video && !mediaMode.audio,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function loadInputFiles(resolved, mediaMode, {
|
|
42
|
+
stampDates = false, include = [], exclude = [],
|
|
43
|
+
} = {}) {
|
|
44
|
+
if (resolved.files) {
|
|
45
|
+
return filterByGlobs(resolved.files, {
|
|
46
|
+
include,
|
|
47
|
+
exclude,
|
|
48
|
+
rootDir: path.dirname(resolved.files[0]),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return discoverFiles(resolved.targetPath, resolved.recursive, mediaMode, { stampDates, include, exclude });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function mediaModeHint({ combinedMode, audioOnlyMode }) {
|
|
55
|
+
if (combinedMode) return 'video or audio files';
|
|
56
|
+
if (audioOnlyMode) return 'audio files';
|
|
57
|
+
return 'video files';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function warnUnknownExtension(resolved, mediaMode, { stampDates = false } = {}) {
|
|
61
|
+
if (resolved.mode !== 'single') return null;
|
|
62
|
+
if (hasMediaExt(resolved.files[0], mediaMode, { stampDates })) return null;
|
|
63
|
+
const expected = resolved.combinedMode ? 'video or audio' : resolved.audioOnlyMode ? 'audio' : 'video';
|
|
64
|
+
return { file: path.basename(resolved.files[0]), expected };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function formatUnknownExtensionError({ file, expected }) {
|
|
68
|
+
return `${file} is not a known ${expected} extension. Folder scans skip unknown types; pass a supported media file.`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildModeParts({
|
|
72
|
+
cleanupOriginals, stampDates, stampVideo = true, combinedMode, audioOnlyMode, nvenc, quality, deinterlace,
|
|
73
|
+
mediaMode, preferMtime, embedArt, extractAudio, audioQuality, verify,
|
|
74
|
+
deleteOriginals, deletePermanent = false, dryRun, resume, jobs = 1, dupeReport = false, dupeHash = false,
|
|
75
|
+
recupMap = false, recupApply = false, archiveDir = null, sampleSeconds = null,
|
|
76
|
+
reencodeAudio = false,
|
|
77
|
+
}) {
|
|
78
|
+
if (recupMap) {
|
|
79
|
+
const parts = ['recup-map'];
|
|
80
|
+
if (recupApply) parts.push('apply');
|
|
81
|
+
if (dupeHash) parts.push('hash');
|
|
82
|
+
if (cleanupOriginals) parts.push('cleanup-originals');
|
|
83
|
+
if (deletePermanent) parts.push('delete-permanent');
|
|
84
|
+
if (dryRun) parts.push('dry-run');
|
|
85
|
+
return parts;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (dupeReport) {
|
|
89
|
+
const parts = ['dupe-report'];
|
|
90
|
+
if (dupeHash) parts.push('hash');
|
|
91
|
+
if (dryRun) parts.push('dry-run');
|
|
92
|
+
return parts;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (stampDates) {
|
|
96
|
+
const parts = ['stamp-dates'];
|
|
97
|
+
if (preferMtime) parts.push('prefer-mtime');
|
|
98
|
+
if (dryRun) parts.push('dry-run');
|
|
99
|
+
return parts;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const modeParts = cleanupOriginals
|
|
103
|
+
? ['cleanup-originals', 'verify']
|
|
104
|
+
: combinedMode
|
|
105
|
+
? ['video+audio', quality]
|
|
106
|
+
: audioOnlyMode
|
|
107
|
+
? ['audio', quality]
|
|
108
|
+
: [`${nvenc ? 'NVENC' : 'CPU'}`, quality];
|
|
109
|
+
if (!cleanupOriginals && mediaMode.video) modeParts.push(deinterlace);
|
|
110
|
+
if (!cleanupOriginals && mediaMode.video && stampVideo) modeParts.push('stamp');
|
|
111
|
+
if (!cleanupOriginals && mediaMode.audio && preferMtime) modeParts.push('prefer-mtime');
|
|
112
|
+
if (!cleanupOriginals && mediaMode.audio && !embedArt) modeParts.push('no-embed-art');
|
|
113
|
+
if (extractAudio) modeParts.push('extract-audio');
|
|
114
|
+
if (audioQuality !== quality) modeParts.push(`audio:${audioQuality}`);
|
|
115
|
+
if (!cleanupOriginals && verify) modeParts.push('verify');
|
|
116
|
+
if (deleteOriginals) modeParts.push('delete-originals');
|
|
117
|
+
if (deletePermanent) modeParts.push('delete-permanent');
|
|
118
|
+
if (archiveDir) modeParts.push('archive');
|
|
119
|
+
if (sampleSeconds) modeParts.push(`sample:${sampleSeconds}s`);
|
|
120
|
+
if (reencodeAudio) modeParts.push('reencode-audio');
|
|
121
|
+
if (resume) modeParts.push('resume');
|
|
122
|
+
if (jobs > 1) modeParts.push(`jobs:${jobs}`);
|
|
123
|
+
if (dryRun) modeParts.push('dry-run');
|
|
124
|
+
return modeParts;
|
|
125
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { formatEntryStatus } from './status.js';
|
|
4
|
+
|
|
5
|
+
export const STATE_FILENAME = '.mediatuna-state.json';
|
|
6
|
+
export const STATE_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
export function buildRunKey({
|
|
9
|
+
outputDir, quality, audioQuality, deinterlace, mediaMode, extractAudio, verify,
|
|
10
|
+
stampVideo = true, reencodeAudio = false,
|
|
11
|
+
}) {
|
|
12
|
+
return JSON.stringify({
|
|
13
|
+
outputDir: outputDir ?? null,
|
|
14
|
+
quality,
|
|
15
|
+
audioQuality,
|
|
16
|
+
deinterlace,
|
|
17
|
+
video: mediaMode.video,
|
|
18
|
+
audio: mediaMode.audio,
|
|
19
|
+
extractAudio,
|
|
20
|
+
verify,
|
|
21
|
+
stampVideo,
|
|
22
|
+
reencodeAudio,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function defaultStatePath(logFile) {
|
|
27
|
+
return path.join(path.dirname(logFile), STATE_FILENAME);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadResumeState(statePath) {
|
|
31
|
+
try {
|
|
32
|
+
if (!fs.existsSync(statePath)) return null;
|
|
33
|
+
const data = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
34
|
+
if (data?.version !== STATE_VERSION || !Array.isArray(data.completed)) return null;
|
|
35
|
+
return {
|
|
36
|
+
version: STATE_VERSION,
|
|
37
|
+
runKey: data.runKey ?? '',
|
|
38
|
+
completed: [...new Set(data.completed.map(p => path.resolve(p)))],
|
|
39
|
+
updated: data.updated ?? null,
|
|
40
|
+
};
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createResumeState(runKey) {
|
|
47
|
+
return {
|
|
48
|
+
version: STATE_VERSION,
|
|
49
|
+
runKey,
|
|
50
|
+
completed: [],
|
|
51
|
+
updated: null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function saveResumeState(statePath, state) {
|
|
56
|
+
const payload = {
|
|
57
|
+
version: STATE_VERSION,
|
|
58
|
+
runKey: state.runKey,
|
|
59
|
+
completed: [...new Set(state.completed.map(p => path.resolve(p)))],
|
|
60
|
+
updated: new Date().toISOString(),
|
|
61
|
+
};
|
|
62
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
63
|
+
fs.writeFileSync(statePath, JSON.stringify(payload, null, 2) + '\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function markCompleted(state, inputPath) {
|
|
67
|
+
const resolved = path.resolve(inputPath);
|
|
68
|
+
if (!state.completed.includes(resolved)) {
|
|
69
|
+
state.completed.push(resolved);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isMarkedCompleted(state, inputPath) {
|
|
74
|
+
return state.completed.includes(path.resolve(inputPath));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function entryNeedsWork(entry, mediaMode, extractAudio) {
|
|
78
|
+
const { videoStatus, extractStatus, meta } = entry;
|
|
79
|
+
if (videoStatus === 'unreadable') return false;
|
|
80
|
+
if (videoStatus === 'skip (wrong type)' && !extractStatus) return false;
|
|
81
|
+
|
|
82
|
+
let needs = false;
|
|
83
|
+
if (meta.mediaType === 'video' && mediaMode.video && videoStatus !== 'skip (wrong type)') {
|
|
84
|
+
if (videoStatus.startsWith('convert')) needs = true;
|
|
85
|
+
} else if (meta.mediaType === 'audio' && mediaMode.audio) {
|
|
86
|
+
if (videoStatus.startsWith('convert')) needs = true;
|
|
87
|
+
}
|
|
88
|
+
if (extractStatus && extractAudio && extractStatus.startsWith('convert')) {
|
|
89
|
+
needs = true;
|
|
90
|
+
}
|
|
91
|
+
return needs;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function verifyEntryOutputs(entry, verifyOutputFn) {
|
|
95
|
+
const primaryType = entry.meta.mediaType === 'audio' ? 'audio' : 'video';
|
|
96
|
+
if (entry.out && (entry.meta.mediaType === 'video' || entry.meta.mediaType === 'audio')) {
|
|
97
|
+
if (!fs.existsSync(entry.out)) return { ok: false, reason: 'output missing' };
|
|
98
|
+
const check = verifyOutputFn(entry.out, entry.meta.duration, primaryType);
|
|
99
|
+
if (!check.ok) return check;
|
|
100
|
+
}
|
|
101
|
+
if (entry.audioOut && entry.extractStatus) {
|
|
102
|
+
if (!fs.existsSync(entry.audioOut)) return { ok: false, reason: 'extract output missing' };
|
|
103
|
+
const check = verifyOutputFn(entry.audioOut, entry.meta.duration, 'audio');
|
|
104
|
+
if (!check.ok) return check;
|
|
105
|
+
}
|
|
106
|
+
return { ok: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function shouldSkipResumed(entry, state, { mediaMode, extractAudio, verifyOutputFn, force = false }) {
|
|
110
|
+
if (force) return false;
|
|
111
|
+
if (!state || !isMarkedCompleted(state, entry.input)) return false;
|
|
112
|
+
if (!entryNeedsWork(entry, mediaMode, extractAudio)) return false;
|
|
113
|
+
const check = verifyEntryOutputs(entry, verifyOutputFn);
|
|
114
|
+
return check.ok;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function applyResumeToPreflight(preflight, state, options) {
|
|
118
|
+
if (!state || !options.resume) return { entries: preflight, resumed: 0 };
|
|
119
|
+
let resumed = 0;
|
|
120
|
+
const entries = preflight.map(entry => {
|
|
121
|
+
if (!shouldSkipResumed(entry, state, options)) return entry;
|
|
122
|
+
resumed++;
|
|
123
|
+
const videoStatus = entry.videoStatus.startsWith('convert') ? 'skip (resumed)' : entry.videoStatus;
|
|
124
|
+
let extractStatus = entry.extractStatus;
|
|
125
|
+
if (extractStatus?.startsWith('convert')) {
|
|
126
|
+
extractStatus = 'skip (resumed)';
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
...entry,
|
|
130
|
+
videoStatus,
|
|
131
|
+
extractStatus,
|
|
132
|
+
status: formatEntryStatus(videoStatus, extractStatus),
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
return { entries, resumed };
|
|
136
|
+
}
|