mediatuna 1.21.11 → 1.22.0
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/README.md +14 -6
- package/index.js +11 -0
- package/lib/cli-config.js +35 -0
- package/lib/ledger.js +169 -0
- package/lib/resolve-inputs.js +3 -1
- package/lib/run.js +53 -8
- package/package.json +3 -2
- package/skills/mediatuna/SKILL.md +81 -0
package/README.md
CHANGED
|
@@ -10,15 +10,12 @@ A home archive is mixed types, interlaced DV, missing tags, and Windows dates th
|
|
|
10
10
|
|
|
11
11
|
**Docs:** [mediatuna.dev/docs](https://mediatuna.dev/docs) · **Site:** [mediatuna.dev](https://mediatuna.dev)
|
|
12
12
|
|
|
13
|
-
Your **local agent** can run this for you — dry-run, sample, convert. It should call `mediatuna`, not invent an ffmpeg command line. [Agents](docs/agents.md).
|
|
14
|
-
|
|
15
13
|
## Install
|
|
16
14
|
|
|
17
15
|
[Node.js](https://nodejs.org/) 18+ and **ffmpeg** / **ffprobe** on PATH. [Full install](docs/install.md).
|
|
18
16
|
|
|
19
17
|
```bash
|
|
20
|
-
|
|
21
|
-
npm link
|
|
18
|
+
npm install -g mediatuna
|
|
22
19
|
mediatuna --version
|
|
23
20
|
```
|
|
24
21
|
|
|
@@ -36,15 +33,24 @@ More examples: [docs/usage.md](docs/usage.md). Every flag: [docs/options.md](doc
|
|
|
36
33
|
|
|
37
34
|
## What you get
|
|
38
35
|
|
|
36
|
+
### Core
|
|
37
|
+
|
|
39
38
|
- One pass over video and audio (or `--video-only` / `--audio-only`)
|
|
40
39
|
- Safe defaults — dry-run, duration verify, Recycle Bin, hash before recup cleanup
|
|
41
|
-
- Dates survive container tags, filenames, and Windows Created/Modified
|
|
42
40
|
- Skip normalized MP3s and existing outputs; `--resume` a long batch
|
|
43
41
|
- AAC copy when the source is already AAC LC (`--reencode-audio` to force 192k)
|
|
42
|
+
|
|
43
|
+
### Data preservation
|
|
44
|
+
|
|
45
|
+
- Dates survive container tags, filenames, and Windows Created/Modified
|
|
44
46
|
- Recovery helpers — Everything dupes, PhotoRec folder rebuild
|
|
45
47
|
|
|
46
48
|
Stays 100% local. Flags, logging, dates, and recovery live in the [docs](docs/README.md).
|
|
47
49
|
|
|
50
|
+
## Agents
|
|
51
|
+
|
|
52
|
+
A **local agent** (Cursor, Claude Code, and the like) can dry-run, sample, and convert for you. It should call `mediatuna`, not invent an ffmpeg command line. [Agents](docs/agents.md).
|
|
53
|
+
|
|
48
54
|
## Safety
|
|
49
55
|
|
|
50
56
|
Hardening is care, not a guarantee. Encoding is lossy; “same file” is usually size or duration. **Keep a backup you can restore from.** Details: [docs/safety.md](docs/safety.md).
|
|
@@ -53,6 +59,8 @@ Hardening is care, not a guarantee. Encoding is lossy; “same file” is usuall
|
|
|
53
59
|
|
|
54
60
|
## Development
|
|
55
61
|
|
|
62
|
+
Checkout, `pnpm install`, and `npm link` — [full install](docs/install.md).
|
|
63
|
+
|
|
56
64
|
```bash
|
|
57
65
|
pnpm test # unit suite; ffmpeg cases run only if ffmpeg is on PATH
|
|
58
66
|
pnpm test:ffmpeg # synthetic encode checks
|
|
@@ -62,4 +70,4 @@ Core logic is in `lib/`; `index.js` is the CLI. Site (FilePress + docs mount): `
|
|
|
62
70
|
|
|
63
71
|
## Roadmap
|
|
64
72
|
|
|
65
|
-
Shipped work and open items: [specs/improvements.md](specs/improvements.md), [specs/mediatuna.md](specs/mediatuna.md), [specs/partial/hardening.md](specs/partial/hardening.md), [specs/testing.md](specs/testing.md). Parked: [output formats](specs/output-formats.md), [agents](specs/agents.md).
|
|
73
|
+
Shipped work and open items: [specs/improvements.md](specs/improvements.md), [specs/mediatuna.md](specs/mediatuna.md), [specs/partial/hardening.md](specs/partial/hardening.md), [specs/testing.md](specs/testing.md). Parked: [output formats](specs/output-formats.md), [agents](specs/agents.md). Post-publish review: [specs/post-publish-review.md](specs/post-publish-review.md).
|
package/index.js
CHANGED
|
@@ -107,6 +107,8 @@ Options:
|
|
|
107
107
|
--recup-map Map a PhotoRec-style dump to folders using copies found elsewhere
|
|
108
108
|
--ext <list> With --recup-map: extensions (default: audio + phone video)
|
|
109
109
|
--apply With --recup-map: copy placed files into proposed-tree/
|
|
110
|
+
--ledger Write provenance into each new MP4/MP3 (comment + mediatuna tag)
|
|
111
|
+
--ledger-json Also append .mediatuna/archive.json under --output (or cwd)
|
|
110
112
|
|
|
111
113
|
Video formats: AVI, MOV, MOD, VOB, MTS, M2TS, MPG, MPEG, WMV, 3GP, 3G2 → MP4
|
|
112
114
|
Audio formats: MP3, FLAC, WAV, AIFF, M4A, AAC, OGG, Opus, WMA, AC3, DTS, AMR, QCP → MP3
|
|
@@ -175,6 +177,7 @@ const {
|
|
|
175
177
|
deleteOriginals, cleanupOriginals, audioQuality, extractAudio, resume, jobs: requestedJobs,
|
|
176
178
|
stampDates, stampVideo, backupDir, dupeReport, dupeHash, recupMap, recupExt, recupApply,
|
|
177
179
|
yes, deletePermanent, include, exclude, archiveDir, sampleSeconds, reencodeAudio,
|
|
180
|
+
ledger, ledgerJsonPath,
|
|
178
181
|
} = cli;
|
|
179
182
|
|
|
180
183
|
const LOG_FILE = cli.logFile;
|
|
@@ -554,6 +557,7 @@ const modeParts = buildModeParts({
|
|
|
554
557
|
nvenc, quality, deinterlace, mediaMode, preferMtime, embedArt, extractAudio, audioQuality,
|
|
555
558
|
verify, deleteOriginals, deletePermanent, dryRun, resume, jobs,
|
|
556
559
|
archiveDir, sampleSeconds, reencodeAudio,
|
|
560
|
+
ledger, ledgerJsonPath,
|
|
557
561
|
});
|
|
558
562
|
logConsole(`MediaTuna: ${files.length} files | ${modeParts.join(' | ')}`);
|
|
559
563
|
const sourceDir = resolved.mode === 'folder' ? resolved.targetPath : path.dirname(files[0]);
|
|
@@ -562,6 +566,10 @@ logConsole(formatWriteLocationBanner({
|
|
|
562
566
|
outputDir,
|
|
563
567
|
sourceDir,
|
|
564
568
|
}));
|
|
569
|
+
if (ledger) {
|
|
570
|
+
logFile('Ledger: embed provenance in each new MP4/MP3 (comment + mediatuna tag)');
|
|
571
|
+
if (ledgerJsonPath) logFile(`Ledger json: ${ledgerJsonPath}`);
|
|
572
|
+
}
|
|
565
573
|
|
|
566
574
|
let preflight = await buildPreflightEntries(files, outputDir, force, mediaMode, {
|
|
567
575
|
audioQuality,
|
|
@@ -687,6 +695,9 @@ const { stats, failedPaths, convertedInputs } = await runConversion({
|
|
|
687
695
|
config: {
|
|
688
696
|
dryRun, verify, keepPartial, quality, audioQuality, deinterlace, nvenc,
|
|
689
697
|
preferMtime, embedArt, extractAudio, mediaMode, verbose, jobs, sampleSeconds, reencodeAudio,
|
|
698
|
+
ledger, ledgerJsonPath,
|
|
699
|
+
sourceRoot: resolved.mode === 'folder' ? resolved.targetPath : path.dirname(files[0]),
|
|
700
|
+
mediatunaVersion: pkg.version,
|
|
690
701
|
},
|
|
691
702
|
logger,
|
|
692
703
|
progress: {
|
package/lib/cli-config.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import { VALID_DEINTERLACE, VALID_QUALITY } from './constants.js';
|
|
4
4
|
import { parseJobsValue } from './jobs.js';
|
|
5
5
|
import { parseGlobList } from './globs.js';
|
|
6
|
+
import { defaultLedgerJsonPath } from './ledger.js';
|
|
6
7
|
|
|
7
8
|
export class CliConfigError extends Error {
|
|
8
9
|
constructor(message) {
|
|
@@ -53,6 +54,8 @@ export const CLI_PARSE_OPTIONS = {
|
|
|
53
54
|
archive: { type: 'string' },
|
|
54
55
|
sample: { type: 'string' },
|
|
55
56
|
'reencode-audio': { type: 'boolean' },
|
|
57
|
+
ledger: { type: 'boolean' },
|
|
58
|
+
'ledger-json': { type: 'boolean' },
|
|
56
59
|
};
|
|
57
60
|
|
|
58
61
|
export function buildCliConfig(values, positionals, { cwd = process.cwd(), homedir = os.homedir() } = {}) {
|
|
@@ -280,6 +283,36 @@ export function buildCliConfig(values, positionals, { cwd = process.cwd(), homed
|
|
|
280
283
|
throw new CliConfigError('--sample cannot be used with --recup-map.');
|
|
281
284
|
}
|
|
282
285
|
|
|
286
|
+
const ledgerJson = values['ledger-json'] ?? false;
|
|
287
|
+
const ledger = (values.ledger ?? false) || ledgerJson;
|
|
288
|
+
|
|
289
|
+
if (ledger && values['stamp-dates']) {
|
|
290
|
+
throw new CliConfigError('--ledger cannot be used with --stamp-dates.');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (ledger && values['dupe-report']) {
|
|
294
|
+
throw new CliConfigError('--ledger cannot be used with --dupe-report.');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (ledger && values['recup-map']) {
|
|
298
|
+
throw new CliConfigError('--ledger cannot be used with --recup-map.');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (ledger && sampleSeconds) {
|
|
302
|
+
throw new CliConfigError('--ledger cannot be used with --sample (samples are not the archive).');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (ledger && values['cleanup-originals']) {
|
|
306
|
+
throw new CliConfigError('--ledger writes provenance on new outputs; omit --cleanup-originals.');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const ledgerJsonPath = ledgerJson
|
|
310
|
+
? defaultLedgerJsonPath({
|
|
311
|
+
outputDir: values.output ? path.resolve(values.output) : null,
|
|
312
|
+
cwd,
|
|
313
|
+
})
|
|
314
|
+
: null;
|
|
315
|
+
|
|
283
316
|
let jobs;
|
|
284
317
|
try {
|
|
285
318
|
jobs = parseJobsValue(values.jobs);
|
|
@@ -331,6 +364,8 @@ export function buildCliConfig(values, positionals, { cwd = process.cwd(), homed
|
|
|
331
364
|
archiveDir: values.archive ? path.resolve(values.archive) : null,
|
|
332
365
|
sampleSeconds,
|
|
333
366
|
reencodeAudio: values['reencode-audio'] ?? false,
|
|
367
|
+
ledger,
|
|
368
|
+
ledgerJsonPath,
|
|
334
369
|
target,
|
|
335
370
|
};
|
|
336
371
|
}
|
package/lib/ledger.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
4
|
+
import { parseFilenameDate } from './filename-dates.js';
|
|
5
|
+
import { parseCreationDate } from './stamp-dates.js';
|
|
6
|
+
|
|
7
|
+
export const LEDGER_JSON_NAME = 'archive.json';
|
|
8
|
+
|
|
9
|
+
let sidecarChain = Promise.resolve();
|
|
10
|
+
|
|
11
|
+
export function defaultLedgerJsonPath({ outputDir = null, cwd = process.cwd() } = {}) {
|
|
12
|
+
return path.join(outputDir || cwd, '.mediatuna', LEDGER_JSON_NAME);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function relativeLedgerPath(root, abs) {
|
|
16
|
+
const resolvedRoot = path.resolve(root);
|
|
17
|
+
const resolvedAbs = path.resolve(abs);
|
|
18
|
+
const rel = path.relative(resolvedRoot, resolvedAbs);
|
|
19
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
20
|
+
return resolvedAbs.split(path.sep).join('/');
|
|
21
|
+
}
|
|
22
|
+
return rel.split(path.sep).join('/');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isoFromParsed(parsed) {
|
|
26
|
+
if (!parsed) return null;
|
|
27
|
+
const date = `${parsed.year}-${parsed.month}-${parsed.day}`;
|
|
28
|
+
if (!parsed.hasTime) return date;
|
|
29
|
+
return `${date}T${parsed.hour}:${parsed.minute}:${parsed.second}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fromFilesystemTime(value) {
|
|
33
|
+
if (value == null || value === 'N/A') return null;
|
|
34
|
+
const parsed = parseCreationDate(value);
|
|
35
|
+
if (!parsed) return null;
|
|
36
|
+
return { sourceDate: isoFromParsed(parsed), dateSource: 'filesystem' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fromFilename(input) {
|
|
40
|
+
const hit = parseFilenameDate(path.basename(input));
|
|
41
|
+
if (!hit?.parsed) return null;
|
|
42
|
+
return {
|
|
43
|
+
sourceDate: isoFromParsed(hit.parsed),
|
|
44
|
+
dateSource: hit.source === 'mtime' ? 'filesystem' : 'filename',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveLedgerDate(meta, input, { preferMtime = false } = {}) {
|
|
49
|
+
const fromTag = parseCreationDate(meta?.creation_time);
|
|
50
|
+
if (fromTag) {
|
|
51
|
+
return { sourceDate: isoFromParsed(fromTag), dateSource: 'embedded' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const filenameHit = fromFilename(input);
|
|
55
|
+
const fsHit = fromFilesystemTime(meta?.modified_time);
|
|
56
|
+
|
|
57
|
+
if (preferMtime) return fsHit || filenameHit || { sourceDate: null, dateSource: null };
|
|
58
|
+
return filenameHit || fsHit || { sourceDate: null, dateSource: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function ledgerAction(mode) {
|
|
62
|
+
return mode === 'extract' ? 'extract' : 'transcode';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildLedgerRecord({
|
|
66
|
+
input,
|
|
67
|
+
out,
|
|
68
|
+
meta,
|
|
69
|
+
mode,
|
|
70
|
+
sourceRoot,
|
|
71
|
+
mediatunaVersion,
|
|
72
|
+
preferMtime = false,
|
|
73
|
+
verified = false,
|
|
74
|
+
durationDeltaMs = null,
|
|
75
|
+
processedAt = new Date().toISOString(),
|
|
76
|
+
}) {
|
|
77
|
+
const { sourceDate, dateSource } = resolveLedgerDate(meta, input, { preferMtime });
|
|
78
|
+
const ext = path.extname(input).slice(1).toLowerCase() || 'unknown';
|
|
79
|
+
return {
|
|
80
|
+
source: relativeLedgerPath(sourceRoot, input),
|
|
81
|
+
sourceFormat: ext,
|
|
82
|
+
sourceDate,
|
|
83
|
+
dateSource,
|
|
84
|
+
action: ledgerAction(mode),
|
|
85
|
+
output: relativeLedgerPath(sourceRoot, out),
|
|
86
|
+
verified,
|
|
87
|
+
durationDeltaMs,
|
|
88
|
+
processedAt,
|
|
89
|
+
mediatunaVersion,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function formatLedgerComment(record) {
|
|
94
|
+
const date = record.sourceDate ? ` ${record.sourceDate}` : '';
|
|
95
|
+
const dest = path.posix.basename(String(record.output || '').replaceAll('\\', '/'));
|
|
96
|
+
return `MediaTuna ${record.mediatunaVersion} ${record.action} ${record.source} → ${dest}${date}`.slice(0, 250);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildLedgerRemuxArgs(src, dest, record, { video }) {
|
|
100
|
+
const comment = formatLedgerComment(record);
|
|
101
|
+
const tag = JSON.stringify(record);
|
|
102
|
+
const args = [
|
|
103
|
+
'-hide_banner', '-loglevel', 'error', '-y',
|
|
104
|
+
'-i', src,
|
|
105
|
+
'-map', '0',
|
|
106
|
+
'-c', 'copy',
|
|
107
|
+
'-map_metadata', '0',
|
|
108
|
+
];
|
|
109
|
+
if (video) {
|
|
110
|
+
args.push('-movflags', '+faststart+use_metadata_tags');
|
|
111
|
+
} else {
|
|
112
|
+
args.push('-id3v2_version', '3', '-write_id3v1', '0');
|
|
113
|
+
}
|
|
114
|
+
args.push('-metadata', `comment=${comment}`, '-metadata', `mediatuna=${tag}`, dest);
|
|
115
|
+
return args;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function applyLedgerMetadata(outPath, record, { ffmpeg = 'ffmpeg' } = {}) {
|
|
119
|
+
const ext = path.extname(outPath);
|
|
120
|
+
const tmp = `${outPath}.ledger${ext}`;
|
|
121
|
+
const args = buildLedgerRemuxArgs(outPath, tmp, record, {
|
|
122
|
+
video: ext.toLowerCase() === '.mp4',
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
execFileSync(ffmpeg, args, { stdio: 'pipe', timeout: 120_000 });
|
|
126
|
+
if (!fs.existsSync(tmp)) {
|
|
127
|
+
throw new Error('ledger remux produced no file');
|
|
128
|
+
}
|
|
129
|
+
if (fs.existsSync(outPath)) fs.unlinkSync(outPath);
|
|
130
|
+
fs.renameSync(tmp, outPath);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
try {
|
|
133
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
134
|
+
} catch { /* keep the original output */ }
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function upsertLedgerSidecar(filePath, record) {
|
|
140
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
141
|
+
let doc = { version: 1, updatedAt: record.processedAt, entries: [] };
|
|
142
|
+
if (fs.existsSync(filePath)) {
|
|
143
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
144
|
+
if (parsed && typeof parsed === 'object') {
|
|
145
|
+
doc = parsed;
|
|
146
|
+
}
|
|
147
|
+
if (!Array.isArray(doc.entries)) doc.entries = [];
|
|
148
|
+
if (doc.version == null) doc.version = 1;
|
|
149
|
+
}
|
|
150
|
+
const index = doc.entries.findIndex((entry) => entry.output === record.output);
|
|
151
|
+
if (index >= 0) doc.entries[index] = record;
|
|
152
|
+
else doc.entries.push(record);
|
|
153
|
+
doc.updatedAt = record.processedAt;
|
|
154
|
+
fs.writeFileSync(filePath, `${JSON.stringify(doc, null, 2)}\n`);
|
|
155
|
+
return doc;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function enqueueSidecarWrite(filePath, record) {
|
|
159
|
+
const next = sidecarChain.then(
|
|
160
|
+
() => upsertLedgerSidecar(filePath, record),
|
|
161
|
+
() => upsertLedgerSidecar(filePath, record),
|
|
162
|
+
);
|
|
163
|
+
sidecarChain = next.catch(() => {});
|
|
164
|
+
return next;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function resetSidecarQueue() {
|
|
168
|
+
sidecarChain = Promise.resolve();
|
|
169
|
+
}
|
package/lib/resolve-inputs.js
CHANGED
|
@@ -73,7 +73,7 @@ export function buildModeParts({
|
|
|
73
73
|
mediaMode, preferMtime, embedArt, extractAudio, audioQuality, verify,
|
|
74
74
|
deleteOriginals, deletePermanent = false, dryRun, resume, jobs = 1, dupeReport = false, dupeHash = false,
|
|
75
75
|
recupMap = false, recupApply = false, archiveDir = null, sampleSeconds = null,
|
|
76
|
-
reencodeAudio = false,
|
|
76
|
+
reencodeAudio = false, ledger = false, ledgerJsonPath = null,
|
|
77
77
|
}) {
|
|
78
78
|
if (recupMap) {
|
|
79
79
|
const parts = ['recup-map'];
|
|
@@ -118,6 +118,8 @@ export function buildModeParts({
|
|
|
118
118
|
if (archiveDir) modeParts.push('archive');
|
|
119
119
|
if (sampleSeconds) modeParts.push(`sample:${sampleSeconds}s`);
|
|
120
120
|
if (reencodeAudio) modeParts.push('reencode-audio');
|
|
121
|
+
if (ledger) modeParts.push('ledger');
|
|
122
|
+
if (ledgerJsonPath) modeParts.push('ledger-json');
|
|
121
123
|
if (resume) modeParts.push('resume');
|
|
122
124
|
if (jobs > 1) modeParts.push(`jobs:${jobs}`);
|
|
123
125
|
if (dryRun) modeParts.push('dry-run');
|
package/lib/run.js
CHANGED
|
@@ -21,6 +21,11 @@ import { formatDryRunProgress, shellQuote } from './format.js';
|
|
|
21
21
|
import { secondsToHMS, formatHMSValue, formatTimeHMS, timeToSeconds } from './time.js';
|
|
22
22
|
import { verifyOutput } from './verify.js';
|
|
23
23
|
import { runWithConcurrency } from './jobs.js';
|
|
24
|
+
import {
|
|
25
|
+
applyLedgerMetadata,
|
|
26
|
+
buildLedgerRecord,
|
|
27
|
+
enqueueSidecarWrite,
|
|
28
|
+
} from './ledger.js';
|
|
24
29
|
|
|
25
30
|
export async function runConversion({
|
|
26
31
|
preflight,
|
|
@@ -33,6 +38,7 @@ export async function runConversion({
|
|
|
33
38
|
dryRun, verify, keepPartial, quality, audioQuality, deinterlace, nvenc,
|
|
34
39
|
preferMtime, embedArt, extractAudio, mediaMode, verbose, jobs = 1,
|
|
35
40
|
sampleSeconds = null, reencodeAudio = false,
|
|
41
|
+
ledger = false, ledgerJsonPath = null, sourceRoot = null, mediatunaVersion = null,
|
|
36
42
|
} = config;
|
|
37
43
|
|
|
38
44
|
const stats = { done: 0, skipped: 0, failed: 0, resumed: 0 };
|
|
@@ -158,26 +164,61 @@ export async function runConversion({
|
|
|
158
164
|
? `${Math.floor(elapsedSec / 60)}m ${Math.round(elapsedSec % 60)}s`
|
|
159
165
|
: `${elapsedSec.toFixed(1)}s`;
|
|
160
166
|
|
|
167
|
+
let check = null;
|
|
161
168
|
if (verify) {
|
|
162
169
|
const verifyContext = isAudioJob ? { sourceMeta: meta, sourceSize: meta.size } : null;
|
|
163
|
-
|
|
170
|
+
check = verifyOutput(out, expectedDuration, expectedType, verifyContext);
|
|
164
171
|
if (!check.ok) throw new Error(`verification failed: ${check.reason}`);
|
|
165
172
|
for (const warning of check.warnings ?? []) {
|
|
166
173
|
logger.logFile(`[WARN] ${passName}: ${warning}`);
|
|
167
174
|
if (verbose) logger.logConsole(`[WARN] ${passName}: ${warning}`);
|
|
168
175
|
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (ledger) {
|
|
179
|
+
const durationDeltaMs = check?.ok && expectedDuration > 0
|
|
180
|
+
? Math.round((check.duration - expectedDuration) * 1000)
|
|
181
|
+
: null;
|
|
182
|
+
const record = buildLedgerRecord({
|
|
183
|
+
input,
|
|
184
|
+
out,
|
|
185
|
+
meta,
|
|
186
|
+
mode,
|
|
187
|
+
sourceRoot: sourceRoot || path.dirname(input),
|
|
188
|
+
mediatunaVersion,
|
|
189
|
+
preferMtime,
|
|
190
|
+
verified: Boolean(check?.ok),
|
|
191
|
+
durationDeltaMs,
|
|
192
|
+
});
|
|
169
193
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
194
|
+
applyLedgerMetadata(out, record);
|
|
195
|
+
logger.logFile(`Ledger: wrote metadata on ${path.basename(out)}`);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
logger.logFile(`[WARN] ledger metadata failed for ${path.basename(out)}: ${err.message}`);
|
|
198
|
+
if (verbose) {
|
|
199
|
+
logger.logConsole(`[WARN] ledger metadata failed for ${path.basename(out)}: ${err.message}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (ledgerJsonPath) {
|
|
203
|
+
try {
|
|
204
|
+
await enqueueSidecarWrite(ledgerJsonPath, record);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
logger.logFile(`[WARN] ledger json failed: ${err.message}`);
|
|
207
|
+
if (verbose) logger.logConsole(`[WARN] ledger json failed: ${err.message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
applyOutputTimestamps(input, out);
|
|
214
|
+
} catch { }
|
|
215
|
+
|
|
216
|
+
const outBase = path.basename(out);
|
|
217
|
+
if (verify) {
|
|
173
218
|
const detail = `✓ ${outBase} | Verified (${secondsToHMS(check.duration)}) | Metadata copied`;
|
|
174
219
|
logger.logFile(`${detail} | --- end ${passName} (${elapsedStr}) ---`);
|
|
175
220
|
logger.logToConsole(verbose ? detail : `✓ ${outBase}`);
|
|
176
221
|
} else {
|
|
177
|
-
try {
|
|
178
|
-
applyOutputTimestamps(input, out);
|
|
179
|
-
} catch { }
|
|
180
|
-
const outBase = path.basename(out);
|
|
181
222
|
logger.logFile(`✓ ${outBase} | --- end ${passName} (${elapsedStr}) ---`);
|
|
182
223
|
logger.logToConsole(verbose ? `✓ ${outBase}` : `✓ ${outBase}`);
|
|
183
224
|
}
|
|
@@ -235,6 +276,10 @@ export async function runConversion({
|
|
|
235
276
|
stats.resumed++;
|
|
236
277
|
convertedInputs.push(input);
|
|
237
278
|
}
|
|
279
|
+
if (ledger && (videoStatus.startsWith('convert') || extractStatus?.startsWith('convert'))) {
|
|
280
|
+
logger.logFile(`Would write ledger metadata for ${base}`);
|
|
281
|
+
if (ledgerJsonPath) logger.logFile(`Would append ledger json: ${ledgerJsonPath}`);
|
|
282
|
+
}
|
|
238
283
|
if (total > 1) {
|
|
239
284
|
logger.logConsole(formatDryRunProgress(index, total, entry.status, base));
|
|
240
285
|
} else if (verbose && (videoStatus.startsWith('convert') || extractStatus?.startsWith('convert'))) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mediatuna",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Make sure the old files can still play. Local convert to MP4 and MP3, dates kept.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"homepage": "https://mediatuna.dev",
|
|
34
34
|
"files": [
|
|
35
35
|
"index.js",
|
|
36
|
-
"lib"
|
|
36
|
+
"lib",
|
|
37
|
+
"skills"
|
|
37
38
|
],
|
|
38
39
|
"scripts": {
|
|
39
40
|
"start": "node index.js",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mediatuna
|
|
3
|
+
description: >-
|
|
4
|
+
Runs the MediaTuna local CLI to batch-convert home video/audio archives to
|
|
5
|
+
MP4 and MP3 (ffmpeg, dates, skip-already-done). Use when the user mentions
|
|
6
|
+
MediaTuna, old camcorder files, PhotoRec dumps, or converting a folder
|
|
7
|
+
of AVI/MOV/MOD/VOB/FLAC/WMA to playable files.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# MediaTuna
|
|
11
|
+
|
|
12
|
+
Local batch convert. Stays 100% local. The CLI is the only encoder — do not replace it with a hand-rolled ffmpeg line.
|
|
13
|
+
|
|
14
|
+
## Before any write
|
|
15
|
+
|
|
16
|
+
1. `mediatuna --version` (or `node index.js --version` from a checkout). If missing: Node 18+, ffmpeg/ffprobe on PATH, then `npm install -g mediatuna`. Docs: https://mediatuna.dev/docs/install/
|
|
17
|
+
2. Confirm the user has a **restore backup** of the source folder. If they do not, stop and say so.
|
|
18
|
+
3. `--dry-run` first. Read the preflight table with them.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
mediatuna "./archives/media" --dry-run
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
4. Then a short sample to a **different** folder:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
mediatuna "./archives/media" --sample 20 --output "./samples"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
5. Full convert to `--output`, not in-place, until they have checked a sample:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
mediatuna "./archives/media" --output "./converted"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Do not
|
|
37
|
+
|
|
38
|
+
- `--delete-originals`, `--cleanup-originals`, or `--archive` unless the user explicitly asked **and** the backup exists.
|
|
39
|
+
- `--force` on a first run.
|
|
40
|
+
- Upload source media, samples, or logs that contain home paths to a remote host beyond what this agent session already does.
|
|
41
|
+
- Promise lossless. Encodes are lossy. “Same file” is size or duration, not a bitstream compare.
|
|
42
|
+
|
|
43
|
+
## Useful flags
|
|
44
|
+
|
|
45
|
+
| Need | Flag |
|
|
46
|
+
|------|------|
|
|
47
|
+
| Video or audio only | `--video-only` / `--audio-only` |
|
|
48
|
+
| Nested folders | `--recursive` |
|
|
49
|
+
| Resume overnight | `--resume` |
|
|
50
|
+
| Skip junk paths | `--include "*.avi"` / `--exclude "previews/**"` |
|
|
51
|
+
| NVENC parallel | `--jobs 3` (after a single-job sample works) |
|
|
52
|
+
| Forced AAC 192k on video | `--reencode-audio` (default copies AAC LC) |
|
|
53
|
+
| PhotoRec tree | `--recup-map` — add `--hash` before `--apply` or cleanup |
|
|
54
|
+
| Dupes | `--dupe-report` (Everything / `es.exe` on Windows) |
|
|
55
|
+
| Provenance on new files | `--ledger` (embed). Add `--ledger-json` for `.mediatuna/archive.json` |
|
|
56
|
+
|
|
57
|
+
Full list: https://mediatuna.dev/docs/options/ · safety: https://mediatuna.dev/docs/safety/
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm install -g mediatuna
|
|
63
|
+
mediatuna --version
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Copy this skill from the global package if you are not in a checkout:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
mkdir -p ~/.cursor/skills/mediatuna
|
|
70
|
+
cp "$(npm root -g)/mediatuna/skills/mediatuna/SKILL.md" ~/.cursor/skills/mediatuna/SKILL.md
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Development checkout
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pnpm install
|
|
77
|
+
npm link
|
|
78
|
+
mediatuna --version
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`pnpm link -g` errors on some setups. After `git pull`, run `npm link` again.
|