Merge remote-tracking branch 'origin/master' into auth_passportjs

This commit is contained in:
lukeIam 2023-05-27 10:56:05 +02:00
commit 95e6fef3d1
65 changed files with 1739 additions and 378 deletions

View file

@ -684,13 +684,12 @@ class LibraryController {
}
async getAuthors(req, res) {
var libraryItems = req.libraryItems
var authors = {}
libraryItems.forEach((li) => {
const authors = {}
req.libraryItems.forEach((li) => {
if (li.media.metadata.authors && li.media.metadata.authors.length) {
li.media.metadata.authors.forEach((au) => {
if (!authors[au.id]) {
var _author = this.db.authors.find(_au => _au.id === au.id)
const _author = this.db.authors.find(_au => _au.id === au.id)
if (_author) {
authors[au.id] = _author.toJSON()
authors[au.id].numBooks = 1
@ -707,6 +706,85 @@ class LibraryController {
})
}
async getNarrators(req, res) {
const narrators = {}
req.libraryItems.forEach((li) => {
if (li.media.metadata.narrators?.length) {
li.media.metadata.narrators.forEach((n) => {
if (typeof n !== 'string') {
Logger.error(`[LibraryController] getNarrators: Invalid narrator "${n}" on book "${li.media.metadata.title}"`)
} else if (!narrators[n]) {
narrators[n] = {
id: encodeURIComponent(Buffer.from(n).toString('base64')),
name: n,
numBooks: 1
}
} else {
narrators[n].numBooks++
}
})
}
})
res.json({
narrators: naturalSort(Object.values(narrators)).asc(n => n.name)
})
}
async updateNarrator(req, res) {
if (!req.user.canUpdate) {
Logger.error(`[LibraryController] Unauthorized user "${req.user.username}" attempted to update narrator`)
return res.sendStatus(403)
}
const narratorName = libraryHelpers.decode(req.params.narratorId)
const updatedName = req.body.name
if (!updatedName) {
return res.status(400).send('Invalid request payload. Name not specified.')
}
const itemsUpdated = []
for (const libraryItem of req.libraryItems) {
if (libraryItem.media.metadata.updateNarrator(narratorName, updatedName)) {
itemsUpdated.push(libraryItem)
}
}
if (itemsUpdated.length) {
await this.db.updateLibraryItems(itemsUpdated)
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
}
res.json({
updated: itemsUpdated.length
})
}
async removeNarrator(req, res) {
if (!req.user.canUpdate) {
Logger.error(`[LibraryController] Unauthorized user "${req.user.username}" attempted to remove narrator`)
return res.sendStatus(403)
}
const narratorName = libraryHelpers.decode(req.params.narratorId)
const itemsUpdated = []
for (const libraryItem of req.libraryItems) {
if (libraryItem.media.metadata.removeNarrator(narratorName)) {
itemsUpdated.push(libraryItem)
}
}
if (itemsUpdated.length) {
await this.db.updateLibraryItems(itemsUpdated)
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
}
res.json({
updated: itemsUpdated.length
})
}
async matchAll(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryController] Non-root user attempted to match library items`, req.user)
@ -776,7 +854,7 @@ class LibraryController {
return res.sendStatus(404)
}
var library = this.db.libraries.find(lib => lib.id === req.params.id)
const library = this.db.libraries.find(lib => lib.id === req.params.id)
if (!library) {
return res.status(404).send('Library not found')
}

View file

@ -48,8 +48,7 @@ class MeController {
// DELETE: api/me/progress/:id
async removeMediaProgress(req, res) {
var wasRemoved = req.user.removeMediaProgress(req.params.id)
if (!wasRemoved) {
if (!req.user.removeMediaProgress(req.params.id)) {
return res.sendStatus(200)
}
await this.db.updateEntity('user', req.user)

View file

@ -18,6 +18,8 @@ class BookFinder {
this.fantLab = new FantLab()
this.audiobookCovers = new AudiobookCovers()
this.providers = ['google', 'itunes', 'openlibrary', 'fantlab', 'audiobookcovers', 'audible', 'audible.ca', 'audible.uk', 'audible.au', 'audible.fr', 'audible.de', 'audible.jp', 'audible.it', 'audible.in', 'audible.es']
this.verbose = false
}
@ -183,7 +185,7 @@ class BookFinder {
var books = []
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
Logger.debug(`Book Search: title: "${title}", author: "${author}", provider: ${provider}`)
Logger.debug(`Book Search: title: "${title}", author: "${author || ''}", provider: ${provider}`)
if (provider === 'google') {
books = await this.getGoogleBooksResults(title, author)
@ -222,19 +224,29 @@ class BookFinder {
}
async findCovers(provider, title, author, options = {}) {
var searchResults = await this.search(provider, title, author, options)
let searchResults = []
if (provider === 'all') {
for (const providerString of this.providers) {
const providerResults = await this.search(providerString, title, author, options)
Logger.debug(`[BookFinder] Found ${providerResults.length} covers from ${providerString}`)
searchResults.push(...providerResults)
}
} else {
searchResults = await this.search(provider, title, author, options)
}
Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)
var covers = []
const covers = []
searchResults.forEach((result) => {
if (result.covers && result.covers.length) {
covers = covers.concat(result.covers)
covers.push(...result.covers)
}
if (result.cover) {
covers.push(result.cover)
}
})
return covers
return [...(new Set(covers))]
}
findChapters(asin, region) {

View file

@ -112,7 +112,7 @@ class AbMergeManager {
let toneJsonPath = null
try {
toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1)
await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1, 'audio/mp4')
} catch (error) {
Logger.error(`[AbMergeManager] Write metadata.json failed`, error)
toneJsonPath = null

View file

@ -34,7 +34,10 @@ class AudioMetadataMangaer {
}
getToneMetadataObjectForApi(libraryItem) {
return toneHelpers.getToneMetadataObject(libraryItem, libraryItem.media.chapters, libraryItem.media.tracks.length)
const audioFiles = libraryItem.media.includedAudioFiles
let mimeType = audioFiles[0].mimeType
if (audioFiles.some(a => a.mimeType !== mimeType)) mimeType = null
return toneHelpers.getToneMetadataObject(libraryItem, libraryItem.media.chapters, libraryItem.media.tracks.length, mimeType)
}
handleBatchEmbed(user, libraryItems, options = {}) {
@ -56,6 +59,9 @@ class AudioMetadataMangaer {
// Only writing chapters for single file audiobooks
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters.map(c => ({ ...c })) : null
let mimeType = audioFiles[0].mimeType
if (audioFiles.some(a => a.mimeType !== mimeType)) mimeType = null
// Create task
const taskData = {
libraryItemId: libraryItem.id,
@ -71,7 +77,7 @@ class AudioMetadataMangaer {
}
)),
coverPath: libraryItem.media.coverPath,
metadataObject: toneHelpers.getToneMetadataObject(libraryItem, chapters, audioFiles.length),
metadataObject: toneHelpers.getToneMetadataObject(libraryItem, chapters, audioFiles.length, mimeType),
itemCachePath,
chapters,
options: {

View file

@ -132,7 +132,7 @@ class CronManager {
for (const libraryItem of libraryItems) {
const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem)
if (!keepAutoDownloading) { // auto download was disabled
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItem.id) // Filter it out
}
}

View file

@ -9,6 +9,7 @@ const Podcast = require('./mediaTypes/Podcast')
const Video = require('./mediaTypes/Video')
const Music = require('./mediaTypes/Music')
const { areEquivalent, copyValue, getId, cleanStringForSearch } = require('../utils/index')
const { filePathToPOSIX } = require('../utils/fileUtils')
class LibraryItem {
constructor(libraryItem = null) {
@ -368,7 +369,7 @@ class LibraryItem {
const fileFoundCheck = this.checkFileFound(lf, true)
if (fileFoundCheck === null) {
newLibraryFiles.push(lf)
} else if (fileFoundCheck && lf.metadata.format !== 'abs') { // Ignore abs file updates
} else if (fileFoundCheck && lf.metadata.format !== 'abs' && lf.metadata.filename !== 'metadata.json') { // Ignore abs file updates
hasUpdated = true
existingLibraryFiles.push(lf)
} else {
@ -499,14 +500,56 @@ class LibraryItem {
// Make sure metadata book dir exists
await fs.ensureDir(metadataPath)
}
metadataPath = Path.join(metadataPath, 'metadata.abs')
return abmetadataGenerator.generate(this, metadataPath).then((success) => {
this.isSavingMetadata = false
if (!success) Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataPath}"`)
else Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataPath}"`)
return success
})
const metadataFileFormat = global.ServerSettings.metadataFileFormat
const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`)
if (metadataFileFormat === 'json') {
// Remove metadata.abs if it exists
if (await fs.pathExists(Path.join(metadataPath, `metadata.abs`))) {
Logger.debug(`[LibraryItem] Removing metadata.abs for item "${this.media.metadata.title}"`)
await fs.remove(Path.join(metadataPath, `metadata.abs`))
this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`)))
}
return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => {
this.isSavingMetadata = false
// Add metadata.json to libraryFiles array if it is new
if (global.ServerSettings.storeMetadataWithItem && !this.libraryFiles.some(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))) {
const newLibraryFile = new LibraryFile()
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
this.libraryFiles.push(newLibraryFile)
}
return true
}).catch((error) => {
this.isSavingMetadata = false
Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error)
return false
})
} else {
// Remove metadata.json if it exists
if (await fs.pathExists(Path.join(metadataPath, `metadata.json`))) {
Logger.debug(`[LibraryItem] Removing metadata.json for item "${this.media.metadata.title}"`)
await fs.remove(Path.join(metadataPath, `metadata.json`))
this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`)))
}
return abmetadataGenerator.generate(this, metadataFilePath).then(async (success) => {
this.isSavingMetadata = false
if (!success) Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataFilePath}"`)
else {
// Add metadata.abs to libraryFiles array if it is new
if (global.ServerSettings.storeMetadataWithItem && !this.libraryFiles.some(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))) {
const newLibraryFile = new LibraryFile()
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`)
this.libraryFiles.push(newLibraryFile)
}
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
}
return success
})
}
}
removeLibraryFile(ino) {

View file

@ -177,7 +177,7 @@ class PlaybackSession {
this.episodeId = episodeId
this.mediaType = libraryItem.mediaType
this.mediaMetadata = libraryItem.media.metadata.clone()
this.chapters = (libraryItem.media.chapters || []).map(c => ({ ...c })) // Only book mediaType has chapters
this.chapters = libraryItem.media.getChapters(episodeId)
this.displayTitle = libraryItem.media.getPlaybackTitle(episodeId)
this.displayAuthor = libraryItem.media.getPlaybackAuthor()
this.coverPath = libraryItem.media.coverPath

View file

@ -1,5 +1,5 @@
const Path = require('path')
const { getFileTimestampsWithIno } = require('../../utils/fileUtils')
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../../utils/fileUtils')
const globals = require('../../utils/globals')
const FileMetadata = require('../metadata/FileMetadata')
@ -59,8 +59,8 @@ class LibraryFile {
var fileMetadata = new FileMetadata()
fileMetadata.setData(fileTsData)
fileMetadata.filename = Path.basename(relPath)
fileMetadata.path = path
fileMetadata.relPath = relPath
fileMetadata.path = filePathToPOSIX(path)
fileMetadata.relPath = filePathToPOSIX(relPath)
fileMetadata.ext = Path.extname(relPath)
this.ino = fileTsData.ino
this.metadata = fileMetadata

View file

@ -89,6 +89,14 @@ class Book {
}
}
toJSONForMetadataFile() {
return {
tags: [...this.tags],
chapters: this.chapters.map(c => ({ ...c })),
metadata: this.metadata.toJSONForMetadataFile()
}
}
get size() {
var total = 0
this.audioFiles.forEach((af) => total += af.metadata.size)
@ -229,7 +237,7 @@ class Book {
// Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found
async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
let metadataUpdatePayload = {}
let tagsUpdated = false
let hasUpdated = false
const descTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'desc.txt')
if (descTxt) {
@ -248,17 +256,25 @@ class Book {
}
}
const metadataIsJSON = global.ServerSettings.metadataFileFormat === 'json'
const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs')
if (metadataAbs) {
Logger.debug(`[Book] Found metadata.abs file for "${this.metadata.title}"`)
const metadataText = await readTextFile(metadataAbs.metadata.path)
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'book')
const metadataJson = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.json')
const metadataFile = metadataIsJSON ? metadataJson : metadataAbs
if (metadataFile) {
Logger.debug(`[Book] Found ${metadataFile.metadata.filename} file for "${this.metadata.title}"`)
const metadataText = await readTextFile(metadataFile.metadata.path)
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'book', metadataIsJSON)
if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) {
Logger.debug(`[Book] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates)
if (abmetadataUpdates.tags) { // Set media tags if updated
this.tags = abmetadataUpdates.tags
tagsUpdated = true
hasUpdated = true
}
if (abmetadataUpdates.chapters) { // Set chapters if updated
this.chapters = abmetadataUpdates.chapters
hasUpdated = true
}
if (abmetadataUpdates.metadata) {
metadataUpdatePayload = {
@ -267,6 +283,9 @@ class Book {
}
}
}
} else if (metadataAbs || metadataJson) { // Has different metadata file format so mark as updated
Logger.debug(`[Book] Found different format metadata file ${(metadataAbs || metadataJson).metadata.filename}, expecting .${global.ServerSettings.metadataFileFormat} for "${this.metadata.title}"`)
hasUpdated = true
}
const metadataOpf = textMetadataFiles.find(lf => lf.isOPFFile || lf.metadata.filename === 'metadata.xml')
@ -280,7 +299,7 @@ class Book {
if (key === 'tags') { // Add tags only if tags are empty
if (opfMetadata.tags.length && (!this.tags.length || opfMetadataOverrideDetails)) {
this.tags = opfMetadata.tags
tagsUpdated = true
hasUpdated = true
}
} else if (key === 'genres') { // Add genres only if genres are empty
if (opfMetadata.genres.length && (!this.metadata.genres.length || opfMetadataOverrideDetails)) {
@ -312,9 +331,9 @@ class Book {
}
if (Object.keys(metadataUpdatePayload).length) {
return this.metadata.update(metadataUpdatePayload) || tagsUpdated
return this.metadata.update(metadataUpdatePayload) || hasUpdated
}
return tagsUpdated
return hasUpdated
}
searchQuery(query) {
@ -509,5 +528,9 @@ class Book {
getPlaybackAuthor() {
return this.metadata.authorName
}
getChapters() {
return this.chapters?.map(ch => ({ ...ch })) || []
}
}
module.exports = Book

View file

@ -94,6 +94,13 @@ class Podcast {
}
}
toJSONForMetadataFile() {
return {
tags: [...this.tags],
metadata: this.metadata.toJSON()
}
}
get size() {
var total = 0
this.episodes.forEach((ep) => total += ep.size)
@ -199,10 +206,11 @@ class Podcast {
let metadataUpdatePayload = {}
let tagsUpdated = false
const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs')
const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs' || lf.metadata.filename === 'metadata.json')
if (metadataAbs) {
const isJSON = metadataAbs.metadata.filename === 'metadata.json'
const metadataText = await readTextFile(metadataAbs.metadata.path)
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'podcast')
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'podcast', isJSON)
if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) {
Logger.debug(`[Podcast] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates)
@ -331,5 +339,9 @@ class Podcast {
if (!audioFile?.metaTags) return false
return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails)
}
getChapters(episodeId) {
return this.getEpisode(episodeId)?.chapters?.map(ch => ({ ...ch })) || []
}
}
module.exports = Podcast

View file

@ -27,9 +27,9 @@ class BookMetadata {
construct(metadata) {
this.title = metadata.title
this.subtitle = metadata.subtitle
this.authors = (metadata.authors && metadata.authors.map) ? metadata.authors.map(a => ({ ...a })) : []
this.narrators = metadata.narrators ? [...metadata.narrators] : []
this.series = (metadata.series && metadata.series.map) ? metadata.series.map(s => ({ ...s })) : []
this.authors = (metadata.authors?.map) ? metadata.authors.map(a => ({ ...a })) : []
this.narrators = metadata.narrators ? [...metadata.narrators].filter(n => n) : []
this.series = (metadata.series?.map) ? metadata.series.map(s => ({ ...s })) : []
this.genres = metadata.genres ? [...metadata.genres] : []
this.publishedYear = metadata.publishedYear || null
this.publishedDate = metadata.publishedDate || null
@ -109,6 +109,16 @@ class BookMetadata {
}
}
toJSONForMetadataFile() {
const json = this.toJSON()
json.authors = json.authors.map(au => au.name)
json.series = json.series.map(se => {
if (!se.sequence) return se.name
return `${se.name} #${se.sequence}`
})
return json
}
clone() {
return new BookMetadata(this.toJSON())
}
@ -191,8 +201,9 @@ class BookMetadata {
}
update(payload) {
var json = this.toJSON()
var hasUpdates = false
const json = this.toJSON()
let hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (!areEquivalent(payload[key], json[key])) {
@ -221,6 +232,32 @@ class BookMetadata {
})
}
/**
* Update narrator name if narrator is in book
* @param {String} oldNarratorName - Narrator name to get updated
* @param {String} newNarratorName - Updated narrator name
* @return {Boolean} True if narrator was updated
*/
updateNarrator(oldNarratorName, newNarratorName) {
if (!this.hasNarrator(oldNarratorName)) return false
this.narrators = this.narrators.filter(n => n !== oldNarratorName)
if (newNarratorName && !this.hasNarrator(newNarratorName)) {
this.narrators.push(newNarratorName)
}
return true
}
/**
* Remove narrator name if narrator is in book
* @param {String} narratorName - Narrator name to remove
* @return {Boolean} True if narrator was updated
*/
removeNarrator(narratorName) {
if (!this.hasNarrator(narratorName)) return false
this.narrators = this.narrators.filter(n => n !== narratorName)
return true
}
setData(scanMediaData = {}) {
this.title = scanMediaData.title || null
this.subtitle = scanMediaData.subtitle || null
@ -347,8 +384,10 @@ class BookMetadata {
const parsed = parseNameString.parse(authorsTag)
if (!parsed) return []
return (parsed.names || []).map((au) => {
const findAuthor = this.authors.find(_au => _au.name == au)
return {
id: `new-${Math.floor(Math.random() * 1000000)}`,
id: findAuthor?.id || `new-${Math.floor(Math.random() * 1000000)}`,
name: au
}
})
@ -385,7 +424,7 @@ class BookMetadata {
return this.narrators.filter(n => cleanStringForSearch(n).includes(query))
}
searchQuery(query) { // Returns key if match is found
const keysToCheck = ['title', 'asin', 'isbn']
const keysToCheck = ['title', 'asin', 'isbn', 'subtitle']
for (const key of keysToCheck) {
if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) {
return {

View file

@ -20,6 +20,7 @@ class ServerSettings {
// Metadata - choose to store inside users library item folder
this.storeCoverWithItem = false
this.storeMetadataWithItem = false
this.metadataFileFormat = 'json'
// Security/Rate limits
this.rateLimitLoginRequests = 10
@ -94,6 +95,7 @@ class ServerSettings {
this.storeCoverWithItem = !!settings.storeCoverWithItem
this.storeMetadataWithItem = !!settings.storeMetadataWithItem
this.metadataFileFormat = settings.metadataFileFormat || 'json'
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
@ -175,6 +177,16 @@ class ServerSettings {
if (settings.homeBookshelfView == undefined) { // homeBookshelfView was added in 2.1.3
this.homeBookshelfView = settings.bookshelfView
}
if (settings.metadataFileFormat == undefined) { // metadataFileFormat was added in 2.2.21
// All users using old settings will stay abs until changed
this.metadataFileFormat = 'abs'
}
// Validation
if (!['abs', 'json'].includes(this.metadataFileFormat)) {
Logger.error(`[ServerSettings] construct: Invalid metadataFileFormat ${this.metadataFileFormat}`)
this.metadataFileFormat = 'json'
}
if (this.logLevel !== Logger.logLevel) {
Logger.setLogLevel(this.logLevel)
@ -196,6 +208,7 @@ class ServerSettings {
scannerUseTone: this.scannerUseTone,
storeCoverWithItem: this.storeCoverWithItem,
storeMetadataWithItem: this.storeMetadataWithItem,
metadataFileFormat: this.metadataFileFormat,
rateLimitLoginRequests: this.rateLimitLoginRequests,
rateLimitLoginWindow: this.rateLimitLoginWindow,
backupSchedule: this.backupSchedule,

View file

@ -10,7 +10,7 @@ class MediaProgress {
this.isFinished = false
this.hideFromContinueListening = false
this.ebookLocation = null // current cfi tag
this.ebookLocation = null // cfi tag for epub, page number for pdf
this.ebookProgress = null // 0 to 1
this.lastUpdate = null
@ -46,11 +46,11 @@ class MediaProgress {
this.episodeId = progress.episodeId
this.duration = progress.duration || 0
this.progress = progress.progress
this.currentTime = progress.currentTime
this.currentTime = progress.currentTime || 0
this.isFinished = !!progress.isFinished
this.hideFromContinueListening = !!progress.hideFromContinueListening
this.ebookLocation = progress.ebookLocation || null
this.ebookProgress = progress.ebookProgress
this.ebookProgress = progress.ebookProgress || null
this.lastUpdate = progress.lastUpdate
this.startedAt = progress.startedAt
this.finishedAt = progress.finishedAt || null

View file

@ -13,7 +13,7 @@ class Audible {
'de': '.de',
'jp': '.co.jp',
'it': '.it',
'in': '.co.in',
'in': '.in',
'es': '.es'
}
}

View file

@ -84,6 +84,9 @@ class ApiRouter {
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this))
this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this))
this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this))
this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this))
this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this))

View file

@ -111,8 +111,8 @@ class Scanner {
}
if (hasUpdated) {
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
await this.db.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
return ScanResult.UPDATED
}
return ScanResult.UPTODATE

View file

@ -2,7 +2,8 @@ const fs = require('../libs/fsExtra')
const filePerms = require('./filePerms')
const package = require('../../package.json')
const Logger = require('../Logger')
const { getId, copyValue } = require('./index')
const { getId } = require('./index')
const areEquivalent = require('../utils/areEquivalent')
const CurrentAbMetadataVersion = 2
@ -328,11 +329,11 @@ function parseAbMetadataText(text, mediaType) {
module.exports.parse = parseAbMetadataText
function checkUpdatedBookAuthors(abmetadataAuthors, authors) {
var finalAuthors = []
var hasUpdates = false
const finalAuthors = []
let hasUpdates = false
abmetadataAuthors.forEach((authorName) => {
var findAuthor = authors.find(au => au.name.toLowerCase() == authorName.toLowerCase())
const findAuthor = authors.find(au => au.name.toLowerCase() == authorName.toLowerCase())
if (!findAuthor) {
hasUpdates = true
finalAuthors.push({
@ -397,18 +398,81 @@ function checkArraysChanged(abmetadataArray, mediaArray) {
return abmetadataArray.join(',') != mediaArray.join(',')
}
function parseJsonMetadataText(text) {
try {
const abmetadataData = JSON.parse(text)
if (abmetadataData.metadata?.series?.length) {
abmetadataData.metadata.series = abmetadataData.metadata.series.map(series => {
let sequence = null
let name = series
// Series sequence match any characters after " #" other than whitespace and another #
// e.g. "Name #1a" is valid. "Name #1#a" or "Name #1 a" is not valid.
const matchResults = series.match(/ #([^#\s]+)$/) // Pull out sequence #
if (matchResults && matchResults.length && matchResults.length > 1) {
sequence = matchResults[1] // Group 1
name = series.replace(matchResults[0], '')
}
return {
name,
sequence
}
})
}
return abmetadataData
} catch (error) {
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
return null
}
}
function cleanChaptersArray(chaptersArray, mediaTitle) {
const chapters = []
let index = 0
for (const chap of chaptersArray) {
if (chap.start === null || isNaN(chap.start)) {
Logger.error(`[abmetadataGenerator] Invalid chapter start time ${chap.start} for "${mediaTitle}" metadata file`)
return null
}
if (chap.end === null || isNaN(chap.end)) {
Logger.error(`[abmetadataGenerator] Invalid chapter end time ${chap.end} for "${mediaTitle}" metadata file`)
return null
}
if (!chap.title || typeof chap.title !== 'string') {
Logger.error(`[abmetadataGenerator] Invalid chapter title ${chap.title} for "${mediaTitle}" metadata file`)
return null
}
chapters.push({
id: index++,
start: chap.start,
end: chap.end,
title: chap.title
})
}
return chapters
}
// Input text from abmetadata file and return object of media changes
// only returns object of changes. empty object means no changes
function parseAndCheckForUpdates(text, media, mediaType) {
function parseAndCheckForUpdates(text, media, mediaType, isJSON) {
if (!text || !media || !media.metadata || !mediaType) {
Logger.error(`Invalid inputs to parseAndCheckForUpdates`)
return null
}
const mediaMetadata = media.metadata
const metadataUpdatePayload = {} // Only updated key/values
const abmetadataData = parseAbMetadataText(text, mediaType)
let abmetadataData = null
if (isJSON) {
abmetadataData = parseJsonMetadataText(text)
} else {
abmetadataData = parseAbMetadataText(text, mediaType)
}
if (!abmetadataData || !abmetadataData.metadata) {
Logger.error(`[abmetadataGenerator] Invalid metadata file`)
return null
}
@ -441,6 +505,15 @@ function parseAndCheckForUpdates(text, media, mediaType) {
}
}
if (abmetadataData.chapters && mediaType === 'book') {
const abmetadataChaptersCleaned = cleanChaptersArray(abmetadataData.chapters)
if (abmetadataChaptersCleaned) {
if (!areEquivalent(abmetadataChaptersCleaned, media.chapters)) {
updatePayload.chapters = abmetadataChaptersCleaned
}
}
}
if (Object.keys(metadataUpdatePayload).length) {
updatePayload.metadata = metadataUpdatePayload
}

View file

@ -4,7 +4,7 @@ const globals = {
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
SupportedVideoTypes: ['mp4'],
TextFileTypes: ['txt', 'nfo'],
MetadataFileTypes: ['opf', 'abs', 'xml']
MetadataFileTypes: ['opf', 'abs', 'xml', 'json']
}
module.exports = globals

View file

@ -4,6 +4,8 @@ const { parseString } = require("xml2js")
const areEquivalent = require('./areEquivalent')
const levenshteinDistance = (str1, str2, caseSensitive = false) => {
str1 = String(str1)
str2 = String(str2)
if (!caseSensitive) {
str1 = str1.toLowerCase()
str2 = str2.toLowerCase()

View file

@ -170,7 +170,7 @@ function parseTags(format, verbose) {
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'),
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'),
@ -183,7 +183,7 @@ function parseTags(format, verbose) {
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'),
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

View file

@ -2,10 +2,13 @@ const tone = require('node-tone')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
function getToneMetadataObject(libraryItem, chapters, trackTotal) {
function getToneMetadataObject(libraryItem, chapters, trackTotal, mimeType = null) {
const bookMetadata = libraryItem.media.metadata
const coverPath = libraryItem.media.coverPath
const isMp4 = mimeType === 'audio/mp4'
const isMp3 = mimeType === 'audio/mpeg'
const metadataObject = {
'album': bookMetadata.title || '',
'title': bookMetadata.title || '',
@ -28,10 +31,24 @@ function getToneMetadataObject(libraryItem, chapters, trackTotal) {
metadataObject['composer'] = bookMetadata.narratorName
}
if (bookMetadata.firstSeriesName) {
if (!isMp3) {
metadataObject.additionalFields['----:com.pilabor.tone:SERIES'] = bookMetadata.firstSeriesName
}
metadataObject['movementName'] = bookMetadata.firstSeriesName
}
if (bookMetadata.firstSeriesSequence) {
metadataObject['movement'] = bookMetadata.firstSeriesSequence
// Non-mp3
if (!isMp3) {
metadataObject.additionalFields['----:com.pilabor.tone:PART'] = bookMetadata.firstSeriesSequence
}
// MP3 Files with non-integer sequence
const isNonIntegerSequence = String(bookMetadata.firstSeriesSequence).includes('.') || isNaN(bookMetadata.firstSeriesSequence)
if (isMp3 && isNonIntegerSequence) {
metadataObject.additionalFields['PART'] = bookMetadata.firstSeriesSequence
}
if (!isNonIntegerSequence) {
metadataObject['movement'] = bookMetadata.firstSeriesSequence
}
}
if (bookMetadata.genres.length) {
metadataObject['genre'] = bookMetadata.genres.join('/')
@ -40,7 +57,12 @@ function getToneMetadataObject(libraryItem, chapters, trackTotal) {
metadataObject['publisher'] = bookMetadata.publisher
}
if (bookMetadata.asin) {
metadataObject.additionalFields['asin'] = bookMetadata.asin
if (!isMp3) {
metadataObject.additionalFields['----:com.pilabor.tone:AUDIBLE_ASIN'] = bookMetadata.asin
}
if (!isMp4) {
metadataObject.additionalFields['asin'] = bookMetadata.asin
}
}
if (bookMetadata.isbn) {
metadataObject.additionalFields['isbn'] = bookMetadata.isbn
@ -67,8 +89,8 @@ function getToneMetadataObject(libraryItem, chapters, trackTotal) {
}
module.exports.getToneMetadataObject = getToneMetadataObject
module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal) => {
const metadataObject = getToneMetadataObject(libraryItem, chapters, trackTotal)
module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal, mimeType) => {
const metadataObject = getToneMetadataObject(libraryItem, chapters, trackTotal, mimeType)
return fs.writeFile(filePath, JSON.stringify({ meta: metadataObject }, null, 2))
}