Add support to custom episode cover art

This commit is contained in:
mfcar 2025-11-06 18:29:35 +00:00
parent 0c7b738b7c
commit f703fb60da
No known key found for this signature in database
16 changed files with 446 additions and 20 deletions

View File

@ -1,7 +1,7 @@
<template>
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2">
<div class="absolute left-2 top-2 lg:left-4 cursor-pointer">
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :cover-src="currentCoverSrc" />
</div>
<div class="flex items-start mb-6 lg:mb-0" :class="isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
<div class="min-w-0 w-full">
@ -178,6 +178,12 @@ export default {
},
playerQueueItems() {
return this.$store.state.playerQueueItems || []
},
currentCoverSrc() {
if (this.streamEpisode?.coverPath) {
return `${this.$store.state.routerBasePath}/api/podcasts/${this.libraryItemId}/episode/${this.streamEpisode.id}/cover?ts=${this.streamEpisode.updatedAt}`
}
return null
}
},
methods: {
@ -397,7 +403,7 @@ export default {
album: this.mediaMetadata.seriesName || '',
artwork: [
{
src: this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
src: this.currentCoverSrc || this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
}
],
chapterInfo

View File

@ -230,6 +230,9 @@ export default {
return this.store.getters['globals/getPlaceholderCoverSrc']
},
bookCoverSrc() {
if (this.recentEpisode?.coverPath) {
return `${this.store.state.routerBasePath}/api/podcasts/${this.libraryItemId}/episode/${this.recentEpisode.id}/cover?ts=${this.recentEpisode.updatedAt}`
}
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
},
libraryItemId() {
@ -872,7 +875,7 @@ export default {
subtitle: this.mediaMetadata.title,
caption: this.recentEpisode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(this.recentEpisode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: this.recentEpisode.audioFile.duration || null,
coverPath: this.media.coverPath || null
coverPath: this.recentEpisode.coverPath || this.media.coverPath || null
}
} else {
queueItem = {
@ -1032,7 +1035,7 @@ export default {
subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null
coverPath: episode.coverPath || this.media.coverPath || null
})
}
}

View File

@ -45,7 +45,11 @@ export default {
default: 120
},
expandOnClick: Boolean,
bookCoverAspectRatio: Number
bookCoverAspectRatio: Number,
coverSrc: {
type: String,
default: null
}
},
data() {
return {
@ -100,6 +104,7 @@ export default {
return store.getters['globals/getPlaceholderCoverSrc']
},
fullCoverUrl() {
if (this.coverSrc) return this.coverSrc
if (!this.libraryItem) return null
const store = this.$store || this.$nuxt.$store
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)

View File

@ -8,7 +8,7 @@
<div ref="wrapper" class="p-4 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
<div class="flex mb-4">
<div class="w-12 h-12">
<covers-book-cover :library-item="libraryItem" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover :library-item="libraryItem" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :cover-src="episodeCoverSrc" />
</div>
<div class="grow px-2">
<p class="text-base mb-1">{{ podcastTitle }}</p>
@ -102,6 +102,12 @@ export default {
},
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
episodeCoverSrc() {
if (this.episode?.coverPath) {
return `${this.$store.state.routerBasePath}/api/podcasts/${this.libraryItem.id}/episode/${this.episode.id}/cover?ts=${this.episode.updatedAt}`
}
return null
}
},
methods: {

View File

@ -8,11 +8,11 @@
<p v-if="!recentEpisodes.length && !processing" class="text-center text-xl">{{ $strings.MessageNoEpisodes }}</p>
<template v-for="(episode, index) in episodesMapped">
<div :key="episode.id" class="flex py-5 cursor-pointer relative" @click.stop="clickEpisode(episode)">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId, episode.updatedAt)" :width="96" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="hidden md:block" />
<covers-preview-cover :src="getEpisodeCoverSrc(episode)" :width="96" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="hidden md:block" />
<div class="grow pl-4 max-w-2xl">
<!-- mobile -->
<div class="flex md:hidden mb-2">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId, episode.updatedAt)" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
<covers-preview-cover :src="getEpisodeCoverSrc(episode)" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
<div class="grow px-2">
<div class="flex items-center">
<div class="flex" @click.stop>
@ -145,6 +145,13 @@ export default {
}
},
methods: {
getEpisodeCoverSrc(episode) {
if (episode.coverPath) {
return `${this.$store.state.routerBasePath}/api/podcasts/${episode.libraryItemId}/episode/${episode.id}/cover?ts=${episode.updatedAt}`
}
// Fallback to podcast cover
return this.$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId, episode.updatedAt)
},
async toggleEpisodeFinished(episode, confirmed = false) {
if (this.episodesProcessingMap[episode.id]) {
console.warn('Episode is already processing')
@ -236,7 +243,7 @@ export default {
subtitle: episode.podcast.metadata.title,
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.duration || null,
coverPath: episode.podcast.coverPath || null
coverPath: episode.coverPath || episode.podcast.coverPath || null
})
}
}
@ -273,7 +280,7 @@ export default {
subtitle: episode.podcast.metadata.title,
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.duration || null,
coverPath: episode.podcast.coverPath || null
coverPath: episode.coverPath || episode.podcast.coverPath || null
}
this.$store.commit('addItemToQueue', queueItem)
}

