mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-10 03:11:34 +00:00
Merge branch 'advplyr:master' into feat/all-stats-page
This commit is contained in:
commit
df15c22c6d
336 changed files with 21709 additions and 13151 deletions
207
server/utils/comicBookExtractors.js
Normal file
207
server/utils/comicBookExtractors.js
Normal 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 }
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 Chinese,Japanese 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
|
||||
}
|
||||
27
server/utils/parsers/parseSeriesString.js
Normal file
27
server/utils/parsers/parseSeriesString.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";`, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue