mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
Merge branch 'advplyr:master' into ffmpeg-progress
This commit is contained in:
commit
7faf42d892
@ -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
|
||||
})
|
||||
|
@ -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',
|
||||
|
@ -35,11 +35,13 @@
|
||||
<player-ui
|
||||
ref="audioPlayer"
|
||||
:chapters="chapters"
|
||||
:current-chapter="currentChapter"
|
||||
:paused="!isPlaying"
|
||||
:loading="playerLoading"
|
||||
:bookmarks="bookmarks"
|
||||
:sleep-timer-set="sleepTimerSet"
|
||||
:sleep-timer-remaining="sleepTimerRemaining"
|
||||
:sleep-timer-type="sleepTimerType"
|
||||
:is-podcast="isPodcast"
|
||||
@playPause="playPause"
|
||||
@jumpForward="jumpForward"
|
||||
@ -51,13 +53,16 @@
|
||||
@showBookmarks="showBookmarks"
|
||||
@showSleepTimer="showSleepTimerModal = true"
|
||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||
@showPlayerSettings="showPlayerSettingsModal = true"
|
||||
/>
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||
|
||||
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
|
||||
|
||||
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -76,9 +81,10 @@ export default {
|
||||
currentTime: 0,
|
||||
showSleepTimerModal: false,
|
||||
showPlayerQueueItemsModal: false,
|
||||
showPlayerSettingsModal: false,
|
||||
sleepTimerSet: false,
|
||||
sleepTimerTime: 0,
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimerType: null,
|
||||
sleepTimer: null,
|
||||
displayTitle: null,
|
||||
currentPlaybackRate: 1,
|
||||
@ -145,6 +151,9 @@ export default {
|
||||
if (this.streamEpisode) return this.streamEpisode.chapters || []
|
||||
return this.media.chapters || []
|
||||
},
|
||||
currentChapter() {
|
||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||
},
|
||||
title() {
|
||||
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
@ -204,14 +213,18 @@ export default {
|
||||
this.$store.commit('setIsPlaying', isPlaying)
|
||||
this.updateMediaSessionPlaybackState()
|
||||
},
|
||||
setSleepTimer(seconds) {
|
||||
setSleepTimer(time) {
|
||||
this.sleepTimerSet = true
|
||||
this.sleepTimerTime = seconds
|
||||
this.sleepTimerRemaining = seconds
|
||||
this.runSleepTimer()
|
||||
this.showSleepTimerModal = false
|
||||
|
||||
this.sleepTimerType = time.timerType
|
||||
if (this.sleepTimerType === this.$constants.SleepTimerTypes.COUNTDOWN) {
|
||||
this.runSleepTimer(time)
|
||||
}
|
||||
},
|
||||
runSleepTimer() {
|
||||
runSleepTimer(time) {
|
||||
this.sleepTimerRemaining = time.seconds
|
||||
|
||||
var lastTick = Date.now()
|
||||
clearInterval(this.sleepTimer)
|
||||
this.sleepTimer = setInterval(() => {
|
||||
@ -220,12 +233,23 @@ export default {
|
||||
this.sleepTimerRemaining -= elapsed / 1000
|
||||
|
||||
if (this.sleepTimerRemaining <= 0) {
|
||||
this.clearSleepTimer()
|
||||
this.playerHandler.pause()
|
||||
this.$toast.info('Sleep Timer Done.. zZzzZz')
|
||||
this.sleepTimerEnd()
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
checkChapterEnd(time) {
|
||||
if (!this.currentChapter) return
|
||||
const chapterEndTime = this.currentChapter.end
|
||||
const tolerance = 0.75
|
||||
if (time >= chapterEndTime - tolerance) {
|
||||
this.sleepTimerEnd()
|
||||
}
|
||||
},
|
||||
sleepTimerEnd() {
|
||||
this.clearSleepTimer()
|
||||
this.playerHandler.pause()
|
||||
this.$toast.info('Sleep Timer Done.. zZzzZz')
|
||||
},
|
||||
cancelSleepTimer() {
|
||||
this.showSleepTimerModal = false
|
||||
this.clearSleepTimer()
|
||||
@ -235,6 +259,7 @@ export default {
|
||||
this.sleepTimerRemaining = 0
|
||||
this.sleepTimer = null
|
||||
this.sleepTimerSet = false
|
||||
this.sleepTimerType = null
|
||||
},
|
||||
incrementSleepTimer(amount) {
|
||||
if (!this.sleepTimerSet) return
|
||||
@ -275,6 +300,10 @@ export default {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setCurrentTime(time)
|
||||
}
|
||||
|
||||
if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER && this.sleepTimerSet) {
|
||||
this.checkChapterEnd(time)
|
||||
}
|
||||
},
|
||||
setDuration(duration) {
|
||||
this.totalDuration = duration
|
||||
|
@ -79,6 +79,14 @@
|
||||
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isBookLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/stats`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isStatsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-symbols text-2xl">monitoring</span>
|
||||
|
||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonStats }}</p>
|
||||
|
||||
<div v-show="isStatsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="abs-icons icon-podcast text-xl"></span>
|
||||
|
||||
@ -103,7 +111,7 @@
|
||||
<div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
|
||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : 'bg-error bg-opacity-20'">
|
||||
<span class="material-symbols text-2xl">warning</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
|
||||
@ -194,6 +202,9 @@ export default {
|
||||
isPlaylistsPage() {
|
||||
return this.paramId === 'playlists'
|
||||
},
|
||||
isStatsPage() {
|
||||
return this.$route.name === 'library-library-stats'
|
||||
},
|
||||
libraryBookshelfPage() {
|
||||
return this.$route.name === 'library-library-bookshelf-id'
|
||||
},
|
||||
|
@ -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: {
|
||||
|
70
client/components/modals/PlayerSettingsModal.vue
Normal file
70
client/components/modals/PlayerSettingsModal.vue
Normal file
@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="player-settings" :width="500" :height="'unset'">
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-4" style="max-height: 80vh; min-height: 40vh">
|
||||
<h3 class="text-xl font-semibold mb-8">{{ $strings.HeaderPlayerSettings }}</h3>
|
||||
<div class="flex items-center mb-4">
|
||||
<ui-toggle-switch v-model="useChapterTrack" @input="setUseChapterTrack" />
|
||||
<div class="pl-4">
|
||||
<span>{{ $strings.LabelUseChapterTrack }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
<ui-select-input v-model="jumpForwardAmount" :label="$strings.LabelJumpForwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpForwardAmount" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<ui-select-input v-model="jumpBackwardAmount" :label="$strings.LabelJumpBackwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpBackwardAmount" />
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
useChapterTrack: false,
|
||||
jumpValues: [
|
||||
{ text: this.$getString('LabelTimeDurationXSeconds', ['10']), value: 10 },
|
||||
{ text: this.$getString('LabelTimeDurationXSeconds', ['15']), value: 15 },
|
||||
{ text: this.$getString('LabelTimeDurationXSeconds', ['30']), value: 30 },
|
||||
{ text: this.$getString('LabelTimeDurationXSeconds', ['60']), value: 60 },
|
||||
{ text: this.$getString('LabelTimeDurationXMinutes', ['2']), value: 120 },
|
||||
{ text: this.$getString('LabelTimeDurationXMinutes', ['5']), value: 300 }
|
||||
],
|
||||
jumpForwardAmount: 10,
|
||||
jumpBackwardAmount: 10
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setUseChapterTrack() {
|
||||
this.$store.dispatch('user/updateUserSettings', { useChapterTrack: this.useChapterTrack })
|
||||
},
|
||||
setJumpForwardAmount(val) {
|
||||
this.jumpForwardAmount = val
|
||||
this.$store.dispatch('user/updateUserSettings', { jumpForwardAmount: val })
|
||||
},
|
||||
setJumpBackwardAmount(val) {
|
||||
this.jumpBackwardAmount = val
|
||||
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
||||
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
||||
}
|
||||
}
|
||||
</script>
|
@ -6,34 +6,36 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div v-if="!timerSet" class="w-full">
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div class="w-full">
|
||||
<template v-for="time in sleepTimes">
|
||||
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time.seconds)">
|
||||
<p class="text-xl text-center">{{ time.text }}</p>
|
||||
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-primary/25 relative" @click="setTime(time)">
|
||||
<p class="text-lg text-center">{{ time.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
|
||||
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" placeholder="Time in minutes" class="w-48" />
|
||||
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" :placeholder="$strings.LabelTimeInMinutes" class="w-48" />
|
||||
<ui-btn color="success" type="submit" :padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else class="w-full p-4">
|
||||
<div class="mb-4 flex items-center justify-center">
|
||||
<ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center mr-4" @click="decrement(30 * 60)">
|
||||
<div v-if="timerSet" class="w-full p-4">
|
||||
<div class="mb-4 h-px w-full bg-white/10" />
|
||||
|
||||
<div v-if="timerType === $constants.SleepTimerTypes.COUNTDOWN" class="mb-4 flex items-center justify-center space-x-4">
|
||||
<ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center h-9" @click="decrement(30 * 60)">
|
||||
<span class="material-symbols text-lg">remove</span>
|
||||
<span class="pl-1 text-base font-mono">30m</span>
|
||||
<span class="pl-1 text-sm">30m</span>
|
||||
</ui-btn>
|
||||
|
||||
<ui-icon-btn icon="remove" @click="decrement(60 * 5)" />
|
||||
<ui-icon-btn icon="remove" class="min-w-9" @click="decrement(60 * 5)" />
|
||||
|
||||
<p class="mx-6 text-2xl font-mono">{{ $secondsToTimestamp(remaining) }}</p>
|
||||
<p class="text-2xl font-mono">{{ $secondsToTimestamp(remaining) }}</p>
|
||||
|
||||
<ui-icon-btn icon="add" @click="increment(60 * 5)" />
|
||||
<ui-icon-btn icon="add" class="min-w-9" @click="increment(60 * 5)" />
|
||||
|
||||
<ui-btn :padding-x="2" small class="flex items-center ml-4" @click="increment(30 * 60)">
|
||||
<ui-btn :padding-x="2" small class="flex items-center h-9" @click="increment(30 * 60)">
|
||||
<span class="material-symbols text-lg">add</span>
|
||||
<span class="pl-1 text-base font-mono">30m</span>
|
||||
<span class="pl-1 text-sm">30m</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<ui-btn class="w-full" @click="$emit('cancel')">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
@ -47,52 +49,13 @@ export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
timerSet: Boolean,
|
||||
timerTime: Number,
|
||||
remaining: Number
|
||||
timerType: String,
|
||||
remaining: Number,
|
||||
hasChapters: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
customTime: null,
|
||||
sleepTimes: [
|
||||
{
|
||||
seconds: 60 * 5,
|
||||
text: '5 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 15,
|
||||
text: '15 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 20,
|
||||
text: '20 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 30,
|
||||
text: '30 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 45,
|
||||
text: '45 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 60,
|
||||
text: '60 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 90,
|
||||
text: '90 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 120,
|
||||
text: '2 hours'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
}
|
||||
customTime: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -103,6 +66,54 @@ export default {
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
sleepTimes() {
|
||||
const times = [
|
||||
{
|
||||
seconds: 60 * 5,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['5']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 15,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['15']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 20,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['20']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 30,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['30']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 45,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['45']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 60,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['60']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 90,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['90']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 120,
|
||||
text: this.$getString('LabelTimeDurationXHours', ['2']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
}
|
||||
]
|
||||
if (this.hasChapters) {
|
||||
times.push({ seconds: -1, text: this.$strings.LabelEndOfChapter, timerType: this.$constants.SleepTimerTypes.CHAPTER })
|
||||
}
|
||||
return times
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -113,10 +124,14 @@ export default {
|
||||
}
|
||||
|
||||
const timeInSeconds = Math.round(Number(this.customTime) * 60)
|
||||
this.setTime(timeInSeconds)
|
||||
const time = {
|
||||
seconds: timeInSeconds,
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
}
|
||||
this.setTime(time)
|
||||
},
|
||||
setTime(seconds) {
|
||||
this.$emit('set', seconds)
|
||||
setTime(time) {
|
||||
this.$emit('set', time)
|
||||
},
|
||||
increment(amount) {
|
||||
this.$emit('increment', amount)
|
||||
|
@ -16,11 +16,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-lg font-semibold mb-2">{{ $strings.HeaderPodcastsToAdd }}</p>
|
||||
<p class="text-lg font-semibold mb-1">{{ $strings.HeaderPodcastsToAdd }}</p>
|
||||
<p class="text-sm text-gray-300 mb-4">{{ $strings.MessageOpmlPreviewNote }}</p>
|
||||
|
||||
<div class="w-full overflow-y-auto" style="max-height: 50vh">
|
||||
<template v-for="(feed, index) in feedMetadata">
|
||||
<cards-podcast-feed-summary-card :key="index" :feed="feed" :library-folder-path="selectedFolderPath" class="my-1" />
|
||||
<template v-for="(feed, index) in feeds">
|
||||
<div :key="index" class="py-1 flex items-center">
|
||||
<p class="text-lg font-semibold">{{ index + 1 }}.</p>
|
||||
<div class="pl-2">
|
||||
<p v-if="feed.title" class="text-sm font-semibold">{{ feed.title }}</p>
|
||||
<p class="text-xs text-gray-400">{{ feed.feedUrl }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@ -45,9 +52,7 @@ export default {
|
||||
return {
|
||||
processing: false,
|
||||
selectedFolderId: null,
|
||||
fullPath: null,
|
||||
autoDownloadEpisodes: false,
|
||||
feedMetadata: []
|
||||
autoDownloadEpisodes: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -96,73 +101,36 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toFeedMetadata(feed) {
|
||||
const metadata = feed.metadata
|
||||
return {
|
||||
title: metadata.title,
|
||||
author: metadata.author,
|
||||
description: metadata.description,
|
||||
releaseDate: '',
|
||||
genres: [...metadata.categories],
|
||||
feedUrl: metadata.feedUrl,
|
||||
imageUrl: metadata.image,
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
language: '',
|
||||
numEpisodes: feed.numEpisodes
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.feedMetadata = this.feeds.map(this.toFeedMetadata)
|
||||
|
||||
if (this.folderItems[0]) {
|
||||
this.selectedFolderId = this.folderItems[0].value
|
||||
}
|
||||
},
|
||||
async submit() {
|
||||
this.processing = true
|
||||
const newFeedPayloads = this.feedMetadata.map((metadata) => {
|
||||
return {
|
||||
path: `${this.selectedFolderPath}/${this.$sanitizeFilename(metadata.title)}`,
|
||||
folderId: this.selectedFolderId,
|
||||
libraryId: this.currentLibrary.id,
|
||||
media: {
|
||||
metadata: {
|
||||
...metadata
|
||||
},
|
||||
autoDownloadEpisodes: this.autoDownloadEpisodes
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('New feed payloads', newFeedPayloads)
|
||||
|
||||
for (const podcastPayload of newFeedPayloads) {
|
||||
await this.$axios
|
||||
.$post('/api/podcasts', podcastPayload)
|
||||
.then(() => {
|
||||
this.$toast.success(`${podcastPayload.media.metadata.title}: ${this.$strings.ToastPodcastCreateSuccess}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastPodcastCreateFailed
|
||||
console.error('Failed to create podcast', podcastPayload, error)
|
||||
this.$toast.error(`${podcastPayload.media.metadata.title}: ${errorMsg}`)
|
||||
})
|
||||
const payload = {
|
||||
feeds: this.feeds.map((f) => f.feedUrl),
|
||||
folderId: this.selectedFolderId,
|
||||
libraryId: this.currentLibrary.id,
|
||||
autoDownloadEpisodes: this.autoDownloadEpisodes
|
||||
}
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$axios
|
||||
.$post('/api/podcasts/opml/create', payload)
|
||||
.then(() => {
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response?.data || this.$strings.ToastPodcastCreateFailed
|
||||
console.error('Failed to create podcast', payload, error)
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#podcast-wrapper {
|
||||
min-height: 400px;
|
||||
max-height: 80vh;
|
||||
}
|
||||
#episodes-scroll {
|
||||
max-height: calc(80vh - 200px);
|
||||
}
|
||||
</style>
|
@ -132,7 +132,7 @@ export default {
|
||||
this.searchedTitle = this.episodeTitle
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$get(`/api/podcasts/${this.libraryItem.id}/search-episode?title=${this.$encodeUriPath(this.episodeTitle)}`)
|
||||
.$get(`/api/podcasts/${this.libraryItem.id}/search-episode?title=${encodeURIComponent(this.episodeTitle)}`)
|
||||
.then((results) => {
|
||||
this.episodesFound = results.episodes.map((ep) => ep.episode)
|
||||
console.log('Episodes found', this.episodesFound)
|
||||
|
@ -7,17 +7,17 @@
|
||||
<span class="material-symbols text-2xl sm:text-3xl">first_page</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip direction="top" :text="$strings.ButtonJumpBackward">
|
||||
<button :aria-label="$strings.ButtonJumpBackward" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">replay_10</span>
|
||||
<ui-tooltip direction="top" :text="jumpBackwardText">
|
||||
<button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">replay</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<button :aria-label="paused ? $strings.ButtonPlay : $strings.ButtonPause" class="p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 lg:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<span class="material-symbols fill text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||
</button>
|
||||
<ui-tooltip direction="top" :text="$strings.ButtonJumpForward">
|
||||
<button :aria-label="$strings.ButtonJumpForward" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">forward_10</span>
|
||||
<ui-tooltip direction="top" :text="jumpForwardText">
|
||||
<button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">forward_media</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip direction="top" :text="$strings.ButtonNextChapter" class="ml-4 lg:ml-8">
|
||||
@ -29,7 +29,7 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||
<span class="material-symbols">autorenew</span>
|
||||
<span class="material-symbols text-2xl">autorenew</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex-grow" />
|
||||
@ -56,6 +56,12 @@ export default {
|
||||
set(val) {
|
||||
this.$emit('update:playbackRate', val)
|
||||
}
|
||||
},
|
||||
jumpForwardText() {
|
||||
return this.getJumpText('jumpForwardAmount', this.$strings.ButtonJumpForward)
|
||||
},
|
||||
jumpBackwardText() {
|
||||
return this.getJumpText('jumpBackwardAmount', this.$strings.ButtonJumpBackward)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -83,6 +89,20 @@ 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() {}
|
||||
|
@ -13,7 +13,7 @@
|
||||
<span v-if="!sleepTimerSet" class="material-symbols text-2xl">snooze</span>
|
||||
<div v-else class="flex items-center">
|
||||
<span class="material-symbols text-lg text-warning">snooze</span>
|
||||
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
|
||||
<p class="text-sm sm:text-lg text-warning font-mono font-semibold text-center px-0.5 sm:pb-0.5 sm:min-w-8">{{ sleepTimerRemainingString }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
@ -36,9 +36,9 @@
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack">
|
||||
<button :aria-label="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack" class="text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
|
||||
<span class="material-symbols text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
|
||||
<ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
|
||||
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerSettings')">
|
||||
<span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@ -72,12 +72,14 @@ export default {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentChapter: Object,
|
||||
bookmarks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
sleepTimerSet: Boolean,
|
||||
sleepTimerRemaining: Number,
|
||||
sleepTimerType: String,
|
||||
isPodcast: Boolean,
|
||||
hideBookmarks: Boolean,
|
||||
hideSleepTimer: Boolean
|
||||
@ -90,27 +92,34 @@ export default {
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
useChapterTrack: false
|
||||
duration: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
playbackRate() {
|
||||
this.updateTimestamp()
|
||||
},
|
||||
useChapterTrack() {
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||
this.updateTimestamp()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sleepTimerRemainingString() {
|
||||
var rounded = Math.round(this.sleepTimerRemaining)
|
||||
if (rounded < 90) {
|
||||
return `${rounded}s`
|
||||
if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER) {
|
||||
return 'EoC'
|
||||
} else {
|
||||
var rounded = Math.round(this.sleepTimerRemaining)
|
||||
if (rounded < 90) {
|
||||
return `${rounded}s`
|
||||
}
|
||||
var minutesRounded = Math.round(rounded / 60)
|
||||
if (minutesRounded <= 90) {
|
||||
return `${minutesRounded}m`
|
||||
}
|
||||
var hoursRounded = Math.round(minutesRounded / 60)
|
||||
return `${hoursRounded}h`
|
||||
}
|
||||
var minutesRounded = Math.round(rounded / 60)
|
||||
if (minutesRounded < 90) {
|
||||
return `${minutesRounded}m`
|
||||
}
|
||||
var hoursRounded = Math.round(minutesRounded / 60)
|
||||
return `${hoursRounded}h`
|
||||
},
|
||||
token() {
|
||||
return this.$store.getters['user/getToken']
|
||||
@ -135,9 +144,6 @@ export default {
|
||||
if (!duration) return 0
|
||||
return Math.round((100 * time) / duration)
|
||||
},
|
||||
currentChapter() {
|
||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||
},
|
||||
currentChapterName() {
|
||||
return this.currentChapter ? this.currentChapter.title : ''
|
||||
},
|
||||
@ -162,6 +168,10 @@ export default {
|
||||
},
|
||||
playerQueueItems() {
|
||||
return this.$store.state.playerQueueItems || []
|
||||
},
|
||||
useChapterTrack() {
|
||||
const _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
|
||||
return this.chapters.length ? _useChapterTrack : false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -310,9 +320,6 @@ export default {
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
|
||||
const _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
|
||||
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
|
||||
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
},
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
||||
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
|
||||
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-symbols" :class="iconClass">more_vert</span>
|
||||
<span class="material-symbols text-2xl" :class="iconClass">more_vert</span>
|
||||
</button>
|
||||
<div v-else class="h-full w-full flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
|
151
client/components/ui/SelectInput.vue
Normal file
151
client/components/ui/SelectInput.vue
Normal file
@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button ref="buttonWrapper" type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
||||
<span v-if="selectedSubtext">: </span>
|
||||
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span class="material-symbols text-2xl">expand_more</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-60 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox" :style="{ maxHeight: menuMaxHeight }" v-click-outside="clickOutsideObj">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click.stop.prevent="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
|
||||
<span v-if="item.subtext">: </span>
|
||||
<span v-if="item.subtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ item.subtext }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
disabled: Boolean,
|
||||
small: Boolean,
|
||||
menuMaxHeight: {
|
||||
type: String,
|
||||
default: '224px'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
clickOutsideObj: {
|
||||
handler: this.clickedOutside,
|
||||
events: ['click'],
|
||||
isActive: true
|
||||
},
|
||||
menu: null,
|
||||
showMenu: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
itemsToShow() {
|
||||
return this.items.map((i) => {
|
||||
if (typeof i === 'string' || typeof i === 'number') {
|
||||
return {
|
||||
text: i,
|
||||
value: i
|
||||
}
|
||||
}
|
||||
return i
|
||||
})
|
||||
},
|
||||
selectedItem() {
|
||||
return this.itemsToShow.find((i) => i.value === this.selected)
|
||||
},
|
||||
selectedText() {
|
||||
return this.selectedItem ? this.selectedItem.text : ''
|
||||
},
|
||||
selectedSubtext() {
|
||||
return this.selectedItem ? this.selectedItem.subtext : ''
|
||||
},
|
||||
buttonClass() {
|
||||
var classes = []
|
||||
if (this.small) classes.push('h-9')
|
||||
else classes.push('h-10')
|
||||
|
||||
if (this.disabled) classes.push('cursor-not-allowed border-gray-600 bg-primary bg-opacity-70 border-opacity-70 text-gray-400')
|
||||
else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')
|
||||
|
||||
return classes.join(' ')
|
||||
},
|
||||
longLabel() {
|
||||
let result = ''
|
||||
if (this.label) result += this.label + ': '
|
||||
if (this.selectedText) result += this.selectedText
|
||||
if (this.selectedSubtext) result += ' ' + this.selectedSubtext
|
||||
return result
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
recalcMenuPos() {
|
||||
if (!this.menu || !this.$refs.buttonWrapper) return
|
||||
const boundingBox = this.$refs.buttonWrapper.getBoundingClientRect()
|
||||
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
|
||||
this.menu.style.left = boundingBox.x + 'px'
|
||||
this.menu.style.width = boundingBox.width + 'px'
|
||||
},
|
||||
unmountMountMenu() {
|
||||
if (!this.$refs.menu || !this.$refs.buttonWrapper) return
|
||||
this.menu = this.$refs.menu
|
||||
this.menu.remove()
|
||||
},
|
||||
clickShowMenu() {
|
||||
if (this.disabled) return
|
||||
if (!this.showMenu) this.handleShowMenu()
|
||||
else this.handleCloseMenu()
|
||||
},
|
||||
handleShowMenu() {
|
||||
if (!this.menu) {
|
||||
this.unmountMountMenu()
|
||||
}
|
||||
document.body.appendChild(this.menu)
|
||||
this.recalcMenuPos()
|
||||
this.showMenu = true
|
||||
},
|
||||
handleCloseMenu() {
|
||||
this.showMenu = false
|
||||
if (this.menu) this.menu.remove()
|
||||
},
|
||||
clickedOutside() {
|
||||
this.handleCloseMenu()
|
||||
},
|
||||
clickedOption(itemValue) {
|
||||
this.selected = itemValue
|
||||
this.handleCloseMenu()
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
if (this.menu) this.menu.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
@ -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
|
||||
|
@ -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 * * *'
|
||||
}
|
||||
},
|
||||
|
@ -1,175 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderLibraryStats + ': ' + currentLibraryName">
|
||||
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
|
||||
|
||||
<div class="flex lg:flex-row flex-wrap justify-between flex-col mt-8">
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop5Genres }}</h1>
|
||||
<p v-if="!top5Genres.length">{{ $strings.MessageNoGenres }}</p>
|
||||
<template v-for="genre in top5Genres">
|
||||
<div :key="genre.genre" class="w-full py-2">
|
||||
<div class="flex items-end mb-1">
|
||||
<p class="text-2xl font-bold">{{ Math.round((100 * genre.count) / totalItems) }} %</p>
|
||||
<div class="flex-grow" />
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`" class="text-base text-white text-opacity-70 hover:underline">
|
||||
{{ genre.genre }}
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="w-full rounded-full h-3 bg-primary bg-opacity-50 overflow-hidden">
|
||||
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * genre.count) / totalItems) + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop10Authors }}</h1>
|
||||
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
|
||||
<template v-for="(author, index) in top10Authors">
|
||||
<div :key="author.id" class="w-full py-2">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="text-sm text-white text-opacity-70 w-36 pr-2 truncate">
|
||||
{{ index + 1 }}. <nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}</nuxt-link>
|
||||
</p>
|
||||
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
|
||||
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * author.count) / mostUsedAuthorCount) + '%' }" />
|
||||
</div>
|
||||
<div class="w-4 ml-3">
|
||||
<p class="text-sm font-bold">{{ author.count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLongestItems }}</h1>
|
||||
<p v-if="!top10LongestItems.length">{{ $strings.MessageNoItems }}</p>
|
||||
<template v-for="(ab, index) in top10LongestItems">
|
||||
<div :key="index" class="w-full py-2">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
|
||||
{{ index + 1 }}. <nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
|
||||
</p>
|
||||
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
|
||||
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.duration) / longestItemDuration) + '%' }" />
|
||||
</div>
|
||||
<div class="w-4 ml-3">
|
||||
<p class="text-sm font-bold">{{ (ab.duration / 3600).toFixed(1) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLargestItems }}</h1>
|
||||
<p v-if="!top10LargestItems.length">{{ $strings.MessageNoItems }}</p>
|
||||
<template v-for="(ab, index) in top10LargestItems">
|
||||
<div :key="index" class="w-full py-2">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
|
||||
{{ index + 1 }}. <nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
|
||||
</p>
|
||||
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
|
||||
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.size) / largestItemSize) + '%' }" />
|
||||
</div>
|
||||
<div class="w-4 ml-3">
|
||||
<p class="text-sm font-bold">{{ $bytesPretty(ab.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ redirect, store }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
return
|
||||
}
|
||||
|
||||
if (!store.state.libraries.currentLibraryId) {
|
||||
return redirect('/config')
|
||||
}
|
||||
return {}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
libraryStats: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentLibraryId(newVal, oldVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
totalItems() {
|
||||
return this.libraryStats?.totalItems || 0
|
||||
},
|
||||
genresWithCount() {
|
||||
return this.libraryStats?.genresWithCount || []
|
||||
},
|
||||
top5Genres() {
|
||||
return this.genresWithCount?.slice(0, 5) || []
|
||||
},
|
||||
top10LongestItems() {
|
||||
return this.libraryStats?.longestItems || []
|
||||
},
|
||||
longestItemDuration() {
|
||||
if (!this.top10LongestItems.length) return 0
|
||||
return this.top10LongestItems[0].duration
|
||||
},
|
||||
top10LargestItems() {
|
||||
return this.libraryStats?.largestItems || []
|
||||
},
|
||||
largestItemSize() {
|
||||
if (!this.top10LargestItems.length) return 0
|
||||
return this.top10LargestItems[0].size
|
||||
},
|
||||
authorsWithCount() {
|
||||
return this.libraryStats?.authorsWithCount || []
|
||||
},
|
||||
mostUsedAuthorCount() {
|
||||
if (!this.authorsWithCount.length) return 0
|
||||
return this.authorsWithCount[0].count
|
||||
},
|
||||
top10Authors() {
|
||||
return this.authorsWithCount?.slice(0, 10) || []
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
currentLibraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.currentLibraryMediaType === 'book'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async init() {
|
||||
this.libraryStats = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/stats`).catch((err) => {
|
||||
console.error('Failed to get library stats', err)
|
||||
var errorMsg = err.response ? err.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.$toast.error(`Failed to get library stats: ${errorMsg}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
@ -121,7 +121,7 @@
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
|
||||
<template #default="{ showMenu, clickShowMenu, disabled }">
|
||||
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-symbols">more_horiz</span>
|
||||
<span class="material-symbols text-2xl">more_horiz</span>
|
||||
</button>
|
||||
</template>
|
||||
</ui-context-menu-dropdown>
|
||||
|
@ -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
|
||||
|
181
client/pages/library/_library/stats.vue
Normal file
181
client/pages/library/_library/stats.vue
Normal file
@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="page relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<app-book-shelf-toolbar page="library-stats" is-home />
|
||||
<div id="bookshelf" class="w-full h-full px-1 py-4 md:p-8 relative overflow-y-auto">
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
|
||||
|
||||
<div class="flex lg:flex-row flex-wrap justify-between flex-col mt-8">
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop5Genres }}</h1>
|
||||
<p v-if="!top5Genres.length">{{ $strings.MessageNoGenres }}</p>
|
||||
<template v-for="genre in top5Genres">
|
||||
<div :key="genre.genre" class="w-full py-2">
|
||||
<div class="flex items-end mb-1">
|
||||
<p class="text-2xl font-bold">{{ Math.round((100 * genre.count) / totalItems) }} %</p>
|
||||
<div class="flex-grow" />
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`" class="text-base text-white text-opacity-70 hover:underline">
|
||||
{{ genre.genre }}
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="w-full rounded-full h-3 bg-primary bg-opacity-50 overflow-hidden">
|
||||
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * genre.count) / totalItems) + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop10Authors }}</h1>
|
||||
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
|
||||
<template v-for="(author, index) in top10Authors">
|
||||
<div :key="author.id" class="w-full py-2">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="text-sm text-white text-opacity-70 w-36 pr-2 truncate">
|
||||
{{ index + 1 }}. <nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}</nuxt-link>
|
||||
</p>
|
||||
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
|
||||
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * author.count) / mostUsedAuthorCount) + '%' }" />
|
||||
</div>
|
||||
<div class="w-4 ml-3">
|
||||
<p class="text-sm font-bold">{{ author.count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLongestItems }}</h1>
|
||||
<p v-if="!top10LongestItems.length">{{ $strings.MessageNoItems }}</p>
|
||||
<template v-for="(ab, index) in top10LongestItems">
|
||||
<div :key="index" class="w-full py-2">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
|
||||
{{ index + 1 }}. <nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
|
||||
</p>
|
||||
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
|
||||
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.duration) / longestItemDuration) + '%' }" />
|
||||
</div>
|
||||
<div class="w-4 ml-3">
|
||||
<p class="text-sm font-bold">{{ (ab.duration / 3600).toFixed(1) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLargestItems }}</h1>
|
||||
<p v-if="!top10LargestItems.length">{{ $strings.MessageNoItems }}</p>
|
||||
<template v-for="(ab, index) in top10LargestItems">
|
||||
<div :key="index" class="w-full py-2">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
|
||||
{{ index + 1 }}. <nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
|
||||
</p>
|
||||
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
|
||||
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.size) / largestItemSize) + '%' }" />
|
||||
</div>
|
||||
<div class="w-4 ml-3">
|
||||
<p class="text-sm font-bold">{{ $bytesPretty(ab.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ redirect, store }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
return
|
||||
}
|
||||
|
||||
if (!store.state.libraries.currentLibraryId) {
|
||||
return redirect('/config')
|
||||
}
|
||||
return {}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
libraryStats: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentLibraryId(newVal, oldVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
totalItems() {
|
||||
return this.libraryStats?.totalItems || 0
|
||||
},
|
||||
genresWithCount() {
|
||||
return this.libraryStats?.genresWithCount || []
|
||||
},
|
||||
top5Genres() {
|
||||
return this.genresWithCount?.slice(0, 5) || []
|
||||
},
|
||||
top10LongestItems() {
|
||||
return this.libraryStats?.longestItems || []
|
||||
},
|
||||
longestItemDuration() {
|
||||
if (!this.top10LongestItems.length) return 0
|
||||
return this.top10LongestItems[0].duration
|
||||
},
|
||||
top10LargestItems() {
|
||||
return this.libraryStats?.largestItems || []
|
||||
},
|
||||
largestItemSize() {
|
||||
if (!this.top10LargestItems.length) return 0
|
||||
return this.top10LargestItems[0].size
|
||||
},
|
||||
authorsWithCount() {
|
||||
return this.libraryStats?.authorsWithCount || []
|
||||
},
|
||||
mostUsedAuthorCount() {
|
||||
if (!this.authorsWithCount.length) return 0
|
||||
return this.authorsWithCount[0].count
|
||||
},
|
||||
top10Authors() {
|
||||
return this.authorsWithCount?.slice(0, 10) || []
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
currentLibraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.currentLibraryMediaType === 'book'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async init() {
|
||||
this.libraryStats = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/stats`).catch((err) => {
|
||||
console.error('Failed to get library stats', err)
|
||||
var errorMsg = err.response ? err.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.$toast.error(`Failed to get library stats: ${errorMsg}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
@ -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) {
|
||||
|
@ -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 = {
|
||||
|
@ -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
|
||||
|
@ -14,7 +14,9 @@ export const state = () => ({
|
||||
seriesSortDesc: false,
|
||||
seriesFilterBy: 'all',
|
||||
authorSortBy: 'name',
|
||||
authorSortDesc: false
|
||||
authorSortDesc: false,
|
||||
jumpForwardAmount: 10,
|
||||
jumpBackwardAmount: 10,
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -258,6 +258,7 @@
|
||||
"LabelCurrently": "Actuellement :",
|
||||
"LabelCustomCronExpression": "Expression cron personnalisée :",
|
||||
"LabelDatetime": "Date",
|
||||
"LabelDays": "Jours",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)",
|
||||
"LabelDescription": "Description",
|
||||
"LabelDeselectAll": "Tout déselectionner",
|
||||
@ -321,6 +322,7 @@
|
||||
"LabelHighestPriority": "Priorité la plus élevée",
|
||||
"LabelHost": "Hôte",
|
||||
"LabelHour": "Heure",
|
||||
"LabelHours": "Heures",
|
||||
"LabelIcon": "Icône",
|
||||
"LabelImageURLFromTheWeb": "URL de l’image à partir du web",
|
||||
"LabelInProgress": "En cours",
|
||||
@ -371,6 +373,7 @@
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "Les sources de métadonnées ayant une priorité plus élevée auront la priorité sur celles ayant une priorité moins élevée",
|
||||
"LabelMetadataProvider": "Fournisseur de métadonnées",
|
||||
"LabelMinute": "Minute",
|
||||
"LabelMinutes": "Minutes",
|
||||
"LabelMissing": "Manquant",
|
||||
"LabelMissingEbook": "Ne possède aucun livre numérique",
|
||||
"LabelMissingSupplementaryEbook": "Ne possède aucun livre numérique supplémentaire",
|
||||
@ -410,6 +413,7 @@
|
||||
"LabelOverwrite": "Écraser",
|
||||
"LabelPassword": "Mot de passe",
|
||||
"LabelPath": "Chemin",
|
||||
"LabelPermanent": "Permanent",
|
||||
"LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque",
|
||||
"LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes",
|
||||
"LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint",
|
||||
@ -507,6 +511,9 @@
|
||||
"LabelSettingsStoreMetadataWithItem": "Enregistrer les métadonnées avec l’élément",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les fichiers de métadonnées sont stockés dans /metadata/items. En activant ce paramètre, les fichiers de métadonnées seront stockés dans les dossiers des éléments de votre bibliothèque",
|
||||
"LabelSettingsTimeFormat": "Format d’heure",
|
||||
"LabelShare": "Partager",
|
||||
"LabelShareOpen": "Ouvrir le partage",
|
||||
"LabelShareURL": "Partager l’URL",
|
||||
"LabelShowAll": "Tout afficher",
|
||||
"LabelShowSeconds": "Afficher les seondes",
|
||||
"LabelSize": "Taille",
|
||||
@ -598,6 +605,7 @@
|
||||
"MessageAppriseDescription": "Nécessite une instance d’<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.<br />L’URL de l’API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression des utilisateurs, les détails des éléments de la bibliothèque, les paramètres du serveur et les images stockées dans <code>/metadata/items</code> & <code>/metadata/authors</code>. Les sauvegardes <strong>n’incluent pas</strong> les fichiers stockés dans les dossiers de votre bibliothèque.",
|
||||
"MessageBackupsLocationEditNote": "Remarque : Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes",
|
||||
"MessageBackupsLocationNoEditNote": "Remarque : l’emplacement de sauvegarde est défini via une variable d’environnement et ne peut pas être modifié ici.",
|
||||
"MessageBackupsLocationPathEmpty": "L'emplacement de secours ne peut pas être vide",
|
||||
"MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et métadonnées manquantes pour les éléments sélectionnés. Activez les options ci-dessous pour permettre la Recherche par correspondance d’écraser les couvertures et/ou métadonnées existantes.",
|
||||
"MessageBookshelfNoCollections": "Vous n’avez pas encore de collections",
|
||||
@ -716,6 +724,9 @@
|
||||
"MessageSelected": "{0} sélectionnés",
|
||||
"MessageServerCouldNotBeReached": "Serveur inaccessible",
|
||||
"MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre",
|
||||
"MessageShareExpirationWillBe": "Expire le <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "Expire dans {0}",
|
||||
"MessageShareURLWillBe": "L’adresse de partage sera <strong>{0}</strong>",
|
||||
"MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?",
|
||||
"MessageThinking": "Je cherche…",
|
||||
"MessageUploaderItemFailed": "Échec du téléversement",
|
||||
@ -730,7 +741,7 @@
|
||||
"NoteChapterEditorTimes": "Information : l’horodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du livre audio.",
|
||||
"NoteFolderPicker": "Information : les dossiers déjà surveillés ne sont pas affichés",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.",
|
||||
"NoteUploaderOnlyAudioFiles": "Si vous téléversez uniquement des fichiers audio, chaque fichier audio sera traité comme un livre audio distinct.",
|
||||
"NoteUploaderUnsupportedFiles": "Les fichiers non pris en charge sont ignorés. Lorsque vous choisissez ou déposez un dossier, les autres fichiers qui ne sont pas dans un dossier d’élément sont ignorés.",
|
||||
|
@ -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": "קצה תקשורת חובר",
|
||||
|
@ -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",
|
||||
|
@ -62,8 +62,8 @@
|
||||
"ButtonQuickMatch": "Szybkie dopasowanie",
|
||||
"ButtonReScan": "Ponowne skanowanie",
|
||||
"ButtonRead": "Czytaj",
|
||||
"ButtonReadLess": "Read less",
|
||||
"ButtonReadMore": "Read more",
|
||||
"ButtonReadLess": "Pokaż mniej",
|
||||
"ButtonReadMore": "Pokaż więcej",
|
||||
"ButtonRefresh": "Odśwież",
|
||||
"ButtonRemove": "Usuń",
|
||||
"ButtonRemoveAll": "Usuń wszystko",
|
||||
@ -88,6 +88,7 @@
|
||||
"ButtonShow": "Pokaż",
|
||||
"ButtonStartM4BEncode": "Eksportuj jako plik M4B",
|
||||
"ButtonStartMetadataEmbed": "Osadź metadane",
|
||||
"ButtonStats": "Statystyki",
|
||||
"ButtonSubmit": "Zaloguj",
|
||||
"ButtonTest": "Test",
|
||||
"ButtonUpload": "Wgraj",
|
||||
@ -130,13 +131,13 @@
|
||||
"HeaderIgnoredFiles": "Zignoruj pliki",
|
||||
"HeaderItemFiles": "Pliki",
|
||||
"HeaderItemMetadataUtils": "Item Metadata Utils",
|
||||
"HeaderLastListeningSession": "Ostatnio odtwarzana sesja",
|
||||
"HeaderLastListeningSession": "Ostatnia sesja słuchania",
|
||||
"HeaderLatestEpisodes": "Najnowsze odcinki",
|
||||
"HeaderLibraries": "Biblioteki",
|
||||
"HeaderLibraryFiles": "Pliki w bibliotece",
|
||||
"HeaderLibraryStats": "Statystyki biblioteki",
|
||||
"HeaderListeningSessions": "Sesje słuchania",
|
||||
"HeaderListeningStats": "Statystyki odtwarzania",
|
||||
"HeaderListeningStats": "Statystyki słuchania",
|
||||
"HeaderLogin": "Zaloguj się",
|
||||
"HeaderLogs": "Logi",
|
||||
"HeaderManageGenres": "Zarządzaj gatunkami",
|
||||
@ -148,12 +149,13 @@
|
||||
"HeaderNewAccount": "Nowe konto",
|
||||
"HeaderNewLibrary": "Nowa biblioteka",
|
||||
"HeaderNotifications": "Powiadomienia",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenIDConnectAuthentication": "Uwierzytelnianie OpenID Connect",
|
||||
"HeaderOpenRSSFeed": "Utwórz kanał RSS",
|
||||
"HeaderOtherFiles": "Inne pliki",
|
||||
"HeaderPasswordAuthentication": "Uwierzytelnianie hasłem",
|
||||
"HeaderPermissions": "Uprawnienia",
|
||||
"HeaderPlayerQueue": "Kolejka odtwarzania",
|
||||
"HeaderPlayerSettings": "Ustawienia Odtwarzania",
|
||||
"HeaderPlaylist": "Playlista",
|
||||
"HeaderPlaylistItems": "Pozycje listy odtwarzania",
|
||||
"HeaderPodcastsToAdd": "Podcasty do dodania",
|
||||
@ -175,7 +177,7 @@
|
||||
"HeaderSettingsScanner": "Skanowanie",
|
||||
"HeaderSleepTimer": "Wyłącznik czasowy",
|
||||
"HeaderStatsLargestItems": "Największe pozycje",
|
||||
"HeaderStatsLongestItems": "Najdłuższe pozycje (hrs)",
|
||||
"HeaderStatsLongestItems": "Najdłuższe pozycje (godziny)",
|
||||
"HeaderStatsMinutesListeningChart": "Czas słuchania w minutach (ostatnie 7 dni)",
|
||||
"HeaderStatsRecentSessions": "Ostatnie sesje",
|
||||
"HeaderStatsTop10Authors": "Top 10 Autorów",
|
||||
@ -200,8 +202,8 @@
|
||||
"LabelActivity": "Aktywność",
|
||||
"LabelAddToCollection": "Dodaj do kolekcji",
|
||||
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
|
||||
"LabelAddToPlaylist": "Add to Playlist",
|
||||
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
|
||||
"LabelAddToPlaylist": "Dodaj do playlisty",
|
||||
"LabelAddToPlaylistBatch": "Dodaj {0} pozycji do playlisty",
|
||||
"LabelAdded": "Dodane",
|
||||
"LabelAddedAt": "Dodano",
|
||||
"LabelAdminUsersOnly": "Tylko użytkownicy administracyjni",
|
||||
@ -226,14 +228,14 @@
|
||||
"LabelBackupLocation": "Lokalizacja kopii zapasowej",
|
||||
"LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Kopie zapasowe są zapisywane w folderze /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Maksymalny łączny rozmiar backupów (w GB)",
|
||||
"LabelBackupsMaxBackupSize": "Maksymalny rozmiar kopii zapasowej (w GB)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "Jako zabezpieczenie przed błędną konfiguracją, kopie zapasowe nie będą wykonywane, jeśli przekroczą skonfigurowany rozmiar.",
|
||||
"LabelBackupsNumberToKeep": "Liczba kopii zapasowych do przechowywania",
|
||||
"LabelBackupsNumberToKeepHelp": "Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Książki",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelByAuthor": "by {0}",
|
||||
"LabelByAuthor": "autorstwa {0}",
|
||||
"LabelChangePassword": "Zmień hasło",
|
||||
"LabelChannels": "Kanały",
|
||||
"LabelChapterTitle": "Tytuł rozdziału",
|
||||
@ -247,7 +249,7 @@
|
||||
"LabelCollections": "Kolekcje",
|
||||
"LabelComplete": "Ukończone",
|
||||
"LabelConfirmPassword": "Potwierdź hasło",
|
||||
"LabelContinueListening": "Kontynuuj odtwarzanie",
|
||||
"LabelContinueListening": "Kontynuuj słuchanie",
|
||||
"LabelContinueReading": "Kontynuuj czytanie",
|
||||
"LabelContinueSeries": "Kontynuuj serię",
|
||||
"LabelCover": "Okładka",
|
||||
@ -319,6 +321,7 @@
|
||||
"LabelHardDeleteFile": "Usuń trwale plik",
|
||||
"LabelHasEbook": "Ma ebooka",
|
||||
"LabelHasSupplementaryEbook": "Posiada dodatkowy ebook",
|
||||
"LabelHideSubtitles": "Ukryj napisy",
|
||||
"LabelHighestPriority": "Najwyższy priorytet",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Godzina",
|
||||
@ -413,7 +416,7 @@
|
||||
"LabelOverwrite": "Nadpisz",
|
||||
"LabelPassword": "Hasło",
|
||||
"LabelPath": "Ścieżka",
|
||||
"LabelPermanent": "Trwały",
|
||||
"LabelPermanent": "Stałe",
|
||||
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
|
||||
"LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów",
|
||||
"LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite",
|
||||
@ -446,6 +449,7 @@
|
||||
"LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu",
|
||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||
"LabelRSSFeedURL": "URL kanały RSS",
|
||||
"LabelReAddSeriesToContinueListening": "Ponownie Dodaj Serię do sekcji Kontunuuj Odtwarzanie",
|
||||
"LabelRead": "Czytaj",
|
||||
"LabelReadAgain": "Czytaj ponownie",
|
||||
"LabelReadEbookWithoutProgress": "Czytaj książkę bez zapamiętywania postępu",
|
||||
@ -516,6 +520,7 @@
|
||||
"LabelShareURL": "Link do udziału",
|
||||
"LabelShowAll": "Pokaż wszystko",
|
||||
"LabelShowSeconds": "Pokaż sekundy",
|
||||
"LabelShowSubtitles": "Pokaż Napisy",
|
||||
"LabelSize": "Rozmiar",
|
||||
"LabelSleepTimer": "Wyłącznik czasowy",
|
||||
"LabelSlug": "Slug",
|
||||
@ -534,10 +539,10 @@
|
||||
"LabelStatsItemsFinished": "Pozycje zakończone",
|
||||
"LabelStatsItemsInLibrary": "Pozycje w bibliotece",
|
||||
"LabelStatsMinutes": "Minuty",
|
||||
"LabelStatsMinutesListening": "Minuty odtwarzania",
|
||||
"LabelStatsMinutesListening": "Minuty słuchania",
|
||||
"LabelStatsOverallDays": "Całkowity czas (dni)",
|
||||
"LabelStatsOverallHours": "Całkowity czas (godziny)",
|
||||
"LabelStatsWeekListening": "Tydzień odtwarzania",
|
||||
"LabelStatsWeekListening": "Tydzień słuchania",
|
||||
"LabelSubtitle": "Podtytuł",
|
||||
"LabelSupportedFileTypes": "Obsługiwane typy plików",
|
||||
"LabelTag": "Tag",
|
||||
@ -592,6 +597,7 @@
|
||||
"LabelVersion": "Wersja",
|
||||
"LabelViewBookmarks": "Wyświetlaj zakładki",
|
||||
"LabelViewChapters": "Wyświetlaj rozdziały",
|
||||
"LabelViewPlayerSettings": "Zobacz ustawienia odtwarzacza",
|
||||
"LabelViewQueue": "Wyświetlaj kolejkę odtwarzania",
|
||||
"LabelVolume": "Głośność",
|
||||
"LabelWeekdaysToRun": "Dni tygodnia",
|
||||
@ -642,7 +648,7 @@
|
||||
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
|
||||
"MessageConfirmRemoveListeningSessions": "Czy na pewno chcesz usunąć {0} sesji słuchania?",
|
||||
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Czy jesteś pewien, że chcesz usunąć twoją playlistę \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
||||
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
|
||||
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
|
||||
@ -663,7 +669,7 @@
|
||||
"MessageItemsSelected": "{0} zaznaczone elementy",
|
||||
"MessageItemsUpdated": "{0} Items Updated",
|
||||
"MessageJoinUsOn": "Dołącz do nas na",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku",
|
||||
"MessageListeningSessionsInTheLastYear": "Sesje słuchania w ostatnim roku: {0}",
|
||||
"MessageLoading": "Ładowanie...",
|
||||
"MessageLoadingFolders": "Ładowanie folderów...",
|
||||
"MessageLogsDescription": "Logi zapisane są w <code>/metadata/logs</code> jako pliki JSON. Logi awaryjne są zapisane w <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@ -692,7 +698,7 @@
|
||||
"MessageNoIssues": "Brak problemów",
|
||||
"MessageNoItems": "Brak elementów",
|
||||
"MessageNoItemsFound": "Nie znaleziono żadnych elementów",
|
||||
"MessageNoListeningSessions": "Brak sesji odtwarzania",
|
||||
"MessageNoListeningSessions": "Brak sesji słuchania",
|
||||
"MessageNoLogs": "Brak logów",
|
||||
"MessageNoMediaProgress": "Brak postępu",
|
||||
"MessageNoNotifications": "Brak powiadomień",
|
||||
@ -709,7 +715,7 @@
|
||||
"MessageOr": "lub",
|
||||
"MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
|
||||
"MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału",
|
||||
"MessagePlaylistCreateFromCollection": "Utwórz listę odtwarznia na podstawie kolekcji",
|
||||
"MessagePlaylistCreateFromCollection": "Utwórz listę odtwarzania na podstawie kolekcji",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nie ma adresu url kanału RSS, który mógłby zostać użyty do dopasowania",
|
||||
"MessageQuickMatchDescription": "Wypełnij puste informacje i okładkę pierwszym wynikiem dopasowania z '{0}'. Nie nadpisuje szczegółów, chyba że włączone jest ustawienie serwera 'Preferuj dopasowane metadane'.",
|
||||
"MessageRemoveChapter": "Usuń rozdział",
|
||||
@ -724,8 +730,9 @@
|
||||
"MessageSelected": "{0} wybranych",
|
||||
"MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem",
|
||||
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
||||
"MessageShareExpirationWillBe": "Czas udostępniania <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "Wygaśnie za {0}",
|
||||
"MessageShareURLWillBe": "URL udziału będzie <strong>{0}</strong>",
|
||||
"MessageShareURLWillBe": "Udostępnione pod linkiem <strong>{0}</strong>",
|
||||
"MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
|
||||
"MessageThinking": "Myślę...",
|
||||
"MessageUploaderItemFailed": "Nie udało się przesłać",
|
||||
@ -746,7 +753,7 @@
|
||||
"NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.",
|
||||
"PlaceholderNewCollection": "Nowa nazwa kolekcji",
|
||||
"PlaceholderNewFolderPath": "Nowa ścieżka folderu",
|
||||
"PlaceholderNewPlaylist": "New playlist name",
|
||||
"PlaceholderNewPlaylist": "Nowa nazwa playlisty",
|
||||
"PlaceholderSearch": "Szukanie..",
|
||||
"PlaceholderSearchEpisode": "Szukanie odcinka..",
|
||||
"ToastAccountUpdateFailed": "Nie udało się zaktualizować konta",
|
||||
@ -802,12 +809,12 @@
|
||||
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
|
||||
"ToastLibraryUpdateFailed": "Nie udało się zaktualizować biblioteki",
|
||||
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
|
||||
"ToastPlaylistCreateFailed": "Failed to create playlist",
|
||||
"ToastPlaylistCreateSuccess": "Playlist created",
|
||||
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
|
||||
"ToastPlaylistRemoveSuccess": "Playlist removed",
|
||||
"ToastPlaylistUpdateFailed": "Failed to update playlist",
|
||||
"ToastPlaylistUpdateSuccess": "Playlist updated",
|
||||
"ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty",
|
||||
"ToastPlaylistCreateSuccess": "Playlista utworzona",
|
||||
"ToastPlaylistRemoveFailed": "Nie udało się usunąć playlisty",
|
||||
"ToastPlaylistRemoveSuccess": "Playlista usunięta",
|
||||
"ToastPlaylistUpdateFailed": "Nie udało się zaktualizować playlisty",
|
||||
"ToastPlaylistUpdateSuccess": "Playlista zaktualizowana",
|
||||
"ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
|
||||
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
|
||||
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
|
||||
|
@ -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',
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -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
|
||||
|
@ -23,8 +23,8 @@ class TaskManager {
|
||||
* @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())
|
||||
}
|
||||
}
|
||||
@ -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()
|
@ -60,7 +60,7 @@ class Library extends Model {
|
||||
/**
|
||||
* Convert expanded Library to oldLibrary
|
||||
* @param {Library} libraryExpanded
|
||||
* @returns {Promise<oldLibrary>}
|
||||
* @returns {oldLibrary}
|
||||
*/
|
||||
static getOldLibrary(libraryExpanded) {
|
||||
const folders = libraryExpanded.libraryFolders.map((folder) => {
|
||||
|
@ -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
|
||||
|
@ -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))
|
||||
|
@ -2,7 +2,7 @@ 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
|
||||
@ -15,11 +15,13 @@ class NfoFileScanner {
|
||||
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]
|
||||
|
@ -81,6 +81,10 @@ function parseNfoMetadata(nfoText) {
|
||||
case 'isbn-13':
|
||||
metadata.isbn = value
|
||||
break
|
||||
case 'language':
|
||||
case 'lang':
|
||||
metadata.language = value
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user