mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-10 11:21:36 +00:00
Merge branch 'master' into mf/rssInboundManager
# Conflicts: # client/pages/config/rss-feeds.vue # client/strings/en-us.json # server/managers/PodcastManager.js # server/models/Podcast.js
This commit is contained in:
commit
eb8e49e4fc
180 changed files with 14188 additions and 5517 deletions
|
|
@ -1,7 +1,8 @@
|
|||
const fs = require('../libs/fsExtra')
|
||||
const rra = require('../libs/recursiveReaddirAsync')
|
||||
const axios = require('axios')
|
||||
const Path = require('path')
|
||||
const ssrfFilter = require('ssrf-req-filter')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const rra = require('../libs/recursiveReaddirAsync')
|
||||
const Logger = require('../Logger')
|
||||
const { AudioMimeType } = require('./constants')
|
||||
|
||||
|
|
@ -18,22 +19,33 @@ const filePathToPOSIX = (path) => {
|
|||
}
|
||||
module.exports.filePathToPOSIX = filePathToPOSIX
|
||||
|
||||
async function getFileStat(path) {
|
||||
/**
|
||||
* Check path is a child of or equal to another path
|
||||
*
|
||||
* @param {string} parentPath
|
||||
* @param {string} childPath
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSameOrSubPath(parentPath, childPath) {
|
||||
parentPath = filePathToPOSIX(parentPath)
|
||||
childPath = filePathToPOSIX(childPath)
|
||||
if (parentPath === childPath) return true
|
||||
const relativePath = Path.relative(parentPath, childPath)
|
||||
return (
|
||||
relativePath === '' // Same path (e.g. parentPath = '/a/b/', childPath = '/a/b')
|
||||
|| !relativePath.startsWith('..') && !Path.isAbsolute(relativePath) // Sub path
|
||||
)
|
||||
}
|
||||
module.exports.isSameOrSubPath = isSameOrSubPath
|
||||
|
||||
function getFileStat(path) {
|
||||
try {
|
||||
var stat = await fs.stat(path)
|
||||
return {
|
||||
size: stat.size,
|
||||
atime: stat.atime,
|
||||
mtime: stat.mtime,
|
||||
ctime: stat.ctime,
|
||||
birthtime: stat.birthtime
|
||||
}
|
||||
return fs.stat(path)
|
||||
} catch (err) {
|
||||
Logger.error('[fileUtils] Failed to stat', err)
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}
|
||||
module.exports.getFileStat = getFileStat
|
||||
|
||||
async function getFileTimestampsWithIno(path) {
|
||||
try {
|
||||
|
|
@ -52,12 +64,25 @@ async function getFileTimestampsWithIno(path) {
|
|||
}
|
||||
module.exports.getFileTimestampsWithIno = getFileTimestampsWithIno
|
||||
|
||||
async function getFileSize(path) {
|
||||
var stat = await getFileStat(path)
|
||||
if (!stat) return 0
|
||||
return stat.size || 0
|
||||
/**
|
||||
* Get file size
|
||||
*
|
||||
* @param {string} path
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
module.exports.getFileSize = async (path) => {
|
||||
return (await getFileStat(path))?.size || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file mtimeMs
|
||||
*
|
||||
* @param {string} path
|
||||
* @returns {Promise<number>} epoch timestamp
|
||||
*/
|
||||
module.exports.getFileMTimeMs = async (path) => {
|
||||
return (await getFileStat(path))?.mtimeMs || 0
|
||||
}
|
||||
module.exports.getFileSize = getFileSize
|
||||
|
||||
/**
|
||||
*
|
||||
|
|
@ -203,15 +228,32 @@ async function recurseFiles(path, relPathToReplace = null) {
|
|||
}
|
||||
module.exports.recurseFiles = recurseFiles
|
||||
|
||||
module.exports.downloadFile = (url, filepath) => {
|
||||
/**
|
||||
* Download file from web to local file system
|
||||
* Uses SSRF filter to prevent internal URLs
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} filepath path to download the file to
|
||||
* @param {Function} [contentTypeFilter] validate content type before writing
|
||||
* @returns {Promise}
|
||||
*/
|
||||
module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
Logger.debug(`[fileUtils] Downloading file to ${filepath}`)
|
||||
axios({
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
timeout: 30000
|
||||
timeout: 30000,
|
||||
httpAgent: ssrfFilter(url),
|
||||
httpsAgent: ssrfFilter(url)
|
||||
}).then((response) => {
|
||||
// Validate content type
|
||||
if (contentTypeFilter && !contentTypeFilter?.(response.headers?.['content-type'])) {
|
||||
return reject(new Error(`Invalid content type "${response.headers?.['content-type'] || ''}"`))
|
||||
}
|
||||
|
||||
// Write to filepath
|
||||
const writer = fs.createWriteStream(filepath)
|
||||
response.data.pipe(writer)
|
||||
|
||||
|
|
@ -224,6 +266,21 @@ module.exports.downloadFile = (url, filepath) => {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Download image file from web to local file system
|
||||
* Response header must have content-type of image/ (excluding svg)
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} filepath
|
||||
* @returns {Promise}
|
||||
*/
|
||||
module.exports.downloadImageFile = (url, filepath) => {
|
||||
const contentTypeFilter = (contentType) => {
|
||||
return contentType?.startsWith('image/') && contentType !== 'image/svg+xml'
|
||||
}
|
||||
return this.downloadFile(url, filepath, contentTypeFilter)
|
||||
}
|
||||
|
||||
module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
||||
if (typeof filename !== 'string') {
|
||||
return false
|
||||
|
|
@ -251,6 +308,7 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
|||
.replace(lineBreaks, replacement)
|
||||
.replace(windowsReservedRe, replacement)
|
||||
.replace(windowsTrailingRe, replacement)
|
||||
.replace(/\s+/g, ' ') // Replace consecutive spaces with a single space
|
||||
|
||||
// Check if basename is too many bytes
|
||||
const ext = Path.extname(sanitized) // separate out file extension
|
||||
|
|
|
|||
|
|
@ -1,461 +1,26 @@
|
|||
const fs = require('../../libs/fsExtra')
|
||||
const package = require('../../../package.json')
|
||||
const Logger = require('../../Logger')
|
||||
const { getId } = require('../index')
|
||||
const areEquivalent = require('../areEquivalent')
|
||||
|
||||
|
||||
const CurrentAbMetadataVersion = 2
|
||||
// abmetadata v1 key map
|
||||
// const bookKeyMap = {
|
||||
// title: 'title',
|
||||
// subtitle: 'subtitle',
|
||||
// author: 'authorFL',
|
||||
// narrator: 'narratorFL',
|
||||
// publishedYear: 'publishedYear',
|
||||
// publisher: 'publisher',
|
||||
// description: 'description',
|
||||
// isbn: 'isbn',
|
||||
// asin: 'asin',
|
||||
// language: 'language',
|
||||
// genres: 'genresCommaSeparated'
|
||||
// }
|
||||
|
||||
const commaSeparatedToArray = (v) => {
|
||||
if (!v) return []
|
||||
return [...new Set(v.split(',').map(_v => _v.trim()).filter(_v => _v))]
|
||||
}
|
||||
|
||||
const podcastMetadataMapper = {
|
||||
title: {
|
||||
to: (m) => m.title || '',
|
||||
from: (v) => v || ''
|
||||
},
|
||||
author: {
|
||||
to: (m) => m.author || '',
|
||||
from: (v) => v || null
|
||||
},
|
||||
language: {
|
||||
to: (m) => m.language || '',
|
||||
from: (v) => v || null
|
||||
},
|
||||
genres: {
|
||||
to: (m) => m.genres?.join(', ') || '',
|
||||
from: (v) => commaSeparatedToArray(v)
|
||||
},
|
||||
feedUrl: {
|
||||
to: (m) => m.feedUrl || '',
|
||||
from: (v) => v || null
|
||||
},
|
||||
itunesId: {
|
||||
to: (m) => m.itunesId || '',
|
||||
from: (v) => v || null
|
||||
},
|
||||
explicit: {
|
||||
to: (m) => m.explicit ? 'Y' : 'N',
|
||||
from: (v) => v && v.toLowerCase() == 'y'
|
||||
}
|
||||
}
|
||||
|
||||
const bookMetadataMapper = {
|
||||
title: {
|
||||
to: (m) => m.title || '',
|
||||
from: (v) => v || ''
|
||||
},
|
||||
subtitle: {
|
||||
to: (m) => m.subtitle || '',
|
||||
from: (v) => v || null
|
||||
},
|
||||
authors: {
|
||||
to: (m) => {
|
||||
if (m.authorName !== undefined) return m.authorName
|
||||
if (!m.authors?.length) return ''
|
||||
return m.authors.map(au => au.name).join(', ')
|
||||
},
|
||||
from: (v) => commaSeparatedToArray(v)
|
||||
},
|
||||
narrators: {
|
||||
to: (m) => m.narrators?.join(', ') || '',
|
||||
from: (v) => commaSeparatedToArray(v)
|
||||
},
|
||||
publishedYear: {
|
||||
to: (m) => m.publishedYear || '',
|
||||
from: (v) => v || null
|
||||
},
|
||||
publisher: {
|
||||
to: (m) => m.publisher || '',
|
||||
from: (v) => v || null
|
||||
},
|
||||
isbn: {
|
||||
to: (m) => m.isbn || '',
|
||||
from: (v) => v || null
|
||||
},
|
||||
asin: {
|
||||
to: (m) => m.asin || '',
|
||||
from: (v) => v || null
|
||||
},
|
||||
language: {
|
||||
to: (m) => m.language || '',
|
||||
from: (v) => v || null
|
||||
},
|
||||
genres: {
|
||||
to: (m) => m.genres?.join(', ') || '',
|
||||
from: (v) => commaSeparatedToArray(v)
|
||||
},
|
||||
series: {
|
||||
to: (m) => {
|
||||
if (m.seriesName !== undefined) return m.seriesName
|
||||
if (!m.series?.length) return ''
|
||||
return m.series.map((se) => {
|
||||
const sequence = se.bookSeries?.sequence || ''
|
||||
if (!sequence) return se.name
|
||||
return `${se.name} #${sequence}`
|
||||
}).join(', ')
|
||||
},
|
||||
from: (v) => {
|
||||
return commaSeparatedToArray(v).map(series => { // Return array of { name, sequence }
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
explicit: {
|
||||
to: (m) => m.explicit ? 'Y' : 'N',
|
||||
from: (v) => v && v.toLowerCase() == 'y'
|
||||
},
|
||||
abridged: {
|
||||
to: (m) => m.abridged ? 'Y' : 'N',
|
||||
from: (v) => v && v.toLowerCase() == 'y'
|
||||
}
|
||||
}
|
||||
|
||||
const metadataMappers = {
|
||||
book: bookMetadataMapper,
|
||||
podcast: podcastMetadataMapper
|
||||
}
|
||||
|
||||
function generate(libraryItem, outputPath) {
|
||||
let fileString = `;ABMETADATA${CurrentAbMetadataVersion}\n`
|
||||
fileString += `#audiobookshelf v${package.version}\n\n`
|
||||
|
||||
const mediaType = libraryItem.mediaType
|
||||
|
||||
fileString += `media=${mediaType}\n`
|
||||
fileString += `tags=${JSON.stringify(libraryItem.media.tags)}\n`
|
||||
|
||||
const metadataMapper = metadataMappers[mediaType]
|
||||
var mediaMetadata = libraryItem.media.metadata
|
||||
for (const key in metadataMapper) {
|
||||
fileString += `${key}=${metadataMapper[key].to(mediaMetadata)}\n`
|
||||
}
|
||||
|
||||
// Description block
|
||||
if (mediaMetadata.description) {
|
||||
fileString += '\n[DESCRIPTION]\n'
|
||||
fileString += mediaMetadata.description + '\n'
|
||||
}
|
||||
|
||||
// Book chapters
|
||||
if (libraryItem.mediaType == 'book' && libraryItem.media.chapters.length) {
|
||||
fileString += '\n'
|
||||
libraryItem.media.chapters.forEach((chapter) => {
|
||||
fileString += `[CHAPTER]\n`
|
||||
fileString += `start=${chapter.start}\n`
|
||||
fileString += `end=${chapter.end}\n`
|
||||
fileString += `title=${chapter.title}\n`
|
||||
})
|
||||
}
|
||||
return fs.writeFile(outputPath, fileString).then(() => true).catch((error) => {
|
||||
Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
module.exports.generate = generate
|
||||
|
||||
function generateFromNewModel(libraryItem, outputPath) {
|
||||
let fileString = `;ABMETADATA${CurrentAbMetadataVersion}\n`
|
||||
fileString += `#audiobookshelf v${package.version}\n\n`
|
||||
|
||||
const mediaType = libraryItem.mediaType
|
||||
|
||||
fileString += `media=${mediaType}\n`
|
||||
fileString += `tags=${JSON.stringify(libraryItem.media.tags || '')}\n`
|
||||
|
||||
const metadataMapper = metadataMappers[mediaType]
|
||||
for (const key in metadataMapper) {
|
||||
fileString += `${key}=${metadataMapper[key].to(libraryItem.media)}\n`
|
||||
}
|
||||
|
||||
// Description block
|
||||
if (libraryItem.media.description) {
|
||||
fileString += '\n[DESCRIPTION]\n'
|
||||
fileString += libraryItem.media.description + '\n'
|
||||
}
|
||||
|
||||
// Book chapters
|
||||
if (mediaType == 'book' && libraryItem.media.chapters?.length) {
|
||||
fileString += '\n'
|
||||
libraryItem.media.chapters.forEach((chapter) => {
|
||||
fileString += `[CHAPTER]\n`
|
||||
fileString += `start=${chapter.start}\n`
|
||||
fileString += `end=${chapter.end}\n`
|
||||
fileString += `title=${chapter.title}\n`
|
||||
})
|
||||
}
|
||||
return fs.writeFile(outputPath, fileString).then(() => true).catch((error) => {
|
||||
Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
module.exports.generateFromNewModel = generateFromNewModel
|
||||
|
||||
function parseSections(lines) {
|
||||
if (!lines || !lines.length || !lines[0].startsWith('[')) { // First line must be section start
|
||||
return []
|
||||
}
|
||||
|
||||
var sections = []
|
||||
var currentSection = []
|
||||
lines.forEach(line => {
|
||||
if (!line || !line.trim()) return
|
||||
|
||||
if (line.startsWith('[') && currentSection.length) { // current section ended
|
||||
sections.push(currentSection)
|
||||
currentSection = []
|
||||
}
|
||||
|
||||
currentSection.push(line)
|
||||
})
|
||||
if (currentSection.length) sections.push(currentSection)
|
||||
return sections
|
||||
}
|
||||
|
||||
// lines inside chapter section
|
||||
function parseChapterLines(lines) {
|
||||
var chapter = {
|
||||
start: null,
|
||||
end: null,
|
||||
title: null
|
||||
}
|
||||
|
||||
lines.forEach((line) => {
|
||||
var keyValue = line.split('=')
|
||||
if (keyValue.length > 1) {
|
||||
var key = keyValue[0].trim()
|
||||
var value = keyValue[1].trim()
|
||||
|
||||
if (key === 'start' || key === 'end') {
|
||||
if (!isNaN(value)) {
|
||||
chapter[key] = Number(value)
|
||||
} else {
|
||||
Logger.warn(`[abmetadataGenerator] Invalid chapter value for ${key}: ${value}`)
|
||||
}
|
||||
} else if (key === 'title') {
|
||||
chapter[key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (chapter.start === null || chapter.end === null || chapter.end < chapter.start) {
|
||||
Logger.warn(`[abmetadataGenerator] Invalid chapter`)
|
||||
return null
|
||||
}
|
||||
return chapter
|
||||
}
|
||||
|
||||
function parseTags(value) {
|
||||
if (!value) return null
|
||||
try {
|
||||
const parsedTags = []
|
||||
JSON.parse(value).forEach((loadedTag) => {
|
||||
if (loadedTag.trim()) parsedTags.push(loadedTag) // Only push tags that are non-empty
|
||||
})
|
||||
return parsedTags
|
||||
} catch (err) {
|
||||
Logger.error(`[abmetadataGenerator] Error parsing TAGS "${value}":`, err.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseAbMetadataText(text, mediaType) {
|
||||
if (!text) return null
|
||||
let lines = text.split(/\r?\n/)
|
||||
|
||||
// Check first line and get abmetadata version number
|
||||
const firstLine = lines.shift().toLowerCase()
|
||||
if (!firstLine.startsWith(';abmetadata')) {
|
||||
Logger.error(`Invalid abmetadata file first line is not ;abmetadata "${firstLine}"`)
|
||||
return null
|
||||
}
|
||||
const abmetadataVersion = Number(firstLine.replace(';abmetadata', '').trim())
|
||||
if (isNaN(abmetadataVersion) || abmetadataVersion != CurrentAbMetadataVersion) {
|
||||
Logger.warn(`Invalid abmetadata version ${abmetadataVersion} - must use version ${CurrentAbMetadataVersion}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Remove comments and empty lines
|
||||
const ignoreFirstChars = [' ', '#', ';'] // Ignore any line starting with the following
|
||||
lines = lines.filter(line => !!line.trim() && !ignoreFirstChars.includes(line[0]))
|
||||
|
||||
// Get lines that map to book details (all lines before the first chapter or description section)
|
||||
const firstSectionLine = lines.findIndex(l => l.startsWith('['))
|
||||
const detailLines = firstSectionLine > 0 ? lines.slice(0, firstSectionLine) : lines
|
||||
const remainingLines = firstSectionLine > 0 ? lines.slice(firstSectionLine) : []
|
||||
|
||||
if (!detailLines.length) {
|
||||
Logger.error(`Invalid abmetadata file no detail lines`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Check the media type saved for this abmetadata file show warning if not matching expected
|
||||
if (detailLines[0].toLowerCase().startsWith('media=')) {
|
||||
const mediaLine = detailLines.shift() // Remove media line
|
||||
const abMediaType = mediaLine.toLowerCase().split('=')[1].trim()
|
||||
if (abMediaType != mediaType) {
|
||||
Logger.warn(`Invalid media type in abmetadata file ${abMediaType} expecting ${mediaType}`)
|
||||
}
|
||||
} else {
|
||||
Logger.warn(`No media type found in abmetadata file - expecting ${mediaType}`)
|
||||
}
|
||||
|
||||
const metadataMapper = metadataMappers[mediaType]
|
||||
// Put valid book detail values into map
|
||||
const mediaDetails = {
|
||||
metadata: {},
|
||||
chapters: [],
|
||||
tags: null // When tags are null it will not be used
|
||||
}
|
||||
|
||||
for (let i = 0; i < detailLines.length; i++) {
|
||||
const line = detailLines[i]
|
||||
const keyValue = line.split('=')
|
||||
if (keyValue.length < 2) {
|
||||
Logger.warn('abmetadata invalid line has no =', line)
|
||||
} else if (keyValue[0].trim() === 'tags') { // Parse tags
|
||||
const value = keyValue.slice(1).join('=').trim() // Everything after "tags="
|
||||
mediaDetails.tags = parseTags(value)
|
||||
} else if (!metadataMapper[keyValue[0].trim()]) { // Ensure valid media metadata key
|
||||
Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid ${mediaType} metadata key`)
|
||||
} else {
|
||||
const key = keyValue.shift().trim()
|
||||
const value = keyValue.join('=').trim()
|
||||
mediaDetails.metadata[key] = metadataMapper[key].from(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse sections for description and chapters
|
||||
const sections = parseSections(remainingLines)
|
||||
sections.forEach((section) => {
|
||||
const sectionHeader = section.shift()
|
||||
if (sectionHeader.toLowerCase().startsWith('[description]')) {
|
||||
mediaDetails.metadata.description = section.join('\n')
|
||||
} else if (sectionHeader.toLowerCase().startsWith('[chapter]')) {
|
||||
const chapter = parseChapterLines(section)
|
||||
if (chapter) {
|
||||
mediaDetails.chapters.push(chapter)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
mediaDetails.chapters.sort((a, b) => a.start - b.start)
|
||||
|
||||
if (mediaDetails.chapters.length) {
|
||||
mediaDetails.chapters = cleanChaptersArray(mediaDetails.chapters, mediaDetails.metadata.title) || []
|
||||
}
|
||||
|
||||
return mediaDetails
|
||||
}
|
||||
module.exports.parse = parseAbMetadataText
|
||||
|
||||
function checkUpdatedBookAuthors(abmetadataAuthors, authors) {
|
||||
const finalAuthors = []
|
||||
let hasUpdates = false
|
||||
|
||||
abmetadataAuthors.forEach((authorName) => {
|
||||
const findAuthor = authors.find(au => au.name.toLowerCase() == authorName.toLowerCase())
|
||||
if (!findAuthor) {
|
||||
hasUpdates = true
|
||||
finalAuthors.push({
|
||||
id: getId('new'), // New author gets created in Scanner.js after library scan
|
||||
name: authorName
|
||||
})
|
||||
} else {
|
||||
finalAuthors.push(findAuthor)
|
||||
}
|
||||
})
|
||||
|
||||
var authorsRemoved = authors.filter(au => !abmetadataAuthors.some(auname => auname.toLowerCase() == au.name.toLowerCase()))
|
||||
if (authorsRemoved.length) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
return {
|
||||
authors: finalAuthors,
|
||||
hasUpdates
|
||||
}
|
||||
}
|
||||
|
||||
function checkUpdatedBookSeries(abmetadataSeries, series) {
|
||||
var finalSeries = []
|
||||
var hasUpdates = false
|
||||
|
||||
abmetadataSeries.forEach((seriesObj) => {
|
||||
var findSeries = series.find(se => se.name.toLowerCase() == seriesObj.name.toLowerCase())
|
||||
if (!findSeries) {
|
||||
hasUpdates = true
|
||||
finalSeries.push({
|
||||
id: getId('new'), // New series gets created in Scanner.js after library scan
|
||||
name: seriesObj.name,
|
||||
sequence: seriesObj.sequence
|
||||
})
|
||||
} else if (findSeries.sequence != seriesObj.sequence) { // Sequence was updated
|
||||
hasUpdates = true
|
||||
finalSeries.push({
|
||||
id: findSeries.id,
|
||||
name: findSeries.name,
|
||||
sequence: seriesObj.sequence
|
||||
})
|
||||
} else {
|
||||
finalSeries.push(findSeries)
|
||||
}
|
||||
})
|
||||
|
||||
var seriesRemoved = series.filter(se => !abmetadataSeries.some(_se => _se.name.toLowerCase() == se.name.toLowerCase()))
|
||||
if (seriesRemoved.length) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
return {
|
||||
series: finalSeries,
|
||||
hasUpdates
|
||||
}
|
||||
}
|
||||
|
||||
function checkArraysChanged(abmetadataArray, mediaArray) {
|
||||
if (!Array.isArray(abmetadataArray)) return false
|
||||
if (!Array.isArray(mediaArray)) return true
|
||||
return abmetadataArray.join(',') != mediaArray.join(',')
|
||||
}
|
||||
|
||||
function parseJsonMetadataText(text) {
|
||||
try {
|
||||
const abmetadataData = JSON.parse(text)
|
||||
if (!abmetadataData.metadata) abmetadataData.metadata = {}
|
||||
|
||||
if (abmetadataData.metadata.series?.length) {
|
||||
abmetadataData.metadata.series = [...new Set(abmetadataData.metadata.series.map(t => t?.trim()).filter(t => t))]
|
||||
abmetadataData.metadata.series = abmetadataData.metadata.series.map(series => {
|
||||
// Old metadata.json used nested "metadata"
|
||||
if (abmetadataData.metadata) {
|
||||
for (const key in abmetadataData.metadata) {
|
||||
if (abmetadataData.metadata[key] === undefined) continue
|
||||
let newModelKey = key
|
||||
if (key === 'feedUrl') newModelKey = 'feedURL'
|
||||
else if (key === 'imageUrl') newModelKey = 'imageURL'
|
||||
else if (key === 'itunesPageUrl') newModelKey = 'itunesPageURL'
|
||||
else if (key === 'type') newModelKey = 'podcastType'
|
||||
abmetadataData[newModelKey] = abmetadataData.metadata[key]
|
||||
}
|
||||
}
|
||||
delete abmetadataData.metadata
|
||||
|
||||
if (abmetadataData.series?.length) {
|
||||
abmetadataData.series = [...new Set(abmetadataData.series.map(t => t?.trim()).filter(t => t))]
|
||||
abmetadataData.series = abmetadataData.series.map(series => {
|
||||
let sequence = null
|
||||
let name = series
|
||||
// Series sequence match any characters after " #" other than whitespace and another #
|
||||
|
|
@ -476,17 +41,17 @@ function parseJsonMetadataText(text) {
|
|||
abmetadataData.tags = [...new Set(abmetadataData.tags.map(t => t?.trim()).filter(t => t))]
|
||||
}
|
||||
if (abmetadataData.chapters?.length) {
|
||||
abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.metadata.title)
|
||||
abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title)
|
||||
}
|
||||
// clean remove dupes
|
||||
if (abmetadataData.metadata.authors?.length) {
|
||||
abmetadataData.metadata.authors = [...new Set(abmetadataData.metadata.authors.map(t => t?.trim()).filter(t => t))]
|
||||
if (abmetadataData.authors?.length) {
|
||||
abmetadataData.authors = [...new Set(abmetadataData.authors.map(t => t?.trim()).filter(t => t))]
|
||||
}
|
||||
if (abmetadataData.metadata.narrators?.length) {
|
||||
abmetadataData.metadata.narrators = [...new Set(abmetadataData.metadata.narrators.map(t => t?.trim()).filter(t => t))]
|
||||
if (abmetadataData.narrators?.length) {
|
||||
abmetadataData.narrators = [...new Set(abmetadataData.narrators.map(t => t?.trim()).filter(t => t))]
|
||||
}
|
||||
if (abmetadataData.metadata.genres?.length) {
|
||||
abmetadataData.metadata.genres = [...new Set(abmetadataData.metadata.genres.map(t => t?.trim()).filter(t => t))]
|
||||
if (abmetadataData.genres?.length) {
|
||||
abmetadataData.genres = [...new Set(abmetadataData.genres.map(t => t?.trim()).filter(t => t))]
|
||||
}
|
||||
return abmetadataData
|
||||
} catch (error) {
|
||||
|
|
@ -522,73 +87,3 @@ function cleanChaptersArray(chaptersArray, mediaTitle) {
|
|||
}
|
||||
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, 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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const abMetadata = abmetadataData.metadata // Metadata from abmetadata file
|
||||
for (const key in abMetadata) {
|
||||
if (mediaMetadata[key] !== undefined) {
|
||||
if (key === 'authors') {
|
||||
const authorUpdatePayload = checkUpdatedBookAuthors(abMetadata[key], mediaMetadata[key])
|
||||
if (authorUpdatePayload.hasUpdates) metadataUpdatePayload.authors = authorUpdatePayload.authors
|
||||
} else if (key === 'series') {
|
||||
const seriesUpdatePayload = checkUpdatedBookSeries(abMetadata[key], mediaMetadata[key])
|
||||
if (seriesUpdatePayload.hasUpdates) metadataUpdatePayload.series = seriesUpdatePayload.series
|
||||
} else if (key === 'genres' || key === 'narrators') { // Compare array differences
|
||||
if (checkArraysChanged(abMetadata[key], mediaMetadata[key])) {
|
||||
metadataUpdatePayload[key] = abMetadata[key]
|
||||
}
|
||||
} else if (abMetadata[key] !== mediaMetadata[key]) {
|
||||
metadataUpdatePayload[key] = abMetadata[key]
|
||||
}
|
||||
} else {
|
||||
Logger.warn('[abmetadataGenerator] Invalid key', key)
|
||||
}
|
||||
}
|
||||
|
||||
const updatePayload = {} // Only updated key/values
|
||||
// Check update tags
|
||||
if (abmetadataData.tags) {
|
||||
if (checkArraysChanged(abmetadataData.tags, media.tags)) {
|
||||
updatePayload.tags = abmetadataData.tags
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return updatePayload
|
||||
}
|
||||
module.exports.parseAndCheckForUpdates = parseAndCheckForUpdates
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ module.exports.getId = (prepend = '') => {
|
|||
}
|
||||
|
||||
function elapsedPretty(seconds) {
|
||||
if (seconds > 0 && seconds < 1) {
|
||||
return `${Math.floor(seconds * 1000)} ms`
|
||||
}
|
||||
if (seconds < 60) {
|
||||
return `${Math.floor(seconds)} sec`
|
||||
}
|
||||
|
|
@ -166,4 +169,39 @@ module.exports.getTitleIgnorePrefix = (title) => {
|
|||
module.exports.getTitlePrefixAtEnd = (title) => {
|
||||
let [sort, prefix] = getTitleParts(title)
|
||||
return prefix ? `${sort}, ${prefix}` : title
|
||||
}
|
||||
|
||||
/**
|
||||
* to lower case for only ascii characters
|
||||
* used to handle sqlite that doesnt support unicode lower
|
||||
* @see https://github.com/advplyr/audiobookshelf/issues/2187
|
||||
*
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
module.exports.asciiOnlyToLowerCase = (str) => {
|
||||
if (!str) return ''
|
||||
|
||||
let temp = ''
|
||||
for (let chars of str) {
|
||||
let value = chars.charCodeAt()
|
||||
if (value >= 65 && value <= 90) {
|
||||
temp += String.fromCharCode(value + 32)
|
||||
} else {
|
||||
temp += chars
|
||||
}
|
||||
}
|
||||
return temp
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape string used in RegExp
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
|
||||
*
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
module.exports.escapeRegExp = (str) => {
|
||||
if (typeof str !== 'string') return ''
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
93
server/utils/migrations/absMetadataMigration.js
Normal file
93
server/utils/migrations/absMetadataMigration.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
const Path = require('path')
|
||||
const Logger = require('../../Logger')
|
||||
const fsExtra = require('../../libs/fsExtra')
|
||||
const fileUtils = require('../fileUtils')
|
||||
const LibraryFile = require('../../objects/files/LibraryFile')
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../../models/LibraryItem')} libraryItem
|
||||
* @returns {Promise<boolean>} false if failed
|
||||
*/
|
||||
async function writeMetadataFileForItem(libraryItem) {
|
||||
const storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem && !libraryItem.isFile
|
||||
const metadataPath = storeMetadataWithItem ? libraryItem.path : Path.join(global.MetadataPath, 'items', libraryItem.id)
|
||||
const metadataFilepath = fileUtils.filePathToPOSIX(Path.join(metadataPath, 'metadata.json'))
|
||||
if ((await fsExtra.pathExists(metadataFilepath))) {
|
||||
// Metadata file already exists do nothing
|
||||
return null
|
||||
}
|
||||
Logger.info(`[absMetadataMigration] metadata file not found at "${metadataFilepath}" - creating`)
|
||||
|
||||
if (!storeMetadataWithItem) {
|
||||
// Ensure /metadata/items/<lid> dir
|
||||
await fsExtra.ensureDir(metadataPath)
|
||||
}
|
||||
|
||||
const metadataJson = libraryItem.media.getAbsMetadataJson()
|
||||
|
||||
// Save to file
|
||||
const success = await fsExtra.writeFile(metadataFilepath, JSON.stringify(metadataJson, null, 2)).then(() => true).catch((error) => {
|
||||
Logger.error(`[absMetadataMigration] failed to save metadata file at "${metadataFilepath}"`, error.message || error)
|
||||
return false
|
||||
})
|
||||
|
||||
if (!success) return false
|
||||
if (!storeMetadataWithItem) return true // No need to do anything else
|
||||
|
||||
// Safety check to make sure library file with the same path isnt already there
|
||||
libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== metadataFilepath)
|
||||
|
||||
// Put new library file in library item
|
||||
const newLibraryFile = new LibraryFile()
|
||||
await newLibraryFile.setDataFromPath(metadataFilepath, 'metadata.json')
|
||||
libraryItem.libraryFiles.push(newLibraryFile.toJSON())
|
||||
|
||||
// Update library item timestamps and total size
|
||||
const libraryItemDirTimestamps = await fileUtils.getFileTimestampsWithIno(libraryItem.path)
|
||||
if (libraryItemDirTimestamps) {
|
||||
libraryItem.mtime = libraryItemDirTimestamps.mtimeMs
|
||||
libraryItem.ctime = libraryItemDirTimestamps.ctimeMs
|
||||
let size = 0
|
||||
libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
||||
libraryItem.size = size
|
||||
}
|
||||
|
||||
libraryItem.changed('libraryFiles', true)
|
||||
return libraryItem.save().then(() => true).catch((error) => {
|
||||
Logger.error(`[absMetadataMigration] failed to save libraryItem "${libraryItem.id}"`, error.message || error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../../Database')} Database
|
||||
* @param {number} [offset=0]
|
||||
* @param {number} [totalCreated=0]
|
||||
*/
|
||||
async function runMigration(Database, offset = 0, totalCreated = 0) {
|
||||
const libraryItems = await Database.libraryItemModel.getLibraryItemsIncrement(offset, 500, { isMissing: false })
|
||||
if (!libraryItems.length) return totalCreated
|
||||
|
||||
let numCreated = 0
|
||||
for (const libraryItem of libraryItems) {
|
||||
const success = await writeMetadataFileForItem(libraryItem)
|
||||
if (success) numCreated++
|
||||
}
|
||||
|
||||
if (libraryItems.length < 500) {
|
||||
return totalCreated + numCreated
|
||||
}
|
||||
return runMigration(Database, offset + libraryItems.length, totalCreated + numCreated)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../../Database')} Database
|
||||
*/
|
||||
module.exports.migrate = async (Database) => {
|
||||
Logger.info(`[absMetadataMigration] Starting metadata.json migration`)
|
||||
const totalCreated = await runMigration(Database)
|
||||
Logger.info(`[absMetadataMigration] Finished metadata.json migration (${totalCreated} files created)`)
|
||||
}
|
||||
100
server/utils/parsers/parseNfoMetadata.js
Normal file
100
server/utils/parsers/parseNfoMetadata.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
function parseNfoMetadata(nfoText) {
|
||||
if (!nfoText) return null
|
||||
const lines = nfoText.split(/\r?\n/)
|
||||
const metadata = {}
|
||||
let insideBookDescription = false
|
||||
lines.forEach(line => {
|
||||
if (line.search(/^\s*book description\s*$/i) !== -1) {
|
||||
insideBookDescription = true
|
||||
return
|
||||
}
|
||||
if (insideBookDescription) {
|
||||
if (line.search(/^\s*=+\s*$/i) !== -1) return
|
||||
metadata.description = metadata.description || ''
|
||||
metadata.description += line + '\n'
|
||||
return
|
||||
}
|
||||
const match = line.match(/^(.*?):(.*)$/)
|
||||
if (match) {
|
||||
const key = match[1].toLowerCase().trim()
|
||||
const value = match[2].trim()
|
||||
if (!value) return
|
||||
switch (key) {
|
||||
case 'title':
|
||||
{
|
||||
const titleMatch = value.match(/^(.*?):(.*)$/)
|
||||
if (titleMatch) {
|
||||
metadata.title = titleMatch[1].trim()
|
||||
metadata.subtitle = titleMatch[2].trim()
|
||||
} else {
|
||||
metadata.title = value
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'author':
|
||||
metadata.authors = value.split(/\s*,\s*/).filter(v => v)
|
||||
break
|
||||
case 'narrator':
|
||||
case 'read by':
|
||||
metadata.narrators = value.split(/\s*,\s*/).filter(v => v)
|
||||
break
|
||||
case 'series name':
|
||||
metadata.series = value
|
||||
break
|
||||
case 'genre':
|
||||
metadata.genres = value.split(/\s*,\s*/).filter(v => v)
|
||||
break
|
||||
case 'tags':
|
||||
metadata.tags = value.split(/\s*,\s*/).filter(v => v)
|
||||
break
|
||||
case 'copyright':
|
||||
case 'audible.com release':
|
||||
case 'audiobook copyright':
|
||||
case 'book copyright':
|
||||
case 'recording copyright':
|
||||
case 'release date':
|
||||
case 'date':
|
||||
{
|
||||
const year = extractYear(value)
|
||||
if (year) {
|
||||
metadata.publishedYear = year
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'position in series':
|
||||
metadata.sequence = value
|
||||
break
|
||||
case 'unabridged':
|
||||
metadata.abridged = value.toLowerCase() === 'yes' ? false : true
|
||||
break
|
||||
case 'abridged':
|
||||
metadata.abridged = value.toLowerCase() === 'no' ? false : true
|
||||
break
|
||||
case 'publisher':
|
||||
metadata.publisher = value
|
||||
break
|
||||
case 'asin':
|
||||
metadata.asin = value
|
||||
break
|
||||
case 'isbn':
|
||||
case 'isbn-10':
|
||||
case 'isbn-13':
|
||||
metadata.isbn = value
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Trim leading/trailing whitespace for description
|
||||
if (metadata.description) {
|
||||
metadata.description = metadata.description.trim()
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
module.exports = { parseNfoMetadata }
|
||||
|
||||
function extractYear(str) {
|
||||
const match = str.match(/\d{4}/g)
|
||||
return match ? match[match.length - 1] : null
|
||||
}
|
||||
|
|
@ -1,13 +1,6 @@
|
|||
const xml2js = require('xml2js')
|
||||
const Logger = require('../../Logger')
|
||||
|
||||
// given a list of audio files, extract all of the Overdrive Media Markers metaTags, and return an array of them as XML
|
||||
function extractOverdriveMediaMarkers(includedAudioFiles) {
|
||||
Logger.debug('[parseOverdriveMediaMarkers] Extracting overdrive media markers')
|
||||
var markers = includedAudioFiles.map((af) => af.metaTags.tagOverdriveMediaMarker).filter(af => af) || []
|
||||
|
||||
return markers
|
||||
}
|
||||
|
||||
// given the array of Overdrive Media Markers from generateOverdriveMediaMarkers()
|
||||
// parse and clean them in to something a bit more usable
|
||||
function cleanOverdriveMediaMarkers(overdriveMediaMarkers) {
|
||||
|
|
@ -29,12 +22,11 @@ function cleanOverdriveMediaMarkers(overdriveMediaMarkers) {
|
|||
]
|
||||
*/
|
||||
|
||||
var parseString = require('xml2js').parseString // function to convert xml to JSON
|
||||
var parsedOverdriveMediaMarkers = []
|
||||
|
||||
const parsedOverdriveMediaMarkers = []
|
||||
overdriveMediaMarkers.forEach((item, index) => {
|
||||
var parsed_result = null
|
||||
parseString(item, function (err, result) {
|
||||
let parsed_result = null
|
||||
// convert xml to JSON
|
||||
xml2js.parseString(item, function (err, result) {
|
||||
/*
|
||||
result.Markers.Marker is the result of parsing the XML for the MediaMarker tags for the MP3 file (Part##.mp3)
|
||||
it is shaped like this, and needs further cleaning below:
|
||||
|
|
@ -54,7 +46,7 @@ function cleanOverdriveMediaMarkers(overdriveMediaMarkers) {
|
|||
*/
|
||||
|
||||
// The values for Name and Time in results.Markers.Marker are returned as Arrays from parseString and should be strings
|
||||
if (result && result.Markers && result.Markers.Marker) {
|
||||
if (result?.Markers?.Marker) {
|
||||
parsed_result = objectValuesArrayToString(result.Markers.Marker)
|
||||
}
|
||||
})
|
||||
|
|
@ -138,22 +130,13 @@ function generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers
|
|||
return parsedChapters
|
||||
}
|
||||
|
||||
module.exports.overdriveMediaMarkersExist = (includedAudioFiles) => {
|
||||
return extractOverdriveMediaMarkers(includedAudioFiles).length > 1
|
||||
}
|
||||
|
||||
module.exports.parseOverdriveMediaMarkersAsChapters = (includedAudioFiles) => {
|
||||
Logger.info('[parseOverdriveMediaMarkers] Parsing of Overdrive Media Markers started')
|
||||
|
||||
var overdriveMediaMarkers = extractOverdriveMediaMarkers(includedAudioFiles)
|
||||
const overdriveMediaMarkers = includedAudioFiles.map((af) => af.metaTags.tagOverdriveMediaMarker).filter(af => af) || []
|
||||
if (!overdriveMediaMarkers.length) return null
|
||||
|
||||
var cleanedOverdriveMediaMarkers = cleanOverdriveMediaMarkers(overdriveMediaMarkers)
|
||||
// TODO: generateParsedChapters requires overdrive media markers and included audio files length to be the same
|
||||
// so if not equal then we must exit
|
||||
if (cleanedOverdriveMediaMarkers.length !== includedAudioFiles.length) return null
|
||||
|
||||
var parsedChapters = generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers)
|
||||
|
||||
return parsedChapters
|
||||
return generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers)
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ const { xmlToJSON, levenshteinDistance } = require('./index')
|
|||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
function extractFirstArrayItem(json, key) {
|
||||
if (!json[key] || !json[key].length) return null
|
||||
if (!json[key]?.length) return null
|
||||
return json[key][0]
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ function extractPodcastMetadata(channel) {
|
|||
arrayFields.forEach((key) => {
|
||||
const cleanKey = key.split(':').pop()
|
||||
let value = extractFirstArrayItem(channel, key)
|
||||
if (value && value['_']) value = value['_']
|
||||
if (value?.['_']) value = value['_']
|
||||
metadata[cleanKey] = value
|
||||
})
|
||||
return metadata
|
||||
|
|
@ -110,17 +110,30 @@ function extractEpisodeData(item) {
|
|||
const pubDate = extractFirstArrayItem(item, 'pubDate')
|
||||
if (typeof pubDate === 'string') {
|
||||
episode.pubDate = pubDate
|
||||
} else if (pubDate && typeof pubDate._ === 'string') {
|
||||
} else if (typeof pubDate?._ === 'string') {
|
||||
episode.pubDate = pubDate._
|
||||
} else {
|
||||
Logger.error(`[podcastUtils] Invalid pubDate ${item['pubDate']} for ${episode.enclosure.url}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (item['guid']) {
|
||||
const guidItem = extractFirstArrayItem(item, 'guid')
|
||||
if (typeof guidItem === 'string') {
|
||||
episode.guid = guidItem
|
||||
} else if (typeof guidItem?._ === 'string') {
|
||||
episode.guid = guidItem._
|
||||
} else {
|
||||
Logger.error(`[podcastUtils] Invalid guid ${item['guid']} for ${episode.enclosure.url}`)
|
||||
}
|
||||
}
|
||||
|
||||
const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
|
||||
arrayFields.forEach((key) => {
|
||||
const cleanKey = key.split(':').pop()
|
||||
episode[cleanKey] = extractFirstArrayItem(item, key)
|
||||
let value = extractFirstArrayItem(item, key)
|
||||
if (value?.['_']) value = value['_']
|
||||
episode[cleanKey] = value
|
||||
})
|
||||
return episode
|
||||
}
|
||||
|
|
@ -142,6 +155,7 @@ function cleanEpisodeData(data) {
|
|||
explicit: data.explicit || '',
|
||||
publishedAt,
|
||||
enclosure: data.enclosure,
|
||||
guid: data.guid || null,
|
||||
chaptersUrl: data.chaptersUrl || null,
|
||||
chaptersType: data.chaptersType || null
|
||||
}
|
||||
|
|
@ -159,16 +173,16 @@ function extractPodcastEpisodes(items) {
|
|||
}
|
||||
|
||||
function cleanPodcastJson(rssJson, excludeEpisodeMetadata) {
|
||||
if (!rssJson.channel || !rssJson.channel.length) {
|
||||
if (!rssJson.channel?.length) {
|
||||
Logger.error(`[podcastUtil] Invalid podcast no channel object`)
|
||||
return null
|
||||
}
|
||||
var channel = rssJson.channel[0]
|
||||
if (!channel.item || !channel.item.length) {
|
||||
const channel = rssJson.channel[0]
|
||||
if (!channel.item?.length) {
|
||||
Logger.error(`[podcastUtil] Invalid podcast no episodes`)
|
||||
return null
|
||||
}
|
||||
var podcast = {
|
||||
const podcast = {
|
||||
metadata: extractPodcastMetadata(channel)
|
||||
}
|
||||
if (!excludeEpisodeMetadata) {
|
||||
|
|
@ -181,8 +195,8 @@ function cleanPodcastJson(rssJson, excludeEpisodeMetadata) {
|
|||
|
||||
module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = false, includeRaw = false) => {
|
||||
if (!xml) return null
|
||||
var json = await xmlToJSON(xml)
|
||||
if (!json || !json.rss) {
|
||||
const json = await xmlToJSON(xml)
|
||||
if (!json?.rss) {
|
||||
Logger.error('[podcastUtils] Invalid XML or RSS feed')
|
||||
return null
|
||||
}
|
||||
|
|
@ -215,12 +229,12 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
|||
data.data = data.data.toString()
|
||||
}
|
||||
|
||||
if (!data || !data.data) {
|
||||
if (!data?.data) {
|
||||
Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`)
|
||||
return false
|
||||
}
|
||||
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}" success - parsing xml`)
|
||||
var payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata)
|
||||
const payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata)
|
||||
if (!payload) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -246,7 +260,7 @@ module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => {
|
|||
|
||||
module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
|
||||
searchTitle = searchTitle.toLowerCase().trim()
|
||||
if (!feed || !feed.episodes) {
|
||||
if (!feed?.episodes) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -308,6 +308,8 @@ module.exports = {
|
|||
async getNewestAuthors(library, user, limit) {
|
||||
if (library.mediaType !== 'book') return { authors: [], count: 0 }
|
||||
|
||||
const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(user)
|
||||
|
||||
const { rows: authors, count } = await Database.authorModel.findAndCountAll({
|
||||
where: {
|
||||
libraryId: library.id,
|
||||
|
|
@ -315,9 +317,15 @@ module.exports = {
|
|||
[Sequelize.Op.gte]: new Date(new Date() - (60 * 24 * 60 * 60 * 1000)) // 60 days ago
|
||||
}
|
||||
},
|
||||
replacements,
|
||||
include: {
|
||||
model: Database.bookAuthorModel,
|
||||
required: true // Must belong to a book
|
||||
model: Database.bookModel,
|
||||
attributes: ['id', 'tags', 'explicit'],
|
||||
where: bookWhere,
|
||||
required: true, // Must belong to a book
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
limit,
|
||||
distinct: true,
|
||||
|
|
@ -328,7 +336,7 @@ module.exports = {
|
|||
|
||||
return {
|
||||
authors: authors.map((au) => {
|
||||
const numBooks = au.bookAuthors?.length || 0
|
||||
const numBooks = au.books.length || 0
|
||||
return au.getOldAuthor().toJSONExpanded(numBooks)
|
||||
}),
|
||||
count
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const Sequelize = require('sequelize')
|
|||
const Database = require('../../Database')
|
||||
const Logger = require('../../Logger')
|
||||
const authorFilters = require('./authorFilters')
|
||||
const { asciiOnlyToLowerCase } = require('../index')
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
|
|
@ -1013,7 +1014,8 @@ module.exports = {
|
|||
let matchText = null
|
||||
let matchKey = null
|
||||
for (const key of ['title', 'subtitle', 'asin', 'isbn']) {
|
||||
if (book[key]?.toLowerCase().includes(query)) {
|
||||
const valueToLower = asciiOnlyToLowerCase(book[key])
|
||||
if (valueToLower.includes(query)) {
|
||||
matchText = book[key]
|
||||
matchKey = key
|
||||
break
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
const Sequelize = require('sequelize')
|
||||
const Database = require('../../Database')
|
||||
const Logger = require('../../Logger')
|
||||
const { asciiOnlyToLowerCase } = require('../index')
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
|
|
@ -364,7 +365,8 @@ module.exports = {
|
|||
let matchText = null
|
||||
let matchKey = null
|
||||
for (const key of ['title', 'author', 'itunesId', 'itunesArtistId']) {
|
||||
if (podcast[key]?.toLowerCase().includes(query)) {
|
||||
const valueToLower = asciiOnlyToLowerCase(podcast[key])
|
||||
if (valueToLower.includes(query)) {
|
||||
matchText = podcast[key]
|
||||
matchKey = key
|
||||
break
|
||||
|
|
|
|||
|
|
@ -2,6 +2,19 @@ const Path = require('path')
|
|||
const { filePathToPOSIX } = require('./fileUtils')
|
||||
const globals = require('./globals')
|
||||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const parseNameString = require('./parsers/parseNameString')
|
||||
|
||||
/**
|
||||
* @typedef LibraryItemFilenameMetadata
|
||||
* @property {string} title
|
||||
* @property {string} subtitle Book mediaType only
|
||||
* @property {string} asin Book mediaType only
|
||||
* @property {string[]} authors Book mediaType only
|
||||
* @property {string[]} narrators Book mediaType only
|
||||
* @property {string} seriesName Book mediaType only
|
||||
* @property {string} seriesSequence Book mediaType only
|
||||
* @property {string} publishedYear Book mediaType only
|
||||
*/
|
||||
|
||||
function isMediaFile(mediaType, ext, audiobooksOnly = false) {
|
||||
if (!ext) return false
|
||||
|
|
@ -210,58 +223,71 @@ function buildLibraryFile(libraryItemPath, files) {
|
|||
}
|
||||
module.exports.buildLibraryFile = buildLibraryFile
|
||||
|
||||
// Input relative filepath, output all details that can be parsed
|
||||
function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
|
||||
relPath = filePathToPOSIX(relPath)
|
||||
var splitDir = relPath.split('/')
|
||||
/**
|
||||
* Get details parsed from filenames
|
||||
*
|
||||
* @param {string} relPath
|
||||
* @param {boolean} parseSubtitle
|
||||
* @returns {LibraryItemFilenameMetadata}
|
||||
*/
|
||||
function getBookDataFromDir(relPath, parseSubtitle = false) {
|
||||
const splitDir = relPath.split('/')
|
||||
|
||||
var folder = splitDir.pop() // Audio files will always be in the directory named for the title
|
||||
series = (splitDir.length > 1) ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
|
||||
author = (splitDir.length > 0) ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
|
||||
|
||||
// The may contain various other pieces of metadata, these functions extract it.
|
||||
var [folder, asin] = getASIN(folder)
|
||||
var [folder, narrators] = getNarrator(folder)
|
||||
var [folder, sequence] = series ? getSequence(folder) : [folder, null]
|
||||
var [folder, publishedYear] = getPublishedYear(folder)
|
||||
var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null]
|
||||
|
||||
|
||||
return {
|
||||
mediaMetadata: {
|
||||
author,
|
||||
title,
|
||||
subtitle,
|
||||
series,
|
||||
sequence,
|
||||
publishedYear,
|
||||
narrators,
|
||||
},
|
||||
relPath: relPath, // relative audiobook path i.e. /Author Name/Book Name/..
|
||||
path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/..
|
||||
title,
|
||||
subtitle,
|
||||
asin,
|
||||
authors: parseNameString.parse(author)?.names || [],
|
||||
narrators: parseNameString.parse(narrators)?.names || [],
|
||||
seriesName: series,
|
||||
seriesSequence: sequence,
|
||||
publishedYear
|
||||
}
|
||||
}
|
||||
module.exports.getBookDataFromDir = getBookDataFromDir
|
||||
|
||||
/**
|
||||
* Extract narrator from folder name
|
||||
*
|
||||
* @param {string} folder
|
||||
* @returns {[string, string]} [folder, narrator]
|
||||
*/
|
||||
function getNarrator(folder) {
|
||||
let pattern = /^(?<title>.*) \{(?<narrators>.*)\}$/
|
||||
let match = folder.match(pattern)
|
||||
return match ? [match.groups.title, match.groups.narrators] : [folder, null]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract series sequence from folder name
|
||||
*
|
||||
* @example
|
||||
* 'Book 2 - Title - Subtitle'
|
||||
* 'Title - Subtitle - Vol 12'
|
||||
* 'Title - volume 9 - Subtitle'
|
||||
* 'Vol. 3 Title Here - Subtitle'
|
||||
* '1980 - Book 2 - Title'
|
||||
* 'Volume 12. Title - Subtitle'
|
||||
* '100 - Book Title'
|
||||
* '6. Title'
|
||||
* '0.5 - Book Title'
|
||||
*
|
||||
* @param {string} folder
|
||||
* @returns {[string, string]} [folder, sequence]
|
||||
*/
|
||||
function getSequence(folder) {
|
||||
// Valid ways of including a volume number:
|
||||
// [
|
||||
// 'Book 2 - Title - Subtitle',
|
||||
// 'Title - Subtitle - Vol 12',
|
||||
// 'Title - volume 9 - Subtitle',
|
||||
// 'Vol. 3 Title Here - Subtitle',
|
||||
// '1980 - Book 2 - Title',
|
||||
// 'Volume 12. Title - Subtitle',
|
||||
// '100 - Book Title',
|
||||
// '2 - Book Title',
|
||||
// '6. Title',
|
||||
// '0.5 - Book Title'
|
||||
// ]
|
||||
|
||||
// Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later.
|
||||
let pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{0,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?$/i
|
||||
|
||||
|
|
@ -282,6 +308,12 @@ function getSequence(folder) {
|
|||
return [folder, volumeNumber]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract published year from folder name
|
||||
*
|
||||
* @param {string} folder
|
||||
* @returns {[string, string]} [folder, publishedYear]
|
||||
*/
|
||||
function getPublishedYear(folder) {
|
||||
var publishedYear = null
|
||||
|
||||
|
|
@ -295,34 +327,73 @@ function getPublishedYear(folder) {
|
|||
return [folder, publishedYear]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract subtitle from folder name
|
||||
*
|
||||
* @param {string} folder
|
||||
* @returns {[string, string]} [folder, subtitle]
|
||||
*/
|
||||
function getSubtitle(folder) {
|
||||
// Subtitle is everything after " - "
|
||||
var splitTitle = folder.split(' - ')
|
||||
return [splitTitle.shift(), splitTitle.join(' - ')]
|
||||
}
|
||||
|
||||
function getPodcastDataFromDir(folderPath, relPath) {
|
||||
relPath = filePathToPOSIX(relPath)
|
||||
/**
|
||||
* Extract asin from folder name
|
||||
*
|
||||
* @param {string} folder
|
||||
* @returns {[string, string]} [folder, asin]
|
||||
*/
|
||||
function getASIN(folder) {
|
||||
let asin = null
|
||||
|
||||
let pattern = /(?: |^)\[([A-Z0-9]{10})](?= |$)/ // Matches "[B0015T963C]"
|
||||
const match = folder.match(pattern)
|
||||
if (match) {
|
||||
asin = match[1]
|
||||
folder = folder.replace(match[0], '')
|
||||
}
|
||||
return [folder.trim(), asin]
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} relPath
|
||||
* @returns {LibraryItemFilenameMetadata}
|
||||
*/
|
||||
function getPodcastDataFromDir(relPath) {
|
||||
const splitDir = relPath.split('/')
|
||||
|
||||
// Audio files will always be in the directory named for the title
|
||||
const title = splitDir.pop()
|
||||
return {
|
||||
mediaMetadata: {
|
||||
title
|
||||
},
|
||||
relPath: relPath, // relative podcast path i.e. /Podcast Name/..
|
||||
path: Path.posix.join(folderPath, relPath) // i.e. /podcasts/Podcast Name/..
|
||||
title
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} libraryMediaType
|
||||
* @param {string} folderPath
|
||||
* @param {string} relPath
|
||||
* @returns {{ mediaMetadata: LibraryItemFilenameMetadata, relPath: string, path: string}}
|
||||
*/
|
||||
function getDataFromMediaDir(libraryMediaType, folderPath, relPath) {
|
||||
relPath = filePathToPOSIX(relPath)
|
||||
let fullPath = Path.posix.join(folderPath, relPath)
|
||||
let mediaMetadata = null
|
||||
|
||||
if (libraryMediaType === 'podcast') {
|
||||
return getPodcastDataFromDir(folderPath, relPath)
|
||||
} else if (libraryMediaType === 'book') {
|
||||
return getBookDataFromDir(folderPath, relPath, !!global.ServerSettings.scannerParseSubtitle)
|
||||
} else {
|
||||
return getPodcastDataFromDir(folderPath, relPath)
|
||||
mediaMetadata = getPodcastDataFromDir(relPath)
|
||||
} else { // book
|
||||
mediaMetadata = getBookDataFromDir(relPath, !!global.ServerSettings.scannerParseSubtitle)
|
||||
}
|
||||
|
||||
return {
|
||||
mediaMetadata,
|
||||
relPath,
|
||||
path: fullPath
|
||||
}
|
||||
}
|
||||
module.exports.getDataFromMediaDir = getDataFromMediaDir
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue