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
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import {
|
|
4
|
+
buildNameSizeQuery,
|
|
5
|
+
buildSizeHashQuery,
|
|
6
|
+
buildSizeQuery,
|
|
7
|
+
detectEverything,
|
|
8
|
+
isEverything15,
|
|
9
|
+
searchEverything,
|
|
10
|
+
} from './everything.js';
|
|
11
|
+
import { formatSize } from './format.js';
|
|
12
|
+
import { sha256File } from './hash.js';
|
|
13
|
+
|
|
14
|
+
export { sha256File };
|
|
15
|
+
|
|
16
|
+
const SIZE_HIT_CAP = 25;
|
|
17
|
+
|
|
18
|
+
export function sameResolvedPath(a, b) {
|
|
19
|
+
return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function classifyHits(inputPath, nameHits, sizeHits) {
|
|
23
|
+
const ext = path.extname(inputPath).toLowerCase();
|
|
24
|
+
const sameNameAndSize = nameHits.filter(hit => !sameResolvedPath(hit.path, inputPath));
|
|
25
|
+
const known = [inputPath, ...sameNameAndSize.map(hit => hit.path)];
|
|
26
|
+
const sizeOnly = sizeHits.filter(hit => {
|
|
27
|
+
if (known.some(item => sameResolvedPath(item, hit.path))) return false;
|
|
28
|
+
if (ext && path.extname(hit.path).toLowerCase() !== ext) return false;
|
|
29
|
+
return true;
|
|
30
|
+
});
|
|
31
|
+
return { sameNameAndSize, sizeOnly };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatDupeReportLines({
|
|
35
|
+
folder,
|
|
36
|
+
everythingVersion,
|
|
37
|
+
hashEnabled,
|
|
38
|
+
rows,
|
|
39
|
+
}) {
|
|
40
|
+
const withName = rows.filter(row => row.sameNameAndSize.length > 0);
|
|
41
|
+
const withSize = rows.filter(row => row.sameNameAndSize.length === 0 && row.sizeOnly.length > 0);
|
|
42
|
+
const unique = rows.filter(row => row.sameNameAndSize.length === 0 && row.sizeOnly.length === 0);
|
|
43
|
+
const errors = rows.filter(row => row.error);
|
|
44
|
+
|
|
45
|
+
const lines = [
|
|
46
|
+
'MediaTuna duplicate report (Everything)',
|
|
47
|
+
`Folder: ${folder}`,
|
|
48
|
+
`Everything: ${everythingVersion}${hashEnabled ? ' | hash confirm' : ''}`,
|
|
49
|
+
`Files: ${rows.length} name+size copies: ${withName.length} size-only: ${withSize.length} unique: ${unique.length}${errors.length ? ` errors: ${errors.length}` : ''}`,
|
|
50
|
+
'',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const section = (title, list, pick) => {
|
|
54
|
+
if (list.length === 0) return;
|
|
55
|
+
lines.push(`## ${title}`);
|
|
56
|
+
for (const row of list) {
|
|
57
|
+
lines.push(`${path.basename(row.input)} (${formatSize(row.size)})`);
|
|
58
|
+
for (const hit of pick(row).slice(0, SIZE_HIT_CAP)) {
|
|
59
|
+
const note = hit.hashMatch === true
|
|
60
|
+
? ' [hash match]'
|
|
61
|
+
: hit.hashMatch === false
|
|
62
|
+
? ' [hash differs]'
|
|
63
|
+
: '';
|
|
64
|
+
lines.push(` ${hit.path}${note}`);
|
|
65
|
+
}
|
|
66
|
+
if (pick(row).length > SIZE_HIT_CAP) {
|
|
67
|
+
lines.push(` … ${pick(row).length - SIZE_HIT_CAP} more`);
|
|
68
|
+
}
|
|
69
|
+
lines.push('');
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const hashedMatches = hashEnabled
|
|
74
|
+
? rows.filter(row => row.sizeOnly.some(hit => hit.hashMatch === true) || row.sameNameAndSize.some(hit => hit.hashMatch === true))
|
|
75
|
+
: [];
|
|
76
|
+
|
|
77
|
+
section(
|
|
78
|
+
'Same name and size (not hashed)',
|
|
79
|
+
withName,
|
|
80
|
+
row => row.sameNameAndSize,
|
|
81
|
+
);
|
|
82
|
+
section(
|
|
83
|
+
'Size + same extension (not hashed — possible renamed copy)',
|
|
84
|
+
withSize.filter(row => !hashEnabled || row.sizeOnly.every(hit => hit.hashMatch !== true)),
|
|
85
|
+
row => row.sizeOnly.filter(hit => hit.hashMatch !== true),
|
|
86
|
+
);
|
|
87
|
+
if (hashedMatches.length > 0) {
|
|
88
|
+
section(
|
|
89
|
+
'SHA-256 match (whole-file bytes)',
|
|
90
|
+
hashedMatches,
|
|
91
|
+
row => [...row.sameNameAndSize, ...row.sizeOnly].filter(hit => hit.hashMatch === true),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (unique.length > 0) {
|
|
96
|
+
lines.push('## No other copies found');
|
|
97
|
+
for (const row of unique) {
|
|
98
|
+
lines.push(`${path.basename(row.input)} (${formatSize(row.size)})`);
|
|
99
|
+
}
|
|
100
|
+
lines.push('');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (errors.length > 0) {
|
|
104
|
+
lines.push('## Errors');
|
|
105
|
+
for (const row of errors) {
|
|
106
|
+
lines.push(`${path.basename(row.input)}: ${row.error}`);
|
|
107
|
+
}
|
|
108
|
+
lines.push('');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function buildDupeRows(files, {
|
|
115
|
+
searchFn,
|
|
116
|
+
hashFn = null,
|
|
117
|
+
useEverythingHash = false,
|
|
118
|
+
} = {}) {
|
|
119
|
+
const rows = [];
|
|
120
|
+
for (const input of files) {
|
|
121
|
+
let size;
|
|
122
|
+
try {
|
|
123
|
+
size = fs.statSync(input).size;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
rows.push({
|
|
126
|
+
input, size: 0, sameNameAndSize: [], sizeOnly: [],
|
|
127
|
+
error: `stat failed: ${err.message}`,
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const ext = path.extname(input).replace(/^\./, '').toLowerCase();
|
|
134
|
+
const nameHits = searchFn(buildNameSizeQuery(path.basename(input), size));
|
|
135
|
+
const sizeHits = searchFn(buildSizeQuery(size, ext));
|
|
136
|
+
const classified = classifyHits(input, nameHits, sizeHits);
|
|
137
|
+
|
|
138
|
+
if (hashFn && classified.sizeOnly.length > 0) {
|
|
139
|
+
if (useEverythingHash) {
|
|
140
|
+
const digest = await hashFn(input);
|
|
141
|
+
const hashHits = searchFn(buildSizeHashQuery(size, digest, ext));
|
|
142
|
+
classified.sizeOnly = classified.sizeOnly.map(hit => ({
|
|
143
|
+
...hit,
|
|
144
|
+
hashMatch: hashHits.some(other => sameResolvedPath(other.path, hit.path)),
|
|
145
|
+
}));
|
|
146
|
+
} else {
|
|
147
|
+
const sourceHash = await hashFn(input);
|
|
148
|
+
const confirmed = [];
|
|
149
|
+
for (const hit of classified.sizeOnly.slice(0, SIZE_HIT_CAP)) {
|
|
150
|
+
try {
|
|
151
|
+
const digest = await hashFn(hit.path);
|
|
152
|
+
confirmed.push({ ...hit, hashMatch: digest === sourceHash });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
confirmed.push({ ...hit, hashMatch: null, error: err.message });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
classified.sizeOnly = confirmed;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
rows.push({
|
|
162
|
+
input,
|
|
163
|
+
size,
|
|
164
|
+
sameNameAndSize: classified.sameNameAndSize,
|
|
165
|
+
sizeOnly: classified.sizeOnly,
|
|
166
|
+
error: null,
|
|
167
|
+
});
|
|
168
|
+
} catch (err) {
|
|
169
|
+
rows.push({
|
|
170
|
+
input, size, sameNameAndSize: [], sizeOnly: [],
|
|
171
|
+
error: err.message,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return rows;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function runDupeReport({
|
|
179
|
+
files,
|
|
180
|
+
rootDir,
|
|
181
|
+
hash = false,
|
|
182
|
+
dryRun = false,
|
|
183
|
+
detectFn = detectEverything,
|
|
184
|
+
searchFn = null,
|
|
185
|
+
hashFn = sha256File,
|
|
186
|
+
writeFileFn = fs.writeFileSync,
|
|
187
|
+
} = {}) {
|
|
188
|
+
const everything = detectFn();
|
|
189
|
+
const query = searchFn ?? ((q) => searchEverything(q, {
|
|
190
|
+
esPath: everything.esPath,
|
|
191
|
+
instance: everything.instance,
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
const rows = await buildDupeRows(files, {
|
|
195
|
+
searchFn: query,
|
|
196
|
+
hashFn: hash ? hashFn : null,
|
|
197
|
+
useEverythingHash: hash && isEverything15(everything.version),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const lines = formatDupeReportLines({
|
|
201
|
+
folder: rootDir,
|
|
202
|
+
everythingVersion: everything.version.raw,
|
|
203
|
+
hashEnabled: hash,
|
|
204
|
+
rows,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const reportPath = path.join(rootDir, 'mediatuna-dupe-report.txt');
|
|
208
|
+
if (!dryRun) writeFileFn(reportPath, lines.join('\n') + '\n');
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
lines,
|
|
212
|
+
rows,
|
|
213
|
+
reportPath: dryRun ? null : reportPath,
|
|
214
|
+
everything,
|
|
215
|
+
stats: {
|
|
216
|
+
files: rows.length,
|
|
217
|
+
nameCopies: rows.filter(r => r.sameNameAndSize.length > 0).length,
|
|
218
|
+
sizeOnly: rows.filter(r => r.sameNameAndSize.length === 0 && r.sizeOnly.length > 0).length,
|
|
219
|
+
unique: rows.filter(r => !r.error && r.sameNameAndSize.length === 0 && r.sizeOnly.length === 0).length,
|
|
220
|
+
errors: rows.filter(r => r.error).length,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
package/lib/encode.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { execFile, execFileSync } from 'child_process';
|
|
3
|
+
import { lameQuality } from './audio-policy.js';
|
|
4
|
+
import { formatFfmpegError } from './format.js';
|
|
5
|
+
import { formatMtimeDate } from './paths.js';
|
|
6
|
+
import { hasDateTag } from './tags.js';
|
|
7
|
+
import { NVENC_MIN_HEIGHT, NVENC_MIN_WIDTH } from './constants.js';
|
|
8
|
+
import { copyAndRepairTimestamps } from './timestamps.js';
|
|
9
|
+
import { timeToSeconds } from './time.js';
|
|
10
|
+
|
|
11
|
+
export function nvencSupportsFrame(meta) {
|
|
12
|
+
const width = Number(meta?.width) || 0;
|
|
13
|
+
const height = Number(meta?.height) || 0;
|
|
14
|
+
if (width <= 0 || height <= 0) return true;
|
|
15
|
+
return width >= NVENC_MIN_WIDTH && height >= NVENC_MIN_HEIGHT;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function shouldDeinterlace(mode, meta) {
|
|
19
|
+
if (mode === 'on') return true;
|
|
20
|
+
if (mode === 'off') return false;
|
|
21
|
+
return meta.interlaced;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildVideoFilter(deinterlaceMode, meta) {
|
|
25
|
+
if (!shouldDeinterlace(deinterlaceMode, meta)) return null;
|
|
26
|
+
return 'yadif';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function withSampleLimit(args, sampleSeconds) {
|
|
30
|
+
if (!sampleSeconds) return args;
|
|
31
|
+
const inputFlag = args.indexOf('-i');
|
|
32
|
+
if (inputFlag < 0) return [...args, '-t', String(sampleSeconds)];
|
|
33
|
+
const afterInput = inputFlag + 2;
|
|
34
|
+
return [...args.slice(0, afterInput), '-t', String(sampleSeconds), ...args.slice(afterInput)];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sampleDuration(sourceDuration, sampleSeconds) {
|
|
38
|
+
if (!sampleSeconds) return sourceDuration;
|
|
39
|
+
if (!sourceDuration || sourceDuration <= 0) return sampleSeconds;
|
|
40
|
+
return Math.min(sourceDuration, sampleSeconds);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const COPYABLE_AAC_PROFILES = new Set(['', 'unknown', 'lc', 'low complexity', 'aac lc']);
|
|
44
|
+
|
|
45
|
+
export function canCopyAacAudio(meta, { reencodeAudio = false } = {}) {
|
|
46
|
+
if (reencodeAudio) return false;
|
|
47
|
+
const codec = (meta?.audioCodec || '').toLowerCase();
|
|
48
|
+
if (codec !== 'aac') return false;
|
|
49
|
+
const profile = (meta?.audioProfile || '').toLowerCase().trim();
|
|
50
|
+
if (profile && !COPYABLE_AAC_PROFILES.has(profile)) return false;
|
|
51
|
+
const channels = Number(meta?.audioChannels) || 0;
|
|
52
|
+
if (channels > 2) return false;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function videoAudioEncodeArgs(meta, { reencodeAudio = false } = {}) {
|
|
57
|
+
if (canCopyAacAudio(meta, { reencodeAudio })) return ['-c:a', 'copy'];
|
|
58
|
+
return ['-c:a', 'aac', '-b:a', '192k'];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildFfmpegArgs(input, out, meta, {
|
|
62
|
+
quality, nvenc, deinterlaceMode, sampleSeconds = null, reencodeAudio = false,
|
|
63
|
+
}) {
|
|
64
|
+
const args = ['-hide_banner', '-loglevel', 'info', '-n', '-i', input, '-map_metadata', '0'];
|
|
65
|
+
|
|
66
|
+
const vf = buildVideoFilter(deinterlaceMode, meta);
|
|
67
|
+
if (vf) args.push('-vf', vf);
|
|
68
|
+
|
|
69
|
+
args.push('-pix_fmt', 'yuv420p', '-movflags', '+faststart');
|
|
70
|
+
|
|
71
|
+
if (nvenc && nvencSupportsFrame(meta)) {
|
|
72
|
+
const p = quality === 'high' ? 'p7' : quality === 'fast' ? 'p4' : 'p6';
|
|
73
|
+
const cq = quality === 'high' ? '15' : '18';
|
|
74
|
+
args.push('-c:v', 'h264_nvenc', '-preset', p, '-cq', cq);
|
|
75
|
+
} else {
|
|
76
|
+
const crf = quality === 'high' ? '16' : quality === 'fast' ? '23' : '18';
|
|
77
|
+
args.push('-c:v', 'libx264', '-crf', crf, '-preset', quality === 'fast' ? 'medium' : 'slow');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
args.push(...videoAudioEncodeArgs(meta, { reencodeAudio }), out);
|
|
81
|
+
return withSampleLimit(args, sampleSeconds);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildAudioFfmpegArgs(input, out, { audioQuality, embedArt, preferMtime, meta, sampleSeconds = null }) {
|
|
85
|
+
const args = [
|
|
86
|
+
'-hide_banner', '-loglevel', 'info', '-n', '-i', input,
|
|
87
|
+
'-map_metadata', '0',
|
|
88
|
+
'-id3v2_version', '3',
|
|
89
|
+
'-write_id3v1', '0',
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
if (preferMtime && !hasDateTag(meta.tags)) {
|
|
93
|
+
args.push('-metadata', `date=${formatMtimeDate(input)}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
args.push('-map', '0:a:0', '-c:a', 'libmp3lame', '-q:a', lameQuality(audioQuality));
|
|
97
|
+
|
|
98
|
+
if (embedArt) {
|
|
99
|
+
args.push('-map', '0:v?', '-c:v', 'copy', '-disposition:v:0', 'attached_pic');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
args.push(out);
|
|
103
|
+
return withSampleLimit(args, sampleSeconds);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildExtractAudioFfmpegArgs(input, out, { audioQuality, preferMtime, meta, sampleSeconds = null }) {
|
|
107
|
+
const args = [
|
|
108
|
+
'-hide_banner', '-loglevel', 'info', '-n', '-i', input,
|
|
109
|
+
'-map_metadata', '0',
|
|
110
|
+
'-id3v2_version', '3',
|
|
111
|
+
'-write_id3v1', '0',
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
if (preferMtime && !hasDateTag(meta.tags)) {
|
|
115
|
+
args.push('-metadata', `date=${formatMtimeDate(input)}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
args.push('-map', '0:a:0', '-c:a', 'libmp3lame', '-q:a', lameQuality(audioQuality), out);
|
|
119
|
+
return withSampleLimit(args, sampleSeconds);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function copyWindowsTimestamps(input, output) {
|
|
123
|
+
execFileSync('powershell', [
|
|
124
|
+
'-NoProfile', '-Command',
|
|
125
|
+
`$i = ${JSON.stringify(input)}; $o = ${JSON.stringify(output)}; `
|
|
126
|
+
+ '$src = Get-Item -LiteralPath $i; $dst = Get-Item -LiteralPath $o; '
|
|
127
|
+
+ '$dst.CreationTime = $src.CreationTime; $dst.LastWriteTime = $src.LastWriteTime',
|
|
128
|
+
], { stdio: 'ignore' });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function removePartialOutput(outPath, keepPartial, onRemoved) {
|
|
132
|
+
if (keepPartial || !fs.existsSync(outPath)) return;
|
|
133
|
+
try {
|
|
134
|
+
fs.unlinkSync(outPath);
|
|
135
|
+
onRemoved?.(`Removed incomplete output: ${outPath}`);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
onRemoved?.(`Could not remove incomplete output: ${outPath} (${err.message})`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function runFfmpeg(args, { onProgress, setActiveProc } = {}) {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
let stderrBuf = '';
|
|
144
|
+
const proc = execFile('ffmpeg', args, { maxBuffer: 1024 * 1024 * 100 }, (err) => {
|
|
145
|
+
setActiveProc?.(null);
|
|
146
|
+
if (err) reject(new Error(formatFfmpegError(stderrBuf, err.message)));
|
|
147
|
+
else resolve(stderrBuf);
|
|
148
|
+
});
|
|
149
|
+
setActiveProc?.(proc);
|
|
150
|
+
proc.stderr?.on('data', (data) => {
|
|
151
|
+
const chunk = data.toString();
|
|
152
|
+
stderrBuf += chunk;
|
|
153
|
+
onProgress?.(chunk, stderrBuf);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function nvencEncoderListed(encodersText) {
|
|
159
|
+
return typeof encodersText === 'string' && encodersText.includes('h264_nvenc');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Tiny lavfi encode: encoder-list alone is true on apt ffmpeg with no GPU. */
|
|
163
|
+
export const NVENC_PROBE_ARGS = [
|
|
164
|
+
'-hide_banner', '-loglevel', 'error',
|
|
165
|
+
'-f', 'lavfi', '-i', 'color=c=black:s=160x120:d=0.05',
|
|
166
|
+
'-frames:v', '1',
|
|
167
|
+
'-c:v', 'h264_nvenc',
|
|
168
|
+
'-f', 'null', '-',
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
export function detectNvenc() {
|
|
172
|
+
try {
|
|
173
|
+
const encoders = execFileSync('ffmpeg', ['-hide_banner', '-encoders'], {
|
|
174
|
+
encoding: 'utf8',
|
|
175
|
+
stdio: 'pipe',
|
|
176
|
+
});
|
|
177
|
+
if (!nvencEncoderListed(encoders)) return false;
|
|
178
|
+
execFileSync('ffmpeg', NVENC_PROBE_ARGS, { stdio: 'pipe', timeout: 15_000 });
|
|
179
|
+
return true;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function applyOutputTimestamps(input, out) {
|
|
186
|
+
try {
|
|
187
|
+
copyAndRepairTimestamps(input, out);
|
|
188
|
+
return;
|
|
189
|
+
} catch {
|
|
190
|
+
const s = fs.statSync(input);
|
|
191
|
+
fs.utimesSync(out, s.atime, s.mtime);
|
|
192
|
+
if (process.platform === 'win32') copyWindowsTimestamps(input, out);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const WELL_KNOWN_ES = [
|
|
6
|
+
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Everything', 'es.exe'),
|
|
7
|
+
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Everything 1.5a', 'es.exe'),
|
|
8
|
+
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Everything 1.5b', 'es.exe'),
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const INSTANCES = [null, '1.5a', '1.5b'];
|
|
12
|
+
|
|
13
|
+
export function parseEverythingVersion(text) {
|
|
14
|
+
const match = String(text ?? '').trim().match(/(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
15
|
+
if (!match) return null;
|
|
16
|
+
return {
|
|
17
|
+
raw: match[0],
|
|
18
|
+
major: Number(match[1]),
|
|
19
|
+
minor: Number(match[2]),
|
|
20
|
+
patch: Number(match[3]),
|
|
21
|
+
build: match[4] != null ? Number(match[4]) : 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isEverything15(version) {
|
|
26
|
+
const parsed = typeof version === 'string' ? parseEverythingVersion(version) : version;
|
|
27
|
+
return Boolean(parsed && (parsed.major > 1 || (parsed.major === 1 && parsed.minor >= 5)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function quoteEverythingTerm(value) {
|
|
31
|
+
const text = String(value).replace(/"/g, '');
|
|
32
|
+
return /[\s&|<>^]/.test(text) ? `"${text}"` : text;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function extensionTerm(filePath) {
|
|
36
|
+
const ext = path.extname(filePath).replace(/^\./, '').toLowerCase();
|
|
37
|
+
return ext || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildNameSizeQuery(basename, size) {
|
|
41
|
+
return ['file:', `size:${size}`, `nopath:exact:${quoteEverythingTerm(basename)}`];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildSizeQuery(size, ext) {
|
|
45
|
+
const terms = ['file:', `size:${size}`];
|
|
46
|
+
if (ext) terms.push(`ext:${String(ext).replace(/^\./, '').toLowerCase()}`);
|
|
47
|
+
return terms;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildSizeHashQuery(size, sha256, ext) {
|
|
51
|
+
const terms = ['file:', `size:${size}`, `sha256:${String(sha256).toLowerCase()}`];
|
|
52
|
+
if (ext) terms.push(`ext:${String(ext).replace(/^\./, '').toLowerCase()}`);
|
|
53
|
+
return terms;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function searchTerms(query) {
|
|
57
|
+
return Array.isArray(query) ? query : String(query).split(/\s+/).filter(Boolean);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseCsvRow(line) {
|
|
61
|
+
const cells = [];
|
|
62
|
+
let current = '';
|
|
63
|
+
let inQuotes = false;
|
|
64
|
+
for (let i = 0; i < line.length; i++) {
|
|
65
|
+
const ch = line[i];
|
|
66
|
+
if (inQuotes) {
|
|
67
|
+
if (ch === '"' && line[i + 1] === '"') {
|
|
68
|
+
current += '"';
|
|
69
|
+
i++;
|
|
70
|
+
} else if (ch === '"') {
|
|
71
|
+
inQuotes = false;
|
|
72
|
+
} else {
|
|
73
|
+
current += ch;
|
|
74
|
+
}
|
|
75
|
+
} else if (ch === '"') {
|
|
76
|
+
inQuotes = true;
|
|
77
|
+
} else if (ch === ',') {
|
|
78
|
+
cells.push(current);
|
|
79
|
+
current = '';
|
|
80
|
+
} else {
|
|
81
|
+
current += ch;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
cells.push(current);
|
|
85
|
+
return cells;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parseEsCsv(text) {
|
|
89
|
+
const hits = [];
|
|
90
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
91
|
+
if (!line.trim()) continue;
|
|
92
|
+
const cells = parseCsvRow(line);
|
|
93
|
+
if (cells.length < 2) continue;
|
|
94
|
+
const size = Number(String(cells[0]).replace(/,/g, ''));
|
|
95
|
+
const filePath = cells[1].trim();
|
|
96
|
+
if (!Number.isFinite(size) || !filePath) continue;
|
|
97
|
+
hits.push({ size, path: filePath });
|
|
98
|
+
}
|
|
99
|
+
return hits;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveEsPath({ env = process.env, existsFn = fs.existsSync } = {}) {
|
|
103
|
+
if (env.MEDIATUNA_ES && existsFn(env.MEDIATUNA_ES)) return env.MEDIATUNA_ES;
|
|
104
|
+
for (const candidate of WELL_KNOWN_ES) {
|
|
105
|
+
if (existsFn(candidate)) return candidate;
|
|
106
|
+
}
|
|
107
|
+
return 'es.exe';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function runEs(esPath, args, { instance = null } = {}) {
|
|
111
|
+
const full = instance ? ['-instance', instance, ...args] : args;
|
|
112
|
+
return execFileSync(esPath, full, {
|
|
113
|
+
encoding: 'utf8',
|
|
114
|
+
windowsHide: true,
|
|
115
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function detectEverything({
|
|
120
|
+
esPath = resolveEsPath(),
|
|
121
|
+
runEsFn = runEs,
|
|
122
|
+
} = {}) {
|
|
123
|
+
let lastError = null;
|
|
124
|
+
for (const instance of INSTANCES) {
|
|
125
|
+
try {
|
|
126
|
+
const raw = String(runEsFn(esPath, ['-get-everything-version'], { instance })).trim();
|
|
127
|
+
const version = parseEverythingVersion(raw);
|
|
128
|
+
if (!version) continue;
|
|
129
|
+
return { esPath, instance, version };
|
|
130
|
+
} catch (err) {
|
|
131
|
+
lastError = err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const hint = lastError?.message ? ` (${lastError.message})` : '';
|
|
135
|
+
throw new Error(`Everything CLI did not respond${hint}. Is Everything running?`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function searchEverything(query, {
|
|
139
|
+
esPath,
|
|
140
|
+
instance = null,
|
|
141
|
+
runEsFn = runEs,
|
|
142
|
+
maxResults = 50,
|
|
143
|
+
} = {}) {
|
|
144
|
+
const output = runEsFn(esPath, [
|
|
145
|
+
'-csv',
|
|
146
|
+
'-no-header',
|
|
147
|
+
'-no-digit-grouping',
|
|
148
|
+
'-n', String(maxResults),
|
|
149
|
+
'-size',
|
|
150
|
+
'-full-path-and-name',
|
|
151
|
+
...searchTerms(query),
|
|
152
|
+
], { instance });
|
|
153
|
+
return parseEsCsv(output);
|
|
154
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { VIDEO_EXTS, AUDIO_EXTS, LOSSLESS_AUDIO_EXTS, STAMP_EXTRA_EXTS } from './constants.js';
|
|
3
|
+
|
|
4
|
+
export function hasVideoExt(filePath) {
|
|
5
|
+
return VIDEO_EXTS.has(path.extname(filePath).toLowerCase());
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function hasAudioExt(filePath) {
|
|
9
|
+
return AUDIO_EXTS.has(path.extname(filePath).toLowerCase());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function hasMediaExt(filePath, mediaMode, { stampDates = false } = {}) {
|
|
13
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
14
|
+
if (mediaMode.video && VIDEO_EXTS.has(ext)) return true;
|
|
15
|
+
if (mediaMode.video && stampDates && STAMP_EXTRA_EXTS.has(ext)) return true;
|
|
16
|
+
if (mediaMode.audio && AUDIO_EXTS.has(ext)) return true;
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isLossyAudioSource(input) {
|
|
21
|
+
return LOSSLESS_AUDIO_EXTS.has(path.extname(input).toLowerCase());
|
|
22
|
+
}
|