Merge branch 'master' into mf/rssInboundManager

This commit is contained in:
mfcar 2023-10-01 00:34:38 +01:00
commit 4de5b92fa7
No known key found for this signature in database
44 changed files with 991 additions and 109 deletions

View file

@ -92,7 +92,7 @@ class Logger {
* @param {...any} args
*/
dev(...args) {
if (!this.isDev) return
if (!this.isDev || process.env.HIDE_DEV_LOGS === '1') return
console.log(`[${this.timestamp}] DEV:`, ...args)
}

View file

@ -28,6 +28,8 @@ class FolderWatcher extends EventEmitter {
this.ignoreDirs = []
/** @type {string[]} */
this.pendingDirsToRemoveFromIgnore = []
/** @type {NodeJS.Timeout} */
this.removeFromIgnoreTimer = null
this.disabled = false
}
@ -240,9 +242,12 @@ class FolderWatcher extends EventEmitter {
*/
addIgnoreDir(path) {
path = this.cleanDirPath(path)
if (this.ignoreDirs.includes(path)) return
this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
Logger.debug(`[Watcher] Ignoring directory "${path}"`)
if (this.ignoreDirs.includes(path)) {
// Already ignoring dir
return
}
Logger.debug(`[Watcher] addIgnoreDir: Ignoring directory "${path}"`)
this.ignoreDirs.push(path)
}
@ -255,18 +260,24 @@ class FolderWatcher extends EventEmitter {
*/
removeIgnoreDir(path) {
path = this.cleanDirPath(path)
if (!this.ignoreDirs.includes(path) || this.pendingDirsToRemoveFromIgnore.includes(path)) return
if (!this.ignoreDirs.includes(path)) {
Logger.debug(`[Watcher] removeIgnoreDir: Path is not being ignored "${path}"`)
return
}
// Add a 5 second delay before removing the ignore from this dir
this.pendingDirsToRemoveFromIgnore.push(path)
setTimeout(() => {
if (!this.pendingDirsToRemoveFromIgnore.includes(path)) {
this.pendingDirsToRemoveFromIgnore.push(path)
}
clearTimeout(this.removeFromIgnoreTimer)
this.removeFromIgnoreTimer = setTimeout(() => {
if (this.pendingDirsToRemoveFromIgnore.includes(path)) {
this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
Logger.debug(`[Watcher] No longer ignoring directory "${path}"`)
Logger.debug(`[Watcher] removeIgnoreDir: No longer ignoring directory "${path}"`)
this.ignoreDirs = this.ignoreDirs.filter(p => p !== path)
}
}, 5000)
}
}
module.exports = FolderWatcher

View file

@ -167,6 +167,30 @@ class AuthorController {
}
}
/**
* DELETE: /api/authors/:id
* Remove author from all books and delete
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async delete(req, res) {
Logger.info(`[AuthorController] Removing author "${req.author.name}"`)
await Database.authorModel.removeById(req.author.id)
if (req.author.imagePath) {
await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
SocketAuthority.emitter('author_removed', req.author.toJSON())
// Update filter data
Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)
res.sendStatus(200)
}
async match(req, res) {
let authorData = null
const region = req.body.region || 'us'

View file

@ -620,7 +620,7 @@ class LibraryController {
model: Database.bookModel,
attributes: ['id', 'tags', 'explicit'],
where: bookWhere,
required: true,
required: false,
through: {
attributes: []
}

View file

@ -196,7 +196,7 @@ class MeController {
const libraryItem = await Database.libraryItemModel.getOldById(localProgress.libraryItemId)
if (!libraryItem) {
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item`, localProgress)
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item with id "${localProgress.libraryItemId}"`, localProgress)
continue
}

View file

@ -91,7 +91,7 @@ class PodcastController {
res.json(libraryItem.toJSONExpanded())
if (payload.episodesToDownload && payload.episodesToDownload.length) {
if (payload.episodesToDownload?.length) {
Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`)
this.podcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
}

View file

@ -100,7 +100,7 @@ class BackupManager {
let entries
try {
entries = await zip.entries()
} catch(error){
} catch (error) {
// Not a valid zip file
Logger.error('[BackupManager] Failed to read backup file - backup might not be a valid .zip file', tempPath, error)
return res.status(400).send('Failed to read backup file - backup might not be a valid .zip file')
@ -182,7 +182,6 @@ class BackupManager {
data = await zip.entryData('details')
} catch (error) {
Logger.error(`[BackupManager] Failed to unzip backup "${fullFilePath}"`, error)
await zip.close()
continue
}

View file

@ -47,10 +47,14 @@ class BookAuthor extends Model {
book.belongsToMany(author, { through: BookAuthor })
author.belongsToMany(book, { through: BookAuthor })
book.hasMany(BookAuthor)
book.hasMany(BookAuthor, {
onDelete: 'CASCADE'
})
BookAuthor.belongsTo(book)
author.hasMany(BookAuthor)
author.hasMany(BookAuthor, {
onDelete: 'CASCADE'
})
BookAuthor.belongsTo(author)
}
}

View file

@ -176,6 +176,8 @@ class Feed extends Model {
if (!existingFeed) return false
let hasUpdates = false
// Remove and update existing feed episodes
for (const feedEpisode of existingFeed.feedEpisodes) {
const oldFeedEpisode = oldFeedEpisodes.find(ep => ep.id === feedEpisode.id)
// Episode removed
@ -196,6 +198,14 @@ class Feed extends Model {
}
}
// Add new feed episodes
for (const episode of oldFeedEpisodes) {
if (!existingFeed.feedEpisodes.some(fe => fe.id === episode.id)) {
await this.sequelize.models.feedEpisode.createFromOld(feedObj.id, episode)
hasUpdates = true
}
}
let feedHasUpdates = false
for (const key in feedObj) {
let existingValue = existingFeed[key]

View file

@ -63,6 +63,19 @@ class FeedEpisode extends Model {
}
}
/**
* Create feed episode from old model
*
* @param {string} feedId
* @param {Object} oldFeedEpisode
* @returns {Promise<FeedEpisode>}
*/
static createFromOld(feedId, oldFeedEpisode) {
const newEpisode = this.getFromOld(oldFeedEpisode)
newEpisode.feedId = feedId
return this.create(newEpisode)
}
static getFromOld(oldFeedEpisode) {
return {
id: oldFeedEpisode.id,

View file

@ -794,6 +794,9 @@ class LibraryItem extends Model {
{
fields: ['libraryId', 'mediaType']
},
{
fields: ['libraryId', 'mediaId', 'mediaType']
},
{
fields: ['birthtime']
},

View file

@ -147,6 +147,7 @@ class ServerSettings {
loggerScannerLogsToKeep: this.loggerScannerLogsToKeep,
homeBookshelfView: this.homeBookshelfView,
bookshelfView: this.bookshelfView,
podcastEpisodeSchedule: this.podcastEpisodeSchedule,
sortingIgnorePrefix: this.sortingIgnorePrefix,
sortingPrefixes: [...this.sortingPrefixes],
chromecastEnabled: this.chromecastEnabled,

View file

@ -199,6 +199,7 @@ class ApiRouter {
//
this.router.get('/authors/:id', AuthorController.middleware.bind(this), AuthorController.findOne.bind(this))
this.router.patch('/authors/:id', AuthorController.middleware.bind(this), AuthorController.update.bind(this))
this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this))
this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this))
this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this))

View file

@ -168,9 +168,7 @@ class BookScanner {
hasMediaChanges = true
}
// TODO: When metadata file is stored in /metadata/items/{libraryItemId}.[abs|json] we should load this
// TODO: store an additional array of metadata keys that the user has changed manually so we know what not to override
const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, libraryItemData, libraryScan)
const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, libraryItemData, libraryScan, existingLibraryItem.id)
let authorsUpdated = false
const bookAuthorsRemoved = []
let seriesUpdated = false
@ -550,9 +548,10 @@ class BookScanner {
* @param {import('../models/Book').AudioFileObject[]} audioFiles
* @param {import('./LibraryItemScanData')} libraryItemData
* @param {LibraryScan} libraryScan
* @param {string} [existingLibraryItemId]
* @returns {Promise<BookMetadataObject>}
*/
async getBookMetadataFromScanData(audioFiles, libraryItemData, libraryScan) {
async getBookMetadataFromScanData(audioFiles, libraryItemData, libraryScan, existingLibraryItemId = null) {
// First set book metadata from folder/file names
const bookMetadata = {
title: libraryItemData.mediaMetadata.title,
@ -722,11 +721,31 @@ class BookScanner {
// If metadata.json or metadata.abs use this for metadata
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile || libraryItemData.metadataAbsLibraryFile
const metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null
let metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null
let metadataFilePath = metadataLibraryFile?.metadata.path
let metadataFileFormat = libraryItemData.metadataJsonLibraryFile ? 'json' : 'abs'
// When metadata file is not stored with library item then check in the /metadata/items folder for it
if (!metadataText && existingLibraryItemId) {
let metadataPath = Path.join(global.MetadataPath, 'items', existingLibraryItemId)
let altFormat = global.ServerSettings.metadataFileFormat === 'json' ? 'abs' : 'json'
// First check the metadata format set in server settings, fallback to the alternate
metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
metadataFileFormat = global.ServerSettings.metadataFileFormat
if (await fsExtra.pathExists(metadataFilePath)) {
metadataText = await readTextFile(metadataFilePath)
} else if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.${altFormat}`))) {
metadataFilePath = Path.join(metadataPath, `metadata.${altFormat}`)
metadataFileFormat = altFormat
metadataText = await readTextFile(metadataFilePath)
}
}
if (metadataText) {
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataLibraryFile.metadata.path}" - preferring`)
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}" - preferring`)
let abMetadata = null
if (!!libraryItemData.metadataJsonLibraryFile) {
if (metadataFileFormat === 'json') {
abMetadata = abmetadataGenerator.parseJson(metadataText)
} else {
abMetadata = abmetadataGenerator.parse(metadataText, 'book')

View file

@ -145,8 +145,7 @@ class PodcastScanner {
hasMediaChanges = true
}
// TODO: When metadata file is stored in /metadata/items/{libraryItemId}.[abs|json] we should load this
const podcastMetadata = await this.getPodcastMetadataFromScanData(existingPodcastEpisodes, libraryItemData, libraryScan)
const podcastMetadata = await this.getPodcastMetadataFromScanData(existingPodcastEpisodes, libraryItemData, libraryScan, existingLibraryItem.id)
for (const key in podcastMetadata) {
// Ignore unset metadata and empty arrays
@ -312,9 +311,10 @@ class PodcastScanner {
* @param {PodcastEpisode[]} podcastEpisodes Not the models for new podcasts
* @param {import('./LibraryItemScanData')} libraryItemData
* @param {import('./LibraryScan')} libraryScan
* @param {string} [existingLibraryItemId]
* @returns {Promise<PodcastMetadataObject>}
*/
async getPodcastMetadataFromScanData(podcastEpisodes, libraryItemData, libraryScan) {
async getPodcastMetadataFromScanData(podcastEpisodes, libraryItemData, libraryScan, existingLibraryItemId = null) {
const podcastMetadata = {
title: libraryItemData.mediaMetadata.title,
titleIgnorePrefix: getTitleIgnorePrefix(libraryItemData.mediaMetadata.title),
@ -389,11 +389,31 @@ class PodcastScanner {
// If metadata.json or metadata.abs use this for metadata
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile || libraryItemData.metadataAbsLibraryFile
const metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null
let metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null
let metadataFilePath = metadataLibraryFile?.metadata.path
let metadataFileFormat = libraryItemData.metadataJsonLibraryFile ? 'json' : 'abs'
// When metadata file is not stored with library item then check in the /metadata/items folder for it
if (!metadataText && existingLibraryItemId) {
let metadataPath = Path.join(global.MetadataPath, 'items', existingLibraryItemId)
let altFormat = global.ServerSettings.metadataFileFormat === 'json' ? 'abs' : 'json'
// First check the metadata format set in server settings, fallback to the alternate
metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
metadataFileFormat = global.ServerSettings.metadataFileFormat
if (await fsExtra.pathExists(metadataFilePath)) {
metadataText = await readTextFile(metadataFilePath)
} else if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.${altFormat}`))) {
metadataFilePath = Path.join(metadataPath, `metadata.${altFormat}`)
metadataFileFormat = altFormat
metadataText = await readTextFile(metadataFilePath)
}
}
if (metadataText) {
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataLibraryFile.metadata.path}" - preferring`)
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}" - preferring`)
let abMetadata = null
if (!!libraryItemData.metadataJsonLibraryFile) {
if (metadataFileFormat === 'json') {
abMetadata = abmetadataGenerator.parseJson(metadataText)
} else {
abMetadata = abmetadataGenerator.parse(metadataText, 'podcast')

View file

@ -190,6 +190,7 @@ module.exports = {
const json = li.toJSONMinified()
json.media.metadata.series = {
id: filteredSeries.id,
name: filteredSeries.name,
sequence: filteredSeries.sequence
}

View file

@ -16,8 +16,8 @@ function parseCreators(metadata) {
}
function fetchCreators(creators, role) {
if (!creators || !creators.length) return null
return [...new Set(creators.filter(c => c.role === role).map(c => c.value))]
if (!creators?.length) return null
return [...new Set(creators.filter(c => c.role === role && c.value).map(c => c.value))]
}
function fetchTagString(metadata, tag) {
@ -92,7 +92,7 @@ function fetchDescription(metadata) {
function fetchGenres(metadata) {
if (!metadata['dc:subject'] || !metadata['dc:subject'].length) return []
return [...new Set(metadata['dc:subject'].filter(g => typeof g === 'string'))]
return [...new Set(metadata['dc:subject'].filter(g => g && typeof g === 'string'))]
}
function fetchLanguage(metadata) {
@ -122,7 +122,7 @@ function fetchNarrators(creators, metadata) {
function fetchTags(metadata) {
if (!metadata['dc:tag'] || !metadata['dc:tag'].length) return []
return [...new Set(metadata['dc:tag'].filter(tag => typeof tag === 'string'))]
return [...new Set(metadata['dc:tag'].filter(tag => tag && typeof tag === 'string'))]
}
function stripPrefix(str) {

View file

@ -205,6 +205,15 @@ module.exports = {
}
}
]
// Handle library setting to hide single book series
// TODO: Merge with existing query
if (library.settings.hideSingleBookSeries) {
seriesWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id)`), {
[Sequelize.Op.gt]: 1
}))
}
// Handle user permissions to only include series with at least 1 book
// TODO: Simplify to a single query
if (userPermissionBookWhere.bookWhere.length) {

View file

@ -247,7 +247,7 @@ module.exports = {
podcastEpisodeWhere['$mediaProgresses.isFinished$'] = true
}
} else if (filterGroup === 'recent') {
libraryItemWhere['createdAt'] = {
podcastEpisodeWhere['createdAt'] = {
[Sequelize.Op.gte]: new Date(new Date() - (60 * 24 * 60 * 60 * 1000)) // 60 days ago
}
}