This commit is contained in:
kyle-in-the-cloud 2026-06-06 09:36:43 -07:00
parent cbda0360aa
commit 392bb4e290
11 changed files with 362 additions and 14 deletions

View file

@ -215,6 +215,7 @@ class LibraryItemController {
// Podcast specific
let isPodcastAutoDownloadUpdated = false
let isFetchEpisodeMetadataEnabled = false
if (req.libraryItem.isPodcast) {
if (mediaPayload.autoDownloadEpisodes !== undefined && req.libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
isPodcastAutoDownloadUpdated = true
@ -226,6 +227,11 @@ class LibraryItemController {
Logger.error(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${req.libraryItem.media.title}"`)
return res.status(400).send('Invalid auto download schedule cron expression')
}
// Check if fetchEpisodeMetadata is being toggled on
if (mediaPayload.fetchEpisodeMetadata && !req.libraryItem.media.fetchEpisodeMetadata) {
isFetchEpisodeMetadataEnabled = true
}
}
let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
@ -276,6 +282,14 @@ class LibraryItemController {
this.cronManager.checkUpdatePodcastCron(req.libraryItem)
}
// Fetch and populate episode metadata from RSS when toggle is turned on
if (isFetchEpisodeMetadataEnabled) {
const episodesUpdated = await this.podcastManager.populateEpisodeMetadataFromFeed(req.libraryItem)
if (episodesUpdated) {
await req.libraryItem.saveMetadataFile()
}
}
Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`)
SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)
}

View file

@ -250,6 +250,11 @@ class PodcastManager {
await libraryItem.media.save()
}
// Save episode metadata to metadata.json if fetchEpisodeMetadata is enabled
if (libraryItem.media.fetchEpisodeMetadata) {
await libraryItem.saveMetadataFile()
}
SocketAuthority.libraryItemEmitter('item_updated', libraryItem)
const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id)
podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded()
@ -739,5 +744,90 @@ class PodcastManager {
TaskManager.taskFinished(task)
Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`)
}
/**
* Fetch episode metadata from RSS feed and backfill missing fields on existing episodes
*
* @param {import('../models/LibraryItem')} libraryItem - must have media.podcastEpisodes loaded
* @returns {Promise<boolean>} - true if any episodes were updated
*/
async populateEpisodeMetadataFromFeed(libraryItem) {
const podcast = libraryItem.media
if (!podcast.feedURL) {
Logger.warn(`[PodcastManager] Cannot fetch episode metadata - no feed URL for "${podcast.title}"`)
return false
}
const feed = await getPodcastFeed(podcast.feedURL).catch((error) => {
Logger.error(`[PodcastManager] Failed to fetch feed for episode metadata population`, error)
return null
})
if (!feed?.episodes?.length) {
Logger.warn(`[PodcastManager] No episodes found in feed for "${podcast.title}"`)
return false
}
const episodes = podcast.podcastEpisodes || []
let hasUpdates = false
for (const episode of episodes) {
// Match by GUID first, then by title
const guid = episode.extraData?.guid
let matched = null
if (guid) {
matched = feed.episodes.find((fe) => fe.guid === guid)
}
if (!matched) {
const episodeTitle = (episode.title || '').trim().toLowerCase()
if (episodeTitle) {
matched = feed.episodes.find((fe) => (fe.title || '').trim().toLowerCase() === episodeTitle)
}
}
if (!matched) continue
let episodeUpdated = false
if (!episode.description && matched.description) {
episode.description = matched.description
episodeUpdated = true
}
if (!episode.season && matched.season) {
episode.season = matched.season
episodeUpdated = true
}
if (!episode.episode && matched.episode) {
episode.episode = matched.episode
episodeUpdated = true
}
if (!episode.episodeType && matched.episodeType) {
episode.episodeType = matched.episodeType
episodeUpdated = true
}
if (!episode.pubDate && matched.pubDate) {
episode.pubDate = matched.pubDate
episodeUpdated = true
}
if (!episode.subtitle && matched.subtitle) {
episode.subtitle = matched.subtitle
episodeUpdated = true
}
if (matched.guid && !episode.extraData?.guid) {
episode.extraData = { ...(episode.extraData || {}), guid: matched.guid }
episode.changed('extraData', true)
episodeUpdated = true
}
if (episodeUpdated) {
await episode.save()
hasUpdates = true
}
}
if (hasUpdates) {
Logger.info(`[PodcastManager] Populated episode metadata from feed for "${podcast.title}"`)
}
return hasUpdates
}
}
module.exports = PodcastManager

