Merge branch 'advplyr:master' into feat/all-stats-page

This commit is contained in:
Vito0912 2024-12-29 20:28:49 +01:00 committed by GitHub
commit df15c22c6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
336 changed files with 21709 additions and 13151 deletions

View file

@ -0,0 +1,207 @@
const Path = require('path')
const os = require('os')
const unrar = require('node-unrar-js')
const Logger = require('../Logger')
const fs = require('../libs/fsExtra')
const StreamZip = require('../libs/nodeStreamZip')
const Archive = require('../libs/libarchive/archive')
const { isWritable } = require('./fileUtils')
class AbstractComicBookExtractor {
constructor(comicPath) {
this.comicPath = comicPath
}
async getBuffer() {
if (!(await fs.pathExists(this.comicPath))) {
Logger.error(`[parseComicMetadata] Comic path does not exist "${this.comicPath}"`)
return null
}
try {
return fs.readFile(this.comicPath)
} catch (error) {
Logger.error(`[parseComicMetadata] Failed to read comic at "${this.comicPath}"`, error)
return null
}
}
async open() {
throw new Error('Not implemented')
}
async getFilePaths() {
throw new Error('Not implemented')
}
async extractToFile(filePath, outputFilePath) {
throw new Error('Not implemented')
}
async extractToBuffer(filePath) {
throw new Error('Not implemented')
}
close() {
throw new Error('Not implemented')
}
}
class CbrComicBookExtractor extends AbstractComicBookExtractor {
constructor(comicPath) {
super(comicPath)
this.archive = null
this.tmpDir = null
}
async open() {
this.tmpDir = global.MetadataPath ? Path.join(global.MetadataPath, 'tmp') : os.tmpdir()
await fs.ensureDir(this.tmpDir)
if (!(await isWritable(this.tmpDir))) throw new Error(`[CbrComicBookExtractor] Temp directory "${this.tmpDir}" is not writable`)
this.archive = await unrar.createExtractorFromFile({ filepath: this.comicPath, targetPath: this.tmpDir })
Logger.debug(`[CbrComicBookExtractor] Opened comic book "${this.comicPath}". Using temp directory "${this.tmpDir}" for extraction.`)
}
async getFilePaths() {
if (!this.archive) return null
const list = this.archive.getFileList()
const fileHeaders = [...list.fileHeaders]
const filePaths = fileHeaders.filter((fh) => !fh.flags.directory).map((fh) => fh.name)
Logger.debug(`[CbrComicBookExtractor] Found ${filePaths.length} files in comic book "${this.comicPath}"`)
return filePaths
}
async removeEmptyParentDirs(file) {
let dir = Path.dirname(file)
while (dir !== '.') {
const fullDirPath = Path.join(this.tmpDir, dir)
const files = await fs.readdir(fullDirPath)
if (files.length > 0) break
await fs.remove(fullDirPath)
dir = Path.dirname(dir)
}
}
async extractToBuffer(file) {
if (!this.archive) return null
const extracted = this.archive.extract({ files: [file] })
const files = [...extracted.files]
const filePath = Path.join(this.tmpDir, files[0].fileHeader.name)
const fileData = await fs.readFile(filePath)
await fs.remove(filePath)
await this.removeEmptyParentDirs(files[0].fileHeader.name)
Logger.debug(`[CbrComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to buffer, size: ${fileData.length}`)
return fileData
}
async extractToFile(file, outputFilePath) {
if (!this.archive) return false
const extracted = this.archive.extract({ files: [file] })
const files = [...extracted.files]
const extractedFilePath = Path.join(this.tmpDir, files[0].fileHeader.name)
await fs.move(extractedFilePath, outputFilePath, { overwrite: true })
await this.removeEmptyParentDirs(files[0].fileHeader.name)
Logger.debug(`[CbrComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to "${outputFilePath}"`)
return true
}
close() {
Logger.debug(`[CbrComicBookExtractor] Closed comic book "${this.comicPath}"`)
}
}
class CbzComicBookExtractor extends AbstractComicBookExtractor {
constructor(comicPath) {
super(comicPath)
this.archive = null
}
async open() {
const buffer = await this.getBuffer()
this.archive = await Archive.open(buffer)
Logger.debug(`[CbzComicBookExtractor] Opened comic book "${this.comicPath}"`)
}
async getFilePaths() {
if (!this.archive) return null
const list = await this.archive.getFilesArray()
const fileNames = list.map((fo) => fo.file._path)
Logger.debug(`[CbzComicBookExtractor] Found ${fileNames.length} files in comic book "${this.comicPath}"`)
return fileNames
}
async extractToBuffer(file) {
if (!this.archive) return null
const extracted = await this.archive.extractSingleFile(file)
Logger.debug(`[CbzComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to buffer, size: ${extracted?.fileData.length}`)
return extracted?.fileData
}
async extractToFile(file, outputFilePath) {
const data = await this.extractToBuffer(file)
if (!data) return false
await fs.writeFile(outputFilePath, data)
Logger.debug(`[CbzComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to "${outputFilePath}"`)
return true
}
close() {
this.archive?.close()
Logger.debug(`[CbzComicBookExtractor] Closed comic book "${this.comicPath}"`)
}
}
class CbzStreamZipComicBookExtractor extends AbstractComicBookExtractor {
constructor(comicPath) {
super(comicPath)
this.archive = null
}
async open() {
this.archive = new StreamZip.async({ file: this.comicPath })
Logger.debug(`[CbzStreamZipComicBookExtractor] Opened comic book "${this.comicPath}"`)
}
async getFilePaths() {
if (!this.archive) return null
const entries = await this.archive.entries()
const fileNames = Object.keys(entries).filter((entry) => !entries[entry].isDirectory)
Logger.debug(`[CbzStreamZipComicBookExtractor] Found ${fileNames.length} files in comic book "${this.comicPath}"`)
return fileNames
}
async extractToBuffer(file) {
if (!this.archive) return null
const extracted = await this.archive?.entryData(file)
Logger.debug(`[CbzStreamZipComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to buffer, size: ${extracted.length}`)
return extracted
}
async extractToFile(file, outputFilePath) {
if (!this.archive) return false
try {
await this.archive.extract(file, outputFilePath)
Logger.debug(`[CbzStreamZipComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to "${outputFilePath}"`)
return true
} catch (error) {
Logger.error(`[CbzStreamZipComicBookExtractor] Failed to extract file "${file}" to "${outputFilePath}"`, error)
return false
}
}
close() {
this.archive?.close()
Logger.debug(`[CbzStreamZipComicBookExtractor] Closed comic book "${this.comicPath}"`)
}
}
function createComicBookExtractor(comicPath) {
const ext = Path.extname(comicPath).toLowerCase()
if (ext === '.cbr') {
return new CbrComicBookExtractor(comicPath)
} else if (ext === '.cbz') {
return new CbzStreamZipComicBookExtractor(comicPath)
} else {
throw new Error(`Unsupported comic book format "${ext}"`)
}
}
module.exports = { createComicBookExtractor }

View file

@ -49,9 +49,7 @@ module.exports.AudioMimeType = {
WEBMA: 'audio/webm',
MKA: 'audio/x-matroska',
AWB: 'audio/amr-wb',
CAF: 'audio/x-caf'
CAF: 'audio/x-caf',
MPEG: 'audio/mpeg',
MPG: 'audio/mpeg'
}
module.exports.VideoMimeType = {
MP4: 'video/mp4'
}

View file

