diff --git a/client/components/app/BookShelfToolbar.vue b/client/components/app/BookShelfToolbar.vue
index 57599877..30739b54 100644
--- a/client/components/app/BookShelfToolbar.vue
+++ b/client/components/app/BookShelfToolbar.vue
@@ -81,6 +81,8 @@
{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}
+
+
@@ -186,6 +188,9 @@ export default {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
+ userCanDownload() {
+ return this.$store.getters['user/getUserCanDownload']
+ },
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
@@ -276,9 +281,29 @@ export default {
},
isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
+ },
+ contextMenuItems() {
+ const items = []
+
+ if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {
+ items.push({
+ text: 'Export OPML',
+ action: 'export-opml'
+ })
+ }
+
+ return items
}
},
methods: {
+ contextMenuAction(action) {
+ if (action === 'export-opml') {
+ this.exportOPML()
+ }
+ },
+ exportOPML() {
+ this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)
+ },
seriesContextMenuAction(action) {
if (action === 'open-rss-feed') {
this.showOpenSeriesRSSFeed()
diff --git a/client/components/tables/AudioTracksTableRow.vue b/client/components/tables/AudioTracksTableRow.vue
index 1b1919d7..c2c3fc95 100644
--- a/client/components/tables/AudioTracksTableRow.vue
+++ b/client/components/tables/AudioTracksTableRow.vue
@@ -107,15 +107,7 @@ export default {
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
- const a = document.createElement('a')
- a.style.display = 'none'
- a.href = this.downloadUrl
- a.download = this.track.metadata.filename
- document.body.appendChild(a)
- a.click()
- setTimeout(() => {
- a.remove()
- })
+ this.$downloadFile(this.downloadUrl, this.track.metadata.filename)
}
},
mounted() {}
diff --git a/client/components/tables/LibraryFilesTableRow.vue b/client/components/tables/LibraryFilesTableRow.vue
index d4154a93..529fca00 100644
--- a/client/components/tables/LibraryFilesTableRow.vue
+++ b/client/components/tables/LibraryFilesTableRow.vue
@@ -102,15 +102,7 @@ export default {
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
- const a = document.createElement('a')
- a.style.display = 'none'
- a.href = this.downloadUrl
- a.download = this.file.metadata.filename
- document.body.appendChild(a)
- a.click()
- setTimeout(() => {
- a.remove()
- })
+ this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
}
},
mounted() {}
diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue
index 7b744938..f4e39523 100644
--- a/client/pages/item/_id/index.vue
+++ b/client/pages/item/_id/index.vue
@@ -677,14 +677,7 @@ export default {
}
},
downloadLibraryItem() {
- const a = document.createElement('a')
- a.style.display = 'none'
- a.href = this.downloadUrl
- document.body.appendChild(a)
- a.click()
- setTimeout(() => {
- a.remove()
- })
+ this.$downloadFile(this.downloadUrl)
},
deleteLibraryItem() {
const payload = {
diff --git a/client/plugins/utils.js b/client/plugins/utils.js
index c4162de5..439f65c5 100644
--- a/client/plugins/utils.js
+++ b/client/plugins/utils.js
@@ -145,6 +145,25 @@ Vue.prototype.$getNextScheduledDate = (expression) => {
return interval.next().toDate()
}
+Vue.prototype.$downloadFile = (url, filename = null, openInNewTab = false) => {
+ const a = document.createElement('a')
+ a.style.display = 'none'
+ a.href = url
+
+ if (filename) {
+ a.download = filename
+ }
+ if (openInNewTab) {
+ a.target = '_blank'
+ }
+
+ document.body.appendChild(a)
+ a.click()
+ setTimeout(() => {
+ a.remove()
+ })
+}
+
export function supplant(str, subs) {
// source: http://crockford.com/javascript/remedial.html
return str.replace(/{([^{}]*)}/g,
diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js
index 82ccd09e..e52c6652 100644
--- a/server/controllers/LibraryController.js
+++ b/server/controllers/LibraryController.js
@@ -848,6 +848,12 @@ class LibraryController {
res.json(payload)
}
+ getOPMLFile(req, res) {
+ const opmlText = this.podcastManager.generateOPMLFileText(req.libraryItems)
+ res.type('application/xml')
+ res.send(opmlText)
+ }
+
middleware(req, res, next) {
if (!req.user.checkCanAccessLibrary(req.params.id)) {
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js
index f19bba9f..9bde330d 100644
--- a/server/controllers/PodcastController.js
+++ b/server/controllers/PodcastController.js
@@ -109,7 +109,7 @@ class PodcastController {
res.json({ podcast })
}
- async getOPMLFeeds(req, res) {
+ async getFeedsFromOPMLText(req, res) {
if (!req.body.opmlText) {
return res.sendStatus(400)
}
diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js
index f234e958..28fe37f1 100644
--- a/server/managers/PodcastManager.js
+++ b/server/managers/PodcastManager.js
@@ -8,6 +8,7 @@ const { removeFile, downloadFile } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
const { levenshteinDistance } = require('../utils/index')
const opmlParser = require('../utils/parsers/parseOPML')
+const opmlGenerator = require('../utils/generators/opmlGenerator')
const prober = require('../utils/prober')
const ffmpegHelpers = require('../utils/ffmpegHelpers')
@@ -373,6 +374,10 @@ class PodcastManager {
}
}
+ generateOPMLFileText(libraryItems) {
+ return opmlGenerator.generate(libraryItems)
+ }
+
getDownloadQueueDetails(libraryId = null) {
let _currentDownload = this.currentDownload
if (libraryId && _currentDownload?.libraryId !== libraryId) _currentDownload = null
diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js
index b95559f8..7dfc2312 100644
--- a/server/objects/LibraryItem.js
+++ b/server/objects/LibraryItem.js
@@ -2,7 +2,7 @@ const fs = require('../libs/fsExtra')
const Path = require('path')
const { version } = require('../../package.json')
const Logger = require('../Logger')
-const abmetadataGenerator = require('../utils/abmetadataGenerator')
+const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast')
diff --git a/server/objects/Stream.js b/server/objects/Stream.js
index 5d6442dc..51f01e7a 100644
--- a/server/objects/Stream.js
+++ b/server/objects/Stream.js
@@ -10,7 +10,7 @@ const Ffmpeg = require('../libs/fluentFfmpeg')
const { secondsToTimestamp } = require('../utils/index')
const { writeConcatFile } = require('../utils/ffmpegHelpers')
const { AudioMimeType } = require('../utils/constants')
-const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
+const hlsPlaylistGenerator = require('../utils/generators/hlsPlaylistGenerator')
const AudioTrack = require('./files/AudioTrack')
class Stream extends EventEmitter {
diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js
index 8cd2c584..c6e6b5e4 100644
--- a/server/objects/mediaTypes/Book.js
+++ b/server/objects/mediaTypes/Book.js
@@ -4,7 +4,7 @@ const BookMetadata = require('../metadata/BookMetadata')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata')
const { parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
-const abmetadataGenerator = require('../../utils/abmetadataGenerator')
+const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator')
const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js
index 47364f8a..e1fb9d80 100644
--- a/server/objects/mediaTypes/Podcast.js
+++ b/server/objects/mediaTypes/Podcast.js
@@ -2,7 +2,7 @@ const Logger = require('../../Logger')
const PodcastEpisode = require('../entities/PodcastEpisode')
const PodcastMetadata = require('../metadata/PodcastMetadata')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
-const abmetadataGenerator = require('../../utils/abmetadataGenerator')
+const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator')
const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils')
const { createNewSortInstance } = require('../../libs/fastSort')
const naturalSort = createNewSortInstance({
diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js
index f853219e..a7cae46f 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -90,7 +90,7 @@ class ApiRouter {
this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this))
this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this))
-
+ this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this))
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
//
@@ -236,7 +236,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.post('/podcasts/opml', PodcastController.getFeedsFromOPMLText.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/abmetadataGenerator.js b/server/utils/generators/abmetadataGenerator.js
similarity index 98%
rename from server/utils/abmetadataGenerator.js
rename to server/utils/generators/abmetadataGenerator.js
index b0415aea..da9db2c9 100644
--- a/server/utils/abmetadataGenerator.js
+++ b/server/utils/generators/abmetadataGenerator.js
@@ -1,9 +1,9 @@
-const fs = require('../libs/fsExtra')
-const filePerms = require('./filePerms')
-const package = require('../../package.json')
-const Logger = require('../Logger')
-const { getId } = require('./index')
-const areEquivalent = require('../utils/areEquivalent')
+const fs = require('../../libs/fsExtra')
+const filePerms = require('../filePerms')
+const package = require('../../../package.json')
+const Logger = require('../../Logger')
+const { getId } = require('../index')
+const areEquivalent = require('../areEquivalent')
const CurrentAbMetadataVersion = 2
diff --git a/server/utils/hlsPlaylistGenerator.js b/server/utils/generators/hlsPlaylistGenerator.js
similarity index 96%
rename from server/utils/hlsPlaylistGenerator.js
rename to server/utils/generators/hlsPlaylistGenerator.js
index 27511a43..58c1d69a 100644
--- a/server/utils/hlsPlaylistGenerator.js
+++ b/server/utils/generators/hlsPlaylistGenerator.js
@@ -1,4 +1,4 @@
-const fs = require('../libs/fsExtra')
+const fs = require('../../libs/fsExtra')
function getPlaylistStr(segmentName, duration, segmentLength, hlsSegmentType, token) {
var ext = hlsSegmentType === 'fmp4' ? 'm4s' : 'ts'
diff --git a/server/utils/generators/opmlGenerator.js b/server/utils/generators/opmlGenerator.js
new file mode 100644
index 00000000..60d3d450
--- /dev/null
+++ b/server/utils/generators/opmlGenerator.js
@@ -0,0 +1,52 @@
+const xml = require('../../libs/xml')
+
+module.exports.generate = (libraryItems, indent = true) => {
+ const bodyItems = []
+ libraryItems.forEach((item) => {
+ if (!item.media.metadata.feedUrl) return
+ const feedAttributes = {
+ type: 'rss',
+ text: item.media.metadata.title,
+ title: item.media.metadata.title,
+ xmlUrl: item.media.metadata.feedUrl
+ }
+ if (item.media.metadata.description) {
+ feedAttributes.description = item.media.metadata.description
+ }
+ if (item.media.metadata.itunesPageUrl) {
+ feedAttributes.htmlUrl = item.media.metadata.itunesPageUrl
+ }
+ if (item.media.metadata.language) {
+ feedAttributes.language = item.media.metadata.language
+ }
+ bodyItems.push({
+ outline: {
+ _attr: feedAttributes
+ }
+ })
+ })
+
+ const data = [
+ {
+ opml: [
+ {
+ _attr: {
+ version: '1.0'
+ }
+ },
+ {
+ head: [
+ {
+ title: 'Audiobookshelf Podcast Subscriptions'
+ }
+ ]
+ },
+ {
+ body: bodyItems
+ }
+ ]
+ }
+ ]
+
+ return '\n' + xml(data, indent)
+}
\ No newline at end of file