View file

@ -0,0 +1,68 @@
/**
* @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.36.0'
const migrationName = `${migrationVersion}-podcast-add-fetch-episode-metadata`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This migration script adds the fetchEpisodeMetadata column to the podcasts 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}`)
if (await queryInterface.tableExists('podcasts')) {
const tableDescription = await queryInterface.describeTable('podcasts')
if (!tableDescription.fetchEpisodeMetadata) {
logger.info(`${loggerPrefix} Adding fetchEpisodeMetadata column to podcasts table`)
await queryInterface.addColumn('podcasts', 'fetchEpisodeMetadata', {
type: queryInterface.sequelize.Sequelize.DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false
})
logger.info(`${loggerPrefix} Added fetchEpisodeMetadata column to podcasts table`)
} else {
logger.info(`${loggerPrefix} fetchEpisodeMetadata column already exists in podcasts table`)
}
} else {
logger.info(`${loggerPrefix} podcasts table does not exist`)
}
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This migration script removes the fetchEpisodeMetadata column from the podcasts 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}`)
if (await queryInterface.tableExists('podcasts')) {
const tableDescription = await queryInterface.describeTable('podcasts')
if (tableDescription.fetchEpisodeMetadata) {
logger.info(`${loggerPrefix} Removing fetchEpisodeMetadata column from podcasts table`)
await queryInterface.removeColumn('podcasts', 'fetchEpisodeMetadata')
logger.info(`${loggerPrefix} Removed fetchEpisodeMetadata column from podcasts table`)
} else {
logger.info(`${loggerPrefix} fetchEpisodeMetadata column does not exist in podcasts table`)
}
} else {
logger.info(`${loggerPrefix} podcasts table does not exist`)
}
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }

View file

@ -672,6 +672,20 @@ class LibraryItem extends Model {
explicit: !!mediaExpanded.explicit,
podcastType: mediaExpanded.podcastType
}
if (mediaExpanded.fetchEpisodeMetadata && mediaExpanded.podcastEpisodes?.length) {
jsonObject.episodes = mediaExpanded.podcastEpisodes.map((ep) => ({
title: ep.title,
description: ep.description || null,
season: ep.season || null,
episode: ep.episode || null,
episodeType: ep.episodeType || null,
pubDate: ep.pubDate || null,
guid: ep.extraData?.guid || null,
subtitle: ep.subtitle || null,
duration: ep.audioFile?.duration ? String(Math.round(ep.audioFile.duration)) : null
}))
}
}
return fsExtra

View file

@ -44,6 +44,8 @@ class Podcast extends Model {
/** @type {boolean} */
this.explicit
/** @type {boolean} */
this.fetchEpisodeMetadata
/** @type {boolean} */
this.autoDownloadEpisodes
/** @type {string} */
this.autoDownloadSchedule
@ -108,6 +110,7 @@ class Podcast extends Model {
language: typeof payload.metadata.language === 'string' ? payload.metadata.language : null,
podcastType: typeof payload.metadata.type === 'string' ? payload.metadata.type : null,
explicit: !!payload.metadata.explicit,
fetchEpisodeMetadata: !!payload.fetchEpisodeMetadata,
autoDownloadEpisodes: !!payload.autoDownloadEpisodes,
autoDownloadSchedule: autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule,
lastEpisodeCheck: new Date(),
@ -146,6 +149,7 @@ class Podcast extends Model {
podcastType: DataTypes.STRING,
explicit: DataTypes.BOOLEAN,
fetchEpisodeMetadata: DataTypes.BOOLEAN,
autoDownloadEpisodes: DataTypes.BOOLEAN,
autoDownloadSchedule: DataTypes.STRING,
lastEpisodeCheck: DataTypes.DATE,
@ -269,6 +273,10 @@ class Podcast extends Model {
hasUpdates = true
}
if (payload.fetchEpisodeMetadata !== undefined && payload.fetchEpisodeMetadata !== this.fetchEpisodeMetadata) {
this.fetchEpisodeMetadata = !!payload.fetchEpisodeMetadata
hasUpdates = true
}
if (payload.autoDownloadEpisodes !== undefined && payload.autoDownloadEpisodes !== this.autoDownloadEpisodes) {
this.autoDownloadEpisodes = !!payload.autoDownloadEpisodes
hasUpdates = true
@ -443,6 +451,7 @@ class Podcast extends Model {
coverPath: this.coverPath,
tags: [...(this.tags || [])],
episodes: this.podcastEpisodes.map((episode) => episode.toOldJSON(libraryItemId)),
fetchEpisodeMetadata: this.fetchEpisodeMetadata,
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
@ -459,6 +468,7 @@ class Podcast extends Model {
coverPath: this.coverPath,
tags: [...(this.tags || [])],
numEpisodes: this.podcastEpisodes?.length || 0,
fetchEpisodeMetadata: this.fetchEpisodeMetadata,
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
@ -483,6 +493,7 @@ class Podcast extends Model {
coverPath: this.coverPath,
tags: [...(this.tags || [])],
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)),
fetchEpisodeMetadata: this.fetchEpisodeMetadata,
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,

View file

@ -12,6 +12,71 @@ const fsExtra = require('../libs/fsExtra')
const PodcastEpisode = require('../models/PodcastEpisode')
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
const htmlSanitizer = require('../utils/htmlSanitizer')
const Logger = require('../Logger')
/**
* Match a podcast episode to an entry in the metadata episodes array
* @param {Object} episode - PodcastEpisode model or plain object
* @param {Object[]} metadataEpisodes - episodes array from metadata.json
* @returns {Object|null}
*/
function matchEpisodeMetadata(episode, metadataEpisodes) {
// Prefer GUID match
const guid = episode.extraData?.guid
if (guid) {
const match = metadataEpisodes.find((me) => me.guid === guid)
if (match) return match
}
// Fall back to title match (case-insensitive, trimmed)
const episodeTitle = (episode.title || '').trim().toLowerCase()
if (episodeTitle) {
const match = metadataEpisodes.find((me) => (me.title || '').trim().toLowerCase() === episodeTitle)
if (match) return match
}
return null
}
/**
* Backfill null episode fields from matched metadata
* @param {Object} episode - PodcastEpisode model or plain object
* @param {Object} matched - matched metadata entry
* @returns {boolean} whether any fields were updated
*/
function applyEpisodeMetadata(episode, matched) {
let updated = false
if (!episode.description && matched.description) {
episode.description = matched.description
updated = true
}
if (!episode.season && matched.season) {
episode.season = matched.season
updated = true
}
if (!episode.episode && matched.episode) {
episode.episode = matched.episode
updated = true
}
if (!episode.episodeType && matched.episodeType) {
episode.episodeType = matched.episodeType
updated = true
}
if (!episode.pubDate && matched.pubDate) {
episode.pubDate = matched.pubDate
updated = true
}
if (!episode.subtitle && matched.subtitle) {
episode.subtitle = matched.subtitle
updated = true
}
if (matched.guid && !episode.extraData?.guid) {
episode.extraData = { ...(episode.extraData || {}), guid: matched.guid }
if (typeof episode.changed === 'function') {
episode.changed('extraData', true)
}
updated = true
}
return updated
}
/**
* Metadata for podcasts pulled from files
@ -232,6 +297,17 @@ class PodcastScanner {
}
}
// Backfill episode metadata from metadata.json if fetchEpisodeMetadata is enabled
if (media.fetchEpisodeMetadata && podcastMetadata.episodes?.length) {
for (const episode of existingPodcastEpisodes) {
const matched = matchEpisodeMetadata(episode, podcastMetadata.episodes)
if (matched && applyEpisodeMetadata(episode, matched)) {
libraryScan.addLog(LogLevel.INFO, `Backfilled metadata for episode "${episode.title}" from metadata.json`)
await episode.save()
}
}
}
existingLibraryItem.media = media
let libraryItemUpdated = false
@ -312,6 +388,16 @@ class PodcastScanner {
podcastMetadata.podcastType = 'episodic'
}
// Backfill episode metadata from metadata.json if episodes data exists
if (podcastMetadata.episodes?.length) {
for (const newEpisode of newPodcastEpisodes) {
const matched = matchEpisodeMetadata(newEpisode, podcastMetadata.episodes)
if (matched && applyEpisodeMetadata(newEpisode, matched)) {
libraryScan.addLog(LogLevel.INFO, `Backfilled metadata for episode "${newEpisode.title}" from metadata.json`)
}
}
}
const podcastObject = {
...podcastMetadata,
autoDownloadEpisodes: false,
@ -445,6 +531,21 @@ class PodcastScanner {
explicit: !!libraryItem.media.explicit,
podcastType: libraryItem.media.podcastType
}
if (libraryItem.media.fetchEpisodeMetadata && libraryItem.media.podcastEpisodes?.length) {
jsonObject.episodes = libraryItem.media.podcastEpisodes.map((ep) => ({
title: ep.title,
description: ep.description || null,
season: ep.season || null,
episode: ep.episode || null,
episodeType: ep.episodeType || null,
pubDate: ep.pubDate || null,
guid: ep.extraData?.guid || null,
subtitle: ep.subtitle || null,
duration: ep.audioFile?.duration ? String(Math.round(ep.audioFile.duration)) : null
}))
}
return fsExtra
.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2))
.then(async () => {

View file

@ -84,6 +84,16 @@ function parseJsonMetadataText(text, mediaType) {
validated.series = validated.series.map((series) => parseSeriesString.parse(series)).filter(Boolean)
}
if (mediaType === 'podcast' && 'episodes' in abmetadataData) {
if (abmetadataData.episodes === null) {
validated.episodes = []
} else if (Array.isArray(abmetadataData.episodes)) {
validated.episodes = cleanEpisodesArray(abmetadataData.episodes)
} else {
Logger.warn(`[abmetadataGenerator] Invalid metadata key "episodes" expected array, got ${typeof abmetadataData.episodes}`)
}
}
if (mediaType === 'book' && 'chapters' in abmetadataData) {
if (abmetadataData.chapters === null) {
validated.chapters = []
@ -148,6 +158,29 @@ function validateMetadataValue(key, value, expectedType) {
return undefined
}
/**
* @param {Object[]} episodesArray
* @returns {Object[]}
*/
function cleanEpisodesArray(episodesArray) {
const episodes = []
for (const ep of episodesArray) {
if (!ep.title || typeof ep.title !== 'string') continue
episodes.push({
title: ep.title,
description: typeof ep.description === 'string' ? ep.description : null,
season: typeof ep.season === 'string' ? ep.season : null,
episode: typeof ep.episode === 'string' ? ep.episode : null,
episodeType: typeof ep.episodeType === 'string' ? ep.episodeType : null,
pubDate: typeof ep.pubDate === 'string' ? ep.pubDate : null,
guid: typeof ep.guid === 'string' ? ep.guid : null,
subtitle: typeof ep.subtitle === 'string' ? ep.subtitle : null,
duration: typeof ep.duration === 'string' ? ep.duration : null
})
}
return episodes
}
/**
* @param {Object[]} chaptersArray
* @param {string} mediaTitle