mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
Add:Experimental tone library for scanning metadata
This commit is contained in:
parent
0e98620939
commit
c16e6d19ae
@ -6,7 +6,9 @@ RUN npm ci && npm cache clean --force
|
||||
RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
FROM sandreas/tone:v0.0.9 AS tone
|
||||
FROM node:16-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
RUN apk update && \
|
||||
apk add --no-cache --update \
|
||||
@ -14,6 +16,7 @@ RUN apk update && \
|
||||
tzdata \
|
||||
ffmpeg
|
||||
|
||||
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
|
||||
COPY --from=build /client/dist /client/dist
|
||||
COPY index.js package* /
|
||||
COPY server server
|
||||
|
@ -172,17 +172,15 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||
<p class="pl-4">
|
||||
Experimental Features
|
||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</a>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||
<p class="pl-4">
|
||||
Experimental Features
|
||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</a>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
@ -195,15 +193,15 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- <div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerUseSingleThreadedProber" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseSingleThreadedProber', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerUseSingleThreadedProber">
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerUseTone" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseTone', val)" />
|
||||
<ui-tooltip text="Tone library for metadata">
|
||||
<p class="pl-4">
|
||||
Scanner use old single threaded audio prober
|
||||
Use Tone library for metadata
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,11 +3,11 @@ version: "3.7"
|
||||
|
||||
services:
|
||||
audiobookshelf:
|
||||
image: ghcr.io/advplyr/audiobookshelf
|
||||
image: audiobookshelf-test
|
||||
ports:
|
||||
- 13378:80
|
||||
volumes:
|
||||
- ./audiobooks:/audiobooks
|
||||
- ./metadata:/metadata
|
||||
- ./config:/config
|
||||
- ./media/audiobooks:/audiobooks
|
||||
- ./test/metadata:/metadata
|
||||
- ./test/config:/config
|
||||
restart: unless-stopped
|
||||
|
13
package-lock.json
generated
13
package-lock.json
generated
@ -13,6 +13,7 @@
|
||||
"express": "^4.17.1",
|
||||
"graceful-fs": "^4.2.10",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"node-tone": "^1.0.1",
|
||||
"socket.io": "^4.4.1",
|
||||
"xml2js": "^0.4.23"
|
||||
},
|
||||
@ -594,6 +595,11 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-tone": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz",
|
||||
"integrity": "sha512-wi7L0taDZMN6tM5l85TDKHsYzdhqJTtPNgvgpk2zHeZzPt6ZIUZ9vBLTJRRDpm0xzCvbsvFHjAaudeQjLHTE4w=="
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@ -1360,6 +1366,11 @@
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="
|
||||
},
|
||||
"node-tone": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz",
|
||||
"integrity": "sha512-wi7L0taDZMN6tM5l85TDKHsYzdhqJTtPNgvgpk2zHeZzPt6ZIUZ9vBLTJRRDpm0xzCvbsvFHjAaudeQjLHTE4w=="
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@ -1602,4 +1613,4 @@
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -34,7 +34,8 @@
|
||||
"express": "^4.17.1",
|
||||
"graceful-fs": "^4.2.10",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"node-tone": "^1.0.1",
|
||||
"socket.io": "^4.4.1",
|
||||
"xml2js": "^0.4.23"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -87,6 +87,10 @@ class AudioMetaTags {
|
||||
this.tagOverdriveMediaMarker = payload.file_tag_overdrive_media_marker || null
|
||||
}
|
||||
|
||||
setDataFromTone(tags) {
|
||||
// TODO: Implement
|
||||
}
|
||||
|
||||
updateData(payload) {
|
||||
const dataMap = {
|
||||
tagAlbum: payload.file_tag_album || null,
|
||||
|
@ -17,7 +17,8 @@ class ServerSettings {
|
||||
this.scannerDisableWatcher = false
|
||||
this.scannerPreferOverdriveMediaMarker = false
|
||||
this.scannerUseSingleThreadedProber = true
|
||||
this.scannerMaxThreads = 0 // 0 = defaults to CPUs * 2
|
||||
this.scannerMaxThreads = 0 // Currently not being used
|
||||
this.scannerUseTone = false
|
||||
|
||||
// Metadata - choose to store inside users library item folder
|
||||
this.storeCoverWithItem = false
|
||||
@ -82,6 +83,7 @@ class ServerSettings {
|
||||
this.scannerUseSingleThreadedProber = true
|
||||
}
|
||||
this.scannerMaxThreads = isNullOrNaN(settings.scannerMaxThreads) ? 0 : Number(settings.scannerMaxThreads)
|
||||
this.scannerUseTone = !!settings.scannerUseTone
|
||||
|
||||
this.storeCoverWithItem = !!settings.storeCoverWithItem
|
||||
this.storeMetadataWithItem = !!settings.storeMetadataWithItem
|
||||
@ -139,6 +141,7 @@ class ServerSettings {
|
||||
scannerPreferOverdriveMediaMarker: this.scannerPreferOverdriveMediaMarker,
|
||||
scannerUseSingleThreadedProber: this.scannerUseSingleThreadedProber,
|
||||
scannerMaxThreads: this.scannerMaxThreads,
|
||||
scannerUseTone: this.scannerUseTone,
|
||||
storeCoverWithItem: this.storeCoverWithItem,
|
||||
storeMetadataWithItem: this.storeMetadataWithItem,
|
||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||
|
@ -3,9 +3,8 @@ const Path = require('path')
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
const VideoFile = require('../objects/files/VideoFile')
|
||||
|
||||
const MediaProbePool = require('./MediaProbePool')
|
||||
|
||||
const prober = require('../utils/prober')
|
||||
const toneProber = require('../utils/toneProber')
|
||||
const Logger = require('../Logger')
|
||||
const { LogLevel } = require('../utils/constants')
|
||||
|
||||
@ -59,7 +58,15 @@ class MediaFileScanner {
|
||||
|
||||
async scan(mediaType, libraryFile, mediaMetadataFromScan, verbose = false) {
|
||||
var probeStart = Date.now()
|
||||
var probeData = await prober.probe(libraryFile.metadata.path, verbose)
|
||||
|
||||
var probeData = null
|
||||
if (global.ServerSettings.scannerUseTone) {
|
||||
Logger.debug(`[MediaFileScanner] using tone to probe audio file "${libraryFile.metadata.path}"`)
|
||||
probeData = await toneProber.probe(libraryFile.metadata.path, true)
|
||||
} else {
|
||||
probeData = await prober.probe(libraryFile.metadata.path, verbose)
|
||||
}
|
||||
|
||||
if (probeData.error) {
|
||||
Logger.error(`[MediaFileScanner] ${probeData.error} : "${libraryFile.metadata.path}"`)
|
||||
return null
|
||||
@ -105,35 +112,18 @@ class MediaFileScanner {
|
||||
async executeMediaFileScans(libraryItem, mediaLibraryFiles, scanData) {
|
||||
const mediaType = libraryItem.mediaType
|
||||
|
||||
if (!global.ServerSettings.scannerUseSingleThreadedProber) { // New multi-threaded scanner
|
||||
var scanStart = Date.now()
|
||||
const probeResults = await new Promise((resolve) => {
|
||||
// const probePool = new MediaProbePool(mediaType, mediaLibraryFiles, scanData, global.ServerSettings.scannerMaxThreads)
|
||||
const itemBatch = MediaProbePool.initBatch(libraryItem, mediaLibraryFiles, scanData)
|
||||
itemBatch.on('done', resolve)
|
||||
MediaProbePool.runBatch(itemBatch)
|
||||
})
|
||||
|
||||
return {
|
||||
audioFiles: probeResults.audioFiles || [],
|
||||
videoFiles: probeResults.videoFiles || [],
|
||||
elapsed: Date.now() - scanStart,
|
||||
averageScanDuration: probeResults.averageTimePerMb
|
||||
}
|
||||
} else { // Old single threaded scanner
|
||||
var scanStart = Date.now()
|
||||
var mediaMetadataFromScan = scanData.media.metadata || null
|
||||
var proms = []
|
||||
for (let i = 0; i < mediaLibraryFiles.length; i++) {
|
||||
proms.push(this.scan(mediaType, mediaLibraryFiles[i], mediaMetadataFromScan))
|
||||
}
|
||||
var results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr))
|
||||
return {
|
||||
audioFiles: results.filter(r => r.audioFile).map(r => r.audioFile),
|
||||
videoFiles: results.filter(r => r.videoFile).map(r => r.videoFile),
|
||||
elapsed: Date.now() - scanStart,
|
||||
averageScanDuration: this.getAverageScanDurationMs(results)
|
||||
}
|
||||
var scanStart = Date.now()
|
||||
var mediaMetadataFromScan = scanData.media.metadata || null
|
||||
var proms = []
|
||||
for (let i = 0; i < mediaLibraryFiles.length; i++) {
|
||||
proms.push(this.scan(mediaType, mediaLibraryFiles[i], mediaMetadataFromScan))
|
||||
}
|
||||
var results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr))
|
||||
return {
|
||||
audioFiles: results.filter(r => r.audioFile).map(r => r.audioFile),
|
||||
videoFiles: results.filter(r => r.videoFile).map(r => r.videoFile),
|
||||
elapsed: Date.now() - scanStart,
|
||||
averageScanDuration: this.getAverageScanDurationMs(results)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -66,15 +66,20 @@ class MediaProbeData {
|
||||
this.sampleRate = audioStream.sample_rate
|
||||
this.chapters = data.chapters || []
|
||||
|
||||
var metatags = {}
|
||||
for (const key in data) {
|
||||
if (data[key] && key.startsWith('file_tag')) {
|
||||
metatags[key] = data[key]
|
||||
if (data.tags) { // New for tone library data (toneProber.js)
|
||||
this.audioFileMetadata = new AudioFileMetadata()
|
||||
this.audioFileMetadata.setDataFromTone(data.tags)
|
||||
} else { // Data from ffprobe (prober.js)
|
||||
var metatags = {}
|
||||
for (const key in data) {
|
||||
if (data[key] && key.startsWith('file_tag')) {
|
||||
metatags[key] = data[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.audioFileMetadata = new AudioFileMetadata()
|
||||
this.audioFileMetadata.setData(metatags)
|
||||
this.audioFileMetadata = new AudioFileMetadata()
|
||||
this.audioFileMetadata.setData(metatags)
|
||||
}
|
||||
|
||||
// Track ID3 tag might be "3/10" or just "3"
|
||||
if (this.audioFileMetadata.tagTrack) {
|
||||
|
@ -1,209 +0,0 @@
|
||||
const os = require('os')
|
||||
const Path = require('path')
|
||||
const { EventEmitter } = require('events')
|
||||
const { Worker } = require("worker_threads")
|
||||
const Logger = require('../Logger')
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
const VideoFile = require('../objects/files/VideoFile')
|
||||
const MediaProbeData = require('./MediaProbeData')
|
||||
|
||||
class LibraryItemBatch extends EventEmitter {
|
||||
constructor(libraryItem, libraryFiles, scanData) {
|
||||
super()
|
||||
|
||||
this.id = libraryItem.id
|
||||
this.mediaType = libraryItem.mediaType
|
||||
this.mediaMetadataFromScan = scanData.media.metadata || null
|
||||
this.libraryFilesToScan = libraryFiles
|
||||
|
||||
// Results
|
||||
this.totalElapsed = 0
|
||||
this.totalProbed = 0
|
||||
this.audioFiles = []
|
||||
this.videoFiles = []
|
||||
}
|
||||
|
||||
done() {
|
||||
this.emit('done', {
|
||||
videoFiles: this.videoFiles,
|
||||
audioFiles: this.audioFiles,
|
||||
averageTimePerMb: Math.round(this.totalElapsed / this.totalProbed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class MediaProbePool {
|
||||
constructor() {
|
||||
this.MaxThreads = 0
|
||||
this.probeWorkerScript = null
|
||||
|
||||
this.itemBatchMap = {}
|
||||
|
||||
this.probesRunning = []
|
||||
this.probeQueue = []
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (this.probesRunning.length < this.MaxThreads) {
|
||||
if (this.probeQueue.length > 0) {
|
||||
const pw = this.probeQueue.shift()
|
||||
// console.log('Unqueued probe - Remaining is', this.probeQueue.length, 'Currently running is', this.probesRunning.length)
|
||||
this.startTask(pw)
|
||||
} else if (!this.probesRunning.length) {
|
||||
// console.log('No more probes to run')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async startTask(task) {
|
||||
this.probesRunning.push(task)
|
||||
|
||||
const itemBatch = this.itemBatchMap[task.batchId]
|
||||
|
||||
await task.start().then((taskResult) => {
|
||||
itemBatch.libraryFilesToScan = itemBatch.libraryFilesToScan.filter(lf => lf.ino !== taskResult.libraryFile.ino)
|
||||
|
||||
var fileSizeMb = taskResult.libraryFile.metadata.size / (1024 * 1024)
|
||||
var elapsedPerMb = Math.round(taskResult.elapsed / fileSizeMb)
|
||||
|
||||
const probeData = new MediaProbeData(taskResult.data)
|
||||
|
||||
if (itemBatch.mediaType === 'video') {
|
||||
if (!probeData.videoStream) {
|
||||
Logger.error('[MediaProbePool] Invalid video file no video stream')
|
||||
} else {
|
||||
itemBatch.totalElapsed += elapsedPerMb
|
||||
itemBatch.totalProbed++
|
||||
|
||||
var videoFile = new VideoFile()
|
||||
videoFile.setDataFromProbe(libraryFile, probeData)
|
||||
itemBatch.videoFiles.push(videoFile)
|
||||
}
|
||||
} else {
|
||||
if (!probeData.audioStream) {
|
||||
Logger.error('[MediaProbePool] Invalid audio file no audio stream')
|
||||
} else {
|
||||
itemBatch.totalElapsed += elapsedPerMb
|
||||
itemBatch.totalProbed++
|
||||
|
||||
var audioFile = new AudioFile()
|
||||
audioFile.trackNumFromMeta = probeData.trackNumber
|
||||
audioFile.discNumFromMeta = probeData.discNumber
|
||||
if (itemBatch.mediaType === 'book') {
|
||||
const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(itemBatch.mediaMetadataFromScan, taskResult.libraryFile)
|
||||
audioFile.trackNumFromFilename = trackNumber
|
||||
audioFile.discNumFromFilename = discNumber
|
||||
}
|
||||
audioFile.setDataFromProbe(taskResult.libraryFile, probeData)
|
||||
|
||||
itemBatch.audioFiles.push(audioFile)
|
||||
}
|
||||
}
|
||||
|
||||
this.probesRunning = this.probesRunning.filter(tq => tq.mediaPath !== task.mediaPath)
|
||||
this.tick()
|
||||
}).catch((error) => {
|
||||
itemBatch.libraryFilesToScan = itemBatch.libraryFilesToScan.filter(lf => lf.ino !== taskResult.libraryFile.ino)
|
||||
|
||||
Logger.error('[MediaProbePool] Task failed', error)
|
||||
this.probesRunning = this.probesRunning.filter(tq => tq.mediaPath !== task.mediaPath)
|
||||
this.tick()
|
||||
})
|
||||
|
||||
if (!itemBatch.libraryFilesToScan.length) {
|
||||
itemBatch.done()
|
||||
delete this.itemBatchMap[itemBatch.id]
|
||||
}
|
||||
}
|
||||
|
||||
buildTask(libraryFile, batchId) {
|
||||
return {
|
||||
batchId,
|
||||
mediaPath: libraryFile.metadata.path,
|
||||
start: () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
const worker = new Worker(this.probeWorkerScript)
|
||||
worker.on("message", ({ data }) => {
|
||||
if (data.error) {
|
||||
reject(data.error)
|
||||
} else {
|
||||
resolve({
|
||||
data,
|
||||
elapsed: Date.now() - startTime,
|
||||
libraryFile
|
||||
})
|
||||
}
|
||||
})
|
||||
worker.postMessage({
|
||||
mediaPath: libraryFile.metadata.path
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initBatch(libraryItem, libraryFiles, scanData) {
|
||||
this.MaxThreads = global.ServerSettings.scannerMaxThreads || (os.cpus().length * 2)
|
||||
this.probeWorkerScript = Path.join(global.appRoot, 'server/utils/probeWorker.js')
|
||||
|
||||
Logger.debug(`[MediaProbePool] Run item batch ${libraryItem.id} with`, libraryFiles.length, 'files and max concurrent of', this.MaxThreads)
|
||||
|
||||
const itemBatch = new LibraryItemBatch(libraryItem, libraryFiles, scanData)
|
||||
this.itemBatchMap[itemBatch.id] = itemBatch
|
||||
|
||||
return itemBatch
|
||||
}
|
||||
|
||||
runBatch(itemBatch) {
|
||||
for (const libraryFile of itemBatch.libraryFilesToScan) {
|
||||
const probeTask = this.buildTask(libraryFile, itemBatch.id)
|
||||
|
||||
if (this.probesRunning.length < this.MaxThreads) {
|
||||
this.startTask(probeTask)
|
||||
} else {
|
||||
this.probeQueue.push(probeTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) {
|
||||
const { title, author, series, publishedYear } = mediaMetadataFromScan
|
||||
const { filename, path } = audioLibraryFile.metadata
|
||||
var partbasename = Path.basename(filename, Path.extname(filename))
|
||||
|
||||
// Remove title, author, series, and publishedYear from filename if there
|
||||
if (title) partbasename = partbasename.replace(title, '')
|
||||
if (author) partbasename = partbasename.replace(author, '')
|
||||
if (series) partbasename = partbasename.replace(series, '')
|
||||
if (publishedYear) partbasename = partbasename.replace(publishedYear)
|
||||
|
||||
// Look for disc number
|
||||
var discNumber = null
|
||||
var discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i)
|
||||
if (discMatch && discMatch.length > 2 && discMatch[2]) {
|
||||
if (!isNaN(discMatch[2])) {
|
||||
discNumber = Number(discMatch[2])
|
||||
}
|
||||
|
||||
// Remove disc number from filename
|
||||
partbasename = partbasename.replace(/\b(disc|cd) ?(\d\d?)\b/i, '')
|
||||
}
|
||||
|
||||
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
|
||||
var pathdir = Path.dirname(path).split('/').pop()
|
||||
if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) {
|
||||
var discFromFolder = Number(pathdir.replace(/cd/i, ''))
|
||||
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
|
||||
}
|
||||
|
||||
var numbersinpath = partbasename.match(/\d{1,4}/g)
|
||||
var trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null
|
||||
return {
|
||||
trackNumber,
|
||||
discNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = new MediaProbePool()
|
@ -200,12 +200,6 @@ function parseTags(format, verbose) {
|
||||
}
|
||||
}
|
||||
|
||||
// var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime', 'file_tag_isbn']
|
||||
// keysToLookOutFor.forEach((key) => {
|
||||
// if (tags[key]) {
|
||||
// Logger.debug(`Notable! ${key} => ${tags[key]}`)
|
||||
// }
|
||||
// })
|
||||
return tags
|
||||
}
|
||||
|
||||
|
158
server/utils/toneProber.js
Normal file
158
server/utils/toneProber.js
Normal file
@ -0,0 +1,158 @@
|
||||
const tone = require('node-tone')
|
||||
const MediaProbeData = require('../scanner/MediaProbeData')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
/*
|
||||
Sample dump from tone
|
||||
{
|
||||
"audio": {
|
||||
"bitrate": 17,
|
||||
"format": "MPEG-4 Part 14",
|
||||
"formatShort": "MPEG-4",
|
||||
"sampleRate": 44100.0,
|
||||
"duration": 209284.0,
|
||||
"channels": {
|
||||
"count": 2,
|
||||
"description": "Stereo (2/0.0)"
|
||||
},
|
||||
"frames": {
|
||||
"offset": 42168,
|
||||
"length": 446932
|
||||
"metaFormat": [
|
||||
"mp4"
|
||||
]
|
||||
},
|
||||
"meta": {
|
||||
"album": "node-tone",
|
||||
"albumArtist": "advplyr",
|
||||
"artist": "advplyr",
|
||||
"composer": "Composer 5",
|
||||
"comment": "testing out tone metadata",
|
||||
"encodingTool": "audiobookshelf",
|
||||
"genre": "abs",
|
||||
"itunesCompilation": "no",
|
||||
"itunesMediaType": "audiobook",
|
||||
"itunesPlayGap": "noGap",
|
||||
"narrator": "Narrator 5",
|
||||
"recordingDate": "2022-09-10T00:00:00",
|
||||
"title": "Test 5",
|
||||
"trackNumber": 5,
|
||||
"chapters": [
|
||||
{
|
||||
"start": 0,
|
||||
"length": 500,
|
||||
"title": "chapter 1"
|
||||
},
|
||||
{
|
||||
"start": 500,
|
||||
"length": 500,
|
||||
"title": "chapter 2"
|
||||
},
|
||||
{
|
||||
"start": 1000,
|
||||
"length": 208284,
|
||||
"title": "chapter 3"
|
||||
}
|
||||
],
|
||||
"embeddedPictures": [
|
||||
{
|
||||
"code": 14,
|
||||
"mimetype": "image/png",
|
||||
"data": "..."
|
||||
},
|
||||
"additionalFields": {
|
||||
"test": "Test 5"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"size": 530793,
|
||||
"created": "2022-09-10T13:32:51.1942586-05:00",
|
||||
"modified": "2022-09-10T14:09:19.366071-05:00",
|
||||
"accessed": "2022-09-11T13:00:56.5097533-05:00",
|
||||
"path": "C:\\Users\\Coop\\Documents\\NodeProjects\\node-tone\\samples",
|
||||
"name": "m4b.m4b"
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
function bitrateKilobitToBit(bitrate) {
|
||||
if (isNaN(bitrate) || !bitrate) return 0
|
||||
return Number(bitrate) * 1000
|
||||
}
|
||||
|
||||
function msToSeconds(ms) {
|
||||
if (isNaN(ms) || !ms) return 0
|
||||
return Number(ms) / 1000
|
||||
}
|
||||
|
||||
function parseProbeDump(dumpPayload) {
|
||||
const audioMetadata = dumpPayload.audio
|
||||
const audioChannels = audioMetadata.channels || {}
|
||||
const audio_stream = {
|
||||
bit_rate: bitrateKilobitToBit(audioMetadata.bitrate), // tone uses Kbps but ffprobe uses bps so convert to bits
|
||||
codec: null,
|
||||
time_base: null,
|
||||
language: null,
|
||||
channel_layout: audioChannels.description || null,
|
||||
channels: audioChannels.count || null,
|
||||
sample_rate: audioMetadata.sampleRate || null
|
||||
}
|
||||
|
||||
let chapterIndex = 0
|
||||
const chapters = (dumpPayload.meta.chapters || []).map(chap => {
|
||||
return {
|
||||
id: chapterIndex++,
|
||||
start: msToSeconds(chap.start),
|
||||
end: msToSeconds(chap.start + chap.length),
|
||||
title: chap.title || ''
|
||||
}
|
||||
})
|
||||
|
||||
var video_stream = null
|
||||
if (dumpPayload.meta.embeddedPictures && dumpPayload.meta.embeddedPictures.length) {
|
||||
const mimetype = dumpPayload.meta.embeddedPictures[0].mimetype
|
||||
video_stream = {
|
||||
codec: mimetype === 'image/png' ? 'png' : 'jpeg'
|
||||
}
|
||||
}
|
||||
|
||||
const tags = { ...dumpPayload.meta }
|
||||
delete tags.chapters
|
||||
delete tags.embeddedPictures
|
||||
|
||||
const fileMetadata = dumpPayload.file
|
||||
var sizeBytes = !isNaN(fileMetadata.size) ? Number(fileMetadata.size) : null
|
||||
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
|
||||
return {
|
||||
format: audioMetadata.format || 'Unknown',
|
||||
duration: msToSeconds(audioMetadata.duration),
|
||||
size: sizeBytes,
|
||||
sizeMb,
|
||||
bit_rate: audio_stream.bit_rate,
|
||||
audio_stream,
|
||||
video_stream,
|
||||
chapters,
|
||||
tags
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.probe = (filepath, verbose = false) => {
|
||||
if (process.env.TONE_PATH) {
|
||||
ffprobe.TONE_PATH = process.env.TONE_PATH
|
||||
}
|
||||
|
||||
return tone.dump(filepath).then((dumpPayload) => {
|
||||
if (verbose) {
|
||||
Logger.debug(`[toneProber] dump for file "${filepath}"`, dumpPayload)
|
||||
}
|
||||
const rawProbeData = parseProbeDump(dumpPayload)
|
||||
const probeData = new MediaProbeData()
|
||||
probeData.setData(rawProbeData)
|
||||
return probeData
|
||||
}).catch((error) => {
|
||||
Logger.error(`[toneProber] Failed to probe file at path "${filepath}"`, error)
|
||||
return {
|
||||
error
|
||||
}
|
||||
})
|
||||
}
|
Loading…
Reference in New Issue
Block a user