mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-05-22 01:18:13 +02:00
Update podcast rss feed parser to use psc chapters on episodes
This commit is contained in:
parent
ddcda197b4
commit
6ed66fea16
@ -607,6 +607,7 @@ class Feed extends Model {
|
|||||||
custom_namespaces: {
|
custom_namespaces: {
|
||||||
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
|
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
|
||||||
podcast: 'https://podcastindex.org/namespace/1.0',
|
podcast: 'https://podcastindex.org/namespace/1.0',
|
||||||
|
psc: 'http://podlove.org/simple-chapters',
|
||||||
googleplay: 'http://www.google.com/schemas/play-podcasts/1.0'
|
googleplay: 'http://www.google.com/schemas/play-podcasts/1.0'
|
||||||
},
|
},
|
||||||
custom_elements: customElements
|
custom_elements: customElements
|
||||||
|
@ -325,6 +325,24 @@ class FeedEpisode extends Model {
|
|||||||
customElements.push({ 'itunes:summary': { _cdata: this.description } })
|
customElements.push({ 'itunes:summary': { _cdata: this.description } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
customElements.push({
|
||||||
|
'psc:chapters': [
|
||||||
|
{
|
||||||
|
_attr: {
|
||||||
|
version: '1.2'
|
||||||
|
},
|
||||||
|
'psc:chapter': [
|
||||||
|
{
|
||||||
|
_attr: {
|
||||||
|
title: 'Test',
|
||||||
|
start: '00:00:00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: this.title,
|
title: this.title,
|
||||||
description: this.description || '',
|
description: this.description || '',
|
||||||
|
@ -80,9 +80,13 @@ class PodcastEpisode extends Model {
|
|||||||
if (rssPodcastEpisode.guid) {
|
if (rssPodcastEpisode.guid) {
|
||||||
podcastEpisode.extraData.guid = rssPodcastEpisode.guid
|
podcastEpisode.extraData.guid = rssPodcastEpisode.guid
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioFile.chapters?.length) {
|
if (audioFile.chapters?.length) {
|
||||||
podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch }))
|
podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch }))
|
||||||
|
} else if (rssPodcastEpisode.chapters?.length) {
|
||||||
|
podcastEpisode.chapters = rssPodcastEpisode.chapters.map((ch) => ({ ...ch }))
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.create(podcastEpisode)
|
return this.create(podcastEpisode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -243,3 +243,29 @@ module.exports.isValidASIN = (str) => {
|
|||||||
if (!str || typeof str !== 'string') return false
|
if (!str || typeof str !== 'string') return false
|
||||||
return /^[A-Z0-9]{10}$/.test(str)
|
return /^[A-Z0-9]{10}$/.test(str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert timestamp to seconds
|
||||||
|
* @example "01:00:00" => 3600
|
||||||
|
* @example "01:00" => 60
|
||||||
|
* @example "01" => 1
|
||||||
|
*
|
||||||
|
* @param {string} timestamp
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
module.exports.timestampToSeconds = (timestamp) => {
|
||||||
|
if (typeof timestamp !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const parts = timestamp.split(':').map(Number)
|
||||||
|
if (parts.some(isNaN)) {
|
||||||
|
return null
|
||||||
|
} else if (parts.length === 1) {
|
||||||
|
return parts[0]
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
return parts[0] * 60 + parts[1]
|
||||||
|
} else if (parts.length === 3) {
|
||||||
|
return parts[0] * 3600 + parts[1] * 60 + parts[2]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
@ -1,9 +1,17 @@
|
|||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const ssrfFilter = require('ssrf-req-filter')
|
const ssrfFilter = require('ssrf-req-filter')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { xmlToJSON, levenshteinDistance } = require('./index')
|
const { xmlToJSON, levenshteinDistance, timestampToSeconds } = require('./index')
|
||||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef RssPodcastChapter
|
||||||
|
* @property {number} id
|
||||||
|
* @property {string} title
|
||||||
|
* @property {number} start
|
||||||
|
* @property {number} end
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef RssPodcastEpisode
|
* @typedef RssPodcastEpisode
|
||||||
* @property {string} title
|
* @property {string} title
|
||||||
@ -22,6 +30,7 @@ const htmlSanitizer = require('../utils/htmlSanitizer')
|
|||||||
* @property {string} guid
|
* @property {string} guid
|
||||||
* @property {string} chaptersUrl
|
* @property {string} chaptersUrl
|
||||||
* @property {string} chaptersType
|
* @property {string} chaptersType
|
||||||
|
* @property {RssPodcastChapter[]} chapters
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -205,12 +214,53 @@ function extractEpisodeData(item) {
|
|||||||
const cleanKey = key.split(':').pop()
|
const cleanKey = key.split(':').pop()
|
||||||
episode[cleanKey] = extractFirstArrayItemString(item, key)
|
episode[cleanKey] = extractFirstArrayItemString(item, key)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Extract psc:chapters if duration is set
|
||||||
|
let episodeDuration = !isNaN(episode.duration) ? timestampToSeconds(episode.duration) : null
|
||||||
|
if (item['psc:chapters']?.[0]?.['psc:chapter']?.length && episodeDuration) {
|
||||||
|
// Example chapter:
|
||||||
|
// {"id":0,"start":0,"end":43.004286,"title":"chapter 1"}
|
||||||
|
|
||||||
|
const cleanedChapters = item['psc:chapters'][0]['psc:chapter'].map((chapter, index) => {
|
||||||
|
if (!chapter['$']?.title || !chapter['$']?.start || typeof chapter['$']?.start !== 'string' || typeof chapter['$']?.title !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = timestampToSeconds(chapter['$'].start)
|
||||||
|
if (start === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
title: chapter['$'].title,
|
||||||
|
start
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (cleanedChapters.some((chapter) => !chapter)) {
|
||||||
|
Logger.warn(`[podcastUtils] Invalid chapter data for ${episode.enclosure.url}`)
|
||||||
|
} else {
|
||||||
|
episode.chapters = cleanedChapters.map((chapter, index) => {
|
||||||
|
const nextChapter = cleanedChapters[index + 1]
|
||||||
|
const end = nextChapter ? nextChapter.start : episodeDuration
|
||||||
|
return {
|
||||||
|
id: chapter.id,
|
||||||
|
title: chapter.title,
|
||||||
|
start: chapter.start,
|
||||||
|
end
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return episode
|
return episode
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanEpisodeData(data) {
|
function cleanEpisodeData(data) {
|
||||||
const pubJsDate = data.pubDate ? new Date(data.pubDate) : null
|
const pubJsDate = data.pubDate ? new Date(data.pubDate) : null
|
||||||
const publishedAt = pubJsDate && !isNaN(pubJsDate) ? pubJsDate.valueOf() : null
|
const publishedAt = pubJsDate && !isNaN(pubJsDate) ? pubJsDate.valueOf() : null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
subtitle: data.subtitle || '',
|
subtitle: data.subtitle || '',
|
||||||
@ -227,7 +277,8 @@ function cleanEpisodeData(data) {
|
|||||||
enclosure: data.enclosure,
|
enclosure: data.enclosure,
|
||||||
guid: data.guid || null,
|
guid: data.guid || null,
|
||||||
chaptersUrl: data.chaptersUrl || null,
|
chaptersUrl: data.chaptersUrl || null,
|
||||||
chaptersType: data.chaptersType || null
|
chaptersType: data.chaptersType || null,
|
||||||
|
chapters: data.chapters || []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user