Update:Experimental metadata embed tool to use tone

This commit is contained in:
advplyr 2022-09-25 15:56:06 -05:00
parent b6e3559aba
commit 97da73baf3
10 changed files with 296 additions and 119 deletions

View File

@ -1,29 +1,31 @@
<template>
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex justify-center mb-2">
<div class="flex justify-center mb-4">
<div class="w-full max-w-2xl">
<p class="text-xl">Metadata to embed</p>
<p class="text-xl mb-2">Metadata to embed</p>
<p class="mb-4 text-base text-gray-300">audiobookshelf uses <a href="https://github.com/sandreas/tone" target="_blank" class="hover:underline text-blue-400 hover:text-blue-300">tone</a> to write metadata.</p>
</div>
<div class="w-full max-w-2xl"></div>
</div>
<div class="flex justify-center flex-wrap">
<div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2">
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4">
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">Meta Tag</div>
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">Value</div>
</div>
<div class="w-full max-h-72 overflow-auto">
<template v-for="(keyValue, index) in metadataKeyValues">
<div :key="keyValue.key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="w-1/3 font-semibold">{{ keyValue.key }}</div>
<template v-for="(value, key, index) in toneObject">
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="w-1/3 font-semibold">{{ key }}</div>
<div class="w-2/3">
{{ keyValue.value }}
{{ value }}
</div>
</div>
</template>
</div>
</div>
<div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2">
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4">
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Chapter Title</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">Start</div>
@ -34,10 +36,10 @@
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
<div class="w-24">
{{ chapter.start.toFixed(2) }}
{{ $secondsToTimestamp(chapter.start) }}
</div>
<div class="w-24">
{{ chapter.end.toFixed(2) }}
{{ $secondsToTimestamp(chapter.end) }}
</div>
</div>
</template>
@ -48,12 +50,17 @@
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full max-w-4xl mx-auto">
<div class="w-full flex justify-between items-center mb-4">
<div class="w-full flex justify-between items-center mb-2">
<p class="text-warning text-lg font-semibold">Warning: Modifies your audio files</p>
<ui-btn v-if="!embedFinished" color="primary" :loading="updatingMetadata" @click="updateAudioFileMetadata">Embed Metadata</ui-btn>
<ui-btn v-if="!embedFinished" color="primary" :loading="updatingMetadata" @click.stop="embedClick">Embed Metadata</ui-btn>
<p v-else class="text-success text-lg font-semibold">Embed Finished!</p>
</div>
<div class="w-full mx-auto border border-opacity-10 bg-bg">
<div class="flex mb-4">
<p class="text-gray-200">
A backup of your audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span> so you can restore the originals if necessary.
</p>
</div>
<div class="w-full mx-auto border border-white border-opacity-10 bg-bg">
<div class="flex py-2 px-4">
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Filename</div>
@ -118,7 +125,8 @@ export default {
audiofilesEncoding: {},
audiofilesFinished: {},
updatingMetadata: false,
embedFinished: false
embedFinished: false,
toneObject: null
}
},
computed: {
@ -137,98 +145,35 @@ export default {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
metadataKeyValues() {
const keyValues = [
{
key: 'title',
value: this.mediaMetadata.title
},
{
key: 'artist',
value: this.mediaMetadata.authorName
},
{
key: 'album_artist',
value: this.mediaMetadata.authorName
},
{
key: 'date',
value: this.mediaMetadata.publishedYear
},
{
key: 'description',
value: this.mediaMetadata.description
},
{
key: 'genre',
value: this.mediaMetadata.genres.join(';')
},
{
key: 'performer',
value: this.mediaMetadata.narratorName
}
]
if (this.mediaMetadata.subtitle) {
keyValues.push({
key: 'subtitle',
value: this.mediaMetadata.subtitle
})
}
if (this.mediaMetadata.asin) {
keyValues.push({
key: 'asin',
value: this.mediaMetadata.asin
})
}
if (this.mediaMetadata.isbn) {
keyValues.push({
key: 'isbn',
value: this.mediaMetadata.isbn
})
}
if (this.mediaMetadata.language) {
keyValues.push({
key: 'language',
value: this.mediaMetadata.language
})
}
if (this.mediaMetadata.series.length) {
var firstSeries = this.mediaMetadata.series[0]
keyValues.push({
key: 'series',
value: firstSeries.name
})
if (firstSeries.sequence) {
keyValues.push({
key: 'series-part',
value: firstSeries.sequence
})
}
}
return keyValues
},
metadataChapters() {
var chapters = this.media.chapters || []
return chapters.concat(chapters)
}
},
methods: {
updateAudioFileMetadata() {
if (confirm(`Warning!\n\nThis will modify the audio files for this audiobook.\nMake sure your audio files are backed up before using this feature.`)) {
this.updatingMetadata = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/audio-metadata`)
.then(() => {
console.log('Audio metadata encode started')
})
.catch((error) => {
console.error('Audio metadata encode failed', error)
this.updatingMetadata = false
})
embedClick() {
const payload = {
message: `Are you sure you want to embed metadata in ${this.audioFiles.length} audio files?`,
callback: (confirmed) => {
if (confirmed) {
this.updateAudioFileMetadata()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
updateAudioFileMetadata() {
this.updatingMetadata = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/audio-metadata?tone=1`)
.then(() => {
console.log('Audio metadata encode started')
})
.catch((error) => {
console.error('Audio metadata encode failed', error)
this.updatingMetadata = false
})
},
audioMetadataStarted(data) {
console.log('audio metadata started', data)
@ -252,9 +197,21 @@ export default {
if (data.libraryItemId !== this.libraryItemId) return
this.$set(this.audiofilesEncoding, data.ino, false)
this.$set(this.audiofilesFinished, data.ino, true)
},
fetchToneObject() {
this.$axios
.$get(`/api/items/${this.libraryItemId}/tone-object`)
.then((toneObject) => {
delete toneObject.CoverFile
this.toneObject = toneObject
})
.catch((error) => {
console.error('Failed to fetch tone object', error)
})
}
},
mounted() {
this.fetchToneObject()
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)

View File

@ -311,7 +311,7 @@ class LibraryItemController {
Logger.warn('User other than admin attempted to batch quick match library items', req.user)
return res.sendStatus(403)
}
var itemsUpdated = 0
var itemsUnmatched = 0
@ -322,17 +322,17 @@ class LibraryItemController {
return res.sendStatus(500)
}
res.sendStatus(200)
for (let i = 0; i < items.length; i++) {
var libraryItem = this.db.libraryItems.find(_li => _li.id === items[i])
var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
if (matchResult.updated) {
itemsUpdated++
} else if (matchResult.warning) {
itemsUnmatched++
}
var libraryItem = this.db.libraryItems.find(_li => _li.id === items[i])
var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
if (matchResult.updated) {
itemsUpdated++
} else if (matchResult.warning) {
itemsUnmatched++
}
}
var result = {
success: itemsUpdated > 0,
updates: itemsUpdated,
@ -371,6 +371,20 @@ class LibraryItemController {
})
}
getToneMetadataObject(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-root user attempted to get tone metadata object`, req.user)
return res.sendStatus(403)
}
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500)
}
res.json(this.audioMetadataManager.getToneMetadataObjectForApi(req.libraryItem))
}
// GET: api/items/:id/audio-metadata
async updateAudioFileMetadata(req, res) {
if (!req.user.isAdminOrUp) {
@ -383,7 +397,8 @@ class LibraryItemController {
return res.sendStatus(500)
}
this.audioMetadataManager.updateAudioFileMetadataForItem(req.user, req.libraryItem)
const useTone = req.query.tone === '1'
this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, useTone)
res.sendStatus(200)
}

View File

@ -5,6 +5,7 @@ const Logger = require('../Logger')
const filePerms = require('../utils/filePerms')
const { secondsToTimestamp } = require('../utils/index')
const { writeMetadataFile } = require('../utils/ffmpegHelpers')
const toneHelpers = require('../utils/toneHelpers')
class AudioMetadataMangaer {
constructor(db, emitter, clientEmitter) {
@ -13,7 +14,104 @@ class AudioMetadataMangaer {
this.clientEmitter = clientEmitter
}
async updateAudioFileMetadataForItem(user, libraryItem) {
updateMetadataForItem(user, libraryItem, useTone = true) {
if (useTone) {
this.updateMetadataForItemWithTone(user, libraryItem)
} else {
this.updateMetadataForItemWithFfmpeg(user, libraryItem)
}
}
//
// TONE
//
getToneMetadataObjectForApi(libraryItem) {
return toneHelpers.getToneMetadataObject(libraryItem)
}
async updateMetadataForItemWithTone(user, libraryItem) {
var audioFiles = libraryItem.media.includedAudioFiles
const itemAudioMetadataPayload = {
userId: user.id,
libraryItemId: libraryItem.id,
startedAt: Date.now(),
audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename }))
}
this.emitter('audio_metadata_started', itemAudioMetadataPayload)
// Write chapters file
var chaptersFilePath = null
var cachePath = Path.join(global.MetadataPath, 'cache/items')
console.log('Items Cache Path', cachePath)
var itemCacheDir = Path.join(cachePath, libraryItem.id)
await fs.ensureDir(itemCacheDir)
if (libraryItem.media.chapters.length) {
chaptersFilePath = Path.join(itemCacheDir, 'chapters.txt')
try {
await toneHelpers.writeToneChaptersFile(libraryItem.media.chapters, chaptersFilePath)
} catch (error) {
Logger.error(`[AudioMetadataManager] Write chapters.txt failed`, error)
chaptersFilePath = null
}
}
const toneMetadataObject = toneHelpers.getToneMetadataObject(libraryItem, chaptersFilePath)
Logger.debug(`[AudioMetadataManager] Book "${libraryItem.media.metadata.title}" tone metadata object=`, toneMetadataObject)
const results = []
for (const af of audioFiles) {
const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneMetadataObject, itemCacheDir)
results.push(result)
}
const elapsed = Date.now() - itemAudioMetadataPayload.startedAt
Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed)}`)
itemAudioMetadataPayload.results = results
itemAudioMetadataPayload.elapsed = elapsed
itemAudioMetadataPayload.finishedAt = Date.now()
this.emitter('audio_metadata_finished', itemAudioMetadataPayload)
}
async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneMetadataObject, itemCacheDir) {
const resultPayload = {
libraryItemId,
index: audioFile.index,
ino: audioFile.ino,
filename: audioFile.metadata.filename
}
this.emitter('audiofile_metadata_started', resultPayload)
// Backup audio file
try {
const backupFilePath = Path.join(itemCacheDir, audioFile.metadata.filename)
await fs.copy(audioFile.metadata.path, backupFilePath)
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
} catch (err) {
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${audioFile.metadata.path}"`, err)
}
const _toneMetadataObject = {
...toneMetadataObject,
'TrackNumber': audioFile.index
}
resultPayload.success = await toneHelpers.tagAudioFile(audioFile.metadata.path, _toneMetadataObject)
if (resultPayload.success) {
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${audioFile.metadata.path}"`)
}
this.emitter('audiofile_metadata_finished', resultPayload)
return resultPayload
}
//
// FFMPEG
//
async updateMetadataForItemWithFfmpeg(user, libraryItem) {
var audioFiles = libraryItem.media.audioFiles
const itemAudioMetadataPayload = {
@ -36,9 +134,8 @@ class AudioMetadataMangaer {
var coverPath = libraryItem.media.coverPath.replace(/\\/g, '/')
}
// TODO: Split into batches
const proms = audioFiles.map(af => {
return this.updateAudioFileMetadata(libraryItem.id, af, outputDir, metadataFilePath, coverPath)
return this.updateAudioFileMetadataWithFfmpeg(libraryItem.id, af, outputDir, metadataFilePath, coverPath)
})
const results = await Promise.all(proms)
@ -55,7 +152,7 @@ class AudioMetadataMangaer {
this.emitter('audio_metadata_finished', itemAudioMetadataPayload)
}
updateAudioFileMetadata(libraryItemId, audioFile, outputDir, metadataFilePath, coverPath = '') {
updateAudioFileMetadataWithFfmpeg(libraryItemId, audioFile, outputDir, metadataFilePath, coverPath = '') {
return new Promise((resolve) => {
const resultPayload = {
libraryItemId,

View File

@ -10,6 +10,7 @@ class CacheManager {
this.CachePath = Path.join(global.MetadataPath, 'cache')
this.CoverCachePath = Path.join(this.CachePath, 'covers')
this.ImageCachePath = Path.join(this.CachePath, 'images')
this.ItemCachePath = Path.join(this.CachePath, 'items')
}
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
@ -29,6 +30,11 @@ class CacheManager {
pathsCreated = true
}
if (!(await fs.pathExists(this.ItemCachePath))) {
await fs.mkdir(this.ItemCachePath)
pathsCreated = true
}
if (pathsCreated) {
await filePerms.setDefault(this.CachePath)
}

View File

@ -3,7 +3,7 @@ const Logger = require('../../Logger')
const BookMetadata = require('../metadata/BookMetadata')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata')
const { overdriveMediaMarkersExist, parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
const { parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
const { readTextFile } = require('../../utils/fileUtils')
const AudioFile = require('../files/AudioFile')
@ -111,12 +111,15 @@ class Book {
get invalidAudioFiles() {
return this.audioFiles.filter(af => af.invalid)
}
get includedAudioFiles() {
return this.audioFiles.filter(af => !af.exclude && !af.invalid)
}
get hasIssues() {
return this.missingParts.length || this.invalidAudioFiles.length
}
get tracks() {
var startOffset = 0
return this.audioFiles.filter(af => !af.exclude && !af.invalid).map((af) => {
return this.includedAudioFiles.map((af) => {
var audioTrack = new AudioTrack()
audioTrack.setData(this.libraryItemId, af, startOffset)
startOffset += audioTrack.duration

View File

@ -133,6 +133,14 @@ class BookMetadata {
return `${getTitleIgnorePrefix(se.name)} #${se.sequence}`
}).join(', ')
}
get firstSeriesName() {
if (!this.series.length) return ''
return this.series[0].name
}
get firstSeriesSequence() {
if (!this.series.length) return ''
return this.series[0].sequence
}
get narratorName() {
return this.narrators.join(', ')
}

View File

@ -95,6 +95,7 @@ class ApiRouter {
this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this))
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
this.router.get('/items/:id/tone-object', LibraryItemController.middleware.bind(this), LibraryItemController.getToneMetadataObject.bind(this))
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this))
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
this.router.post('/items/:id/open-feed', LibraryItemController.middleware.bind(this), LibraryItemController.openRSSFeed.bind(this))

View File

@ -80,7 +80,7 @@ function elapsedPretty(seconds) {
}
module.exports.elapsedPretty = elapsedPretty
function secondsToTimestamp(seconds, includeMs = false) {
function secondsToTimestamp(seconds, includeMs = false, alwaysIncludeHours = false) {
var _seconds = seconds
var _minutes = Math.floor(seconds / 60)
_seconds -= _minutes * 60
@ -91,6 +91,9 @@ function secondsToTimestamp(seconds, includeMs = false) {
_seconds = Math.floor(_seconds)
var msString = '.' + (includeMs ? ms.toFixed(3) : '0.0').split('.')[1]
if (alwaysIncludeHours) {
return `${_hours.toString().padStart(2, '0')}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}`
}
if (!_hours) {
return `${_minutes}:${_seconds.toString().padStart(2, '0')}${msString}`
}

View File

@ -177,8 +177,8 @@ function parseTags(format, verbose) {
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'),
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id'),
file_tag_series: tryGrabTags(format, 'series', 'show', 'mvin'),
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvnm'),
file_tag_isbn: tryGrabTags(format, 'isbn'),
file_tag_language: tryGrabTags(format, 'language', 'lang'),
file_tag_asin: tryGrabTags(format, 'asin'),

View File

@ -0,0 +1,87 @@
const tone = require('node-tone')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const { secondsToTimestamp } = require('./index')
module.exports.writeToneChaptersFile = (chapters, filePath) => {
var chaptersTxt = ''
for (const chapter of chapters) {
chaptersTxt += `${secondsToTimestamp(chapter.start, true, true)} ${chapter.title}\n`
}
return fs.writeFile(filePath, chaptersTxt)
}
module.exports.getToneMetadataObject = (libraryItem, chaptersFile) => {
const coverPath = libraryItem.media.coverPath
const bookMetadata = libraryItem.media.metadata
const metadataObject = {
'Title': bookMetadata.title || '',
'Album': bookMetadata.title || '',
'TrackTotal': libraryItem.media.tracks.length
}
const additionalFields = []
if (bookMetadata.subtitle) {
metadataObject['Subtitle'] = bookMetadata.subtitle
}
if (bookMetadata.authorName) {
metadataObject['Artist'] = bookMetadata.authorName
metadataObject['AlbumArtist'] = bookMetadata.authorName
}
if (bookMetadata.description) {
metadataObject['Comment'] = bookMetadata.description
metadataObject['Description'] = bookMetadata.description
}
if (bookMetadata.narratorName) {
metadataObject['Narrator'] = bookMetadata.narratorName
metadataObject['Composer'] = bookMetadata.narratorName
}
if (bookMetadata.firstSeriesName) {
metadataObject['MovementName'] = bookMetadata.firstSeriesName
}
if (bookMetadata.firstSeriesSequence) {
metadataObject['Movement'] = bookMetadata.firstSeriesSequence
}
if (bookMetadata.genres.length) {
metadataObject['Genre'] = bookMetadata.genres.join('/')
}
if (bookMetadata.publisher) {
metadataObject['Publisher'] = bookMetadata.publisher
}
if (bookMetadata.asin) {
additionalFields.push(`ASIN=${bookMetadata.asin}`)
}
if (bookMetadata.isbn) {
additionalFields.push(`ISBN=${bookMetadata.isbn}`)
}
if (coverPath) {
metadataObject['CoverFile'] = coverPath
}
if (parsePublishedYear(bookMetadata.publishedYear)) {
metadataObject['PublishingDate'] = parsePublishedYear(bookMetadata.publishedYear)
}
if (chaptersFile) {
metadataObject['ChaptersFile'] = chaptersFile
}
if (additionalFields.length) {
metadataObject['AdditionalFields'] = additionalFields
}
return metadataObject
}
module.exports.tagAudioFile = (filePath, payload) => {
return tone.tag(filePath, payload).then((data) => {
return true
}).catch((error) => {
Logger.error(`[toneHelpers] tagAudioFile: Failed for "${filePath}"`, error)
return false
})
}
function parsePublishedYear(publishedYear) {
if (isNaN(publishedYear) || !publishedYear || Number(publishedYear) <= 0) return null
return `01/01/${publishedYear}`
}