diff --git a/Dockerfile b/Dockerfile index 47aced8a..ddafa0a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 8e73e1ab..ad79caff 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -172,17 +172,15 @@
-
- - -

- Experimental Features - - info_outlined - -

-
-
+ + +

+ Experimental Features + + info_outlined + +

+
@@ -195,15 +193,15 @@
- + diff --git a/docker-compose.yml b/docker-compose.yml index 43acbfac..de1a3e52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/package-lock.json b/package-lock.json index 6388098a..17bf4bfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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==" } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 5fc15ffd..25bebfa4 100644 --- a/package.json +++ b/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/server/objects/metadata/AudioMetaTags.js b/server/objects/metadata/AudioMetaTags.js index 13bb2a83..7f1486b7 100644 --- a/server/objects/metadata/AudioMetaTags.js +++ b/server/objects/metadata/AudioMetaTags.js @@ -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, diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 0f701f50..85efb70c 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -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, diff --git a/server/scanner/MediaFileScanner.js b/server/scanner/MediaFileScanner.js index 38b74e0d..c57d30b2 100644 --- a/server/scanner/MediaFileScanner.js +++ b/server/scanner/MediaFileScanner.js @@ -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) } } diff --git a/server/scanner/MediaProbeData.js b/server/scanner/MediaProbeData.js index 88edb4d7..f4ac2b59 100644 --- a/server/scanner/MediaProbeData.js +++ b/server/scanner/MediaProbeData.js @@ -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) { diff --git a/server/scanner/MediaProbePool.js b/server/scanner/MediaProbePool.js deleted file mode 100644 index 4076c809..00000000 --- a/server/scanner/MediaProbePool.js +++ /dev/null @@ -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() \ No newline at end of file diff --git a/server/utils/prober.js b/server/utils/prober.js index 770c235e..8b235f9b 100644 --- a/server/utils/prober.js +++ b/server/utils/prober.js @@ -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 } diff --git a/server/utils/toneProber.js b/server/utils/toneProber.js new file mode 100644 index 00000000..7713edb3 --- /dev/null +++ b/server/utils/toneProber.js @@ -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 + } + }) +} \ No newline at end of file