mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-11-24 20:05:41 +01:00
Add support to custom episode cover art
This commit is contained in:
parent
0c7b738b7c
commit
f703fb60da
@ -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
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
*
|
||||
|
||||
@ -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
|
||||
|
||||
133
server/migrations/v2.30.1-episode-cover-support.js
Normal file
133
server/migrations/v2.30.1-episode-cover-support.js
Normal 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 }
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user