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