mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-01 05:29:41 +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
20
artifacts/2026-02-15/badge.md
Normal file
20
artifacts/2026-02-15/badge.md
Normal 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.
|
||||
37
artifacts/2026-02-17/badge.md
Normal file
37
artifacts/2026-02-17/badge.md
Normal 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).
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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