mediatuna 1.21.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/archive.js ADDED
@@ -0,0 +1,66 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { uniqueDestPath } from './paths.js';
4
+
5
+ export function archiveDestPath(input, archiveDir, { rootDir = null } = {}) {
6
+ const abs = path.resolve(input);
7
+ const root = rootDir ? path.resolve(rootDir) : path.dirname(abs);
8
+ const rel = path.relative(root, abs);
9
+ const safeRel = !rel || rel.startsWith('..') || path.isAbsolute(rel) ? path.basename(abs) : rel;
10
+ return path.join(path.resolve(archiveDir), safeRel);
11
+ }
12
+
13
+ export function formatArchivePlanLines(candidates, { archiveDir, dryRun = false } = {}) {
14
+ const prefix = dryRun ? '[DRY] ' : '';
15
+ const lines = ['', `${prefix}Sources below will be moved to ${archiveDir} after a verified convert (or cleanup).`, ''];
16
+ const showMax = 25;
17
+ for (const [i, entry] of candidates.entries()) {
18
+ if (i >= showMax) {
19
+ lines.push(` ... and ${candidates.length - showMax} more`);
20
+ break;
21
+ }
22
+ lines.push(` ${entry.input}`);
23
+ lines.push(` → ${entry.dest}`);
24
+ }
25
+ lines.push('');
26
+ lines.push(`${prefix}${candidates.length} file(s) ready to archive.`);
27
+ lines.push('');
28
+ return lines;
29
+ }
30
+
31
+ export function moveOriginalsToArchive(paths, archiveDir, {
32
+ rootDir = null,
33
+ logConsole = () => {},
34
+ existsFn = fs.existsSync,
35
+ mkdirFn = fs.mkdirSync,
36
+ renameFn = fs.renameSync,
37
+ copyFn = fs.copyFileSync,
38
+ unlinkFn = fs.unlinkSync,
39
+ statFn = fs.statSync,
40
+ } = {}) {
41
+ let moved = 0;
42
+ let failed = 0;
43
+ for (const filePath of paths) {
44
+ try {
45
+ if (!existsFn(filePath)) continue;
46
+ const destBase = archiveDestPath(filePath, archiveDir, { rootDir });
47
+ mkdirFn(path.dirname(destBase), { recursive: true });
48
+ const dest = uniqueDestPath(destBase, existsFn);
49
+ try {
50
+ renameFn(filePath, dest);
51
+ } catch (err) {
52
+ if (err.code !== 'EXDEV') throw err;
53
+ copyFn(filePath, dest);
54
+ const stat = statFn(filePath);
55
+ fs.utimesSync(dest, stat.atime, stat.mtime);
56
+ unlinkFn(filePath);
57
+ }
58
+ logConsole(`Archived original: ${filePath} → ${dest}`);
59
+ moved++;
60
+ } catch (err) {
61
+ logConsole(`Error archiving ${filePath}: ${err.message}`);
62
+ failed++;
63
+ }
64
+ }
65
+ return { moved, failed };
66
+ }
@@ -0,0 +1,53 @@
1
+ const MP3_BITRATE_FLOOR_KBPS = {
2
+ high: 224,
3
+ medium: 160,
4
+ fast: 128,
5
+ };
6
+
7
+ export function lameQuality(quality) {
8
+ if (quality === 'high') return '0';
9
+ if (quality === 'fast') return '4';
10
+ return '2';
11
+ }
12
+
13
+ export function mp3BitrateFloorKbps(quality) {
14
+ return MP3_BITRATE_FLOOR_KBPS[quality] ?? MP3_BITRATE_FLOOR_KBPS.medium;
15
+ }
16
+
17
+ export function isSourceMp3(inputPath, meta) {
18
+ return pathExtLower(inputPath) === '.mp3' && meta.audioCodec === 'mp3';
19
+ }
20
+
21
+ export function hasBasicTags(meta) {
22
+ if (!meta?.tags) return false;
23
+ const tags = meta.tags;
24
+ return Boolean(
25
+ findTag(tags, 'title') ||
26
+ findTag(tags, 'artist') ||
27
+ findTag(tags, 'album') ||
28
+ (meta.tagCount ?? 0) >= 2
29
+ );
30
+ }
31
+
32
+ export function isNormalizedMp3(inputPath, meta, quality) {
33
+ if (!isSourceMp3(inputPath, meta)) return false;
34
+ if (!hasBasicTags(meta)) return false;
35
+ const bitrate = meta.audioBitrate ?? 0;
36
+ if (bitrate <= 0) return false;
37
+ return bitrate >= mp3BitrateFloorKbps(quality) * 1000;
38
+ }
39
+
40
+ function pathExtLower(filePath) {
41
+ const ext = filePath.slice(filePath.lastIndexOf('.'));
42
+ return ext.includes('.') ? ext.toLowerCase() : '';
43
+ }
44
+
45
+ function findTag(tags, name) {
46
+ const target = name.toLowerCase();
47
+ for (const [key, value] of Object.entries(tags)) {
48
+ const k = key.toLowerCase();
49
+ const normalized = k.includes(':') ? k.slice(k.lastIndexOf(':') + 1) : k;
50
+ if (normalized === target && value != null && String(value).trim()) return String(value).trim();
51
+ }
52
+ return null;
53
+ }
package/lib/cleanup.js ADDED
@@ -0,0 +1,77 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { moveToTrash } from './trash.js';
4
+
5
+ export function formatDeletionPlanLines(candidates, { intro, countLabel, dryRun = false }) {
6
+ const prefix = dryRun ? '[DRY] ' : '';
7
+ const lines = ['', `${prefix}${intro}`, ''];
8
+ const showMax = 25;
9
+ for (const [i, entry] of candidates.entries()) {
10
+ if (i >= showMax) {
11
+ lines.push(` ... and ${candidates.length - showMax} more`);
12
+ break;
13
+ }
14
+ lines.push(` ${entry.input}`);
15
+ lines.push(` → ${entry.out}`);
16
+ }
17
+ lines.push('');
18
+ lines.push(`${prefix}${candidates.length} file(s) ${countLabel}.`);
19
+ lines.push('');
20
+ return lines;
21
+ }
22
+
23
+ export function buildCleanupCandidates(preflight, verifyOutputFn) {
24
+ const eligible = [];
25
+ const skipped = [];
26
+
27
+ for (const entry of preflight) {
28
+ if (entry.status !== 'skip (exists)') {
29
+ if (entry.status.startsWith('convert')) {
30
+ skipped.push({ entry, reason: 'output does not exist yet' });
31
+ }
32
+ continue;
33
+ }
34
+
35
+ if (path.resolve(entry.input) === path.resolve(entry.out)) {
36
+ skipped.push({ entry, reason: 'already the converted output' });
37
+ continue;
38
+ }
39
+
40
+ const expectedType = entry.meta.mediaType === 'audio' ? 'audio' : 'video';
41
+ const check = verifyOutputFn(entry.out, entry.meta.duration, expectedType);
42
+ if (check.ok) {
43
+ eligible.push(entry);
44
+ } else {
45
+ skipped.push({ entry, reason: check.reason });
46
+ }
47
+ }
48
+
49
+ return { eligible, skipped };
50
+ }
51
+
52
+ export async function deleteOriginalFiles(paths, logConsole, {
53
+ permanent = false,
54
+ trashFn = moveToTrash,
55
+ unlinkFn = (filePath) => fs.unlinkSync(filePath),
56
+ existsFn = fs.existsSync,
57
+ } = {}) {
58
+ let deleted = 0;
59
+ let deleteFailed = 0;
60
+ for (const filePath of paths) {
61
+ try {
62
+ if (!existsFn(filePath)) continue;
63
+ if (permanent) {
64
+ unlinkFn(filePath);
65
+ logConsole(`Deleted original (permanent): ${filePath}`);
66
+ } else {
67
+ await trashFn(filePath);
68
+ logConsole(`Moved original to Recycle Bin / trash: ${filePath}`);
69
+ }
70
+ deleted++;
71
+ } catch (err) {
72
+ logConsole(`Error deleting ${filePath}: ${err.message}`);
73
+ deleteFailed++;
74
+ }
75
+ }
76
+ return { deleted, deleteFailed };
77
+ }
@@ -0,0 +1,336 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import { VALID_DEINTERLACE, VALID_QUALITY } from './constants.js';
4
+ import { parseJobsValue } from './jobs.js';
5
+ import { parseGlobList } from './globs.js';
6
+
7
+ export class CliConfigError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = 'CliConfigError';
11
+ }
12
+ }
13
+
14
+ export const CLI_PARSE_OPTIONS = {
15
+ help: { type: 'boolean', short: 'h' },
16
+ version: { type: 'boolean', short: 'V' },
17
+ force: { type: 'boolean' },
18
+ 'dry-run': { type: 'boolean' },
19
+ recursive: { type: 'boolean' },
20
+ flat: { type: 'boolean' },
21
+ output: { type: 'string' },
22
+ log: { type: 'string' },
23
+ 'master-log': { type: 'string' },
24
+ 'no-master-log': { type: 'boolean' },
25
+ 'delete-originals': { type: 'boolean' },
26
+ 'cleanup-originals': { type: 'boolean' },
27
+ quality: { type: 'string', default: 'medium' },
28
+ deinterlace: { type: 'string', default: 'auto' },
29
+ 'no-verify': { type: 'boolean' },
30
+ 'keep-partial': { type: 'boolean' },
31
+ verbose: { type: 'boolean' },
32
+ 'video-only': { type: 'boolean' },
33
+ 'audio-only': { type: 'boolean' },
34
+ 'prefer-mtime': { type: 'boolean' },
35
+ 'embed-art': { type: 'boolean' },
36
+ 'no-embed-art': { type: 'boolean' },
37
+ 'extract-audio': { type: 'boolean' },
38
+ 'audio-quality': { type: 'string' },
39
+ resume: { type: 'boolean' },
40
+ jobs: { type: 'string' },
41
+ 'stamp-dates': { type: 'boolean' },
42
+ 'no-stamp-dates': { type: 'boolean' },
43
+ backup: { type: 'string' },
44
+ 'dupe-report': { type: 'boolean' },
45
+ hash: { type: 'boolean' },
46
+ 'recup-map': { type: 'boolean' },
47
+ ext: { type: 'string' },
48
+ apply: { type: 'boolean' },
49
+ yes: { type: 'boolean', short: 'y' },
50
+ 'delete-permanent': { type: 'boolean' },
51
+ include: { type: 'string', multiple: true },
52
+ exclude: { type: 'string', multiple: true },
53
+ archive: { type: 'string' },
54
+ sample: { type: 'string' },
55
+ 'reencode-audio': { type: 'boolean' },
56
+ };
57
+
58
+ export function buildCliConfig(values, positionals, { cwd = process.cwd(), homedir = os.homedir() } = {}) {
59
+ if (values.recursive && values.flat) {
60
+ throw new CliConfigError('--recursive and --flat cannot be used together.');
61
+ }
62
+
63
+ if (values['audio-only'] && values['video-only']) {
64
+ throw new CliConfigError('--audio-only and --video-only cannot be used together.');
65
+ }
66
+
67
+ if (values['embed-art'] && values['no-embed-art']) {
68
+ throw new CliConfigError('--embed-art and --no-embed-art cannot be used together.');
69
+ }
70
+
71
+ const mediaMode = values['audio-only']
72
+ ? { video: false, audio: true }
73
+ : values['video-only']
74
+ ? { video: true, audio: false }
75
+ : { video: true, audio: true };
76
+
77
+ const quality = values.quality.toLowerCase();
78
+ if (!VALID_QUALITY.has(quality)) {
79
+ throw new CliConfigError(`invalid quality "${values.quality}". Use: high, medium, fast`);
80
+ }
81
+
82
+ const audioQuality = values['audio-quality']
83
+ ? values['audio-quality'].toLowerCase()
84
+ : quality;
85
+ if (!VALID_QUALITY.has(audioQuality)) {
86
+ throw new CliConfigError(`invalid audio-quality "${values['audio-quality']}". Use: high, medium, fast`);
87
+ }
88
+
89
+ if (values['extract-audio'] && values['audio-only']) {
90
+ throw new CliConfigError('--extract-audio requires video sources; omit --audio-only.');
91
+ }
92
+
93
+ const deinterlace = values.deinterlace.toLowerCase();
94
+ if (!VALID_DEINTERLACE.has(deinterlace)) {
95
+ throw new CliConfigError(`invalid deinterlace mode "${values.deinterlace}". Use: auto, on, off`);
96
+ }
97
+
98
+ if (values.output !== undefined && !values.output.trim()) {
99
+ throw new CliConfigError('--output requires a folder path.');
100
+ }
101
+
102
+ if (values.log !== undefined && !values.log.trim()) {
103
+ throw new CliConfigError('--log requires a file path.');
104
+ }
105
+
106
+ if (values['master-log'] !== undefined && !values['master-log'].trim()) {
107
+ throw new CliConfigError('--master-log requires a file path.');
108
+ }
109
+
110
+ if (values['delete-originals'] && values['no-verify']) {
111
+ throw new CliConfigError('--delete-originals requires post-encode verification (omit --no-verify).');
112
+ }
113
+
114
+ if (values['delete-originals'] && values['cleanup-originals']) {
115
+ throw new CliConfigError('--delete-originals and --cleanup-originals cannot be used together.');
116
+ }
117
+
118
+ if (values['cleanup-originals'] && values['no-verify']) {
119
+ throw new CliConfigError('--cleanup-originals verifies outputs before deleting (omit --no-verify).');
120
+ }
121
+
122
+ if (values['cleanup-originals'] && values.force) {
123
+ throw new CliConfigError('--cleanup-originals skips conversion; omit --force (convert first in a separate run).');
124
+ }
125
+
126
+ if (values.resume && values['dry-run']) {
127
+ throw new CliConfigError('--resume cannot be used with --dry-run.');
128
+ }
129
+
130
+ if (values.resume && values['no-verify']) {
131
+ throw new CliConfigError('--resume requires post-encode verification (omit --no-verify).');
132
+ }
133
+
134
+ if (values.backup !== undefined && !values.backup.trim()) {
135
+ throw new CliConfigError('--backup requires a folder path.');
136
+ }
137
+
138
+ if (values.backup && !values['stamp-dates']) {
139
+ throw new CliConfigError('--backup is only valid with --stamp-dates.');
140
+ }
141
+
142
+ if (values['stamp-dates'] && values['delete-originals']) {
143
+ throw new CliConfigError('--stamp-dates cannot be used with --delete-originals.');
144
+ }
145
+
146
+ if (values['stamp-dates'] && values['cleanup-originals']) {
147
+ throw new CliConfigError('--stamp-dates cannot be used with --cleanup-originals.');
148
+ }
149
+
150
+ if (values['stamp-dates'] && values['extract-audio']) {
151
+ throw new CliConfigError('--stamp-dates cannot be used with --extract-audio.');
152
+ }
153
+
154
+ if (values['stamp-dates'] && values.resume) {
155
+ throw new CliConfigError('--stamp-dates cannot be used with --resume.');
156
+ }
157
+
158
+ if (values['stamp-dates'] && values.output) {
159
+ throw new CliConfigError('--stamp-dates renames in place; use --backup instead of --output.');
160
+ }
161
+
162
+ if (values['stamp-dates'] && values['no-stamp-dates']) {
163
+ throw new CliConfigError('--stamp-dates and --no-stamp-dates cannot be used together.');
164
+ }
165
+
166
+ if (values.hash && !values['dupe-report'] && !values['recup-map']) {
167
+ throw new CliConfigError('--hash is only valid with --dupe-report or --recup-map.');
168
+ }
169
+
170
+ if (values['delete-permanent'] && !values['delete-originals'] && !values['cleanup-originals']) {
171
+ throw new CliConfigError('--delete-permanent is only valid with --delete-originals or --cleanup-originals.');
172
+ }
173
+
174
+ if (values['dupe-report'] && values['stamp-dates']) {
175
+ throw new CliConfigError('--dupe-report cannot be used with --stamp-dates.');
176
+ }
177
+
178
+ if (values['dupe-report'] && values['delete-originals']) {
179
+ throw new CliConfigError('--dupe-report cannot be used with --delete-originals.');
180
+ }
181
+
182
+ if (values['dupe-report'] && values['cleanup-originals']) {
183
+ throw new CliConfigError('--dupe-report cannot be used with --cleanup-originals.');
184
+ }
185
+
186
+ if (values['dupe-report'] && values['extract-audio']) {
187
+ throw new CliConfigError('--dupe-report cannot be used with --extract-audio.');
188
+ }
189
+
190
+ if (values['dupe-report'] && values.resume) {
191
+ throw new CliConfigError('--dupe-report cannot be used with --resume.');
192
+ }
193
+
194
+ if (values['dupe-report'] && values.output) {
195
+ throw new CliConfigError('--dupe-report writes a text report in the folder; omit --output.');
196
+ }
197
+
198
+ if (values.ext && !values['recup-map']) {
199
+ throw new CliConfigError('--ext is only valid with --recup-map.');
200
+ }
201
+
202
+ if (values.apply && !values['recup-map']) {
203
+ throw new CliConfigError('--apply is only valid with --recup-map.');
204
+ }
205
+
206
+ if (values['recup-map'] && values['dupe-report']) {
207
+ throw new CliConfigError('--recup-map cannot be used with --dupe-report.');
208
+ }
209
+
210
+ if (values['recup-map'] && values['stamp-dates']) {
211
+ throw new CliConfigError('--recup-map cannot be used with --stamp-dates.');
212
+ }
213
+
214
+ if (values['recup-map'] && values['delete-originals']) {
215
+ throw new CliConfigError('--recup-map cannot be used with --delete-originals. Use --cleanup-originals after --apply.');
216
+ }
217
+
218
+ if (values['recup-map'] && values['extract-audio']) {
219
+ throw new CliConfigError('--recup-map cannot be used with --extract-audio.');
220
+ }
221
+
222
+ if (values['recup-map'] && values.resume) {
223
+ throw new CliConfigError('--recup-map cannot be used with --resume.');
224
+ }
225
+
226
+ if (values.archive !== undefined && !values.archive.trim()) {
227
+ throw new CliConfigError('--archive requires a folder path.');
228
+ }
229
+
230
+ if (values.archive && values['delete-originals']) {
231
+ throw new CliConfigError('--archive cannot be used with --delete-originals (move or delete, not both).');
232
+ }
233
+
234
+ if (values.archive && values['delete-permanent']) {
235
+ throw new CliConfigError('--archive moves files; omit --delete-permanent.');
236
+ }
237
+
238
+ if (values.archive && values['no-verify']) {
239
+ throw new CliConfigError('--archive requires post-encode verification (omit --no-verify).');
240
+ }
241
+
242
+ if (values.archive && values['stamp-dates']) {
243
+ throw new CliConfigError('--archive cannot be used with --stamp-dates.');
244
+ }
245
+
246
+ if (values.archive && values['dupe-report']) {
247
+ throw new CliConfigError('--archive cannot be used with --dupe-report.');
248
+ }
249
+
250
+ if (values.archive && values['recup-map']) {
251
+ throw new CliConfigError('--archive cannot be used with --recup-map.');
252
+ }
253
+
254
+ let sampleSeconds = null;
255
+ if (values.sample !== undefined) {
256
+ const n = Number(values.sample);
257
+ if (!Number.isInteger(n) || n < 1 || n > 600) {
258
+ throw new CliConfigError('--sample requires an integer number of seconds from 1 to 600.');
259
+ }
260
+ sampleSeconds = n;
261
+ }
262
+
263
+ if (sampleSeconds && (values['delete-originals'] || values['cleanup-originals'] || values.archive)) {
264
+ throw new CliConfigError('--sample writes a short preview only; omit delete/archive flags.');
265
+ }
266
+
267
+ if (sampleSeconds && values.resume) {
268
+ throw new CliConfigError('--sample cannot be used with --resume.');
269
+ }
270
+
271
+ if (sampleSeconds && values['stamp-dates']) {
272
+ throw new CliConfigError('--sample cannot be used with --stamp-dates.');
273
+ }
274
+
275
+ if (sampleSeconds && values['dupe-report']) {
276
+ throw new CliConfigError('--sample cannot be used with --dupe-report.');
277
+ }
278
+
279
+ if (sampleSeconds && values['recup-map']) {
280
+ throw new CliConfigError('--sample cannot be used with --recup-map.');
281
+ }
282
+
283
+ let jobs;
284
+ try {
285
+ jobs = parseJobsValue(values.jobs);
286
+ } catch (err) {
287
+ throw new CliConfigError(err.message);
288
+ }
289
+
290
+ const target = positionals[0] ?? null;
291
+ if (positionals.length > 1) {
292
+ throw new CliConfigError(`unexpected extra arguments: ${positionals.slice(1).join(' ')}`);
293
+ }
294
+
295
+ return {
296
+ force: values.force ?? false,
297
+ dryRun: values['dry-run'] ?? false,
298
+ recursive: values.recursive ?? false,
299
+ outputDir: values.output ? path.resolve(values.output) : null,
300
+ logFile: values.log ? path.resolve(values.log) : path.join(cwd, 'mediatuna-log.txt'),
301
+ masterLogEnabled: !(values['no-master-log'] ?? false),
302
+ masterLogFile: values['master-log']
303
+ ? path.resolve(values['master-log'])
304
+ : path.join(homedir, '.mediatuna', 'history.log'),
305
+ deleteOriginals: values['delete-originals'] ?? false,
306
+ cleanupOriginals: values['cleanup-originals'] ?? false,
307
+ quality,
308
+ audioQuality,
309
+ extractAudio: values['extract-audio'] ?? false,
310
+ deinterlace,
311
+ verify: !(values['no-verify'] ?? false),
312
+ keepPartial: values['keep-partial'] ?? false,
313
+ verbose: values.verbose ?? false,
314
+ mediaMode,
315
+ preferMtime: values['prefer-mtime'] ?? false,
316
+ embedArt: values['no-embed-art'] ? false : (values['embed-art'] ?? true),
317
+ resume: values.resume ?? false,
318
+ jobs,
319
+ stampDates: values['stamp-dates'] ?? false,
320
+ stampVideo: !(values['no-stamp-dates'] ?? false),
321
+ backupDir: values.backup ? path.resolve(values.backup) : null,
322
+ dupeReport: values['dupe-report'] ?? false,
323
+ dupeHash: values.hash ?? false,
324
+ recupMap: values['recup-map'] ?? false,
325
+ recupExt: values.ext ?? null,
326
+ recupApply: values.apply ?? false,
327
+ yes: values.yes ?? false,
328
+ deletePermanent: values['delete-permanent'] ?? false,
329
+ include: parseGlobList(values.include),
330
+ exclude: parseGlobList(values.exclude),
331
+ archiveDir: values.archive ? path.resolve(values.archive) : null,
332
+ sampleSeconds,
333
+ reencodeAudio: values['reencode-audio'] ?? false,
334
+ target,
335
+ };
336
+ }
@@ -0,0 +1,17 @@
1
+ export const VIDEO_EXTS = new Set(['.avi', '.mov', '.mod', '.vob', '.mts', '.m2ts', '.mpg', '.mpeg', '.wmv', '.3gp', '.3g2']);
2
+ export const AUDIO_EXTS = new Set([
3
+ '.mp3', '.flac', '.wav', '.aiff', '.aif', '.ape', '.m4a', '.aac', '.alac',
4
+ '.ogg', '.opus', '.wma', '.ac3', '.dts', '.amr', '.qcp',
5
+ ]);
6
+ export const LOSSLESS_AUDIO_EXTS = new Set(['.flac', '.wav', '.aiff', '.aif', '.ape']);
7
+ export const VIDEO_GLOB_PATTERN = '**/*.{avi,mov,mod,vob,mts,m2ts,mpg,mpeg,wmv,3gp,3g2}';
8
+ export const AUDIO_GLOB_PATTERN = '**/*.{mp3,flac,wav,aiff,aif,ape,m4a,aac,alac,ogg,opus,wma,ac3,dts,amr,qcp}';
9
+ /** Converted MP4s are not re-encoded, but --stamp-dates still rewrites their names. */
10
+ export const STAMP_EXTRA_EXTS = new Set(['.mp4']);
11
+ export const STAMP_VIDEO_GLOB_PATTERN = '**/*.{avi,mov,mod,vob,mts,m2ts,mpg,mpeg,wmv,3gp,3g2,mp4}';
12
+ export const VALID_QUALITY = new Set(['high', 'medium', 'fast']);
13
+ export const VALID_DEINTERLACE = new Set(['auto', 'on', 'off']);
14
+ export const INTERLACED_FIELD_ORDERS = new Set(['tt', 'bb', 'tb', 'bt']);
15
+ /** NVIDIA H.264 NVENC rejects frames below this size (seen as "Frame Dimension less than the minimum"). */
16
+ export const NVENC_MIN_WIDTH = 145;
17
+ export const NVENC_MIN_HEIGHT = 49;
@@ -0,0 +1,38 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { glob } from 'glob';
4
+ import { VIDEO_GLOB_PATTERN, AUDIO_GLOB_PATTERN, STAMP_VIDEO_GLOB_PATTERN } from './constants.js';
5
+ import { hasMediaExt } from './extensions.js';
6
+ import { filterByGlobs } from './globs.js';
7
+
8
+ export function getFlatFiles(target, mediaMode, { stampDates = false } = {}) {
9
+ const files = [];
10
+ for (const item of fs.readdirSync(target)) {
11
+ const full = path.join(target, item);
12
+ if (fs.statSync(full).isFile() && hasMediaExt(full, mediaMode, { stampDates })) files.push(full);
13
+ }
14
+ return files;
15
+ }
16
+
17
+ export function dedupeFiles(fileList) {
18
+ return [...new Set(fileList.map(f => path.resolve(f)))].sort();
19
+ }
20
+
21
+ export async function discoverFiles(target, recursive, mediaMode, {
22
+ stampDates = false, include = [], exclude = [],
23
+ } = {}) {
24
+ let files = getFlatFiles(target, mediaMode, { stampDates });
25
+ if (recursive) {
26
+ const patterns = [];
27
+ if (mediaMode.video) patterns.push(stampDates ? STAMP_VIDEO_GLOB_PATTERN : VIDEO_GLOB_PATTERN);
28
+ if (mediaMode.audio) patterns.push(AUDIO_GLOB_PATTERN);
29
+ for (const pattern of patterns) {
30
+ const recFiles = await glob(pattern, { cwd: target, absolute: true, nocase: true });
31
+ files = [...files, ...recFiles];
32
+ }
33
+ files = dedupeFiles(files);
34
+ } else {
35
+ files = dedupeFiles(files);
36
+ }
37
+ return filterByGlobs(files, { include, exclude, rootDir: target });
38
+ }