- Add new migration to add an autoGenerateChapters column in the Podcasts table

- Bump minor version (I wasn't sure if this was needed for the migration)
- Feature is now controlled by the field in the podcast database object
- Move parsing code and tests to existing utils/parsers/ dir
- Add more test cases
This commit is contained in:
Harry Rose 2026-03-16 18:42:01 +00:00
parent 12b04faed2
commit 4907e70a48
10 changed files with 393 additions and 200 deletions

View file

@ -1,5 +1,6 @@
const { DataTypes, Model } = require('sequelize')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
const parsePodcastDescriptionForChapters = require('../utils/parsers/parsePodcastDescriptionForChapters')
const Logger = require('../Logger')
/**
* @typedef ChapterObject
@ -57,9 +58,10 @@ class PodcastEpisode extends Model {
*
* @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode
* @param {string} podcastId
* @param {boolean} autoGenerateChapters
* @param {import('../objects/files/AudioFile')} audioFile
*/
static async createFromRssPodcastEpisode(rssPodcastEpisode, podcastId, audioFile) {
static async createFromRssPodcastEpisode(rssPodcastEpisode, podcastId, autoGenerateChapters, audioFile) {
const podcastEpisode = {
index: null,
season: rssPodcastEpisode.season,
@ -86,11 +88,10 @@ class PodcastEpisode extends Model {
podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch }))
} else if (rssPodcastEpisode.chapters?.length) {
podcastEpisode.chapters = rssPodcastEpisode.chapters.map((ch) => ({ ...ch }))
} else {
} else if (autoGenerateChapters) {
Logger.info("[PodcastEpisode] New episode doesn't have chapters, attempting to generate them from timestamps", rssPodcastEpisode.title)
try {
let autoGeneratedChapters = PodcastEpisode.autoGenerateChaptersFromTimestamps(podcastEpisode.description, podcastEpisode.audioFile.duration)
podcastEpisode.chapters = autoGeneratedChapters
podcastEpisode.chapters = parsePodcastDescriptionForChapters.parse(podcastEpisode.description, podcastEpisode.audioFile.duration)
} catch (error) {
Logger.error(`[PodcastEpisode] createFromRssPodcastEpisode: Failed to auto generate chapters for "${podcastEpisode.title}"`, error)
}
@ -245,82 +246,6 @@ class PodcastEpisode extends Model {
return json
}
/**
*
* @param {string} podcastDescription
* @param {number} audioDurationSecs
* @returns {ChapterObject[]}
*/
static autoGenerateChaptersFromTimestamps(podcastDescription, audioDurationSecs) {
if (podcastDescription == null) {
throw new Error('Description must not be null')
}
if (audioDurationSecs == null) {
throw new Error('Audio duration must not be null')
}
const timestampRegex = /\b(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?\b/
const chapterTitleRegex = /\b\d{1,2}:\d{1,2}(?::\d{1,2})?\b(?:\s+|\))(.+)$/
const descriptionLineSplitRegex = /\<\s*\/\s*p\s*\>|\<\s*br\s*\s*\/\>|\n/
var descriptionLines = podcastDescription.split(descriptionLineSplitRegex)
var newChapters = []
for (let i = 0; i < descriptionLines.length; i++) {
let line = descriptionLines[i]
let match = timestampRegex.exec(line)
if (match == null) continue
let first = match[1]
let second = match[2]
let third = match[3]
let hours = 0
let minutes = 0
let seconds = 0
// If there's three components then we can assume its hh:mm:ss
if (first && second && third) {
hours = Number(first)
minutes = Number(second)
seconds = Number(third)
} else if (first && second) // otherwise assume mm:ss
{
minutes = Number(first)
seconds = Number(second)
}
let startTime = seconds + minutes * 60 + hours * 60 * 60
let chapterTitleMatch = chapterTitleRegex.exec(line)
if (chapterTitleMatch == null || chapterTitleMatch.length < 2) {
// Unknown chapter state
throw new Error(`Unable to get chapter title from description, line ${line}`)
}
let chapter = { title: chapterTitleMatch[1].trim(), id: newChapters.length + 1, start: startTime }
if (newChapters.length > 0) {
newChapters[newChapters.length - 1].end = startTime
}
newChapters.push(chapter)
}
if (newChapters.length > 0) {
newChapters[newChapters.length - 1].end = audioDurationSecs
}
Logger.info(`[PodcastEpisode] Successfully generated ${newChapters.length} chapters`)
if (newChapters.length == 1) {
throw new Error('Only one chapter found, treating as invalid description')
}
return newChapters
}
}
module.exports = PodcastEpisode