diff --git a/package-lock.json b/package-lock.json index 9df54fdde..26759f071 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", + "mediainfo.js": "^0.2.1", "node-tone": "^1.0.1", "nodemailer": "^6.9.2", "openid-client": "^5.6.1", @@ -908,7 +909,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -1358,7 +1358,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -1369,8 +1368,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/color-support": { "version": "1.1.3", @@ -1787,7 +1785,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, "engines": { "node": ">=6" } @@ -2099,7 +2096,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -3052,6 +3048,58 @@ "node": ">= 0.6" } }, + "node_modules/mediainfo.js": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.2.1.tgz", + "integrity": "sha512-xbTstvy34gDmxNLVytixbY8Uw4DGKKsQIMvX7q1K8FwIk/gwAVLd30EVvPh/g+QHVscATRuqrNtbTb7XUjDeyw==", + "dependencies": { + "yargs": "^17.7.2" + }, + "bin": { + "mediainfo.js": "dist/esm/cli.js" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/mediainfo.js/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mediainfo.js/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mediainfo.js/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -4278,7 +4326,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -5247,7 +5294,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -5321,7 +5367,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "engines": { "node": ">=10" } @@ -6128,7 +6173,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "requires": { "color-convert": "^2.0.1" } @@ -6463,7 +6507,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "requires": { "color-name": "~1.1.4" } @@ -6471,8 +6514,7 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "color-support": { "version": "1.1.3", @@ -6786,8 +6828,7 @@ "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" }, "escape-html": { "version": "1.0.3", @@ -7005,8 +7046,7 @@ "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, "get-func-name": { "version": "2.0.2", @@ -7735,6 +7775,45 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" }, + "mediainfo.js": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.2.1.tgz", + "integrity": "sha512-xbTstvy34gDmxNLVytixbY8Uw4DGKKsQIMvX7q1K8FwIk/gwAVLd30EVvPh/g+QHVscATRuqrNtbTb7XUjDeyw==", + "requires": { + "yargs": "^17.7.2" + }, + "dependencies": { + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + }, + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + } + } + }, "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -8649,8 +8728,7 @@ "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" }, "require-main-filename": { "version": "2.0.0", @@ -9354,7 +9432,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "requires": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -9401,8 +9478,7 @@ "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" }, "yallist": { "version": "4.0.0", @@ -9487,4 +9563,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 061e2a7f9..3d4ec7cd9 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", + "mediainfo.js": "^0.2.1", "node-tone": "^1.0.1", "nodemailer": "^6.9.2", "openid-client": "^5.6.1", @@ -61,4 +62,4 @@ "nyc": "^15.1.0", "sinon": "^17.0.1" } -} \ No newline at end of file +} diff --git a/server/utils/mediaInfoHelpers.js b/server/utils/mediaInfoHelpers.js new file mode 100644 index 000000000..5794e4908 --- /dev/null +++ b/server/utils/mediaInfoHelpers.js @@ -0,0 +1,97 @@ +const Logger = require('../Logger') +const fsPromises = require('fs/promises') +const MediaInfoFactory = require('mediainfo.js').default + +// File chunking logic pulled from the official cli example +// https://github.com/buzz/mediainfo.js/blob/main/src/cli.ts + +/** + * Uses mediainfo.js to dump a file's data + * @param {string} filepath + * @returns {Promise} + */ +const mediaInfoDump = async (filepath) => { + let fileHandle + let fileSize + let mediaInfo + + /** @type {import('mediainfo.js').ReadChunkFunc} */ + const readChunk = async (size, offset) => { + if (fileHandle === undefined) throw new Error('File unavailable for Mediainfo reading') + const buffer = new Uint8Array(size) + await fileHandle.read(buffer, 0, size, offset) + return buffer + } + + try { + fileHandle = await fsPromises.open(filepath, 'r') + fileSize = (await fileHandle.stat()).size + mediaInfo = await MediaInfoFactory({ + format: 'object', + coverData: false, + full: false, + }) + if (mediaInfo === undefined) { + Logger.warn('[MediaInfo] Failed to initialize MediaInfo parser for scanning media file', { + filepath, + }) + + return null + } + const result = await mediaInfo.analyzeData(() => fileSize, readChunk) + + return result + } catch (error) { + Logger.error(`[MediaInfo] MediaInfo failed while scanning media file`, error) + } finally { + fileHandle && (await fileHandle.close()) + mediaInfo && mediaInfo.close() + } + return null +} + +/** + * Extracts tags from a MediaInfoType object into a predictable internal object type + * + * Currently only returns "extra" fields as defined by mediainfo, but can easily consume normal tags too + * + * @param {import("mediainfo.js").MediaInfoType | null} mediaInfo + * @returns {{ fields: Record, lowerCaseFields: Record }} Object containing all lower-case + */ +const extractTags = (mediaInfo) => { + const defaultEmpty = { fields: {}, lowerCaseFields: {} } + if (!mediaInfo || !mediaInfo.media) return defaultEmpty + + const generalTrack = mediaInfo.media.track?.find((track) => track['@type'] === 'General') + + if (!generalTrack) return defaultEmpty + + const fields = {} + const lowerCaseFields = {} + + Object.entries(generalTrack.extra || {}).forEach(([key, value]) => { + // Lets keep it predictable... only supporting numbers and strings for now. + let cleanValue + if (typeof value === 'string') { + cleanValue = value.trim() + } + if (typeof value === 'number' && !isNaN(value)) { + cleanValue = number.toString() + } + + if (cleanValue) { + fields[key] = cleanValue + lowerCaseFields[key.toLowerCase()] = key + } + }) + + return { + fields, + lowerCaseFields, + } +} + +module.exports = { + mediaInfoDump, + extractTags, +} diff --git a/server/utils/prober.js b/server/utils/prober.js index 8e2930536..dd366c905 100644 --- a/server/utils/prober.js +++ b/server/utils/prober.js @@ -1,3 +1,4 @@ +const mediaInfoHelpers = require('./mediaInfoHelpers') const ffprobe = require('../libs/nodeFfprobe') const MediaProbeData = require('../scanner/MediaProbeData') @@ -20,7 +21,7 @@ function tryGrabBitRate(stream, all_streams, total_bit_rate) { var tagDuration = stream.tags.DURATION || stream.tags['DURATION-eng'] || stream.tags['DURATION_eng'] var tagBytes = stream.tags.NUMBER_OF_BYTES || stream.tags['NUMBER_OF_BYTES-eng'] || stream.tags['NUMBER_OF_BYTES_eng'] if (tagDuration && tagBytes && !isNaN(tagDuration) && !isNaN(tagBytes)) { - var bps = Math.floor(Number(tagBytes) * 8 / Number(tagDuration)) + var bps = Math.floor((Number(tagBytes) * 8) / Number(tagDuration)) if (bps && !isNaN(bps)) { return bps } @@ -33,7 +34,7 @@ function tryGrabBitRate(stream, all_streams, total_bit_rate) { estimated_bit_rate -= Number(stream.bit_rate) } }) - if (!all_streams.find(s => s.codec_type === 'audio' && s.bit_rate && Number(s.bit_rate) > estimated_bit_rate)) { + if (!all_streams.find((s) => s.codec_type === 'audio' && s.bit_rate && Number(s.bit_rate) > estimated_bit_rate)) { return estimated_bit_rate } else { return total_bit_rate @@ -70,12 +71,29 @@ function tryGrabChannelLayout(stream) { return String(layout).split('(').shift() } -function tryGrabTags(stream, ...tags) { - if (!stream.tags) return null - for (let i = 0; i < tags.length; i++) { - const tagKey = Object.keys(stream.tags).find(t => t.toLowerCase() === tags[i].toLowerCase()) - const value = stream.tags[tagKey] - if (value && value.trim()) return value.trim() +function tryGrabTags(rawTagData, ...tags) { + const { ffprobe, mediaInfo } = rawTagData + + if (!ffprobe && !mediaInfo) return null + + // Try ffprobe tags first + if (ffprobe) { + for (let i = 0; i < tags.length; i++) { + const tagKey = Object.keys(ffprobe).find((t) => t.toLowerCase() === tags[i].toLowerCase()) + const value = ffprobe[tagKey] + if (value && value.trim()) return value.trim() + } + } + + if (mediaInfo) { + // Try mediainfo tags as a backup only if ffprobe doesn't return anything first... + for (let i = 0; i < tags.length; i++) { + const tagKey = mediaInfo.lowerCaseFields[tags[i]] + if (tagKey) { + const value = mediaInfo.fields[tagKey] + if (value && value.trim()) return value.trim() + } + } } return null } @@ -90,7 +108,7 @@ function parseMediaStreamInfo(stream, all_streams, total_bit_rate) { time_base: stream.time_base || null, bit_rate: tryGrabBitRate(stream, all_streams, total_bit_rate), language: tryGrabTags(stream, 'language'), - title: tryGrabTags(stream, 'title') + title: tryGrabTags(stream, 'title'), } if (stream.tags) info.tags = stream.tags @@ -101,7 +119,7 @@ function parseMediaStreamInfo(stream, all_streams, total_bit_rate) { if (info.type === 'video') { info.profile = stream.profile || null - info.is_avc = (stream.is_avc !== '0' && stream.is_avc !== 'false') + info.is_avc = stream.is_avc !== '0' && stream.is_avc !== 'false' info.pix_fmt = stream.pix_fmt || null info.frame_rate = tryGrabFrameRate(stream) info.width = !isNaN(stream.width) ? Number(stream.width) : null @@ -123,7 +141,6 @@ function isNullOrNaN(val) { return val === null || isNaN(val) } - /* Example chapter object * { "id": 71, @@ -140,7 +157,7 @@ function isNullOrNaN(val) { function parseChapters(chapters) { if (!chapters) return [] let index = 0 - return chapters.map(chap => { + return chapters.map((chap) => { let title = chap['TAG:title'] || chap.title || '' if (!title && chap.tags?.title) title = chap.tags.title @@ -151,66 +168,75 @@ function parseChapters(chapters) { id: index++, start, end, - title + title, } }) } -function parseTags(format, verbose) { +function parseTags(format, mediaInfoTags, verbose) { if (!format.tags) { return {} } if (verbose) { - Logger.debug('Tags', format.tags) + Logger.debug('ffprobe Tags', format.tags) + Logger.debug('mediainfo Tags', mediaInfoTags.fields) + } + + /** + * Comment. + */ + const rawTagData = { + ffprobe: format.tags, + mediaInfo: mediaInfoTags, } const tags = { - file_tag_encoder: tryGrabTags(format, 'encoder', 'tsse', 'tss'), - file_tag_encodedby: tryGrabTags(format, 'encoded_by', 'tenc', 'ten'), - file_tag_title: tryGrabTags(format, 'title', 'tit2', 'tt2'), - file_tag_titlesort: tryGrabTags(format, 'title-sort', 'tsot'), - file_tag_subtitle: tryGrabTags(format, 'subtitle', 'tit3', 'tt3'), - file_tag_track: tryGrabTags(format, 'track', 'trck', 'trk'), - file_tag_disc: tryGrabTags(format, 'discnumber', 'disc', 'disk', 'tpos', 'tpa'), - file_tag_album: tryGrabTags(format, 'album', 'talb', 'tal'), - file_tag_albumsort: tryGrabTags(format, 'album-sort', 'tsoa'), - file_tag_artist: tryGrabTags(format, 'artist', 'tpe1', 'tp1'), - file_tag_artistsort: tryGrabTags(format, 'artist-sort', 'tsop'), - file_tag_albumartist: tryGrabTags(format, 'albumartist', 'album_artist', 'tpe2'), - file_tag_date: tryGrabTags(format, 'date', 'tyer', 'tye'), - file_tag_composer: tryGrabTags(format, 'composer', 'tcom', 'tcm'), - file_tag_publisher: tryGrabTags(format, 'publisher', 'tpub', 'tpb'), - file_tag_comment: tryGrabTags(format, 'comment', 'comm', 'com'), - file_tag_description: tryGrabTags(format, 'description', 'desc'), - file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'), - file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'), - file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'), - file_tag_isbn: tryGrabTags(format, 'isbn'), // custom - file_tag_language: tryGrabTags(format, 'language', 'lang'), - file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom - file_tag_itunesid: tryGrabTags(format, 'itunes-id'), // custom - file_tag_podcasttype: tryGrabTags(format, 'podcast-type'), // custom - file_tag_episodetype: tryGrabTags(format, 'episode-type'), // custom - file_tag_originalyear: tryGrabTags(format, 'originalyear'), - file_tag_releasecountry: tryGrabTags(format, 'MusicBrainz Album Release Country', 'releasecountry'), - file_tag_releasestatus: tryGrabTags(format, 'MusicBrainz Album Status', 'releasestatus', 'musicbrainz_albumstatus'), - file_tag_releasetype: tryGrabTags(format, 'MusicBrainz Album Type', 'releasetype', 'musicbrainz_albumtype'), - file_tag_isrc: tryGrabTags(format, 'tsrc', 'isrc'), - file_tag_musicbrainz_trackid: tryGrabTags(format, 'MusicBrainz Release Track Id', 'musicbrainz_releasetrackid'), - file_tag_musicbrainz_albumid: tryGrabTags(format, 'MusicBrainz Album Id', 'musicbrainz_albumid'), - file_tag_musicbrainz_albumartistid: tryGrabTags(format, 'MusicBrainz Album Artist Id', 'musicbrainz_albumartistid'), - file_tag_musicbrainz_artistid: tryGrabTags(format, 'MusicBrainz Artist Id', 'musicbrainz_artistid'), + file_tag_encoder: tryGrabTags(rawTagData, 'encoder', 'tsse', 'tss'), + file_tag_encodedby: tryGrabTags(rawTagData, 'encoded_by', 'tenc', 'ten'), + file_tag_title: tryGrabTags(rawTagData, 'title', 'tit2', 'tt2'), + file_tag_titlesort: tryGrabTags(rawTagData, 'title-sort', 'tsot'), + file_tag_subtitle: tryGrabTags(rawTagData, 'subtitle', 'tit3', 'tt3'), + file_tag_track: tryGrabTags(rawTagData, 'track', 'trck', 'trk'), + file_tag_disc: tryGrabTags(rawTagData, 'discnumber', 'disc', 'disk', 'tpos', 'tpa'), + file_tag_album: tryGrabTags(rawTagData, 'album', 'talb', 'tal'), + file_tag_albumsort: tryGrabTags(rawTagData, 'album-sort', 'tsoa'), + file_tag_artist: tryGrabTags(rawTagData, 'artist', 'tpe1', 'tp1'), + file_tag_artistsort: tryGrabTags(rawTagData, 'artist-sort', 'tsop'), + file_tag_albumartist: tryGrabTags(rawTagData, 'albumartist', 'album_artist', 'tpe2'), + file_tag_date: tryGrabTags(rawTagData, 'date', 'tyer', 'tye'), + file_tag_composer: tryGrabTags(rawTagData, 'composer', 'tcom', 'tcm'), + file_tag_publisher: tryGrabTags(rawTagData, 'publisher', 'tpub', 'tpb'), + file_tag_comment: tryGrabTags(rawTagData, 'comment', 'comm', 'com'), + file_tag_description: tryGrabTags(rawTagData, 'description', 'desc'), + file_tag_genre: tryGrabTags(rawTagData, 'genre', 'tcon', 'tco'), + file_tag_series: tryGrabTags(rawTagData, 'series', 'show', 'mvnm'), + file_tag_seriespart: tryGrabTags(rawTagData, 'series-part', 'episode_id', 'mvin', 'part'), + file_tag_isbn: tryGrabTags(rawTagData, 'isbn'), // custom + file_tag_language: tryGrabTags(rawTagData, 'language', 'lang'), + file_tag_asin: tryGrabTags(rawTagData, 'asin', 'audible_asin', 'cdek'), // custom + file_tag_itunesid: tryGrabTags(rawTagData, 'itunes-id'), // custom + file_tag_podcasttype: tryGrabTags(rawTagData, 'podcast-type'), // custom + file_tag_episodetype: tryGrabTags(rawTagData, 'episode-type'), // custom + file_tag_originalyear: tryGrabTags(rawTagData, 'originalyear'), + file_tag_releasecountry: tryGrabTags(rawTagData, 'MusicBrainz Album Release Country', 'releasecountry'), + file_tag_releasestatus: tryGrabTags(rawTagData, 'MusicBrainz Album Status', 'releasestatus', 'musicbrainz_albumstatus'), + file_tag_releasetype: tryGrabTags(rawTagData, 'MusicBrainz Album Type', 'releasetype', 'musicbrainz_albumtype'), + file_tag_isrc: tryGrabTags(rawTagData, 'tsrc', 'isrc'), + file_tag_musicbrainz_trackid: tryGrabTags(rawTagData, 'MusicBrainz Release Track Id', 'musicbrainz_releasetrackid'), + file_tag_musicbrainz_albumid: tryGrabTags(rawTagData, 'MusicBrainz Album Id', 'musicbrainz_albumid'), + file_tag_musicbrainz_albumartistid: tryGrabTags(rawTagData, 'MusicBrainz Album Artist Id', 'musicbrainz_albumartistid'), + file_tag_musicbrainz_artistid: tryGrabTags(rawTagData, 'MusicBrainz Artist Id', 'musicbrainz_artistid'), // Not sure if these are actually used yet or not - file_tag_creation_time: tryGrabTags(format, 'creation_time'), - file_tag_wwwaudiofile: tryGrabTags(format, 'wwwaudiofile', 'woaf', 'waf'), - file_tag_contentgroup: tryGrabTags(format, 'contentgroup', 'tit1', 'tt1'), - file_tag_releasetime: tryGrabTags(format, 'releasetime', 'tdrl'), - file_tag_movementname: tryGrabTags(format, 'movementname', 'mvnm'), - file_tag_movement: tryGrabTags(format, 'movement', 'mvin'), - file_tag_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'), - file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2'), - file_tag_overdrive_media_marker: tryGrabTags(format, 'OverDrive MediaMarkers'), + file_tag_creation_time: tryGrabTags(rawTagData, 'creation_time'), + file_tag_wwwaudiofile: tryGrabTags(rawTagData, 'wwwaudiofile', 'woaf', 'waf'), + file_tag_contentgroup: tryGrabTags(rawTagData, 'contentgroup', 'tit1', 'tt1'), + file_tag_releasetime: tryGrabTags(rawTagData, 'releasetime', 'tdrl'), + file_tag_movementname: tryGrabTags(rawTagData, 'movementname', 'mvnm'), + file_tag_movement: tryGrabTags(rawTagData, 'movement', 'mvin'), + file_tag_genre1: tryGrabTags(rawTagData, 'tmp_genre1', 'genre1'), + file_tag_genre2: tryGrabTags(rawTagData, 'tmp_genre2', 'genre2'), + file_tag_overdrive_media_marker: tryGrabTags(rawTagData, 'OverDrive MediaMarkers'), } for (const key in tags) { if (!tags[key]) { @@ -224,12 +250,13 @@ function parseTags(format, verbose) { function getDefaultAudioStream(audioStreams) { if (!audioStreams || !audioStreams.length) return null if (audioStreams.length === 1) return audioStreams[0] - var defaultStream = audioStreams.find(a => a.is_default) + var defaultStream = audioStreams.find((a) => a.is_default) if (!defaultStream) return audioStreams[0] return defaultStream } -function parseProbeData(data, verbose = false) { +function parseProbeData(data, mediaInfoTags, verbose = false) { + console.log(data) try { const { format, streams, chapters } = data @@ -242,15 +269,16 @@ function parseProbeData(data, verbose = false) { size: sizeBytes, sizeMb, bit_rate: !isNaN(format.bit_rate) ? Number(format.bit_rate) : null, - tags: parseTags(format, verbose) + tags: parseTags(format, mediaInfoTags, verbose), } if (verbose && format.tags) { - cleanedData.rawTags = format.tags + cleanedData.rawProbeTags = format.tags + cleanedData.rawMediaInfoTags = mediaInfoTags.fields } - const cleaned_streams = streams.map(s => parseMediaStreamInfo(s, streams, cleanedData.bit_rate)) - cleanedData.video_stream = cleaned_streams.find(s => s.type === 'video') - const audioStreams = cleaned_streams.filter(s => s.type === 'audio') + const cleaned_streams = streams.map((s) => parseMediaStreamInfo(s, streams, cleanedData.bit_rate)) + cleanedData.video_stream = cleaned_streams.find((s) => s.type === 'video') + const audioStreams = cleaned_streams.filter((s) => s.type === 'audio') cleanedData.audio_stream = getDefaultAudioStream(audioStreams) if (cleanedData.audio_stream && cleanedData.video_stream) { @@ -265,7 +293,7 @@ function parseProbeData(data, verbose = false) { if (!format.tags && cleanedData.audio_stream && cleanedData.audio_stream.tags) { cleanedData = { ...cleanedData, - tags: parseTags(cleanedData.audio_stream, verbose) + tags: parseTags(cleanedData.audio_stream, mediaInfoTags, verbose), } } @@ -280,45 +308,57 @@ function parseProbeData(data, verbose = false) { /** * Run ffprobe on audio filepath - * @param {string} filepath - * @param {boolean} [verbose=false] - * @returns {import('../scanner/MediaProbeData')|{error:string}} + * @param {string} filepath + * @param {boolean} [verbose=false] + * @returns {Promise} */ -function probe(filepath, verbose = false) { +async function probe(filepath, verbose = false) { if (process.env.FFPROBE_PATH) { ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH } - return ffprobe(filepath) - .then(raw => { - if (raw.error) { - return { - error: raw.error.string - } - } - - const rawProbeData = parseProbeData(raw, verbose) - if (!rawProbeData || (!rawProbeData.audio_stream && !rawProbeData.video_stream)) { - return { - error: rawProbeData ? 'Invalid media file: no audio or video streams found' : 'Probe Failed' - } - } else { - const probeData = new MediaProbeData() - probeData.setData(rawProbeData) - return probeData - } - }) - .catch((err) => { + let unparsedProbeData + try { + const rawProbe = await ffprobe(filepath) + if (rawProbe.error) { return { - error: err + error: raw.error.string, } - }) + } + unparsedProbeData = rawProbe + } catch (error) { + return { + error: error, + } + } + + let mediaInfoTags + try { + let rawMediaInfo = await mediaInfoHelpers.mediaInfoDump(filepath) + mediaInfoTags = mediaInfoHelpers.extractTags(rawMediaInfo) + } catch (error) { + // Currently mediainfo will not throw, but keeping this here for posterity + return { + error: error.toString(), + } + } + + const rawProbeData = parseProbeData(unparsedProbeData, mediaInfoTags, verbose) + if (!rawProbeData || (!rawProbeData.audio_stream && !rawProbeData.video_stream)) { + return { + error: rawProbeData ? 'Invalid media file: no audio or video streams found' : 'Probe Failed', + } + } else { + const probeData = new MediaProbeData() + probeData.setData(rawProbeData) + return probeData + } } module.exports.probe = probe /** * Ffprobe for audio file path - * + * * @param {string} filepath * @returns {Object} ffprobe json output */ @@ -327,11 +367,10 @@ function rawProbe(filepath) { ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH } - return ffprobe(filepath) - .catch((err) => { - return { - error: err - } - }) + return ffprobe(filepath).catch((err) => { + return { + error: err, + } + }) } -module.exports.rawProbe = rawProbe \ No newline at end of file +module.exports.rawProbe = rawProbe