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

@ -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}`
}