View File

@ -18,7 +18,11 @@ const { escapeRegExp } = require('./utils')
class Auth {
constructor() {
const escapedRouterBasePath = escapeRegExp(global.RouterBasePath)
this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)]
this.ignorePatterns = [
new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`),
new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`),
new RegExp(`^(${escapedRouterBasePath}/api)?/podcasts/[^/]+/episode/[^/]+/cover$`)
]
/** @type {import('express-rate-limit').RateLimitRequestHandler} */
this.authRateLimiter = RateLimiterFactory.getAuthRateLimiter()

View File

@ -13,6 +13,7 @@ const htmlSanitizer = require('../utils/htmlSanitizer')
const Scanner = require('../scanner/Scanner')
const CoverManager = require('../managers/CoverManager')
const CacheManager = require('../managers/CacheManager')
/**
* @typedef RequestUserObject
@ -456,6 +457,34 @@ class PodcastController {
res.json(episode.toOldJSON(req.libraryItem.id))
}
/**
* GET: /api/podcasts/:id/episode/:episodeId/cover
* Get episode cover image
* Fallback to podcast cover if episode has no custom cover art
*
* @param {import('express').Request} req
* @param {Response} res
*/
async getEpisodeCover(req, res) {
const libraryItemId = req.params.id
const episodeId = req.params.episodeId
const { query: { width, height, format } } = req
if (!libraryItemId || !episodeId) {
return res.sendStatus(400)
}
if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400')
const options = {
format: format || 'webp',
width: width ? parseInt(width) : null,
height: height ? parseInt(height) : null
}
return CacheManager.handleEpisodeCoverCache(res, libraryItemId, episodeId, options)
}
/**
* DELETE: /api/podcasts/:id/episode/:episodeId
*

View File

@ -77,7 +77,66 @@ class CacheManager {
readStream.pipe(res)
}
purgeCoverCache(libraryItemId) {
/**
* @param {import('express').Response} res
* @param {string} libraryItemId
* @param {string} episodeId
* @param {{ format?: string, width?: number, height?: number }} options
* @returns {Promise<void>}
*/
async handleEpisodeCoverCache(res, libraryItemId, episodeId, options = {}) {
const format = options.format || 'webp'
const width = options.width || 400
const height = options.height || null
res.type(`image/${format}`)
const cachePath = Path.join(this.CoverCachePath, `${libraryItemId}_episode_${episodeId}_${width}${height ? `x${height}` : ''}`) + '.' + format
// Cache exists
if (await fs.pathExists(cachePath)) {
if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + cachePath)
Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
}
const r = fs.createReadStream(cachePath)
const ps = new stream.PassThrough()
stream.pipeline(r, ps, (err) => {
if (err) {
console.log(err)
return res.sendStatus(500)
}
})
return ps.pipe(res)
}
const episode = await Database.podcastEpisodeModel.findByPk(episodeId)
if (!episode || !episode.coverPath || !(await fs.pathExists(episode.coverPath))) {
// Fallback to podcast cover
return this.handleCoverCache(res, libraryItemId, options)
}
const writtenFile = await resizeImage(episode.coverPath, cachePath, width, height)
if (!writtenFile) return res.sendStatus(500)
if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + writtenFile)
Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
}
var readStream = fs.createReadStream(writtenFile)
readStream.pipe(res)
}
purgeCoverCache(libraryItemId, episodeId = null) {
if (libraryItemId && episodeId) {
return this.purgeEntityCache(`${libraryItemId}_episode_${episodeId}`, this.CoverCachePath)
} else if (!libraryItemId && episodeId) {
return this.purgeEntityCache(`episode_${episodeId}`, this.CoverCachePath)
}
return this.purgeEntityCache(libraryItemId, this.CoverCachePath)
}

