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

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