Merge branch 'advplyr:master' into auto-generate-chapters-from-timestamps

This commit is contained in:
Harry 2026-04-21 20:04:24 +01:00 committed by GitHub
commit 64fd42ebf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 197 additions and 67 deletions

View file

@ -10,7 +10,7 @@ const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager')
const AuthorFinder = require('../finders/AuthorFinder')
const { reqSupportsWebp, isValidASIN } = require('../utils/index')
const { reqSupportsWebp, isValidASIN, clampPositiveInt } = require('../utils/index')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
@ -412,8 +412,8 @@ class AuthorController {
const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null
height: clampPositiveInt(height ? parseInt(height) : null, 4096),
width: clampPositiveInt(width ? parseInt(width) : null, 4096)
}
return CacheManager.handleAuthorCache(res, authorId, options)
}

View file

@ -117,7 +117,7 @@ class FileSystemController {
filepath = fileUtils.filePathToPOSIX(filepath)
// Ensure filepath is inside library folder (prevents directory traversal)
if (!filepath.startsWith(libraryFolder.path)) {
if (!fileUtils.isSameOrSubPath(libraryFolder.path, filepath)) {
Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`)
return res.sendStatus(400)
}

View file

@ -462,7 +462,7 @@ class LibraryController {
}
}
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
}
if (authorIds.length) {
@ -563,7 +563,7 @@ class LibraryController {
mediaItemIds.push(libraryItem.mediaId)
}
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
}
// Set PlaybackSessions libraryId to null
@ -714,7 +714,7 @@ class LibraryController {
}
}
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
}
if (authorIds.length) {
@ -1435,10 +1435,15 @@ class LibraryController {
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'libraryId', 'path', 'isFile'],
where: {
id: itemIds
id: itemIds,
libraryId: req.library.id
}
})
if (libraryItems.length < itemIds.length) {
Logger.warn(`[LibraryController] User "${req.user.username}" requested ${itemIds.length} items but only ${libraryItems.length} are in library "${req.library.id}"`)
}
Logger.info(`[LibraryController] User "${req.user.username}" requested download for items "${itemIds}"`)
const filename = `LibraryItems-${Date.now()}.zip`

View file

@ -7,7 +7,7 @@ const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const zipHelpers = require('../utils/zipHelpers')
const { reqSupportsWebp } = require('../utils/index')
const { reqSupportsWebp, clampPositiveInt } = require('../utils/index')
const { ScanResult, AudioMimeType } = require('../utils/constants')
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
@ -111,7 +111,7 @@ class LibraryItemController {
}
}
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds, req.libraryItem.libraryId)
if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => {
@ -398,8 +398,8 @@ class LibraryItemController {
const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null
height: clampPositiveInt(height ? parseInt(height) : null, 4096),
width: clampPositiveInt(width ? parseInt(width) : null, 4096)
}
return CacheManager.handleCoverCache(res, libraryItemId, options)
}
@ -565,7 +565,7 @@ class LibraryItemController {
authorIds.push(...libraryItem.media.authors.map((au) => au.id))
}
}
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, libraryItem.libraryId)
if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => {

View file

@ -7,7 +7,7 @@ const Database = require('../Database')
const fs = require('../libs/fsExtra')
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
const { getFileTimestampsWithIno, filePathToPOSIX, isSameOrSubPath } = require('../utils/fileUtils')
const { validateUrl } = require('../utils/index')
const htmlSanitizer = require('../utils/htmlSanitizer')
@ -58,8 +58,18 @@ class PodcastController {
return res.status(404).send('Folder not found')
}
if (typeof payload.path !== 'string' || !payload.path.trim()) {
return res.status(400).send('Invalid request body. "path" must be a non-empty string')
}
const libraryFolderPath = filePathToPOSIX(folder.path)
const podcastPath = filePathToPOSIX(payload.path)
if (!isSameOrSubPath(libraryFolderPath, podcastPath)) {
Logger.error(`[PodcastController] Create: Podcast path is outside library folder "${libraryFolderPath}": "${podcastPath}"`)
return res.status(400).send('Podcast path must be inside the selected library folder')
}
// Check if a library item with this podcast folder exists already
const existingLibraryItem =
(await Database.libraryItemModel.count({
@ -83,7 +93,7 @@ class PodcastController {
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
let relPath = payload.path.replace(folder.fullPath, '')
let relPath = podcastPath.replace(libraryFolderPath, '')
if (relPath.startsWith('/')) relPath = relPath.slice(1)
let newLibraryItem = null

View file

@ -126,13 +126,31 @@ class BackupManager {
} 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)
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(400).send('Failed to read backup file - backup might not be a valid .zip file')
}
if (!Object.keys(entries).includes('absdatabase.sqlite')) {
if (!entries['absdatabase.sqlite']) {
Logger.error(`[BackupManager] Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.`)
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(500).send('Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.')
}
const detailsEntry = entries['details']
if (!detailsEntry) {
Logger.error('[BackupManager] Invalid backup - missing details entry')
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(400).send('Invalid backup file - missing details entry')
}
if (detailsEntry.size > 1024 * 1024) {
Logger.error(`[BackupManager] Backup details entry too large: ${detailsEntry.size} bytes`)
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(400).send('Invalid backup file - details entry too large')
}
const data = await zip.entryData('details')
const details = data.toString('utf8').split('\n')
@ -140,9 +158,13 @@ class BackupManager {
if (!backup.serverVersion) {
Logger.error(`[BackupManager] Invalid backup with no server version - might be a backup created before version 2.0.0`)
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(500).send('Invalid backup. Might be a backup created before version 2.0.0.')
}
await zip.close().catch(() => {})
backup.fileSize = await getFileSize(backup.fullPath)
const existingBackupIndex = this.backups.findIndex((b) => b.id === backup.id)
@ -257,9 +279,24 @@ class BackupManager {
let data = null
try {
zip = new StreamZip.async({ file: fullFilePath })
const entries = await zip.entries()
const detailsEntry = entries['details']
if (!detailsEntry) {
Logger.error(`[BackupManager] Backup "${fullFilePath}" missing details entry - skipping`)
await zip.close().catch(() => {})
continue
}
if (detailsEntry.size > 1024 * 1024) {
Logger.error(`[BackupManager] Backup "${fullFilePath}" details entry too large (${detailsEntry.size} bytes) - skipping`)
await zip.close().catch(() => {})
continue
}
data = await zip.entryData('details')
} catch (error) {
Logger.error(`[BackupManager] Failed to unzip backup "${fullFilePath}"`, error)
if (zip) await zip.close().catch(() => {})
continue
}

View file

@ -111,16 +111,17 @@ class Author extends Model {
*
* @param {string} name
* @param {string} libraryId
* @returns {Promise<Author>}
* @returns {Promise<{ author: Author, created: boolean }>}
*/
static async findOrCreateByNameAndLibrary(name, libraryId) {
const author = await this.getByNameAndLibrary(name, libraryId)
if (author) return author
return this.create({
if (author) return { author, created: false }
const newAuthor = await this.create({
name,
lastFirst: this.getLastFirst(name),
libraryId
})
return { author: newAuthor, created: true }
}
/**

View file

@ -4,6 +4,7 @@ const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
const parseNameString = require('../utils/parsers/parseNameString')
const htmlSanitizer = require('../utils/htmlSanitizer')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
const SocketAuthority = require('../SocketAuthority')
/**
* @typedef EBookFileObject
@ -470,13 +471,23 @@ class Book extends Model {
for (const author of authorsRemoved) {
await bookAuthorModel.removeByIds(author.id, this.id)
const numBooks = await bookAuthorModel.getCountForAuthor(author.id)
if (numBooks > 0) {
SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks))
}
Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`)
this.authors = this.authors.filter((au) => au.id !== author.id)
}
const authorsAdded = []
for (const authorName of newAuthorNames) {
const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
const { author, created } = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
await bookAuthorModel.create({ bookId: this.id, authorId: author.id })
if (created) {
SocketAuthority.emitter('author_added', author.toOldJSON())
} else {
const numBooks = await bookAuthorModel.getCountForAuthor(author.id)
SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks))
}
Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`)
this.authors.push(author)
authorsAdded.push(author)

View file

@ -110,7 +110,8 @@ class PlaybackSession {
startedAt: this.startedAt,
updatedAt: this.updatedAt,
audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }),
libraryItem: libraryItem?.toOldJSONExpanded() || null
libraryItem: libraryItem?.toOldJSONExpanded() || null,
coverAspectRatio: this.coverAspectRatio !== null ? this.coverAspectRatio : undefined // Used for share sessions
}
}

View file

@ -73,7 +73,7 @@ class Stream extends EventEmitter {
return [AudioMimeType.FLAC, AudioMimeType.OPUS, AudioMimeType.WMA, AudioMimeType.AIFF, AudioMimeType.WEBM, AudioMimeType.WEBMA, AudioMimeType.AWB, AudioMimeType.CAF]
}
get codecsToForceAAC() {
return ['alac', 'ac3', 'eac3']
return ['alac', 'ac3', 'eac3', 'opus']
}
get userToken() {
return this.user.token

View file

@ -363,8 +363,9 @@ class ApiRouter {
* Remove library item and associated entities
* @param {string} libraryItemId
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
* @param {string} libraryId
*/
async handleDeleteLibraryItem(libraryItemId, mediaItemIds) {
async handleDeleteLibraryItem(libraryItemId, mediaItemIds, libraryId) {
const numProgressRemoved = await Database.mediaProgressModel.destroy({
where: {
mediaItemId: mediaItemIds
@ -395,7 +396,8 @@ class ApiRouter {
await Database.libraryItemModel.removeById(libraryItemId)
SocketAuthority.emitter('item_removed', {
id: libraryItemId
id: libraryItemId,
libraryId
})
}

View file

@ -48,6 +48,8 @@ module.exports.AudioMimeType = {
AIF: 'audio/x-aiff',
WEBM: 'audio/webm',
WEBMA: 'audio/webm',
// TODO: Switch to `audio/matroska`? marked as deprecated in IANA registry
// ref: https://datatracker.ietf.org/doc/html/rfc9559
MKA: 'audio/x-matroska',
AWB: 'audio/amr-wb',
CAF: 'audio/x-caf',

View file

@ -54,6 +54,16 @@ module.exports.isNullOrNaN = (num) => {
return num === null || isNaN(num)
}
/**
* @param {number|null|undefined} value
* @param {number} max
* @returns {number|null}
*/
module.exports.clampPositiveInt = (value, max) => {
if (value == null || !Number.isFinite(value) || value <= 0) return null
return Math.min(Math.floor(value), max)
}
const xmlToJSON = (xml) => {
return new Promise((resolve, reject) => {
parseString(xml, (err, results) => {

View file

@ -217,6 +217,10 @@ function extractEpisodeData(item) {
episode[cleanKey] = extractFirstArrayItemString(item, key)
})
if (episode.subtitle) {
episode.subtitle = htmlSanitizer.sanitize(episode.subtitle.trim())
}
// Extract psc:chapters if duration is set
episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null