View File

@ -333,5 +333,89 @@ class CoverManager {
}
}
}
/**
* @param {string} episodeId
* @returns {string} directory path
*/
getEpisodeCoverDirectory(episodeId) {
return Path.posix.join(global.MetadataPath, 'episodes', episodeId)
}
/**
* @param {string} url - Image URL to download
* @param {string} episodeId
* @returns {Promise<{error:string}|{cover:string}>}
*/
async saveEpisodeCoverFromUrl(url, episodeId) {
try {
const coverDirPath = this.getEpisodeCoverDirectory(episodeId)
await fs.ensureDir(coverDirPath)
const temppath = Path.posix.join(coverDirPath, 'cover')
const success = await downloadImageFile(url, temppath)
.then(() => true)
.catch((err) => {
Logger.error(`[CoverManager] Download episode cover failed for "${url}"`, err)
return false
})
if (!success) {
return {
error: 'Failed to download episode cover from url'
}
}
const imgtype = await this.checkFileIsValidImage(temppath, true)
if (imgtype.error) {
return imgtype
}
const coverFullPath = Path.posix.join(coverDirPath, `cover.${imgtype.ext}`)
await fs.rename(temppath, coverFullPath)
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
await CacheManager.purgeCoverCache(null, episodeId)
Logger.info(`[CoverManager] Downloaded episode cover "${coverFullPath}" from url "${url}"`)
return {
cover: coverFullPath
}
} catch (error) {
Logger.error(`[CoverManager] Fetch episode cover from url "${url}" failed`, error)
return {
error: 'Failed to fetch episode cover from url'
}
}
}
/**
* @param {import('../models/Book').AudioFileObject} audioFile
* @param {string} episodeId
* @returns {Promise<string|null>} returns cover path or null
*/
async extractEpisodeCoverFromAudio(audioFile, episodeId) {
if (!audioFile?.embeddedCoverArt) return null
const coverDirPath = this.getEpisodeCoverDirectory(episodeId)
await fs.ensureDir(coverDirPath)
const coverFilename = audioFile.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
const coverFilePath = Path.join(coverDirPath, coverFilename)
const coverAlreadyExists = await fs.pathExists(coverFilePath)
if (coverAlreadyExists) {
Logger.debug(`[CoverManager] Episode cover already exists at "${coverFilePath}"`)
return null
}
const success = await extractCoverArt(audioFile.metadata.path, coverFilePath)
if (success) {
await CacheManager.purgeCoverCache(null, episodeId)
Logger.info(`[CoverManager] Extracted episode cover from audio file to "${coverFilePath}"`)
return coverFilePath
}
return null
}
}
module.exports = new CoverManager()

View File

