diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue
index c8a70a2a..d7d850d5 100644
--- a/client/components/app/BookShelfCategorized.vue
+++ b/client/components/app/BookShelfCategorized.vue
@@ -167,8 +167,19 @@ export default {
       this.loaded = true
     },
     async fetchCategories() {
+      // Sets the limit for the number of items to be displayed based on the viewport width.
+      const viewportWidth = window.innerWidth
+      let limit
+      if (viewportWidth >= 3240) {
+        limit = 15
+      } else if (viewportWidth >= 2880 && viewportWidth < 3240) {
+        limit = 12
+      }
+
+      const limitQuery = limit ? `&limit=${limit}` : ''
+
       const categories = await this.$axios
-        .$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete,share`)
+        .$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete,share${limitQuery}`)
         .then((data) => {
           return data
         })
diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue
index b4835255..2b46eb7c 100644
--- a/client/components/app/ConfigSideNav.vue
+++ b/client/components/app/ConfigSideNav.vue
@@ -114,9 +114,9 @@ export default {
 
       if (this.currentLibraryId) {
         configRoutes.push({
-          id: 'config-library-stats',
+          id: 'library-stats',
           title: this.$strings.HeaderLibraryStats,
-          path: '/config/library-stats'
+          path: `/library/${this.currentLibraryId}/stats`
         })
         configRoutes.push({
           id: 'config-stats',
@@ -182,4 +182,4 @@ export default {
     }
   }
 }
-</script>
\ No newline at end of file
+</script>
diff --git a/client/components/app/MediaPlayerContainer.vue b/client/components/app/MediaPlayerContainer.vue
index 3c99a6da..cbc76803 100644
--- a/client/components/app/MediaPlayerContainer.vue
+++ b/client/components/app/MediaPlayerContainer.vue
@@ -35,11 +35,13 @@
     <player-ui
       ref="audioPlayer"
       :chapters="chapters"
+      :current-chapter="currentChapter"
       :paused="!isPlaying"
       :loading="playerLoading"
       :bookmarks="bookmarks"
       :sleep-timer-set="sleepTimerSet"
       :sleep-timer-remaining="sleepTimerRemaining"
+      :sleep-timer-type="sleepTimerType"
       :is-podcast="isPodcast"
       @playPause="playPause"
       @jumpForward="jumpForward"
@@ -51,13 +53,16 @@
       @showBookmarks="showBookmarks"
       @showSleepTimer="showSleepTimerModal = true"
       @showPlayerQueueItems="showPlayerQueueItemsModal = true"
+      @showPlayerSettings="showPlayerSettingsModal = true"
     />
 
     <modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
 
-    <modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
+    <modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
 
     <modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
+
+    <modals-player-settings-modal v-model="showPlayerSettingsModal" />
   </div>
 </template>
 
@@ -76,9 +81,10 @@ export default {
       currentTime: 0,
       showSleepTimerModal: false,
       showPlayerQueueItemsModal: false,
+      showPlayerSettingsModal: false,
       sleepTimerSet: false,
-      sleepTimerTime: 0,
       sleepTimerRemaining: 0,
+      sleepTimerType: null,
       sleepTimer: null,
       displayTitle: null,
       currentPlaybackRate: 1,
@@ -145,6 +151,9 @@ export default {
       if (this.streamEpisode) return this.streamEpisode.chapters || []
       return this.media.chapters || []
     },
+    currentChapter() {
+      return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
+    },
     title() {
       if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
       return this.mediaMetadata.title || 'No Title'
@@ -204,14 +213,18 @@ export default {
       this.$store.commit('setIsPlaying', isPlaying)
       this.updateMediaSessionPlaybackState()
     },
-    setSleepTimer(seconds) {
+    setSleepTimer(time) {
       this.sleepTimerSet = true
-      this.sleepTimerTime = seconds
-      this.sleepTimerRemaining = seconds
-      this.runSleepTimer()
       this.showSleepTimerModal = false
+
+      this.sleepTimerType = time.timerType
+      if (this.sleepTimerType === this.$constants.SleepTimerTypes.COUNTDOWN) {
+        this.runSleepTimer(time)
+      }
     },
-    runSleepTimer() {
+    runSleepTimer(time) {
+      this.sleepTimerRemaining = time.seconds
+
       var lastTick = Date.now()
       clearInterval(this.sleepTimer)
       this.sleepTimer = setInterval(() => {
@@ -220,12 +233,23 @@ export default {
         this.sleepTimerRemaining -= elapsed / 1000
 
         if (this.sleepTimerRemaining <= 0) {
-          this.clearSleepTimer()
-          this.playerHandler.pause()
-          this.$toast.info('Sleep Timer Done.. zZzzZz')
+          this.sleepTimerEnd()
         }
       }, 1000)
     },
+    checkChapterEnd(time) {
+      if (!this.currentChapter) return
+      const chapterEndTime = this.currentChapter.end
+      const tolerance = 0.75
+      if (time >= chapterEndTime - tolerance) {
+        this.sleepTimerEnd()
+      }
+    },
+    sleepTimerEnd() {
+      this.clearSleepTimer()
+      this.playerHandler.pause()
+      this.$toast.info('Sleep Timer Done.. zZzzZz')
+    },
     cancelSleepTimer() {
       this.showSleepTimerModal = false
       this.clearSleepTimer()
@@ -235,6 +259,7 @@ export default {
       this.sleepTimerRemaining = 0
       this.sleepTimer = null
       this.sleepTimerSet = false
+      this.sleepTimerType = null
     },
     incrementSleepTimer(amount) {
       if (!this.sleepTimerSet) return
@@ -275,6 +300,10 @@ export default {
       if (this.$refs.audioPlayer) {
         this.$refs.audioPlayer.setCurrentTime(time)
       }
+
+      if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER && this.sleepTimerSet) {
+        this.checkChapterEnd(time)
+      }
     },
     setDuration(duration) {
       this.totalDuration = duration
diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue
index 7475f7ed..2c1538ec 100644
--- a/client/components/app/SideRail.vue
+++ b/client/components/app/SideRail.vue
@@ -79,6 +79,14 @@
         <div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
       </nuxt-link>
 
+      <nuxt-link v-if="isBookLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/stats`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isStatsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
+        <span class="material-symbols text-2xl">monitoring</span>
+
+        <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonStats }}</p>
+
+        <div v-show="isStatsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
+      </nuxt-link>
+
       <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
         <span class="abs-icons icon-podcast text-xl"></span>
 
@@ -103,7 +111,7 @@
         <div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
       </nuxt-link>
 
-      <nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
+      <nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : 'bg-error bg-opacity-20'">
         <span class="material-symbols text-2xl">warning</span>
 
         <p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
@@ -194,6 +202,9 @@ export default {
     isPlaylistsPage() {
       return this.paramId === 'playlists'
     },
+    isStatsPage() {
+      return this.$route.name === 'library-library-stats'
+    },
     libraryBookshelfPage() {
       return this.$route.name === 'library-library-bookshelf-id'
     },
diff --git a/client/components/cards/LazySeriesCard.vue b/client/components/cards/LazySeriesCard.vue
index ff7d2a87..1479d189 100644
--- a/client/components/cards/LazySeriesCard.vue
+++ b/client/components/cards/LazySeriesCard.vue
@@ -81,16 +81,16 @@ export default {
       return this.store.getters['user/getSizeMultiplier']
     },
     seriesId() {
-      return this.series ? this.series.id : ''
+      return this.series?.id || ''
     },
     title() {
-      return this.series ? this.series.name : ''
+      return this.series?.name || ''
     },
     nameIgnorePrefix() {
-      return this.series ? this.series.nameIgnorePrefix : ''
+      return this.series?.nameIgnorePrefix || ''
     },
     displayTitle() {
-      if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title
+      if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title || '\u00A0'
       return this.title || '\u00A0'
     },
     displaySortLine() {
@@ -110,13 +110,13 @@ export default {
       }
     },
     books() {
-      return this.series ? this.series.books || [] : []
+      return this.series?.books || []
     },
     addedAt() {
-      return this.series ? this.series.addedAt : 0
+      return this.series?.addedAt || 0
     },
     totalDuration() {
-      return this.series ? this.series.totalDuration : 0
+      return this.series?.totalDuration || 0
     },
     seriesBookProgress() {
       return this.books
@@ -161,7 +161,7 @@ export default {
       return this.bookshelfView == constants.BookshelfView.DETAIL
     },
     rssFeed() {
-      return this.series ? this.series.rssFeed : null
+      return this.series?.rssFeed
     }
   },
   methods: {
diff --git a/client/components/modals/PlayerSettingsModal.vue b/client/components/modals/PlayerSettingsModal.vue
new file mode 100644
index 00000000..ec178d9c
--- /dev/null
+++ b/client/components/modals/PlayerSettingsModal.vue
@@ -0,0 +1,70 @@
+<template>
+  <modals-modal v-model="show" name="player-settings" :width="500" :height="'unset'">
+    <div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-4" style="max-height: 80vh; min-height: 40vh">
+      <h3 class="text-xl font-semibold mb-8">{{ $strings.HeaderPlayerSettings }}</h3>
+      <div class="flex items-center mb-4">
+        <ui-toggle-switch v-model="useChapterTrack" @input="setUseChapterTrack" />
+        <div class="pl-4">
+          <span>{{ $strings.LabelUseChapterTrack }}</span>
+        </div>
+      </div>
+      <div class="flex items-center mb-4">
+        <ui-select-input v-model="jumpForwardAmount" :label="$strings.LabelJumpForwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpForwardAmount" />
+      </div>
+      <div class="flex items-center">
+        <ui-select-input v-model="jumpBackwardAmount" :label="$strings.LabelJumpBackwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpBackwardAmount" />
+      </div>
+    </div>
+  </modals-modal>
+</template>
+
+<script>
+export default {
+  props: {
+    value: Boolean
+  },
+  data() {
+    return {
+      useChapterTrack: false,
+      jumpValues: [
+        { text: this.$getString('LabelTimeDurationXSeconds', ['10']), value: 10 },
+        { text: this.$getString('LabelTimeDurationXSeconds', ['15']), value: 15 },
+        { text: this.$getString('LabelTimeDurationXSeconds', ['30']), value: 30 },
+        { text: this.$getString('LabelTimeDurationXSeconds', ['60']), value: 60 },
+        { text: this.$getString('LabelTimeDurationXMinutes', ['2']), value: 120 },
+        { text: this.$getString('LabelTimeDurationXMinutes', ['5']), value: 300 }
+      ],
+      jumpForwardAmount: 10,
+      jumpBackwardAmount: 10
+    }
+  },
+  computed: {
+    show: {
+      get() {
+        return this.value
+      },
+      set(val) {
+        this.$emit('input', val)
+      }
+    }
+  },
+  methods: {
+    setUseChapterTrack() {
+      this.$store.dispatch('user/updateUserSettings', { useChapterTrack: this.useChapterTrack })
+    },
+    setJumpForwardAmount(val) {
+      this.jumpForwardAmount = val
+      this.$store.dispatch('user/updateUserSettings', { jumpForwardAmount: val })
+    },
+    setJumpBackwardAmount(val) {
+      this.jumpBackwardAmount = val
+      this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
+    }
+  },
+  mounted() {
+    this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
+    this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
+    this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
+  }
+}
+</script>
diff --git a/client/components/modals/SleepTimerModal.vue b/client/components/modals/SleepTimerModal.vue
index 051c5d3d..43b55217 100644
--- a/client/components/modals/SleepTimerModal.vue
+++ b/client/components/modals/SleepTimerModal.vue
@@ -6,34 +6,36 @@
       </div>
     </template>
 
-    <div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
-      <div v-if="!timerSet" class="w-full">
+    <div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
+      <div class="w-full">
         <template v-for="time in sleepTimes">
-          <div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time.seconds)">
-            <p class="text-xl text-center">{{ time.text }}</p>
+          <div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-primary/25 relative" @click="setTime(time)">
+            <p class="text-lg text-center">{{ time.text }}</p>
           </div>
         </template>
         <form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
-          <ui-text-input v-model="customTime" type="number" step="any" min="0.1" placeholder="Time in minutes" class="w-48" />
+          <ui-text-input v-model="customTime" type="number" step="any" min="0.1" :placeholder="$strings.LabelTimeInMinutes" class="w-48" />
           <ui-btn color="success" type="submit" :padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
         </form>
       </div>
-      <div v-else class="w-full p-4">
-        <div class="mb-4 flex items-center justify-center">
-          <ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center mr-4" @click="decrement(30 * 60)">
+      <div v-if="timerSet" class="w-full p-4">
+        <div class="mb-4 h-px w-full bg-white/10" />
+
+        <div v-if="timerType === $constants.SleepTimerTypes.COUNTDOWN" class="mb-4 flex items-center justify-center space-x-4">
+          <ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center h-9" @click="decrement(30 * 60)">
             <span class="material-symbols text-lg">remove</span>
-            <span class="pl-1 text-base font-mono">30m</span>
+            <span class="pl-1 text-sm">30m</span>
           </ui-btn>
 
-          <ui-icon-btn icon="remove" @click="decrement(60 * 5)" />
+          <ui-icon-btn icon="remove" class="min-w-9" @click="decrement(60 * 5)" />
 
-          <p class="mx-6 text-2xl font-mono">{{ $secondsToTimestamp(remaining) }}</p>
+          <p class="text-2xl font-mono">{{ $secondsToTimestamp(remaining) }}</p>
 
-          <ui-icon-btn icon="add" @click="increment(60 * 5)" />
+          <ui-icon-btn icon="add" class="min-w-9" @click="increment(60 * 5)" />
 
-          <ui-btn :padding-x="2" small class="flex items-center ml-4" @click="increment(30 * 60)">
+          <ui-btn :padding-x="2" small class="flex items-center h-9" @click="increment(30 * 60)">
             <span class="material-symbols text-lg">add</span>
-            <span class="pl-1 text-base font-mono">30m</span>
+            <span class="pl-1 text-sm">30m</span>
           </ui-btn>
         </div>
         <ui-btn class="w-full" @click="$emit('cancel')">{{ $strings.ButtonCancel }}</ui-btn>
@@ -47,52 +49,13 @@ export default {
   props: {
     value: Boolean,
     timerSet: Boolean,
-    timerTime: Number,
-    remaining: Number
+    timerType: String,
+    remaining: Number,
+    hasChapters: Boolean
   },
   data() {
     return {
-      customTime: null,
-      sleepTimes: [
-        {
-          seconds: 60 * 5,
-          text: '5 minutes'
-        },
-        {
-          seconds: 60 * 15,
-          text: '15 minutes'
-        },
-        {
-          seconds: 60 * 20,
-          text: '20 minutes'
-        },
-        {
-          seconds: 60 * 30,
-          text: '30 minutes'
-        },
-        {
-          seconds: 60 * 45,
-          text: '45 minutes'
-        },
-        {
-          seconds: 60 * 60,
-          text: '60 minutes'
-        },
-        {
-          seconds: 60 * 90,
-          text: '90 minutes'
-        },
-        {
-          seconds: 60 * 120,
-          text: '2 hours'
-        }
-      ]
-    }
-  },
-  watch: {
-    show(newVal) {
-      if (newVal) {
-      }
+      customTime: null
     }
   },
   computed: {
@@ -103,6 +66,54 @@ export default {
       set(val) {
         this.$emit('input', val)
       }
+    },
+    sleepTimes() {
+      const times = [
+        {
+          seconds: 60 * 5,
+          text: this.$getString('LabelTimeDurationXMinutes', ['5']),
+          timerType: this.$constants.SleepTimerTypes.COUNTDOWN
+        },
+        {
+          seconds: 60 * 15,
+          text: this.$getString('LabelTimeDurationXMinutes', ['15']),
+          timerType: this.$constants.SleepTimerTypes.COUNTDOWN
+        },
+        {
+          seconds: 60 * 20,
+          text: this.$getString('LabelTimeDurationXMinutes', ['20']),
+          timerType: this.$constants.SleepTimerTypes.COUNTDOWN
+        },
+        {
+          seconds: 60 * 30,
+          text: this.$getString('LabelTimeDurationXMinutes', ['30']),
+          timerType: this.$constants.SleepTimerTypes.COUNTDOWN
+        },
+        {
+          seconds: 60 * 45,
+          text: this.$getString('LabelTimeDurationXMinutes', ['45']),
+          timerType: this.$constants.SleepTimerTypes.COUNTDOWN
+        },
+        {
+          seconds: 60 * 60,
+          text: this.$getString('LabelTimeDurationXMinutes', ['60']),
+          timerType: this.$constants.SleepTimerTypes.COUNTDOWN
+        },
+        {
+          seconds: 60 * 90,
+          text: this.$getString('LabelTimeDurationXMinutes', ['90']),
+          timerType: this.$constants.SleepTimerTypes.COUNTDOWN
+        },
+        {
+          seconds: 60 * 120,
+          text: this.$getString('LabelTimeDurationXHours', ['2']),
+          timerType: this.$constants.SleepTimerTypes.COUNTDOWN
+        }
+      ]
+      if (this.hasChapters) {
+        times.push({ seconds: -1, text: this.$strings.LabelEndOfChapter, timerType: this.$constants.SleepTimerTypes.CHAPTER })
+      }
+      return times
     }
   },
   methods: {
@@ -113,10 +124,14 @@ export default {
       }
 
       const timeInSeconds = Math.round(Number(this.customTime) * 60)
-      this.setTime(timeInSeconds)
+      const time = {
+        seconds: timeInSeconds,
+        timerType: this.$constants.SleepTimerTypes.COUNTDOWN
+      }
+      this.setTime(time)
     },
-    setTime(seconds) {
-      this.$emit('set', seconds)
+    setTime(time) {
+      this.$emit('set', time)
     },
     increment(amount) {
       this.$emit('increment', amount)
@@ -130,4 +145,4 @@ export default {
     }
   }
 }
-</script>
\ No newline at end of file
+</script>
diff --git a/client/components/modals/podcast/OpmlFeedsModal.vue b/client/components/modals/podcast/OpmlFeedsModal.vue
index 7d7327d2..41a75225 100644
--- a/client/components/modals/podcast/OpmlFeedsModal.vue
+++ b/client/components/modals/podcast/OpmlFeedsModal.vue
@@ -16,11 +16,18 @@
           </div>
         </div>
 
-        <p class="text-lg font-semibold mb-2">{{ $strings.HeaderPodcastsToAdd }}</p>
+        <p class="text-lg font-semibold mb-1">{{ $strings.HeaderPodcastsToAdd }}</p>
+        <p class="text-sm text-gray-300 mb-4">{{ $strings.MessageOpmlPreviewNote }}</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 v-for="(feed, index) in feeds">
+            <div :key="index" class="py-1 flex items-center">
+              <p class="text-lg font-semibold">{{ index + 1 }}.</p>
+              <div class="pl-2">
+                <p v-if="feed.title" class="text-sm font-semibold">{{ feed.title }}</p>
+                <p class="text-xs text-gray-400">{{ feed.feedUrl }}</p>
+              </div>
+            </div>
           </template>
         </div>
       </div>
@@ -45,9 +52,7 @@ export default {
     return {
       processing: false,
       selectedFolderId: null,
-      fullPath: null,
-      autoDownloadEpisodes: false,
-      feedMetadata: []
+      autoDownloadEpisodes: false
     }
   },
   watch: {
@@ -96,73 +101,36 @@ export default {
     }
   },
   methods: {
-    toFeedMetadata(feed) {
-      const 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
-      const 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}: ${this.$strings.ToastPodcastCreateSuccess}`)
-          })
-          .catch((error) => {
-            var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastPodcastCreateFailed
-            console.error('Failed to create podcast', podcastPayload, error)
-            this.$toast.error(`${podcastPayload.media.metadata.title}: ${errorMsg}`)
-          })
+      const payload = {
+        feeds: this.feeds.map((f) => f.feedUrl),
+        folderId: this.selectedFolderId,
+        libraryId: this.currentLibrary.id,
+        autoDownloadEpisodes: this.autoDownloadEpisodes
       }
-      this.processing = false
-      this.show = false
+      this.$axios
+        .$post('/api/podcasts/opml/create', payload)
+        .then(() => {
+          this.show = false
+        })
+        .catch((error) => {
+          const errorMsg = error.response?.data || this.$strings.ToastPodcastCreateFailed
+          console.error('Failed to create podcast', payload, error)
+          this.$toast.error(errorMsg)
+        })
+        .finally(() => {
+          this.processing = false
+        })
     }
   },
   mounted() {}
 }
 </script>
 
-<style scoped>
-#podcast-wrapper {
-  min-height: 400px;
-  max-height: 80vh;
-}
-#episodes-scroll {
-  max-height: calc(80vh - 200px);
-}
-</style>
\ No newline at end of file
diff --git a/client/components/modals/podcast/tabs/EpisodeMatch.vue b/client/components/modals/podcast/tabs/EpisodeMatch.vue
index 640ec547..de58bdf9 100644
--- a/client/components/modals/podcast/tabs/EpisodeMatch.vue
+++ b/client/components/modals/podcast/tabs/EpisodeMatch.vue
@@ -132,7 +132,7 @@ export default {
       this.searchedTitle = this.episodeTitle
       this.isProcessing = true
       this.$axios
-        .$get(`/api/podcasts/${this.libraryItem.id}/search-episode?title=${this.$encodeUriPath(this.episodeTitle)}`)
+        .$get(`/api/podcasts/${this.libraryItem.id}/search-episode?title=${encodeURIComponent(this.episodeTitle)}`)
         .then((results) => {
           this.episodesFound = results.episodes.map((ep) => ep.episode)
           console.log('Episodes found', this.episodesFound)
@@ -153,4 +153,4 @@ export default {
   },
   mounted() {}
 }
-</script>
\ No newline at end of file
+</script>
diff --git a/client/components/player/PlayerPlaybackControls.vue b/client/components/player/PlayerPlaybackControls.vue
index 6a70b28c..1a92480b 100644
--- a/client/components/player/PlayerPlaybackControls.vue
+++ b/client/components/player/PlayerPlaybackControls.vue
@@ -7,17 +7,17 @@
           <span class="material-symbols text-2xl sm:text-3xl">first_page</span>
         </button>
       </ui-tooltip>
-      <ui-tooltip direction="top" :text="$strings.ButtonJumpBackward">
-        <button :aria-label="$strings.ButtonJumpBackward" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
-          <span class="material-symbols text-2xl sm:text-3xl">replay_10</span>
+      <ui-tooltip direction="top" :text="jumpBackwardText">
+        <button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
+          <span class="material-symbols text-2xl sm:text-3xl">replay</span>
         </button>
       </ui-tooltip>
       <button :aria-label="paused ? $strings.ButtonPlay : $strings.ButtonPause" class="p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 lg:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
         <span class="material-symbols fill text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
       </button>
-      <ui-tooltip direction="top" :text="$strings.ButtonJumpForward">
-        <button :aria-label="$strings.ButtonJumpForward" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
-          <span class="material-symbols text-2xl sm:text-3xl">forward_10</span>
+      <ui-tooltip direction="top" :text="jumpForwardText">
+        <button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
+          <span class="material-symbols text-2xl sm:text-3xl">forward_media</span>
         </button>
       </ui-tooltip>
       <ui-tooltip direction="top" :text="$strings.ButtonNextChapter" class="ml-4 lg:ml-8">
@@ -29,7 +29,7 @@
     </template>
     <template v-else>
       <div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
-        <span class="material-symbols">autorenew</span>
+        <span class="material-symbols text-2xl">autorenew</span>
       </div>
     </template>
     <div class="flex-grow" />
@@ -56,6 +56,12 @@ export default {
       set(val) {
         this.$emit('update:playbackRate', val)
       }
+    },
+    jumpForwardText() {
+      return this.getJumpText('jumpForwardAmount', this.$strings.ButtonJumpForward)
+    },
+    jumpBackwardText() {
+      return this.getJumpText('jumpBackwardAmount', this.$strings.ButtonJumpBackward)
     }
   },
   methods: {
@@ -83,8 +89,22 @@ export default {
       this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
         console.error('Failed to update settings', err)
       })
+    },
+    getJumpText(setting, prefix) {
+      const amount = this.$store.getters['user/getUserSetting'](setting)
+      if (!amount) return prefix
+
+      let formattedTime = ''
+      if (amount <= 60) {
+        formattedTime = this.$getString('LabelTimeDurationXSeconds', [amount])
+      } else {
+        const minutes = Math.floor(amount / 60)
+        formattedTime = this.$getString('LabelTimeDurationXMinutes', [minutes])
+      }
+
+      return `${prefix} - ${formattedTime}`
     }
   },
   mounted() {}
 }
-</script>
\ No newline at end of file
+</script>
diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue
index 3093975f..68452061 100644
--- a/client/components/player/PlayerUi.vue
+++ b/client/components/player/PlayerUi.vue
@@ -13,7 +13,7 @@
             <span v-if="!sleepTimerSet" class="material-symbols text-2xl">snooze</span>
             <div v-else class="flex items-center">
               <span class="material-symbols text-lg text-warning">snooze</span>
-              <p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
+              <p class="text-sm sm:text-lg text-warning font-mono font-semibold text-center px-0.5 sm:pb-0.5 sm:min-w-8">{{ sleepTimerRemainingString }}</p>
             </div>
           </button>
         </ui-tooltip>
@@ -36,9 +36,9 @@
           </button>
         </ui-tooltip>
 
-        <ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack">
-          <button :aria-label="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack" class="text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
-            <span class="material-symbols text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
+        <ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
+          <button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerSettings')">
+            <span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
           </button>
         </ui-tooltip>
       </div>
@@ -72,12 +72,14 @@ export default {
       type: Array,
       default: () => []
     },
+    currentChapter: Object,
     bookmarks: {
       type: Array,
       default: () => []
     },
     sleepTimerSet: Boolean,
     sleepTimerRemaining: Number,
+    sleepTimerType: String,
     isPodcast: Boolean,
     hideBookmarks: Boolean,
     hideSleepTimer: Boolean
@@ -90,27 +92,34 @@ export default {
       seekLoading: false,
       showChaptersModal: false,
       currentTime: 0,
-      duration: 0,
-      useChapterTrack: false
+      duration: 0
     }
   },
   watch: {
     playbackRate() {
       this.updateTimestamp()
+    },
+    useChapterTrack() {
+      if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
+      this.updateTimestamp()
     }
   },
   computed: {
     sleepTimerRemainingString() {
-      var rounded = Math.round(this.sleepTimerRemaining)
-      if (rounded < 90) {
-        return `${rounded}s`
+      if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER) {
+        return 'EoC'
+      } else {
+        var rounded = Math.round(this.sleepTimerRemaining)
+        if (rounded < 90) {
+          return `${rounded}s`
+        }
+        var minutesRounded = Math.round(rounded / 60)
+        if (minutesRounded <= 90) {
+          return `${minutesRounded}m`
+        }
+        var hoursRounded = Math.round(minutesRounded / 60)
+        return `${hoursRounded}h`
       }
-      var minutesRounded = Math.round(rounded / 60)
-      if (minutesRounded < 90) {
-        return `${minutesRounded}m`
-      }
-      var hoursRounded = Math.round(minutesRounded / 60)
-      return `${hoursRounded}h`
     },
     token() {
       return this.$store.getters['user/getToken']
@@ -135,9 +144,6 @@ export default {
       if (!duration) return 0
       return Math.round((100 * time) / duration)
     },
-    currentChapter() {
-      return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
-    },
     currentChapterName() {
       return this.currentChapter ? this.currentChapter.title : ''
     },
@@ -162,6 +168,10 @@ export default {
     },
     playerQueueItems() {
       return this.$store.state.playerQueueItems || []
+    },
+    useChapterTrack() {
+      const _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
+      return this.chapters.length ? _useChapterTrack : false
     }
   },
   methods: {
@@ -310,9 +320,6 @@ export default {
     init() {
       this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
 
-      const _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
-      this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
-
       if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
       this.setPlaybackRate(this.playbackRate)
     },
diff --git a/client/components/ui/ContextMenuDropdown.vue b/client/components/ui/ContextMenuDropdown.vue
index 172c4999..e6e4e6e5 100644
--- a/client/components/ui/ContextMenuDropdown.vue
+++ b/client/components/ui/ContextMenuDropdown.vue
@@ -2,7 +2,7 @@
   <div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
     <slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
       <button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
-        <span class="material-symbols" :class="iconClass">more_vert</span>
+        <span class="material-symbols text-2xl" :class="iconClass">more_vert</span>
       </button>
       <div v-else class="h-full w-full flex items-center justify-center">
         <widgets-loading-spinner />
@@ -116,4 +116,4 @@ export default {
   },
   mounted() {}
 }
-</script>
\ No newline at end of file
+</script>
diff --git a/client/components/ui/SelectInput.vue b/client/components/ui/SelectInput.vue
new file mode 100644
index 00000000..e7c302d5
--- /dev/null
+++ b/client/components/ui/SelectInput.vue
@@ -0,0 +1,151 @@
+<template>
+  <div class="relative w-full">
+    <p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
+    <button ref="buttonWrapper" type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
+      <span class="flex items-center">
+        <span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
+        <span v-if="selectedSubtext">:&nbsp;</span>
+        <span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
+      </span>
+      <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
+        <span class="material-symbols text-2xl">expand_more</span>
+      </span>
+    </button>
+
+    <transition name="menu">
+      <ul ref="menu" v-show="showMenu" class="absolute z-60 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox" :style="{ maxHeight: menuMaxHeight }" v-click-outside="clickOutsideObj">
+        <template v-for="item in itemsToShow">
+          <li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click.stop.prevent="clickedOption(item.value)">
+            <div class="flex items-center">
+              <span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
+              <span v-if="item.subtext">:&nbsp;</span>
+              <span v-if="item.subtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ item.subtext }}</span>
+            </div>
+          </li>
+        </template>
+      </ul>
+    </transition>
+  </div>
+</template>
+
+<script>
+export default {
+  props: {
+    value: [String, Number],
+    label: {
+      type: String,
+      default: ''
+    },
+    items: {
+      type: Array,
+      default: () => []
+    },
+    disabled: Boolean,
+    small: Boolean,
+    menuMaxHeight: {
+      type: String,
+      default: '224px'
+    }
+  },
+  data() {
+    return {
+      clickOutsideObj: {
+        handler: this.clickedOutside,
+        events: ['click'],
+        isActive: true
+      },
+      menu: null,
+      showMenu: false
+    }
+  },
+  computed: {
+    selected: {
+      get() {
+        return this.value
+      },
+      set(val) {
+        this.$emit('input', val)
+      }
+    },
+    itemsToShow() {
+      return this.items.map((i) => {
+        if (typeof i === 'string' || typeof i === 'number') {
+          return {
+            text: i,
+            value: i
+          }
+        }
+        return i
+      })
+    },
+    selectedItem() {
+      return this.itemsToShow.find((i) => i.value === this.selected)
+    },
+    selectedText() {
+      return this.selectedItem ? this.selectedItem.text : ''
+    },
+    selectedSubtext() {
+      return this.selectedItem ? this.selectedItem.subtext : ''
+    },
+    buttonClass() {
+      var classes = []
+      if (this.small) classes.push('h-9')
+      else classes.push('h-10')
+
+      if (this.disabled) classes.push('cursor-not-allowed border-gray-600 bg-primary bg-opacity-70 border-opacity-70 text-gray-400')
+      else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')
+
+      return classes.join(' ')
+    },
+    longLabel() {
+      let result = ''
+      if (this.label) result += this.label + ': '
+      if (this.selectedText) result += this.selectedText
+      if (this.selectedSubtext) result += ' ' + this.selectedSubtext
+      return result
+    }
+  },
+  methods: {
+    recalcMenuPos() {
+      if (!this.menu || !this.$refs.buttonWrapper) return
+      const boundingBox = this.$refs.buttonWrapper.getBoundingClientRect()
+      this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
+      this.menu.style.left = boundingBox.x + 'px'
+      this.menu.style.width = boundingBox.width + 'px'
+    },
+    unmountMountMenu() {
+      if (!this.$refs.menu || !this.$refs.buttonWrapper) return
+      this.menu = this.$refs.menu
+      this.menu.remove()
+    },
+    clickShowMenu() {
+      if (this.disabled) return
+      if (!this.showMenu) this.handleShowMenu()
+      else this.handleCloseMenu()
+    },
+    handleShowMenu() {
+      if (!this.menu) {
+        this.unmountMountMenu()
+      }
+      document.body.appendChild(this.menu)
+      this.recalcMenuPos()
+      this.showMenu = true
+    },
+    handleCloseMenu() {
+      this.showMenu = false
+      if (this.menu) this.menu.remove()
+    },
+    clickedOutside() {
+      this.handleCloseMenu()
+    },
+    clickedOption(itemValue) {
+      this.selected = itemValue
+      this.handleCloseMenu()
+    }
+  },
+  mounted() {},
+  beforeDestroy() {
+    if (this.menu) this.menu.remove()
+  }
+}
+</script>
diff --git a/client/pages/config.vue b/client/pages/config.vue
index 957cef52..4492bbfd 100644
--- a/client/pages/config.vue
+++ b/client/pages/config.vue
@@ -52,7 +52,6 @@ export default {
         else if (pageName === 'notifications') return this.$strings.HeaderNotifications
         else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions
         else if (pageName === 'stats') return this.$strings.HeaderYourStats
-        else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
         else if (pageName === 'users') return this.$strings.HeaderUsers
         else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
         else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
@@ -94,4 +93,4 @@ export default {
     max-width: 100%;
   }
 }
-</style>
\ No newline at end of file
+</style>
diff --git a/client/pages/config/backups.vue b/client/pages/config/backups.vue
index f7845119..44a92f2e 100644
--- a/client/pages/config/backups.vue
+++ b/client/pages/config/backups.vue
@@ -170,7 +170,7 @@ export default {
         })
     },
     updateBackupsSettings() {
-      if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) {
+      if (isNaN(this.maxBackupSize) || this.maxBackupSize < 0) {
         this.$toast.error('Invalid maximum backup size')
         return
       }
@@ -200,10 +200,9 @@ export default {
     },
     initServerSettings() {
       this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
-
       this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
       this.enableBackups = !!this.newServerSettings.backupSchedule
-      this.maxBackupSize = this.newServerSettings.maxBackupSize || 1
+      this.maxBackupSize = this.newServerSettings.maxBackupSize === 0 ? 0 : this.newServerSettings.maxBackupSize || 1
       this.cronExpression = this.newServerSettings.backupSchedule || '30 1 * * *'
     }
   },
diff --git a/client/pages/config/library-stats.vue b/client/pages/config/library-stats.vue
deleted file mode 100644
index 1a95c630..00000000
--- a/client/pages/config/library-stats.vue
+++ /dev/null
@@ -1,175 +0,0 @@
-<template>
-  <div>
-    <app-settings-content :header-text="$strings.HeaderLibraryStats + ': ' + currentLibraryName">
-      <stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
-
-      <div class="flex lg:flex-row flex-wrap justify-between flex-col mt-8">
-        <div class="w-80 my-6 mx-auto">
-          <h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop5Genres }}</h1>
-          <p v-if="!top5Genres.length">{{ $strings.MessageNoGenres }}</p>
-          <template v-for="genre in top5Genres">
-            <div :key="genre.genre" class="w-full py-2">
-              <div class="flex items-end mb-1">
-                <p class="text-2xl font-bold">{{ Math.round((100 * genre.count) / totalItems) }}&nbsp;%</p>
-                <div class="flex-grow" />
-                <nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`" class="text-base text-white text-opacity-70 hover:underline">
-                  {{ genre.genre }}
-                </nuxt-link>
-              </div>
-              <div class="w-full rounded-full h-3 bg-primary bg-opacity-50 overflow-hidden">
-                <div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * genre.count) / totalItems) + '%' }" />
-              </div>
-            </div>
-          </template>
-        </div>
-        <div v-if="isBookLibrary" class="w-80 my-6 mx-auto">
-          <h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop10Authors }}</h1>
-          <p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
-          <template v-for="(author, index) in top10Authors">
-            <div :key="author.id" class="w-full py-2">
-              <div class="flex items-center mb-1">
-                <p class="text-sm text-white text-opacity-70 w-36 pr-2 truncate">
-                  {{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}</nuxt-link>
-                </p>
-                <div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
-                  <div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * author.count) / mostUsedAuthorCount) + '%' }" />
-                </div>
-                <div class="w-4 ml-3">
-                  <p class="text-sm font-bold">{{ author.count }}</p>
-                </div>
-              </div>
-            </div>
-          </template>
-        </div>
-        <div class="w-80 my-6 mx-auto">
-          <h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLongestItems }}</h1>
-          <p v-if="!top10LongestItems.length">{{ $strings.MessageNoItems }}</p>
-          <template v-for="(ab, index) in top10LongestItems">
-            <div :key="index" class="w-full py-2">
-              <div class="flex items-center mb-1">
-                <p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
-                  {{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
-                </p>
-                <div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
-                  <div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.duration) / longestItemDuration) + '%' }" />
-                </div>
-                <div class="w-4 ml-3">
-                  <p class="text-sm font-bold">{{ (ab.duration / 3600).toFixed(1) }}</p>
-                </div>
-              </div>
-            </div>
-          </template>
-        </div>
-        <div class="w-80 my-6 mx-auto">
-          <h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLargestItems }}</h1>
-          <p v-if="!top10LargestItems.length">{{ $strings.MessageNoItems }}</p>
-          <template v-for="(ab, index) in top10LargestItems">
-            <div :key="index" class="w-full py-2">
-              <div class="flex items-center mb-1">
-                <p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
-                  {{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
-                </p>
-                <div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
-                  <div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.size) / largestItemSize) + '%' }" />
-                </div>
-                <div class="w-4 ml-3">
-                  <p class="text-sm font-bold">{{ $bytesPretty(ab.size) }}</p>
-                </div>
-              </div>
-            </div>
-          </template>
-        </div>
-      </div>
-    </app-settings-content>
-  </div>
-</template>
-
-<script>
-export default {
-  asyncData({ redirect, store }) {
-    if (!store.getters['user/getIsAdminOrUp']) {
-      redirect('/')
-      return
-    }
-
-    if (!store.state.libraries.currentLibraryId) {
-      return redirect('/config')
-    }
-    return {}
-  },
-  data() {
-    return {
-      libraryStats: null
-    }
-  },
-  watch: {
-    currentLibraryId(newVal, oldVal) {
-      if (newVal) {
-        this.init()
-      }
-    }
-  },
-  computed: {
-    user() {
-      return this.$store.state.user.user
-    },
-    totalItems() {
-      return this.libraryStats?.totalItems || 0
-    },
-    genresWithCount() {
-      return this.libraryStats?.genresWithCount || []
-    },
-    top5Genres() {
-      return this.genresWithCount?.slice(0, 5) || []
-    },
-    top10LongestItems() {
-      return this.libraryStats?.longestItems || []
-    },
-    longestItemDuration() {
-      if (!this.top10LongestItems.length) return 0
-      return this.top10LongestItems[0].duration
-    },
-    top10LargestItems() {
-      return this.libraryStats?.largestItems || []
-    },
-    largestItemSize() {
-      if (!this.top10LargestItems.length) return 0
-      return this.top10LargestItems[0].size
-    },
-    authorsWithCount() {
-      return this.libraryStats?.authorsWithCount || []
-    },
-    mostUsedAuthorCount() {
-      if (!this.authorsWithCount.length) return 0
-      return this.authorsWithCount[0].count
-    },
-    top10Authors() {
-      return this.authorsWithCount?.slice(0, 10) || []
-    },
-    currentLibraryId() {
-      return this.$store.state.libraries.currentLibraryId
-    },
-    currentLibraryName() {
-      return this.$store.getters['libraries/getCurrentLibraryName']
-    },
-    currentLibraryMediaType() {
-      return this.$store.getters['libraries/getCurrentLibraryMediaType']
-    },
-    isBookLibrary() {
-      return this.currentLibraryMediaType === 'book'
-    }
-  },
-  methods: {
-    async init() {
-      this.libraryStats = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/stats`).catch((err) => {
-        console.error('Failed to get library stats', err)
-        var errorMsg = err.response ? err.response.data || 'Unknown Error' : 'Unknown Error'
-        this.$toast.error(`Failed to get library stats: ${errorMsg}`)
-      })
-    }
-  },
-  mounted() {
-    this.init()
-  }
-}
-</script>
diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue
index b6490126..35b1f518 100644
--- a/client/pages/item/_id/index.vue
+++ b/client/pages/item/_id/index.vue
@@ -121,7 +121,7 @@
             <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
               <template #default="{ showMenu, clickShowMenu, disabled }">
                 <button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
-                  <span class="material-symbols">more_horiz</span>
+                  <span class="material-symbols text-2xl">more_horiz</span>
                 </button>
               </template>
             </ui-context-menu-dropdown>
diff --git a/client/pages/library/_library/podcast/search.vue b/client/pages/library/_library/podcast/search.vue
index 841927c6..c7808979 100644
--- a/client/pages/library/_library/podcast/search.vue
+++ b/client/pages/library/_library/podcast/search.vue
@@ -113,18 +113,23 @@ export default {
         return
       }
 
-      await this.$axios
-        .$post(`/api/podcasts/opml`, { opmlText: txt })
+      this.$axios
+        .$post(`/api/podcasts/opml/parse`, { opmlText: txt })
         .then((data) => {
-          console.log(data)
-          this.opmlFeeds = data.feeds || []
-          this.showOPMLFeedsModal = true
+          if (!data.feeds?.length) {
+            this.$toast.error('No feeds found in OPML file')
+          } else {
+            this.opmlFeeds = data.feeds || []
+            this.showOPMLFeedsModal = true
+          }
         })
         .catch((error) => {
           console.error('Failed', error)
           this.$toast.error('Failed to parse OPML file')
         })
-      this.processing = false
+        .finally(() => {
+          this.processing = false
+        })
     },
     submit() {
       if (!this.searchInput) return
diff --git a/client/pages/library/_library/stats.vue b/client/pages/library/_library/stats.vue
new file mode 100644
index 00000000..7cd97248
--- /dev/null
+++ b/client/pages/library/_library/stats.vue
@@ -0,0 +1,181 @@
+<template>
+  <div class="page relative" :class="streamLibraryItem ? 'streaming' : ''">
+    <app-book-shelf-toolbar page="library-stats" is-home />
+    <div id="bookshelf" class="w-full h-full px-1 py-4 md:p-8 relative overflow-y-auto">
+      <div class="w-full max-w-4xl mx-auto">
+        <stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
+
+        <div class="flex lg:flex-row flex-wrap justify-between flex-col mt-8">
+          <div class="w-80 my-6 mx-auto">
+            <h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop5Genres }}</h1>
+            <p v-if="!top5Genres.length">{{ $strings.MessageNoGenres }}</p>
+            <template v-for="genre in top5Genres">
+              <div :key="genre.genre" class="w-full py-2">
+                <div class="flex items-end mb-1">
+                  <p class="text-2xl font-bold">{{ Math.round((100 * genre.count) / totalItems) }}&nbsp;%</p>
+                  <div class="flex-grow" />
+                  <nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`" class="text-base text-white text-opacity-70 hover:underline">
+                    {{ genre.genre }}
+                  </nuxt-link>
+                </div>
+                <div class="w-full rounded-full h-3 bg-primary bg-opacity-50 overflow-hidden">
+                  <div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * genre.count) / totalItems) + '%' }" />
+                </div>
+              </div>
+            </template>
+          </div>
+          <div v-if="isBookLibrary" class="w-80 my-6 mx-auto">
+            <h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop10Authors }}</h1>
+            <p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
+            <template v-for="(author, index) in top10Authors">
+              <div :key="author.id" class="w-full py-2">
+                <div class="flex items-center mb-1">
+                  <p class="text-sm text-white text-opacity-70 w-36 pr-2 truncate">
+                    {{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}</nuxt-link>
+                  </p>
+                  <div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
+                    <div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * author.count) / mostUsedAuthorCount) + '%' }" />
+                  </div>
+                  <div class="w-4 ml-3">
+                    <p class="text-sm font-bold">{{ author.count }}</p>
+                  </div>
+                </div>
+              </div>
+            </template>
+          </div>
+          <div class="w-80 my-6 mx-auto">
+            <h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLongestItems }}</h1>
+            <p v-if="!top10LongestItems.length">{{ $strings.MessageNoItems }}</p>
+            <template v-for="(ab, index) in top10LongestItems">
+              <div :key="index" class="w-full py-2">
+                <div class="flex items-center mb-1">
+                  <p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
+                    {{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
+                  </p>
+                  <div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
+                    <div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.duration) / longestItemDuration) + '%' }" />
+                  </div>
+                  <div class="w-4 ml-3">
+                    <p class="text-sm font-bold">{{ (ab.duration / 3600).toFixed(1) }}</p>
+                  </div>
+                </div>
+              </div>
+            </template>
+          </div>
+          <div class="w-80 my-6 mx-auto">
+            <h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLargestItems }}</h1>
+            <p v-if="!top10LargestItems.length">{{ $strings.MessageNoItems }}</p>
+            <template v-for="(ab, index) in top10LargestItems">
+              <div :key="index" class="w-full py-2">
+                <div class="flex items-center mb-1">
+                  <p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
+                    {{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
+                  </p>
+                  <div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
+                    <div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.size) / largestItemSize) + '%' }" />
+                  </div>
+                  <div class="w-4 ml-3">
+                    <p class="text-sm font-bold">{{ $bytesPretty(ab.size) }}</p>
+                  </div>
+                </div>
+              </div>
+            </template>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  asyncData({ redirect, store }) {
+    if (!store.getters['user/getIsAdminOrUp']) {
+      redirect('/')
+      return
+    }
+
+    if (!store.state.libraries.currentLibraryId) {
+      return redirect('/config')
+    }
+    return {}
+  },
+  data() {
+    return {
+      libraryStats: null
+    }
+  },
+  watch: {
+    currentLibraryId(newVal, oldVal) {
+      if (newVal) {
+        this.init()
+      }
+    }
+  },
+  computed: {
+    streamLibraryItem() {
+      return this.$store.state.streamLibraryItem
+    },
+    user() {
+      return this.$store.state.user.user
+    },
+    totalItems() {
+      return this.libraryStats?.totalItems || 0
+    },
+    genresWithCount() {
+      return this.libraryStats?.genresWithCount || []
+    },
+    top5Genres() {
+      return this.genresWithCount?.slice(0, 5) || []
+    },
+    top10LongestItems() {
+      return this.libraryStats?.longestItems || []
+    },
+    longestItemDuration() {
+      if (!this.top10LongestItems.length) return 0
+      return this.top10LongestItems[0].duration
+    },
+    top10LargestItems() {
+      return this.libraryStats?.largestItems || []
+    },
+    largestItemSize() {
+      if (!this.top10LargestItems.length) return 0
+      return this.top10LargestItems[0].size
+    },
+    authorsWithCount() {
+      return this.libraryStats?.authorsWithCount || []
+    },
+    mostUsedAuthorCount() {
+      if (!this.authorsWithCount.length) return 0
+      return this.authorsWithCount[0].count
+    },
+    top10Authors() {
+      return this.authorsWithCount?.slice(0, 10) || []
+    },
+    currentLibraryId() {
+      return this.$store.state.libraries.currentLibraryId
+    },
+    currentLibraryName() {
+      return this.$store.getters['libraries/getCurrentLibraryName']
+    },
+    currentLibraryMediaType() {
+      return this.$store.getters['libraries/getCurrentLibraryMediaType']
+    },
+    isBookLibrary() {
+      return this.currentLibraryMediaType === 'book'
+    }
+  },
+  methods: {
+    async init() {
+      this.libraryStats = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/stats`).catch((err) => {
+        console.error('Failed to get library stats', err)
+        var errorMsg = err.response ? err.response.data || 'Unknown Error' : 'Unknown Error'
+        this.$toast.error(`Failed to get library stats: ${errorMsg}`)
+      })
+    }
+  },
+  mounted() {
+    this.init()
+  }
+}
+</script>
diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js
index 660ca2c1..42d76bd0 100644
--- a/client/players/PlayerHandler.js
+++ b/client/players/PlayerHandler.js
@@ -36,10 +36,10 @@ export default class PlayerHandler {
     return this.libraryItem ? this.libraryItem.id : null
   }
   get isPlayingCastedItem() {
-    return this.libraryItem && (this.player instanceof CastPlayer)
+    return this.libraryItem && this.player instanceof CastPlayer
   }
   get isPlayingLocalItem() {
-    return this.libraryItem && (this.player instanceof LocalAudioPlayer)
+    return this.libraryItem && this.player instanceof LocalAudioPlayer
   }
   get userToken() {
     return this.ctx.$store.getters['user/getToken']
@@ -49,7 +49,13 @@ export default class PlayerHandler {
   }
   get episode() {
     if (!this.episodeId) return null
-    return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
+    return this.libraryItem.media.episodes.find((ep) => ep.id === this.episodeId)
+  }
+  get jumpForwardAmount() {
+    return this.ctx.$store.getters['user/getUserSetting']('jumpForwardAmount')
+  }
+  get jumpBackwardAmount() {
+    return this.ctx.$store.getters['user/getUserSetting']('jumpBackwardAmount')
   }
 
   setSessionId(sessionId) {
@@ -66,7 +72,7 @@ export default class PlayerHandler {
     this.playWhenReady = playWhenReady
     this.initialPlaybackRate = this.isMusic ? 1 : playbackRate
 
-    this.startTimeOverride = (startTimeOverride == null || isNaN(startTimeOverride)) ? undefined : Number(startTimeOverride)
+    this.startTimeOverride = startTimeOverride == null || isNaN(startTimeOverride) ? undefined : Number(startTimeOverride)
 
     if (!this.player) this.switchPlayer(playWhenReady)
     else this.prepare()
@@ -127,7 +133,7 @@ export default class PlayerHandler {
 
   playerError() {
     // Switch to HLS stream on error
-    if (!this.isCasting && (this.player instanceof LocalAudioPlayer)) {
+    if (!this.isCasting && this.player instanceof LocalAudioPlayer) {
       console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
       this.prepare(true)
     }
@@ -207,7 +213,8 @@ export default class PlayerHandler {
     this.prepareSession(session)
   }
 
-  prepareOpenSession(session, playbackRate) { // Session opened on init socket
+  prepareOpenSession(session, playbackRate) {
+    // Session opened on init socket
     if (!this.player) this.switchPlayer() // Must set player first for open sessions
 
     this.libraryItem = session.libraryItem
@@ -241,7 +248,7 @@ export default class PlayerHandler {
 
       this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
     } else {
-      var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
+      var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken))
 
       this.ctx.playerLoading = true
       this.isHlsTranscode = true
@@ -295,7 +302,7 @@ export default class PlayerHandler {
       const currentTime = this.player.getCurrentTime()
       this.ctx.setCurrentTime(currentTime)
 
-      const exactTimeElapsed = ((Date.now() - lastTick) / 1000)
+      const exactTimeElapsed = (Date.now() - lastTick) / 1000
       lastTick = Date.now()
       this.listeningTimeSinceSync += exactTimeElapsed
       const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 10 : 20
@@ -320,7 +327,7 @@ export default class PlayerHandler {
     }
     this.listeningTimeSinceSync = 0
     this.lastSyncTime = 0
-    return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 6000 }).catch((error) => {
+    return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 6000, progress: false }).catch((error) => {
       console.error('Failed to close session', error)
     })
   }
@@ -340,17 +347,20 @@ export default class PlayerHandler {
     }
 
     this.listeningTimeSinceSync = 0
-    this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000 }).then(() => {
-      this.failedProgressSyncs = 0
-    }).catch((error) => {
-      console.error('Failed to update session progress', error)
-      // After 4 failed sync attempts show an alert toast
-      this.failedProgressSyncs++
-      if (this.failedProgressSyncs >= 4) {
-        this.ctx.showFailedProgressSyncs()
+    this.ctx.$axios
+      .$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000, progress: false })
+      .then(() => {
         this.failedProgressSyncs = 0
-      }
-    })
+      })
+      .catch((error) => {
+        console.error('Failed to update session progress', error)
+        // After 4 failed sync attempts show an alert toast
+        this.failedProgressSyncs++
+        if (this.failedProgressSyncs >= 4) {
+          this.ctx.showFailedProgressSyncs()
+          this.failedProgressSyncs = 0
+        }
+      })
   }
 
   stopPlayInterval() {
@@ -381,13 +391,15 @@ export default class PlayerHandler {
   jumpBackward() {
     if (!this.player) return
     var currentTime = this.getCurrentTime()
-    this.seek(Math.max(0, currentTime - 10))
+    const jumpAmount = this.jumpBackwardAmount
+    this.seek(Math.max(0, currentTime - jumpAmount))
   }
 
   jumpForward() {
     if (!this.player) return
     var currentTime = this.getCurrentTime()
-    this.seek(Math.min(currentTime + 10, this.getDuration()))
+    const jumpAmount = this.jumpForwardAmount
+    this.seek(Math.min(currentTime + jumpAmount, this.getDuration()))
   }
 
   setVolume(volume) {
@@ -411,4 +423,4 @@ export default class PlayerHandler {
       this.sendProgressSync(time)
     }
   }
-}
\ No newline at end of file
+}
diff --git a/client/plugins/constants.js b/client/plugins/constants.js
index f001f6ce..d89fbbbd 100644
--- a/client/plugins/constants.js
+++ b/client/plugins/constants.js
@@ -32,12 +32,18 @@ const PlayMethod = {
   LOCAL: 3
 }
 
+const SleepTimerTypes = {
+  COUNTDOWN: 'countdown',
+  CHAPTER: 'chapter'
+}
+
 const Constants = {
   SupportedFileTypes,
   DownloadStatus,
   BookCoverAspectRatio,
   BookshelfView,
-  PlayMethod
+  PlayMethod,
+  SleepTimerTypes
 }
 
 const KeyNames = {
diff --git a/client/plugins/init.client.js b/client/plugins/init.client.js
index cbf514fd..984ec9d0 100644
--- a/client/plugins/init.client.js
+++ b/client/plugins/init.client.js
@@ -6,7 +6,6 @@ import * as locale from 'date-fns/locale'
 
 Vue.directive('click-outside', vClickOutside.directive)
 
-
 Vue.prototype.$setDateFnsLocale = (localeString) => {
   if (!locale[localeString]) return 0
   return setDefaultOptions({ locale: locale[localeString] })
@@ -112,14 +111,15 @@ Vue.prototype.$sanitizeSlug = (str) => {
   str = str.toLowerCase()
 
   // remove accents, swap ñ for n, etc
-  var from = "àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;"
-  var to = "aaaaeeeeiiiioooouuuuncescrzyuudtn-----"
+  var from = 'àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;'
+  var to = 'aaaaeeeeiiiioooouuuuncescrzyuudtn-----'
 
   for (var i = 0, l = from.length; i < l; i++) {
     str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i))
   }
 
-  str = str.replace('.', '-') // replace a dot by a dash
+  str = str
+    .replace('.', '-') // replace a dot by a dash
     .replace(/[^a-z0-9 -_]/g, '') // remove invalid chars
     .replace(/\s+/g, '-') // collapse whitespace and replace by a dash
     .replace(/-+/g, '-') // collapse dashes
@@ -131,13 +131,16 @@ Vue.prototype.$sanitizeSlug = (str) => {
 Vue.prototype.$copyToClipboard = (str, ctx) => {
   return new Promise((resolve) => {
     if (navigator.clipboard) {
-      navigator.clipboard.writeText(str).then(() => {
-        if (ctx) ctx.$toast.success('Copied to clipboard')
-        resolve(true)
-      }, (err) => {
-        console.error('Clipboard copy failed', str, err)
-        resolve(false)
-      })
+      navigator.clipboard.writeText(str).then(
+        () => {
+          if (ctx) ctx.$toast.success('Copied to clipboard')
+          resolve(true)
+        },
+        (err) => {
+          console.error('Clipboard copy failed', str, err)
+          resolve(false)
+        }
+      )
     } else {
       const el = document.createElement('textarea')
       el.value = str
@@ -160,26 +163,18 @@ function xmlToJson(xml) {
   for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
     const key = res[1] || res[3]
     const value = res[2] && xmlToJson(res[2])
-    json[key] = ((value && Object.keys(value).length) ? value : res[2]) || null
-
+    json[key] = (value && Object.keys(value).length ? value : res[2]) || null
   }
   return json
 }
 Vue.prototype.$xmlToJson = xmlToJson
 
-Vue.prototype.$encodeUriPath = (path) => {
-  return path.replace(/\\/g, '/').replace(/%/g, '%25').replace(/#/g, '%23')
-}
-
 const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
 Vue.prototype.$encode = encode
 const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
 Vue.prototype.$decode = decode
 
-export {
-  encode,
-  decode
-}
+export { encode, decode }
 export default ({ app, store }, inject) => {
   app.$decode = decode
   app.$encode = encode
diff --git a/client/store/user.js b/client/store/user.js
index 3555d63e..7571f916 100644
--- a/client/store/user.js
+++ b/client/store/user.js
@@ -14,7 +14,9 @@ export const state = () => ({
     seriesSortDesc: false,
     seriesFilterBy: 'all',
     authorSortBy: 'name',
-    authorSortDesc: false
+    authorSortDesc: false,
+    jumpForwardAmount: 10,
+    jumpBackwardAmount: 10,
   }
 })
 
diff --git a/client/strings/de.json b/client/strings/de.json
index af57225f..ac3650cb 100644
--- a/client/strings/de.json
+++ b/client/strings/de.json
@@ -59,6 +59,7 @@
   "ButtonPurgeItemsCache": "Lösche Medien-Cache",
   "ButtonQueueAddItem": "Zur Warteschlange hinzufügen",
   "ButtonQueueRemoveItem": "Aus der Warteschlange entfernen",
+  "ButtonQuickEmbedMetadata": "Schnelles Hinzufügen von Metadaten",
   "ButtonQuickMatch": "Schnellabgleich",
   "ButtonReScan": "Neu scannen",
   "ButtonRead": "Lesen",
@@ -66,11 +67,11 @@
   "ButtonReadMore": "Mehr anzeigen",
   "ButtonRefresh": "Neu Laden",
   "ButtonRemove": "Entfernen",
-  "ButtonRemoveAll": "Alles löschen",
-  "ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge",
-  "ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste",
-  "ButtonRemoveFromContinueReading": "Lösche die Serie aus der Lesefortsetzungsliste",
-  "ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste",
+  "ButtonRemoveAll": "Alles entfernen",
+  "ButtonRemoveAllLibraryItems": "Entferne alle Bibliothekseinträge",
+  "ButtonRemoveFromContinueListening": "Entferne den Eintrag aus der Fortsetzungsliste",
+  "ButtonRemoveFromContinueReading": "Entferne die Serie aus der Lesefortsetzungsliste",
+  "ButtonRemoveSeriesFromContinueSeries": "Entferne die Serie aus der Serienfortsetzungsliste",
   "ButtonReset": "Zurücksetzen",
   "ButtonResetToDefault": "Zurücksetzen auf Standard",
   "ButtonRestore": "Wiederherstellen",
@@ -88,6 +89,7 @@
   "ButtonShow": "Anzeigen",
   "ButtonStartM4BEncode": "M4B-Kodierung starten",
   "ButtonStartMetadataEmbed": "Metadateneinbettung starten",
+  "ButtonStats": "Statistiken",
   "ButtonSubmit": "Ok",
   "ButtonTest": "Test",
   "ButtonUpload": "Hochladen",
@@ -154,6 +156,7 @@
   "HeaderPasswordAuthentication": "Passwort Authentifizierung",
   "HeaderPermissions": "Berechtigungen",
   "HeaderPlayerQueue": "Player Warteschlange",
+  "HeaderPlayerSettings": "Player Einstellungen",
   "HeaderPlaylist": "Wiedergabeliste",
   "HeaderPlaylistItems": "Einträge in der Wiedergabeliste",
   "HeaderPodcastsToAdd": "Podcasts zum Hinzufügen",
@@ -161,8 +164,8 @@
   "HeaderRSSFeedGeneral": "RSS Details",
   "HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
   "HeaderRSSFeeds": "RSS-Feeds",
-  "HeaderRemoveEpisode": "Episode löschen",
-  "HeaderRemoveEpisodes": "Lösche {0} Episoden",
+  "HeaderRemoveEpisode": "Episode entfernen",
+  "HeaderRemoveEpisodes": "Entferne {0} Episoden",
   "HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
   "HeaderSchedule": "Zeitplan",
   "HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
@@ -259,7 +262,7 @@
   "LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck:",
   "LabelDatetime": "Datum & Uhrzeit",
   "LabelDays": "Tage",
-  "LabelDeleteFromFileSystemCheckbox": "Löschen von der Festplatte + Datenbank (deaktivieren um nur aus der Datenbank zu löschen)",
+  "LabelDeleteFromFileSystemCheckbox": "Löschen von der Festplatte + Datenbank (deaktivieren um nur aus der Datenbank zu entfernen)",
   "LabelDescription": "Beschreibung",
   "LabelDeselectAll": "Alles abwählen",
   "LabelDevice": "Gerät",
@@ -289,13 +292,16 @@
   "LabelEmbeddedCover": "Eingebettetes Cover",
   "LabelEnable": "Aktivieren",
   "LabelEnd": "Ende",
+  "LabelEndOfChapter": "Ende des Kapitels",
   "LabelEpisode": "Episode",
   "LabelEpisodeTitle": "Episodentitel",
   "LabelEpisodeType": "Episodentyp",
   "LabelExample": "Beispiel",
+  "LabelExpandSeries": "Serie erweitern",
   "LabelExplicit": "Explizit (Altersbeschränkung)",
   "LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)",
   "LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)",
+  "LabelExportOPML": "OPML exportieren",
   "LabelFeedURL": "Feed URL",
   "LabelFetchingMetadata": "Abholen der Metadaten",
   "LabelFile": "Datei",
@@ -319,6 +325,7 @@
   "LabelHardDeleteFile": "Datei dauerhaft löschen",
   "LabelHasEbook": "E-Book verfügbar",
   "LabelHasSupplementaryEbook": "Ergänzendes E-Book verfügbar",
+  "LabelHideSubtitles": "Untertitel ausblenden",
   "LabelHighestPriority": "Höchste Priorität",
   "LabelHost": "Host",
   "LabelHour": "Stunde",
@@ -339,6 +346,8 @@
   "LabelIntervalEveryHour": "Jede Stunde",
   "LabelInvert": "Umkehren",
   "LabelItem": "Medium",
+  "LabelJumpBackwardAmount": "Zurückspringen Zeit",
+  "LabelJumpForwardAmount": "Vorwärtsspringn Zeit",
   "LabelLanguage": "Sprache",
   "LabelLanguageDefaultServer": "Standard-Server-Sprache",
   "LabelLanguages": "Sprachen",
@@ -446,6 +455,7 @@
   "LabelRSSFeedPreventIndexing": "Indizierung verhindern",
   "LabelRSSFeedSlug": "RSS-Feed-Schlagwort",
   "LabelRSSFeedURL": "RSS Feed URL",
+  "LabelReAddSeriesToContinueListening": "Serien erneut zur Fortsetzungsliste hinzufügen",
   "LabelRead": "Lesen",
   "LabelReadAgain": "Noch einmal Lesen",
   "LabelReadEbookWithoutProgress": "E-Book lesen und Fortschritt verwerfen",
@@ -455,7 +465,7 @@
   "LabelRedo": "Wiederholen",
   "LabelRegion": "Region",
   "LabelReleaseDate": "Veröffentlichungsdatum",
-  "LabelRemoveCover": "Lösche Titelbild",
+  "LabelRemoveCover": "Entferne Titelbild",
   "LabelRowsPerPage": "Zeilen pro Seite",
   "LabelSearchTerm": "Begriff suchen",
   "LabelSearchTitle": "Titel suchen",
@@ -512,10 +522,11 @@
   "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
   "LabelSettingsTimeFormat": "Zeitformat",
   "LabelShare": "Teilen",
-  "LabelShareOpen": "Teilen Offen",
+  "LabelShareOpen": "Teilen öffnen",
   "LabelShareURL": "URL teilen",
   "LabelShowAll": "Alles anzeigen",
   "LabelShowSeconds": "Zeige Sekunden",
+  "LabelShowSubtitles": "Untertitel anzeigen",
   "LabelSize": "Größe",
   "LabelSleepTimer": "Schlummerfunktion",
   "LabelSlug": "URL Teil",
@@ -553,6 +564,10 @@
   "LabelThemeDark": "Dunkel",
   "LabelThemeLight": "Hell",
   "LabelTimeBase": "Basiszeit",
+  "LabelTimeDurationXHours": "{0} Stunden",
+  "LabelTimeDurationXMinutes": "{0} Minuten",
+  "LabelTimeDurationXSeconds": "{0} Sekunden",
+  "LabelTimeInMinutes": "Zeit in Minuten",
   "LabelTimeListened": "Gehörte Zeit",
   "LabelTimeListenedToday": "Heute gehörte Zeit",
   "LabelTimeRemaining": "{0} verbleibend",
@@ -592,6 +607,7 @@
   "LabelVersion": "Version",
   "LabelViewBookmarks": "Lesezeichen anzeigen",
   "LabelViewChapters": "Kapitel anzeigen",
+  "LabelViewPlayerSettings": "Zeige player Einstellungen",
   "LabelViewQueue": "Player-Warteschlange anzeigen",
   "LabelVolume": "Lautstärke",
   "LabelWeekdaysToRun": "Wochentage für die Ausführung",
@@ -637,11 +653,11 @@
   "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
   "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
   "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
-  "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird gelöscht! Bist du dir sicher?",
-  "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird geloscht! Bist du dir sicher?",
-  "MessageConfirmRemoveEpisodes": "{0} Episoden werden gelöscht! Bist du dir sicher?",
+  "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?",
+  "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?",
+  "MessageConfirmRemoveEpisodes": "{0} Episoden werden entfernt! Bist du dir sicher?",
   "MessageConfirmRemoveListeningSessions": "Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?",
-  "MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird gelöscht! Bist du dir sicher?",
+  "MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird entfernt! Bist du dir sicher?",
   "MessageConfirmRemovePlaylist": "Wiedergabeliste \"{0}\" wird entfernt! Bist du dir sicher?",
   "MessageConfirmRenameGenre": "Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Bist du dir sicher?",
   "MessageConfirmRenameGenreMergeNote": "Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.",
@@ -712,9 +728,9 @@
   "MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
   "MessagePodcastHasNoRSSFeedForMatching": "Der Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
   "MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.",
-  "MessageRemoveChapter": "Kapitel löschen",
+  "MessageRemoveChapter": "Kapitel entfernen",
   "MessageRemoveEpisodes": "Entferne {0} Episode(n)",
-  "MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen",
+  "MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste entfernen",
   "MessageRemoveUserWarning": "Benutzer \"{0}\" wird dauerhaft gelöscht! Bist du dir sicher?",
   "MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und mitwirken",
   "MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?",
@@ -769,8 +785,8 @@
   "ToastBatchUpdateSuccess": "Stapelaktualisierung erfolgreich",
   "ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
   "ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt",
-  "ToastBookmarkRemoveFailed": "Lesezeichen konnte nicht gelöscht werden",
-  "ToastBookmarkRemoveSuccess": "Lesezeichen gelöscht",
+  "ToastBookmarkRemoveFailed": "Lesezeichen konnte nicht entfernt werden",
+  "ToastBookmarkRemoveSuccess": "Lesezeichen entfernt",
   "ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen",
   "ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
   "ToastCachePurgeFailed": "Cache leeren fehlgeschlagen",
@@ -780,7 +796,7 @@
   "ToastCollectionItemsRemoveFailed": "Fehler beim Entfernen der Medien aus der Sammlung",
   "ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt",
   "ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden",
-  "ToastCollectionRemoveSuccess": "Sammlung gelöscht",
+  "ToastCollectionRemoveSuccess": "Sammlung entfernt",
   "ToastCollectionUpdateFailed": "Sammlung konnte nicht aktualisiert werden",
   "ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
   "ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden",
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index 98031ce4..c6afc371 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -89,6 +89,7 @@
   "ButtonShow": "Show",
   "ButtonStartM4BEncode": "Start M4B Encode",
   "ButtonStartMetadataEmbed": "Start Metadata Embed",
+  "ButtonStats": "Stats",
   "ButtonSubmit": "Submit",
   "ButtonTest": "Test",
   "ButtonUpload": "Upload",
@@ -155,6 +156,7 @@
   "HeaderPasswordAuthentication": "Password Authentication",
   "HeaderPermissions": "Permissions",
   "HeaderPlayerQueue": "Player Queue",
+  "HeaderPlayerSettings": "Player Settings",
   "HeaderPlaylist": "Playlist",
   "HeaderPlaylistItems": "Playlist Items",
   "HeaderPodcastsToAdd": "Podcasts to Add",
@@ -227,7 +229,7 @@
   "LabelBackupLocation": "Backup Location",
   "LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
   "LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/backups",
-  "LabelBackupsMaxBackupSize": "Maximum backup size (in GB)",
+  "LabelBackupsMaxBackupSize": "Maximum backup size (in GB) (0 for unlimited)",
   "LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
   "LabelBackupsNumberToKeep": "Number of backups to keep",
   "LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
@@ -290,6 +292,7 @@
   "LabelEmbeddedCover": "Embedded Cover",
   "LabelEnable": "Enable",
   "LabelEnd": "End",
+  "LabelEndOfChapter": "End of Chapter",
   "LabelEpisode": "Episode",
   "LabelEpisodeTitle": "Episode Title",
   "LabelEpisodeType": "Episode Type",
@@ -343,6 +346,8 @@
   "LabelIntervalEveryHour": "Every hour",
   "LabelInvert": "Invert",
   "LabelItem": "Item",
+  "LabelJumpBackwardAmount": "Jump backward amount",
+  "LabelJumpForwardAmount": "Jump forward amount",
   "LabelLanguage": "Language",
   "LabelLanguageDefaultServer": "Default Server Language",
   "LabelLanguages": "Languages",
@@ -559,6 +564,10 @@
   "LabelThemeDark": "Dark",
   "LabelThemeLight": "Light",
   "LabelTimeBase": "Time Base",
+  "LabelTimeDurationXHours": "{0} hours",
+  "LabelTimeDurationXMinutes": "{0} minutes",
+  "LabelTimeDurationXSeconds": "{0} seconds",
+  "LabelTimeInMinutes": "Time in minutes",
   "LabelTimeListened": "Time Listened",
   "LabelTimeListenedToday": "Time Listened Today",
   "LabelTimeRemaining": "{0} remaining",
@@ -598,6 +607,7 @@
   "LabelVersion": "Version",
   "LabelViewBookmarks": "View bookmarks",
   "LabelViewChapters": "View chapters",
+  "LabelViewPlayerSettings": "View player settings",
   "LabelViewQueue": "View player queue",
   "LabelVolume": "Volume",
   "LabelWeekdaysToRun": "Weekdays to run",
@@ -713,6 +723,7 @@
   "MessageNoUpdatesWereNecessary": "No updates were necessary",
   "MessageNoUserPlaylists": "You have no playlists",
   "MessageNotYetImplemented": "Not yet implemented",
+  "MessageOpmlPreviewNote": "Note: This is a preview of the parsed OPML file. The actual podcast title will be taken from the RSS feed.",
   "MessageOr": "or",
   "MessagePauseChapter": "Pause chapter playback",
   "MessagePlayChapter": "Listen to beginning of chapter",
diff --git a/client/strings/es.json b/client/strings/es.json
index 93a99abc..d5af9736 100644
--- a/client/strings/es.json
+++ b/client/strings/es.json
@@ -59,6 +59,7 @@
   "ButtonPurgeItemsCache": "Purgar Elementos de Cache",
   "ButtonQueueAddItem": "Agregar a la Fila",
   "ButtonQueueRemoveItem": "Remover de la Fila",
+  "ButtonQuickEmbedMetadata": "Agregue metadatos rápidamente",
   "ButtonQuickMatch": "Encontrar Rápido",
   "ButtonReScan": "Re-Escanear",
   "ButtonRead": "Leer",
@@ -88,6 +89,7 @@
   "ButtonShow": "Mostrar",
   "ButtonStartM4BEncode": "Iniciar Codificación M4B",
   "ButtonStartMetadataEmbed": "Iniciar la Inserción de Metadata",
+  "ButtonStats": "Estadísticas",
   "ButtonSubmit": "Enviar",
   "ButtonTest": "Prueba",
   "ButtonUpload": "Subir",
@@ -154,6 +156,7 @@
   "HeaderPasswordAuthentication": "Autenticación por contraseña",
   "HeaderPermissions": "Permisos",
   "HeaderPlayerQueue": "Fila del Reproductor",
+  "HeaderPlayerSettings": "Ajustes del reproductor",
   "HeaderPlaylist": "Lista de reproducción",
   "HeaderPlaylistItems": "Elementos de lista de reproducción",
   "HeaderPodcastsToAdd": "Podcasts para agregar",
@@ -289,13 +292,16 @@
   "LabelEmbeddedCover": "Portada Integrada",
   "LabelEnable": "Habilitar",
   "LabelEnd": "Fin",
+  "LabelEndOfChapter": "Fin del capítulo",
   "LabelEpisode": "Episodio",
   "LabelEpisodeTitle": "Titulo de Episodio",
   "LabelEpisodeType": "Tipo de Episodio",
   "LabelExample": "Ejemplo",
+  "LabelExpandSeries": "Ampliar serie",
   "LabelExplicit": "Explicito",
   "LabelExplicitChecked": "Explícito (marcado)",
   "LabelExplicitUnchecked": "No Explícito (sin marcar)",
+  "LabelExportOPML": "Exportar OPML",
   "LabelFeedURL": "Fuente de URL",
   "LabelFetchingMetadata": "Obteniendo metadatos",
   "LabelFile": "Archivo",
@@ -319,6 +325,7 @@
   "LabelHardDeleteFile": "Eliminar Definitivamente",
   "LabelHasEbook": "Tiene un libro",
   "LabelHasSupplementaryEbook": "Tiene un libro complementario",
+  "LabelHideSubtitles": "Ocultar subtítulos",
   "LabelHighestPriority": "Mayor prioridad",
   "LabelHost": "Host",
   "LabelHour": "Hora",
@@ -339,6 +346,8 @@
   "LabelIntervalEveryHour": "Cada Hora",
   "LabelInvert": "Invertir",
   "LabelItem": "Elemento",
+  "LabelJumpBackwardAmount": "Cantidad de saltos hacia atrás",
+  "LabelJumpForwardAmount": "Cantidad de saltos hacia adelante",
   "LabelLanguage": "Idioma",
   "LabelLanguageDefaultServer": "Lenguaje Predeterminado del Servidor",
   "LabelLanguages": "Idiomas",
@@ -446,6 +455,7 @@
   "LabelRSSFeedPreventIndexing": "Prevenir indexado",
   "LabelRSSFeedSlug": "Fuente RSS Slug",
   "LabelRSSFeedURL": "URL de Fuente RSS",
+  "LabelReAddSeriesToContinueListening": "Volver a agregar la serie para continuar escuchándola",
   "LabelRead": "Leído",
   "LabelReadAgain": "Volver a leer",
   "LabelReadEbookWithoutProgress": "Leer Ebook sin guardar progreso",
@@ -512,9 +522,11 @@
   "LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca",
   "LabelSettingsTimeFormat": "Formato de Tiempo",
   "LabelShare": "Compartir",
+  "LabelShareOpen": "abrir un recurso compartido",
   "LabelShareURL": "Compartir la URL",
   "LabelShowAll": "Mostrar Todos",
   "LabelShowSeconds": "Mostrar segundos",
+  "LabelShowSubtitles": "Mostrar subtítulos",
   "LabelSize": "Tamaño",
   "LabelSleepTimer": "Temporizador de apagado",
   "LabelSlug": "Slug",
@@ -552,6 +564,10 @@
   "LabelThemeDark": "Oscuro",
   "LabelThemeLight": "Claro",
   "LabelTimeBase": "Tiempo Base",
+  "LabelTimeDurationXHours": "{0} horas",
+  "LabelTimeDurationXMinutes": "{0} minutos",
+  "LabelTimeDurationXSeconds": "{0} segundos",
+  "LabelTimeInMinutes": "Tiempo en minutos",
   "LabelTimeListened": "Tiempo Escuchando",
   "LabelTimeListenedToday": "Tiempo Escuchando Hoy",
   "LabelTimeRemaining": "{0} restante",
@@ -591,6 +607,7 @@
   "LabelVersion": "Versión",
   "LabelViewBookmarks": "Ver Marcadores",
   "LabelViewChapters": "Ver Capítulos",
+  "LabelViewPlayerSettings": "Ver los ajustes del reproductor",
   "LabelViewQueue": "Ver Fila del Reproductor",
   "LabelVolume": "Volumen",
   "LabelWeekdaysToRun": "Correr en Días de la Semana",
diff --git a/client/strings/fi.json b/client/strings/fi.json
index 88b2cee3..ecda586c 100644
--- a/client/strings/fi.json
+++ b/client/strings/fi.json
@@ -136,7 +136,7 @@
   "HeaderYourStats": "Tilastosi",
   "LabelAddToPlaylist": "Lisää soittolistaan",
   "LabelAdded": "Lisätty",
-  "LabelAddedAt": "Lisätty",
+  "LabelAddedAt": "Lisätty listalle",
   "LabelAll": "Kaikki",
   "LabelAuthor": "Tekijä",
   "LabelAuthorFirstLast": "Tekijä (Etunimi Sukunimi)",
@@ -152,11 +152,34 @@
   "LabelContinueReading": "Jatka lukemista",
   "LabelContinueSeries": "Jatka sarjoja",
   "LabelDescription": "Kuvaus",
+  "LabelDownload": "Lataa",
   "LabelDuration": "Kesto",
   "LabelEbook": "E-kirja",
   "LabelEbooks": "E-kirjat",
+  "LabelEnable": "Ota käyttöön",
   "LabelFile": "Tiedosto",
   "LabelFileBirthtime": "Tiedoston syntymäaika",
   "LabelFileModified": "Muutettu tiedosto",
-  "LabelFilename": "Tiedostonimi"
+  "LabelFilename": "Tiedostonimi",
+  "LabelFolder": "Kansio",
+  "LabelLanguage": "Kieli",
+  "LabelMore": "Lisää",
+  "LabelNarrator": "Lukija",
+  "LabelNarrators": "Lukijat",
+  "LabelNewestAuthors": "Uusimmat kirjailijat",
+  "LabelNewestEpisodes": "Uusimmat jaksot",
+  "LabelPassword": "Salasana",
+  "LabelPath": "Polku",
+  "LabelRead": "Lue",
+  "LabelReadAgain": "Lue uudelleen",
+  "LabelSeason": "Kausi",
+  "LabelShowAll": "Näytä kaikki",
+  "LabelSize": "Koko",
+  "LabelSleepTimer": "Uniajastin",
+  "LabelTheme": "Teema",
+  "LabelThemeDark": "Tumma",
+  "LabelThemeLight": "Kirkas",
+  "LabelUser": "Käyttäjä",
+  "LabelUsername": "Käyttäjätunnus",
+  "MessageDownloadingEpisode": "Ladataan jaksoa"
 }
diff --git a/client/strings/fr.json b/client/strings/fr.json
index 5aceef63..afab77a1 100644
--- a/client/strings/fr.json
+++ b/client/strings/fr.json
@@ -258,6 +258,7 @@
   "LabelCurrently": "Actuellement :",
   "LabelCustomCronExpression": "Expression cron personnalisée :",
   "LabelDatetime": "Date",
+  "LabelDays": "Jours",
   "LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)",
   "LabelDescription": "Description",
   "LabelDeselectAll": "Tout déselectionner",
@@ -321,6 +322,7 @@
   "LabelHighestPriority": "Priorité la plus élevée",
   "LabelHost": "Hôte",
   "LabelHour": "Heure",
+  "LabelHours": "Heures",
   "LabelIcon": "Icône",
   "LabelImageURLFromTheWeb": "URL de l’image à partir du web",
   "LabelInProgress": "En cours",
@@ -371,6 +373,7 @@
   "LabelMetadataOrderOfPrecedenceDescription": "Les sources de métadonnées ayant une priorité plus élevée auront la priorité sur celles ayant une priorité moins élevée",
   "LabelMetadataProvider": "Fournisseur de métadonnées",
   "LabelMinute": "Minute",
+  "LabelMinutes": "Minutes",
   "LabelMissing": "Manquant",
   "LabelMissingEbook": "Ne possède aucun livre numérique",
   "LabelMissingSupplementaryEbook": "Ne possède aucun livre numérique supplémentaire",
@@ -410,6 +413,7 @@
   "LabelOverwrite": "Écraser",
   "LabelPassword": "Mot de passe",
   "LabelPath": "Chemin",
+  "LabelPermanent": "Permanent",
   "LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque",
   "LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes",
   "LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint",
@@ -507,6 +511,9 @@
   "LabelSettingsStoreMetadataWithItem": "Enregistrer les métadonnées avec l’élément",
   "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les fichiers de métadonnées sont stockés dans /metadata/items. En activant ce paramètre, les fichiers de métadonnées seront stockés dans les dossiers des éléments de votre bibliothèque",
   "LabelSettingsTimeFormat": "Format d’heure",
+  "LabelShare": "Partager",
+  "LabelShareOpen": "Ouvrir le partage",
+  "LabelShareURL": "Partager l’URL",
   "LabelShowAll": "Tout afficher",
   "LabelShowSeconds": "Afficher les seondes",
   "LabelSize": "Taille",
@@ -598,6 +605,7 @@
   "MessageAppriseDescription": "Nécessite une instance d’<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.<br />L’URL de l’API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.",
   "MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression des utilisateurs, les détails des éléments de la bibliothèque, les paramètres du serveur et les images stockées dans <code>/metadata/items</code> & <code>/metadata/authors</code>. Les sauvegardes <strong>n’incluent pas</strong> les fichiers stockés dans les dossiers de votre bibliothèque.",
   "MessageBackupsLocationEditNote": "Remarque : Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes",
+  "MessageBackupsLocationNoEditNote": "Remarque : l’emplacement de sauvegarde est défini via une variable d’environnement et ne peut pas être modifié ici.",
   "MessageBackupsLocationPathEmpty": "L'emplacement de secours ne peut pas être vide",
   "MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et métadonnées manquantes pour les éléments sélectionnés. Activez les options ci-dessous pour permettre la Recherche par correspondance d’écraser les couvertures et/ou métadonnées existantes.",
   "MessageBookshelfNoCollections": "Vous n’avez pas encore de collections",
@@ -716,6 +724,9 @@
   "MessageSelected": "{0} sélectionnés",
   "MessageServerCouldNotBeReached": "Serveur inaccessible",
   "MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre",
+  "MessageShareExpirationWillBe": "Expire le <strong>{0}</strong>",
+  "MessageShareExpiresIn": "Expire dans {0}",
+  "MessageShareURLWillBe": "L’adresse de partage sera <strong>{0}</strong>",
   "MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?",
   "MessageThinking": "Je cherche…",
   "MessageUploaderItemFailed": "Échec du téléversement",
@@ -730,7 +741,7 @@
   "NoteChapterEditorTimes": "Information : l’horodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du livre audio.",
   "NoteFolderPicker": "Information : les dossiers déjà surveillés ne sont pas affichés",
   "NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux HTTPS",
-  "NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
+  "NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
   "NoteUploaderFoldersWithMediaFiles": "Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.",
   "NoteUploaderOnlyAudioFiles": "Si vous téléversez uniquement des fichiers audio, chaque fichier audio sera traité comme un livre audio distinct.",
   "NoteUploaderUnsupportedFiles": "Les fichiers non pris en charge sont ignorés. Lorsque vous choisissez ou déposez un dossier, les autres fichiers qui ne sont pas dans un dossier d’élément sont ignorés.",
diff --git a/client/strings/he.json b/client/strings/he.json
index aa6eb986..51463940 100644
--- a/client/strings/he.json
+++ b/client/strings/he.json
@@ -9,7 +9,7 @@
   "ButtonApply": "החל",
   "ButtonApplyChapters": "החל פרקים",
   "ButtonAuthors": "יוצרים",
-  "ButtonBack": "Back",
+  "ButtonBack": "חזור",
   "ButtonBrowseForFolder": "עיין בתיקייה",
   "ButtonCancel": "בטל",
   "ButtonCancelEncode": "בטל קידוד",
@@ -62,8 +62,8 @@
   "ButtonQuickMatch": "התאמה מהירה",
   "ButtonReScan": "סרוק מחדש",
   "ButtonRead": "קרא",
-  "ButtonReadLess": "Read less",
-  "ButtonReadMore": "Read more",
+  "ButtonReadLess": "קרא פחות",
+  "ButtonReadMore": "קרא יותר",
   "ButtonRefresh": "רענן",
   "ButtonRemove": "הסר",
   "ButtonRemoveAll": "הסר הכל",
@@ -115,7 +115,7 @@
   "HeaderCollectionItems": "פריטי אוסף",
   "HeaderCover": "כריכה",
   "HeaderCurrentDownloads": "הורדות נוכחיות",
-  "HeaderCustomMessageOnLogin": "Custom Message on Login",
+  "HeaderCustomMessageOnLogin": "הודעה מותאמת אישית בהתחברות",
   "HeaderCustomMetadataProviders": "ספקי מטא-נתונים מותאמים אישית",
   "HeaderDetails": "פרטים",
   "HeaderDownloadQueue": "תור הורדה",
@@ -806,8 +806,8 @@
   "ToastSendEbookToDeviceSuccess": "הספר נשלח אל המכשיר \"{0}\"",
   "ToastSeriesUpdateFailed": "עדכון הסדרה נכשל",
   "ToastSeriesUpdateSuccess": "הסדרה עודכנה בהצלחה",
-  "ToastServerSettingsUpdateFailed": "Failed to update server settings",
-  "ToastServerSettingsUpdateSuccess": "Server settings updated",
+  "ToastServerSettingsUpdateFailed": "כשל בעדכון הגדרות שרת",
+  "ToastServerSettingsUpdateSuccess": "הגדרות שרת עודכנו בהצלחה",
   "ToastSessionDeleteFailed": "מחיקת הפעולה נכשלה",
   "ToastSessionDeleteSuccess": "הפעולה נמחקה בהצלחה",
   "ToastSocketConnected": "קצה תקשורת חובר",
diff --git a/client/strings/nl.json b/client/strings/nl.json
index 18bb4218..e209c3a5 100644
--- a/client/strings/nl.json
+++ b/client/strings/nl.json
@@ -1,15 +1,15 @@
 {
   "ButtonAdd": "Toevoegen",
   "ButtonAddChapters": "Hoofdstukken toevoegen",
-  "ButtonAddDevice": "Add Device",
-  "ButtonAddLibrary": "Add Library",
+  "ButtonAddDevice": "Toestel toevoegen",
+  "ButtonAddLibrary": "Bibliotheek toevoegen",
   "ButtonAddPodcasts": "Podcasts toevoegen",
-  "ButtonAddUser": "Add User",
+  "ButtonAddUser": "Gebruiker toevoegen",
   "ButtonAddYourFirstLibrary": "Voeg je eerste bibliotheek toe",
   "ButtonApply": "Pas toe",
   "ButtonApplyChapters": "Hoofdstukken toepassen",
   "ButtonAuthors": "Auteurs",
-  "ButtonBack": "Back",
+  "ButtonBack": "Terug",
   "ButtonBrowseForFolder": "Bladeren naar map",
   "ButtonCancel": "Annuleren",
   "ButtonCancelEncode": "Encoding annuleren",
@@ -32,9 +32,9 @@
   "ButtonFullPath": "Volledig pad",
   "ButtonHide": "Verberg",
   "ButtonHome": "Home",
-  "ButtonIssues": "Issues",
-  "ButtonJumpBackward": "Jump Backward",
-  "ButtonJumpForward": "Jump Forward",
+  "ButtonIssues": "Problemen",
+  "ButtonJumpBackward": "Spring achteruit",
+  "ButtonJumpForward": "Spring vooruit",
   "ButtonLatest": "Meest recent",
   "ButtonLibrary": "Bibliotheek",
   "ButtonLogout": "Log uit",
@@ -44,17 +44,17 @@
   "ButtonMatchAllAuthors": "Alle auteurs matchen",
   "ButtonMatchBooks": "Alle boeken matchen",
   "ButtonNevermind": "Laat maar",
-  "ButtonNext": "Next",
-  "ButtonNextChapter": "Next Chapter",
+  "ButtonNext": "Volgende",
+  "ButtonNextChapter": "Volgend hoofdstuk",
   "ButtonOk": "Ok",
   "ButtonOpenFeed": "Feed openen",
   "ButtonOpenManager": "Manager openen",
-  "ButtonPause": "Pause",
+  "ButtonPause": "Pauze",
   "ButtonPlay": "Afspelen",
   "ButtonPlaying": "Speelt",
   "ButtonPlaylists": "Afspeellijsten",
-  "ButtonPrevious": "Previous",
-  "ButtonPreviousChapter": "Previous Chapter",
+  "ButtonPrevious": "Vorige",
+  "ButtonPreviousChapter": "Vorig hoofdstuk",
   "ButtonPurgeAllCache": "Volledige cache legen",
   "ButtonPurgeItemsCache": "Onderdelen-cache legen",
   "ButtonQueueAddItem": "In wachtrij zetten",
@@ -62,14 +62,14 @@
   "ButtonQuickMatch": "Snelle match",
   "ButtonReScan": "Nieuwe scan",
   "ButtonRead": "Lees",
-  "ButtonReadLess": "Read less",
-  "ButtonReadMore": "Read more",
-  "ButtonRefresh": "Refresh",
+  "ButtonReadLess": "Lees minder",
+  "ButtonReadMore": "Lees meer",
+  "ButtonRefresh": "Verversen",
   "ButtonRemove": "Verwijder",
   "ButtonRemoveAll": "Alles verwijderen",
   "ButtonRemoveAllLibraryItems": "Verwijder volledige bibliotheekinhoud",
   "ButtonRemoveFromContinueListening": "Vewijder uit Verder luisteren",
-  "ButtonRemoveFromContinueReading": "Remove from Continue Reading",
+  "ButtonRemoveFromContinueReading": "Verwijder van Verder luisteren",
   "ButtonRemoveSeriesFromContinueSeries": "Verwijder serie uit Serie vervolgen",
   "ButtonReset": "Reset",
   "ButtonResetToDefault": "Reset to default",
@@ -83,7 +83,7 @@
   "ButtonSelectFolderPath": "Maplocatie selecteren",
   "ButtonSeries": "Series",
   "ButtonSetChaptersFromTracks": "Maak hoofdstukken op basis van tracks",
-  "ButtonShare": "Share",
+  "ButtonShare": "Deel",
   "ButtonShiftTimes": "Tijden verschuiven",
   "ButtonShow": "Toon",
   "ButtonStartM4BEncode": "Start M4B-encoding",
@@ -98,9 +98,9 @@
   "ButtonUserEdit": "Wijzig gebruiker {0}",
   "ButtonViewAll": "Toon alle",
   "ButtonYes": "Ja",
-  "ErrorUploadFetchMetadataAPI": "Error fetching metadata",
-  "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
-  "ErrorUploadLacksTitle": "Must have a title",
+  "ErrorUploadFetchMetadataAPI": "Error metadata ophalen",
+  "ErrorUploadFetchMetadataNoResults": "Kan metadata niet ophalen - probeer de titel en/of auteur te updaten",
+  "ErrorUploadLacksTitle": "Moet een titel hebben",
   "HeaderAccount": "Account",
   "HeaderAdvanced": "Geavanceerd",
   "HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
@@ -113,13 +113,13 @@
   "HeaderChooseAFolder": "Map kiezen",
   "HeaderCollection": "Collectie",
   "HeaderCollectionItems": "Collectie-objecten",
-  "HeaderCover": "Cover",
+  "HeaderCover": "Omslag",
   "HeaderCurrentDownloads": "Huidige downloads",
   "HeaderCustomMessageOnLogin": "Custom Message on Login",
   "HeaderCustomMetadataProviders": "Custom Metadata Providers",
   "HeaderDetails": "Details",
   "HeaderDownloadQueue": "Download-wachtrij",
-  "HeaderEbookFiles": "Ebook Files",
+  "HeaderEbookFiles": "Ebook bestanden",
   "HeaderEmail": "E-mail",
   "HeaderEmailSettings": "E-mail instellingen",
   "HeaderEpisodes": "Afleveringen",
@@ -239,11 +239,11 @@
   "LabelChapterTitle": "Hoofdstuktitel",
   "LabelChapters": "Hoofdstukken",
   "LabelChaptersFound": "Hoofdstukken gevonden",
-  "LabelClickForMoreInfo": "Click for more info",
+  "LabelClickForMoreInfo": "Klik voor meer informatie",
   "LabelClosePlayer": "Sluit speler",
   "LabelCodec": "Codec",
   "LabelCollapseSeries": "Series inklappen",
-  "LabelCollection": "Collection",
+  "LabelCollection": "Collectie",
   "LabelCollections": "Collecties",
   "LabelComplete": "Compleet",
   "LabelConfirmPassword": "Bevestig wachtwoord",
@@ -258,6 +258,7 @@
   "LabelCurrently": "Op dit moment:",
   "LabelCustomCronExpression": "Aangepaste Cron-uitdrukking:",
   "LabelDatetime": "Datum-tijd",
+  "LabelDays": "Dagen",
   "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)",
   "LabelDescription": "Beschrijving",
   "LabelDeselectAll": "Deselecteer alle",
@@ -296,7 +297,7 @@
   "LabelExplicitChecked": "Explicit (checked)",
   "LabelExplicitUnchecked": "Not Explicit (unchecked)",
   "LabelFeedURL": "Feed URL",
-  "LabelFetchingMetadata": "Fetching Metadata",
+  "LabelFetchingMetadata": "Metadata ophalen",
   "LabelFile": "Bestand",
   "LabelFileBirthtime": "Aanmaaktijd bestand",
   "LabelFileModified": "Bestand gewijzigd",
@@ -306,7 +307,7 @@
   "LabelFinished": "Voltooid",
   "LabelFolder": "Map",
   "LabelFolders": "Mappen",
-  "LabelFontBold": "Bold",
+  "LabelFontBold": "Vetgedrukt",
   "LabelFontBoldness": "Font Boldness",
   "LabelFontFamily": "Lettertypefamilie",
   "LabelFontItalic": "Italic",
@@ -321,6 +322,7 @@
   "LabelHighestPriority": "Highest priority",
   "LabelHost": "Host",
   "LabelHour": "Uur",
+  "LabelHours": "Uren",
   "LabelIcon": "Icoon",
   "LabelImageURLFromTheWeb": "Image URL from the web",
   "LabelInProgress": "Bezig",
@@ -567,7 +569,7 @@
   "LabelTracksSingleTrack": "Enkele track",
   "LabelType": "Type",
   "LabelUnabridged": "Onverkort",
-  "LabelUndo": "Undo",
+  "LabelUndo": "Ongedaan maken",
   "LabelUnknown": "Onbekend",
   "LabelUpdateCover": "Cover bijwerken",
   "LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden",
@@ -630,7 +632,7 @@
   "MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?",
   "MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
   "MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?",
-  "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
+  "MessageConfirmRemoveListeningSessions": "Weet je zeker dat je {0} luistersessies wilt verwijderen?",
   "MessageConfirmRemoveNarrator": "Weet je zeker dat je verteller \"{0}\" wil verwijderen?",
   "MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{0}\" wil verwijderen?",
   "MessageConfirmRenameGenre": "Weet je zeker dat je genre \"{0}\" wil hernoemen naar \"{1}\" voor alle onderdelen?",
@@ -714,6 +716,7 @@
   "MessageSelected": "{0} selected",
   "MessageServerCouldNotBeReached": "Server niet bereikbaar",
   "MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel",
+  "MessageShareExpiresIn": "Vervalt in {0}",
   "MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {1}?",
   "MessageThinking": "Aan het denken...",
   "MessageUploaderItemFailed": "Uploaden mislukt",
diff --git a/client/strings/pl.json b/client/strings/pl.json
index 92dd2735..0fe8535d 100644
--- a/client/strings/pl.json
+++ b/client/strings/pl.json
@@ -62,8 +62,8 @@
   "ButtonQuickMatch": "Szybkie dopasowanie",
   "ButtonReScan": "Ponowne skanowanie",
   "ButtonRead": "Czytaj",
-  "ButtonReadLess": "Read less",
-  "ButtonReadMore": "Read more",
+  "ButtonReadLess": "Pokaż mniej",
+  "ButtonReadMore": "Pokaż więcej",
   "ButtonRefresh": "Odśwież",
   "ButtonRemove": "Usuń",
   "ButtonRemoveAll": "Usuń wszystko",
@@ -88,6 +88,7 @@
   "ButtonShow": "Pokaż",
   "ButtonStartM4BEncode": "Eksportuj jako plik M4B",
   "ButtonStartMetadataEmbed": "Osadź metadane",
+  "ButtonStats": "Statystyki",
   "ButtonSubmit": "Zaloguj",
   "ButtonTest": "Test",
   "ButtonUpload": "Wgraj",
@@ -130,13 +131,13 @@
   "HeaderIgnoredFiles": "Zignoruj pliki",
   "HeaderItemFiles": "Pliki",
   "HeaderItemMetadataUtils": "Item Metadata Utils",
-  "HeaderLastListeningSession": "Ostatnio odtwarzana sesja",
+  "HeaderLastListeningSession": "Ostatnia sesja słuchania",
   "HeaderLatestEpisodes": "Najnowsze odcinki",
   "HeaderLibraries": "Biblioteki",
   "HeaderLibraryFiles": "Pliki w bibliotece",
   "HeaderLibraryStats": "Statystyki biblioteki",
   "HeaderListeningSessions": "Sesje słuchania",
-  "HeaderListeningStats": "Statystyki odtwarzania",
+  "HeaderListeningStats": "Statystyki słuchania",
   "HeaderLogin": "Zaloguj się",
   "HeaderLogs": "Logi",
   "HeaderManageGenres": "Zarządzaj gatunkami",
@@ -148,12 +149,13 @@
   "HeaderNewAccount": "Nowe konto",
   "HeaderNewLibrary": "Nowa biblioteka",
   "HeaderNotifications": "Powiadomienia",
-  "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
+  "HeaderOpenIDConnectAuthentication": "Uwierzytelnianie OpenID Connect",
   "HeaderOpenRSSFeed": "Utwórz kanał RSS",
   "HeaderOtherFiles": "Inne pliki",
   "HeaderPasswordAuthentication": "Uwierzytelnianie hasłem",
   "HeaderPermissions": "Uprawnienia",
   "HeaderPlayerQueue": "Kolejka odtwarzania",
+  "HeaderPlayerSettings": "Ustawienia Odtwarzania",
   "HeaderPlaylist": "Playlista",
   "HeaderPlaylistItems": "Pozycje listy odtwarzania",
   "HeaderPodcastsToAdd": "Podcasty do dodania",
@@ -175,7 +177,7 @@
   "HeaderSettingsScanner": "Skanowanie",
   "HeaderSleepTimer": "Wyłącznik czasowy",
   "HeaderStatsLargestItems": "Największe pozycje",
-  "HeaderStatsLongestItems": "Najdłuższe pozycje (hrs)",
+  "HeaderStatsLongestItems": "Najdłuższe pozycje (godziny)",
   "HeaderStatsMinutesListeningChart": "Czas słuchania w minutach (ostatnie 7 dni)",
   "HeaderStatsRecentSessions": "Ostatnie sesje",
   "HeaderStatsTop10Authors": "Top 10 Autorów",
@@ -200,8 +202,8 @@
   "LabelActivity": "Aktywność",
   "LabelAddToCollection": "Dodaj do kolekcji",
   "LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
-  "LabelAddToPlaylist": "Add to Playlist",
-  "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
+  "LabelAddToPlaylist": "Dodaj do playlisty",
+  "LabelAddToPlaylistBatch": "Dodaj {0} pozycji do playlisty",
   "LabelAdded": "Dodane",
   "LabelAddedAt": "Dodano",
   "LabelAdminUsersOnly": "Tylko użytkownicy administracyjni",
@@ -226,14 +228,14 @@
   "LabelBackupLocation": "Lokalizacja kopii zapasowej",
   "LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe",
   "LabelBackupsEnableAutomaticBackupsHelp": "Kopie zapasowe są zapisywane w folderze /metadata/backups",
-  "LabelBackupsMaxBackupSize": "Maksymalny łączny rozmiar backupów (w GB)",
+  "LabelBackupsMaxBackupSize": "Maksymalny rozmiar kopii zapasowej (w GB)",
   "LabelBackupsMaxBackupSizeHelp": "Jako zabezpieczenie przed błędną konfiguracją, kopie zapasowe nie będą wykonywane, jeśli przekroczą skonfigurowany rozmiar.",
   "LabelBackupsNumberToKeep": "Liczba kopii zapasowych do przechowywania",
   "LabelBackupsNumberToKeepHelp": "Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.",
   "LabelBitrate": "Bitrate",
   "LabelBooks": "Książki",
   "LabelButtonText": "Button Text",
-  "LabelByAuthor": "by {0}",
+  "LabelByAuthor": "autorstwa {0}",
   "LabelChangePassword": "Zmień hasło",
   "LabelChannels": "Kanały",
   "LabelChapterTitle": "Tytuł rozdziału",
@@ -247,7 +249,7 @@
   "LabelCollections": "Kolekcje",
   "LabelComplete": "Ukończone",
   "LabelConfirmPassword": "Potwierdź hasło",
-  "LabelContinueListening": "Kontynuuj odtwarzanie",
+  "LabelContinueListening": "Kontynuuj słuchanie",
   "LabelContinueReading": "Kontynuuj czytanie",
   "LabelContinueSeries": "Kontynuuj serię",
   "LabelCover": "Okładka",
@@ -319,6 +321,7 @@
   "LabelHardDeleteFile": "Usuń trwale plik",
   "LabelHasEbook": "Ma ebooka",
   "LabelHasSupplementaryEbook": "Posiada dodatkowy ebook",
+  "LabelHideSubtitles": "Ukryj napisy",
   "LabelHighestPriority": "Najwyższy priorytet",
   "LabelHost": "Host",
   "LabelHour": "Godzina",
@@ -413,7 +416,7 @@
   "LabelOverwrite": "Nadpisz",
   "LabelPassword": "Hasło",
   "LabelPath": "Ścieżka",
-  "LabelPermanent": "Trwały",
+  "LabelPermanent": "Stałe",
   "LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
   "LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów",
   "LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite",
@@ -446,6 +449,7 @@
   "LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu",
   "LabelRSSFeedSlug": "RSS Feed Slug",
   "LabelRSSFeedURL": "URL kanały RSS",
+  "LabelReAddSeriesToContinueListening": "Ponownie Dodaj Serię do sekcji Kontunuuj Odtwarzanie",
   "LabelRead": "Czytaj",
   "LabelReadAgain": "Czytaj ponownie",
   "LabelReadEbookWithoutProgress": "Czytaj książkę bez zapamiętywania postępu",
@@ -516,6 +520,7 @@
   "LabelShareURL": "Link do udziału",
   "LabelShowAll": "Pokaż wszystko",
   "LabelShowSeconds": "Pokaż sekundy",
+  "LabelShowSubtitles": "Pokaż Napisy",
   "LabelSize": "Rozmiar",
   "LabelSleepTimer": "Wyłącznik czasowy",
   "LabelSlug": "Slug",
@@ -534,10 +539,10 @@
   "LabelStatsItemsFinished": "Pozycje zakończone",
   "LabelStatsItemsInLibrary": "Pozycje w bibliotece",
   "LabelStatsMinutes": "Minuty",
-  "LabelStatsMinutesListening": "Minuty odtwarzania",
+  "LabelStatsMinutesListening": "Minuty słuchania",
   "LabelStatsOverallDays": "Całkowity czas (dni)",
   "LabelStatsOverallHours": "Całkowity czas (godziny)",
-  "LabelStatsWeekListening": "Tydzień odtwarzania",
+  "LabelStatsWeekListening": "Tydzień słuchania",
   "LabelSubtitle": "Podtytuł",
   "LabelSupportedFileTypes": "Obsługiwane typy plików",
   "LabelTag": "Tag",
@@ -592,6 +597,7 @@
   "LabelVersion": "Wersja",
   "LabelViewBookmarks": "Wyświetlaj zakładki",
   "LabelViewChapters": "Wyświetlaj rozdziały",
+  "LabelViewPlayerSettings": "Zobacz ustawienia odtwarzacza",
   "LabelViewQueue": "Wyświetlaj kolejkę odtwarzania",
   "LabelVolume": "Głośność",
   "LabelWeekdaysToRun": "Dni tygodnia",
@@ -642,7 +648,7 @@
   "MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
   "MessageConfirmRemoveListeningSessions": "Czy na pewno chcesz usunąć {0} sesji słuchania?",
   "MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
-  "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
+  "MessageConfirmRemovePlaylist": "Czy jesteś pewien, że chcesz usunąć twoją playlistę \"{0}\"?",
   "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
   "MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
   "MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
@@ -663,7 +669,7 @@
   "MessageItemsSelected": "{0} zaznaczone elementy",
   "MessageItemsUpdated": "{0} Items Updated",
   "MessageJoinUsOn": "Dołącz do nas na",
-  "MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku",
+  "MessageListeningSessionsInTheLastYear": "Sesje słuchania w ostatnim roku: {0}",
   "MessageLoading": "Ładowanie...",
   "MessageLoadingFolders": "Ładowanie folderów...",
   "MessageLogsDescription": "Logi zapisane są w <code>/metadata/logs</code> jako pliki JSON. Logi awaryjne są zapisane w <code>/metadata/logs/crash_logs.txt</code>.",
@@ -692,7 +698,7 @@
   "MessageNoIssues": "Brak problemów",
   "MessageNoItems": "Brak elementów",
   "MessageNoItemsFound": "Nie znaleziono żadnych elementów",
-  "MessageNoListeningSessions": "Brak sesji odtwarzania",
+  "MessageNoListeningSessions": "Brak sesji słuchania",
   "MessageNoLogs": "Brak logów",
   "MessageNoMediaProgress": "Brak postępu",
   "MessageNoNotifications": "Brak powiadomień",
@@ -709,7 +715,7 @@
   "MessageOr": "lub",
   "MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
   "MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału",
-  "MessagePlaylistCreateFromCollection": "Utwórz listę odtwarznia na podstawie kolekcji",
+  "MessagePlaylistCreateFromCollection": "Utwórz listę odtwarzania na podstawie kolekcji",
   "MessagePodcastHasNoRSSFeedForMatching": "Podcast nie ma adresu url kanału RSS, który mógłby zostać użyty do dopasowania",
   "MessageQuickMatchDescription": "Wypełnij puste informacje i okładkę pierwszym wynikiem dopasowania z '{0}'. Nie nadpisuje szczegółów, chyba że włączone jest ustawienie serwera 'Preferuj dopasowane metadane'.",
   "MessageRemoveChapter": "Usuń rozdział",
@@ -724,8 +730,9 @@
   "MessageSelected": "{0} wybranych",
   "MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem",
   "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
+  "MessageShareExpirationWillBe": "Czas udostępniania <strong>{0}</strong>",
   "MessageShareExpiresIn": "Wygaśnie za {0}",
-  "MessageShareURLWillBe": "URL udziału będzie <strong>{0}</strong>",
+  "MessageShareURLWillBe": "Udostępnione pod linkiem <strong>{0}</strong>",
   "MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
   "MessageThinking": "Myślę...",
   "MessageUploaderItemFailed": "Nie udało się przesłać",
@@ -746,7 +753,7 @@
   "NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.",
   "PlaceholderNewCollection": "Nowa nazwa kolekcji",
   "PlaceholderNewFolderPath": "Nowa ścieżka folderu",
-  "PlaceholderNewPlaylist": "New playlist name",
+  "PlaceholderNewPlaylist": "Nowa nazwa playlisty",
   "PlaceholderSearch": "Szukanie..",
   "PlaceholderSearchEpisode": "Szukanie odcinka..",
   "ToastAccountUpdateFailed": "Nie udało się zaktualizować konta",
@@ -802,12 +809,12 @@
   "ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
   "ToastLibraryUpdateFailed": "Nie udało się zaktualizować biblioteki",
   "ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
-  "ToastPlaylistCreateFailed": "Failed to create playlist",
-  "ToastPlaylistCreateSuccess": "Playlist created",
-  "ToastPlaylistRemoveFailed": "Failed to remove playlist",
-  "ToastPlaylistRemoveSuccess": "Playlist removed",
-  "ToastPlaylistUpdateFailed": "Failed to update playlist",
-  "ToastPlaylistUpdateSuccess": "Playlist updated",
+  "ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty",
+  "ToastPlaylistCreateSuccess": "Playlista utworzona",
+  "ToastPlaylistRemoveFailed": "Nie udało się usunąć playlisty",
+  "ToastPlaylistRemoveSuccess": "Playlista usunięta",
+  "ToastPlaylistUpdateFailed": "Nie udało się zaktualizować playlisty",
+  "ToastPlaylistUpdateSuccess": "Playlista zaktualizowana",
   "ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
   "ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
   "ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
diff --git a/server/Server.js b/server/Server.js
index 76d8466d..8649c5ad 100644
--- a/server/Server.js
+++ b/server/Server.js
@@ -285,6 +285,7 @@ class Server {
       '/library/:library/bookshelf/:id?',
       '/library/:library/authors',
       '/library/:library/narrators',
+      '/library/:library/stats',
       '/library/:library/series/:id?',
       '/library/:library/podcast/search',
       '/library/:library/podcast/latest',
diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js
index 11985486..b20547e3 100644
--- a/server/controllers/PodcastController.js
+++ b/server/controllers/PodcastController.js
@@ -14,6 +14,15 @@ const CoverManager = require('../managers/CoverManager')
 const LibraryItem = require('../objects/LibraryItem')
 
 class PodcastController {
+  /**
+   * POST /api/podcasts
+   * Create podcast
+   *
+   * @this import('../routers/ApiRouter')
+   *
+   * @param {import('express').Request} req
+   * @param {import('express').Response} res
+   */
   async create(req, res) {
     if (!req.user.isAdminOrUp) {
       Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to create podcast`)
@@ -133,6 +142,14 @@ class PodcastController {
     res.json({ podcast })
   }
 
+  /**
+   * POST: /api/podcasts/opml
+   *
+   * @this import('../routers/ApiRouter')
+   *
+   * @param {import('express').Request} req
+   * @param {import('express').Response} res
+   */
   async getFeedsFromOPMLText(req, res) {
     if (!req.user.isAdminOrUp) {
       Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get feeds from opml`)
@@ -143,8 +160,44 @@ class PodcastController {
       return res.sendStatus(400)
     }
 
-    const rssFeedsData = await this.podcastManager.getOPMLFeeds(req.body.opmlText)
-    res.json(rssFeedsData)
+    res.json({
+      feeds: this.podcastManager.getParsedOPMLFileFeeds(req.body.opmlText)
+    })
+  }
+
+  /**
+   * POST: /api/podcasts/opml/create
+   *
+   * @this import('../routers/ApiRouter')
+   *
+   * @param {import('express').Request} req
+   * @param {import('express').Response} res
+   */
+  async bulkCreatePodcastsFromOpmlFeedUrls(req, res) {
+    if (!req.user.isAdminOrUp) {
+      Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to bulk create podcasts`)
+      return res.sendStatus(403)
+    }
+
+    const rssFeeds = req.body.feeds
+    if (!Array.isArray(rssFeeds) || !rssFeeds.length || rssFeeds.some((feed) => !validateUrl(feed))) {
+      return res.status(400).send('Invalid request body. "feeds" must be an array of RSS feed URLs')
+    }
+
+    const libraryId = req.body.libraryId
+    const folderId = req.body.folderId
+    if (!libraryId || !folderId) {
+      return res.status(400).send('Invalid request body. "libraryId" and "folderId" are required')
+    }
+
+    const folder = await Database.libraryFolderModel.findByPk(folderId)
+    if (!folder || folder.libraryId !== libraryId) {
+      return res.status(404).send('Folder not found')
+    }
+    const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes
+    this.podcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)
+
+    res.sendStatus(200)
   }
 
   async checkNewEpisodes(req, res) {
diff --git a/server/managers/BackupManager.js b/server/managers/BackupManager.js
index 88772c58..13493952 100644
--- a/server/managers/BackupManager.js
+++ b/server/managers/BackupManager.js
@@ -42,7 +42,7 @@ class BackupManager {
   }
 
   get maxBackupSize() {
-    return global.ServerSettings.maxBackupSize || 1
+    return global.ServerSettings.maxBackupSize || Infinity
   }
 
   async init() {
@@ -419,14 +419,16 @@ class BackupManager {
         reject(err)
       })
       archive.on('progress', ({ fs: fsobj }) => {
-        const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
-        if (fsobj.processedBytes > maxBackupSizeInBytes) {
-          Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
-          archive.abort()
-          setTimeout(() => {
-            this.removeBackup(backup)
-            output.destroy('Backup too large') // Promise is reject in write stream error evt
-          }, 500)
+        if (this.maxBackupSize !== Infinity) {
+          const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
+          if (fsobj.processedBytes > maxBackupSizeInBytes) {
+            Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
+            archive.abort()
+            setTimeout(() => {
+              this.removeBackup(backup)
+              output.destroy('Backup too large') // Promise is reject in write stream error evt
+            }, 500)
+          }
         }
       })
 
diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js
index d8db6492..adec5987 100644
--- a/server/managers/PodcastManager.js
+++ b/server/managers/PodcastManager.js
@@ -5,7 +5,7 @@ const Database = require('../Database')
 const fs = require('../libs/fsExtra')
 
 const { getPodcastFeed } = require('../utils/podcastUtils')
-const { removeFile, downloadFile } = require('../utils/fileUtils')
+const { removeFile, downloadFile, sanitizeFilename, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
 const { levenshteinDistance } = require('../utils/index')
 const opmlParser = require('../utils/parsers/parseOPML')
 const opmlGenerator = require('../utils/generators/opmlGenerator')
@@ -13,11 +13,13 @@ const prober = require('../utils/prober')
 const ffmpegHelpers = require('../utils/ffmpegHelpers')
 
 const TaskManager = require('./TaskManager')
+const CoverManager = require('../managers/CoverManager')
 
 const LibraryFile = require('../objects/files/LibraryFile')
 const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
 const PodcastEpisode = require('../objects/entities/PodcastEpisode')
 const AudioFile = require('../objects/files/AudioFile')
+const LibraryItem = require('../objects/LibraryItem')
 
 class PodcastManager {
   constructor(watcher, notificationManager) {
@@ -350,19 +352,23 @@ class PodcastManager {
     return matches.sort((a, b) => a.levenshtein - b.levenshtein)
   }
 
+  getParsedOPMLFileFeeds(opmlText) {
+    return opmlParser.parse(opmlText)
+  }
+
   async getOPMLFeeds(opmlText) {
-    var extractedFeeds = opmlParser.parse(opmlText)
-    if (!extractedFeeds || !extractedFeeds.length) {
+    const extractedFeeds = opmlParser.parse(opmlText)
+    if (!extractedFeeds?.length) {
       Logger.error('[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML')
       return {
         error: 'No RSS feeds found in OPML'
       }
     }
 
-    var rssFeedData = []
+    const rssFeedData = []
 
     for (let feed of extractedFeeds) {
-      var feedData = await getPodcastFeed(feed.feedUrl, true)
+      const feedData = await getPodcastFeed(feed.feedUrl, true)
       if (feedData) {
         feedData.metadata.feedUrl = feed.feedUrl
         rssFeedData.push(feedData)
@@ -392,5 +398,115 @@ class PodcastManager {
       queue: this.downloadQueue.filter((item) => !libraryId || item.libraryId === libraryId).map((item) => item.toJSONForClient())
     }
   }
+
+  /**
+   *
+   * @param {string[]} rssFeedUrls
+   * @param {import('../models/LibraryFolder')} folder
+   * @param {boolean} autoDownloadEpisodes
+   * @param {import('../managers/CronManager')} cronManager
+   */
+  async createPodcastsFromFeedUrls(rssFeedUrls, folder, autoDownloadEpisodes, cronManager) {
+    const task = TaskManager.createAndAddTask('opml-import', 'OPML import', `Creating podcasts from ${rssFeedUrls.length} RSS feeds`, true, null)
+    let numPodcastsAdded = 0
+    Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Importing ${rssFeedUrls.length} RSS feeds to folder "${folder.path}"`)
+    for (const feedUrl of rssFeedUrls) {
+      const feed = await getPodcastFeed(feedUrl).catch(() => null)
+      if (!feed?.episodes) {
+        TaskManager.createAndEmitFailedTask('opml-import-feed', 'OPML import feed', `Importing RSS feed "${feedUrl}"`, 'Failed to get podcast feed')
+        Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to get podcast feed for "${feedUrl}"`)
+        continue
+      }
+
+      const podcastFilename = sanitizeFilename(feed.metadata.title)
+      const podcastPath = filePathToPOSIX(`${folder.path}/${podcastFilename}`)
+      // Check if a library item with this podcast folder exists already
+      const existingLibraryItem =
+        (await Database.libraryItemModel.count({
+          where: {
+            path: podcastPath
+          }
+        })) > 0
+      if (existingLibraryItem) {
+        Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Podcast already exists at path "${podcastPath}"`)
+        TaskManager.createAndEmitFailedTask('opml-import-feed', 'OPML import feed', `Creating podcast "${feed.metadata.title}"`, 'Podcast already exists at path')
+        continue
+      }
+
+      const successCreatingPath = await fs
+        .ensureDir(podcastPath)
+        .then(() => true)
+        .catch((error) => {
+          Logger.error(`[PodcastManager] Failed to ensure podcast dir "${podcastPath}"`, error)
+          return false
+        })
+      if (!successCreatingPath) {
+        Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast folder at "${podcastPath}"`)
+        TaskManager.createAndEmitFailedTask('opml-import-feed', 'OPML import feed', `Creating podcast "${feed.metadata.title}"`, 'Failed to create podcast folder')
+        continue
+      }
+
+      const newPodcastMetadata = {
+        title: feed.metadata.title,
+        author: feed.metadata.author,
+        description: feed.metadata.description,
+        releaseDate: '',
+        genres: [...feed.metadata.categories],
+        feedUrl: feed.metadata.feedUrl,
+        imageUrl: feed.metadata.image,
+        itunesPageUrl: '',
+        itunesId: '',
+        itunesArtistId: '',
+        language: '',
+        numEpisodes: feed.numEpisodes
+      }
+
+      const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
+      const libraryItemPayload = {
+        path: podcastPath,
+        relPath: podcastFilename,
+        folderId: folder.id,
+        libraryId: folder.libraryId,
+        ino: libraryItemFolderStats.ino,
+        mtimeMs: libraryItemFolderStats.mtimeMs || 0,
+        ctimeMs: libraryItemFolderStats.ctimeMs || 0,
+        birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
+        media: {
+          metadata: newPodcastMetadata,
+          autoDownloadEpisodes
+        }
+      }
+
+      const libraryItem = new LibraryItem()
+      libraryItem.setData('podcast', libraryItemPayload)
+
+      // Download and save cover image
+      if (newPodcastMetadata.imageUrl) {
+        // TODO: Scan cover image to library files
+        // Podcast cover will always go into library item folder
+        const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, newPodcastMetadata.imageUrl, true)
+        if (coverResponse) {
+          if (coverResponse.error) {
+            Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Download cover error from "${newPodcastMetadata.imageUrl}": ${coverResponse.error}`)
+          } else if (coverResponse.cover) {
+            libraryItem.media.coverPath = coverResponse.cover
+          }
+        }
+      }
+
+      await Database.createLibraryItem(libraryItem)
+      SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
+
+      // Turn on podcast auto download cron if not already on
+      if (libraryItem.media.autoDownloadEpisodes) {
+        cronManager.checkUpdatePodcastCron(libraryItem)
+      }
+
+      numPodcastsAdded++
+    }
+    task.setFinished(`Added ${numPodcastsAdded} podcasts`)
+    TaskManager.taskFinished(task)
+    Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`)
+  }
 }
 module.exports = PodcastManager
diff --git a/server/managers/TaskManager.js b/server/managers/TaskManager.js
index 31cf06a1..1a8b6c85 100644
--- a/server/managers/TaskManager.js
+++ b/server/managers/TaskManager.js
@@ -9,8 +9,8 @@ class TaskManager {
 
   /**
    * Add task and emit socket task_started event
-   * 
-   * @param {Task} task 
+   *
+   * @param {Task} task
    */
   addTask(task) {
     this.tasks.push(task)
@@ -19,24 +19,24 @@ class TaskManager {
 
   /**
    * Remove task and emit task_finished event
-   * 
-   * @param {Task} task 
+   *
+   * @param {Task} task
    */
   taskFinished(task) {
-    if (this.tasks.some(t => t.id === task.id)) {
-      this.tasks = this.tasks.filter(t => t.id !== task.id)
+    if (this.tasks.some((t) => t.id === task.id)) {
+      this.tasks = this.tasks.filter((t) => t.id !== task.id)
       SocketAuthority.emitter('task_finished', task.toJSON())
     }
   }
 
   /**
    * Create new task and add
-   * 
-   * @param {string} action 
-   * @param {string} title 
-   * @param {string} description 
-   * @param {boolean} showSuccess 
-   * @param {Object} [data] 
+   *
+   * @param {string} action
+   * @param {string} title
+   * @param {string} description
+   * @param {boolean} showSuccess
+   * @param {Object} [data]
    */
   createAndAddTask(action, title, description, showSuccess, data = {}) {
     const task = new Task()
@@ -44,5 +44,21 @@ class TaskManager {
     this.addTask(task)
     return task
   }
+
+  /**
+   * Create new failed task and add
+   *
+   * @param {string} action
+   * @param {string} title
+   * @param {string} description
+   * @param {string} errorMessage
+   */
+  createAndEmitFailedTask(action, title, description, errorMessage) {
+    const task = new Task()
+    task.setData(action, title, description, false)
+    task.setFailed(errorMessage)
+    SocketAuthority.emitter('task_started', task.toJSON())
+    return task
+  }
 }
-module.exports = new TaskManager()
\ No newline at end of file
+module.exports = new TaskManager()
diff --git a/server/models/Library.js b/server/models/Library.js
index 103d14b6..61706350 100644
--- a/server/models/Library.js
+++ b/server/models/Library.js
@@ -60,7 +60,7 @@ class Library extends Model {
   /**
    * Convert expanded Library to oldLibrary
    * @param {Library} libraryExpanded
-   * @returns {Promise<oldLibrary>}
+   * @returns {oldLibrary}
    */
   static getOldLibrary(libraryExpanded) {
     const folders = libraryExpanded.libraryFolders.map((folder) => {
diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js
index 6ade11a9..6d070dcc 100644
--- a/server/objects/settings/ServerSettings.js
+++ b/server/objects/settings/ServerSettings.js
@@ -102,7 +102,7 @@ class ServerSettings {
     this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups')
     this.backupSchedule = settings.backupSchedule || false
     this.backupsToKeep = settings.backupsToKeep || 2
-    this.maxBackupSize = settings.maxBackupSize || 1
+    this.maxBackupSize = settings.maxBackupSize === 0 ? 0 : settings.maxBackupSize || 1
 
     this.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7
     this.loggerScannerLogsToKeep = settings.loggerScannerLogsToKeep || 2
diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js
index 52c81d02..b66df030 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -45,6 +45,7 @@ class ApiRouter {
     this.backupManager = Server.backupManager
     /** @type {import('../Watcher')} */
     this.watcher = Server.watcher
+    /** @type {import('../managers/PodcastManager')} */
     this.podcastManager = Server.podcastManager
     this.audioMetadataManager = Server.audioMetadataManager
     this.rssFeedManager = Server.rssFeedManager
@@ -239,7 +240,8 @@ 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.getFeedsFromOPMLText.bind(this))
+    this.router.post('/podcasts/opml/parse', PodcastController.getFeedsFromOPMLText.bind(this))
+    this.router.post('/podcasts/opml/create', PodcastController.bulkCreatePodcastsFromOpmlFeedUrls.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/scanner/NfoFileScanner.js b/server/scanner/NfoFileScanner.js
index e450b5c3..7d5b90d6 100644
--- a/server/scanner/NfoFileScanner.js
+++ b/server/scanner/NfoFileScanner.js
@@ -2,24 +2,26 @@ const { parseNfoMetadata } = require('../utils/parsers/parseNfoMetadata')
 const { readTextFile } = require('../utils/fileUtils')
 
 class NfoFileScanner {
-  constructor() { }
+  constructor() {}
 
   /**
    * Parse metadata from .nfo file found in library scan and update bookMetadata
-   * 
-   * @param {import('../models/LibraryItem').LibraryFileObject} nfoLibraryFileObj 
-   * @param {Object} bookMetadata 
+   *
+   * @param {import('../models/LibraryItem').LibraryFileObject} nfoLibraryFileObj
+   * @param {Object} bookMetadata
    */
   async scanBookNfoFile(nfoLibraryFileObj, bookMetadata) {
     const nfoText = await readTextFile(nfoLibraryFileObj.metadata.path)
     const nfoMetadata = nfoText ? await parseNfoMetadata(nfoText) : null
     if (nfoMetadata) {
       for (const key in nfoMetadata) {
-        if (key === 'tags') { // Add tags only if tags are empty
+        if (key === 'tags') {
+          // Add tags only if tags are empty
           if (nfoMetadata.tags.length) {
             bookMetadata.tags = nfoMetadata.tags
           }
-        } else if (key === 'genres') { // Add genres only if genres are empty
+        } else if (key === 'genres') {
+          // Add genres only if genres are empty
           if (nfoMetadata.genres.length) {
             bookMetadata.genres = nfoMetadata.genres
           }
@@ -33,10 +35,12 @@ class NfoFileScanner {
           }
         } else if (key === 'series') {
           if (nfoMetadata.series) {
-            bookMetadata.series = [{
-              name: nfoMetadata.series,
-              sequence: nfoMetadata.sequence || null
-            }]
+            bookMetadata.series = [
+              {
+                name: nfoMetadata.series,
+                sequence: nfoMetadata.sequence || null
+              }
+            ]
           }
         } else if (nfoMetadata[key] && key !== 'sequence') {
           bookMetadata[key] = nfoMetadata[key]
@@ -45,4 +49,4 @@ class NfoFileScanner {
     }
   }
 }
-module.exports = new NfoFileScanner()
\ No newline at end of file
+module.exports = new NfoFileScanner()
diff --git a/server/utils/parsers/parseNfoMetadata.js b/server/utils/parsers/parseNfoMetadata.js
index 56e9400a..6682a007 100644
--- a/server/utils/parsers/parseNfoMetadata.js
+++ b/server/utils/parsers/parseNfoMetadata.js
@@ -81,6 +81,10 @@ function parseNfoMetadata(nfoText) {
         case 'isbn-13':
           metadata.isbn = value
           break
+        case 'language':
+        case 'lang':
+          metadata.language = value
+          break
       }
     }
   })
diff --git a/server/utils/parsers/parseOPML.js b/server/utils/parsers/parseOPML.js
index b109a4e9..a82ec33e 100644
--- a/server/utils/parsers/parseOPML.js
+++ b/server/utils/parsers/parseOPML.js
@@ -1,17 +1,21 @@
 const h = require('htmlparser2')
 const Logger = require('../../Logger')
 
+/**
+ *
+ * @param {string} opmlText
+ * @returns {Array<{title: string, feedUrl: string}>
+ */
 function parse(opmlText) {
   var feeds = []
   var parser = new h.Parser({
     onopentag: (name, attribs) => {
-      if (name === "outline" && attribs.type === 'rss') {
+      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 || '',
+            title: attribs.title || attribs.text || '',
             feedUrl: attribs.xmlurl
           })
         }
@@ -21,4 +25,4 @@ function parse(opmlText) {
   parser.write(opmlText)
   return feeds
 }
-module.exports.parse = parse
\ No newline at end of file
+module.exports.parse = parse
diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js
index bfe540ed..92679903 100644
--- a/server/utils/podcastUtils.js
+++ b/server/utils/podcastUtils.js
@@ -289,7 +289,6 @@ module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
   const matches = []
   feed.episodes.forEach((ep) => {
     if (!ep.title) return
-
     const epTitle = ep.title.toLowerCase().trim()
     if (epTitle === searchTitle) {
       matches.push({
diff --git a/test/server/utils/parsers/parseNfoMetadata.test.js b/test/server/utils/parsers/parseNfoMetadata.test.js
index 70e6a096..9ff51fbe 100644
--- a/test/server/utils/parsers/parseNfoMetadata.test.js
+++ b/test/server/utils/parsers/parseNfoMetadata.test.js
@@ -103,6 +103,16 @@ describe('parseNfoMetadata', () => {
     expect(result.asin).to.equal('B08X5JZJLH')
   })
 
+  it('parses language', () => {
+    const nfoText = 'Language: eng'
+    const result = parseNfoMetadata(nfoText)
+    expect(result.language).to.equal('eng')
+
+    const nfoText2 = 'lang: deu'
+    const result2 = parseNfoMetadata(nfoText2)
+    expect(result2.language).to.equal('deu')
+  })
+
   it('parses description', () => {
     const nfoText = 'Book Description\n=========\nThis is a book.\n It\'s good'
     const result = parseNfoMetadata(nfoText)