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 { } } } - \ No newline at end of file + 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 @@ - + + + @@ -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 @@
+ + monitoring + +

{{ $strings.ButtonStats }}

+ +
+ + @@ -103,7 +111,7 @@
- + warning

{{ $strings.ButtonIssues }}

@@ -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 @@ + + + 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 @@
-
-
+
+
- + Set
-
-
- +
+
+ +
+ remove - 30m + 30m - + -

{{ $secondsToTimestamp(remaining) }}

+

{{ $secondsToTimestamp(remaining) }}

- + - + add - 30m + 30m
{{ $strings.ButtonCancel }} @@ -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 { } } } - \ No newline at end of file + 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 @@
-

{{ $strings.HeaderPodcastsToAdd }}

+

{{ $strings.HeaderPodcastsToAdd }}

+

{{ $strings.MessageOpmlPreviewNote }}

-
@@ -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() {} } - \ No newline at end of file + 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 @@ snooze
snooze -

{{ sleepTimerRemainingString }}

+

{{ sleepTimerRemainingString }}

@@ -36,9 +36,9 @@ - -
@@ -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 @@
@@ -116,4 +116,4 @@ export default { }, mounted() {} } - \ No newline at end of file + 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 @@ + + + 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%; } } - \ No newline at end of file + 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 @@ - - - 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 @@ 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 @@ + + + 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’API Apprise pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.
L’URL de l’API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur http://192.168.1.1:8337 alors vous devez mettre http://192.168.1.1:8337/notify.", "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 /metadata/items & /metadata/authors. Les sauvegardes n’incluent pas 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 {0}", + "MessageShareExpiresIn": "Expire dans {0}", + "MessageShareURLWillBe": "L’adresse de partage sera {0}", "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 /metadata/logs jako pliki JSON. Logi awaryjne są zapisane w /metadata/logs/crash_logs.txt.", @@ -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 {0}", "MessageShareExpiresIn": "Wygaśnie za {0}", - "MessageShareURLWillBe": "URL udziału będzie {0}", + "MessageShareURLWillBe": "Udostępnione pod linkiem {0}", "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} + * @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)