mediatuna 1.21.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ import path from 'path';
2
+
3
+ function validDate({ year, month, day }) {
4
+ const y = Number(year);
5
+ const mo = Number(month);
6
+ const d = Number(day);
7
+ if (y < 1970 || y > 2100) return false;
8
+ if (mo < 1 || mo > 12) return false;
9
+ if (d < 1 || d > 31) return false;
10
+ return true;
11
+ }
12
+
13
+ function validClock({ year, month, day, hour, minute, second }) {
14
+ if (!validDate({ year, month, day })) return false;
15
+ const h = Number(hour);
16
+ const mi = Number(minute);
17
+ const s = Number(second);
18
+ if (h > 23 || mi > 59 || s > 59) return false;
19
+ return true;
20
+ }
21
+
22
+ function pack(year, month, day, hour, minute, second, rest = '', extra = {}) {
23
+ const parsed = {
24
+ year: String(year).padStart(4, '0'),
25
+ month: String(month).padStart(2, '0'),
26
+ day: String(day).padStart(2, '0'),
27
+ hour: String(hour).padStart(2, '0'),
28
+ minute: String(minute).padStart(2, '0'),
29
+ second: String(second).padStart(2, '0'),
30
+ hasTime: true,
31
+ };
32
+ if (!validClock(parsed)) return null;
33
+ return {
34
+ parsed,
35
+ rest: rest.replace(/^[-_]+/, ''),
36
+ alreadyStandard: false,
37
+ source: 'filename',
38
+ hasZ: false,
39
+ ...extra,
40
+ };
41
+ }
42
+
43
+ function packDate(year, month, day, rest = '', extra = {}) {
44
+ const parsed = {
45
+ year: String(year).padStart(4, '0'),
46
+ month: String(month).padStart(2, '0'),
47
+ day: String(day).padStart(2, '0'),
48
+ hour: '00',
49
+ minute: '00',
50
+ second: '00',
51
+ hasTime: false,
52
+ };
53
+ if (!validDate(parsed)) return null;
54
+ return {
55
+ parsed,
56
+ rest: rest.replace(/^[-_]+/, ''),
57
+ alreadyStandard: false,
58
+ source: 'filename',
59
+ hasZ: false,
60
+ ...extra,
61
+ };
62
+ }
63
+
64
+ export function filenameStampPrefix(parsed, { z = false, mtime = false } = {}) {
65
+ const clock = parsed.hasTime
66
+ ? `_${parsed.hour}-${parsed.minute}-${parsed.second}${z ? 'Z' : ''}`
67
+ : '';
68
+ const tag = mtime ? 'MTIME_' : '';
69
+ return `${tag}${parsed.year}-${parsed.month}-${parsed.day}${clock}`;
70
+ }
71
+
72
+ export function parseFilenameDate(basename) {
73
+ const stem = path.basename(basename, path.extname(basename));
74
+
75
+ const dashed = stem.match(/^(MTIME_)?(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})(Z)?(?:_(.*))?$/);
76
+ if (dashed) {
77
+ const [, tag, year, month, day, hour, minute, second, z, rest] = dashed;
78
+ const packed = pack(year, month, day, hour, minute, second, rest || '', {
79
+ alreadyStandard: true,
80
+ source: tag ? 'mtime' : (z ? 'creation_time' : 'filename'),
81
+ hasZ: Boolean(z),
82
+ });
83
+ return packed;
84
+ }
85
+
86
+ const compact = stem.match(/^(MTIME_)?(\d{4})-(\d{2})-(\d{2})_(\d{6})(Z)?(?:_(.*))?$/);
87
+ if (compact) {
88
+ const [, tag, year, month, day, hms, z, rest] = compact;
89
+ return pack(year, month, day, hms.slice(0, 2), hms.slice(2, 4), hms.slice(4, 6), rest || '', {
90
+ alreadyStandard: false,
91
+ source: tag ? 'mtime' : (z ? 'creation_time' : 'filename'),
92
+ hasZ: Boolean(z),
93
+ });
94
+ }
95
+
96
+ const yyyyDash = stem.match(/^(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})(?:-(.+))?$/);
97
+ if (yyyyDash) {
98
+ return pack(yyyyDash[1], yyyyDash[2], yyyyDash[3], yyyyDash[4], yyyyDash[5], yyyyDash[6], yyyyDash[7] || '');
99
+ }
100
+
101
+ const yy = stem.match(/^(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})(?:-(.+))?$/);
102
+ if (yy) {
103
+ return pack(2000 + Number(yy[1]), yy[2], yy[3], yy[4], yy[5], yy[6], yy[7] || '');
104
+ }
105
+
106
+ const vr = stem.match(/^VR_(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})$/);
107
+ if (vr) return pack(vr[1], vr[2], vr[3], vr[4], vr[5], vr[6], '');
108
+
109
+ const note = stem.match(/^AudioNote-(\d{4})-(\d{2})-(\d{2})_(\d{6})$/);
110
+ if (note) {
111
+ return pack(note[1], note[2], note[3], note[4].slice(0, 2), note[4].slice(2, 4), note[4].slice(4, 6), '');
112
+ }
113
+
114
+ const us = stem.match(/^(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})$/);
115
+ if (us) return pack(us[1], us[2], us[3], us[4], us[5], us[6], '');
116
+
117
+ const compactDay = stem.match(/^(\d{8})[ _-](\d{6})(?:[ _-](.+))?$/);
118
+ if (compactDay) {
119
+ const day = compactDay[1];
120
+ const hms = compactDay[2];
121
+ return pack(
122
+ day.slice(0, 4), day.slice(4, 6), day.slice(6, 8),
123
+ hms.slice(0, 2), hms.slice(2, 4), hms.slice(4, 6),
124
+ compactDay[3] || '',
125
+ );
126
+ }
127
+
128
+ const dateOnly = stem.match(/^(MTIME_)?(\d{4})-(\d{2})-(\d{2})(?:([ _-])(.*))?$/);
129
+ if (dateOnly) {
130
+ const [, tag, year, month, day, sep, rest] = dateOnly;
131
+ const label = rest || '';
132
+ return packDate(year, month, day, label, {
133
+ alreadyStandard: !label || sep === '_',
134
+ source: tag ? 'mtime' : 'filename',
135
+ });
136
+ }
137
+
138
+ return null;
139
+ }
140
+
141
+ export function normalizeDatedBasename(basename) {
142
+ const parsed = parseFilenameDate(basename);
143
+ if (!parsed || parsed.alreadyStandard) return basename;
144
+ const ext = path.extname(basename);
145
+ const rest = parsed.rest ? `_${parsed.rest}` : '';
146
+ const prefix = filenameStampPrefix(parsed.parsed, {
147
+ z: parsed.hasZ,
148
+ mtime: parsed.source === 'mtime',
149
+ });
150
+ return `${prefix}${rest}${ext}`;
151
+ }
152
+
153
+ export function isStandardDatedName(basename) {
154
+ return Boolean(parseFilenameDate(basename)?.alreadyStandard);
155
+ }
package/lib/format.js ADDED
@@ -0,0 +1,33 @@
1
+ export function formatSize(bytes) {
2
+ if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
3
+ if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
4
+ if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)} KB`;
5
+ return `${bytes} B`;
6
+ }
7
+
8
+ export function padEnd(str, len) {
9
+ const s = String(str);
10
+ return s.length >= len ? s.slice(0, len) : s + ' '.repeat(len - s.length);
11
+ }
12
+
13
+ export function shellQuote(arg) {
14
+ return /[\s"'$`]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
15
+ }
16
+
17
+ export function formatDryRunProgress(index, total, status, filename) {
18
+ return `[${index + 1}/${total}] ${status} ${filename}`;
19
+ }
20
+
21
+ export function formatFfmpegError(stderr, fallback) {
22
+ const lines = stderr
23
+ .split(/\r?\n/)
24
+ .map(l => l.trim())
25
+ .filter(Boolean)
26
+ .filter(l =>
27
+ !l.startsWith('ffmpeg version') &&
28
+ !l.startsWith('built with') &&
29
+ !l.startsWith('configuration:') &&
30
+ !/^lib\w+\s+\d/.test(l)
31
+ );
32
+ return lines.at(-1) || fallback;
33
+ }
package/lib/globs.js ADDED
@@ -0,0 +1,58 @@
1
+ import path from 'path';
2
+
3
+ export function parseGlobList(values) {
4
+ if (!values) return [];
5
+ const items = Array.isArray(values) ? values : [values];
6
+ return items
7
+ .flatMap(value => String(value).split(/[,;\n]+/))
8
+ .map(value => value.trim())
9
+ .filter(Boolean);
10
+ }
11
+
12
+ export function toPosixPath(filePath) {
13
+ return String(filePath).replace(/\\/g, '/');
14
+ }
15
+
16
+ export function compileGlob(pattern) {
17
+ const normalized = toPosixPath(String(pattern).trim());
18
+ if (!normalized) return null;
19
+ let regex = '';
20
+ for (let i = 0; i < normalized.length; i++) {
21
+ const ch = normalized[i];
22
+ if (ch === '*' && normalized[i + 1] === '*') {
23
+ regex += '.*';
24
+ i += normalized[i + 2] === '/' ? 2 : 1;
25
+ } else if (ch === '*') {
26
+ regex += '[^/]*';
27
+ } else if (ch === '?') {
28
+ regex += '[^/]';
29
+ } else if ('+^$()[]{}|.'.includes(ch)) {
30
+ regex += `\\${ch}`;
31
+ } else {
32
+ regex += ch;
33
+ }
34
+ }
35
+ return new RegExp(`^${regex}$`, 'i');
36
+ }
37
+
38
+ export function pathMatchesGlob(filePath, pattern, rootDir) {
39
+ const re = compileGlob(pattern);
40
+ if (!re) return false;
41
+ const abs = path.resolve(filePath);
42
+ const root = rootDir ? path.resolve(rootDir) : path.dirname(abs);
43
+ const rel = toPosixPath(path.relative(root, abs));
44
+ const base = path.basename(abs);
45
+ if (re.test(rel) || re.test(base) || re.test(toPosixPath(abs))) return true;
46
+ if (!toPosixPath(pattern).includes('/')) {
47
+ return rel.split('/').some(part => re.test(part));
48
+ }
49
+ return false;
50
+ }
51
+
52
+ export function filterByGlobs(files, { include = [], exclude = [], rootDir = null } = {}) {
53
+ return files.filter(filePath => {
54
+ if (exclude.some(pattern => pathMatchesGlob(filePath, pattern, rootDir))) return false;
55
+ if (include.length === 0) return true;
56
+ return include.some(pattern => pathMatchesGlob(filePath, pattern, rootDir));
57
+ });
58
+ }
package/lib/hash.js ADDED
@@ -0,0 +1,12 @@
1
+ import { createHash } from 'crypto';
2
+ import fs from 'fs';
3
+
4
+ export function sha256File(filePath, readStreamFn = fs.createReadStream) {
5
+ return new Promise((resolve, reject) => {
6
+ const hash = createHash('sha256');
7
+ const stream = readStreamFn(filePath);
8
+ stream.on('data', chunk => hash.update(chunk));
9
+ stream.on('error', reject);
10
+ stream.on('end', () => resolve(hash.digest('hex')));
11
+ });
12
+ }
package/lib/jobs.js ADDED
@@ -0,0 +1,51 @@
1
+ import os from 'os';
2
+
3
+ export const DEFAULT_JOBS = 1;
4
+ export const MAX_JOBS = 8;
5
+
6
+ export function parseJobsValue(raw) {
7
+ if (raw === undefined || raw === null) return DEFAULT_JOBS;
8
+ const n = Number(raw);
9
+ if (!Number.isInteger(n) || n < 1) {
10
+ throw new Error('--jobs requires a positive integer.');
11
+ }
12
+ if (n > MAX_JOBS) {
13
+ throw new Error(`--jobs cannot exceed ${MAX_JOBS}.`);
14
+ }
15
+ return n;
16
+ }
17
+
18
+ export function resolveEffectiveJobs(requested, { nvenc, mediaMode }) {
19
+ const jobs = requested ?? DEFAULT_JOBS;
20
+ if (jobs <= 1) return 1;
21
+
22
+ if (mediaMode.video && !nvenc) {
23
+ const cpuCap = Math.max(1, Math.min(MAX_JOBS, os.cpus().length));
24
+ return Math.min(jobs, cpuCap);
25
+ }
26
+
27
+ return jobs;
28
+ }
29
+
30
+ export async function runWithConcurrency(itemCount, concurrency, worker, { shouldStop = () => false } = {}) {
31
+ if (itemCount <= 0) return;
32
+ if (concurrency <= 1 || itemCount <= 1) {
33
+ for (let i = 0; i < itemCount; i++) {
34
+ if (shouldStop()) break;
35
+ await worker(i);
36
+ }
37
+ return;
38
+ }
39
+
40
+ let nextIndex = 0;
41
+ async function runWorker() {
42
+ while (!shouldStop()) {
43
+ const i = nextIndex++;
44
+ if (i >= itemCount) return;
45
+ await worker(i);
46
+ }
47
+ }
48
+
49
+ const workerCount = Math.min(concurrency, itemCount);
50
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
51
+ }
package/lib/log.js ADDED
@@ -0,0 +1,61 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ export function createLogger({ logFile: logFilePath, masterLogFile: masterLogFilePath, masterLogEnabled, verbose, getMultibar = () => null }) {
5
+ let masterLogWarningShown = false;
6
+
7
+ function appendLog(msg) {
8
+ const ts = new Date().toISOString();
9
+ const line = `[${ts}] ${msg}\n`;
10
+ fs.appendFileSync(logFilePath, line);
11
+ if (masterLogEnabled) {
12
+ try {
13
+ fs.appendFileSync(masterLogFilePath, line);
14
+ } catch (err) {
15
+ if (!masterLogWarningShown) {
16
+ masterLogWarningShown = true;
17
+ console.error(`Warning: could not write master log (${masterLogFilePath}): ${err.message}`);
18
+ }
19
+ }
20
+ }
21
+ }
22
+
23
+ function logToConsole(msg) {
24
+ const multibar = getMultibar();
25
+ if (multibar?.isActive) multibar.log(msg + '\n');
26
+ else console.log(msg);
27
+ }
28
+
29
+ function logFile(msg) {
30
+ appendLog(msg);
31
+ }
32
+
33
+ function logConsole(msg) {
34
+ logFile(msg);
35
+ logToConsole(msg);
36
+ }
37
+
38
+ function logVerbose(msg) {
39
+ logFile(msg);
40
+ if (verbose) logToConsole(msg);
41
+ }
42
+
43
+ function fileLog(msg, index, total) {
44
+ const prefix = total > 1 ? `[${index + 1}/${total}] ` : '';
45
+ logVerbose(prefix + msg);
46
+ }
47
+
48
+ function printLines(lines) {
49
+ for (const line of lines) {
50
+ if (line === '') console.log('');
51
+ else console.log(line);
52
+ appendLog(line);
53
+ }
54
+ }
55
+
56
+ return { appendLog, logFile, logConsole, logVerbose, fileLog, printLines, logToConsole };
57
+ }
58
+
59
+ export function ensureLogDir(filePath) {
60
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
61
+ }
package/lib/paths.js ADDED
@@ -0,0 +1,38 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { stampedOutputStem } from './stamp-dates.js';
4
+
5
+ export function uniqueDestPath(dest, existsFn = fs.existsSync) {
6
+ if (!existsFn(dest)) return dest;
7
+ const ext = path.extname(dest);
8
+ const stem = dest.slice(0, dest.length - ext.length);
9
+ for (let n = 2; n < 1000; n++) {
10
+ const candidate = `${stem}-${n}${ext}`;
11
+ if (!existsFn(candidate)) return candidate;
12
+ }
13
+ return `${stem}-${Date.now()}${ext}`;
14
+ }
15
+
16
+ export function outputDirFor(input, outputDir, { rootDir = null } = {}) {
17
+ if (!outputDir) return path.dirname(input);
18
+ if (!rootDir) return outputDir;
19
+ const rel = path.relative(path.resolve(rootDir), path.dirname(path.resolve(input)));
20
+ if (!rel || rel === '.' || rel.startsWith('..') || path.isAbsolute(rel)) return outputDir;
21
+ return path.join(outputDir, rel);
22
+ }
23
+
24
+ export function outputPath(input, outputDir, mediaType, {
25
+ stamp = false, meta = null, preferMtime = false, rootDir = null, sample = false,
26
+ } = {}) {
27
+ const ext = mediaType === 'audio' ? '.mp3' : '.mp4';
28
+ const dir = outputDirFor(input, outputDir, { rootDir });
29
+ const name = stamp
30
+ ? stampedOutputStem(path.basename(input), meta, { preferMtime })
31
+ : path.basename(input, path.extname(input));
32
+ const suffix = sample ? '.sample' : '';
33
+ return path.join(dir, `${name}${suffix}${ext}`);
34
+ }
35
+
36
+ export function formatMtimeDate(input) {
37
+ return fs.statSync(input).mtime.toISOString().slice(0, 10);
38
+ }
@@ -0,0 +1,75 @@
1
+ import path from 'path';
2
+ import {
3
+ classifyStatus,
4
+ classifyExtractStatus,
5
+ formatEntryStatus,
6
+ } from './status.js';
7
+ import { isLossyAudioSource } from './extensions.js';
8
+ import { formatSize, padEnd } from './format.js';
9
+ import { outputPath } from './paths.js';
10
+ import { getMetadata } from './probe.js';
11
+ import { secondsToHMS } from './time.js';
12
+
13
+ export function buildPreflightTableLines(entries, dryRun) {
14
+ const nameW = Math.min(40, Math.max(20, ...entries.map(e => path.basename(e.input).length)));
15
+ const header = ` ${padEnd('File', nameW)} ${padEnd('Type', 5)} ${padEnd('Duration', 10)} ${padEnd('Size', 10)} Status`;
16
+ const rule = ' ' + '─'.repeat(nameW + 44);
17
+ const lines = ['', rule, header, rule];
18
+
19
+ const counts = { convert: 0, skip: 0, unreadable: 0, wrong: 0 };
20
+
21
+ for (const e of entries) {
22
+ let status = e.status;
23
+ if (dryRun && status.startsWith('convert')) status = status.replace('convert', 'would convert');
24
+ if (status === 'unreadable') counts.unreadable++;
25
+ else if (status.includes('skip (exists)') || status.includes('skip (normalized)') || status.includes('skip (resumed)')) counts.skip++;
26
+ else if (status.includes('skip (wrong type)')) counts.wrong++;
27
+ else counts.convert++;
28
+
29
+ lines.push(
30
+ ` ${padEnd(path.basename(e.input), nameW)} ${padEnd(e.meta.mediaType, 5)} ${padEnd(secondsToHMS(e.meta.duration), 10)} ${padEnd(formatSize(e.meta.size), 10)} ${status}`
31
+ );
32
+ }
33
+
34
+ lines.push(rule);
35
+ const action = dryRun ? 'would convert' : 'to convert';
36
+ const wrongNote = counts.wrong > 0 ? `, ${counts.wrong} wrong type` : '';
37
+ lines.push(` ${entries.length} file(s): ${counts.convert} ${action}, ${counts.skip} skip, ${counts.unreadable} unreadable${wrongNote}`);
38
+ lines.push('');
39
+
40
+ return { lines, counts };
41
+ }
42
+
43
+ export async function buildPreflightEntries(fileList, outputDir, force, mediaMode, {
44
+ audioQuality, extractAudio, onProbeProgress, stampVideo = true, preferMtime = false,
45
+ rootDir = null, sampleSeconds = null,
46
+ } = {}) {
47
+ const entries = [];
48
+ for (let i = 0; i < fileList.length; i++) {
49
+ const input = fileList[i];
50
+ onProbeProgress?.(i + 1, fileList.length);
51
+ const meta = getMetadata(input);
52
+ const isVideo = meta.mediaType === 'video';
53
+ const stamp = stampVideo && isVideo;
54
+ const pathOpts = { stamp, meta, preferMtime, rootDir, sample: Boolean(sampleSeconds) };
55
+ const out = outputPath(input, outputDir, isVideo ? 'video' : 'audio', pathOpts);
56
+ const videoStatus = classifyStatus(input, meta, out, force, mediaMode, audioQuality);
57
+ let audioOut = null;
58
+ let extractStatus = null;
59
+ if (extractAudio && isVideo && mediaMode.video) {
60
+ audioOut = outputPath(input, outputDir, 'audio', pathOpts);
61
+ extractStatus = classifyExtractStatus(input, meta, audioOut, force, audioQuality);
62
+ }
63
+ entries.push({
64
+ input,
65
+ out,
66
+ audioOut,
67
+ meta,
68
+ status: formatEntryStatus(videoStatus, extractStatus),
69
+ videoStatus,
70
+ extractStatus,
71
+ lossy: meta.mediaType === 'audio' && isLossyAudioSource(input),
72
+ });
73
+ }
74
+ return entries;
75
+ }
package/lib/probe.js ADDED
@@ -0,0 +1,115 @@
1
+ import fs from 'fs';
2
+ import { execFileSync } from 'child_process';
3
+ import { INTERLACED_FIELD_ORDERS } from './constants.js';
4
+ import { extractTagInfo } from './tags.js';
5
+
6
+ function pickCreationTag(tags = {}) {
7
+ return tags.creation_time || tags.date || tags.DATE || tags.year || null;
8
+ }
9
+
10
+ function pickStreamCreationTag(streams = []) {
11
+ for (const stream of streams) {
12
+ const value = pickCreationTag(stream.tags || {});
13
+ if (value) return value;
14
+ }
15
+ return null;
16
+ }
17
+
18
+ export function parseProbeResult(data, stats) {
19
+ let duration = 0;
20
+ let creation = 'N/A';
21
+ let mediaType = 'unreadable';
22
+ let interlaced = false;
23
+ let fieldOrder = 'unknown';
24
+ let tags = {};
25
+ let tagCount = 0;
26
+ let hasCoverArt = false;
27
+ let audioCodec = 'unknown';
28
+ let audioBitrate = 0;
29
+ let audioProfile = '';
30
+ let audioChannels = 0;
31
+ let width = 0;
32
+ let height = 0;
33
+
34
+ duration = parseFloat(data.format?.duration) || 0;
35
+ tags = data.format?.tags || {};
36
+ tagCount = extractTagInfo(tags).tagCount;
37
+ creation = pickCreationTag(tags) || pickStreamCreationTag(data.streams) || 'N/A';
38
+
39
+ const videoStreams = data.streams?.filter(s => s.codec_type === 'video') ?? [];
40
+ const audioStream = data.streams?.find(s => s.codec_type === 'audio');
41
+
42
+ if (audioStream) {
43
+ audioCodec = (audioStream.codec_name || 'unknown').toLowerCase();
44
+ audioBitrate = parseInt(audioStream.bit_rate, 10)
45
+ || parseInt(data.format?.bit_rate, 10)
46
+ || 0;
47
+ audioProfile = String(audioStream.profile || '').trim();
48
+ audioChannels = parseInt(audioStream.channels, 10) || 0;
49
+ }
50
+
51
+ hasCoverArt = videoStreams.some(s => Number(s.disposition?.attached_pic) === 1);
52
+ const primaryVideo = videoStreams.find(s => Number(s.disposition?.attached_pic) !== 1);
53
+
54
+ if (primaryVideo) {
55
+ mediaType = 'video';
56
+ fieldOrder = (primaryVideo.field_order || 'unknown').toLowerCase();
57
+ interlaced = INTERLACED_FIELD_ORDERS.has(fieldOrder);
58
+ width = parseInt(primaryVideo.width, 10) || 0;
59
+ height = parseInt(primaryVideo.height, 10) || 0;
60
+ } else if (audioStream) {
61
+ mediaType = 'audio';
62
+ }
63
+
64
+ return {
65
+ duration,
66
+ creation_time: creation,
67
+ modified_time: stats.mtime.toISOString(),
68
+ size: stats.size,
69
+ valid: mediaType !== 'unreadable',
70
+ mediaType,
71
+ interlaced,
72
+ field_order: fieldOrder,
73
+ tags,
74
+ tagCount,
75
+ hasCoverArt,
76
+ audioCodec,
77
+ audioBitrate,
78
+ audioProfile,
79
+ audioChannels,
80
+ width,
81
+ height,
82
+ };
83
+ }
84
+
85
+ export function probeFile(input) {
86
+ const stats = fs.statSync(input);
87
+ try {
88
+ const out = execFileSync(
89
+ 'ffprobe',
90
+ ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', input],
91
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
92
+ );
93
+ return parseProbeResult(JSON.parse(out), stats);
94
+ } catch {
95
+ return parseProbeResult({}, stats);
96
+ }
97
+ }
98
+
99
+ export function getMetadata(input) {
100
+ return probeFile(input);
101
+ }
102
+
103
+ export function getFormatTags(filePath) {
104
+ try {
105
+ const out = execFileSync(
106
+ 'ffprobe',
107
+ ['-v', 'error', '-print_format', 'json', '-show_format', filePath],
108
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
109
+ );
110
+ const data = JSON.parse(out);
111
+ return extractTagInfo(data.format?.tags || {});
112
+ } catch {
113
+ return extractTagInfo({});
114
+ }
115
+ }