@ -206,6 +206,29 @@ class PodcastManager {
const podcastEpisode = await Database.podcastEpisodeModel.createFromRssPodcastEpisode(this.currentDownload.rssPodcastEpisode, libraryItem.media.id, audioFile)
if (podcastEpisode.imageURL) {
Logger.debug(`[PodcastManager] Downloading episode cover from RSS feed URL: ${podcastEpisode.imageURL}`)
const coverResult = await CoverManager.saveEpisodeCoverFromUrl(podcastEpisode.imageURL, podcastEpisode.id)
if (coverResult.cover) {
podcastEpisode.coverPath = coverResult.cover
await podcastEpisode.save()
Logger.info(`[PodcastManager] Successfully saved episode cover for "${podcastEpisode.title}"`)
} else if (coverResult.error) {
Logger.warn(`[PodcastManager] Failed to download episode cover: ${coverResult.error}`)
}
}
// Extract embedded cover art as fallback if no RSS cover (itunes:image)
if (!podcastEpisode.coverPath && audioFile.embeddedCoverArt) {
Logger.debug(`[PodcastManager] Extracting embedded cover art from episode audio file`)
const coverPath = await CoverManager.extractEpisodeCoverFromAudio(audioFile, podcastEpisode.id)
if (coverPath) {
podcastEpisode.coverPath = coverPath
await podcastEpisode.save()
Logger.info(`[PodcastManager] Successfully extracted embedded cover for "${podcastEpisode.title}"`)
}
}
libraryItem.libraryFiles.push(libraryFile.toJSON())
// Re-calculating library item size because this wasnt being updated properly for podcasts in v2.20.0 and below
let libraryItemSize = 0

View File

@ -0,0 +1,133 @@
/**
* @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.30.1'
const migrationName = `${migrationVersion}-episode-cover-support`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This migration adds support for episode-specific cover art by adding:
* - coverPath and imageURL columns to podcastEpisodes table
* - episodeCoverURL column to feedEpisodes table
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
// Upgrade podcastEpisodes table
if (await queryInterface.tableExists('podcastEpisodes')) {
const podcastEpisodesDescription = await queryInterface.describeTable('podcastEpisodes')
// Add coverPath column if it doesn't exist
if (!podcastEpisodesDescription.coverPath) {
logger.info(`${loggerPrefix} Adding coverPath column to podcastEpisodes table`)
await queryInterface.addColumn('podcastEpisodes', 'coverPath', {
type: queryInterface.sequelize.Sequelize.DataTypes.STRING,
allowNull: true
})
logger.info(`${loggerPrefix} Added coverPath column to podcastEpisodes table`)
} else {
logger.info(`${loggerPrefix} coverPath column already exists in podcastEpisodes table`)
}
// Add imageURL column if it doesn't exist
if (!podcastEpisodesDescription.imageURL) {
logger.info(`${loggerPrefix} Adding imageURL column to podcastEpisodes table`)
await queryInterface.addColumn('podcastEpisodes', 'imageURL', {
type: queryInterface.sequelize.Sequelize.DataTypes.STRING,
allowNull: true
})
logger.info(`${loggerPrefix} Added imageURL column to podcastEpisodes table`)
} else {
logger.info(`${loggerPrefix} imageURL column already exists in podcastEpisodes table`)
}
} else {
logger.info(`${loggerPrefix} podcastEpisodes table does not exist`)
}
// Upgrade feedEpisodes table
if (await queryInterface.tableExists('feedEpisodes')) {
const feedEpisodesDescription = await queryInterface.describeTable('feedEpisodes')
// Add episodeCoverURL column if it doesn't exist
if (!feedEpisodesDescription.episodeCoverURL) {
logger.info(`${loggerPrefix} Adding episodeCoverURL column to feedEpisodes table`)
await queryInterface.addColumn('feedEpisodes', 'episodeCoverURL', {
type: queryInterface.sequelize.Sequelize.DataTypes.STRING,
allowNull: true
})
logger.info(`${loggerPrefix} Added episodeCoverURL column to feedEpisodes table`)
} else {
logger.info(`${loggerPrefix} episodeCoverURL column already exists in feedEpisodes table`)
}
} else {
logger.info(`${loggerPrefix} feedEpisodes table does not exist`)
}
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This migration removes episode-specific cover art support by removing:
* - coverPath and imageURL columns from podcastEpisodes table
* - episodeCoverURL column from feedEpisodes table
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
// Downgrade podcastEpisodes table
if (await queryInterface.tableExists('podcastEpisodes')) {
const podcastEpisodesDescription = await queryInterface.describeTable('podcastEpisodes')
// Remove coverPath column if it exists
if (podcastEpisodesDescription.coverPath) {
logger.info(`${loggerPrefix} Removing coverPath column from podcastEpisodes table`)
await queryInterface.removeColumn('podcastEpisodes', 'coverPath')
logger.info(`${loggerPrefix} Removed coverPath column from podcastEpisodes table`)
} else {
logger.info(`${loggerPrefix} coverPath column does not exist in podcastEpisodes table`)
}
// Remove imageURL column if it exists
if (podcastEpisodesDescription.imageURL) {
logger.info(`${loggerPrefix} Removing imageURL column from podcastEpisodes table`)
await queryInterface.removeColumn('podcastEpisodes', 'imageURL')
logger.info(`${loggerPrefix} Removed imageURL column from podcastEpisodes table`)
} else {
logger.info(`${loggerPrefix} imageURL column does not exist in podcastEpisodes table`)
}
} else {
logger.info(`${loggerPrefix} podcastEpisodes table does not exist`)
}
// Downgrade feedEpisodes table
if (await queryInterface.tableExists('feedEpisodes')) {
const feedEpisodesDescription = await queryInterface.describeTable('feedEpisodes')
// Remove episodeCoverURL column if it exists
if (feedEpisodesDescription.episodeCoverURL) {
logger.info(`${loggerPrefix} Removing episodeCoverURL column from feedEpisodes table`)
await queryInterface.removeColumn('feedEpisodes', 'episodeCoverURL')
logger.info(`${loggerPrefix} Removed episodeCoverURL column from feedEpisodes table`)
} else {
logger.info(`${loggerPrefix} episodeCoverURL column does not exist in feedEpisodes table`)
}
} else {
logger.info(`${loggerPrefix} feedEpisodes table does not exist`)
}
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }

View File

@ -39,6 +39,8 @@ class FeedEpisode extends Model {
this.filePath
/** @type {boolean} */
this.explicit
/** @type {string} */
this.episodeCoverURL
/** @type {UUIDV4} */
this.feedId
/** @type {Date} */
@ -57,6 +59,12 @@ class FeedEpisode extends Model {
*/
static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisodeId = null) {
const episodeId = existingEpisodeId || uuidv4()
let episodeCoverURL = null
if (episode.coverPath) {
episodeCoverURL = `/api/podcasts/${libraryItemExpanded.id}/episode/${episode.id}/cover`
}
return {
id: episodeId,
title: episode.title,
@ -73,6 +81,7 @@ class FeedEpisode extends Model {
duration: episode.audioFile.duration,
filePath: episode.audioFile.metadata.path,
explicit: libraryItemExpanded.media.explicit,
episodeCoverURL,
feedId: feed.id
}
}
@ -106,7 +115,7 @@ class FeedEpisode extends Model {
feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisode?.id))
}
Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit', 'episodeCoverURL'] })
}
/**
@ -203,7 +212,7 @@ class FeedEpisode extends Model {
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles, i, existingEpisode?.id))
}
Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit', 'episodeCoverURL'] })
}
/**
@ -240,7 +249,7 @@ class FeedEpisode extends Model {
}
}
Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit', 'episodeCoverURL'] })
}
/**
@ -268,7 +277,8 @@ class FeedEpisode extends Model {
episodeType: DataTypes.STRING,
duration: DataTypes.FLOAT,
filePath: DataTypes.STRING,
explicit: DataTypes.BOOLEAN
explicit: DataTypes.BOOLEAN,
episodeCoverURL: DataTypes.STRING
},
{
sequelize,
@ -328,6 +338,9 @@ class FeedEpisode extends Model {
if (this.description) {
customElements.push({ 'itunes:summary': { _cdata: this.description } })
}
if (this.episodeCoverURL) {
customElements.push({ 'itunes:image': { _attr: { href: `${hostPrefix}${this.episodeCoverURL}` } } })
}
return {
title: this.title,

View File

@ -45,6 +45,10 @@ class PodcastEpisode extends Model {
/** @type {Object} */
this.extraData
/** @type {string} */
this.coverPath
/** @type {string} */
this.imageURL
/** @type {string} */
this.podcastId
/** @type {Date} */
this.createdAt
@ -75,7 +79,8 @@ class PodcastEpisode extends Model {
podcastId,
audioFile: audioFile.toJSON(),
chapters: [],
extraData: {}
extraData: {},
imageURL: rssPodcastEpisode.image || null
}
if (rssPodcastEpisode.guid) {
podcastEpisode.extraData.guid = rssPodcastEpisode.guid
@ -117,7 +122,9 @@ class PodcastEpisode extends Model {
audioFile: DataTypes.JSON,
chapters: DataTypes.JSON,
extraData: DataTypes.JSON
extraData: DataTypes.JSON,
coverPath: DataTypes.STRING,
imageURL: DataTypes.STRING
},
{
sequelize,
@ -158,6 +165,22 @@ class PodcastEpisode extends Model {
return this.audioFile?.duration || 0
}
/**
* Check if episode has a custom cover
* @returns {boolean}
*/
hasCover() {
return !!this.coverPath
}
/**
* Get the cover path for this episode
* @returns {string|null}
*/
getCoverPath() {
return this.coverPath || null
}
/**
* Used for matching the episode with an episode in the RSS feed
*
@ -223,7 +246,9 @@ class PodcastEpisode extends Model {
audioFile: structuredClone(this.audioFile),
publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
updatedAt: this.updatedAt.valueOf(),
coverPath: this.coverPath || null,
imageURL: this.imageURL || null
}
}

View File

@ -251,6 +251,7 @@ class ApiRouter {
this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))
this.router.post('/podcasts/:id/match-episodes', PodcastController.middleware.bind(this), PodcastController.quickMatchEpisodes.bind(this))
this.router.get('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.getEpisode.bind(this))
this.router.get('/podcasts/:id/episode/:episodeId/cover', PodcastController.getEpisodeCover.bind(this))
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))
this.router.delete('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.removeEpisode.bind(this))

View File

@ -115,6 +115,16 @@ class PodcastScanner {
AudioFileScanner.setPodcastEpisodeMetadataFromAudioMetaTags(podcastEpisode, libraryScan)
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${podcastEpisode.title}" keys changed [${podcastEpisode.changed()?.join(', ')}]`)
await podcastEpisode.save()
// Extract embedded cover art if episode doesn't have one
if (!podcastEpisode.coverPath && audioFile.embeddedCoverArt) {
const coverPath = await CoverManager.extractEpisodeCoverFromAudio(audioFile, podcastEpisode.id)
if (coverPath) {
podcastEpisode.coverPath = coverPath
await podcastEpisode.save()
libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover for episode "${podcastEpisode.title}"`)
}
}
}
}
@ -153,6 +163,17 @@ class PodcastScanner {
AudioFileScanner.setPodcastEpisodeMetadataFromAudioMetaTags(newPodcastEpisode, libraryScan)
libraryScan.addLog(LogLevel.INFO, `New Podcast episode "${newPodcastEpisode.title}" added`)
await newPodcastEpisode.save()
// Extract embedded cover art from new episode
if (newAudioFile.embeddedCoverArt) {
const coverPath = await CoverManager.extractEpisodeCoverFromAudio(newAudioFile, newPodcastEpisode.id)
if (coverPath) {
newPodcastEpisode.coverPath = coverPath
await newPodcastEpisode.save()
libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover for new episode "${newPodcastEpisode.title}"`)
}
}
existingPodcastEpisodes.push(newPodcastEpisode)
}
}

View File

@ -33,6 +33,7 @@ const Fuse = require('../libs/fusejs')
* @property {string} chaptersUrl
* @property {string} chaptersType
* @property {RssPodcastChapter[]} chapters
* @property {string|null} image - Episode-specific iTunes image URL
*/
/**
@ -211,6 +212,11 @@ function extractEpisodeData(item) {
}
}
// Extract episode image
if (item['itunes:image']?.[0]?.['$']?.href) {
episode.image = item['itunes:image'][0]['$'].href
}
const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
arrayFields.forEach((key) => {
const cleanKey = key.split(':').pop()
@ -282,7 +288,8 @@ function cleanEpisodeData(data) {
guid: data.guid || null,
chaptersUrl: data.chaptersUrl || null,
chaptersType: data.chaptersType || null,
chapters: data.chapters || []
chapters: data.chapters || [],
image: data.image || null
}
}