Merge branch 'advplyr:master' into ffmpeg-progress

This commit is contained in:
mikiher 2024-07-20 12:28:47 +03:00 committed by GitHub
commit 7faf42d892
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1146 additions and 541 deletions

View File

@ -167,8 +167,19 @@ export default {
this.loaded = true this.loaded = true
}, },
async fetchCategories() { 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 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) => { .then((data) => {
return data return data
}) })

View File

@ -114,9 +114,9 @@ export default {
if (this.currentLibraryId) { if (this.currentLibraryId) {
configRoutes.push({ configRoutes.push({
id: 'config-library-stats', id: 'library-stats',
title: this.$strings.HeaderLibraryStats, title: this.$strings.HeaderLibraryStats,
path: '/config/library-stats' path: `/library/${this.currentLibraryId}/stats`
}) })
configRoutes.push({ configRoutes.push({
id: 'config-stats', id: 'config-stats',
@ -182,4 +182,4 @@ export default {
} }
} }
} }
</script> </script>

View File

@ -35,11 +35,13 @@
<player-ui <player-ui
ref="audioPlayer" ref="audioPlayer"
:chapters="chapters" :chapters="chapters"
:current-chapter="currentChapter"
:paused="!isPlaying" :paused="!isPlaying"
:loading="playerLoading" :loading="playerLoading"
:bookmarks="bookmarks" :bookmarks="bookmarks"
:sleep-timer-set="sleepTimerSet" :sleep-timer-set="sleepTimerSet"
:sleep-timer-remaining="sleepTimerRemaining" :sleep-timer-remaining="sleepTimerRemaining"
:sleep-timer-type="sleepTimerType"
:is-podcast="isPodcast" :is-podcast="isPodcast"
@playPause="playPause" @playPause="playPause"
@jumpForward="jumpForward" @jumpForward="jumpForward"
@ -51,13 +53,16 @@
@showBookmarks="showBookmarks" @showBookmarks="showBookmarks"
@showSleepTimer="showSleepTimerModal = true" @showSleepTimer="showSleepTimerModal = true"
@showPlayerQueueItems="showPlayerQueueItemsModal = 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-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-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div> </div>
</template> </template>
@ -76,9 +81,10 @@ export default {
currentTime: 0, currentTime: 0,
showSleepTimerModal: false, showSleepTimerModal: false,
showPlayerQueueItemsModal: false, showPlayerQueueItemsModal: false,
showPlayerSettingsModal: false,
sleepTimerSet: false, sleepTimerSet: false,
sleepTimerTime: 0,
sleepTimerRemaining: 0, sleepTimerRemaining: 0,
sleepTimerType: null,
sleepTimer: null, sleepTimer: null,
displayTitle: null, displayTitle: null,
currentPlaybackRate: 1, currentPlaybackRate: 1,
@ -145,6 +151,9 @@ export default {
if (this.streamEpisode) return this.streamEpisode.chapters || [] if (this.streamEpisode) return this.streamEpisode.chapters || []
return this.media.chapters || [] return this.media.chapters || []
}, },
currentChapter() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
},
title() { title() {
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
return this.mediaMetadata.title || 'No Title' return this.mediaMetadata.title || 'No Title'
@ -204,14 +213,18 @@ export default {
this.$store.commit('setIsPlaying', isPlaying) this.$store.commit('setIsPlaying', isPlaying)
this.updateMediaSessionPlaybackState() this.updateMediaSessionPlaybackState()
}, },
setSleepTimer(seconds) { setSleepTimer(time) {
this.sleepTimerSet = true this.sleepTimerSet = true
this.sleepTimerTime = seconds
this.sleepTimerRemaining = seconds
this.runSleepTimer()
this.showSleepTimerModal = false 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() var lastTick = Date.now()
clearInterval(this.sleepTimer) clearInterval(this.sleepTimer)
this.sleepTimer = setInterval(() => { this.sleepTimer = setInterval(() => {
@ -220,12 +233,23 @@ export default {
this.sleepTimerRemaining -= elapsed / 1000 this.sleepTimerRemaining -= elapsed / 1000
if (this.sleepTimerRemaining <= 0) { if (this.sleepTimerRemaining <= 0) {
this.clearSleepTimer() this.sleepTimerEnd()
this.playerHandler.pause()
this.$toast.info('Sleep Timer Done.. zZzzZz')
} }
}, 1000) }, 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() { cancelSleepTimer() {
this.showSleepTimerModal = false this.showSleepTimerModal = false
this.clearSleepTimer() this.clearSleepTimer()
@ -235,6 +259,7 @@ export default {
this.sleepTimerRemaining = 0 this.sleepTimerRemaining = 0
this.sleepTimer = null this.sleepTimer = null
this.sleepTimerSet = false this.sleepTimerSet = false
this.sleepTimerType = null
}, },
incrementSleepTimer(amount) { incrementSleepTimer(amount) {
if (!this.sleepTimerSet) return if (!this.sleepTimerSet) return
@ -275,6 +300,10 @@ export default {
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setCurrentTime(time) this.$refs.audioPlayer.setCurrentTime(time)
} }
if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER && this.sleepTimerSet) {
this.checkChapterEnd(time)
}
}, },
setDuration(duration) { setDuration(duration) {
this.totalDuration = duration this.totalDuration = duration

View File

@ -79,6 +79,14 @@
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </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'"> <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> <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" /> <div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </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> <span class="material-symbols text-2xl">warning</span>
<p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p> <p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
@ -194,6 +202,9 @@ export default {
isPlaylistsPage() { isPlaylistsPage() {
return this.paramId === 'playlists' return this.paramId === 'playlists'
}, },
isStatsPage() {
return this.$route.name === 'library-library-stats'
},
libraryBookshelfPage() { libraryBookshelfPage() {
return this.$route.name === 'library-library-bookshelf-id' return this.$route.name === 'library-library-bookshelf-id'
}, },

View File

@ -81,16 +81,16 @@ export default {
return this.store.getters['user/getSizeMultiplier'] return this.store.getters['user/getSizeMultiplier']
}, },
seriesId() { seriesId() {
return this.series ? this.series.id : '' return this.series?.id || ''
}, },
title() { title() {
return this.series ? this.series.name : '' return this.series?.name || ''
}, },
nameIgnorePrefix() { nameIgnorePrefix() {
return this.series ? this.series.nameIgnorePrefix : '' return this.series?.nameIgnorePrefix || ''
}, },
displayTitle() { displayTitle() {
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title || '\u00A0'
return this.title || '\u00A0' return this.title || '\u00A0'
}, },
displaySortLine() { displaySortLine() {
@ -110,13 +110,13 @@ export default {
} }
}, },
books() { books() {
return this.series ? this.series.books || [] : [] return this.series?.books || []
}, },
addedAt() { addedAt() {
return this.series ? this.series.addedAt : 0 return this.series?.addedAt || 0
}, },
totalDuration() { totalDuration() {
return this.series ? this.series.totalDuration : 0 return this.series?.totalDuration || 0
}, },
seriesBookProgress() { seriesBookProgress() {
return this.books return this.books
@ -161,7 +161,7 @@ export default {
return this.bookshelfView == constants.BookshelfView.DETAIL return this.bookshelfView == constants.BookshelfView.DETAIL
}, },
rssFeed() { rssFeed() {
return this.series ? this.series.rssFeed : null return this.series?.rssFeed
} }
}, },
methods: { methods: {

View File

@ -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>

View File

@ -6,34 +6,36 @@
</div> </div>
</template> </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 ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="!timerSet" class="w-full"> <div class="w-full">
<template v-for="time in sleepTimes"> <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)"> <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-xl text-center">{{ time.text }}</p> <p class="text-lg text-center">{{ time.text }}</p>
</div> </div>
</template> </template>
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime"> <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> <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> </form>
</div> </div>
<div v-else class="w-full p-4"> <div v-if="timerSet" class="w-full p-4">
<div class="mb-4 flex items-center justify-center"> <div class="mb-4 h-px w-full bg-white/10" />
<ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center mr-4" @click="decrement(30 * 60)">
<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="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-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="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> </ui-btn>
</div> </div>
<ui-btn class="w-full" @click="$emit('cancel')">{{ $strings.ButtonCancel }}</ui-btn> <ui-btn class="w-full" @click="$emit('cancel')">{{ $strings.ButtonCancel }}</ui-btn>
@ -47,52 +49,13 @@ export default {
props: { props: {
value: Boolean, value: Boolean,
timerSet: Boolean, timerSet: Boolean,
timerTime: Number, timerType: String,
remaining: Number remaining: Number,
hasChapters: Boolean
}, },
data() { data() {
return { return {
customTime: null, 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) {
}
} }
}, },
computed: { computed: {
@ -103,6 +66,54 @@ export default {
set(val) { set(val) {
this.$emit('input', 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: { methods: {
@ -113,10 +124,14 @@ export default {
} }
const timeInSeconds = Math.round(Number(this.customTime) * 60) 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) { setTime(time) {
this.$emit('set', seconds) this.$emit('set', time)
}, },
increment(amount) { increment(amount) {
this.$emit('increment', amount) this.$emit('increment', amount)
@ -130,4 +145,4 @@ export default {
} }
} }
} }
</script> </script>

View File

@ -16,11 +16,18 @@
</div> </div>
</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"> <div class="w-full overflow-y-auto" style="max-height: 50vh">
<template v-for="(feed, index) in feedMetadata"> <template v-for="(feed, index) in feeds">
<cards-podcast-feed-summary-card :key="index" :feed="feed" :library-folder-path="selectedFolderPath" class="my-1" /> <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> </template>
</div> </div>
</div> </div>
@ -45,9 +52,7 @@ export default {
return { return {
processing: false, processing: false,
selectedFolderId: null, selectedFolderId: null,
fullPath: null, autoDownloadEpisodes: false
autoDownloadEpisodes: false,
feedMetadata: []
} }
}, },
watch: { watch: {
@ -96,73 +101,36 @@ export default {
} }
}, },
methods: { 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() { init() {
this.feedMetadata = this.feeds.map(this.toFeedMetadata)
if (this.folderItems[0]) { if (this.folderItems[0]) {
this.selectedFolderId = this.folderItems[0].value this.selectedFolderId = this.folderItems[0].value
} }
}, },
async submit() { async submit() {
this.processing = true 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) { const payload = {
await this.$axios feeds: this.feeds.map((f) => f.feedUrl),
.$post('/api/podcasts', podcastPayload) folderId: this.selectedFolderId,
.then(() => { libraryId: this.currentLibrary.id,
this.$toast.success(`${podcastPayload.media.metadata.title}: ${this.$strings.ToastPodcastCreateSuccess}`) autoDownloadEpisodes: this.autoDownloadEpisodes
})
.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}`)
})
} }
this.processing = false this.$axios
this.show = false .$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() {} mounted() {}
} }
</script> </script>
<style scoped>
#podcast-wrapper {
min-height: 400px;
max-height: 80vh;
}
#episodes-scroll {
max-height: calc(80vh - 200px);
}
</style>

View File

@ -132,7 +132,7 @@ export default {
this.searchedTitle = this.episodeTitle this.searchedTitle = this.episodeTitle
this.isProcessing = true this.isProcessing = true
this.$axios 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) => { .then((results) => {
this.episodesFound = results.episodes.map((ep) => ep.episode) this.episodesFound = results.episodes.map((ep) => ep.episode)
console.log('Episodes found', this.episodesFound) console.log('Episodes found', this.episodesFound)
@ -153,4 +153,4 @@ export default {
}, },
mounted() {} mounted() {}
} }
</script> </script>

View File

@ -7,17 +7,17 @@
<span class="material-symbols text-2xl sm:text-3xl">first_page</span> <span class="material-symbols text-2xl sm:text-3xl">first_page</span>
</button> </button>
</ui-tooltip> </ui-tooltip>
<ui-tooltip direction="top" :text="$strings.ButtonJumpBackward"> <ui-tooltip direction="top" :text="jumpBackwardText">
<button :aria-label="$strings.ButtonJumpBackward" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward"> <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_10</span> <span class="material-symbols text-2xl sm:text-3xl">replay</span>
</button> </button>
</ui-tooltip> </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"> <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> <span class="material-symbols fill text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</button> </button>
<ui-tooltip direction="top" :text="$strings.ButtonJumpForward"> <ui-tooltip direction="top" :text="jumpForwardText">
<button :aria-label="$strings.ButtonJumpForward" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward"> <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_10</span> <span class="material-symbols text-2xl sm:text-3xl">forward_media</span>
</button> </button>
</ui-tooltip> </ui-tooltip>
<ui-tooltip direction="top" :text="$strings.ButtonNextChapter" class="ml-4 lg:ml-8"> <ui-tooltip direction="top" :text="$strings.ButtonNextChapter" class="ml-4 lg:ml-8">
@ -29,7 +29,7 @@
</template> </template>
<template v-else> <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"> <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> </div>
</template> </template>
<div class="flex-grow" /> <div class="flex-grow" />
@ -56,6 +56,12 @@ export default {
set(val) { set(val) {
this.$emit('update:playbackRate', val) this.$emit('update:playbackRate', val)
} }
},
jumpForwardText() {
return this.getJumpText('jumpForwardAmount', this.$strings.ButtonJumpForward)
},
jumpBackwardText() {
return this.getJumpText('jumpBackwardAmount', this.$strings.ButtonJumpBackward)
} }
}, },
methods: { methods: {
@ -83,8 +89,22 @@ export default {
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => { this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', 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() {} mounted() {}
} }
</script> </script>

View File

@ -13,7 +13,7 @@
<span v-if="!sleepTimerSet" class="material-symbols text-2xl">snooze</span> <span v-if="!sleepTimerSet" class="material-symbols text-2xl">snooze</span>
<div v-else class="flex items-center"> <div v-else class="flex items-center">
<span class="material-symbols text-lg text-warning">snooze</span> <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> </div>
</button> </button>
</ui-tooltip> </ui-tooltip>
@ -36,9 +36,9 @@
</button> </button>
</ui-tooltip> </ui-tooltip>
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack"> <ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
<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"> <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-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span> <span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
</button> </button>
</ui-tooltip> </ui-tooltip>
</div> </div>
@ -72,12 +72,14 @@ export default {
type: Array, type: Array,
default: () => [] default: () => []
}, },
currentChapter: Object,
bookmarks: { bookmarks: {
type: Array, type: Array,
default: () => [] default: () => []
}, },
sleepTimerSet: Boolean, sleepTimerSet: Boolean,
sleepTimerRemaining: Number, sleepTimerRemaining: Number,
sleepTimerType: String,
isPodcast: Boolean, isPodcast: Boolean,
hideBookmarks: Boolean, hideBookmarks: Boolean,
hideSleepTimer: Boolean hideSleepTimer: Boolean
@ -90,27 +92,34 @@ export default {
seekLoading: false, seekLoading: false,
showChaptersModal: false, showChaptersModal: false,
currentTime: 0, currentTime: 0,
duration: 0, duration: 0
useChapterTrack: false
} }
}, },
watch: { watch: {
playbackRate() { playbackRate() {
this.updateTimestamp() this.updateTimestamp()
},
useChapterTrack() {
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.updateTimestamp()
} }
}, },
computed: { computed: {
sleepTimerRemainingString() { sleepTimerRemainingString() {
var rounded = Math.round(this.sleepTimerRemaining) if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER) {
if (rounded < 90) { return 'EoC'
return `${rounded}s` } 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() { token() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
@ -135,9 +144,6 @@ export default {
if (!duration) return 0 if (!duration) return 0
return Math.round((100 * time) / duration) return Math.round((100 * time) / duration)
}, },
currentChapter() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
},
currentChapterName() { currentChapterName() {
return this.currentChapter ? this.currentChapter.title : '' return this.currentChapter ? this.currentChapter.title : ''
}, },
@ -162,6 +168,10 @@ export default {
}, },
playerQueueItems() { playerQueueItems() {
return this.$store.state.playerQueueItems || [] return this.$store.state.playerQueueItems || []
},
useChapterTrack() {
const _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
return this.chapters.length ? _useChapterTrack : false
} }
}, },
methods: { methods: {
@ -310,9 +320,6 @@ export default {
init() { init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 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) if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.setPlaybackRate(this.playbackRate) this.setPlaybackRate(this.playbackRate)
}, },

View File

@ -2,7 +2,7 @@
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj"> <div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing"> <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"> <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> </button>
<div v-else class="h-full w-full flex items-center justify-center"> <div v-else class="h-full w-full flex items-center justify-center">
<widgets-loading-spinner /> <widgets-loading-spinner />
@ -116,4 +116,4 @@ export default {
}, },
mounted() {} mounted() {}
} }
</script> </script>

View File

@ -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>

View File

@ -52,7 +52,6 @@ export default {
else if (pageName === 'notifications') return this.$strings.HeaderNotifications else if (pageName === 'notifications') return this.$strings.HeaderNotifications
else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions
else if (pageName === 'stats') return this.$strings.HeaderYourStats 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 === 'users') return this.$strings.HeaderUsers
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
@ -94,4 +93,4 @@ export default {
max-width: 100%; max-width: 100%;
} }
} }
</style> </style>

View File

@ -170,7 +170,7 @@ export default {
}) })
}, },
updateBackupsSettings() { updateBackupsSettings() {
if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) { if (isNaN(this.maxBackupSize) || this.maxBackupSize < 0) {
this.$toast.error('Invalid maximum backup size') this.$toast.error('Invalid maximum backup size')
return return
} }
@ -200,10 +200,9 @@ export default {
}, },
initServerSettings() { initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {} this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2 this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
this.enableBackups = !!this.newServerSettings.backupSchedule 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 * * *' this.cronExpression = this.newServerSettings.backupSchedule || '30 1 * * *'
} }
}, },

View File

@ -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>

View File

@ -121,7 +121,7 @@
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction"> <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
<template #default="{ showMenu, clickShowMenu, disabled }"> <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"> <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> </button>
</template> </template>
</ui-context-menu-dropdown> </ui-context-menu-dropdown>

View File

@ -113,18 +113,23 @@ export default {
return return
} }
await this.$axios this.$axios
.$post(`/api/podcasts/opml`, { opmlText: txt }) .$post(`/api/podcasts/opml/parse`, { opmlText: txt })
.then((data) => { .then((data) => {
console.log(data) if (!data.feeds?.length) {
this.opmlFeeds = data.feeds || [] this.$toast.error('No feeds found in OPML file')
this.showOPMLFeedsModal = true } else {
this.opmlFeeds = data.feeds || []
this.showOPMLFeedsModal = true
}
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
this.$toast.error('Failed to parse OPML file') this.$toast.error('Failed to parse OPML file')
}) })
this.processing = false .finally(() => {
this.processing = false
})
}, },
submit() { submit() {
if (!this.searchInput) return if (!this.searchInput) return

View File

@ -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>

View File

@ -36,10 +36,10 @@ export default class PlayerHandler {
return this.libraryItem ? this.libraryItem.id : null return this.libraryItem ? this.libraryItem.id : null
} }
get isPlayingCastedItem() { get isPlayingCastedItem() {
return this.libraryItem && (this.player instanceof CastPlayer) return this.libraryItem && this.player instanceof CastPlayer
} }
get isPlayingLocalItem() { get isPlayingLocalItem() {
return this.libraryItem && (this.player instanceof LocalAudioPlayer) return this.libraryItem && this.player instanceof LocalAudioPlayer
} }
get userToken() { get userToken() {
return this.ctx.$store.getters['user/getToken'] return this.ctx.$store.getters['user/getToken']
@ -49,7 +49,13 @@ export default class PlayerHandler {
} }
get episode() { get episode() {
if (!this.episodeId) return null 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) { setSessionId(sessionId) {
@ -66,7 +72,7 @@ export default class PlayerHandler {
this.playWhenReady = playWhenReady this.playWhenReady = playWhenReady
this.initialPlaybackRate = this.isMusic ? 1 : playbackRate 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) if (!this.player) this.switchPlayer(playWhenReady)
else this.prepare() else this.prepare()
@ -127,7 +133,7 @@ export default class PlayerHandler {
playerError() { playerError() {
// Switch to HLS stream on error // 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`) console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
this.prepare(true) this.prepare(true)
} }
@ -207,7 +213,8 @@ export default class PlayerHandler {
this.prepareSession(session) 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 if (!this.player) this.switchPlayer() // Must set player first for open sessions
this.libraryItem = session.libraryItem this.libraryItem = session.libraryItem
@ -241,7 +248,7 @@ export default class PlayerHandler {
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady) this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
} else { } 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.ctx.playerLoading = true
this.isHlsTranscode = true this.isHlsTranscode = true
@ -295,7 +302,7 @@ export default class PlayerHandler {
const currentTime = this.player.getCurrentTime() const currentTime = this.player.getCurrentTime()
this.ctx.setCurrentTime(currentTime) this.ctx.setCurrentTime(currentTime)
const exactTimeElapsed = ((Date.now() - lastTick) / 1000) const exactTimeElapsed = (Date.now() - lastTick) / 1000
lastTick = Date.now() lastTick = Date.now()
this.listeningTimeSinceSync += exactTimeElapsed this.listeningTimeSinceSync += exactTimeElapsed
const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 10 : 20 const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 10 : 20
@ -320,7 +327,7 @@ export default class PlayerHandler {
} }
this.listeningTimeSinceSync = 0 this.listeningTimeSinceSync = 0
this.lastSyncTime = 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) console.error('Failed to close session', error)
}) })
} }
@ -340,17 +347,20 @@ export default class PlayerHandler {
} }
this.listeningTimeSinceSync = 0 this.listeningTimeSinceSync = 0
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000 }).then(() => { this.ctx.$axios
this.failedProgressSyncs = 0 .$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000, progress: false })
}).catch((error) => { .then(() => {
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 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() { stopPlayInterval() {
@ -381,13 +391,15 @@ export default class PlayerHandler {
jumpBackward() { jumpBackward() {
if (!this.player) return if (!this.player) return
var currentTime = this.getCurrentTime() var currentTime = this.getCurrentTime()
this.seek(Math.max(0, currentTime - 10)) const jumpAmount = this.jumpBackwardAmount
this.seek(Math.max(0, currentTime - jumpAmount))
} }
jumpForward() { jumpForward() {
if (!this.player) return if (!this.player) return
var currentTime = this.getCurrentTime() 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) { setVolume(volume) {
@ -411,4 +423,4 @@ export default class PlayerHandler {
this.sendProgressSync(time) this.sendProgressSync(time)
} }
} }
} }

View File

@ -32,12 +32,18 @@ const PlayMethod = {
LOCAL: 3 LOCAL: 3
} }
const SleepTimerTypes = {
COUNTDOWN: 'countdown',
CHAPTER: 'chapter'
}
const Constants = { const Constants = {
SupportedFileTypes, SupportedFileTypes,
DownloadStatus, DownloadStatus,
BookCoverAspectRatio, BookCoverAspectRatio,
BookshelfView, BookshelfView,
PlayMethod PlayMethod,
SleepTimerTypes
} }
const KeyNames = { const KeyNames = {

View File

@ -6,7 +6,6 @@ import * as locale from 'date-fns/locale'
Vue.directive('click-outside', vClickOutside.directive) Vue.directive('click-outside', vClickOutside.directive)
Vue.prototype.$setDateFnsLocale = (localeString) => { Vue.prototype.$setDateFnsLocale = (localeString) => {
if (!locale[localeString]) return 0 if (!locale[localeString]) return 0
return setDefaultOptions({ locale: locale[localeString] }) return setDefaultOptions({ locale: locale[localeString] })
@ -112,14 +111,15 @@ Vue.prototype.$sanitizeSlug = (str) => {
str = str.toLowerCase() str = str.toLowerCase()
// remove accents, swap ñ for n, etc // remove accents, swap ñ for n, etc
var from = "àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;" var from = 'àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;'
var to = "aaaaeeeeiiiioooouuuuncescrzyuudtn-----" var to = 'aaaaeeeeiiiioooouuuuncescrzyuudtn-----'
for (var i = 0, l = from.length; i < l; i++) { 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(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(/[^a-z0-9 -_]/g, '') // remove invalid chars
.replace(/\s+/g, '-') // collapse whitespace and replace by a dash .replace(/\s+/g, '-') // collapse whitespace and replace by a dash
.replace(/-+/g, '-') // collapse dashes .replace(/-+/g, '-') // collapse dashes
@ -131,13 +131,16 @@ Vue.prototype.$sanitizeSlug = (str) => {
Vue.prototype.$copyToClipboard = (str, ctx) => { Vue.prototype.$copyToClipboard = (str, ctx) => {
return new Promise((resolve) => { return new Promise((resolve) => {
if (navigator.clipboard) { if (navigator.clipboard) {
navigator.clipboard.writeText(str).then(() => { navigator.clipboard.writeText(str).then(
if (ctx) ctx.$toast.success('Copied to clipboard') () => {
resolve(true) if (ctx) ctx.$toast.success('Copied to clipboard')
}, (err) => { resolve(true)
console.error('Clipboard copy failed', str, err) },
resolve(false) (err) => {
}) console.error('Clipboard copy failed', str, err)
resolve(false)
}
)
} else { } else {
const el = document.createElement('textarea') const el = document.createElement('textarea')
el.value = str el.value = str
@ -160,26 +163,18 @@ function xmlToJson(xml) {
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) { for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
const key = res[1] || res[3] const key = res[1] || res[3]
const value = res[2] && xmlToJson(res[2]) 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 return json
} }
Vue.prototype.$xmlToJson = xmlToJson 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')) const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
Vue.prototype.$encode = encode Vue.prototype.$encode = encode
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString() const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
Vue.prototype.$decode = decode Vue.prototype.$decode = decode
export { export { encode, decode }
encode,
decode
}
export default ({ app, store }, inject) => { export default ({ app, store }, inject) => {
app.$decode = decode app.$decode = decode
app.$encode = encode app.$encode = encode

View File

@ -14,7 +14,9 @@ export const state = () => ({
seriesSortDesc: false, seriesSortDesc: false,
seriesFilterBy: 'all', seriesFilterBy: 'all',
authorSortBy: 'name', authorSortBy: 'name',
authorSortDesc: false authorSortDesc: false,
jumpForwardAmount: 10,
jumpBackwardAmount: 10,
} }
}) })

View File

@ -59,6 +59,7 @@
"ButtonPurgeItemsCache": "Lösche Medien-Cache", "ButtonPurgeItemsCache": "Lösche Medien-Cache",
"ButtonQueueAddItem": "Zur Warteschlange hinzufügen", "ButtonQueueAddItem": "Zur Warteschlange hinzufügen",
"ButtonQueueRemoveItem": "Aus der Warteschlange entfernen", "ButtonQueueRemoveItem": "Aus der Warteschlange entfernen",
"ButtonQuickEmbedMetadata": "Schnelles Hinzufügen von Metadaten",
"ButtonQuickMatch": "Schnellabgleich", "ButtonQuickMatch": "Schnellabgleich",
"ButtonReScan": "Neu scannen", "ButtonReScan": "Neu scannen",
"ButtonRead": "Lesen", "ButtonRead": "Lesen",
@ -66,11 +67,11 @@
"ButtonReadMore": "Mehr anzeigen", "ButtonReadMore": "Mehr anzeigen",
"ButtonRefresh": "Neu Laden", "ButtonRefresh": "Neu Laden",
"ButtonRemove": "Entfernen", "ButtonRemove": "Entfernen",
"ButtonRemoveAll": "Alles löschen", "ButtonRemoveAll": "Alles entfernen",
"ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge", "ButtonRemoveAllLibraryItems": "Entferne alle Bibliothekseinträge",
"ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste", "ButtonRemoveFromContinueListening": "Entferne den Eintrag aus der Fortsetzungsliste",
"ButtonRemoveFromContinueReading": "Lösche die Serie aus der Lesefortsetzungsliste", "ButtonRemoveFromContinueReading": "Entferne die Serie aus der Lesefortsetzungsliste",
"ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste", "ButtonRemoveSeriesFromContinueSeries": "Entferne die Serie aus der Serienfortsetzungsliste",
"ButtonReset": "Zurücksetzen", "ButtonReset": "Zurücksetzen",
"ButtonResetToDefault": "Zurücksetzen auf Standard", "ButtonResetToDefault": "Zurücksetzen auf Standard",
"ButtonRestore": "Wiederherstellen", "ButtonRestore": "Wiederherstellen",
@ -88,6 +89,7 @@
"ButtonShow": "Anzeigen", "ButtonShow": "Anzeigen",
"ButtonStartM4BEncode": "M4B-Kodierung starten", "ButtonStartM4BEncode": "M4B-Kodierung starten",
"ButtonStartMetadataEmbed": "Metadateneinbettung starten", "ButtonStartMetadataEmbed": "Metadateneinbettung starten",
"ButtonStats": "Statistiken",
"ButtonSubmit": "Ok", "ButtonSubmit": "Ok",
"ButtonTest": "Test", "ButtonTest": "Test",
"ButtonUpload": "Hochladen", "ButtonUpload": "Hochladen",
@ -154,6 +156,7 @@
"HeaderPasswordAuthentication": "Passwort Authentifizierung", "HeaderPasswordAuthentication": "Passwort Authentifizierung",
"HeaderPermissions": "Berechtigungen", "HeaderPermissions": "Berechtigungen",
"HeaderPlayerQueue": "Player Warteschlange", "HeaderPlayerQueue": "Player Warteschlange",
"HeaderPlayerSettings": "Player Einstellungen",
"HeaderPlaylist": "Wiedergabeliste", "HeaderPlaylist": "Wiedergabeliste",
"HeaderPlaylistItems": "Einträge in der Wiedergabeliste", "HeaderPlaylistItems": "Einträge in der Wiedergabeliste",
"HeaderPodcastsToAdd": "Podcasts zum Hinzufügen", "HeaderPodcastsToAdd": "Podcasts zum Hinzufügen",
@ -161,8 +164,8 @@
"HeaderRSSFeedGeneral": "RSS Details", "HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet", "HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
"HeaderRSSFeeds": "RSS-Feeds", "HeaderRSSFeeds": "RSS-Feeds",
"HeaderRemoveEpisode": "Episode löschen", "HeaderRemoveEpisode": "Episode entfernen",
"HeaderRemoveEpisodes": "Lösche {0} Episoden", "HeaderRemoveEpisodes": "Entferne {0} Episoden",
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte", "HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
"HeaderSchedule": "Zeitplan", "HeaderSchedule": "Zeitplan",
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans", "HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
@ -259,7 +262,7 @@
"LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck:", "LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck:",
"LabelDatetime": "Datum & Uhrzeit", "LabelDatetime": "Datum & Uhrzeit",
"LabelDays": "Tage", "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", "LabelDescription": "Beschreibung",
"LabelDeselectAll": "Alles abwählen", "LabelDeselectAll": "Alles abwählen",
"LabelDevice": "Gerät", "LabelDevice": "Gerät",
@ -289,13 +292,16 @@
"LabelEmbeddedCover": "Eingebettetes Cover", "LabelEmbeddedCover": "Eingebettetes Cover",
"LabelEnable": "Aktivieren", "LabelEnable": "Aktivieren",
"LabelEnd": "Ende", "LabelEnd": "Ende",
"LabelEndOfChapter": "Ende des Kapitels",
"LabelEpisode": "Episode", "LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episodentitel", "LabelEpisodeTitle": "Episodentitel",
"LabelEpisodeType": "Episodentyp", "LabelEpisodeType": "Episodentyp",
"LabelExample": "Beispiel", "LabelExample": "Beispiel",
"LabelExpandSeries": "Serie erweitern",
"LabelExplicit": "Explizit (Altersbeschränkung)", "LabelExplicit": "Explizit (Altersbeschränkung)",
"LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)", "LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)",
"LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)", "LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)",
"LabelExportOPML": "OPML exportieren",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Abholen der Metadaten", "LabelFetchingMetadata": "Abholen der Metadaten",
"LabelFile": "Datei", "LabelFile": "Datei",
@ -319,6 +325,7 @@
"LabelHardDeleteFile": "Datei dauerhaft löschen", "LabelHardDeleteFile": "Datei dauerhaft löschen",
"LabelHasEbook": "E-Book verfügbar", "LabelHasEbook": "E-Book verfügbar",
"LabelHasSupplementaryEbook": "Ergänzendes E-Book verfügbar", "LabelHasSupplementaryEbook": "Ergänzendes E-Book verfügbar",
"LabelHideSubtitles": "Untertitel ausblenden",
"LabelHighestPriority": "Höchste Priorität", "LabelHighestPriority": "Höchste Priorität",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Stunde", "LabelHour": "Stunde",
@ -339,6 +346,8 @@
"LabelIntervalEveryHour": "Jede Stunde", "LabelIntervalEveryHour": "Jede Stunde",
"LabelInvert": "Umkehren", "LabelInvert": "Umkehren",
"LabelItem": "Medium", "LabelItem": "Medium",
"LabelJumpBackwardAmount": "Zurückspringen Zeit",
"LabelJumpForwardAmount": "Vorwärtsspringn Zeit",
"LabelLanguage": "Sprache", "LabelLanguage": "Sprache",
"LabelLanguageDefaultServer": "Standard-Server-Sprache", "LabelLanguageDefaultServer": "Standard-Server-Sprache",
"LabelLanguages": "Sprachen", "LabelLanguages": "Sprachen",
@ -446,6 +455,7 @@
"LabelRSSFeedPreventIndexing": "Indizierung verhindern", "LabelRSSFeedPreventIndexing": "Indizierung verhindern",
"LabelRSSFeedSlug": "RSS-Feed-Schlagwort", "LabelRSSFeedSlug": "RSS-Feed-Schlagwort",
"LabelRSSFeedURL": "RSS Feed URL", "LabelRSSFeedURL": "RSS Feed URL",
"LabelReAddSeriesToContinueListening": "Serien erneut zur Fortsetzungsliste hinzufügen",
"LabelRead": "Lesen", "LabelRead": "Lesen",
"LabelReadAgain": "Noch einmal Lesen", "LabelReadAgain": "Noch einmal Lesen",
"LabelReadEbookWithoutProgress": "E-Book lesen und Fortschritt verwerfen", "LabelReadEbookWithoutProgress": "E-Book lesen und Fortschritt verwerfen",
@ -455,7 +465,7 @@
"LabelRedo": "Wiederholen", "LabelRedo": "Wiederholen",
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Veröffentlichungsdatum", "LabelReleaseDate": "Veröffentlichungsdatum",
"LabelRemoveCover": "Lösche Titelbild", "LabelRemoveCover": "Entferne Titelbild",
"LabelRowsPerPage": "Zeilen pro Seite", "LabelRowsPerPage": "Zeilen pro Seite",
"LabelSearchTerm": "Begriff suchen", "LabelSearchTerm": "Begriff suchen",
"LabelSearchTitle": "Titel 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", "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", "LabelSettingsTimeFormat": "Zeitformat",
"LabelShare": "Teilen", "LabelShare": "Teilen",
"LabelShareOpen": "Teilen Offen", "LabelShareOpen": "Teilen öffnen",
"LabelShareURL": "URL teilen", "LabelShareURL": "URL teilen",
"LabelShowAll": "Alles anzeigen", "LabelShowAll": "Alles anzeigen",
"LabelShowSeconds": "Zeige Sekunden", "LabelShowSeconds": "Zeige Sekunden",
"LabelShowSubtitles": "Untertitel anzeigen",
"LabelSize": "Größe", "LabelSize": "Größe",
"LabelSleepTimer": "Schlummerfunktion", "LabelSleepTimer": "Schlummerfunktion",
"LabelSlug": "URL Teil", "LabelSlug": "URL Teil",
@ -553,6 +564,10 @@
"LabelThemeDark": "Dunkel", "LabelThemeDark": "Dunkel",
"LabelThemeLight": "Hell", "LabelThemeLight": "Hell",
"LabelTimeBase": "Basiszeit", "LabelTimeBase": "Basiszeit",
"LabelTimeDurationXHours": "{0} Stunden",
"LabelTimeDurationXMinutes": "{0} Minuten",
"LabelTimeDurationXSeconds": "{0} Sekunden",
"LabelTimeInMinutes": "Zeit in Minuten",
"LabelTimeListened": "Gehörte Zeit", "LabelTimeListened": "Gehörte Zeit",
"LabelTimeListenedToday": "Heute gehörte Zeit", "LabelTimeListenedToday": "Heute gehörte Zeit",
"LabelTimeRemaining": "{0} verbleibend", "LabelTimeRemaining": "{0} verbleibend",
@ -592,6 +607,7 @@
"LabelVersion": "Version", "LabelVersion": "Version",
"LabelViewBookmarks": "Lesezeichen anzeigen", "LabelViewBookmarks": "Lesezeichen anzeigen",
"LabelViewChapters": "Kapitel anzeigen", "LabelViewChapters": "Kapitel anzeigen",
"LabelViewPlayerSettings": "Zeige player Einstellungen",
"LabelViewQueue": "Player-Warteschlange anzeigen", "LabelViewQueue": "Player-Warteschlange anzeigen",
"LabelVolume": "Lautstärke", "LabelVolume": "Lautstärke",
"LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelWeekdaysToRun": "Wochentage für die Ausführung",
@ -637,11 +653,11 @@
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?", "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
"MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird gelöscht! Bist du dir sicher?", "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?",
"MessageConfirmRemoveEpisode": "Episode \"{0}\" wird geloscht! Bist du dir sicher?", "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?",
"MessageConfirmRemoveEpisodes": "{0} Episoden werden gelöscht! 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?", "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?", "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?", "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.", "MessageConfirmRenameGenreMergeNote": "Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.",
@ -712,9 +728,9 @@
"MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung", "MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
"MessagePodcastHasNoRSSFeedForMatching": "Der Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann", "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.", "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)", "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?", "MessageRemoveUserWarning": "Benutzer \"{0}\" wird dauerhaft gelöscht! Bist du dir sicher?",
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und mitwirken", "MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und mitwirken",
"MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?", "MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?",
@ -769,8 +785,8 @@
"ToastBatchUpdateSuccess": "Stapelaktualisierung erfolgreich", "ToastBatchUpdateSuccess": "Stapelaktualisierung erfolgreich",
"ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden", "ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
"ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt", "ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt",
"ToastBookmarkRemoveFailed": "Lesezeichen konnte nicht gelöscht werden", "ToastBookmarkRemoveFailed": "Lesezeichen konnte nicht entfernt werden",
"ToastBookmarkRemoveSuccess": "Lesezeichen gelöscht", "ToastBookmarkRemoveSuccess": "Lesezeichen entfernt",
"ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen", "ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen",
"ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert", "ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
"ToastCachePurgeFailed": "Cache leeren fehlgeschlagen", "ToastCachePurgeFailed": "Cache leeren fehlgeschlagen",
@ -780,7 +796,7 @@
"ToastCollectionItemsRemoveFailed": "Fehler beim Entfernen der Medien aus der Sammlung", "ToastCollectionItemsRemoveFailed": "Fehler beim Entfernen der Medien aus der Sammlung",
"ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt", "ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt",
"ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden", "ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden",
"ToastCollectionRemoveSuccess": "Sammlung gelöscht", "ToastCollectionRemoveSuccess": "Sammlung entfernt",
"ToastCollectionUpdateFailed": "Sammlung konnte nicht aktualisiert werden", "ToastCollectionUpdateFailed": "Sammlung konnte nicht aktualisiert werden",
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert", "ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
"ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden", "ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden",

View File

@ -89,6 +89,7 @@
"ButtonShow": "Show", "ButtonShow": "Show",
"ButtonStartM4BEncode": "Start M4B Encode", "ButtonStartM4BEncode": "Start M4B Encode",
"ButtonStartMetadataEmbed": "Start Metadata Embed", "ButtonStartMetadataEmbed": "Start Metadata Embed",
"ButtonStats": "Stats",
"ButtonSubmit": "Submit", "ButtonSubmit": "Submit",
"ButtonTest": "Test", "ButtonTest": "Test",
"ButtonUpload": "Upload", "ButtonUpload": "Upload",
@ -155,6 +156,7 @@
"HeaderPasswordAuthentication": "Password Authentication", "HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Permissions", "HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Player Queue", "HeaderPlayerQueue": "Player Queue",
"HeaderPlayerSettings": "Player Settings",
"HeaderPlaylist": "Playlist", "HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items", "HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasts to Add", "HeaderPodcastsToAdd": "Podcasts to Add",
@ -227,7 +229,7 @@
"LabelBackupLocation": "Backup Location", "LabelBackupLocation": "Backup Location",
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups", "LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
"LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/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.", "LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
"LabelBackupsNumberToKeep": "Number of backups to keep", "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.", "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", "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable", "LabelEnable": "Enable",
"LabelEnd": "End", "LabelEnd": "End",
"LabelEndOfChapter": "End of Chapter",
"LabelEpisode": "Episode", "LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episode Title", "LabelEpisodeTitle": "Episode Title",
"LabelEpisodeType": "Episode Type", "LabelEpisodeType": "Episode Type",
@ -343,6 +346,8 @@
"LabelIntervalEveryHour": "Every hour", "LabelIntervalEveryHour": "Every hour",
"LabelInvert": "Invert", "LabelInvert": "Invert",
"LabelItem": "Item", "LabelItem": "Item",
"LabelJumpBackwardAmount": "Jump backward amount",
"LabelJumpForwardAmount": "Jump forward amount",
"LabelLanguage": "Language", "LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language", "LabelLanguageDefaultServer": "Default Server Language",
"LabelLanguages": "Languages", "LabelLanguages": "Languages",
@ -559,6 +564,10 @@
"LabelThemeDark": "Dark", "LabelThemeDark": "Dark",
"LabelThemeLight": "Light", "LabelThemeLight": "Light",
"LabelTimeBase": "Time Base", "LabelTimeBase": "Time Base",
"LabelTimeDurationXHours": "{0} hours",
"LabelTimeDurationXMinutes": "{0} minutes",
"LabelTimeDurationXSeconds": "{0} seconds",
"LabelTimeInMinutes": "Time in minutes",
"LabelTimeListened": "Time Listened", "LabelTimeListened": "Time Listened",
"LabelTimeListenedToday": "Time Listened Today", "LabelTimeListenedToday": "Time Listened Today",
"LabelTimeRemaining": "{0} remaining", "LabelTimeRemaining": "{0} remaining",
@ -598,6 +607,7 @@
"LabelVersion": "Version", "LabelVersion": "Version",
"LabelViewBookmarks": "View bookmarks", "LabelViewBookmarks": "View bookmarks",
"LabelViewChapters": "View chapters", "LabelViewChapters": "View chapters",
"LabelViewPlayerSettings": "View player settings",
"LabelViewQueue": "View player queue", "LabelViewQueue": "View player queue",
"LabelVolume": "Volume", "LabelVolume": "Volume",
"LabelWeekdaysToRun": "Weekdays to run", "LabelWeekdaysToRun": "Weekdays to run",
@ -713,6 +723,7 @@
"MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUpdatesWereNecessary": "No updates were necessary",
"MessageNoUserPlaylists": "You have no playlists", "MessageNoUserPlaylists": "You have no playlists",
"MessageNotYetImplemented": "Not yet implemented", "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", "MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback", "MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter", "MessagePlayChapter": "Listen to beginning of chapter",

View File

@ -59,6 +59,7 @@
"ButtonPurgeItemsCache": "Purgar Elementos de Cache", "ButtonPurgeItemsCache": "Purgar Elementos de Cache",
"ButtonQueueAddItem": "Agregar a la Fila", "ButtonQueueAddItem": "Agregar a la Fila",
"ButtonQueueRemoveItem": "Remover de la Fila", "ButtonQueueRemoveItem": "Remover de la Fila",
"ButtonQuickEmbedMetadata": "Agregue metadatos rápidamente",
"ButtonQuickMatch": "Encontrar Rápido", "ButtonQuickMatch": "Encontrar Rápido",
"ButtonReScan": "Re-Escanear", "ButtonReScan": "Re-Escanear",
"ButtonRead": "Leer", "ButtonRead": "Leer",
@ -88,6 +89,7 @@
"ButtonShow": "Mostrar", "ButtonShow": "Mostrar",
"ButtonStartM4BEncode": "Iniciar Codificación M4B", "ButtonStartM4BEncode": "Iniciar Codificación M4B",
"ButtonStartMetadataEmbed": "Iniciar la Inserción de Metadata", "ButtonStartMetadataEmbed": "Iniciar la Inserción de Metadata",
"ButtonStats": "Estadísticas",
"ButtonSubmit": "Enviar", "ButtonSubmit": "Enviar",
"ButtonTest": "Prueba", "ButtonTest": "Prueba",
"ButtonUpload": "Subir", "ButtonUpload": "Subir",
@ -154,6 +156,7 @@
"HeaderPasswordAuthentication": "Autenticación por contraseña", "HeaderPasswordAuthentication": "Autenticación por contraseña",
"HeaderPermissions": "Permisos", "HeaderPermissions": "Permisos",
"HeaderPlayerQueue": "Fila del Reproductor", "HeaderPlayerQueue": "Fila del Reproductor",
"HeaderPlayerSettings": "Ajustes del reproductor",
"HeaderPlaylist": "Lista de reproducción", "HeaderPlaylist": "Lista de reproducción",
"HeaderPlaylistItems": "Elementos de lista de reproducción", "HeaderPlaylistItems": "Elementos de lista de reproducción",
"HeaderPodcastsToAdd": "Podcasts para agregar", "HeaderPodcastsToAdd": "Podcasts para agregar",
@ -289,13 +292,16 @@
"LabelEmbeddedCover": "Portada Integrada", "LabelEmbeddedCover": "Portada Integrada",
"LabelEnable": "Habilitar", "LabelEnable": "Habilitar",
"LabelEnd": "Fin", "LabelEnd": "Fin",
"LabelEndOfChapter": "Fin del capítulo",
"LabelEpisode": "Episodio", "LabelEpisode": "Episodio",
"LabelEpisodeTitle": "Titulo de Episodio", "LabelEpisodeTitle": "Titulo de Episodio",
"LabelEpisodeType": "Tipo de Episodio", "LabelEpisodeType": "Tipo de Episodio",
"LabelExample": "Ejemplo", "LabelExample": "Ejemplo",
"LabelExpandSeries": "Ampliar serie",
"LabelExplicit": "Explicito", "LabelExplicit": "Explicito",
"LabelExplicitChecked": "Explícito (marcado)", "LabelExplicitChecked": "Explícito (marcado)",
"LabelExplicitUnchecked": "No Explícito (sin marcar)", "LabelExplicitUnchecked": "No Explícito (sin marcar)",
"LabelExportOPML": "Exportar OPML",
"LabelFeedURL": "Fuente de URL", "LabelFeedURL": "Fuente de URL",
"LabelFetchingMetadata": "Obteniendo metadatos", "LabelFetchingMetadata": "Obteniendo metadatos",
"LabelFile": "Archivo", "LabelFile": "Archivo",
@ -319,6 +325,7 @@
"LabelHardDeleteFile": "Eliminar Definitivamente", "LabelHardDeleteFile": "Eliminar Definitivamente",
"LabelHasEbook": "Tiene un libro", "LabelHasEbook": "Tiene un libro",
"LabelHasSupplementaryEbook": "Tiene un libro complementario", "LabelHasSupplementaryEbook": "Tiene un libro complementario",
"LabelHideSubtitles": "Ocultar subtítulos",
"LabelHighestPriority": "Mayor prioridad", "LabelHighestPriority": "Mayor prioridad",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Hora", "LabelHour": "Hora",
@ -339,6 +346,8 @@
"LabelIntervalEveryHour": "Cada Hora", "LabelIntervalEveryHour": "Cada Hora",
"LabelInvert": "Invertir", "LabelInvert": "Invertir",
"LabelItem": "Elemento", "LabelItem": "Elemento",
"LabelJumpBackwardAmount": "Cantidad de saltos hacia atrás",
"LabelJumpForwardAmount": "Cantidad de saltos hacia adelante",
"LabelLanguage": "Idioma", "LabelLanguage": "Idioma",
"LabelLanguageDefaultServer": "Lenguaje Predeterminado del Servidor", "LabelLanguageDefaultServer": "Lenguaje Predeterminado del Servidor",
"LabelLanguages": "Idiomas", "LabelLanguages": "Idiomas",
@ -446,6 +455,7 @@
"LabelRSSFeedPreventIndexing": "Prevenir indexado", "LabelRSSFeedPreventIndexing": "Prevenir indexado",
"LabelRSSFeedSlug": "Fuente RSS Slug", "LabelRSSFeedSlug": "Fuente RSS Slug",
"LabelRSSFeedURL": "URL de Fuente RSS", "LabelRSSFeedURL": "URL de Fuente RSS",
"LabelReAddSeriesToContinueListening": "Volver a agregar la serie para continuar escuchándola",
"LabelRead": "Leído", "LabelRead": "Leído",
"LabelReadAgain": "Volver a leer", "LabelReadAgain": "Volver a leer",
"LabelReadEbookWithoutProgress": "Leer Ebook sin guardar progreso", "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", "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", "LabelSettingsTimeFormat": "Formato de Tiempo",
"LabelShare": "Compartir", "LabelShare": "Compartir",
"LabelShareOpen": "abrir un recurso compartido",
"LabelShareURL": "Compartir la URL", "LabelShareURL": "Compartir la URL",
"LabelShowAll": "Mostrar Todos", "LabelShowAll": "Mostrar Todos",
"LabelShowSeconds": "Mostrar segundos", "LabelShowSeconds": "Mostrar segundos",
"LabelShowSubtitles": "Mostrar subtítulos",
"LabelSize": "Tamaño", "LabelSize": "Tamaño",
"LabelSleepTimer": "Temporizador de apagado", "LabelSleepTimer": "Temporizador de apagado",
"LabelSlug": "Slug", "LabelSlug": "Slug",
@ -552,6 +564,10 @@
"LabelThemeDark": "Oscuro", "LabelThemeDark": "Oscuro",
"LabelThemeLight": "Claro", "LabelThemeLight": "Claro",
"LabelTimeBase": "Tiempo Base", "LabelTimeBase": "Tiempo Base",
"LabelTimeDurationXHours": "{0} horas",
"LabelTimeDurationXMinutes": "{0} minutos",
"LabelTimeDurationXSeconds": "{0} segundos",
"LabelTimeInMinutes": "Tiempo en minutos",
"LabelTimeListened": "Tiempo Escuchando", "LabelTimeListened": "Tiempo Escuchando",
"LabelTimeListenedToday": "Tiempo Escuchando Hoy", "LabelTimeListenedToday": "Tiempo Escuchando Hoy",
"LabelTimeRemaining": "{0} restante", "LabelTimeRemaining": "{0} restante",
@ -591,6 +607,7 @@
"LabelVersion": "Versión", "LabelVersion": "Versión",
"LabelViewBookmarks": "Ver Marcadores", "LabelViewBookmarks": "Ver Marcadores",
"LabelViewChapters": "Ver Capítulos", "LabelViewChapters": "Ver Capítulos",
"LabelViewPlayerSettings": "Ver los ajustes del reproductor",
"LabelViewQueue": "Ver Fila del Reproductor", "LabelViewQueue": "Ver Fila del Reproductor",
"LabelVolume": "Volumen", "LabelVolume": "Volumen",
"LabelWeekdaysToRun": "Correr en Días de la Semana", "LabelWeekdaysToRun": "Correr en Días de la Semana",

View File

@ -136,7 +136,7 @@
"HeaderYourStats": "Tilastosi", "HeaderYourStats": "Tilastosi",
"LabelAddToPlaylist": "Lisää soittolistaan", "LabelAddToPlaylist": "Lisää soittolistaan",
"LabelAdded": "Lisätty", "LabelAdded": "Lisätty",
"LabelAddedAt": "Lisätty", "LabelAddedAt": "Lisätty listalle",
"LabelAll": "Kaikki", "LabelAll": "Kaikki",
"LabelAuthor": "Tekijä", "LabelAuthor": "Tekijä",
"LabelAuthorFirstLast": "Tekijä (Etunimi Sukunimi)", "LabelAuthorFirstLast": "Tekijä (Etunimi Sukunimi)",
@ -152,11 +152,34 @@
"LabelContinueReading": "Jatka lukemista", "LabelContinueReading": "Jatka lukemista",
"LabelContinueSeries": "Jatka sarjoja", "LabelContinueSeries": "Jatka sarjoja",
"LabelDescription": "Kuvaus", "LabelDescription": "Kuvaus",
"LabelDownload": "Lataa",
"LabelDuration": "Kesto", "LabelDuration": "Kesto",
"LabelEbook": "E-kirja", "LabelEbook": "E-kirja",
"LabelEbooks": "E-kirjat", "LabelEbooks": "E-kirjat",
"LabelEnable": "Ota käyttöön",
"LabelFile": "Tiedosto", "LabelFile": "Tiedosto",
"LabelFileBirthtime": "Tiedoston syntymäaika", "LabelFileBirthtime": "Tiedoston syntymäaika",
"LabelFileModified": "Muutettu tiedosto", "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"
} }

View File

@ -258,6 +258,7 @@
"LabelCurrently": "Actuellement :", "LabelCurrently": "Actuellement :",
"LabelCustomCronExpression": "Expression cron personnalisée :", "LabelCustomCronExpression": "Expression cron personnalisée :",
"LabelDatetime": "Date", "LabelDatetime": "Date",
"LabelDays": "Jours",
"LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)", "LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)",
"LabelDescription": "Description", "LabelDescription": "Description",
"LabelDeselectAll": "Tout déselectionner", "LabelDeselectAll": "Tout déselectionner",
@ -321,6 +322,7 @@
"LabelHighestPriority": "Priorité la plus élevée", "LabelHighestPriority": "Priorité la plus élevée",
"LabelHost": "Hôte", "LabelHost": "Hôte",
"LabelHour": "Heure", "LabelHour": "Heure",
"LabelHours": "Heures",
"LabelIcon": "Icône", "LabelIcon": "Icône",
"LabelImageURLFromTheWeb": "URL de limage à partir du web", "LabelImageURLFromTheWeb": "URL de limage à partir du web",
"LabelInProgress": "En cours", "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", "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", "LabelMetadataProvider": "Fournisseur de métadonnées",
"LabelMinute": "Minute", "LabelMinute": "Minute",
"LabelMinutes": "Minutes",
"LabelMissing": "Manquant", "LabelMissing": "Manquant",
"LabelMissingEbook": "Ne possède aucun livre numérique", "LabelMissingEbook": "Ne possède aucun livre numérique",
"LabelMissingSupplementaryEbook": "Ne possède aucun livre numérique supplémentaire", "LabelMissingSupplementaryEbook": "Ne possède aucun livre numérique supplémentaire",
@ -410,6 +413,7 @@
"LabelOverwrite": "Écraser", "LabelOverwrite": "Écraser",
"LabelPassword": "Mot de passe", "LabelPassword": "Mot de passe",
"LabelPath": "Chemin", "LabelPath": "Chemin",
"LabelPermanent": "Permanent",
"LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque", "LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque",
"LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes", "LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes",
"LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint", "LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint",
@ -507,6 +511,9 @@
"LabelSettingsStoreMetadataWithItem": "Enregistrer les métadonnées avec lélément", "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", "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 dheure", "LabelSettingsTimeFormat": "Format dheure",
"LabelShare": "Partager",
"LabelShareOpen": "Ouvrir le partage",
"LabelShareURL": "Partager lURL",
"LabelShowAll": "Tout afficher", "LabelShowAll": "Tout afficher",
"LabelShowSeconds": "Afficher les seondes", "LabelShowSeconds": "Afficher les seondes",
"LabelSize": "Taille", "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 />LURL de lAPI 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>.", "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 />LURL de lAPI 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>nincluent pas</strong> les fichiers stockés dans les dossiers de votre bibliothèque.", "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>nincluent 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", "MessageBackupsLocationEditNote": "Remarque: Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes",
"MessageBackupsLocationNoEditNote": "Remarque: lemplacement de sauvegarde est défini via une variable denvironnement et ne peut pas être modifié ici.",
"MessageBackupsLocationPathEmpty": "L'emplacement de secours ne peut pas être vide", "MessageBackupsLocationPathEmpty": "L'emplacement de secours ne peut pas être vide",
"MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera dajouter 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.", "MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera dajouter 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 navez pas encore de collections", "MessageBookshelfNoCollections": "Vous navez pas encore de collections",
@ -716,6 +724,9 @@
"MessageSelected": "{0} sélectionnés", "MessageSelected": "{0} sélectionnés",
"MessageServerCouldNotBeReached": "Serveur inaccessible", "MessageServerCouldNotBeReached": "Serveur inaccessible",
"MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre", "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": "Ladresse de partage sera <strong>{0}</strong>",
"MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?", "MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?",
"MessageThinking": "Je cherche…", "MessageThinking": "Je cherche…",
"MessageUploaderItemFailed": "Échec du téléversement", "MessageUploaderItemFailed": "Échec du téléversement",
@ -730,7 +741,7 @@
"NoteChapterEditorTimes": "Information : lhorodatage 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.", "NoteChapterEditorTimes": "Information : lhorodatage 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", "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", "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.", "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.", "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.", "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.",

View File

@ -9,7 +9,7 @@
"ButtonApply": "החל", "ButtonApply": "החל",
"ButtonApplyChapters": "החל פרקים", "ButtonApplyChapters": "החל פרקים",
"ButtonAuthors": "יוצרים", "ButtonAuthors": "יוצרים",
"ButtonBack": "Back", "ButtonBack": "חזור",
"ButtonBrowseForFolder": "עיין בתיקייה", "ButtonBrowseForFolder": "עיין בתיקייה",
"ButtonCancel": "בטל", "ButtonCancel": "בטל",
"ButtonCancelEncode": "בטל קידוד", "ButtonCancelEncode": "בטל קידוד",
@ -62,8 +62,8 @@
"ButtonQuickMatch": "התאמה מהירה", "ButtonQuickMatch": "התאמה מהירה",
"ButtonReScan": "סרוק מחדש", "ButtonReScan": "סרוק מחדש",
"ButtonRead": "קרא", "ButtonRead": "קרא",
"ButtonReadLess": "Read less", "ButtonReadLess": "קרא פחות",
"ButtonReadMore": "Read more", "ButtonReadMore": "קרא יותר",
"ButtonRefresh": "רענן", "ButtonRefresh": "רענן",
"ButtonRemove": "הסר", "ButtonRemove": "הסר",
"ButtonRemoveAll": "הסר הכל", "ButtonRemoveAll": "הסר הכל",
@ -115,7 +115,7 @@
"HeaderCollectionItems": "פריטי אוסף", "HeaderCollectionItems": "פריטי אוסף",
"HeaderCover": "כריכה", "HeaderCover": "כריכה",
"HeaderCurrentDownloads": "הורדות נוכחיות", "HeaderCurrentDownloads": "הורדות נוכחיות",
"HeaderCustomMessageOnLogin": "Custom Message on Login", "HeaderCustomMessageOnLogin": "הודעה מותאמת אישית בהתחברות",
"HeaderCustomMetadataProviders": "ספקי מטא-נתונים מותאמים אישית", "HeaderCustomMetadataProviders": "ספקי מטא-נתונים מותאמים אישית",
"HeaderDetails": "פרטים", "HeaderDetails": "פרטים",
"HeaderDownloadQueue": "תור הורדה", "HeaderDownloadQueue": "תור הורדה",
@ -806,8 +806,8 @@
"ToastSendEbookToDeviceSuccess": "הספר נשלח אל המכשיר \"{0}\"", "ToastSendEbookToDeviceSuccess": "הספר נשלח אל המכשיר \"{0}\"",
"ToastSeriesUpdateFailed": "עדכון הסדרה נכשל", "ToastSeriesUpdateFailed": "עדכון הסדרה נכשל",
"ToastSeriesUpdateSuccess": "הסדרה עודכנה בהצלחה", "ToastSeriesUpdateSuccess": "הסדרה עודכנה בהצלחה",
"ToastServerSettingsUpdateFailed": "Failed to update server settings", "ToastServerSettingsUpdateFailed": "כשל בעדכון הגדרות שרת",
"ToastServerSettingsUpdateSuccess": "Server settings updated", "ToastServerSettingsUpdateSuccess": "הגדרות שרת עודכנו בהצלחה",
"ToastSessionDeleteFailed": "מחיקת הפעולה נכשלה", "ToastSessionDeleteFailed": "מחיקת הפעולה נכשלה",
"ToastSessionDeleteSuccess": "הפעולה נמחקה בהצלחה", "ToastSessionDeleteSuccess": "הפעולה נמחקה בהצלחה",
"ToastSocketConnected": "קצה תקשורת חובר", "ToastSocketConnected": "קצה תקשורת חובר",

View File

@ -1,15 +1,15 @@
{ {
"ButtonAdd": "Toevoegen", "ButtonAdd": "Toevoegen",
"ButtonAddChapters": "Hoofdstukken toevoegen", "ButtonAddChapters": "Hoofdstukken toevoegen",
"ButtonAddDevice": "Add Device", "ButtonAddDevice": "Toestel toevoegen",
"ButtonAddLibrary": "Add Library", "ButtonAddLibrary": "Bibliotheek toevoegen",
"ButtonAddPodcasts": "Podcasts toevoegen", "ButtonAddPodcasts": "Podcasts toevoegen",
"ButtonAddUser": "Add User", "ButtonAddUser": "Gebruiker toevoegen",
"ButtonAddYourFirstLibrary": "Voeg je eerste bibliotheek toe", "ButtonAddYourFirstLibrary": "Voeg je eerste bibliotheek toe",
"ButtonApply": "Pas toe", "ButtonApply": "Pas toe",
"ButtonApplyChapters": "Hoofdstukken toepassen", "ButtonApplyChapters": "Hoofdstukken toepassen",
"ButtonAuthors": "Auteurs", "ButtonAuthors": "Auteurs",
"ButtonBack": "Back", "ButtonBack": "Terug",
"ButtonBrowseForFolder": "Bladeren naar map", "ButtonBrowseForFolder": "Bladeren naar map",
"ButtonCancel": "Annuleren", "ButtonCancel": "Annuleren",
"ButtonCancelEncode": "Encoding annuleren", "ButtonCancelEncode": "Encoding annuleren",
@ -32,9 +32,9 @@
"ButtonFullPath": "Volledig pad", "ButtonFullPath": "Volledig pad",
"ButtonHide": "Verberg", "ButtonHide": "Verberg",
"ButtonHome": "Home", "ButtonHome": "Home",
"ButtonIssues": "Issues", "ButtonIssues": "Problemen",
"ButtonJumpBackward": "Jump Backward", "ButtonJumpBackward": "Spring achteruit",
"ButtonJumpForward": "Jump Forward", "ButtonJumpForward": "Spring vooruit",
"ButtonLatest": "Meest recent", "ButtonLatest": "Meest recent",
"ButtonLibrary": "Bibliotheek", "ButtonLibrary": "Bibliotheek",
"ButtonLogout": "Log uit", "ButtonLogout": "Log uit",
@ -44,17 +44,17 @@
"ButtonMatchAllAuthors": "Alle auteurs matchen", "ButtonMatchAllAuthors": "Alle auteurs matchen",
"ButtonMatchBooks": "Alle boeken matchen", "ButtonMatchBooks": "Alle boeken matchen",
"ButtonNevermind": "Laat maar", "ButtonNevermind": "Laat maar",
"ButtonNext": "Next", "ButtonNext": "Volgende",
"ButtonNextChapter": "Next Chapter", "ButtonNextChapter": "Volgend hoofdstuk",
"ButtonOk": "Ok", "ButtonOk": "Ok",
"ButtonOpenFeed": "Feed openen", "ButtonOpenFeed": "Feed openen",
"ButtonOpenManager": "Manager openen", "ButtonOpenManager": "Manager openen",
"ButtonPause": "Pause", "ButtonPause": "Pauze",
"ButtonPlay": "Afspelen", "ButtonPlay": "Afspelen",
"ButtonPlaying": "Speelt", "ButtonPlaying": "Speelt",
"ButtonPlaylists": "Afspeellijsten", "ButtonPlaylists": "Afspeellijsten",
"ButtonPrevious": "Previous", "ButtonPrevious": "Vorige",
"ButtonPreviousChapter": "Previous Chapter", "ButtonPreviousChapter": "Vorig hoofdstuk",
"ButtonPurgeAllCache": "Volledige cache legen", "ButtonPurgeAllCache": "Volledige cache legen",
"ButtonPurgeItemsCache": "Onderdelen-cache legen", "ButtonPurgeItemsCache": "Onderdelen-cache legen",
"ButtonQueueAddItem": "In wachtrij zetten", "ButtonQueueAddItem": "In wachtrij zetten",
@ -62,14 +62,14 @@
"ButtonQuickMatch": "Snelle match", "ButtonQuickMatch": "Snelle match",
"ButtonReScan": "Nieuwe scan", "ButtonReScan": "Nieuwe scan",
"ButtonRead": "Lees", "ButtonRead": "Lees",
"ButtonReadLess": "Read less", "ButtonReadLess": "Lees minder",
"ButtonReadMore": "Read more", "ButtonReadMore": "Lees meer",
"ButtonRefresh": "Refresh", "ButtonRefresh": "Verversen",
"ButtonRemove": "Verwijder", "ButtonRemove": "Verwijder",
"ButtonRemoveAll": "Alles verwijderen", "ButtonRemoveAll": "Alles verwijderen",
"ButtonRemoveAllLibraryItems": "Verwijder volledige bibliotheekinhoud", "ButtonRemoveAllLibraryItems": "Verwijder volledige bibliotheekinhoud",
"ButtonRemoveFromContinueListening": "Vewijder uit Verder luisteren", "ButtonRemoveFromContinueListening": "Vewijder uit Verder luisteren",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading", "ButtonRemoveFromContinueReading": "Verwijder van Verder luisteren",
"ButtonRemoveSeriesFromContinueSeries": "Verwijder serie uit Serie vervolgen", "ButtonRemoveSeriesFromContinueSeries": "Verwijder serie uit Serie vervolgen",
"ButtonReset": "Reset", "ButtonReset": "Reset",
"ButtonResetToDefault": "Reset to default", "ButtonResetToDefault": "Reset to default",
@ -83,7 +83,7 @@
"ButtonSelectFolderPath": "Maplocatie selecteren", "ButtonSelectFolderPath": "Maplocatie selecteren",
"ButtonSeries": "Series", "ButtonSeries": "Series",
"ButtonSetChaptersFromTracks": "Maak hoofdstukken op basis van tracks", "ButtonSetChaptersFromTracks": "Maak hoofdstukken op basis van tracks",
"ButtonShare": "Share", "ButtonShare": "Deel",
"ButtonShiftTimes": "Tijden verschuiven", "ButtonShiftTimes": "Tijden verschuiven",
"ButtonShow": "Toon", "ButtonShow": "Toon",
"ButtonStartM4BEncode": "Start M4B-encoding", "ButtonStartM4BEncode": "Start M4B-encoding",
@ -98,9 +98,9 @@
"ButtonUserEdit": "Wijzig gebruiker {0}", "ButtonUserEdit": "Wijzig gebruiker {0}",
"ButtonViewAll": "Toon alle", "ButtonViewAll": "Toon alle",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata", "ErrorUploadFetchMetadataAPI": "Error metadata ophalen",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", "ErrorUploadFetchMetadataNoResults": "Kan metadata niet ophalen - probeer de titel en/of auteur te updaten",
"ErrorUploadLacksTitle": "Must have a title", "ErrorUploadLacksTitle": "Moet een titel hebben",
"HeaderAccount": "Account", "HeaderAccount": "Account",
"HeaderAdvanced": "Geavanceerd", "HeaderAdvanced": "Geavanceerd",
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen", "HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
@ -113,13 +113,13 @@
"HeaderChooseAFolder": "Map kiezen", "HeaderChooseAFolder": "Map kiezen",
"HeaderCollection": "Collectie", "HeaderCollection": "Collectie",
"HeaderCollectionItems": "Collectie-objecten", "HeaderCollectionItems": "Collectie-objecten",
"HeaderCover": "Cover", "HeaderCover": "Omslag",
"HeaderCurrentDownloads": "Huidige downloads", "HeaderCurrentDownloads": "Huidige downloads",
"HeaderCustomMessageOnLogin": "Custom Message on Login", "HeaderCustomMessageOnLogin": "Custom Message on Login",
"HeaderCustomMetadataProviders": "Custom Metadata Providers", "HeaderCustomMetadataProviders": "Custom Metadata Providers",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download-wachtrij", "HeaderDownloadQueue": "Download-wachtrij",
"HeaderEbookFiles": "Ebook Files", "HeaderEbookFiles": "Ebook bestanden",
"HeaderEmail": "E-mail", "HeaderEmail": "E-mail",
"HeaderEmailSettings": "E-mail instellingen", "HeaderEmailSettings": "E-mail instellingen",
"HeaderEpisodes": "Afleveringen", "HeaderEpisodes": "Afleveringen",
@ -239,11 +239,11 @@
"LabelChapterTitle": "Hoofdstuktitel", "LabelChapterTitle": "Hoofdstuktitel",
"LabelChapters": "Hoofdstukken", "LabelChapters": "Hoofdstukken",
"LabelChaptersFound": "Hoofdstukken gevonden", "LabelChaptersFound": "Hoofdstukken gevonden",
"LabelClickForMoreInfo": "Click for more info", "LabelClickForMoreInfo": "Klik voor meer informatie",
"LabelClosePlayer": "Sluit speler", "LabelClosePlayer": "Sluit speler",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Series inklappen", "LabelCollapseSeries": "Series inklappen",
"LabelCollection": "Collection", "LabelCollection": "Collectie",
"LabelCollections": "Collecties", "LabelCollections": "Collecties",
"LabelComplete": "Compleet", "LabelComplete": "Compleet",
"LabelConfirmPassword": "Bevestig wachtwoord", "LabelConfirmPassword": "Bevestig wachtwoord",
@ -258,6 +258,7 @@
"LabelCurrently": "Op dit moment:", "LabelCurrently": "Op dit moment:",
"LabelCustomCronExpression": "Aangepaste Cron-uitdrukking:", "LabelCustomCronExpression": "Aangepaste Cron-uitdrukking:",
"LabelDatetime": "Datum-tijd", "LabelDatetime": "Datum-tijd",
"LabelDays": "Dagen",
"LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)",
"LabelDescription": "Beschrijving", "LabelDescription": "Beschrijving",
"LabelDeselectAll": "Deselecteer alle", "LabelDeselectAll": "Deselecteer alle",
@ -296,7 +297,7 @@
"LabelExplicitChecked": "Explicit (checked)", "LabelExplicitChecked": "Explicit (checked)",
"LabelExplicitUnchecked": "Not Explicit (unchecked)", "LabelExplicitUnchecked": "Not Explicit (unchecked)",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata", "LabelFetchingMetadata": "Metadata ophalen",
"LabelFile": "Bestand", "LabelFile": "Bestand",
"LabelFileBirthtime": "Aanmaaktijd bestand", "LabelFileBirthtime": "Aanmaaktijd bestand",
"LabelFileModified": "Bestand gewijzigd", "LabelFileModified": "Bestand gewijzigd",
@ -306,7 +307,7 @@
"LabelFinished": "Voltooid", "LabelFinished": "Voltooid",
"LabelFolder": "Map", "LabelFolder": "Map",
"LabelFolders": "Mappen", "LabelFolders": "Mappen",
"LabelFontBold": "Bold", "LabelFontBold": "Vetgedrukt",
"LabelFontBoldness": "Font Boldness", "LabelFontBoldness": "Font Boldness",
"LabelFontFamily": "Lettertypefamilie", "LabelFontFamily": "Lettertypefamilie",
"LabelFontItalic": "Italic", "LabelFontItalic": "Italic",
@ -321,6 +322,7 @@
"LabelHighestPriority": "Highest priority", "LabelHighestPriority": "Highest priority",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Uur", "LabelHour": "Uur",
"LabelHours": "Uren",
"LabelIcon": "Icoon", "LabelIcon": "Icoon",
"LabelImageURLFromTheWeb": "Image URL from the web", "LabelImageURLFromTheWeb": "Image URL from the web",
"LabelInProgress": "Bezig", "LabelInProgress": "Bezig",
@ -567,7 +569,7 @@
"LabelTracksSingleTrack": "Enkele track", "LabelTracksSingleTrack": "Enkele track",
"LabelType": "Type", "LabelType": "Type",
"LabelUnabridged": "Onverkort", "LabelUnabridged": "Onverkort",
"LabelUndo": "Undo", "LabelUndo": "Ongedaan maken",
"LabelUnknown": "Onbekend", "LabelUnknown": "Onbekend",
"LabelUpdateCover": "Cover bijwerken", "LabelUpdateCover": "Cover bijwerken",
"LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden", "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?", "MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?",
"MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?", "MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
"MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen 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?", "MessageConfirmRemoveNarrator": "Weet je zeker dat je verteller \"{0}\" wil verwijderen?",
"MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{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?", "MessageConfirmRenameGenre": "Weet je zeker dat je genre \"{0}\" wil hernoemen naar \"{1}\" voor alle onderdelen?",
@ -714,6 +716,7 @@
"MessageSelected": "{0} selected", "MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Server niet bereikbaar", "MessageServerCouldNotBeReached": "Server niet bereikbaar",
"MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel", "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}?", "MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {1}?",
"MessageThinking": "Aan het denken...", "MessageThinking": "Aan het denken...",
"MessageUploaderItemFailed": "Uploaden mislukt", "MessageUploaderItemFailed": "Uploaden mislukt",

View File

@ -62,8 +62,8 @@
"ButtonQuickMatch": "Szybkie dopasowanie", "ButtonQuickMatch": "Szybkie dopasowanie",
"ButtonReScan": "Ponowne skanowanie", "ButtonReScan": "Ponowne skanowanie",
"ButtonRead": "Czytaj", "ButtonRead": "Czytaj",
"ButtonReadLess": "Read less", "ButtonReadLess": "Pokaż mniej",
"ButtonReadMore": "Read more", "ButtonReadMore": "Pokaż więcej",
"ButtonRefresh": "Odśwież", "ButtonRefresh": "Odśwież",
"ButtonRemove": "Usuń", "ButtonRemove": "Usuń",
"ButtonRemoveAll": "Usuń wszystko", "ButtonRemoveAll": "Usuń wszystko",
@ -88,6 +88,7 @@
"ButtonShow": "Pokaż", "ButtonShow": "Pokaż",
"ButtonStartM4BEncode": "Eksportuj jako plik M4B", "ButtonStartM4BEncode": "Eksportuj jako plik M4B",
"ButtonStartMetadataEmbed": "Osadź metadane", "ButtonStartMetadataEmbed": "Osadź metadane",
"ButtonStats": "Statystyki",
"ButtonSubmit": "Zaloguj", "ButtonSubmit": "Zaloguj",
"ButtonTest": "Test", "ButtonTest": "Test",
"ButtonUpload": "Wgraj", "ButtonUpload": "Wgraj",
@ -130,13 +131,13 @@
"HeaderIgnoredFiles": "Zignoruj pliki", "HeaderIgnoredFiles": "Zignoruj pliki",
"HeaderItemFiles": "Pliki", "HeaderItemFiles": "Pliki",
"HeaderItemMetadataUtils": "Item Metadata Utils", "HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Ostatnio odtwarzana sesja", "HeaderLastListeningSession": "Ostatnia sesja słuchania",
"HeaderLatestEpisodes": "Najnowsze odcinki", "HeaderLatestEpisodes": "Najnowsze odcinki",
"HeaderLibraries": "Biblioteki", "HeaderLibraries": "Biblioteki",
"HeaderLibraryFiles": "Pliki w bibliotece", "HeaderLibraryFiles": "Pliki w bibliotece",
"HeaderLibraryStats": "Statystyki biblioteki", "HeaderLibraryStats": "Statystyki biblioteki",
"HeaderListeningSessions": "Sesje słuchania", "HeaderListeningSessions": "Sesje słuchania",
"HeaderListeningStats": "Statystyki odtwarzania", "HeaderListeningStats": "Statystyki słuchania",
"HeaderLogin": "Zaloguj się", "HeaderLogin": "Zaloguj się",
"HeaderLogs": "Logi", "HeaderLogs": "Logi",
"HeaderManageGenres": "Zarządzaj gatunkami", "HeaderManageGenres": "Zarządzaj gatunkami",
@ -148,12 +149,13 @@
"HeaderNewAccount": "Nowe konto", "HeaderNewAccount": "Nowe konto",
"HeaderNewLibrary": "Nowa biblioteka", "HeaderNewLibrary": "Nowa biblioteka",
"HeaderNotifications": "Powiadomienia", "HeaderNotifications": "Powiadomienia",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenIDConnectAuthentication": "Uwierzytelnianie OpenID Connect",
"HeaderOpenRSSFeed": "Utwórz kanał RSS", "HeaderOpenRSSFeed": "Utwórz kanał RSS",
"HeaderOtherFiles": "Inne pliki", "HeaderOtherFiles": "Inne pliki",
"HeaderPasswordAuthentication": "Uwierzytelnianie hasłem", "HeaderPasswordAuthentication": "Uwierzytelnianie hasłem",
"HeaderPermissions": "Uprawnienia", "HeaderPermissions": "Uprawnienia",
"HeaderPlayerQueue": "Kolejka odtwarzania", "HeaderPlayerQueue": "Kolejka odtwarzania",
"HeaderPlayerSettings": "Ustawienia Odtwarzania",
"HeaderPlaylist": "Playlista", "HeaderPlaylist": "Playlista",
"HeaderPlaylistItems": "Pozycje listy odtwarzania", "HeaderPlaylistItems": "Pozycje listy odtwarzania",
"HeaderPodcastsToAdd": "Podcasty do dodania", "HeaderPodcastsToAdd": "Podcasty do dodania",
@ -175,7 +177,7 @@
"HeaderSettingsScanner": "Skanowanie", "HeaderSettingsScanner": "Skanowanie",
"HeaderSleepTimer": "Wyłącznik czasowy", "HeaderSleepTimer": "Wyłącznik czasowy",
"HeaderStatsLargestItems": "Największe pozycje", "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)", "HeaderStatsMinutesListeningChart": "Czas słuchania w minutach (ostatnie 7 dni)",
"HeaderStatsRecentSessions": "Ostatnie sesje", "HeaderStatsRecentSessions": "Ostatnie sesje",
"HeaderStatsTop10Authors": "Top 10 Autorów", "HeaderStatsTop10Authors": "Top 10 Autorów",
@ -200,8 +202,8 @@
"LabelActivity": "Aktywność", "LabelActivity": "Aktywność",
"LabelAddToCollection": "Dodaj do kolekcji", "LabelAddToCollection": "Dodaj do kolekcji",
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji", "LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
"LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylist": "Dodaj do playlisty",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Dodaj {0} pozycji do playlisty",
"LabelAdded": "Dodane", "LabelAdded": "Dodane",
"LabelAddedAt": "Dodano", "LabelAddedAt": "Dodano",
"LabelAdminUsersOnly": "Tylko użytkownicy administracyjni", "LabelAdminUsersOnly": "Tylko użytkownicy administracyjni",
@ -226,14 +228,14 @@
"LabelBackupLocation": "Lokalizacja kopii zapasowej", "LabelBackupLocation": "Lokalizacja kopii zapasowej",
"LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe", "LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe",
"LabelBackupsEnableAutomaticBackupsHelp": "Kopie zapasowe są zapisywane w folderze /metadata/backups", "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.", "LabelBackupsMaxBackupSizeHelp": "Jako zabezpieczenie przed błędną konfiguracją, kopie zapasowe nie będą wykonywane, jeśli przekroczą skonfigurowany rozmiar.",
"LabelBackupsNumberToKeep": "Liczba kopii zapasowych do przechowywania", "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ąć.", "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", "LabelBitrate": "Bitrate",
"LabelBooks": "Książki", "LabelBooks": "Książki",
"LabelButtonText": "Button Text", "LabelButtonText": "Button Text",
"LabelByAuthor": "by {0}", "LabelByAuthor": "autorstwa {0}",
"LabelChangePassword": "Zmień hasło", "LabelChangePassword": "Zmień hasło",
"LabelChannels": "Kanały", "LabelChannels": "Kanały",
"LabelChapterTitle": "Tytuł rozdziału", "LabelChapterTitle": "Tytuł rozdziału",
@ -247,7 +249,7 @@
"LabelCollections": "Kolekcje", "LabelCollections": "Kolekcje",
"LabelComplete": "Ukończone", "LabelComplete": "Ukończone",
"LabelConfirmPassword": "Potwierdź hasło", "LabelConfirmPassword": "Potwierdź hasło",
"LabelContinueListening": "Kontynuuj odtwarzanie", "LabelContinueListening": "Kontynuuj słuchanie",
"LabelContinueReading": "Kontynuuj czytanie", "LabelContinueReading": "Kontynuuj czytanie",
"LabelContinueSeries": "Kontynuuj serię", "LabelContinueSeries": "Kontynuuj serię",
"LabelCover": "Okładka", "LabelCover": "Okładka",
@ -319,6 +321,7 @@
"LabelHardDeleteFile": "Usuń trwale plik", "LabelHardDeleteFile": "Usuń trwale plik",
"LabelHasEbook": "Ma ebooka", "LabelHasEbook": "Ma ebooka",
"LabelHasSupplementaryEbook": "Posiada dodatkowy ebook", "LabelHasSupplementaryEbook": "Posiada dodatkowy ebook",
"LabelHideSubtitles": "Ukryj napisy",
"LabelHighestPriority": "Najwyższy priorytet", "LabelHighestPriority": "Najwyższy priorytet",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Godzina", "LabelHour": "Godzina",
@ -413,7 +416,7 @@
"LabelOverwrite": "Nadpisz", "LabelOverwrite": "Nadpisz",
"LabelPassword": "Hasło", "LabelPassword": "Hasło",
"LabelPath": "Ścieżka", "LabelPath": "Ścieżka",
"LabelPermanent": "Trwały", "LabelPermanent": "Stałe",
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek", "LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
"LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów", "LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów",
"LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite", "LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite",
@ -446,6 +449,7 @@
"LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu", "LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu",
"LabelRSSFeedSlug": "RSS Feed Slug", "LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "URL kanały RSS", "LabelRSSFeedURL": "URL kanały RSS",
"LabelReAddSeriesToContinueListening": "Ponownie Dodaj Serię do sekcji Kontunuuj Odtwarzanie",
"LabelRead": "Czytaj", "LabelRead": "Czytaj",
"LabelReadAgain": "Czytaj ponownie", "LabelReadAgain": "Czytaj ponownie",
"LabelReadEbookWithoutProgress": "Czytaj książkę bez zapamiętywania postępu", "LabelReadEbookWithoutProgress": "Czytaj książkę bez zapamiętywania postępu",
@ -516,6 +520,7 @@
"LabelShareURL": "Link do udziału", "LabelShareURL": "Link do udziału",
"LabelShowAll": "Pokaż wszystko", "LabelShowAll": "Pokaż wszystko",
"LabelShowSeconds": "Pokaż sekundy", "LabelShowSeconds": "Pokaż sekundy",
"LabelShowSubtitles": "Pokaż Napisy",
"LabelSize": "Rozmiar", "LabelSize": "Rozmiar",
"LabelSleepTimer": "Wyłącznik czasowy", "LabelSleepTimer": "Wyłącznik czasowy",
"LabelSlug": "Slug", "LabelSlug": "Slug",
@ -534,10 +539,10 @@
"LabelStatsItemsFinished": "Pozycje zakończone", "LabelStatsItemsFinished": "Pozycje zakończone",
"LabelStatsItemsInLibrary": "Pozycje w bibliotece", "LabelStatsItemsInLibrary": "Pozycje w bibliotece",
"LabelStatsMinutes": "Minuty", "LabelStatsMinutes": "Minuty",
"LabelStatsMinutesListening": "Minuty odtwarzania", "LabelStatsMinutesListening": "Minuty słuchania",
"LabelStatsOverallDays": "Całkowity czas (dni)", "LabelStatsOverallDays": "Całkowity czas (dni)",
"LabelStatsOverallHours": "Całkowity czas (godziny)", "LabelStatsOverallHours": "Całkowity czas (godziny)",
"LabelStatsWeekListening": "Tydzień odtwarzania", "LabelStatsWeekListening": "Tydzień słuchania",
"LabelSubtitle": "Podtytuł", "LabelSubtitle": "Podtytuł",
"LabelSupportedFileTypes": "Obsługiwane typy plików", "LabelSupportedFileTypes": "Obsługiwane typy plików",
"LabelTag": "Tag", "LabelTag": "Tag",
@ -592,6 +597,7 @@
"LabelVersion": "Wersja", "LabelVersion": "Wersja",
"LabelViewBookmarks": "Wyświetlaj zakładki", "LabelViewBookmarks": "Wyświetlaj zakładki",
"LabelViewChapters": "Wyświetlaj rozdziały", "LabelViewChapters": "Wyświetlaj rozdziały",
"LabelViewPlayerSettings": "Zobacz ustawienia odtwarzacza",
"LabelViewQueue": "Wyświetlaj kolejkę odtwarzania", "LabelViewQueue": "Wyświetlaj kolejkę odtwarzania",
"LabelVolume": "Głośność", "LabelVolume": "Głośność",
"LabelWeekdaysToRun": "Dni tygodnia", "LabelWeekdaysToRun": "Dni tygodnia",
@ -642,7 +648,7 @@
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?", "MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
"MessageConfirmRemoveListeningSessions": "Czy na pewno chcesz usunąć {0} sesji słuchania?", "MessageConfirmRemoveListeningSessions": "Czy na pewno chcesz usunąć {0} sesji słuchania?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", "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?", "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.", "MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".", "MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
@ -663,7 +669,7 @@
"MessageItemsSelected": "{0} zaznaczone elementy", "MessageItemsSelected": "{0} zaznaczone elementy",
"MessageItemsUpdated": "{0} Items Updated", "MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Dołącz do nas na", "MessageJoinUsOn": "Dołącz do nas na",
"MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku", "MessageListeningSessionsInTheLastYear": "Sesje słuchania w ostatnim roku: {0}",
"MessageLoading": "Ładowanie...", "MessageLoading": "Ładowanie...",
"MessageLoadingFolders": "Ładowanie folderów...", "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>.", "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", "MessageNoIssues": "Brak problemów",
"MessageNoItems": "Brak elementów", "MessageNoItems": "Brak elementów",
"MessageNoItemsFound": "Nie znaleziono żadnych elementów", "MessageNoItemsFound": "Nie znaleziono żadnych elementów",
"MessageNoListeningSessions": "Brak sesji odtwarzania", "MessageNoListeningSessions": "Brak sesji słuchania",
"MessageNoLogs": "Brak logów", "MessageNoLogs": "Brak logów",
"MessageNoMediaProgress": "Brak postępu", "MessageNoMediaProgress": "Brak postępu",
"MessageNoNotifications": "Brak powiadomień", "MessageNoNotifications": "Brak powiadomień",
@ -709,7 +715,7 @@
"MessageOr": "lub", "MessageOr": "lub",
"MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały", "MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
"MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału", "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", "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'.", "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ł", "MessageRemoveChapter": "Usuń rozdział",
@ -724,8 +730,9 @@
"MessageSelected": "{0} wybranych", "MessageSelected": "{0} wybranych",
"MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem", "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", "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}", "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}?", "MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
"MessageThinking": "Myślę...", "MessageThinking": "Myślę...",
"MessageUploaderItemFailed": "Nie udało się przesłać", "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.", "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", "PlaceholderNewCollection": "Nowa nazwa kolekcji",
"PlaceholderNewFolderPath": "Nowa ścieżka folderu", "PlaceholderNewFolderPath": "Nowa ścieżka folderu",
"PlaceholderNewPlaylist": "New playlist name", "PlaceholderNewPlaylist": "Nowa nazwa playlisty",
"PlaceholderSearch": "Szukanie..", "PlaceholderSearch": "Szukanie..",
"PlaceholderSearchEpisode": "Szukanie odcinka..", "PlaceholderSearchEpisode": "Szukanie odcinka..",
"ToastAccountUpdateFailed": "Nie udało się zaktualizować konta", "ToastAccountUpdateFailed": "Nie udało się zaktualizować konta",
@ -802,12 +809,12 @@
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki", "ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
"ToastLibraryUpdateFailed": "Nie udało się zaktualizować biblioteki", "ToastLibraryUpdateFailed": "Nie udało się zaktualizować biblioteki",
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji", "ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
"ToastPlaylistCreateFailed": "Failed to create playlist", "ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty",
"ToastPlaylistCreateSuccess": "Playlist created", "ToastPlaylistCreateSuccess": "Playlista utworzona",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveFailed": "Nie udało się usunąć playlisty",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistRemoveSuccess": "Playlista usunięta",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateFailed": "Nie udało się zaktualizować playlisty",
"ToastPlaylistUpdateSuccess": "Playlist updated", "ToastPlaylistUpdateSuccess": "Playlista zaktualizowana",
"ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu", "ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony", "ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się", "ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",

View File

@ -285,6 +285,7 @@ class Server {
'/library/:library/bookshelf/:id?', '/library/:library/bookshelf/:id?',
'/library/:library/authors', '/library/:library/authors',
'/library/:library/narrators', '/library/:library/narrators',
'/library/:library/stats',
'/library/:library/series/:id?', '/library/:library/series/:id?',
'/library/:library/podcast/search', '/library/:library/podcast/search',
'/library/:library/podcast/latest', '/library/:library/podcast/latest',

View File

@ -14,6 +14,15 @@ const CoverManager = require('../managers/CoverManager')
const LibraryItem = require('../objects/LibraryItem') const LibraryItem = require('../objects/LibraryItem')
class PodcastController { 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) { async create(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to create podcast`) Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to create podcast`)
@ -133,6 +142,14 @@ class PodcastController {
res.json({ podcast }) 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) { async getFeedsFromOPMLText(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get feeds from opml`) 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) return res.sendStatus(400)
} }
const rssFeedsData = await this.podcastManager.getOPMLFeeds(req.body.opmlText) res.json({
res.json(rssFeedsData) 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) { async checkNewEpisodes(req, res) {

View File

@ -42,7 +42,7 @@ class BackupManager {
} }
get maxBackupSize() { get maxBackupSize() {
return global.ServerSettings.maxBackupSize || 1 return global.ServerSettings.maxBackupSize || Infinity
} }
async init() { async init() {
@ -419,14 +419,16 @@ class BackupManager {
reject(err) reject(err)
}) })
archive.on('progress', ({ fs: fsobj }) => { archive.on('progress', ({ fs: fsobj }) => {
const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000 if (this.maxBackupSize !== Infinity) {
if (fsobj.processedBytes > maxBackupSizeInBytes) { const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`) if (fsobj.processedBytes > maxBackupSizeInBytes) {
archive.abort() Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
setTimeout(() => { archive.abort()
this.removeBackup(backup) setTimeout(() => {
output.destroy('Backup too large') // Promise is reject in write stream error evt this.removeBackup(backup)
}, 500) output.destroy('Backup too large') // Promise is reject in write stream error evt
}, 500)
}
} }
}) })

View File

@ -5,7 +5,7 @@ const Database = require('../Database')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const { getPodcastFeed } = require('../utils/podcastUtils') 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 { levenshteinDistance } = require('../utils/index')
const opmlParser = require('../utils/parsers/parseOPML') const opmlParser = require('../utils/parsers/parseOPML')
const opmlGenerator = require('../utils/generators/opmlGenerator') const opmlGenerator = require('../utils/generators/opmlGenerator')
@ -13,11 +13,13 @@ const prober = require('../utils/prober')
const ffmpegHelpers = require('../utils/ffmpegHelpers') const ffmpegHelpers = require('../utils/ffmpegHelpers')
const TaskManager = require('./TaskManager') const TaskManager = require('./TaskManager')
const CoverManager = require('../managers/CoverManager')
const LibraryFile = require('../objects/files/LibraryFile') const LibraryFile = require('../objects/files/LibraryFile')
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload') const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
const PodcastEpisode = require('../objects/entities/PodcastEpisode') const PodcastEpisode = require('../objects/entities/PodcastEpisode')
const AudioFile = require('../objects/files/AudioFile') const AudioFile = require('../objects/files/AudioFile')
const LibraryItem = require('../objects/LibraryItem')
class PodcastManager { class PodcastManager {
constructor(watcher, notificationManager) { constructor(watcher, notificationManager) {
@ -350,19 +352,23 @@ class PodcastManager {
return matches.sort((a, b) => a.levenshtein - b.levenshtein) return matches.sort((a, b) => a.levenshtein - b.levenshtein)
} }
getParsedOPMLFileFeeds(opmlText) {
return opmlParser.parse(opmlText)
}
async getOPMLFeeds(opmlText) { async getOPMLFeeds(opmlText) {
var extractedFeeds = opmlParser.parse(opmlText) const extractedFeeds = opmlParser.parse(opmlText)
if (!extractedFeeds || !extractedFeeds.length) { if (!extractedFeeds?.length) {
Logger.error('[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML') Logger.error('[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML')
return { return {
error: 'No RSS feeds found in OPML' error: 'No RSS feeds found in OPML'
} }
} }
var rssFeedData = [] const rssFeedData = []
for (let feed of extractedFeeds) { for (let feed of extractedFeeds) {
var feedData = await getPodcastFeed(feed.feedUrl, true) const feedData = await getPodcastFeed(feed.feedUrl, true)
if (feedData) { if (feedData) {
feedData.metadata.feedUrl = feed.feedUrl feedData.metadata.feedUrl = feed.feedUrl
rssFeedData.push(feedData) rssFeedData.push(feedData)
@ -392,5 +398,115 @@ class PodcastManager {
queue: this.downloadQueue.filter((item) => !libraryId || item.libraryId === libraryId).map((item) => item.toJSONForClient()) 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 module.exports = PodcastManager

View File

@ -9,8 +9,8 @@ class TaskManager {
/** /**
* Add task and emit socket task_started event * Add task and emit socket task_started event
* *
* @param {Task} task * @param {Task} task
*/ */
addTask(task) { addTask(task) {
this.tasks.push(task) this.tasks.push(task)
@ -19,24 +19,24 @@ class TaskManager {
/** /**
* Remove task and emit task_finished event * Remove task and emit task_finished event
* *
* @param {Task} task * @param {Task} task
*/ */
taskFinished(task) { taskFinished(task) {
if (this.tasks.some(t => t.id === task.id)) { if (this.tasks.some((t) => t.id === task.id)) {
this.tasks = this.tasks.filter(t => t.id !== task.id) this.tasks = this.tasks.filter((t) => t.id !== task.id)
SocketAuthority.emitter('task_finished', task.toJSON()) SocketAuthority.emitter('task_finished', task.toJSON())
} }
} }
/** /**
* Create new task and add * Create new task and add
* *
* @param {string} action * @param {string} action
* @param {string} title * @param {string} title
* @param {string} description * @param {string} description
* @param {boolean} showSuccess * @param {boolean} showSuccess
* @param {Object} [data] * @param {Object} [data]
*/ */
createAndAddTask(action, title, description, showSuccess, data = {}) { createAndAddTask(action, title, description, showSuccess, data = {}) {
const task = new Task() const task = new Task()
@ -44,5 +44,21 @@ class TaskManager {
this.addTask(task) this.addTask(task)
return 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() module.exports = new TaskManager()

View File

@ -60,7 +60,7 @@ class Library extends Model {
/** /**
* Convert expanded Library to oldLibrary * Convert expanded Library to oldLibrary
* @param {Library} libraryExpanded * @param {Library} libraryExpanded
* @returns {Promise<oldLibrary>} * @returns {oldLibrary}
*/ */
static getOldLibrary(libraryExpanded) { static getOldLibrary(libraryExpanded) {
const folders = libraryExpanded.libraryFolders.map((folder) => { const folders = libraryExpanded.libraryFolders.map((folder) => {

View File

@ -102,7 +102,7 @@ class ServerSettings {
this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups') this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups')
this.backupSchedule = settings.backupSchedule || false this.backupSchedule = settings.backupSchedule || false
this.backupsToKeep = settings.backupsToKeep || 2 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.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7
this.loggerScannerLogsToKeep = settings.loggerScannerLogsToKeep || 2 this.loggerScannerLogsToKeep = settings.loggerScannerLogsToKeep || 2

View File

@ -45,6 +45,7 @@ class ApiRouter {
this.backupManager = Server.backupManager this.backupManager = Server.backupManager
/** @type {import('../Watcher')} */ /** @type {import('../Watcher')} */
this.watcher = Server.watcher this.watcher = Server.watcher
/** @type {import('../managers/PodcastManager')} */
this.podcastManager = Server.podcastManager this.podcastManager = Server.podcastManager
this.audioMetadataManager = Server.audioMetadataManager this.audioMetadataManager = Server.audioMetadataManager
this.rssFeedManager = Server.rssFeedManager this.rssFeedManager = Server.rssFeedManager
@ -239,7 +240,8 @@ class ApiRouter {
// //
this.router.post('/podcasts', PodcastController.create.bind(this)) this.router.post('/podcasts', PodcastController.create.bind(this))
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this)) this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
this.router.post('/podcasts/opml', PodcastController.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/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this))
this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this)) this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))
this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this)) this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))

View File

@ -2,24 +2,26 @@ const { parseNfoMetadata } = require('../utils/parsers/parseNfoMetadata')
const { readTextFile } = require('../utils/fileUtils') const { readTextFile } = require('../utils/fileUtils')
class NfoFileScanner { class NfoFileScanner {
constructor() { } constructor() {}
/** /**
* Parse metadata from .nfo file found in library scan and update bookMetadata * Parse metadata from .nfo file found in library scan and update bookMetadata
* *
* @param {import('../models/LibraryItem').LibraryFileObject} nfoLibraryFileObj * @param {import('../models/LibraryItem').LibraryFileObject} nfoLibraryFileObj
* @param {Object} bookMetadata * @param {Object} bookMetadata
*/ */
async scanBookNfoFile(nfoLibraryFileObj, bookMetadata) { async scanBookNfoFile(nfoLibraryFileObj, bookMetadata) {
const nfoText = await readTextFile(nfoLibraryFileObj.metadata.path) const nfoText = await readTextFile(nfoLibraryFileObj.metadata.path)
const nfoMetadata = nfoText ? await parseNfoMetadata(nfoText) : null const nfoMetadata = nfoText ? await parseNfoMetadata(nfoText) : null
if (nfoMetadata) { if (nfoMetadata) {
for (const key in 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) { if (nfoMetadata.tags.length) {
bookMetadata.tags = nfoMetadata.tags 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) { if (nfoMetadata.genres.length) {
bookMetadata.genres = nfoMetadata.genres bookMetadata.genres = nfoMetadata.genres
} }
@ -33,10 +35,12 @@ class NfoFileScanner {
} }
} else if (key === 'series') { } else if (key === 'series') {
if (nfoMetadata.series) { if (nfoMetadata.series) {
bookMetadata.series = [{ bookMetadata.series = [
name: nfoMetadata.series, {
sequence: nfoMetadata.sequence || null name: nfoMetadata.series,
}] sequence: nfoMetadata.sequence || null
}
]
} }
} else if (nfoMetadata[key] && key !== 'sequence') { } else if (nfoMetadata[key] && key !== 'sequence') {
bookMetadata[key] = nfoMetadata[key] bookMetadata[key] = nfoMetadata[key]
@ -45,4 +49,4 @@ class NfoFileScanner {
} }
} }
} }
module.exports = new NfoFileScanner() module.exports = new NfoFileScanner()

View File

@ -81,6 +81,10 @@ function parseNfoMetadata(nfoText) {
case 'isbn-13': case 'isbn-13':
metadata.isbn = value metadata.isbn = value
break break
case 'language':
case 'lang':
metadata.language = value
break
} }
} }
}) })

View File

@ -1,17 +1,21 @@
const h = require('htmlparser2') const h = require('htmlparser2')
const Logger = require('../../Logger') const Logger = require('../../Logger')
/**
*
* @param {string} opmlText
* @returns {Array<{title: string, feedUrl: string}>
*/
function parse(opmlText) { function parse(opmlText) {
var feeds = [] var feeds = []
var parser = new h.Parser({ var parser = new h.Parser({
onopentag: (name, attribs) => { onopentag: (name, attribs) => {
if (name === "outline" && attribs.type === 'rss') { if (name === 'outline' && attribs.type === 'rss') {
if (!attribs.xmlurl) { if (!attribs.xmlurl) {
Logger.error('[parseOPML] Invalid opml outline tag has no xmlurl attribute') Logger.error('[parseOPML] Invalid opml outline tag has no xmlurl attribute')
} else { } else {
feeds.push({ feeds.push({
title: attribs.title || 'No Title', title: attribs.title || attribs.text || '',
text: attribs.text || '',
feedUrl: attribs.xmlurl feedUrl: attribs.xmlurl
}) })
} }
@ -21,4 +25,4 @@ function parse(opmlText) {
parser.write(opmlText) parser.write(opmlText)
return feeds return feeds
} }
module.exports.parse = parse module.exports.parse = parse

View File

@ -289,7 +289,6 @@ module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
const matches = [] const matches = []
feed.episodes.forEach((ep) => { feed.episodes.forEach((ep) => {
if (!ep.title) return if (!ep.title) return
const epTitle = ep.title.toLowerCase().trim() const epTitle = ep.title.toLowerCase().trim()
if (epTitle === searchTitle) { if (epTitle === searchTitle) {
matches.push({ matches.push({

View File

@ -103,6 +103,16 @@ describe('parseNfoMetadata', () => {
expect(result.asin).to.equal('B08X5JZJLH') 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', () => { it('parses description', () => {
const nfoText = 'Book Description\n=========\nThis is a book.\n It\'s good' const nfoText = 'Book Description\n=========\nThis is a book.\n It\'s good'
const result = parseNfoMetadata(nfoText) const result = parseNfoMetadata(nfoText)