@ -55,7 +55,7 @@ async function extractCoverArt(filepath, outputpath) {
return new Promise((resolve) => {
/** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */
var ffmpeg = Ffmpeg(filepath)
ffmpeg.addOption(['-map 0:v', '-frames:v 1'])
ffmpeg.addOption(['-map 0:v:0', '-frames:v 1'])
ffmpeg.output(outputpath)
ffmpeg.on('start', (cmd) => {
@ -299,6 +299,12 @@ async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataF
'-metadata:s:v',
'comment=Cover' // add comment metadata to cover image stream
])
const ext = Path.extname(coverFilePath).toLowerCase()
if (ext === '.webp') {
ffmpeg.outputOptions([
'-c:v mjpeg' // convert webp images to jpeg
])
}
} else {
ffmpeg.outputOptions([
'-map 0:v?' // retain video stream from input file if exists
@ -374,9 +380,8 @@ function getFFMetadataObject(libraryItem, audioFilesLength) {
copyright: metadata.publisher,
publisher: metadata.publisher, // mp3 only
TRACKTOTAL: `${audioFilesLength}`, // mp3 only
grouping: metadata.series?.map((s) => s.name + (s.sequence ? ` #${s.sequence}` : '')).join(', ')
grouping: metadata.series?.map((s) => s.name + (s.sequence ? ` #${s.sequence}` : '')).join('; ')
}
Object.keys(ffmetadata).forEach((key) => {
if (!ffmetadata[key]) {
delete ffmetadata[key]

View file

@ -131,24 +131,21 @@ async function readTextFile(path) {
}
module.exports.readTextFile = readTextFile
function bytesPretty(bytes, decimals = 0) {
if (bytes === 0) {
return '0 Bytes'
}
const k = 1000
var dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
if (i > 2 && dm === 0) dm = 1
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
module.exports.bytesPretty = bytesPretty
/**
* @typedef FilePathItem
* @property {string} name - file name e.g. "audiofile.m4b"
* @property {string} path - fullpath excluding folder e.g. "Author/Book/audiofile.m4b"
* @property {string} reldirpath - path excluding file name e.g. "Author/Book"
* @property {string} fullpath - full path e.g. "/audiobooks/Author/Book/audiofile.m4b"
* @property {string} extension - file extension e.g. ".m4b"
* @property {number} deep - depth of file in directory (0 is file in folder root)
*/
/**
* Get array of files inside dir
* @param {string} path
* @param {string} [relPathToReplace]
* @returns {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]}
* @returns {FilePathItem[]}
*/
async function recurseFiles(path, relPathToReplace = null) {
path = filePathToPOSIX(path)
@ -226,7 +223,6 @@ async function recurseFiles(path, relPathToReplace = null) {
return {
name: item.name,
path: item.fullname.replace(relPathToReplace, ''),
dirpath: item.path,
reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''),
fullpath: item.fullname,
extension: item.extension,
@ -241,6 +237,26 @@ async function recurseFiles(path, relPathToReplace = null) {
}
module.exports.recurseFiles = recurseFiles
/**
*
* @param {import('../Watcher').PendingFileUpdate} fileUpdate
* @returns {FilePathItem}
*/
module.exports.getFilePathItemFromFileUpdate = (fileUpdate) => {
let relPath = fileUpdate.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
const dirname = Path.dirname(relPath)
return {
name: Path.basename(relPath),
path: relPath,
reldirpath: dirname === '.' ? '' : dirname,
fullpath: fileUpdate.path,
extension: Path.extname(relPath),
deep: relPath.split('/').length - 1
}
}
/**
* Download file from web to local file system
* Uses SSRF filter to prevent internal URLs
@ -261,8 +277,8 @@ module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => {
'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)'
},
timeout: 30000,
httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(url),
httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(url)
httpAgent: global.DisableSsrfRequestFilter?.(url) ? null : ssrfFilter(url),
httpsAgent: global.DisableSsrfRequestFilter?.(url) ? null : ssrfFilter(url)
})
.then((response) => {
// Validate content type

View file

@ -1,4 +1,5 @@
const Logger = require('../../Logger')
const parseSeriesString = require('../parsers/parseSeriesString')
function parseJsonMetadataText(text) {
try {
@ -19,39 +20,25 @@ function parseJsonMetadataText(text) {
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 #
// 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
}
})
abmetadataData.series = [...new Set(abmetadataData.series.map((t) => t?.trim()).filter((t) => t))]
abmetadataData.series = abmetadataData.series.map((series) => parseSeriesString.parse(series))
}
// clean tags & remove dupes
if (abmetadataData.tags?.length) {
abmetadataData.tags = [...new Set(abmetadataData.tags.map(t => t?.trim()).filter(t => t))]
abmetadataData.tags = [...new Set(abmetadataData.tags.map((t) => t?.trim()).filter((t) => t))]
}
if (abmetadataData.chapters?.length) {
abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title)
}
// clean remove dupes
if (abmetadataData.authors?.length) {
abmetadataData.authors = [...new Set(abmetadataData.authors.map(t => t?.trim()).filter(t => t))]
abmetadataData.authors = [...new Set(abmetadataData.authors.map((t) => t?.trim()).filter((t) => t))]
}
if (abmetadataData.narrators?.length) {
abmetadataData.narrators = [...new Set(abmetadataData.narrators.map(t => t?.trim()).filter(t => t))]
abmetadataData.narrators = [...new Set(abmetadataData.narrators.map((t) => t?.trim()).filter((t) => t))]
}
if (abmetadataData.genres?.length) {
abmetadataData.genres = [...new Set(abmetadataData.genres.map(t => t?.trim()).filter(t => t))]
abmetadataData.genres = [...new Set(abmetadataData.genres.map((t) => t?.trim()).filter((t) => t))]
}
return abmetadataData
} catch (error) {

View file

@ -1,8 +1,7 @@
const globals = {
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpg', 'mpeg'],
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
SupportedVideoTypes: ['mp4'],
TextFileTypes: ['txt', 'nfo'],
MetadataFileTypes: ['opf', 'abs', 'xml', 'json']
}

View file

@ -194,29 +194,6 @@ module.exports.getTitlePrefixAtEnd = (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

View file

@ -129,7 +129,7 @@ module.exports = {
* @param {*} payload
* @param {string} seriesId
* @param {import('../models/User')} user
* @param {import('../objects/Library')} library
* @param {import('../models/Library')} library
* @returns {Object[]}
*/
async handleCollapseSubseries(payload, seriesId, user, library) {

View file

@ -1258,7 +1258,7 @@ async function handleOldLibraryItems(ctx) {
*/
async function handleOldLibraries(ctx) {
const oldLibraries = await oldDbFiles.loadOldData('libraries')
const libraries = await ctx.models.library.getAllOldLibraries()
const libraries = await ctx.models.library.getAllWithFolders()
let librariesUpdated = 0
for (const library of libraries) {
@ -1268,13 +1268,17 @@ async function handleOldLibraries(ctx) {
return false
}
const folderPaths = ol.folders?.map((f) => f.fullPath) || []
return folderPaths.join(',') === library.folderPaths.join(',')
return folderPaths.join(',') === library.libraryFolders.map((f) => f.path).join(',')
})
if (matchingOldLibrary) {
library.oldLibraryId = matchingOldLibrary.id
const newExtraData = library.extraData || {}
newExtraData.oldLibraryId = matchingOldLibrary.id
library.extraData = newExtraData
library.changed('extraData', true)
oldDbIdMap.libraries[library.oldLibraryId] = library.id
await ctx.models.library.updateFromOld(library)
await library.save()
librariesUpdated++
}
}

View file

@ -7,6 +7,7 @@ module.exports.notificationData = {
requiresLibrary: true,
libraryMediaType: 'podcast',
description: 'Triggered when a podcast episode is auto-downloaded',
descriptionKey: 'NotificationOnEpisodeDownloadedDescription',
variables: ['libraryItemId', 'libraryId', 'podcastTitle', 'podcastAuthor', 'podcastDescription', 'podcastGenres', 'episodeTitle', 'episodeSubtitle', 'episodeDescription', 'libraryName', 'episodeId', 'mediaTags'],
defaults: {
title: 'New {{podcastTitle}} Episode!',
@ -31,6 +32,7 @@ module.exports.notificationData = {
name: 'onBackupCompleted',
requiresLibrary: false,
description: 'Triggered when a backup is completed',
descriptionKey: 'NotificationOnBackupCompletedDescription',
variables: ['completionTime', 'backupPath', 'backupSize', 'backupCount', 'removedOldest'],
defaults: {
title: 'Backup Completed',
@ -48,6 +50,7 @@ module.exports.notificationData = {
name: 'onBackupFailed',
requiresLibrary: false,
description: 'Triggered when a backup fails',
descriptionKey: 'NotificationOnBackupFailedDescription',
variables: ['errorMsg'],
defaults: {
title: 'Backup Failed',
@ -61,6 +64,7 @@ module.exports.notificationData = {
name: 'onTest',
requiresLibrary: false,
description: 'Event for testing the notification system',
descriptionKey: 'NotificationOnTestDescription',
variables: ['version'],
defaults: {
title: 'Test Notification on Abs {{version}}',

View file

@ -1,28 +1,9 @@
const Path = require('path')
const globals = require('../globals')
const fs = require('../../libs/fsExtra')
const Logger = require('../../Logger')
const Archive = require('../../libs/libarchive/archive')
const { xmlToJSON } = require('../index')
const parseComicInfoMetadata = require('./parseComicInfoMetadata')
/**
*
* @param {string} filepath
* @returns {Promise<Buffer>}
*/
async function getComicFileBuffer(filepath) {
if (!(await fs.pathExists(filepath))) {
Logger.error(`Comic path does not exist "${filepath}"`)
return null
}
try {
return fs.readFile(filepath)
} catch (error) {
Logger.error(`Failed to read comic at "${filepath}"`, error)
return null
}
}
const globals = require('../globals')
const { xmlToJSON } = require('../index')
const { createComicBookExtractor } = require('../comicBookExtractors.js')
/**
* Extract cover image from comic return true if success
@ -33,26 +14,17 @@ async function getComicFileBuffer(filepath) {
* @returns {Promise<boolean>}
*/
async function extractCoverImage(comicPath, comicImageFilepath, outputCoverPath) {
const comicFileBuffer = await getComicFileBuffer(comicPath)
if (!comicFileBuffer) return null
const archive = await Archive.open(comicFileBuffer)
const fileEntry = await archive.extractSingleFile(comicImageFilepath)
if (!fileEntry?.fileData) {
Logger.error(`[parseComicMetadata] Invalid file entry data for comicPath "${comicPath}"/${comicImageFilepath}`)
return false
}
let archive = null
try {
await fs.writeFile(outputCoverPath, fileEntry.fileData)
return true
archive = createComicBookExtractor(comicPath)
await archive.open()
return await archive.extractToFile(comicImageFilepath, outputCoverPath)
} catch (error) {
Logger.error(`[parseComicMetadata] Failed to extract image from comicPath "${comicPath}"`, error)
Logger.error(`[parseComicMetadata] Failed to extract image "${comicImageFilepath}" from comicPath "${comicPath}" into "${outputCoverPath}"`, error)
return false
} finally {
// Ensure we free the memory
archive.close()
archive?.close()
}
}
module.exports.extractCoverImage = extractCoverImage
@ -65,51 +37,55 @@ module.exports.extractCoverImage = extractCoverImage
*/
async function parse(ebookFile) {
const comicPath = ebookFile.metadata.path
Logger.debug(`Parsing metadata from comic at "${comicPath}"`)
Logger.debug(`[parseComicMetadata] Parsing comic metadata at "${comicPath}"`)
let archive = null
try {
archive = createComicBookExtractor(comicPath)
await archive.open()
const comicFileBuffer = await getComicFileBuffer(comicPath)
if (!comicFileBuffer) return null
const filePaths = await archive.getFilePaths()
const archive = await Archive.open(comicFileBuffer)
const fileObjects = await archive.getFilesArray()
fileObjects.sort((a, b) => {
return a.file.name.localeCompare(b.file.name, undefined, {
numeric: true,
sensitivity: 'base'
// Sort the file paths in a natural order to get the first image
filePaths.sort((a, b) => {
return a.localeCompare(b, undefined, {
numeric: true,
sensitivity: 'base'
})
})
})
let metadata = null
const comicInfo = fileObjects.find((fo) => fo.file.name === 'ComicInfo.xml')
if (comicInfo) {
const comicInfoEntry = await comicInfo.file.extract()
if (comicInfoEntry?.fileData) {
const comicInfoStr = new TextDecoder().decode(comicInfoEntry.fileData)
const comicInfoJson = await xmlToJSON(comicInfoStr)
if (comicInfoJson) {
metadata = parseComicInfoMetadata.parse(comicInfoJson)
let metadata = null
const comicInfoPath = filePaths.find((filePath) => filePath === 'ComicInfo.xml')
if (comicInfoPath) {
const comicInfoData = await archive.extractToBuffer(comicInfoPath)
if (comicInfoData) {
const comicInfoStr = new TextDecoder().decode(comicInfoData)
const comicInfoJson = await xmlToJSON(comicInfoStr)
if (comicInfoJson) {
metadata = parseComicInfoMetadata.parse(comicInfoJson)
}
}
}
const payload = {
path: comicPath,
ebookFormat: ebookFile.ebookFormat,
metadata
}
const firstImagePath = filePaths.find((filePath) => globals.SupportedImageTypes.includes(Path.extname(filePath).toLowerCase().slice(1)))
if (firstImagePath) {
payload.ebookCoverPath = firstImagePath
} else {
Logger.warn(`[parseComicMetadata] Cover image not found in comic at "${comicPath}"`)
}
return payload
} catch (error) {
Logger.error(`[parseComicMetadata] Failed to parse comic metadata at "${comicPath}"`, error)
return null
} finally {
// Ensure we free the memory
archive?.close()
}
const payload = {
path: comicPath,
ebookFormat: ebookFile.ebookFormat,
metadata
}
const firstImage = fileObjects.find((fo) => globals.SupportedImageTypes.includes(Path.extname(fo.file.name).toLowerCase().slice(1)))
if (firstImage?.file?._path) {
payload.ebookCoverPath = firstImage.file._path
} else {
Logger.warn(`Cover image not found in comic at "${comicPath}"`)
}
// Ensure we close the archive to free memory
archive.close()
return payload
}
module.exports.parse = parse

View file

@ -42,15 +42,22 @@ module.exports.parse = (nameString) => {
var splitNames = []
// Example &LF: Friedman, Milton & Friedman, Rose
if (nameString.includes('&')) {
nameString.split('&').forEach((asa) => splitNames = splitNames.concat(asa.split(',')))
nameString.split('&').forEach((asa) => (splitNames = splitNames.concat(asa.split(','))))
} else if (nameString.includes(' and ')) {
nameString.split(' and ').forEach((asa) => splitNames = splitNames.concat(asa.split(',')))
nameString.split(' and ').forEach((asa) => (splitNames = splitNames.concat(asa.split(','))))
} else if (nameString.includes(';')) {
nameString.split(';').forEach((asa) => splitNames = splitNames.concat(asa.split(',')))
nameString.split(';').forEach((asa) => (splitNames = splitNames.concat(asa.split(','))))
} else {
splitNames = nameString.split(',')
}
if (splitNames.length) splitNames = splitNames.map(a => a.trim())
if (splitNames.length) splitNames = splitNames.map((a) => a.trim())
// If names are in ChineseJapanese and Korean languages, return as is.
if (/[\u4e00-\u9fff\u3040-\u30ff\u31f0-\u31ff]/.test(splitNames[0])) {
return {
names: splitNames
}
}
var names = []
@ -84,21 +91,12 @@ module.exports.parse = (nameString) => {
}
// Filter out names that have no first and last
names = names.filter(n => n.first_name || n.last_name)
names = names.filter((n) => n.first_name || n.last_name)
// Set name strings and remove duplicates
const namesArray = [...new Set(names.map(a => a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name))]
const namesArray = [...new Set(names.map((a) => (a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name)))]
return {
names: namesArray // Array of first last
}
}
module.exports.checkNamesAreEqual = (name1, name2) => {
if (!name1 || !name2) return false
// e.g. John H. Smith will be equal to John H Smith
name1 = String(name1).toLowerCase().trim().replace(/\./g, '')
name2 = String(name2).toLowerCase().trim().replace(/\./g, '')
return name1 === name2
}

View file

@ -0,0 +1,27 @@
/**
* Parse a series string into a name and sequence
*
* @example
* Name #1a => { name: 'Name', sequence: '1a' }
* Name #1 => { name: 'Name', sequence: '1' }
*
* @param {string} seriesString
* @returns {{name: string, sequence: string}|null}
*/
module.exports.parse = (seriesString) => {
if (!seriesString || typeof seriesString !== 'string') return null
let sequence = null
let name = seriesString
// 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 = seriesString.match(/ #([^#\s]+)$/) // Pull out sequence #
if (matchResults && matchResults.length && matchResults.length > 1) {
sequence = matchResults[1] // Group 1
name = seriesString.replace(matchResults[0], '')
}
return {
name,
sequence
}
}

View file

@ -59,8 +59,8 @@ function extractPodcastMetadata(channel) {
if (channel['description']) {
const rawDescription = extractFirstArrayItem(channel, 'description') || ''
metadata.description = htmlSanitizer.sanitize(rawDescription)
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
metadata.description = htmlSanitizer.sanitize(rawDescription.trim())
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
}
const arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link', 'itunes:type']
@ -103,8 +103,8 @@ function extractEpisodeData(item) {
// Supposed to be the plaintext description but not always followed
if (item['description']) {
const rawDescription = extractFirstArrayItem(item, 'description') || ''
if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription)
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription.trim())
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
}
if (item['pubDate']) {
@ -228,6 +228,13 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal
module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`)
let userAgent = 'audiobookshelf (+https://audiobookshelf.org; like iTMS)'
// Workaround for CBC RSS feeds rejecting our user agent string
// See: https://github.com/advplyr/audiobookshelf/issues/3322
if (feedUrl.startsWith('https://www.cbc.ca')) {
userAgent = 'audiobookshelf (+https://audiobookshelf.org; like iTMS) - CBC'
}
return axios({
url: feedUrl,
method: 'GET',
@ -235,10 +242,10 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
responseType: 'arraybuffer',
headers: {
Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8',
'User-Agent': 'audiobookshelf (+https://audiobookshelf.org; like iTMS)'
'User-Agent': userAgent
},
httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl),
httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl)
httpAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl),
httpsAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl)
})
.then(async (data) => {
// Adding support for ios-8859-1 encoded RSS feeds.

View file

@ -189,6 +189,7 @@ function parseTags(format, verbose) {
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', 'part'),
file_tag_grouping: tryGrabTags(format, 'grouping', 'grp1'),
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

@ -5,7 +5,7 @@ const fsExtra = require('../../libs/fsExtra')
module.exports = {
/**
*
*
* @param {number} year YYYY
* @returns {Promise<PlaybackSession[]>}
*/
@ -22,7 +22,7 @@ module.exports = {
},
/**
*
*
* @param {number} year YYYY
* @returns {Promise<number>}
*/
@ -39,7 +39,7 @@ module.exports = {
},
/**
*
*
* @param {number} year YYYY
* @returns {Promise<import('../../models/Book')[]>}
*/
@ -63,7 +63,7 @@ module.exports = {
},
/**
*
*
* @param {number} year YYYY
*/
async getStatsForYear(year) {
@ -75,7 +75,7 @@ module.exports = {
for (const book of booksAdded) {
// Grab first 25 that have a cover
if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(book.coverPath)) {
if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && (await fsExtra.pathExists(book.coverPath))) {
booksWithCovers.push(book.libraryItem.id)
}
if (book.duration && !isNaN(book.duration)) {
@ -95,45 +95,54 @@ module.exports = {
const listeningSessions = await this.getListeningSessionsForYear(year)
let totalListeningTime = 0
for (const ls of listeningSessions) {
totalListeningTime += (ls.timeListening || 0)
totalListeningTime += ls.timeListening || 0
const authors = ls.mediaMetadata.authors || []
const authors = ls.mediaMetadata?.authors || []
authors.forEach((au) => {
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
authorListeningMap[au.name] += (ls.timeListening || 0)
authorListeningMap[au.name] += ls.timeListening || 0
})
const narrators = ls.mediaMetadata.narrators || []
const narrators = ls.mediaMetadata?.narrators || []
narrators.forEach((narrator) => {
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
narratorListeningMap[narrator] += (ls.timeListening || 0)
narratorListeningMap[narrator] += ls.timeListening || 0
})
// Filter out bad genres like "audiobook" and "audio book"
const genres = (ls.mediaMetadata.genres || []).filter(g => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
genres.forEach((genre) => {
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
genreListeningMap[genre] += (ls.timeListening || 0)
genreListeningMap[genre] += ls.timeListening || 0
})
}
let topAuthors = null
topAuthors = Object.keys(authorListeningMap).map(authorName => ({
name: authorName,
time: Math.round(authorListeningMap[authorName])
})).sort((a, b) => b.time - a.time).slice(0, 3)
topAuthors = Object.keys(authorListeningMap)
.map((authorName) => ({
name: authorName,
time: Math.round(authorListeningMap[authorName])
}))
.sort((a, b) => b.time - a.time)
.slice(0, 3)
let topNarrators = null
topNarrators = Object.keys(narratorListeningMap).map(narratorName => ({
name: narratorName,
time: Math.round(narratorListeningMap[narratorName])
})).sort((a, b) => b.time - a.time).slice(0, 3)
topNarrators = Object.keys(narratorListeningMap)
.map((narratorName) => ({
name: narratorName,
time: Math.round(narratorListeningMap[narratorName])
}))
.sort((a, b) => b.time - a.time)
.slice(0, 3)
let topGenres = null
topGenres = Object.keys(genreListeningMap).map(genre => ({
genre,
time: Math.round(genreListeningMap[genre])
})).sort((a, b) => b.time - a.time).slice(0, 3)
topGenres = Object.keys(genreListeningMap)
.map((genre) => ({
genre,
time: Math.round(genreListeningMap[genre])
}))
.sort((a, b) => b.time - a.time)
.slice(0, 3)
// Stats for total books, size and duration for everything added this year or earlier
const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, {

View file

@ -54,13 +54,13 @@ module.exports = {
* Search authors
*
* @param {string} libraryId
* @param {string} query
* @param {Database.TextQuery} query
* @param {number} limit
* @param {number} offset
* @returns {Promise<Object[]>} oldAuthor with numBooks
*/
async search(libraryId, query, limit, offset) {
const matchAuthor = Database.matchExpression('name', query)
const matchAuthor = query.matchExpression('name')
const authors = await Database.authorModel.findAll({
where: {
[Sequelize.Op.and]: [Sequelize.literal(matchAuthor), { libraryId }]
@ -73,8 +73,7 @@ module.exports = {
})
const authorMatches = []
for (const author of authors) {
const oldAuthor = author.getOldAuthor().toJSON()
oldAuthor.numBooks = author.dataValues.numBooks
const oldAuthor = author.toOldJSONExpanded(author.dataValues.numBooks)
authorMatches.push(oldAuthor)
}
return authorMatches

View file

@ -15,46 +15,46 @@ module.exports = {
/**
* Get library items using filter and sort
* @param {import('../../objects/Library')} library
* @param {string} libraryId
* @param {import('../../models/User')} user
* @param {object} options
* @returns {object} { libraryItems:LibraryItem[], count:number }
*/
async getFilteredLibraryItems(library, user, options) {
async getFilteredLibraryItems(libraryId, user, options) {
const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include, mediaType } = options
let filterValue = null
let filterGroup = null
if (filterBy) {
const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'publishers', 'missing', 'languages', 'tracks', 'ebooks']
const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'publishers', 'publishedDecades', 'missing', 'languages', 'tracks', 'ebooks']
const group = searchGroups.find((_group) => filterBy.startsWith(_group + '.'))
filterGroup = group || filterBy
filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null
}
if (mediaType === 'book') {
return libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset)
return libraryItemsBookFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset)
} else {
return libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset)
return libraryItemsPodcastFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset)
}
},
/**
* Get library items for continue listening & continue reading shelves
* @param {import('../../objects/Library')} library
* @param {import('../../models/Library')} library
* @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {Promise<{ items:import('../../models/LibraryItem')[], count:number }>}
*/
async getMediaItemsInProgress(library, user, include, limit) {
if (library.mediaType === 'book') {
if (library.isBook) {
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'in-progress', 'progress', true, false, include, limit, 0, true)
return {
items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
}
if (li.mediaItemShare) {
oldLibraryItem.mediaItemShare = li.mediaItemShare
@ -78,20 +78,20 @@ module.exports = {
/**
* Get library items for most recently added shelf
* @param {import('../../objects/Library')} library
* @param {import('../../models/Library')} library
* @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object} { libraryItems:LibraryItem[], count:number }
*/
async getLibraryItemsMostRecentlyAdded(library, user, include, limit) {
if (library.mediaType === 'book') {
if (library.isBook) {
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, false, include, limit, 0)
return {
libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
}
if (li.size && !oldLibraryItem.media.size) {
oldLibraryItem.media.size = li.size
@ -109,7 +109,7 @@ module.exports = {
libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
}
if (li.size && !oldLibraryItem.media.size) {
oldLibraryItem.media.size = li.size
@ -126,7 +126,7 @@ module.exports = {
/**
* Get library items for continue series shelf
* @param {import('../../objects/Library')} library
* @param {import('../../models/Library')} library
* @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
@ -138,7 +138,7 @@ module.exports = {
libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
}
if (li.series) {
oldLibraryItem.media.metadata.series = li.series
@ -154,20 +154,21 @@ module.exports = {
/**
* Get library items or podcast episodes for the "Listen Again" and "Read Again" shelf
* @param {import('../../objects/Library')} library
*
* @param {import('../../models/Library')} library
* @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object} { items:object[], count:number }
* @returns {Promise<{ items:oldLibraryItem[], count:number }>}
*/
async getMediaFinished(library, user, include, limit) {
if (library.mediaType === 'book') {
if (library.isBook) {
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'finished', 'progress', true, false, include, limit, 0)
return {
items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
}
if (li.mediaItemShare) {
oldLibraryItem.mediaItemShare = li.mediaItemShare
@ -191,11 +192,11 @@ module.exports = {
/**
* Get series for recent series shelf
* @param {import('../../objects/Library')} library
* @param {import('../../models/Library')} library
* @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {{ series:import('../../objects/entities/Series')[], count:number}}
* @returns {{ series:any[], count:number}}
*/
async getSeriesMostRecentlyAdded(library, user, include, limit) {
if (!library.isBook) return { series: [], count: 0 }
@ -275,10 +276,10 @@ module.exports = {
const allOldSeries = []
for (const s of series) {
const oldSeries = s.getOldSeries().toJSON()
const oldSeries = s.toOldJSON()
if (s.feeds?.length) {
oldSeries.rssFeed = Database.feedModel.getOldFeed(s.feeds[0]).toJSONMinified()
oldSeries.rssFeed = s.feeds[0].toOldJSONMinified()
}
// TODO: Sort books by sequence in query
@ -316,10 +317,11 @@ module.exports = {
/**
* Get most recently created authors for "Newest Authors" shelf
* Author must be linked to at least 1 book
* @param {oldLibrary} library
*
* @param {import('../../models/Library')} library
* @param {import('../../models/User')} user
* @param {number} limit
* @returns {object} { authors:oldAuthor[], count:number }
* @returns {Promise<{ authors:oldAuthor[], count:number }>}
*/
async getNewestAuthors(library, user, limit) {
if (library.mediaType !== 'book') return { authors: [], count: 0 }
@ -351,7 +353,7 @@ module.exports = {
return {
authors: authors.map((au) => {
const numBooks = au.books.length || 0
return au.getOldAuthor().toJSONExpanded(numBooks)
return au.toOldJSONExpanded(numBooks)
}),
count
}
@ -359,11 +361,11 @@ module.exports = {
/**
* Get book library items for the "Discover" shelf
* @param {oldLibrary} library
* @param {import('../../models/Library')} library
* @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object} {libraryItems:oldLibraryItem[], count:number}
* @returns {Promise<{libraryItems:oldLibraryItem[], count:number}>}
*/
async getLibraryItemsToDiscover(library, user, include, limit) {
if (library.mediaType !== 'book') return { libraryItems: [], count: 0 }
@ -373,7 +375,7 @@ module.exports = {
libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
}
if (li.mediaItemShare) {
oldLibraryItem.mediaItemShare = li.mediaItemShare
@ -386,10 +388,10 @@ module.exports = {
/**
* Get podcast episodes most recently added
* @param {oldLibrary} library
* @param {import('../../models/Library')} library
* @param {import('../../models/User')} user
* @param {number} limit
* @returns {object} {libraryItems:oldLibraryItem[], count:number}
* @returns {Promise<{libraryItems:oldLibraryItem[], count:number}>}
*/
async getNewestPodcastEpisodes(library, user, limit) {
if (library.mediaType !== 'podcast') return { libraryItems: [], count: 0 }
@ -407,11 +409,11 @@ module.exports = {
/**
* Get library items for an author, optional use user permissions
* @param {oldAuthor} author
* @param {import('../../models/Author')} author
* @param {import('../../models/User')} user
* @param {number} limit
* @param {number} offset
* @returns {Promise<object>} { libraryItems:LibraryItem[], count:number }
* @returns {Promise<{ libraryItems:import('../../objects/LibraryItem')[], count:number }>}
*/
async getLibraryItemsForAuthor(author, user, limit, offset) {
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(author.libraryId, user, 'authors', author.id, 'addedAt', true, false, [], limit, offset)
@ -424,7 +426,7 @@ module.exports = {
/**
* Get book library items in a collection
* @param {oldCollection} collection
* @returns {Promise<LibraryItem[]>}
* @returns {Promise<import('../../models/LibraryItem')[]>}
*/
getLibraryItemsForCollection(collection) {
return libraryItemsBookFilters.getLibraryItemsForCollection(collection)
@ -456,10 +458,66 @@ module.exports = {
narrators: new Set(),
languages: new Set(),
publishers: new Set(),
publishedDecades: new Set(),
bookCount: 0, // How many books returned from database query
authorCount: 0, // How many authors returned from database query
seriesCount: 0, // How many series returned from database query
podcastCount: 0, // How many podcasts returned from database query
numIssues: 0
}
const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0
if (mediaType === 'podcast') {
// Check how many podcasts are in library to determine if we need to load all of the data
// This is done to handle the edge case of podcasts having been deleted and not having
// an updatedAt timestamp to trigger a reload of the filter data
const podcastCountFromDatabase = await Database.podcastModel.count({
include: {
model: Database.libraryItemModel,
attributes: [],
where: {
libraryId: libraryId
}
}
})
// To reduce the cold-start load time, first check if any podcasts
// have an "updatedAt" timestamp since the last time the filter
// data was loaded. If so, we can skip loading all of the data.
// Because many items could change, just check the count of items instead
// of actually loading the data twice
const changedPodcasts = await Database.podcastModel.count({
include: {
model: Database.libraryItemModel,
attributes: [],
where: {
libraryId: libraryId,
updatedAt: {
[Sequelize.Op.gt]: new Date(lastLoadedAt)
}
}
},
where: {
updatedAt: {
[Sequelize.Op.gt]: new Date(lastLoadedAt)
}
},
limit: 1
})
if (changedPodcasts === 0) {
// If nothing has changed, check if the number of podcasts in
// library is still the same as prior check before updating cache creation time
if (podcastCountFromDatabase === Database.libraryFilterData[libraryId]?.podcastCount) {
Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`)
Database.libraryFilterData[libraryId].loadedAt = Date.now()
return cachedFilterData
}
}
// Something has changed in the podcasts table, so reload all of the filter data for library
const podcasts = await Database.podcastModel.findAll({
include: {
model: Database.libraryItemModel,
@ -481,7 +539,93 @@ module.exports = {
data.languages.add(podcast.language)
}
}
// Set podcast count for later comparison
data.podcastCount = podcastCountFromDatabase
} else {
const bookCountFromDatabase = await Database.bookModel.count({
include: {
model: Database.libraryItemModel,
attributes: [],
where: {
libraryId: libraryId
}
}
})
const seriesCountFromDatabase = await Database.seriesModel.count({
where: {
libraryId: libraryId
}
})
const authorCountFromDatabase = await Database.authorModel.count({
where: {
libraryId: libraryId
}
})
// To reduce the cold-start load time, first check if any library items, series,
// or authors have an "updatedAt" timestamp since the last time the filter
// data was loaded. If so, we can skip loading all of the data.
// Because many items could change, just check the count of items instead
// of actually loading the data twice
const changedBooks = await Database.bookModel.count({
include: {
model: Database.libraryItemModel,
attributes: [],
where: {
libraryId: libraryId,
updatedAt: {
[Sequelize.Op.gt]: new Date(lastLoadedAt)
}
}
},
where: {
updatedAt: {
[Sequelize.Op.gt]: new Date(lastLoadedAt)
}
},
limit: 1
})
const changedSeries = await Database.seriesModel.count({
where: {
libraryId: libraryId,
updatedAt: {
[Sequelize.Op.gt]: new Date(lastLoadedAt)
}
},
limit: 1
})
const changedAuthors = await Database.authorModel.count({
where: {
libraryId: libraryId,
updatedAt: {
[Sequelize.Op.gt]: new Date(lastLoadedAt)
}
},
limit: 1
})
if (changedBooks + changedSeries + changedAuthors === 0) {
// If nothing has changed, check if the number of authors, series, and books
// matches the prior check before updating cache creation time
if (bookCountFromDatabase === Database.libraryFilterData[libraryId]?.bookCount && seriesCountFromDatabase === Database.libraryFilterData[libraryId]?.seriesCount && authorCountFromDatabase === Database.libraryFilterData[libraryId].authorCount) {
Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`)
Database.libraryFilterData[libraryId].loadedAt = Date.now()
return cachedFilterData
}
}
// Store the counts for later comparison
data.bookCount = bookCountFromDatabase
data.seriesCount = seriesCountFromDatabase
data.authorCount = authorCountFromDatabase
// Something has changed in one of the tables, so reload all of the filter data for library
const books = await Database.bookModel.findAll({
include: {
model: Database.libraryItemModel,
@ -490,7 +634,7 @@ module.exports = {
libraryId: libraryId
}
},
attributes: ['tags', 'genres', 'publisher', 'narrators', 'language']
attributes: ['tags', 'genres', 'publisher', 'publishedYear', 'narrators', 'language']
})
for (const book of books) {
if (book.libraryItem.isMissing || book.libraryItem.isInvalid) data.numIssues++
@ -504,6 +648,11 @@ module.exports = {
book.narrators.forEach((narrator) => data.narrators.add(narrator))
}
if (book.publisher) data.publishers.add(book.publisher)
// Check if published year exists and is valid
if (book.publishedYear && !isNaN(book.publishedYear) && book.publishedYear > 0 && book.publishedYear < 3000) {
const decade = (Math.floor(book.publishedYear / 10) * 10).toString()
data.publishedDecades.add(decade)
}
if (book.language) data.languages.add(book.language)
}
@ -513,7 +662,7 @@ module.exports = {
},
attributes: ['id', 'name']
})
series.forEach((s) => data.series.push({ id: s.id, name: s.name }))
series.forEach((s) => data.series.push({ id: s.id, name: s.name || 'No Title' }))
const authors = await Database.authorModel.findAll({
where: {
@ -530,6 +679,7 @@ module.exports = {
data.series = naturalSort(data.series).asc((se) => se.name)
data.narrators = naturalSort([...data.narrators]).asc()
data.publishers = naturalSort([...data.publishers]).asc()
data.publishedDecades = naturalSort([...data.publishedDecades]).asc()
data.languages = naturalSort([...data.languages]).asc()
data.loadedAt = Date.now()
Database.libraryFilterData[libraryId] = data

View file

@ -173,16 +173,16 @@ module.exports = {
/**
* Search library items
* @param {import('../../models/User')} user
* @param {import('../../objects/Library')} oldLibrary
* @param {import('../../models/Library')} library
* @param {string} query
* @param {number} limit
* @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[], podcast:object[]}}
*/
search(user, oldLibrary, query, limit) {
if (oldLibrary.isBook) {
return libraryItemsBookFilters.search(user, oldLibrary, query, limit, 0)
search(user, library, query, limit) {
if (library.isBook) {
return libraryItemsBookFilters.search(user, library, query, limit, 0)
} else {
return libraryItemsPodcastFilters.search(user, oldLibrary, query, limit, 0)
return libraryItemsPodcastFilters.search(user, library, query, limit, 0)
}
},

View file

@ -219,7 +219,7 @@ module.exports = {
mediaWhere[key] = {
[Sequelize.Op.or]: [null, '']
}
} else if (['genres', 'tags', 'narrators'].includes(value)) {
} else if (['genres', 'tags', 'narrators', 'chapters'].includes(value)) {
mediaWhere[value] = {
[Sequelize.Op.or]: [null, Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col(value)), 0)]
}
@ -228,6 +228,12 @@ module.exports = {
} else if (value === 'series') {
mediaWhere['$series.id$'] = null
}
} else if (group === 'publishedDecades') {
const startYear = parseInt(value)
const endYear = parseInt(value, 10) + 9
mediaWhere = Sequelize.where(Sequelize.literal('CAST(`book`.`publishedYear` AS INTEGER)'), {
[Sequelize.Op.between]: [startYear, endYear]
})
}
return { mediaWhere, replacements }
@ -253,7 +259,7 @@ module.exports = {
} else if (sortBy === 'media.duration') {
return [['duration', dir]]
} else if (sortBy === 'media.metadata.publishedYear') {
return [['publishedYear', dir]]
return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]]
} else if (sortBy === 'media.metadata.authorNameLF') {
return [[Sequelize.literal('author_name COLLATE NOCASE'), dir]]
} else if (sortBy === 'media.metadata.authorName') {
@ -499,7 +505,6 @@ module.exports = {
}
let { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue)
let bookWhere = Array.isArray(mediaWhere) ? mediaWhere : [mediaWhere]
// User permissions
@ -610,8 +615,8 @@ module.exports = {
}
}
if (libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0]
if (bookExpanded.libraryItem.feeds?.length) {
libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0]
}
if (includeMediaItemShare) {
@ -636,7 +641,7 @@ module.exports = {
* 2. Has no books in progress
* 3. Has at least 1 unfinished book
* TODO: Reduce queries
* @param {import('../../objects/Library')} library
* @param {import('../../models/Library')} library
* @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
@ -761,8 +766,8 @@ module.exports = {
name: s.name,
sequence: s.bookSeries[bookIndex].sequence
}
if (libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0]
if (s.bookSeries[bookIndex].book.libraryItem.feeds?.length) {
libraryItem.rssFeed = s.bookSeries[bookIndex].book.libraryItem.feeds[0]
}
libraryItem.media = book
return libraryItem
@ -895,8 +900,8 @@ module.exports = {
delete book.libraryItem
libraryItem.media = book
if (libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0]
if (bookExpanded.libraryItem.feeds?.length) {
libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0]
}
return libraryItem
@ -911,7 +916,7 @@ module.exports = {
/**
* Get book library items in a collection
* @param {oldCollection} collection
* @returns {Promise<LibraryItem[]>}
* @returns {Promise<import('../../models/LibraryItem')[]>}
*/
async getLibraryItemsForCollection(collection) {
if (!collection?.books?.length) {
@ -954,31 +959,31 @@ module.exports = {
/**
* Get library items for series
* @param {import('../../objects/entities/Series')} oldSeries
* @param {import('../../models/Series')} series
* @param {import('../../models/User')} [user]
* @returns {Promise<import('../../objects/LibraryItem')[]>}
*/
async getLibraryItemsForSeries(oldSeries, user) {
const { libraryItems } = await this.getFilteredLibraryItems(oldSeries.libraryId, user, 'series', oldSeries.id, null, null, false, [], null, null)
async getLibraryItemsForSeries(series, user) {
const { libraryItems } = await this.getFilteredLibraryItems(series.libraryId, user, 'series', series.id, null, null, false, [], null, null)
return libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li))
},
/**
* Search books, authors, series
* @param {import('../../models/User')} user
* @param {import('../../objects/Library')} oldLibrary
* @param {import('../../models/Library')} library
* @param {string} query
* @param {number} limit
* @param {number} offset
* @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[]}}
*/
async search(user, oldLibrary, query, limit, offset) {
async search(user, library, query, limit, offset) {
const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)
const normalizedQuery = query
const textSearchQuery = await Database.createTextSearchQuery(query)
const matchTitle = Database.matchExpression('title', normalizedQuery)
const matchSubtitle = Database.matchExpression('subtitle', normalizedQuery)
const matchTitle = textSearchQuery.matchExpression('title')
const matchSubtitle = textSearchQuery.matchExpression('subtitle')
// Search title, subtitle, asin, isbn
const books = await Database.bookModel.findAll({
@ -1006,7 +1011,7 @@ module.exports = {
{
model: Database.libraryItemModel,
where: {
libraryId: oldLibrary.id
libraryId: library.id
}
},
{
@ -1041,13 +1046,13 @@ module.exports = {
})
}
const matchJsonValue = Database.matchExpression('json_each.value', normalizedQuery)
const matchJsonValue = textSearchQuery.matchExpression('json_each.value')
// Search narrators
const narratorMatches = []
const [narratorResults] = await Database.sequelize.query(`SELECT value, count(*) AS numBooks FROM books b, libraryItems li, json_each(b.narrators) WHERE json_valid(b.narrators) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
replacements: {
libraryId: oldLibrary.id,
libraryId: library.id,
limit,
offset
},
@ -1064,7 +1069,7 @@ module.exports = {
const tagMatches = []
const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.tags) WHERE json_valid(b.tags) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
replacements: {
libraryId: oldLibrary.id,
libraryId: library.id,
limit,
offset
},
@ -1081,7 +1086,7 @@ module.exports = {
const genreMatches = []
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.genres) WHERE json_valid(b.genres) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
replacements: {
libraryId: oldLibrary.id,
libraryId: library.id,
limit,
offset
},
@ -1095,13 +1100,13 @@ module.exports = {
}
// Search series
const matchName = Database.matchExpression('name', normalizedQuery)
const matchName = textSearchQuery.matchExpression('name')
const allSeries = await Database.seriesModel.findAll({
where: {
[Sequelize.Op.and]: [
Sequelize.literal(matchName),
{
libraryId: oldLibrary.id
libraryId: library.id
}
]
},
@ -1130,13 +1135,13 @@ module.exports = {
return Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSON()
})
seriesMatches.push({
series: series.getOldSeries().toJSON(),
series: series.toOldJSON(),
books
})
}
// Search authors
const authorMatches = await authorFilters.search(oldLibrary.id, normalizedQuery, limit, offset)
const authorMatches = await authorFilters.search(library.id, textSearchQuery, limit, offset)
return {
book: itemMatches,

View file

@ -180,8 +180,8 @@ module.exports = {
delete podcast.libraryItem
if (libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0]
if (podcastExpanded.libraryItem.feeds?.length) {
libraryItem.rssFeed = podcastExpanded.libraryItem.feeds[0]
}
if (podcast.numEpisodesIncomplete) {
libraryItem.numEpisodesIncomplete = podcast.numEpisodesIncomplete
@ -306,18 +306,19 @@ module.exports = {
/**
* Search podcasts
* @param {import('../../models/User')} user
* @param {import('../../objects/Library')} oldLibrary
* @param {import('../../models/Library')} library
* @param {string} query
* @param {number} limit
* @param {number} offset
* @returns {{podcast:object[], tags:object[]}}
*/
async search(user, oldLibrary, query, limit, offset) {
async search(user, library, query, limit, offset) {
const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
const normalizedQuery = query
const matchTitle = Database.matchExpression('title', normalizedQuery)
const matchAuthor = Database.matchExpression('author', normalizedQuery)
const textSearchQuery = await Database.createTextSearchQuery(query)
const matchTitle = textSearchQuery.matchExpression('title')
const matchAuthor = textSearchQuery.matchExpression('author')
// Search title, author, itunesId, itunesArtistId
const podcasts = await Database.podcastModel.findAll({
@ -345,7 +346,7 @@ module.exports = {
{
model: Database.libraryItemModel,
where: {
libraryId: oldLibrary.id
libraryId: library.id
}
}
],
@ -366,13 +367,13 @@ module.exports = {
})
}
const matchJsonValue = Database.matchExpression('json_each.value', normalizedQuery)
const matchJsonValue = textSearchQuery.matchExpression('json_each.value')
// Search tags
const tagMatches = []
const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.tags) WHERE json_valid(p.tags) AND ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
replacements: {
libraryId: oldLibrary.id,
libraryId: library.id,
limit,
offset
},
@ -389,7 +390,7 @@ module.exports = {
const genreMatches = []
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.genres) WHERE json_valid(p.genres) AND ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
replacements: {
libraryId: oldLibrary.id,
libraryId: library.id,
limit,
offset
},
@ -412,12 +413,12 @@ module.exports = {
/**
* Most recent podcast episodes not finished
* @param {import('../../models/User')} user
* @param {import('../../objects/Library')} oldLibrary
* @param {import('../../models/Library')} library
* @param {number} limit
* @param {number} offset
* @returns {Promise<object[]>}
*/
async getRecentEpisodes(user, oldLibrary, limit, offset) {
async getRecentEpisodes(user, library, limit, offset) {
const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
const episodes = await Database.podcastEpisodeModel.findAll({
@ -435,7 +436,7 @@ module.exports = {
include: {
model: Database.libraryItemModel,
where: {
libraryId: oldLibrary.id
libraryId: library.id
}
}
},

View file

@ -11,7 +11,7 @@ module.exports = {
/**
* Get series filtered and sorted
*
* @param {import('../../objects/Library')} library
* @param {import('../../models/Library')} library
* @param {import('../../models/User')} user
* @param {string} filterBy
* @param {string} sortBy
@ -73,15 +73,19 @@ module.exports = {
userPermissionBookWhere.replacements.filterValue = filterValue
} else if (filterGroup === 'progress') {
if (filterValue === 'not-finished') {
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'
userPermissionBookWhere.replacements.userId = user.id
} else if (filterValue === 'finished') {
const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'
const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'
seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0))
userPermissionBookWhere.replacements.userId = user.id
} else if (filterValue === 'not-started') {
const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)'
const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)'
seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0))
userPermissionBookWhere.replacements.userId = user.id
} else if (filterValue === 'in-progress') {
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0'
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0'
userPermissionBookWhere.replacements.userId = user.id
}
}
@ -171,14 +175,14 @@ module.exports = {
// Map series to old series
const allOldSeries = []
for (const s of series) {
const oldSeries = s.getOldSeries().toJSON()
const oldSeries = s.toOldJSON()
if (s.dataValues.totalDuration) {
oldSeries.totalDuration = s.dataValues.totalDuration
}
if (s.feeds?.length) {
oldSeries.rssFeed = Database.feedModel.getOldFeed(s.feeds[0]).toJSONMinified()
oldSeries.rssFeed = s.feeds[0].toOldJSONMinified()
}
// TODO: Sort books by sequence in query

View file

@ -127,20 +127,20 @@ module.exports = {
bookListeningMap[ls.displayTitle] += listeningSessionListeningTime
}
const authors = ls.mediaMetadata.authors || []
const authors = ls.mediaMetadata?.authors || []
authors.forEach((au) => {
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
authorListeningMap[au.name] += listeningSessionListeningTime
})
const narrators = ls.mediaMetadata.narrators || []
const narrators = ls.mediaMetadata?.narrators || []
narrators.forEach((narrator) => {
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
narratorListeningMap[narrator] += listeningSessionListeningTime
})
// Filter out bad genres like "audiobook" and "audio book"
const genres = (ls.mediaMetadata.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
genres.forEach((genre) => {
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
genreListeningMap[genre] += listeningSessionListeningTime

View file

@ -19,8 +19,7 @@ const parseNameString = require('./parsers/parseNameString')
function isMediaFile(mediaType, ext, audiobooksOnly = false) {
if (!ext) return false
const extclean = ext.slice(1).toLowerCase()
if (mediaType === 'podcast' || mediaType === 'music') return globals.SupportedAudioTypes.includes(extclean)
else if (mediaType === 'video') return globals.SupportedVideoTypes.includes(extclean)
if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean)
else if (audiobooksOnly) return globals.SupportedAudioTypes.includes(extclean)
return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean)
}
@ -34,123 +33,24 @@ function checkFilepathIsAudioFile(filepath) {
module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile
/**
* TODO: Function needs to be re-done
* @param {string} mediaType
* @param {string[]} paths array of relative file paths
* @returns {Record<string,string[]>} map of files grouped into potential libarary item dirs
*/
function groupFilesIntoLibraryItemPaths(mediaType, paths) {
// Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir
var nonMediaFilePaths = []
var pathsFiltered = paths.map(path => {
return path.startsWith('/') ? path.slice(1) : path
}).filter(path => {
let parsedPath = Path.parse(path)
// Is not in root dir OR is a book media file
if (parsedPath.dir) {
if (!isMediaFile(mediaType, parsedPath.ext, false)) { // Seperate out non-media files
nonMediaFilePaths.push(path)
return false
}
return true
} else if (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext, false)) { // (book media type supports single file audiobooks/ebooks in root dir)
return true
}
return false
})
// Step 2: Sort by least number of directories
pathsFiltered.sort((a, b) => {
var pathsA = Path.dirname(a).split('/').length
var pathsB = Path.dirname(b).split('/').length
return pathsA - pathsB
})
// Step 3: Group files in dirs
var itemGroup = {}
pathsFiltered.forEach((path) => {
var dirparts = Path.dirname(path).split('/').filter(p => !!p && p !== '.') // dirname returns . if no directory
var numparts = dirparts.length
var _path = ''
if (!numparts) {
// Media file in root
itemGroup[path] = path
} else {
// Iterate over directories in path
for (let i = 0; i < numparts; i++) {
var dirpart = dirparts.shift()
_path = Path.posix.join(_path, dirpart)
if (itemGroup[_path]) { // Directory already has files, add file
var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path))
itemGroup[_path].push(relpath)
return
} else if (!dirparts.length) { // This is the last directory, create group
itemGroup[_path] = [Path.basename(path)]
return
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))]
return
}
}
}
})
// Step 4: Add in non-media files if they fit into item group
if (nonMediaFilePaths.length) {
for (const nonMediaFilePath of nonMediaFilePaths) {
const pathDir = Path.dirname(nonMediaFilePath)
const filename = Path.basename(nonMediaFilePath)
const dirparts = pathDir.split('/')
const numparts = dirparts.length
let _path = ''
// Iterate over directories in path
for (let i = 0; i < numparts; i++) {
const dirpart = dirparts.shift()
_path = Path.posix.join(_path, dirpart)
if (itemGroup[_path]) { // Directory is a group
const relpath = Path.posix.join(dirparts.join('/'), filename)
itemGroup[_path].push(relpath)
} else if (!dirparts.length) {
itemGroup[_path] = [filename]
}
}
}
}
return itemGroup
}
module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
/**
* @param {string} mediaType
* @param {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} fileItems (see recurseFiles)
* @param {boolean} [audiobooksOnly=false]
* @param {string} mediaType
* @param {import('./fileUtils').FilePathItem[]} fileItems
* @param {boolean} [audiobooksOnly=false]
* @returns {Record<string,string[]>} map of files grouped into potential libarary item dirs
*/
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly = false) {
// Handle music where every audio file is a library item
if (mediaType === 'music') {
const audioFileGroup = {}
fileItems.filter(i => isMediaFile(mediaType, i.extension, audiobooksOnly)).forEach((item) => {
audioFileGroup[item.path] = item.path
})
return audioFileGroup
}
// Step 1: Filter out non-book-media files in root dir (with depth of 0)
const itemsFiltered = fileItems.filter(i => {
return i.deep > 0 || ((mediaType === 'book' || mediaType === 'video' || mediaType === 'music') && isMediaFile(mediaType, i.extension, audiobooksOnly))
const itemsFiltered = fileItems.filter((i) => {
return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension, audiobooksOnly))
})
// Step 2: Seperate media files and other files
// - Directories without a media file will not be included
/** @type {import('./fileUtils').FilePathItem[]} */
const mediaFileItems = []
/** @type {import('./fileUtils').FilePathItem[]} */
const otherFileItems = []
itemsFiltered.forEach(item => {
itemsFiltered.forEach((item) => {
if (isMediaFile(mediaType, item.extension, audiobooksOnly)) mediaFileItems.push(item)
else otherFileItems.push(item)
})
@ -158,7 +58,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly
// Step 3: Group audio files in library items
const libraryItemGroup = {}
mediaFileItems.forEach((item) => {
const dirparts = item.reldirpath.split('/').filter(p => !!p)
const dirparts = item.reldirpath.split('/').filter((p) => !!p)
const numparts = dirparts.length
let _path = ''
@ -171,14 +71,17 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly
const dirpart = dirparts.shift()
_path = Path.posix.join(_path, dirpart)
if (libraryItemGroup[_path]) { // Directory already has files, add file
if (libraryItemGroup[_path]) {
// Directory already has files, add file
const relpath = Path.posix.join(dirparts.join('/'), item.name)
libraryItemGroup[_path].push(relpath)
return
} else if (!dirparts.length) { // This is the last directory, create group
} else if (!dirparts.length) {
// This is the last directory, create group
libraryItemGroup[_path] = [item.name]
return
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
} else if (dirparts.length === 1 && /^(cd|dis[ck])\s*\d{1,3}$/i.test(dirparts[0])) {
// Next directory is the last and is a CD dir, create group
libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)]
return
}
@ -196,7 +99,8 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly
for (let i = 0; i < numparts; i++) {
const dirpart = dirparts.shift()
_path = Path.posix.join(_path, dirpart)
if (libraryItemGroup[_path]) { // Directory is audiobook group
if (libraryItemGroup[_path]) {
// Directory is audiobook group
const relpath = Path.posix.join(dirparts.join('/'), item.name)
libraryItemGroup[_path].push(relpath)
return
@ -209,33 +113,35 @@ module.exports.groupFileItemsIntoLibraryItemDirs = groupFileItemsIntoLibraryItem
/**
* Get LibraryFile from filepath
* @param {string} libraryItemPath
* @param {string[]} files
* @param {string} libraryItemPath
* @param {string[]} files
* @returns {import('../objects/files/LibraryFile')}
*/
function buildLibraryFile(libraryItemPath, files) {
return Promise.all(files.map(async (file) => {
const filePath = Path.posix.join(libraryItemPath, file)
const newLibraryFile = new LibraryFile()
await newLibraryFile.setDataFromPath(filePath, file)
return newLibraryFile
}))
return Promise.all(
files.map(async (file) => {
const filePath = Path.posix.join(libraryItemPath, file)
const newLibraryFile = new LibraryFile()
await newLibraryFile.setDataFromPath(filePath, file)
return newLibraryFile
})
)
}
module.exports.buildLibraryFile = buildLibraryFile
/**
* Get details parsed from filenames
*
* @param {string} relPath
* @param {boolean} parseSubtitle
*
* @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/
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)
@ -244,7 +150,6 @@ function getBookDataFromDir(relPath, parseSubtitle = false) {
var [folder, publishedYear] = getPublishedYear(folder)
var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null]
return {
title,
subtitle,
@ -260,8 +165,8 @@ module.exports.getBookDataFromDir = getBookDataFromDir
/**
* Extract narrator from folder name
*
* @param {string} folder
*
* @param {string} folder
* @returns {[string, string]} [folder, narrator]
*/
function getNarrator(folder) {
@ -272,7 +177,7 @@ function getNarrator(folder) {
/**
* Extract series sequence from folder name
*
*
* @example
* 'Book 2 - Title - Subtitle'
* 'Title - Subtitle - Vol 12'
@ -283,8 +188,8 @@ function getNarrator(folder) {
* '100 - Book Title'
* '6. Title'
* '0.5 - Book Title'
*
* @param {string} folder
*
* @param {string} folder
* @returns {[string, string]} [folder, sequence]
*/
function getSequence(folder) {
@ -299,7 +204,9 @@ function getSequence(folder) {
if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) {
volumeNumber = isNaN(match.groups.sequence) ? match.groups.sequence : Number(match.groups.sequence).toString()
parts[i] = match.groups.suffix
if (!parts[i]) { parts.splice(i, 1) }
if (!parts[i]) {
parts.splice(i, 1)
}
break
}
}
@ -310,8 +217,8 @@ function getSequence(folder) {
/**
* Extract published year from folder name
*
* @param {string} folder
*
* @param {string} folder
* @returns {[string, string]} [folder, publishedYear]
*/
function getPublishedYear(folder) {
@ -329,8 +236,8 @@ function getPublishedYear(folder) {
/**
* Extract subtitle from folder name
*
* @param {string} folder
*
* @param {string} folder
* @returns {[string, string]} [folder, subtitle]
*/
function getSubtitle(folder) {
@ -341,8 +248,8 @@ function getSubtitle(folder) {
/**
* Extract asin from folder name
*
* @param {string} folder
*
* @param {string} folder
* @returns {[string, string]} [folder, asin]
*/
function getASIN(folder) {
@ -358,8 +265,8 @@ function getASIN(folder) {
}
/**
*
* @param {string} relPath
*
* @param {string} relPath
* @returns {LibraryItemFilenameMetadata}
*/
function getPodcastDataFromDir(relPath) {
@ -373,10 +280,10 @@ function getPodcastDataFromDir(relPath) {
}
/**
*
* @param {string} libraryMediaType
* @param {string} folderPath
* @param {string} relPath
*
* @param {string} libraryMediaType
* @param {string} folderPath
* @param {string} relPath
* @returns {{ mediaMetadata: LibraryItemFilenameMetadata, relPath: string, path: string}}
*/
function getDataFromMediaDir(libraryMediaType, folderPath, relPath) {
@ -386,7 +293,8 @@ function getDataFromMediaDir(libraryMediaType, folderPath, relPath) {
if (libraryMediaType === 'podcast') {
mediaMetadata = getPodcastDataFromDir(relPath)
} else { // book
} else {
// book
mediaMetadata = getBookDataFromDir(relPath, !!global.ServerSettings.scannerParseSubtitle)
}

View file

@ -7,7 +7,7 @@ module.exports.zipDirectoryPipe = (path, filename, res) => {
res.attachment(filename)
const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
zlib: { level: 0 } // Sets the compression level.
})
// listen for all archive data to be written
@ -49,4 +49,4 @@ module.exports.zipDirectoryPipe = (path, filename, res) => {
archive.finalize()
})
}
}