diff --git a/client/components/cards/PodcastFeedSummaryCard.vue b/client/components/cards/PodcastFeedSummaryCard.vue new file mode 100644 index 00000000..474d1480 --- /dev/null +++ b/client/components/cards/PodcastFeedSummaryCard.vue @@ -0,0 +1,71 @@ + + + \ No newline at end of file diff --git a/client/components/modals/podcast/OpmlFeedsModal.vue b/client/components/modals/podcast/OpmlFeedsModal.vue new file mode 100644 index 00000000..fd68089b --- /dev/null +++ b/client/components/modals/podcast/OpmlFeedsModal.vue @@ -0,0 +1,168 @@ + + + + + \ No newline at end of file diff --git a/client/components/ui/FileInput.vue b/client/components/ui/FileInput.vue index 291f8e1a..b0577102 100644 --- a/client/components/ui/FileInput.vue +++ b/client/components/ui/FileInput.vue @@ -1,6 +1,6 @@ diff --git a/client/pages/library/_library/podcast/search.vue b/client/pages/library/_library/podcast/search.vue index e13ca1a8..f3f05c58 100644 --- a/client/pages/library/_library/podcast/search.vue +++ b/client/pages/library/_library/podcast/search.vue @@ -1,12 +1,14 @@ @@ -62,7 +65,9 @@ export default { processing: false, showNewPodcastModal: false, selectedPodcast: null, - selectedPodcastFeed: null + selectedPodcastFeed: null, + showOPMLFeedsModal: false, + opmlFeeds: [] } }, computed: { @@ -71,6 +76,36 @@ export default { } }, methods: { + async opmlFileUpload(file) { + this.processing = true + var txt = await new Promise((resolve) => { + const reader = new FileReader() + reader.onload = () => { + resolve(reader.result) + } + reader.readAsText(file) + }) + + if (!txt || !txt.includes(' tag not found OR an tag was not found') + this.processing = false + return + } + + await this.$axios + .$post(`/api/podcasts/opml`, { opmlText: txt }) + .then((data) => { + console.log(data) + this.opmlFeeds = data.feeds || [] + this.showOPMLFeedsModal = true + }) + .catch((error) => { + console.error('Failed', error) + this.$toast.error('Failed to parse OPML file') + }) + this.processing = false + }, submit() { if (!this.searchInput) return diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 8378005b..e4dd8311 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -64,8 +64,8 @@ - - + + diff --git a/client/tailwind.config.js b/client/tailwind.config.js index c6510968..a8e3cbb1 100644 --- a/client/tailwind.config.js +++ b/client/tailwind.config.js @@ -37,6 +37,7 @@ module.exports = { minWidth: { '6': '1.5rem', '12': '3rem', + '16': '4rem', '24': '6rem', '32': '8rem', '48': '12rem', @@ -75,6 +76,9 @@ module.exports = { mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono], book: ['Gentium Book Basic', 'serif'] }, + fontSize: { + xxs: '0.625rem' + }, zIndex: { '50': 50 } diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 0a0d9fe0..a6801232 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -104,7 +104,7 @@ class PodcastController { return res.status(500).send('Bad response from feed request') } Logger.debug(`[PdocastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`) - var payload = await parsePodcastRssFeedXml(data.data, includeRaw) + var payload = await parsePodcastRssFeedXml(data.data, false, includeRaw) if (!payload) { return res.status(500).send('Invalid podcast RSS feed') } @@ -119,6 +119,15 @@ class PodcastController { }) } + async getOPMLFeeds(req, res) { + if (!req.body.opmlText) { + return res.sendStatus(400) + } + + const rssFeedsData = await this.podcastManager.getOPMLFeeds(req.body.opmlText) + res.json(rssFeedsData) + } + async checkNewEpisodes(req, res) { if (!req.user.isAdminOrUp) { Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user) diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index b15698aa..d1aba2b6 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -6,6 +6,7 @@ const { parsePodcastRssFeedXml } = require('../utils/podcastUtils') const Logger = require('../Logger') const { downloadFile } = require('../utils/fileUtils') +const opmlParser = require('../utils/parsers/parseOPML') const prober = require('../utils/prober') const LibraryFile = require('../objects/files/LibraryFile') const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload') @@ -258,7 +259,7 @@ class PodcastManager { return newEpisodes } - getPodcastFeed(feedUrl) { + getPodcastFeed(feedUrl, excludeEpisodeMetadata = false) { Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}"`) return axios.get(feedUrl, { timeout: 5000 }).then(async (data) => { if (!data || !data.data) { @@ -266,7 +267,7 @@ class PodcastManager { return false } Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}" success - parsing xml`) - var payload = await parsePodcastRssFeedXml(data.data) + var payload = await parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata) if (!payload) { return false } @@ -276,5 +277,29 @@ class PodcastManager { return false }) } + + async getOPMLFeeds(opmlText) { + var extractedFeeds = opmlParser.parse(opmlText) + if (!extractedFeeds || !extractedFeeds.length) { + Logger.error('[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML') + return { + error: 'No RSS feeds found in OPML' + } + } + + var rssFeedData = [] + + for (let feed of extractedFeeds) { + var feedData = await this.getPodcastFeed(feed.feedUrl, true) + if (feedData) { + feedData.metadata.feedUrl = feed.feedUrl + rssFeedData.push(feedData) + } + } + + return { + feeds: rssFeedData + } + } } module.exports = PodcastManager \ No newline at end of file diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index 5f1f507d..6f0f4a1d 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -2,7 +2,7 @@ const Path = require('path') const Logger = require('../../Logger') const BookMetadata = require('../metadata/BookMetadata') const { areEquivalent, copyValue } = require('../../utils/index') -const { parseOpfMetadataXML } = require('../../utils/parseOpfMetadata') +const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata') const abmetadataGenerator = require('../../utils/abmetadataGenerator') const { readTextFile } = require('../../utils/fileUtils') const AudioFile = require('../files/AudioFile') diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js index 99be71f6..6bcf38bf 100644 --- a/server/objects/metadata/BookMetadata.js +++ b/server/objects/metadata/BookMetadata.js @@ -1,6 +1,6 @@ const Logger = require('../../Logger') const { areEquivalent, copyValue } = require('../../utils/index') -const parseNameString = require('../../utils/parseNameString') +const parseNameString = require('../../utils/parsers/parseNameString') class BookMetadata { constructor(metadata) { this.title = null diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 638dee7d..769ee268 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -182,6 +182,7 @@ class ApiRouter { // this.router.post('/podcasts', PodcastController.create.bind(this)) this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this)) + this.router.post('/podcasts/opml', PodcastController.getOPMLFeeds.bind(this)) this.router.get('/podcasts/:id/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this)) this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this)) this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this)) diff --git a/server/utils/parseFullName.js b/server/utils/parsers/parseFullName.js similarity index 100% rename from server/utils/parseFullName.js rename to server/utils/parsers/parseFullName.js diff --git a/server/utils/parseNameString.js b/server/utils/parsers/parseNameString.js similarity index 100% rename from server/utils/parseNameString.js rename to server/utils/parsers/parseNameString.js diff --git a/server/utils/parsers/parseOPML.js b/server/utils/parsers/parseOPML.js new file mode 100644 index 00000000..b109a4e9 --- /dev/null +++ b/server/utils/parsers/parseOPML.js @@ -0,0 +1,24 @@ +const h = require('htmlparser2') +const Logger = require('../../Logger') + +function parse(opmlText) { + var feeds = [] + var parser = new h.Parser({ + onopentag: (name, attribs) => { + if (name === "outline" && attribs.type === 'rss') { + if (!attribs.xmlurl) { + Logger.error('[parseOPML] Invalid opml outline tag has no xmlurl attribute') + } else { + feeds.push({ + title: attribs.title || 'No Title', + text: attribs.text || '', + feedUrl: attribs.xmlurl + }) + } + } + } + }) + parser.write(opmlText) + return feeds +} +module.exports.parse = parse \ No newline at end of file diff --git a/server/utils/parseOpfMetadata.js b/server/utils/parsers/parseOpfMetadata.js similarity index 98% rename from server/utils/parseOpfMetadata.js rename to server/utils/parsers/parseOpfMetadata.js index ceac6047..07fd305c 100644 --- a/server/utils/parseOpfMetadata.js +++ b/server/utils/parsers/parseOpfMetadata.js @@ -1,5 +1,5 @@ -const { xmlToJSON } = require('./index') -const htmlSanitizer = require('./htmlSanitizer') +const { xmlToJSON } = require('../index') +const htmlSanitizer = require('../htmlSanitizer') function parseCreators(metadata) { if (!metadata['dc:creator']) return null diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index a5c2ab4e..93ed5349 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -131,7 +131,7 @@ function extractPodcastEpisodes(items) { return episodes } -function cleanPodcastJson(rssJson) { +function cleanPodcastJson(rssJson, excludeEpisodeMetadata) { if (!rssJson.channel || !rssJson.channel.length) { Logger.error(`[podcastUtil] Invalid podcast no channel object`) return null @@ -142,13 +142,17 @@ function cleanPodcastJson(rssJson) { return null } var podcast = { - metadata: extractPodcastMetadata(channel), - episodes: extractPodcastEpisodes(channel.item) + metadata: extractPodcastMetadata(channel) + } + if (!excludeEpisodeMetadata) { + podcast.episodes = extractPodcastEpisodes(channel.item) + } else { + podcast.numEpisodes = channel.item.length } return podcast } -module.exports.parsePodcastRssFeedXml = async (xml, includeRaw = false) => { +module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = false, includeRaw = false) => { if (!xml) return null var json = await xmlToJSON(xml) if (!json || !json.rss) { @@ -156,7 +160,7 @@ module.exports.parsePodcastRssFeedXml = async (xml, includeRaw = false) => { return null } - const podcast = cleanPodcastJson(json.rss) + const podcast = cleanPodcastJson(json.rss, excludeEpisodeMetadata) if (!podcast) return null if (includeRaw) {