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

@ -0,0 +1,20 @@
# Cover Size Badge Specification
## Overview
A new badge is displayed on book covers to indicate the resolution of the cover image. This helps users identifying high-quality covers (e.g., Audible's 2400x2400 format).
## Logic
The badge is determined client-side once the image has loaded, using the `naturalWidth` and `naturalHeight` properties of the `HTMLImageElement`.
### Size Tiers
| Tier | Criteria | Label | Color |
| :--- | :--- | :--- | :--- |
| **Big** | Width or Height >= 2400px | BIG | Success (Green) |
| **Medium** | Width or Height >= 1200px | MED | Info (Blue) |
| **Small** | Width or Height < 1200px | SML | Warning (Yellow) |
## Implementation Details
- **Component**: `BookCover.vue` (and other cover components as needed).
- **Detection**: `imageLoaded` event captures dimensions.
- **UI**: Absolute positioned badge in the bottom-right corner.
- **Responsiveness**: Font size and padding scale with the `sizeMultiplier` of the cover component.

View file

@ -0,0 +1,37 @@
# Cover Size Badge Specification
## Overview
Indicates the size tier of a book cover image directly on the cover in various views. This helps users quickly identify high-quality (Audible-grade) covers vs. lower resolution ones.
## Size Tiers
The badge uses the following logic based on the image's natural dimensions (Width or Height):
| Tier | Condition | Text | Color |
| :--- | :--- | :--- | :--- |
| **BIG** | Width or Height >= 1200px | BIG | Green (`bg-success`) |
| **MED** | Width or Height >= 450px | MED | Blue (`bg-info`) |
| **SML** | Width or Height < 450px | SML | Yellow (`bg-warning`) |
## Implementation Details
The detection is performed server-side and stored in the database to ensure accuracy regardless of thumbnail sizes.
### Dimension Detection
- `coverWidth` and `coverHeight` columns added to `books` and `podcasts` tables.
- A `beforeSave` hook on `Book` and `Podcast` models detects dimensions using `ffprobe` when `coverPath` changes.
- A database migration (`v2.32.7-add-cover-dimensions.js`) populates existing items.
### Components Impacted
1. **`BookCover.vue`**: Used in detail views and some table rows (e.g., Collections).
2. **`LazyBookCard.vue`**: Used in main library bookshelf views, home page shelves, and search results.
### Logic
- USE `media.coverWidth` and `media.coverHeight` (from the server) as the primary source.
- FALLBACK to `naturalWidth` and `naturalHeight` from the image's `@load` event if server data is unavailable.
- COMPUTE the badge tier based on the rules above.
- RENDER a small, absolute-positioned badge in the bottom-right corner of the cover.
### UI Styling
- **Position**: Bottom-right of the cover image.
- **Font Size**: Scales with the `sizeMultiplier` (default `0.6rem`).
- **Pointer Events**: `none` (to avoid interfering with clicks).
- **Z-Index**: `20` (to stay above the cover and some overlays).

View file

@ -122,6 +122,11 @@
<span class="material-symbols text-black" :style="{ fontSize: 0.875 + 'em' }">folder_open</span>
</button>
</ui-tooltip>
<!-- Cover Size Badge -->
<div v-if="coverBadge" class="absolute rounded-sm text-white font-bold pointer-events-none z-20" :class="coverBadge.color" :style="{ bottom: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em`, fontSize: 0.6 + 'em', lineHeight: 0.8 + 'em' }">
{{ coverBadge.text }}
</div>
</div>
</div>
@ -176,7 +181,9 @@ export default {
isSelectionMode: false,
displayTitleTruncated: false,
displaySubtitleTruncated: false,
showCoverBg: false
showCoverBg: false,
naturalWidth: 0,
naturalHeight: 0
}
},
watch: {
@ -250,6 +257,18 @@ export default {
if (this.collapsedSeries?.name) return this.collapsedSeries.name
return this.series?.name || null
},
coverBadge() {
const width = this.media?.coverWidth || this.coverWidth
const height = this.media?.coverHeight || this.coverHeight
if (!width || !height) return null
if (width >= 1200 || height >= 1200) {
return { text: 'BIG', color: 'bg-success' }
}
if (width >= 450 || height >= 450) {
return { text: 'MED', color: 'bg-info' }
}
return { text: 'SML', color: 'bg-warning' }
},
seriesSequence() {
return this.series?.sequence || null
},
@ -1148,6 +1167,8 @@ export default {
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
this.naturalWidth = naturalWidth
this.naturalHeight = naturalHeight
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)

View file

@ -22,6 +22,11 @@
</div>
</div>
<!-- Cover Size Badge -->
<div v-if="coverBadge" class="absolute bottom-1 right-1 px-1 rounded-sm text-white font-bold pointer-events-none z-20" :class="coverBadge.color" :style="{ fontSize: (0.6 * sizeMultiplier) + 'rem', lineHeight: (0.8 * sizeMultiplier) + 'rem' }">
{{ coverBadge.text }}
</div>
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
@ -52,7 +57,9 @@ export default {
loading: true,
imageFailed: false,
showCoverBg: false,
imageReady: false
imageReady: false,
naturalWidth: 0,
naturalHeight: 0
}
},
watch: {
@ -133,6 +140,18 @@ export default {
},
resolution() {
return `${this.naturalWidth}x${this.naturalHeight}px`
},
coverBadge() {
const width = this.media?.coverWidth || this.naturalWidth
const height = this.media?.coverHeight || this.naturalHeight
if (!width || !height) return null
if (width >= 1200 || height >= 1200) {
return { text: 'BIG', color: 'bg-success' }
}
if (width >= 450 || height >= 450) {
return { text: 'MED', color: 'bg-info' }
}
return { text: 'SML', color: 'bg-warning' }
}
},
methods: {
@ -154,6 +173,8 @@ export default {
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
this.naturalWidth = naturalWidth
this.naturalHeight = naturalHeight
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)

View file

@ -49,6 +49,18 @@
</div>
</div>
</div>
<div class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-center">
<div>
<p class="text-lg">{{ $strings.LabelUpdateCoverDimensions }}</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelUpdateCoverDimensionsHelp }}</p>
</div>
<div class="grow" />
<div>
<ui-btn @click.stop="updateCoverDimensionsClick">{{ $strings.ButtonUpdate }}</ui-btn>
</div>
</div>
</div>
</div>
</template>
@ -198,6 +210,34 @@ export default {
.finally(() => {
this.$emit('update:processing', false)
})
},
updateCoverDimensionsClick() {
const payload = {
message: this.$strings.MessageConfirmUpdateCoverDimensions,
persistent: true,
callback: (confirmed) => {
if (confirmed) {
this.updateCoverDimensions()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
updateCoverDimensions() {
this.$emit('update:processing', true)
this.$axios
.$post(`/api/libraries/${this.libraryId}/update-cover-dimensions`)
.then((data) => {
this.$toast.success(this.$getString('ToastUpdateCoverDimensionsSuccess', [data.updated]))
})
.catch((error) => {
console.error('Failed to update cover dimensions', error)
this.$toast.error(this.$strings.ToastUpdateCoverDimensionsFailed)
})
.finally(() => {
this.$emit('update:processing', false)
})
}
},
mounted() {}

View file

@ -289,6 +289,8 @@
"LabelCleanupAuthorsHelp": "Remove authors that have no books in this library.",
"LabelUpdateConsolidationStatus": "Update Consolidation Status",
"LabelUpdateConsolidationStatusHelp": "Checks all items in this library and updates their consolidation status. This is useful if you have manually moved folders on disk.",
"LabelUpdateCoverDimensions": "Update Cover Dimensions",
"LabelUpdateCoverDimensionsHelp": "Detect and update cover width and height for all items in this library.",
"LabelClickForMoreInfo": "Click for more info",
"LabelClickToUseCurrentValue": "Click to use current value",
"LabelClosePlayer": "Close player",
@ -810,6 +812,7 @@
"MessageConfirmRemoveItemsWithIssues": "Are you sure you want to remove all items with issues?",
"MessageConfirmCleanupAuthors": "Are you sure you want to remove all authors with no books in this library?",
"MessageConfirmUpdateConsolidationStatus": "Are you sure you want to update the consolidation status for all items in this library? This will re-calculate the 'Not Consolidated' badge for every book.",
"MessageConfirmUpdateCoverDimensions": "Are you sure you want to update cover dimensions for all items in this library? This will use ffprobe to detect dimensions for each cover.",
"MessageConfirmRemoveEpisodeNote": "Note: This does not delete the audio file unless toggling \"Hard delete file\"",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
@ -1194,6 +1197,8 @@
"ToastUserRootRequireName": "Must enter a root username",
"ToastUpdateConsolidationStatusSuccess": "Successfully updated consolidation status for {0} items.",
"ToastUpdateConsolidationStatusFailed": "Failed to update consolidation status.",
"ToastUpdateCoverDimensionsSuccess": "Successfully updated cover dimensions for {0} items.",
"ToastUpdateCoverDimensionsFailed": "Failed to update cover dimensions.",
"TooltipAddChapters": "Add chapter(s)",
"TooltipAddOneSecond": "Add 1 second",
"TooltipAdjustChapterStart": "Click to adjust start time",

View file

@ -1,7 +1,7 @@
{
"name": "audiobookshelf",
"version": "2.32.7",
"buildNumber": 1,
"version": "2.32.8",
"buildNumber": 2,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {

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