mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-03 06:29:42 +00:00
feat: implement server-side cover dimension detection and update badge thresholds
This commit is contained in:
parent
5bf60d5ae3
commit
d0c09d04f1
16 changed files with 353 additions and 10 deletions
|
|
@ -1407,6 +1407,39 @@ class LibraryController {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/libraries/:id/update-cover-dimensions
|
||||
* Recompute cover dimensions for all items in library
|
||||
*
|
||||
* @param {LibraryControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateCoverDimensions(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to update cover dimensions`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const items = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
libraryId: req.library.id
|
||||
})
|
||||
|
||||
let updatedCount = 0
|
||||
for (const item of items) {
|
||||
if (item.media?.coverPath) {
|
||||
// Force coverPath to be seen as changed to trigger beforeSave hook
|
||||
item.media.changed('coverPath', true)
|
||||
await item.media.save()
|
||||
updatedCount++
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(`[LibraryController] Updated cover dimensions for ${updatedCount} items in library "${req.library.name}"`)
|
||||
res.json({
|
||||
updated: updatedCount
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/libraries/:id/podcast-titles
|
||||
*
|
||||
|
|
|
|||
|
|
@ -601,6 +601,8 @@ class LibraryItemController {
|
|||
}
|
||||
|
||||
req.libraryItem.media.coverPath = result.cover
|
||||
req.libraryItem.media.coverWidth = result.width
|
||||
req.libraryItem.media.coverHeight = result.height
|
||||
req.libraryItem.media.changed('coverPath', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
|
|
@ -634,6 +636,8 @@ class LibraryItemController {
|
|||
}
|
||||
if (validationResult.updated) {
|
||||
req.libraryItem.media.coverPath = validationResult.cover
|
||||
req.libraryItem.media.coverWidth = validationResult.width
|
||||
req.libraryItem.media.coverHeight = validationResult.height
|
||||
req.libraryItem.media.changed('coverPath', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const imageType = require('../libs/imageType')
|
|||
|
||||
const globals = require('../utils/globals')
|
||||
const { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils')
|
||||
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
||||
const { extractCoverArt, getImageDimensions } = require('../utils/ffmpegHelpers')
|
||||
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
|
||||
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
|
|
@ -115,11 +115,14 @@ class CoverManager {
|
|||
|
||||
await this.removeOldCovers(coverDirPath, extname)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
const dims = await getImageDimensions(coverFullPath)
|
||||
|
||||
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.title}"`)
|
||||
|
||||
return {
|
||||
cover: coverFullPath
|
||||
cover: coverFullPath,
|
||||
width: dims?.width || null,
|
||||
height: dims?.height || null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,10 +200,13 @@ class CoverManager {
|
|||
}
|
||||
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
const dims = await getImageDimensions(coverPath)
|
||||
|
||||
return {
|
||||
cover: coverPath,
|
||||
updated: true
|
||||
updated: true,
|
||||
width: dims?.width || null,
|
||||
height: dims?.height || null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -321,10 +327,13 @@ class CoverManager {
|
|||
|
||||
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
|
||||
await CacheManager.purgeCoverCache(libraryItemId)
|
||||
const dims = await getImageDimensions(coverFullPath)
|
||||
|
||||
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}"`)
|
||||
return {
|
||||
cover: coverFullPath
|
||||
cover: coverFullPath,
|
||||
width: dims?.width || null,
|
||||
height: dims?.height || null
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[CoverManager] Fetch cover image from url "${url}" failed`, error)
|
||||
|
|
|
|||
89
server/migrations/v2.32.8-add-cover-dimensions.js
Normal file
89
server/migrations/v2.32.8-add-cover-dimensions.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a sequelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.32.7'
|
||||
const migrationName = `${migrationVersion}-add-cover-dimensions`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* Adds coverWidth and coverHeight columns to books and podcasts tables.
|
||||
*
|
||||
* @param {MigrationOptions} options
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
const bookTable = await queryInterface.describeTable('books')
|
||||
if (!bookTable['coverWidth']) {
|
||||
await queryInterface.addColumn('books', 'coverWidth', {
|
||||
type: queryInterface.sequelize.Sequelize.INTEGER
|
||||
})
|
||||
await queryInterface.addColumn('books', 'coverHeight', {
|
||||
type: queryInterface.sequelize.Sequelize.INTEGER
|
||||
})
|
||||
logger.info(`${loggerPrefix} added cover dimensions columns to table "books"`)
|
||||
}
|
||||
|
||||
const podcastTable = await queryInterface.describeTable('podcasts')
|
||||
if (!podcastTable['coverWidth']) {
|
||||
await queryInterface.addColumn('podcasts', 'coverWidth', {
|
||||
type: queryInterface.sequelize.Sequelize.INTEGER
|
||||
})
|
||||
await queryInterface.addColumn('podcasts', 'coverHeight', {
|
||||
type: queryInterface.sequelize.Sequelize.INTEGER
|
||||
})
|
||||
logger.info(`${loggerPrefix} added cover dimensions columns to table "podcasts"`)
|
||||
}
|
||||
|
||||
// Populate dimensions for existing items
|
||||
const { getImageDimensions } = require('../utils/ffmpegHelpers')
|
||||
|
||||
const books = await queryInterface.sequelize.query('SELECT id, coverPath FROM books WHERE coverPath IS NOT NULL', {
|
||||
type: queryInterface.sequelize.QueryTypes.SELECT
|
||||
})
|
||||
logger.info(`${loggerPrefix} Populating cover dimensions for ${books.length} books...`)
|
||||
for (const book of books) {
|
||||
const dims = await getImageDimensions(book.coverPath)
|
||||
if (dims) {
|
||||
await queryInterface.sequelize.query('UPDATE books SET coverWidth = ?, coverHeight = ? WHERE id = ?', {
|
||||
replacements: [dims.width, dims.height, book.id],
|
||||
type: queryInterface.sequelize.QueryTypes.UPDATE
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const podcasts = await queryInterface.sequelize.query('SELECT id, coverPath FROM podcasts WHERE coverPath IS NOT NULL', {
|
||||
type: queryInterface.sequelize.QueryTypes.SELECT
|
||||
})
|
||||
logger.info(`${loggerPrefix} Populating cover dimensions for ${podcasts.length} podcasts...`)
|
||||
for (const podcast of podcasts) {
|
||||
const dims = await getImageDimensions(podcast.coverPath)
|
||||
if (dims) {
|
||||
await queryInterface.sequelize.query('UPDATE podcasts SET coverWidth = ?, coverHeight = ? WHERE id = ?', {
|
||||
replacements: [dims.width, dims.height, podcast.id],
|
||||
type: queryInterface.sequelize.QueryTypes.UPDATE
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
await queryInterface.removeColumn('books', 'coverWidth')
|
||||
await queryInterface.removeColumn('books', 'coverHeight')
|
||||
await queryInterface.removeColumn('podcasts', 'coverWidth')
|
||||
await queryInterface.removeColumn('podcasts', 'coverHeight')
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
|
|
@ -164,7 +164,9 @@ class Book extends Model {
|
|||
ebookFile: DataTypes.JSON,
|
||||
chapters: DataTypes.JSON,
|
||||
tags: DataTypes.JSON,
|
||||
genres: DataTypes.JSON
|
||||
genres: DataTypes.JSON,
|
||||
coverWidth: DataTypes.INTEGER,
|
||||
coverHeight: DataTypes.INTEGER
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
|
|
@ -201,6 +203,20 @@ class Book extends Model {
|
|||
Book.addHook('afterCreate', async (instance) => {
|
||||
libraryItemsBookFilters.clearCountCache('afterCreate')
|
||||
})
|
||||
|
||||
Book.addHook('beforeSave', async (instance) => {
|
||||
if (instance.changed('coverPath') && instance.coverPath) {
|
||||
const { getImageDimensions } = require('../utils/ffmpegHelpers')
|
||||
const dims = await getImageDimensions(instance.coverPath)
|
||||
if (dims) {
|
||||
instance.coverWidth = dims.width
|
||||
instance.coverHeight = dims.height
|
||||
} else {
|
||||
instance.coverWidth = null
|
||||
instance.coverHeight = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -629,6 +645,8 @@ class Book extends Model {
|
|||
libraryItemId: libraryItemId,
|
||||
metadata: this.oldMetadataToJSON(),
|
||||
coverPath: this.coverPath,
|
||||
coverWidth: this.coverWidth,
|
||||
coverHeight: this.coverHeight,
|
||||
tags: [...(this.tags || [])],
|
||||
audioFiles: structuredClone(this.audioFiles),
|
||||
chapters: structuredClone(this.chapters),
|
||||
|
|
@ -648,6 +666,8 @@ class Book extends Model {
|
|||
id: this.id,
|
||||
metadata: this.oldMetadataToJSONMinified(),
|
||||
coverPath: this.coverPath,
|
||||
coverWidth: this.coverWidth,
|
||||
coverHeight: this.coverHeight,
|
||||
tags: [...(this.tags || [])],
|
||||
numTracks: this.includedAudioFiles.length,
|
||||
numAudioFiles: this.audioFiles?.length || 0,
|
||||
|
|
@ -674,6 +694,8 @@ class Book extends Model {
|
|||
libraryItemId: libraryItemId,
|
||||
metadata: this.oldMetadataToJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
coverWidth: this.coverWidth,
|
||||
coverHeight: this.coverHeight,
|
||||
tags: [...(this.tags || [])],
|
||||
audioFiles: structuredClone(this.audioFiles),
|
||||
chapters: structuredClone(this.chapters),
|
||||
|
|
|
|||
|
|
@ -150,7 +150,9 @@ class Podcast extends Model {
|
|||
coverPath: DataTypes.STRING,
|
||||
tags: DataTypes.JSON,
|
||||
genres: DataTypes.JSON,
|
||||
numEpisodes: DataTypes.INTEGER
|
||||
numEpisodes: DataTypes.INTEGER,
|
||||
coverWidth: DataTypes.INTEGER,
|
||||
coverHeight: DataTypes.INTEGER
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
|
|
@ -165,6 +167,20 @@ class Podcast extends Model {
|
|||
Podcast.addHook('afterCreate', async (instance) => {
|
||||
libraryItemsPodcastFilters.clearCountCache('podcast', 'afterCreate')
|
||||
})
|
||||
|
||||
Podcast.addHook('beforeSave', async (instance) => {
|
||||
if (instance.changed('coverPath') && instance.coverPath) {
|
||||
const { getImageDimensions } = require('../utils/ffmpegHelpers')
|
||||
const dims = await getImageDimensions(instance.coverPath)
|
||||
if (dims) {
|
||||
instance.coverWidth = dims.width
|
||||
instance.coverHeight = dims.height
|
||||
} else {
|
||||
instance.coverWidth = null
|
||||
instance.coverHeight = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get hasMediaFiles() {
|
||||
|
|
@ -436,6 +452,8 @@ class Podcast extends Model {
|
|||
libraryItemId: libraryItemId,
|
||||
metadata: this.oldMetadataToJSON(),
|
||||
coverPath: this.coverPath,
|
||||
coverWidth: this.coverWidth,
|
||||
coverHeight: this.coverHeight,
|
||||
tags: [...(this.tags || [])],
|
||||
episodes: this.podcastEpisodes.map((episode) => episode.toOldJSON(libraryItemId)),
|
||||
autoDownloadEpisodes: this.autoDownloadEpisodes,
|
||||
|
|
@ -452,6 +470,8 @@ class Podcast extends Model {
|
|||
// Minified metadata and expanded metadata are the same
|
||||
metadata: this.oldMetadataToJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
coverWidth: this.coverWidth,
|
||||
coverHeight: this.coverHeight,
|
||||
tags: [...(this.tags || [])],
|
||||
numEpisodes: this.podcastEpisodes?.length || 0,
|
||||
autoDownloadEpisodes: this.autoDownloadEpisodes,
|
||||
|
|
@ -476,6 +496,8 @@ class Podcast extends Model {
|
|||
libraryItemId: libraryItemId,
|
||||
metadata: this.oldMetadataToJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
coverWidth: this.coverWidth,
|
||||
coverHeight: this.coverHeight,
|
||||
tags: [...(this.tags || [])],
|
||||
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)),
|
||||
autoDownloadEpisodes: this.autoDownloadEpisodes,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ class ApiRouter {
|
|||
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
||||
this.router.post('/libraries/:id/remove-metadata', LibraryController.middleware.bind(this), LibraryController.removeAllMetadataFiles.bind(this))
|
||||
this.router.post('/libraries/:id/update-consolidation', LibraryController.middleware.bind(this), LibraryController.updateConsolidationStatus.bind(this))
|
||||
this.router.post('/libraries/:id/update-cover-dimensions', LibraryController.middleware.bind(this), LibraryController.updateCoverDimensions.bind(this))
|
||||
this.router.get('/libraries/:id/podcast-titles', LibraryController.middleware.bind(this), LibraryController.getPodcastTitles.bind(this))
|
||||
this.router.get('/libraries/:id/download', LibraryController.middleware.bind(this), LibraryController.downloadMultiple.bind(this))
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const LibraryFile = require('../objects/files/LibraryFile')
|
|||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
const { getImageDimensions } = require('../utils/ffmpegHelpers')
|
||||
|
||||
const LibraryScan = require('./LibraryScan')
|
||||
const OpfFileScanner = require('./OpfFileScanner')
|
||||
|
|
|
|||
|
|
@ -96,6 +96,24 @@ async function resizeImage(filePath, outputPath, width, height) {
|
|||
}
|
||||
module.exports.resizeImage = resizeImage
|
||||
|
||||
async function getImageDimensions(filePath) {
|
||||
return new Promise((resolve) => {
|
||||
Ffmpeg.ffprobe(filePath, (err, metadata) => {
|
||||
if (err) {
|
||||
Logger.error(`[FfmpegHelpers] ffprobe Error ${err}`)
|
||||
return resolve(null)
|
||||
}
|
||||
const stream = metadata?.streams?.find((s) => s.codec_type === 'video')
|
||||
if (stream) {
|
||||
resolve({ width: stream.width, height: stream.height })
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
module.exports.getImageDimensions = getImageDimensions
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue