diff --git a/client/package.json b/client/package.json index a1503a50..dd0f3a0c 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.33.0", + "version": "2.34.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package.json b/package.json index 3108b517..10ba26f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.33.0", + "version": "2.34.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index bdf6fc76..82e0dbd3 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -204,7 +204,7 @@ class PodcastManager { return false } - const podcastEpisode = await Database.podcastEpisodeModel.createFromRssPodcastEpisode(this.currentDownload.rssPodcastEpisode, libraryItem.media.id, audioFile) + const podcastEpisode = await Database.podcastEpisodeModel.createFromRssPodcastEpisode(this.currentDownload.rssPodcastEpisode, libraryItem.media.id, libraryItem.media.autoGenerateChapters, audioFile) libraryItem.libraryFiles.push(libraryFile.toJSON()) // Re-calculating library item size because this wasnt being updated properly for podcasts in v2.20.0 and below diff --git a/server/migrations/v2.34.0-add-auto-generate-podcast-chapters.js b/server/migrations/v2.34.0-add-auto-generate-podcast-chapters.js new file mode 100644 index 00000000..c5567abe --- /dev/null +++ b/server/migrations/v2.34.0-add-auto-generate-podcast-chapters.js @@ -0,0 +1,83 @@ +const util = require('util') + +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.34.0' +const migrationName = `${migrationVersion}-add-auto-generate-podcast-chapters` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration adds a boolean autoGenerateChapters column to the podcasts table and defaults it to false. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + await addColumn(queryInterface, logger, 'podcasts', 'autoGenerateChapters', { type: queryInterface.sequelize.Sequelize.BOOLEAN, allowNull: false, defaultValue: false }) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration removes the autoGenerateChapters column on the podcasts table, + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + await removeColumn(queryInterface, logger, 'podcasts', 'autoGenerateChapters') + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +/** + * Utility function to add a column to a table. If the column already exists, it logs a message and continues. + * + * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @param {import('../Logger')} logger - a Logger object. + * @param {string} table - the name of the table to add the column to. + * @param {string} column - the name of the column to add. + * @param {Object} options - the options for the column. + */ +async function addColumn(queryInterface, logger, table, column, options) { + logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`) + const tableDescription = await queryInterface.describeTable(table) + if (!tableDescription[column]) { + await queryInterface.addColumn(table, column, options) + logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`) + } else { + logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`) + } +} + +/** + * Utility function to remove a column from a table. If the column does not exist, it logs a message and continues. + * + * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @param {import('../Logger')} logger - a Logger object. + * @param {string} table - the name of the table to remove the column from. + * @param {string} column - the name of the column to remove. + */ +async function removeColumn(queryInterface, logger, table, column) { + logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`) + const tableDescription = await queryInterface.describeTable(table) + if (tableDescription[column]) { + await queryInterface.sequelize.query(`ALTER TABLE ${table} DROP COLUMN ${column}`) + logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`) + } else { + logger.info(`${loggerPrefix} column "${column}" does not exist in table "${table}"`) + } +} + +module.exports = { up, down } diff --git a/server/models/Podcast.js b/server/models/Podcast.js index a96e1dd0..bb0e0453 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -53,6 +53,8 @@ class Podcast extends Model { this.maxEpisodesToKeep /** @type {number} */ this.maxNewEpisodesToDownload + /** @type {boolean} */ + this.autoGenerateChapters /** @type {string} */ this.coverPath /** @type {string[]} */ @@ -106,6 +108,7 @@ class Podcast extends Model { explicit: !!payload.metadata.explicit, autoDownloadEpisodes: !!payload.autoDownloadEpisodes, autoDownloadSchedule: autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule, + autoGenerateChapters: !!payload.autoGenerateChapters, lastEpisodeCheck: new Date(), maxEpisodesToKeep: 0, maxNewEpisodesToDownload: 3, @@ -145,6 +148,7 @@ class Podcast extends Model { autoDownloadEpisodes: DataTypes.BOOLEAN, autoDownloadSchedule: DataTypes.STRING, lastEpisodeCheck: DataTypes.DATE, + autoGenerateChapters: DataTypes.BOOLEAN, maxEpisodesToKeep: DataTypes.INTEGER, maxNewEpisodesToDownload: DataTypes.INTEGER, coverPath: DataTypes.STRING, @@ -273,6 +277,10 @@ class Podcast extends Model { this.autoDownloadSchedule = payload.autoDownloadSchedule hasUpdates = true } + if (payload.autoGenerateChapters !== undefined && payload.autoGenerateChapters !== this.autoGenerateChapters) { + this.autoGenerateChapters = !!payload.autoGenerateChapters + hasUpdates = true + } if (typeof payload.lastEpisodeCheck === 'number' && payload.lastEpisodeCheck !== this.lastEpisodeCheck?.valueOf()) { this.lastEpisodeCheck = payload.lastEpisodeCheck hasUpdates = true @@ -441,6 +449,7 @@ class Podcast extends Model { autoDownloadEpisodes: this.autoDownloadEpisodes, autoDownloadSchedule: this.autoDownloadSchedule, lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null, + autoGenerateChapters: this.autoGenerateChapters, maxEpisodesToKeep: this.maxEpisodesToKeep, maxNewEpisodesToDownload: this.maxNewEpisodesToDownload } @@ -457,6 +466,7 @@ class Podcast extends Model { autoDownloadEpisodes: this.autoDownloadEpisodes, autoDownloadSchedule: this.autoDownloadSchedule, lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null, + autoGenerateChapters: this.autoGenerateChapters, maxEpisodesToKeep: this.maxEpisodesToKeep, maxNewEpisodesToDownload: this.maxNewEpisodesToDownload, size: this.size @@ -481,6 +491,7 @@ class Podcast extends Model { autoDownloadEpisodes: this.autoDownloadEpisodes, autoDownloadSchedule: this.autoDownloadSchedule, lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null, + autoGenerateChapters: this.autoGenerateChapters, maxEpisodesToKeep: this.maxEpisodesToKeep, maxNewEpisodesToDownload: this.maxNewEpisodesToDownload, size: this.size diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 3f1f4487..c253f479 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -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 diff --git a/server/models/PodcastEpisode.test.js b/server/models/PodcastEpisode.test.js deleted file mode 100644 index 676c59f1..00000000 --- a/server/models/PodcastEpisode.test.js +++ /dev/null @@ -1,117 +0,0 @@ -const chai = require('chai') -const PodcastEpisode = require('./PodcastEpisode') -const Logger = require('../Logger') -const expect = chai.expect - -describe('PodcastEpisode', () => { - describe('autoGenerateChaptersFromTimestamps', () => { - var testCasesTestingSuccess = [ - { - testName: 'Should handle descriptions using html paragraphs', - description: '

Introduction text paragraph 1

Introduction text paragraph 2

00:48 Chatper 1

12:14 Chapter 2

20:56 Chapter 3

27:34 Chapter 4

32:00 Chapter 5

35:16 Chapter 6

41:32 Chapter 7

46:43 Chapter 8

', - audioDuration: 3060, - expectedChapters: [ - { title: 'Chatper 1', id: 1, start: 48, end: 734 }, - { title: 'Chapter 2', id: 2, start: 734, end: 1256 }, - { title: 'Chapter 3', id: 3, start: 1256, end: 1654 }, - { title: 'Chapter 4', id: 4, start: 1654, end: 1920 }, - { title: 'Chapter 5', id: 5, start: 1920, end: 2116 }, - { title: 'Chapter 6', id: 6, start: 2116, end: 2492 }, - { title: 'Chapter 7', id: 7, start: 2492, end: 2803 }, - { title: 'Chapter 8', id: 8, start: 2803, end: 3060 } - ] - }, - { - testName: 'Should handle descriptions using html line breaks', - description: '
Introduction text paragraph 1

Introduction text paragraph 2

0:00:00 Chapter 1
0:17:05 Chapter 2
0:33:58 Chapter 3
0:40:35 Chapter 4
Unrelated outro line
', - audioDuration: 2700, - expectedChapters: [ - { title: 'Chapter 1', id: 1, start: 0, end: 1025 }, - { title: 'Chapter 2', id: 2, start: 1025, end: 2038 }, - { title: 'Chapter 3', id: 3, start: 2038, end: 2435 }, - { title: 'Chapter 4', id: 4, start: 2435, end: 2700 } - ] - }, - { - testName: 'Should handle descriptions using unix new lines', - description: `Introduction text paragraph 1 - Introduction text paragraph 2 - 0:00:00 Chapter 1 - 0:17:05 Chapter 2 - 0:33:58 Chapter 3 - 0:40:35 Chapter 4 - Unrelated outro line`, - audioDuration: 2700, - expectedChapters: [ - { title: 'Chapter 1', id: 1, start: 0, end: 1025 }, - { title: 'Chapter 2', id: 2, start: 1025, end: 2038 }, - { title: 'Chapter 3', id: 3, start: 2038, end: 2435 }, - { title: 'Chapter 4', id: 4, start: 2435, end: 2700 } - ] - }, - { - testName: 'Should handle descriptions with no timestamps', - description: `Introduction text paragraph 1 - Introduction text paragraph 2`, - audioDuration: 2700, - expectedChapters: [] - }, - { - testName: 'Should handle timestampes in parentheses', - description: '

Introduction text paragraph 1

Introduction text paragraph 2

(00:48) Chatper 1

(12:14) Chapter 2

(20:56) Chapter 3

(27:34) Chapter 4

(32:00) Chapter 5

(35:16) Chapter 6

(41:32) Chapter 7

(46:43) Chapter 8

', - audioDuration: 3060, - expectedChapters: [ - { title: 'Chatper 1', id: 1, start: 48, end: 734 }, - { title: 'Chapter 2', id: 2, start: 734, end: 1256 }, - { title: 'Chapter 3', id: 3, start: 1256, end: 1654 }, - { title: 'Chapter 4', id: 4, start: 1654, end: 1920 }, - { title: 'Chapter 5', id: 5, start: 1920, end: 2116 }, - { title: 'Chapter 6', id: 6, start: 2116, end: 2492 }, - { title: 'Chapter 7', id: 7, start: 2492, end: 2803 }, - { title: 'Chapter 8', id: 8, start: 2803, end: 3060 } - ] - } - ] - testCasesTestingSuccess.forEach(function (testCase) { - it(testCase.testName, () => { - var chapters = PodcastEpisode.autoGenerateChaptersFromTimestamps(testCase.description, testCase.audioDuration) - expect(chapters).to.be.deep.equal(testCase.expectedChapters) - }) - }) - - var testCasesTestingFailure = [ - { - testName: 'Should throw if only one chapter found', - description: '

Introduction text paragraph 1

Introduction text paragraph 2

00:48 Chatper 1

', - audioDuration: 1000, - expectedError: 'Only one chapter found, treating as invalid description' - }, - { - testName: 'Should throw if description is null', - description: null, - audioDuration: 1000, - expectedError: 'Description must not be null' - }, - { - testName: 'Should throw if audio duration is null', - description: '', - audioDuration: null, - expectedError: 'Audio duration must not be null' - }, - { - testName: 'Should throw if chapter has no title', - description: '

Introduction text paragraph 1

Introduction text paragraph 2

00:48 Chatper 1

00:60:12

', - audioDuration: 1000, - expectedError: 'Unable to get chapter title from description' - } - ] - testCasesTestingFailure.forEach(function (testCase) { - it(testCase.testName, () => { - expect(() => { - var chapters = PodcastEpisode.autoGenerateChaptersFromTimestamps(testCase.description, testCase.audioDuration) - Logger.debug('Chapters', chapters) - }).to.throw(testCase.expectedError) - }) - }) - }) -}) diff --git a/server/utils/parsers/parsePodcastDescriptionForChapters.js b/server/utils/parsers/parsePodcastDescriptionForChapters.js new file mode 100644 index 00000000..3f1b4c75 --- /dev/null +++ b/server/utils/parsers/parsePodcastDescriptionForChapters.js @@ -0,0 +1,95 @@ +const Logger = require('../../Logger') + +/** + * Parse podcast descriptions for timestamps and generate chapters + * The following formats are supports: + * + * MM:SS Chapter name + * HH:MM:SS Chapter name + * (HH:MM:SS) Chapter name + * + * Descriptions have to use

,
or \n to split up lines in order to be supported + * + * See test suite for more input examples + * + * @param {string} podcastDescription + * @param {number} audioDurationSecs + * @returns {ChapterObject[]} + */ +module.exports.parse = (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) + } + + if (minutes > 59 || seconds > 59) { + throw new Error(`Timestamp contains invalid minutes or seconds field '${minutes}::${seconds}'`) + } + + let startTime = seconds + minutes * 60 + hours * 60 * 60 + if (startTime > audioDurationSecs) { + throw new Error(`Chapter found that starts after over audio duration. Duration: ${audioDurationSecs}s - Chapter start ${startTime}s`) + } + + 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 +} diff --git a/test/server/migrations/v2.34.0-add-auto-generate-podcast-chapters.test.js b/test/server/migrations/v2.34.0-add-auto-generate-podcast-chapters.test.js new file mode 100644 index 00000000..04822be4 --- /dev/null +++ b/test/server/migrations/v2.34.0-add-auto-generate-podcast-chapters.test.js @@ -0,0 +1,64 @@ +const chai = require('chai') +const sinon = require('sinon') +const { expect } = chai + +const { DataTypes, Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') + +const { up, down } = require('../../../server/migrations/v2.34.0-add-auto-generate-podcast-chapters') + +describe('Migration v2.34.0-add-auto-generate-podcast-chapters', () => { + let sequelize + let queryInterface + let loggerInfoStub + + beforeEach(async () => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + loggerInfoStub = sinon.stub(Logger, 'info') + + await queryInterface.createTable('podcasts', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + title: { type: DataTypes.STRING, allowNull: false }, + titleIgnorePrefix: { type: DataTypes.STRING, allowNull: false } + }) + + await queryInterface.bulkInsert('podcasts', [ + { id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' }, + { id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' } + ]) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('up', () => { + it('should add autoGenerateChapters column to podcasts', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts') + expect(podcasts).to.deep.equal([ + { id: 1, autoGenerateChapters: 0, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' }, + { id: 2, autoGenerateChapters: 0, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' } + ]) + }) + }) + + describe('down', () => { + it('should remove autoGenerateChapters column from podcasts', async () => { + await up({ context: { queryInterface, logger: Logger } }) + try { + await down({ context: { queryInterface, logger: Logger } }) + } catch (error) { + console.log(error) + } + + const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts') + expect(podcasts).to.deep.equal([ + { id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' }, + { id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' } + ]) + }) + }) +}) diff --git a/test/server/utils/parsers/parsePodcastDescriptionForChapters.test.js b/test/server/utils/parsers/parsePodcastDescriptionForChapters.test.js new file mode 100644 index 00000000..0fff4574 --- /dev/null +++ b/test/server/utils/parsers/parsePodcastDescriptionForChapters.test.js @@ -0,0 +1,132 @@ +const chai = require('chai') +const PodcastEpisode = require('../../../../server/models/PodcastEpisode') +const expect = chai.expect +const parsePodcastDescriptionForChapters = require('../../../../server/utils/parsers/parsePodcastDescriptionForChapters') + +describe('parsePodcastDescriptionForChapters', () => { + var testCasesTestingSuccess = [ + { + testName: 'Should handle descriptions using html paragraphs', + description: '

Introduction text paragraph 1

Introduction text paragraph 2

00:48 Chatper 1

12:14 Chapter 2

20:56 Chapter 3

27:34 Chapter 4

32:00 Chapter 5

35:16 Chapter 6

41:32 Chapter 7

46:43 Chapter 8

', + audioDuration: 3060, + expectedChapters: [ + { title: 'Chatper 1', id: 1, start: 48, end: 734 }, + { title: 'Chapter 2', id: 2, start: 734, end: 1256 }, + { title: 'Chapter 3', id: 3, start: 1256, end: 1654 }, + { title: 'Chapter 4', id: 4, start: 1654, end: 1920 }, + { title: 'Chapter 5', id: 5, start: 1920, end: 2116 }, + { title: 'Chapter 6', id: 6, start: 2116, end: 2492 }, + { title: 'Chapter 7', id: 7, start: 2492, end: 2803 }, + { title: 'Chapter 8', id: 8, start: 2803, end: 3060 } + ] + }, + { + testName: 'Should handle descriptions using html line breaks', + description: '
Introduction text paragraph 1

Introduction text paragraph 2

0:00:00 Chapter 1
0:17:05 Chapter 2
0:33:58 Chapter 3
0:40:35 Chapter 4
Unrelated outro line
', + audioDuration: 2700, + expectedChapters: [ + { title: 'Chapter 1', id: 1, start: 0, end: 1025 }, + { title: 'Chapter 2', id: 2, start: 1025, end: 2038 }, + { title: 'Chapter 3', id: 3, start: 2038, end: 2435 }, + { title: 'Chapter 4', id: 4, start: 2435, end: 2700 } + ] + }, + { + testName: 'Should handle descriptions using unix new lines', + description: `Introduction text paragraph 1 + Introduction text paragraph 2 + 0:00:00 Chapter 1 + 0:17:05 Chapter 2 + 0:33:58 Chapter 3 + 0:40:35 Chapter 4 + Unrelated outro line`, + audioDuration: 2700, + expectedChapters: [ + { title: 'Chapter 1', id: 1, start: 0, end: 1025 }, + { title: 'Chapter 2', id: 2, start: 1025, end: 2038 }, + { title: 'Chapter 3', id: 3, start: 2038, end: 2435 }, + { title: 'Chapter 4', id: 4, start: 2435, end: 2700 } + ] + }, + { + testName: 'Should handle descriptions with no timestamps', + description: `Introduction text paragraph 1 + Introduction text paragraph 2`, + audioDuration: 2700, + expectedChapters: [] + }, + { + testName: 'Should handle timestampes in parentheses', + description: '

Introduction text paragraph 1

Introduction text paragraph 2

(00:48) Chatper 1

(12:14) Chapter 2

(20:56) Chapter 3

(27:34) Chapter 4

(32:00) Chapter 5

(35:16) Chapter 6

(41:32) Chapter 7

(46:43) Chapter 8

', + audioDuration: 3060, + expectedChapters: [ + { title: 'Chatper 1', id: 1, start: 48, end: 734 }, + { title: 'Chapter 2', id: 2, start: 734, end: 1256 }, + { title: 'Chapter 3', id: 3, start: 1256, end: 1654 }, + { title: 'Chapter 4', id: 4, start: 1654, end: 1920 }, + { title: 'Chapter 5', id: 5, start: 1920, end: 2116 }, + { title: 'Chapter 6', id: 6, start: 2116, end: 2492 }, + { title: 'Chapter 7', id: 7, start: 2492, end: 2803 }, + { title: 'Chapter 8', id: 8, start: 2803, end: 3060 } + ] + } + ] + testCasesTestingSuccess.forEach(function (testCase) { + it(testCase.testName, () => { + var chapters = parsePodcastDescriptionForChapters.parse(testCase.description, testCase.audioDuration) + expect(chapters).to.be.deep.equal(testCase.expectedChapters) + }) + }) + + var testCasesTestingFailure = [ + { + testName: 'Should throw if only one chapter found', + description: '

Introduction text paragraph 1

Introduction text paragraph 2

00:48 Chatper 1

', + audioDuration: 1000, + expectedError: 'Only one chapter found, treating as invalid description' + }, + { + testName: 'Should throw if invalid minutes', + description: '

Introduction text paragraph 1

Introduction text paragraph 2

75:48 Chatper 1

', + audioDuration: 1000, + expectedError: "Timestamp contains invalid minutes or seconds field '75::48'" + }, + { + testName: 'Should throw if invalid minutes', + description: '

Introduction text paragraph 1

Introduction text paragraph 2

00:90 Chatper 1

', + audioDuration: 1000, + expectedError: "Timestamp contains invalid minutes or seconds field '0::90'" + }, + { + testName: 'Should throw if chapter goes over lenght of audio file', + description: '

Introduction text paragraph 1

Introduction text paragraph 2

00:48 Chatper 1

01:00:01 Chatper 2

', + audioDuration: 3600, + expectedError: 'Chapter found that starts after over audio duration' + }, + { + testName: 'Should throw if description is null', + description: null, + audioDuration: 1000, + expectedError: 'Description must not be null' + }, + { + testName: 'Should throw if audio duration is null', + description: '', + audioDuration: null, + expectedError: 'Audio duration must not be null' + }, + { + testName: 'Should throw if chapter has no title', + description: '

Introduction text paragraph 1

Introduction text paragraph 2

00:48 Chatper 1

00:30:00

', + audioDuration: 3600, + expectedError: 'Unable to get chapter title from description' + } + ] + testCasesTestingFailure.forEach(function (testCase) { + it(testCase.testName, () => { + expect(() => { + parsePodcastDescriptionForChapters.parse(testCase.description, testCase.audioDuration) + }).to.throw(testCase.expectedError) + }) + }) +})