mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Add:OPML Upload for bulk adding podcasts #588
This commit is contained in:
		
							parent
							
								
									e5469cc0f8
								
							
						
					
					
						commit
						514893646a
					
				
							
								
								
									
										71
									
								
								client/components/cards/PodcastFeedSummaryCard.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								client/components/cards/PodcastFeedSummaryCard.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,71 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div ref="wrapper" class="w-full p-2 border border-white border-opacity-10 rounded">
 | 
				
			||||||
 | 
					    <div class="flex">
 | 
				
			||||||
 | 
					      <div class="w-16 min-w-16">
 | 
				
			||||||
 | 
					        <div class="w-full h-16 bg-primary">
 | 
				
			||||||
 | 
					          <img v-if="image" :src="image" class="w-full h-full object-cover" />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <p class="text-gray-400 text-xxs pt-1 text-center">{{ numEpisodes }} Episodes</p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="flex-grow pl-2" :style="{ maxWidth: detailsWidth + 'px' }">
 | 
				
			||||||
 | 
					        <p class="mb-1">{{ title }}</p>
 | 
				
			||||||
 | 
					        <p class="text-xs mb-1 text-gray-300">{{ author }}</p>
 | 
				
			||||||
 | 
					        <p class="text-xs mb-2 text-gray-200">{{ description }}</p>
 | 
				
			||||||
 | 
					        <p class="text-xs truncate text-blue-200">
 | 
				
			||||||
 | 
					          Folder: <span class="font-mono">{{ folderPath }}</span>
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script>
 | 
				
			||||||
 | 
					export default {
 | 
				
			||||||
 | 
					  props: {
 | 
				
			||||||
 | 
					    feed: {
 | 
				
			||||||
 | 
					      type: Object,
 | 
				
			||||||
 | 
					      default: () => {}
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    libraryFolderPath: String
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  data() {
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      width: 900
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  computed: {
 | 
				
			||||||
 | 
					    title() {
 | 
				
			||||||
 | 
					      return this.metadata.title || 'No Title'
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    image() {
 | 
				
			||||||
 | 
					      return this.metadata.imageUrl
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    description() {
 | 
				
			||||||
 | 
					      return this.metadata.description || ''
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    author() {
 | 
				
			||||||
 | 
					      return this.metadata.author || ''
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    metadata() {
 | 
				
			||||||
 | 
					      return this.feed || {}
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    numEpisodes() {
 | 
				
			||||||
 | 
					      return this.feed.numEpisodes || 0
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    folderPath() {
 | 
				
			||||||
 | 
					      if (!this.libraryFolderPath) return ''
 | 
				
			||||||
 | 
					      return `${this.libraryFolderPath}\\${this.$sanitizeFilename(this.title)}`
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    detailsWidth() {
 | 
				
			||||||
 | 
					      return this.width - 85
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  methods: {},
 | 
				
			||||||
 | 
					  updated() {
 | 
				
			||||||
 | 
					    this.width = this.$refs.wrapper.clientWidth
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  mounted() {
 | 
				
			||||||
 | 
					    this.width = this.$refs.wrapper.clientWidth
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
							
								
								
									
										168
									
								
								client/components/modals/podcast/OpmlFeedsModal.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								client/components/modals/podcast/OpmlFeedsModal.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,168 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <modals-modal v-model="show" name="opml-feeds-modal" :width="1000" :height="'unset'" :processing="processing">
 | 
				
			||||||
 | 
					    <template #outer>
 | 
				
			||||||
 | 
					      <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
 | 
				
			||||||
 | 
					        <p class="font-book text-3xl text-white truncate">{{ title }}</p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </template>
 | 
				
			||||||
 | 
					    <div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
 | 
				
			||||||
 | 
					      <div class="w-full p-4">
 | 
				
			||||||
 | 
					        <div class="flex items-center -mx-2 mb-2">
 | 
				
			||||||
 | 
					          <div class="w-full md:w-2/3 p-2">
 | 
				
			||||||
 | 
					            <ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div class="w-full md:w-1/3 p-2 pt-6">
 | 
				
			||||||
 | 
					            <ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="text-sm font-semibold pl-2" />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <p class="text-lg font-semibold mb-2">Podcasts to Add</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div class="w-full overflow-y-auto" style="max-height: 50vh">
 | 
				
			||||||
 | 
					          <template v-for="(feed, index) in feedMetadata">
 | 
				
			||||||
 | 
					            <cards-podcast-feed-summary-card :key="index" :feed="feed" :library-folder-path="selectedFolderPath" class="my-1" />
 | 
				
			||||||
 | 
					          </template>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="flex items-center py-4">
 | 
				
			||||||
 | 
					        <div class="flex-grow" />
 | 
				
			||||||
 | 
					        <ui-btn color="success" @click="submit">Add Podcasts</ui-btn>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </modals-modal>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script>
 | 
				
			||||||
 | 
					export default {
 | 
				
			||||||
 | 
					  props: {
 | 
				
			||||||
 | 
					    value: Boolean,
 | 
				
			||||||
 | 
					    feeds: {
 | 
				
			||||||
 | 
					      type: Array,
 | 
				
			||||||
 | 
					      default: () => []
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  data() {
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      processing: false,
 | 
				
			||||||
 | 
					      selectedFolderId: null,
 | 
				
			||||||
 | 
					      fullPath: null,
 | 
				
			||||||
 | 
					      autoDownloadEpisodes: false,
 | 
				
			||||||
 | 
					      feedMetadata: []
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  watch: {
 | 
				
			||||||
 | 
					    show: {
 | 
				
			||||||
 | 
					      immediate: true,
 | 
				
			||||||
 | 
					      handler(newVal) {
 | 
				
			||||||
 | 
					        if (newVal) {
 | 
				
			||||||
 | 
					          this.init()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  computed: {
 | 
				
			||||||
 | 
					    show: {
 | 
				
			||||||
 | 
					      get() {
 | 
				
			||||||
 | 
					        return this.value
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      set(val) {
 | 
				
			||||||
 | 
					        this.$emit('input', val)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    title() {
 | 
				
			||||||
 | 
					      return 'OPML Feeds'
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    currentLibrary() {
 | 
				
			||||||
 | 
					      return this.$store.getters['libraries/getCurrentLibrary']
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    folders() {
 | 
				
			||||||
 | 
					      if (!this.currentLibrary) return []
 | 
				
			||||||
 | 
					      return this.currentLibrary.folders || []
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    folderItems() {
 | 
				
			||||||
 | 
					      return this.folders.map((fold) => {
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					          value: fold.id,
 | 
				
			||||||
 | 
					          text: fold.fullPath
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    selectedFolder() {
 | 
				
			||||||
 | 
					      return this.folders.find((f) => f.id === this.selectedFolderId)
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    selectedFolderPath() {
 | 
				
			||||||
 | 
					      if (!this.selectedFolder) return ''
 | 
				
			||||||
 | 
					      return this.selectedFolder.fullPath
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  methods: {
 | 
				
			||||||
 | 
					    toFeedMetadata(feed) {
 | 
				
			||||||
 | 
					      var metadata = feed.metadata
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        title: metadata.title,
 | 
				
			||||||
 | 
					        author: metadata.author,
 | 
				
			||||||
 | 
					        description: metadata.description,
 | 
				
			||||||
 | 
					        releaseDate: '',
 | 
				
			||||||
 | 
					        genres: [...metadata.categories],
 | 
				
			||||||
 | 
					        feedUrl: metadata.feedUrl,
 | 
				
			||||||
 | 
					        imageUrl: metadata.image,
 | 
				
			||||||
 | 
					        itunesPageUrl: '',
 | 
				
			||||||
 | 
					        itunesId: '',
 | 
				
			||||||
 | 
					        itunesArtistId: '',
 | 
				
			||||||
 | 
					        language: '',
 | 
				
			||||||
 | 
					        numEpisodes: feed.numEpisodes
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    init() {
 | 
				
			||||||
 | 
					      this.feedMetadata = this.feeds.map(this.toFeedMetadata)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (this.folderItems[0]) {
 | 
				
			||||||
 | 
					        this.selectedFolderId = this.folderItems[0].value
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    async submit() {
 | 
				
			||||||
 | 
					      this.processing = true
 | 
				
			||||||
 | 
					      var newFeedPayloads = this.feedMetadata.map((metadata) => {
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					          path: `${this.selectedFolderPath}\\${this.$sanitizeFilename(metadata.title)}`,
 | 
				
			||||||
 | 
					          folderId: this.selectedFolderId,
 | 
				
			||||||
 | 
					          libraryId: this.currentLibrary.id,
 | 
				
			||||||
 | 
					          media: {
 | 
				
			||||||
 | 
					            metadata: {
 | 
				
			||||||
 | 
					              ...metadata
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            autoDownloadEpisodes: this.autoDownloadEpisodes
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      console.log('New feed payloads', newFeedPayloads)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      for (const podcastPayload of newFeedPayloads) {
 | 
				
			||||||
 | 
					        await this.$axios
 | 
				
			||||||
 | 
					          .$post('/api/podcasts', podcastPayload)
 | 
				
			||||||
 | 
					          .then(() => {
 | 
				
			||||||
 | 
					            this.$toast.success(`${podcastPayload.media.metadata.title}: Podcast created successfully`)
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					          .catch((error) => {
 | 
				
			||||||
 | 
					            var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to create podcast'
 | 
				
			||||||
 | 
					            console.error('Failed to create podcast', podcastPayload, error)
 | 
				
			||||||
 | 
					            this.$toast.error(`${podcastPayload.media.metadata.title}: ${errorMsg}`)
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.processing = false
 | 
				
			||||||
 | 
					      this.show = false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  mounted() {}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					#podcast-wrapper {
 | 
				
			||||||
 | 
					  min-height: 400px;
 | 
				
			||||||
 | 
					  max-height: 80vh;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					#episodes-scroll {
 | 
				
			||||||
 | 
					  max-height: calc(80vh - 200px);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div>
 | 
					  <div>
 | 
				
			||||||
    <input ref="fileInput" id="hidden-input" type="file" :accept="accept" class="hidden" @change="inputChanged" />
 | 
					    <input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" />
 | 
				
			||||||
    <ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn>
 | 
					    <ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,12 +1,14 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
 | 
					  <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
 | 
				
			||||||
    <app-book-shelf-toolbar page="podcast-search" />
 | 
					    <app-book-shelf-toolbar page="podcast-search" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div class="w-full h-full overflow-y-auto p-12 relative">
 | 
					    <div class="w-full h-full overflow-y-auto p-12 relative">
 | 
				
			||||||
      <div class="w-full max-w-3xl mx-auto">
 | 
					      <div class="w-full max-w-4xl mx-auto flex">
 | 
				
			||||||
        <form @submit.prevent="submit" class="flex">
 | 
					        <form @submit.prevent="submit" class="flex flex-grow">
 | 
				
			||||||
          <ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
 | 
					          <ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
 | 
				
			||||||
          <ui-btn type="submit" :disabled="processing">Submit</ui-btn>
 | 
					          <ui-btn type="submit" :disabled="processing">Submit</ui-btn>
 | 
				
			||||||
        </form>
 | 
					        </form>
 | 
				
			||||||
 | 
					        <ui-file-input :accept="'.opml, .txt'" class="mx-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div class="w-full max-w-3xl mx-auto py-4">
 | 
					      <div class="w-full max-w-3xl mx-auto py-4">
 | 
				
			||||||
@ -32,6 +34,7 @@
 | 
				
			|||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <modals-podcast-new-modal v-model="showNewPodcastModal" :podcast-data="selectedPodcast" :podcast-feed-data="selectedPodcastFeed" />
 | 
					    <modals-podcast-new-modal v-model="showNewPodcastModal" :podcast-data="selectedPodcast" :podcast-feed-data="selectedPodcastFeed" />
 | 
				
			||||||
 | 
					    <modals-podcast-opml-feeds-modal v-model="showOPMLFeedsModal" :feeds="opmlFeeds" />
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -62,7 +65,9 @@ export default {
 | 
				
			|||||||
      processing: false,
 | 
					      processing: false,
 | 
				
			||||||
      showNewPodcastModal: false,
 | 
					      showNewPodcastModal: false,
 | 
				
			||||||
      selectedPodcast: null,
 | 
					      selectedPodcast: null,
 | 
				
			||||||
      selectedPodcastFeed: null
 | 
					      selectedPodcastFeed: null,
 | 
				
			||||||
 | 
					      showOPMLFeedsModal: false,
 | 
				
			||||||
 | 
					      opmlFeeds: []
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  computed: {
 | 
					  computed: {
 | 
				
			||||||
@ -71,6 +76,36 @@ export default {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  methods: {
 | 
					  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('<opml') || !txt.includes('<outline ')) {
 | 
				
			||||||
 | 
					        // Quick lazy check for valid OPML
 | 
				
			||||||
 | 
					        this.$toast.error('Invalid OPML file <opml> tag not found OR an <outline> 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() {
 | 
					    submit() {
 | 
				
			||||||
      if (!this.searchInput) return
 | 
					      if (!this.searchInput) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -64,8 +64,8 @@
 | 
				
			|||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
 | 
					    <input ref="fileInput" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
 | 
				
			||||||
    <input ref="fileFolderInput" id="hidden-input" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
 | 
					    <input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -37,6 +37,7 @@ module.exports = {
 | 
				
			|||||||
      minWidth: {
 | 
					      minWidth: {
 | 
				
			||||||
        '6': '1.5rem',
 | 
					        '6': '1.5rem',
 | 
				
			||||||
        '12': '3rem',
 | 
					        '12': '3rem',
 | 
				
			||||||
 | 
					        '16': '4rem',
 | 
				
			||||||
        '24': '6rem',
 | 
					        '24': '6rem',
 | 
				
			||||||
        '32': '8rem',
 | 
					        '32': '8rem',
 | 
				
			||||||
        '48': '12rem',
 | 
					        '48': '12rem',
 | 
				
			||||||
@ -75,6 +76,9 @@ module.exports = {
 | 
				
			|||||||
        mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
 | 
					        mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
 | 
				
			||||||
        book: ['Gentium Book Basic', 'serif']
 | 
					        book: ['Gentium Book Basic', 'serif']
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 | 
					      fontSize: {
 | 
				
			||||||
 | 
					        xxs: '0.625rem'
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
      zIndex: {
 | 
					      zIndex: {
 | 
				
			||||||
        '50': 50
 | 
					        '50': 50
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
				
			|||||||
@ -104,7 +104,7 @@ class PodcastController {
 | 
				
			|||||||
        return res.status(500).send('Bad response from feed request')
 | 
					        return res.status(500).send('Bad response from feed request')
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      Logger.debug(`[PdocastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
 | 
					      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) {
 | 
					      if (!payload) {
 | 
				
			||||||
        return res.status(500).send('Invalid podcast RSS feed')
 | 
					        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) {
 | 
					  async checkNewEpisodes(req, res) {
 | 
				
			||||||
    if (!req.user.isAdminOrUp) {
 | 
					    if (!req.user.isAdminOrUp) {
 | 
				
			||||||
      Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user)
 | 
					      Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user)
 | 
				
			||||||
 | 
				
			|||||||
@ -6,6 +6,7 @@ const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
 | 
				
			|||||||
const Logger = require('../Logger')
 | 
					const Logger = require('../Logger')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { downloadFile } = require('../utils/fileUtils')
 | 
					const { downloadFile } = require('../utils/fileUtils')
 | 
				
			||||||
 | 
					const opmlParser = require('../utils/parsers/parseOPML')
 | 
				
			||||||
const prober = require('../utils/prober')
 | 
					const prober = require('../utils/prober')
 | 
				
			||||||
const LibraryFile = require('../objects/files/LibraryFile')
 | 
					const LibraryFile = require('../objects/files/LibraryFile')
 | 
				
			||||||
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
 | 
					const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
 | 
				
			||||||
@ -258,7 +259,7 @@ class PodcastManager {
 | 
				
			|||||||
    return newEpisodes
 | 
					    return newEpisodes
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getPodcastFeed(feedUrl) {
 | 
					  getPodcastFeed(feedUrl, excludeEpisodeMetadata = false) {
 | 
				
			||||||
    Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}"`)
 | 
					    Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}"`)
 | 
				
			||||||
    return axios.get(feedUrl, { timeout: 5000 }).then(async (data) => {
 | 
					    return axios.get(feedUrl, { timeout: 5000 }).then(async (data) => {
 | 
				
			||||||
      if (!data || !data.data) {
 | 
					      if (!data || !data.data) {
 | 
				
			||||||
@ -266,7 +267,7 @@ class PodcastManager {
 | 
				
			|||||||
        return false
 | 
					        return false
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}" success - parsing xml`)
 | 
					      Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}" success - parsing xml`)
 | 
				
			||||||
      var payload = await parsePodcastRssFeedXml(data.data)
 | 
					      var payload = await parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata)
 | 
				
			||||||
      if (!payload) {
 | 
					      if (!payload) {
 | 
				
			||||||
        return false
 | 
					        return false
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
@ -276,5 +277,29 @@ class PodcastManager {
 | 
				
			|||||||
      return false
 | 
					      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
 | 
					module.exports = PodcastManager
 | 
				
			||||||
@ -2,7 +2,7 @@ const Path = require('path')
 | 
				
			|||||||
const Logger = require('../../Logger')
 | 
					const Logger = require('../../Logger')
 | 
				
			||||||
const BookMetadata = require('../metadata/BookMetadata')
 | 
					const BookMetadata = require('../metadata/BookMetadata')
 | 
				
			||||||
const { areEquivalent, copyValue } = require('../../utils/index')
 | 
					const { areEquivalent, copyValue } = require('../../utils/index')
 | 
				
			||||||
const { parseOpfMetadataXML } = require('../../utils/parseOpfMetadata')
 | 
					const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata')
 | 
				
			||||||
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
 | 
					const abmetadataGenerator = require('../../utils/abmetadataGenerator')
 | 
				
			||||||
const { readTextFile } = require('../../utils/fileUtils')
 | 
					const { readTextFile } = require('../../utils/fileUtils')
 | 
				
			||||||
const AudioFile = require('../files/AudioFile')
 | 
					const AudioFile = require('../files/AudioFile')
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
const Logger = require('../../Logger')
 | 
					const Logger = require('../../Logger')
 | 
				
			||||||
const { areEquivalent, copyValue } = require('../../utils/index')
 | 
					const { areEquivalent, copyValue } = require('../../utils/index')
 | 
				
			||||||
const parseNameString = require('../../utils/parseNameString')
 | 
					const parseNameString = require('../../utils/parsers/parseNameString')
 | 
				
			||||||
class BookMetadata {
 | 
					class BookMetadata {
 | 
				
			||||||
  constructor(metadata) {
 | 
					  constructor(metadata) {
 | 
				
			||||||
    this.title = null
 | 
					    this.title = null
 | 
				
			||||||
 | 
				
			|||||||
@ -182,6 +182,7 @@ class ApiRouter {
 | 
				
			|||||||
    //
 | 
					    //
 | 
				
			||||||
    this.router.post('/podcasts', PodcastController.create.bind(this))
 | 
					    this.router.post('/podcasts', PodcastController.create.bind(this))
 | 
				
			||||||
    this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.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/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/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))
 | 
				
			||||||
    this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
 | 
					    this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										24
									
								
								server/utils/parsers/parseOPML.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								server/utils/parsers/parseOPML.js
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
				
			||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
const { xmlToJSON } = require('./index')
 | 
					const { xmlToJSON } = require('../index')
 | 
				
			||||||
const htmlSanitizer = require('./htmlSanitizer')
 | 
					const htmlSanitizer = require('../htmlSanitizer')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function parseCreators(metadata) {
 | 
					function parseCreators(metadata) {
 | 
				
			||||||
  if (!metadata['dc:creator']) return null
 | 
					  if (!metadata['dc:creator']) return null
 | 
				
			||||||
@ -131,7 +131,7 @@ function extractPodcastEpisodes(items) {
 | 
				
			|||||||
  return episodes
 | 
					  return episodes
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function cleanPodcastJson(rssJson) {
 | 
					function cleanPodcastJson(rssJson, excludeEpisodeMetadata) {
 | 
				
			||||||
  if (!rssJson.channel || !rssJson.channel.length) {
 | 
					  if (!rssJson.channel || !rssJson.channel.length) {
 | 
				
			||||||
    Logger.error(`[podcastUtil] Invalid podcast no channel object`)
 | 
					    Logger.error(`[podcastUtil] Invalid podcast no channel object`)
 | 
				
			||||||
    return null
 | 
					    return null
 | 
				
			||||||
@ -142,13 +142,17 @@ function cleanPodcastJson(rssJson) {
 | 
				
			|||||||
    return null
 | 
					    return null
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  var podcast = {
 | 
					  var podcast = {
 | 
				
			||||||
    metadata: extractPodcastMetadata(channel),
 | 
					    metadata: extractPodcastMetadata(channel)
 | 
				
			||||||
    episodes: extractPodcastEpisodes(channel.item)
 | 
					  }
 | 
				
			||||||
 | 
					  if (!excludeEpisodeMetadata) {
 | 
				
			||||||
 | 
					    podcast.episodes = extractPodcastEpisodes(channel.item)
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    podcast.numEpisodes = channel.item.length
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  return podcast
 | 
					  return podcast
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports.parsePodcastRssFeedXml = async (xml, includeRaw = false) => {
 | 
					module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = false, includeRaw = false) => {
 | 
				
			||||||
  if (!xml) return null
 | 
					  if (!xml) return null
 | 
				
			||||||
  var json = await xmlToJSON(xml)
 | 
					  var json = await xmlToJSON(xml)
 | 
				
			||||||
  if (!json || !json.rss) {
 | 
					  if (!json || !json.rss) {
 | 
				
			||||||
@ -156,7 +160,7 @@ module.exports.parsePodcastRssFeedXml = async (xml, includeRaw = false) => {
 | 
				
			|||||||
    return null
 | 
					    return null
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const podcast = cleanPodcastJson(json.rss)
 | 
					  const podcast = cleanPodcastJson(json.rss, excludeEpisodeMetadata)
 | 
				
			||||||
  if (!podcast) return null
 | 
					  if (!podcast) return null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (includeRaw) {
 | 
					  if (includeRaw) {
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user