mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 18:01:42 +00:00
update
This commit is contained in:
parent
cbda0360aa
commit
392bb4e290
11 changed files with 362 additions and 14 deletions
16
.github/workflows/docker-build.yml
vendored
16
.github/workflows/docker-build.yml
vendored
|
|
@ -6,7 +6,7 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
description: 'Docker Tag'
|
||||
description: 'Docker Tag (e.g. latest)'
|
||||
required: true
|
||||
default: 'latest'
|
||||
push:
|
||||
|
|
@ -22,7 +22,7 @@ on:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ !contains(github.event.head_commit.message, 'skip ci') && github.repository == 'advplyr/audiobookshelf' }}
|
||||
if: ${{ !contains(github.event.head_commit.message, 'skip ci') }}
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
|
|
@ -33,8 +33,9 @@ jobs:
|
|||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
||||
images: entree3000/audiobookshelf
|
||||
tags: |
|
||||
type=edge,branch=main
|
||||
type=edge,branch=master
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
|
|
@ -58,17 +59,10 @@ jobs:
|
|||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Login to ghcr
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GHCR_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
||||
tags: ${{ github.event.inputs.tags && format('entree3000/audiobookshelf:{0}', github.event.inputs.tags) || steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
|
|
|||
|
|
@ -31,9 +31,16 @@
|
|||
|
||||
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoDownloadEpisodes" v-model="cronExpression" />
|
||||
</template>
|
||||
|
||||
<template v-if="feedUrl">
|
||||
<div class="flex items-center justify-between mb-4 mt-6">
|
||||
<p class="text-base md:text-xl font-semibold">{{ $strings.HeaderEpisodeMetadata }}</p>
|
||||
<ui-checkbox v-model="enableFetchEpisodeMetadata" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="feedUrl || autoDownloadEpisodes" class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white/5">
|
||||
<div v-if="feedUrl || autoDownloadEpisodes || fetchEpisodeMetadata" class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white/5">
|
||||
<div class="flex items-center px-2 md:px-4">
|
||||
<div class="grow" />
|
||||
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'bg-success' : 'bg-primary'" class="mx-2">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdatesWereNecessary }}</ui-btn>
|
||||
|
|
@ -54,6 +61,7 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
enableAutoDownloadEpisodes: false,
|
||||
enableFetchEpisodeMetadata: false,
|
||||
cronExpression: null,
|
||||
newMaxEpisodesToKeep: 0,
|
||||
newMaxNewEpisodesToDownload: 0
|
||||
|
|
@ -94,6 +102,9 @@ export default {
|
|||
autoDownloadEpisodes() {
|
||||
return !!this.media.autoDownloadEpisodes
|
||||
},
|
||||
fetchEpisodeMetadata() {
|
||||
return !!this.media.fetchEpisodeMetadata
|
||||
},
|
||||
autoDownloadSchedule() {
|
||||
return this.media.autoDownloadSchedule
|
||||
},
|
||||
|
|
@ -104,7 +115,7 @@ export default {
|
|||
return this.media.maxNewEpisodesToDownload
|
||||
},
|
||||
isUpdated() {
|
||||
return this.autoDownloadSchedule !== this.cronExpression || this.autoDownloadEpisodes !== this.enableAutoDownloadEpisodes || this.maxEpisodesToKeep !== Number(this.newMaxEpisodesToKeep) || this.maxNewEpisodesToDownload !== Number(this.newMaxNewEpisodesToDownload)
|
||||
return this.autoDownloadSchedule !== this.cronExpression || this.autoDownloadEpisodes !== this.enableAutoDownloadEpisodes || this.maxEpisodesToKeep !== Number(this.newMaxEpisodesToKeep) || this.maxNewEpisodesToDownload !== Number(this.newMaxNewEpisodesToDownload) || this.fetchEpisodeMetadata !== this.enableFetchEpisodeMetadata
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -152,6 +163,10 @@ export default {
|
|||
updatePayload.maxNewEpisodesToDownload = this.newMaxNewEpisodesToDownload
|
||||
}
|
||||
|
||||
if (this.enableFetchEpisodeMetadata !== this.fetchEpisodeMetadata) {
|
||||
updatePayload.fetchEpisodeMetadata = this.enableFetchEpisodeMetadata
|
||||
}
|
||||
|
||||
this.updateDetails(updatePayload)
|
||||
},
|
||||
async updateDetails(updatePayload) {
|
||||
|
|
@ -175,6 +190,7 @@ export default {
|
|||
},
|
||||
init() {
|
||||
this.enableAutoDownloadEpisodes = this.autoDownloadEpisodes
|
||||
this.enableFetchEpisodeMetadata = this.fetchEpisodeMetadata
|
||||
this.cronExpression = this.autoDownloadSchedule
|
||||
this.newMaxEpisodesToKeep = this.maxEpisodesToKeep
|
||||
this.newMaxNewEpisodesToDownload = this.maxNewEpisodesToDownload
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@
|
|||
<div class="px-4">
|
||||
<ui-checkbox v-model="podcast.autoDownloadEpisodes" :label="$strings.LabelAutoDownloadEpisodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm md:text-base font-semibold" />
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<ui-checkbox v-model="podcast.fetchEpisodeMetadata" :label="$strings.LabelFetchEpisodeMetadata" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm md:text-base font-semibold" />
|
||||
</div>
|
||||
<ui-btn color="bg-success" @click="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -94,6 +97,7 @@ export default {
|
|||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
autoDownloadEpisodes: false,
|
||||
fetchEpisodeMetadata: false,
|
||||
language: '',
|
||||
explicit: false,
|
||||
type: ''
|
||||
|
|
@ -196,7 +200,8 @@ export default {
|
|||
explicit: this.podcast.explicit,
|
||||
type: this.podcast.type
|
||||
},
|
||||
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
|
||||
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes,
|
||||
fetchEpisodeMetadata: this.podcast.fetchEpisodeMetadata
|
||||
}
|
||||
}
|
||||
console.log('Podcast payload', podcastPayload)
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@
|
|||
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
||||
"HeaderSavedMediaProgress": "Saved Media Progress",
|
||||
"HeaderSchedule": "Schedule",
|
||||
"HeaderEpisodeMetadata": "Episode Metadata",
|
||||
"HeaderScheduleEpisodeDownloads": "Schedule Automatic Episode Downloads",
|
||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
||||
"HeaderSession": "Session",
|
||||
|
|
@ -256,6 +257,7 @@
|
|||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
"LabelAuthors": "Authors",
|
||||
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
||||
"LabelFetchEpisodeMetadata": "Store episode metadata from RSS feed",
|
||||
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||
"LabelAutoLaunch": "Auto Launch",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue