feat: implement server-side cover dimension detection and update badge thresholds

This commit is contained in:
Tiberiu Ichim 2026-02-17 19:30:48 +02:00
parent 5bf60d5ae3
commit d0c09d04f1
16 changed files with 353 additions and 10 deletions

View file

@ -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
*

View file

@ -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()

View file

@ -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)

View 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 }

View file

@ -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),

View file

@ -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,

View file

@ -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))

View file

@ -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')

View file

@ -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