mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-28 22:59:38 +00:00
Add support to custom episode cover art
This commit is contained in:
parent
0c7b738b7c
commit
f703fb60da
16 changed files with 446 additions and 20 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue