Compare commits

..

17 Commits

Author SHA1 Message Date
advplyr
3cae110360 Merge branch 'master' into plugin-implementation-demo 2024-12-30 13:48:02 -06:00
advplyr
c60d74774a Merge branch 'master' into plugin-implementation-demo 2024-12-26 15:38:28 -06:00
advplyr
7557f3e2b9 Fix plugin scan to not include subdirs, update external plugin path env variable to DEV_PLUGINS_PATH to support a folder of dev plugins 2024-12-24 13:04:54 -06:00
advplyr
5f680d7277 Allow env variable to point to specific plugin path for debugging 2024-12-23 16:53:47 -06:00
advplyr
cbbdb0ec29 Update plugin extension prompt yes button text 2024-12-22 16:31:02 -06:00
advplyr
c8682c8456 Add minimal template plugin 2024-12-22 16:01:55 -06:00
advplyr
e7e0056288 Update plugins to only be enabled when ALLOW_PLUGINS=1 env variable is set or AllowPlugins: true in dev.js 2024-12-22 15:27:12 -06:00
advplyr
50e84fc2d5 Update PluginManager to singleton, update PluginContext, support prompt object in plugin extension 2024-12-22 15:15:56 -06:00
advplyr
a762e6ca03 Merge branch 'master' into plugin-implementation-demo 2024-12-22 12:03:08 -06:00
advplyr
fe4d3c0852 Update plugins page ui 2024-12-21 17:09:19 -06:00
advplyr
048790b33a Updates on plugin apis and example 2024-12-21 16:48:56 -06:00
advplyr
fc17a74865 Update plugin to use uuid for id, update example plugin with taskmanager and socketauthority test 2024-12-21 14:54:43 -06:00
advplyr
cfe3deff3b Add isMissing to Plugin model, add manifest version and name validation, create/update plugins table 2024-12-21 13:26:42 -06:00
advplyr
5a96d8aeb3 Add Plugin model with migration 2024-12-21 12:43:20 -06:00
advplyr
23b480b11a Remove separate plugins dir and use metadata dir for plugins folder 2024-12-21 10:20:09 -06:00
advplyr
ad89fb2eac Update example plugin and add plugins frontend page with save config endpoint 2024-12-20 17:21:00 -06:00
advplyr
62bd7e73f4 Example of potential plugin implementation 2024-12-19 17:48:18 -06:00
152 changed files with 5877 additions and 5115 deletions

View File

@@ -46,10 +46,5 @@ RUN apk del make python3 g++
EXPOSE 80
ENV PORT=80
ENV CONFIG_PATH="/config"
ENV METADATA_PATH="/metadata"
ENV SOURCE="docker"
ENTRYPOINT ["tini", "--"]
CMD ["node", "index.js"]

View File

@@ -5,7 +5,7 @@
@import './absicons.css';
:root {
--bookshelf-texture-img: url(~static/textures/wood_default.jpg);
--bookshelf-texture-img: url(/textures/wood_default.jpg);
--bookshelf-divider-bg: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%);
}
@@ -247,4 +247,4 @@ Bookshelf Label
.abs-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
}

View File

@@ -112,6 +112,14 @@ export default {
}
]
if (this.$store.state.pluginsEnabled) {
configRoutes.push({
id: 'config-plugins',
title: 'Plugins',
path: '/config/plugins'
})
}
if (this.currentLibraryId) {
configRoutes.push({
id: 'library-stats',

View File

@@ -55,7 +55,7 @@
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
/>
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :playback-rate="currentPlaybackRate" :library-item-id="libraryItemId" @select="selectBookmark" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
@@ -374,27 +374,19 @@ export default {
return
}
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
if ('mediaSession' in navigator) {
const chapterInfo = []
if (this.chapters.length) {
this.chapters.forEach((chapter) => {
chapterInfo.push({
title: chapter.title,
startTime: chapter.start
})
})
}
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
const artwork = [
{
src: coverImageSrc
}
]
navigator.mediaSession.metadata = new MediaMetadata({
title: this.title,
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
album: this.mediaMetadata.seriesName || '',
artwork: [
{
src: this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
}
]
artwork
})
console.log('Set media session metadata', navigator.mediaSession.metadata)

View File

@@ -1,5 +1,5 @@
<template>
<div class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
<article class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
<nuxt-link :to="`/author/${author?.id}`">
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
@@ -34,7 +34,7 @@
</div>
</div>
</nuxt-link>
</div>
</article>
</template>
<script>

View File

@@ -1,5 +1,5 @@
<template>
<div ref="card" :id="`book-card-${index}`" tabindex="0" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<article ref="card" :id="`book-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
<!-- When cover image does not fill -->
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
@@ -128,7 +128,7 @@
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
<div :style="{ fontSize: 0.9 + 'em' }">
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
<p cy-id="title" ref="displayTitle" aria-hidden="true" class="truncate">{{ displayTitle }}</p>
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
</ui-tooltip>
</div>
@@ -138,7 +138,7 @@
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
</div>
</div>
</article>
</template>
<script>

View File

@@ -1,5 +1,5 @@
<template>
<div cy-id="card" ref="card" :id="`series-card-${index}`" tabindex="0" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<article cy-id="card" ref="card" :id="`series-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
@@ -21,14 +21,14 @@
<div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
<p cy-id="standardBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
</div>
</div>
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center">
<p cy-id="detailBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
<p cy-id="detailBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
</div>
</div>
</article>
</template>
<script>

View File

@@ -5,26 +5,24 @@
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
</div>
</template>
<div v-if="show" class="w-full rounded-lg bg-bg box-shadow-md relative" style="max-height: 80vh">
<div v-if="bookmarks.length" class="h-full max-h-[calc(80vh-60px)] w-full relative overflow-y-auto overflow-x-hidden">
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="show" class="w-full h-full">
<template v-for="bookmark in bookmarks">
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" :playback-rate="playbackRate" @click="clickBookmark" @delete="deleteBookmark" />
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" />
</template>
</div>
<div v-else class="flex h-32 items-center justify-center">
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
</div>
<div v-if="canCreateBookmark && !hideCreate" class="w-full border-t border-white/10">
<form @submit.prevent="submitCreateBookmark">
<div class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
</div>
<div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" />
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
<div class="w-16 max-w-16 text-center">
<p class="text-sm font-mono text-gray-400">
{{ this.$secondsToTimestamp(currentTime / playbackRate) }}
{{ this.$secondsToTimestamp(currentTime) }}
</p>
</div>
<div class="flex-grow px-2">
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full h-10" />
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
</div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">add</span></ui-btn>
</div>
@@ -47,7 +45,6 @@ export default {
default: 0
},
libraryItemId: String,
playbackRate: Number,
hideCreate: Boolean
},
data() {
@@ -60,7 +57,6 @@ export default {
watch: {
show(newVal) {
if (newVal) {
this.selectedBookmark = null
this.showBookmarkTitleInput = false
this.newBookmarkTitle = ''
}
@@ -76,7 +72,7 @@ export default {
}
},
canCreateBookmark() {
return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1)
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
@@ -106,6 +102,19 @@ export default {
clickBookmark(bm) {
this.$emit('select', bm)
},
submitUpdateBookmark(updatedBookmark) {
var bookmark = { ...updatedBookmark }
this.$axios
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.$toast.success(this.$strings.ToastBookmarkUpdateSuccess)
})
.catch((error) => {
this.$toast.error(this.$strings.ToastFailedToUpdate)
console.error(error)
})
this.show = false
},
submitCreateBookmark() {
if (!this.newBookmarkTitle) {
this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)

View File

@@ -1,8 +1,8 @@
<template>
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-primary/10" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
<div class="flex items-center px-4 py-4 justify-start relative bg-primary hover:bg-opacity-25" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
<div class="w-16 max-w-16 text-center">
<p class="text-sm font-mono text-gray-400">
{{ this.$secondsToTimestamp(bookmark.time / playbackRate) }}
{{ this.$secondsToTimestamp(bookmark.time) }}
</p>
</div>
<div class="flex-grow overflow-hidden px-2">
@@ -10,7 +10,7 @@
<form @submit.prevent="submitUpdate">
<div class="flex items-center">
<div class="flex-grow pr-2">
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full h-10" />
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
</div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">forward</span></ui-btn>
<div class="pl-2 flex items-center">
@@ -35,8 +35,7 @@ export default {
type: Object,
default: () => {}
},
highlight: Boolean,
playbackRate: Number
highlight: Boolean
},
data() {
return {
@@ -84,19 +83,11 @@ export default {
if (this.newBookmarkTitle === this.bookmark.title) {
return this.cancelEditing()
}
const bookmark = { ...this.bookmark }
var bookmark = { ...this.bookmark }
bookmark.title = this.newBookmarkTitle
this.$axios
.$patch(`/api/me/item/${bookmark.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.isEditing = false
})
.catch((error) => {
this.$toast.error(this.$strings.ToastFailedToUpdate)
console.error(error)
})
this.$emit('update', bookmark)
}
}
},
mounted() {}
}
</script>

View File

@@ -138,6 +138,7 @@ export default {
.$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds })
.then((updatedCollection) => {
console.log(`Books removed from collection`, updatedCollection)
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
this.processing = false
})
.catch((error) => {
@@ -151,6 +152,7 @@ export default {
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
.then((updatedCollection) => {
console.log(`Book removed from collection`, updatedCollection)
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
this.processing = false
})
.catch((error) => {
@@ -165,11 +167,12 @@ export default {
this.processing = true
if (this.showBatchCollectionModal) {
// BATCH Add books
// BATCH Remove books
this.$axios
.$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })
.then((updatedCollection) => {
console.log(`Books added to collection`, updatedCollection)
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
this.processing = false
})
.catch((error) => {
@@ -184,6 +187,7 @@ export default {
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
.then((updatedCollection) => {
console.log(`Book added to collection`, updatedCollection)
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
this.processing = false
})
.catch((error) => {
@@ -210,6 +214,7 @@ export default {
.$post('/api/collections', newCollection)
.then((data) => {
console.log('New Collection Created', data)
this.$toast.success(`Collection "${data.name}" created`)
this.processing = false
this.newCollectionName = ''
})

View File

@@ -113,10 +113,6 @@ export default {
return false
})
console.log('updateResult', updateResult)
} else if (!lastEpisodeCheck) {
this.$toast.error(this.$strings.ToastDateTimeInvalidOrIncomplete)
this.checkingNewEpisodes = false
return false
}
this.$axios

View File

@@ -5,9 +5,6 @@
<ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
</div>
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoScan" v-model="cronExpression" @input="updatedCron" />
<div v-else>
<p class="text-yellow-400 text-base">{{ $strings.MessageScheduleLibraryScanNote }}</p>
</div>
</div>
</template>

View File

@@ -130,6 +130,7 @@ export default {
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
.then((updatedPlaylist) => {
console.log(`Items removed from playlist`, updatedPlaylist)
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
this.processing = false
})
.catch((error) => {
@@ -147,6 +148,7 @@ export default {
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
.then((updatedPlaylist) => {
console.log(`Items added to playlist`, updatedPlaylist)
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
this.processing = false
})
.catch((error) => {
@@ -172,6 +174,7 @@ export default {
.$post('/api/playlists', newPlaylist)
.then((data) => {
console.log('New playlist created', data)
this.$toast.success(this.$strings.ToastPlaylistCreateSuccess + ': ' + data.name)
this.processing = false
this.newPlaylistName = ''
})

View File

@@ -170,12 +170,6 @@ export default {
this.show = false
}
},
libraryItemUpdated(libraryItem) {
const episode = libraryItem.media.episodes.find((e) => e.id === this.selectedEpisodeId)
if (episode) {
this.episodeItem = episode
}
},
hotkey(action) {
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
this.goNextEpisode()
@@ -184,15 +178,9 @@ export default {
}
},
registerListeners() {
if (this.libraryItem) {
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
}
this.$eventBus.$on('modal-hotkey', this.hotkey)
},
unregisterListeners() {
if (this.libraryItem) {
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
}
this.$eventBus.$off('modal-hotkey', this.hotkey)
}
},

View File

@@ -11,7 +11,7 @@
<ui-dropdown v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" :items="episodeTypes" small />
</div>
<div class="w-2/5 p-1">
<ui-text-input-with-label v-model="pubDateInput" ref="pubdate" type="datetime-local" :label="$strings.LabelPubDate" @input="updatePubDate" />
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" :label="$strings.LabelPubDate" />
</div>
<div class="w-full p-1">
<ui-text-input-with-label v-model="newEpisode.title" :label="$strings.LabelTitle" />
@@ -145,18 +145,11 @@ export default {
return null
}
// Check pubdate is valid if it is being updated. Cannot be set to null in the web client
if (this.newEpisode.pubDate === null && this.$refs.pubdate?.$refs?.input?.isInvalidDate) {
this.$toast.error(this.$strings.ToastDateTimeInvalidOrIncomplete)
return null
}
const updatedDetails = this.getUpdatePayload()
if (!Object.keys(updatedDetails).length) {
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
return false
}
return this.updateDetails(updatedDetails)
},
async updateDetails(updatedDetails) {
@@ -170,10 +163,13 @@ export default {
this.isProcessing = false
if (updateResult) {
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
return true
if (updateResult) {
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
return true
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
}
return false
}
},

View File

@@ -4,7 +4,7 @@
<div class="absolute -top-10 lg:top-0 right-0 lg:right-2 flex items-center h-full">
<controls-playback-speed-control v-model="playbackRate" @input="setPlaybackRate" @change="playbackRateChanged" class="mx-2 block" />
<ui-tooltip direction="bottom" :text="$strings.LabelVolume">
<ui-tooltip direction="left" :text="$strings.LabelVolume">
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden sm:block" />
</ui-tooltip>

View File

@@ -7,6 +7,14 @@
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
<div v-if="formFields.length" class="mb-6 space-y-2">
<template v-for="field in formFields">
<ui-select-input v-if="field.type === 'select'" :key="field.name" v-model="formData[field.name]" :label="field.label" :items="field.options" class="px-1" />
<ui-textarea-with-label v-else-if="field.type === 'textarea'" :key="field.name" v-model="formData[field.name]" :label="field.label" class="px-1" />
<ui-text-input-with-label v-else-if="field.type === 'text'" :key="field.name" v-model="formData[field.name]" :label="field.label" class="px-1" />
</template>
</div>
<div class="flex px-1 items-center">
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
<div class="flex-grow" />
@@ -25,7 +33,8 @@ export default {
return {
el: null,
content: null,
checkboxValue: false
checkboxValue: false,
formData: {}
}
},
watch: {
@@ -61,6 +70,9 @@ export default {
persistent() {
return !!this.confirmPromptOptions.persistent
},
formFields() {
return this.confirmPromptOptions.formFields || []
},
checkboxLabel() {
return this.confirmPromptOptions.checkboxLabel
},
@@ -100,11 +112,31 @@ export default {
this.show = false
},
confirm() {
if (this.callback) this.callback(true, this.checkboxValue)
if (this.callback) {
if (this.formFields.length) {
const formFieldData = {
...this.formData
}
this.callback(true, formFieldData)
} else {
this.callback(true, this.checkboxValue)
}
}
this.show = false
},
setShow() {
this.checkboxValue = this.checkboxDefaultValue
if (this.formFields.length) {
this.formFields.forEach((field) => {
let defaultValue = ''
if (field.type === 'boolean') defaultValue = false
if (field.type === 'select') defaultValue = field.options[0].value
this.$set(this.formData, field.name, defaultValue)
})
}
this.$eventBus.$emit('showing-prompt', true)
document.body.appendChild(this.el)
setTimeout(() => {

View File

@@ -97,9 +97,9 @@ export default {
},
ebookUrl() {
if (this.fileId) {
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}`
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook`
return `/api/items/${this.libraryItemId}/ebook`
},
themeRules() {
const isDark = this.ereaderSettings.theme === 'dark'

View File

@@ -1,7 +1,7 @@
<template>
<div id="heatmap" class="w-full">
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
<p class="mb-2 px-1 text-sm text-gray-200">{{ $getString('MessageDaysListenedInTheLastYear', [daysListenedInTheLastYear]) }}</p>
<p class="mb-2 px-1 text-sm text-gray-200">{{ $getString('MessageListeningSessionsInTheLastYear', [Object.values(daysListening).length]) }}</p>
<div class="border border-white border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
@@ -37,7 +37,6 @@ export default {
innerHeight: 13 * 7,
blockWidth: 13,
data: [],
daysListenedInTheLastYear: 0,
monthLabels: [],
tooltipEl: null,
tooltipTextEl: null,
@@ -63,6 +62,9 @@ export default {
dayOfWeekToday() {
return new Date().getDay()
},
firstWeekStart() {
return this.$addDaysToToday(-this.daysToShow)
},
dayLabels() {
return [
{
@@ -191,59 +193,46 @@ export default {
buildData() {
this.data = []
let maxValue = 0
let minValue = 0
const dates = []
const numDaysInTheLastYear = 52 * 7 + this.dayOfWeekToday
const firstDay = this.$addDaysToToday(-numDaysInTheLastYear)
for (let i = 0; i < numDaysInTheLastYear + 1; i++) {
const date = i === 0 ? firstDay : this.$addDaysToDate(firstDay, i)
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
if (this.daysListening[dateString] > 0) {
this.daysListenedInTheLastYear++
}
const visibleDayIndex = i - (numDaysInTheLastYear - this.daysToShow)
if (visibleDayIndex < 0) {
continue
}
const dateObj = {
col: Math.floor(visibleDayIndex / 7),
row: visibleDayIndex % 7,
date,
dateString,
datePretty: this.$formatJsDate(date, 'MMM d, yyyy'),
monthString: this.$formatJsDate(date, 'MMM'),
dayOfMonth: Number(dateString.split('-').pop()),
yearString: dateString.split('-').shift(),
value: this.daysListening[dateString] || 0
}
dates.push(dateObj)
if (dateObj.value > 0) {
if (dateObj.value > maxValue) maxValue = dateObj.value
if (!minValue || dateObj.value < minValue) minValue = dateObj.value
}
}
var maxValue = 0
var minValue = 0
Object.values(this.daysListening).forEach((val) => {
if (val > maxValue) maxValue = val
if (!minValue || val < minValue) minValue = val
})
const range = maxValue - minValue + 0.01
for (const dateObj of dates) {
let bgColor = this.bgColors[0]
let outlineColor = this.outlineColors[0]
if (dateObj.value) {
for (let i = 0; i < this.daysToShow + 1; i++) {
const col = Math.floor(i / 7)
const row = i % 7
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
const monthString = this.$formatJsDate(date, 'MMM')
const value = this.daysListening[dateString] || 0
const x = col * 13
const y = row * 13
var bgColor = this.bgColors[0]
var outlineColor = this.outlineColors[0]
if (value) {
outlineColor = this.outlineColors[1]
const percentOfAvg = (dateObj.value - minValue) / range
const bgIndex = Math.floor(percentOfAvg * 4) + 1
var percentOfAvg = (value - minValue) / range
var bgIndex = Math.floor(percentOfAvg * 4) + 1
bgColor = this.bgColors[bgIndex] || 'red'
}
this.data.push({
...dateObj,
style: `transform:translate(${dateObj.col * 13}px,${dateObj.row * 13}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
date,
dateString,
datePretty,
monthString,
dayOfMonth: Number(dateString.split('-').pop()),
yearString: dateString.split('-').shift(),
value,
col,
row,
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
})
}
@@ -271,7 +260,6 @@ export default {
const heatmapEl = document.getElementById('heatmap')
this.contentWidth = heatmapEl.clientWidth
this.maxInnerWidth = this.contentWidth - 52
this.daysListenedInTheLastYear = 0
this.buildData()
}
},

View File

@@ -218,6 +218,7 @@ export default {
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
} else {
console.log(`Item removed from playlist`, updatedPlaylist)
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
}
})
.catch((error) => {

View File

@@ -96,7 +96,7 @@ export default {
return this.episode?.title || ''
},
episodeSubtitle() {
return this.episode?.subtitle || this.episode?.description || ''
return this.episode?.subtitle || ''
},
episodeType() {
return this.episode?.episodeType || ''

View File

@@ -30,7 +30,7 @@
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
</form>
</div>
<div class="relative min-h-44">
<div class="relative min-h-[176px]">
<template v-for="episode in totalEpisodes">
<div :key="episode" :id="`episode-${episode - 1}`" class="w-full h-44 px-2 py-3 overflow-hidden relative border-b border-white/10">
<!-- episode is mounted here -->
@@ -39,7 +39,7 @@
<div v-if="isSearching" class="w-full h-full absolute inset-0 flex justify-center py-12" :class="{ 'bg-black/50': totalEpisodes }">
<ui-loading-indicator />
</div>
<div v-else-if="!totalEpisodes" id="no-episodes" class="h-44 flex items-center justify-center">
<div v-else-if="!totalEpisodes" class="h-44 flex items-center justify-center">
<p class="text-lg">{{ $strings.MessageNoEpisodes }}</p>
</div>
</div>
@@ -80,8 +80,7 @@ export default {
episodeComponentRefs: {},
windowHeight: 0,
episodesTableOffsetTop: 0,
episodeRowHeight: 44 * 4, // h-44,
currScrollTop: 0
episodeRowHeight: 176
}
},
watch: {
@@ -485,8 +484,9 @@ export default {
}
}
},
handleScroll() {
const scrollTop = this.currScrollTop
scroll(evt) {
if (!evt?.target?.scrollTop) return
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
let firstEpisodeIndex = Math.floor(scrollTop / this.episodeRowHeight)
let lastEpisodeIndex = Math.ceil((scrollTop + this.windowHeight) / this.episodeRowHeight)
lastEpisodeIndex = Math.min(this.totalEpisodes - 1, lastEpisodeIndex)
@@ -501,12 +501,6 @@ export default {
})
this.mountEpisodes(firstEpisodeIndex, lastEpisodeIndex + 1)
},
scroll(evt) {
if (!evt?.target?.scrollTop) return
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
this.currScrollTop = scrollTop
this.handleScroll()
},
initListeners() {
const itemPageWrapper = document.getElementById('item-page-wrapper')
if (itemPageWrapper) {
@@ -538,24 +532,11 @@ export default {
this.episodesTableOffsetTop = (lazyEpisodesTableEl?.offsetTop || 0) + 64
this.windowHeight = window.innerHeight
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
this.$nextTick(() => {
this.recalcEpisodeRowHeight()
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
// Maybe update currScrollTop if items were removed
const itemPageWrapper = document.getElementById('item-page-wrapper')
const { scrollHeight, clientHeight } = itemPageWrapper
const maxScrollTop = scrollHeight - clientHeight
this.currScrollTop = Math.min(this.currScrollTop, maxScrollTop)
this.handleScroll()
this.mountEpisodes(0, Math.min(this.episodesPerPage, this.totalEpisodes))
})
},
recalcEpisodeRowHeight() {
const episodeRowEl = document.getElementById('episode-0') || document.getElementById('no-episodes')
if (episodeRowEl) {
const height = getComputedStyle(episodeRowEl).height
this.episodeRowHeight = parseInt(height) || this.episodeRowHeight
}
}
},
mounted() {

View File

@@ -31,6 +31,7 @@
</div>
</template>
<button v-else :key="index" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(item.action)">
<span v-if="item.icon" class="material-symbols text-base mr-1">{{ item.icon }}</span>
<p class="text-left">{{ item.text }}</p>
</button>
</template>

View File

@@ -1,6 +1,24 @@
<template>
<div ref="wrapper" class="relative">
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="rounded bg-primary text-gray-200 focus:bg-bg focus:outline-none border h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<input
:id="inputId"
:name="inputName"
ref="input"
v-model="inputValue"
:type="actualType"
:step="step"
:min="min"
:readonly="readonly"
:disabled="disabled"
:placeholder="placeholder"
dir="auto"
class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full"
:class="classList"
@keyup="keyup"
@change="change"
@focus="focused"
@blur="blurred"
/>
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div>
@@ -47,8 +65,7 @@ export default {
showPassword: false,
isHovering: false,
isFocused: false,
hasCopied: false,
isInvalidDate: false
hasCopied: false
}
},
computed: {
@@ -67,10 +84,6 @@ export default {
if (this.noSpinner) _list.push('no-spinner')
if (this.textCenter) _list.push('text-center')
if (this.customInputClass) _list.push(this.customInputClass)
if (this.isInvalidDate) _list.push('border-error')
else _list.push('focus:border-gray-300 border-gray-600')
return _list.join(' ')
},
actualType() {
@@ -105,14 +118,6 @@ export default {
},
keyup(e) {
this.$emit('keyup', e)
if (this.type === 'datetime-local') {
if (e.target.validity?.badInput) {
this.isInvalidDate = true
} else {
this.isInvalidDate = false
}
}
},
blur() {
if (this.$refs.input) this.$refs.input.blur()

View File

@@ -1,10 +1,9 @@
<template>
<div class="w-full">
<slot>
<label :for="identifier" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
{{ label }}
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</label>
<label :for="identifier" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }"
>{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label
>
</slot>
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
</div>
@@ -58,4 +57,4 @@ export default {
},
mounted() {}
}
</script>
</script>

View File

@@ -249,33 +249,11 @@ export default {
}
}
return target
},
enableBreakParagraphOnReturn() {
// Trix works with divs by default, we want paragraphs instead
Trix.config.blockAttributes.default.tagName = 'p'
// Enable break paragraph on Enter (Shift + Enter will still create a line break)
Trix.config.blockAttributes.default.breakOnReturn = true
// Hack to fix buggy paragraph breaks
// Copied from https://github.com/basecamp/trix/issues/680#issuecomment-735742942
Trix.Block.prototype.breaksOnReturn = function () {
const attr = this.getLastAttribute()
const config = Trix.getBlockConfig(attr ? attr : 'default')
return config ? config.breakOnReturn : false
}
Trix.LineBreakInsertion.prototype.shouldInsertBlockBreak = function () {
if (this.block.hasAttributes() && this.block.isListItem() && !this.block.isEmpty()) {
return this.startLocation.offset > 0
} else {
return !this.shouldBreakFormattedBlock() ? this.breaksOnReturn : false
}
}
}
},
mounted() {
/** Override editor configuration */
this.overrideConfig(this.config)
this.enableBreakParagraphOnReturn()
/** Check if editor read-only mode is required */
this.decorateDisabledEditor(this.disabledEditor)
this.$nextTick(() => {
@@ -305,4 +283,4 @@ export default {
.trix_container .trix-content {
background-color: white;
}
</style>
</style>

View File

@@ -1,188 +0,0 @@
import Vue from 'vue'
import '@/plugins/utils'
// This is the actual function that is being tested
const elapsedPrettyExtended = Vue.prototype.$elapsedPrettyExtended
// Helper function to convert days, hours, minutes, seconds to total seconds
function DHMStoSeconds(days, hours, minutes, seconds) {
return seconds + minutes * 60 + hours * 3600 + days * 86400
}
describe('$elapsedPrettyExtended', () => {
describe('function is on the Vue Prototype', () => {
it('exists as a function on Vue.prototype', () => {
expect(Vue.prototype.$elapsedPrettyExtended).to.exist
expect(Vue.prototype.$elapsedPrettyExtended).to.be.a('function')
})
})
describe('param default values', () => {
const testSeconds = DHMStoSeconds(0, 25, 1, 5) // 25h 1m 5s = 90065 seconds
it('uses useDays=true showSeconds=true by default', () => {
expect(elapsedPrettyExtended(testSeconds)).to.equal('1d 1h 1m 5s')
})
it('only useDays=false overrides useDays but keeps showSeconds=true', () => {
expect(elapsedPrettyExtended(testSeconds, false)).to.equal('25h 1m 5s')
})
it('explicit useDays=false showSeconds=false overrides both', () => {
expect(elapsedPrettyExtended(testSeconds, false, false)).to.equal('25h 1m')
})
})
describe('useDays=false showSeconds=true', () => {
const useDaysFalse = false
const showSecondsTrue = true
const testCases = [
[[0, 0, 0, 0], '', '0s -> ""'],
[[0, 1, 0, 1], '1h 1s', '1h 1s -> 1h 1s'],
[[0, 25, 0, 1], '25h 1s', '25h 1s -> 25h 1s']
]
testCases.forEach(([dhms, expected, description]) => {
it(description, () => {
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysFalse, showSecondsTrue)).to.equal(expected)
})
})
})
describe('useDays=true showSeconds=true', () => {
const useDaysTrue = true
const showSecondsTrue = true
const testCases = [
[[0, 0, 0, 0], '', '0s -> ""'],
[[0, 1, 0, 1], '1h 1s', '1h 1s -> 1h 1s'],
[[0, 25, 0, 1], '1d 1h 1s', '25h 1s -> 1d 1h 1s']
]
testCases.forEach(([dhms, expected, description]) => {
it(description, () => {
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsTrue)).to.equal(expected)
})
})
})
describe('useDays=true showSeconds=false', () => {
const useDaysTrue = true
const showSecondsFalse = false
const testCases = [
[[0, 0, 0, 0], '', '0s -> ""'],
[[0, 1, 0, 0], '1h', '1h -> 1h'],
[[0, 1, 0, 1], '1h', '1h 1s -> 1h'],
[[0, 1, 1, 0], '1h 1m', '1h 1m -> 1h 1m'],
[[0, 25, 0, 0], '1d 1h', '25h -> 1d 1h'],
[[0, 25, 0, 1], '1d 1h', '25h 1s -> 1d 1h'],
[[2, 0, 0, 0], '2d', '2d -> 2d']
]
testCases.forEach(([dhms, expected, description]) => {
it(description, () => {
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsFalse)).to.equal(expected)
})
})
})
describe('rounding useDays=true showSeconds=true', () => {
const useDaysTrue = true
const showSecondsTrue = true
const testCases = [
// Seconds rounding
[[0, 0, 0, 1], '1s', '1s -> 1s'],
[[0, 0, 0, 29.9], '30s', '29.9s -> 30s'],
[[0, 0, 0, 30], '30s', '30s -> 30s'],
[[0, 0, 0, 30.1], '30s', '30.1s -> 30s'],
[[0, 0, 0, 59.4], '59s', '59.4s -> 59s'],
[[0, 0, 0, 59.5], '1m', '59.5s -> 1m'],
// Minutes rounding
[[0, 0, 59, 29], '59m 29s', '59m 29s -> 59m 29s'],
[[0, 0, 59, 30], '59m 30s', '59m 30s -> 59m 30s'],
[[0, 0, 59, 59.5], '1h', '59m 59.5s -> 1h'],
// Hours rounding
[[0, 23, 59, 29], '23h 59m 29s', '23h 59m 29s -> 23h 59m 29s'],
[[0, 23, 59, 30], '23h 59m 30s', '23h 59m 30s -> 23h 59m 30s'],
[[0, 23, 59, 59.5], '1d', '23h 59m 59.5s -> 1d'],
// The actual bug case
[[44, 23, 59, 30], '44d 23h 59m 30s', '44d 23h 59m 30s -> 44d 23h 59m 30s']
]
testCases.forEach(([dhms, expected, description]) => {
it(description, () => {
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsTrue)).to.equal(expected)
})
})
})
describe('rounding useDays=true showSeconds=false', () => {
const useDaysTrue = true
const showSecondsFalse = false
const testCases = [
// Seconds rounding - these cases changed behavior from original
[[0, 0, 0, 1], '', '1s -> ""'],
[[0, 0, 0, 29.9], '', '29.9s -> ""'],
[[0, 0, 0, 30], '', '30s -> ""'],
[[0, 0, 0, 30.1], '', '30.1s -> ""'],
[[0, 0, 0, 59.4], '', '59.4s -> ""'],
[[0, 0, 0, 59.5], '1m', '59.5s -> 1m'],
// This is unexpected behavior, but it's consistent with the original behavior
// We preserved the test case, to document the current behavior
// - with showSeconds=false,
// one might expect: 1m 29.5s --round(1.4901m)-> 1m
// actual implementation: 1h 29.5s --roundSeconds-> 1h 30s --roundMinutes-> 2m
// So because of the separate rounding of seconds, and then minutes, it returns 2m
[[0, 0, 1, 29.5], '2m', '1m 29.5s -> 2m'],
// Minutes carry - actual bug fixes below
[[0, 0, 59, 29], '59m', '59m 29s -> 59m'],
[[0, 0, 59, 30], '1h', '59m 30s -> 1h'], // This was an actual bug, used to return 60m
[[0, 0, 59, 59.5], '1h', '59m 59.5s -> 1h'],
// Hours carry
[[0, 23, 59, 29], '23h 59m', '23h 59m 29s -> 23h 59m'],
[[0, 23, 59, 30], '1d', '23h 59m 30s -> 1d'], // This was an actual bug, used to return 23h 60m
[[0, 23, 59, 59.5], '1d', '23h 59m 59.5s -> 1d'],
// The actual bug case
[[44, 23, 59, 30], '45d', '44d 23h 59m 30s -> 45d'] // This was an actual bug, used to return 44d 23h 60m
]
testCases.forEach(([dhms, expected, description]) => {
it(description, () => {
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsFalse)).to.equal(expected)
})
})
})
describe('empty values', () => {
const paramCombos = [
// useDays, showSeconds, description
[true, true, 'with days and seconds'],
[true, false, 'with days, no seconds'],
[false, true, 'no days, with seconds'],
[false, false, 'no days, no seconds']
]
const emptyInputs = [
// input, description
[null, 'null input'],
[undefined, 'undefined input'],
[0, 'zero'],
[0.49, 'rounds to zero'] // Just under rounding threshold
]
paramCombos.forEach(([useDays, showSeconds, paramDesc]) => {
describe(paramDesc, () => {
emptyInputs.forEach(([input, desc]) => {
it(desc, () => {
expect(elapsedPrettyExtended(input, useDays, showSeconds)).to.equal('')
})
})
})
})
})
})

View File

@@ -1,6 +1,6 @@
const pkg = require('./package.json')
const routerBasePath = process.env.ROUTER_BASE_PATH || '/audiobookshelf'
const routerBasePath = process.env.ROUTER_BASE_PATH || ''
const serverHostUrl = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333'
const serverPaths = ['api/', 'public/', 'hls/', 'auth/', 'feed/', 'status', 'login', 'logout', 'init']
const proxy = Object.fromEntries(serverPaths.map((path) => [`${routerBasePath}/${path}`, { target: process.env.NODE_ENV !== 'production' ? serverHostUrl : '/' }]))

View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.18.0",
"version": "2.17.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.18.0",
"version": "2.17.6",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.18.0",
"version": "2.17.6",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",

View File

@@ -0,0 +1,154 @@
<template>
<div>
<app-settings-content :header-text="`Plugin: ${pluginManifest.name}`">
<template #header-prefix>
<nuxt-link to="/config/plugins" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center mr-2">
<span class="material-symbols text-2xl">arrow_back</span>
</nuxt-link>
</template>
<template #header-items>
<ui-tooltip v-if="pluginManifest.documentationUrl" :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a :href="pluginManifest.documentationUrl" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
<div class="flex-grow" />
<a v-if="repositoryUrl" :href="repositoryUrl" target="_blank" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-4 py-1 text-sm inline-flex items-center space-x-2"><span>Source</span><span class="material-symbols text-base">open_in_new</span> </a>
</template>
<div class="py-4">
<p v-if="configDescription" class="mb-4">{{ configDescription }}</p>
<form v-if="configFormFields.length" @submit.prevent="handleFormSubmit">
<template v-for="field in configFormFields">
<div :key="field.name" class="flex items-center mb-4">
<label :for="field.name" class="w-1/3 text-gray-200">{{ field.label }}</label>
<div class="w-2/3">
<input :id="field.name" :type="field.type" :placeholder="field.placeholder" class="w-full bg-bg border border-white border-opacity-20 rounded-md p-2 text-gray-200" />
</div>
</div>
</template>
<div class="flex justify-end">
<ui-btn class="bg-primary bg-opacity-70 text-white rounded-md p-2" :loading="processing" type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</form>
</div>
</app-settings-content>
</div>
</template>
<script>
export default {
async asyncData({ store, redirect, params, app }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
const pluginConfigData = await app.$axios.$get(`/api/plugins/${params.id}/config`).catch((error) => {
console.error('Failed to get plugin config', error)
return null
})
if (!pluginConfigData) {
redirect('/config/plugins')
}
const pluginManifest = store.state.plugins.find((plugin) => plugin.id === params.id)
if (!pluginManifest) {
redirect('/config/plugins')
}
return {
pluginManifest,
pluginConfig: pluginConfigData.config
}
},
data() {
return {
processing: false
}
},
computed: {
pluginManifestConfig() {
return this.pluginManifest.config
},
pluginLocalization() {
return this.pluginManifest.localization || {}
},
localizedStrings() {
const localeKey = this.$languageCodes.current
if (!localeKey) return {}
return this.pluginLocalization[localeKey] || {}
},
configDescription() {
if (this.pluginManifestConfig.descriptionKey && this.localizedStrings[this.pluginManifestConfig.descriptionKey]) {
return this.localizedStrings[this.pluginManifestConfig.descriptionKey]
}
return this.pluginManifestConfig.description
},
configFormFields() {
return this.pluginManifestConfig.formFields || []
},
repositoryUrl() {
return this.pluginManifest.repositoryUrl
}
},
methods: {
getFormData() {
const formData = {}
this.configFormFields.forEach((field) => {
if (field.type === 'checkbox') {
formData[field.name] = document.getElementById(field.name).checked
} else {
formData[field.name] = document.getElementById(field.name).value
}
})
return formData
},
handleFormSubmit() {
const formData = this.getFormData()
console.log('Form data', formData)
const payload = {
config: formData
}
this.processing = true
this.$axios
.$post(`/api/plugins/${this.pluginManifest.id}/config`, payload)
.then(() => {
console.log('Plugin configuration saved')
})
.catch((error) => {
const errorMsg = error.response?.data || 'Error saving plugin configuration'
console.error('Failed to save config:', error)
this.$toast.error(errorMsg)
})
.finally(() => {
this.processing = false
})
},
initializeForm() {
if (!this.pluginConfig) return
this.configFormFields.forEach((field) => {
if (this.pluginConfig[field.name] === undefined) {
return
}
const value = this.pluginConfig[field.name]
if (field.type === 'checkbox') {
document.getElementById(field.name).checked = value
} else {
document.getElementById(field.name).value = value
}
})
}
},
mounted() {
console.log('Plugin manifest', this.pluginManifest, 'config', this.pluginConfig)
this.initializeForm()
},
beforeDestroy() {}
}
</script>

View File

@@ -0,0 +1,48 @@
<template>
<div>
<app-settings-content :header-text="'Plugins'">
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
</template>
<div class="py-4">
<p v-if="!plugins.length" class="text-gray-300">No plugins installed</p>
<template v-for="plugin in plugins">
<nuxt-link :key="plugin.id" :to="`/config/plugins/${plugin.id}`" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
<div class="flex items-center space-x-4">
<p class="text-lg">{{ plugin.name }}</p>
<p class="text-sm text-gray-300">{{ plugin.description }}</p>
<div class="flex-grow" />
<span class="material-symbols">arrow_forward</span>
</div>
</nuxt-link>
</template>
</div>
</app-settings-content>
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {}
},
computed: {
plugins() {
return this.$store.state.plugins
}
},
methods: {},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@@ -141,7 +141,7 @@
</div>
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :playback-rate="1" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
</div>
</template>
@@ -364,6 +364,9 @@ export default {
showCollectionsButton() {
return this.isBook && this.userCanUpdate
},
pluginExtensions() {
return this.$store.getters['getPluginExtensions']('item.detail.actions')
},
contextMenuItems() {
const items = []
@@ -429,6 +432,18 @@ export default {
})
}
if (this.pluginExtensions.length) {
this.pluginExtensions.forEach((plugin) => {
plugin.extensions.forEach((pext) => {
items.push({
text: pext.label,
action: `plugin-${plugin.id}-action-${pext.name}`,
icon: 'extension'
})
})
})
}
return items
}
},
@@ -763,7 +778,54 @@ export default {
} else if (action === 'share') {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShareModal', this.mediaItemShare)
} else if (action.startsWith('plugin-')) {
const actionStrSplit = action.replace('plugin-', '').split('-action-')
const pluginId = actionStrSplit[0]
const pluginAction = actionStrSplit[1]
this.onPluginAction(pluginId, pluginAction)
}
},
onPluginAction(pluginId, pluginAction) {
const plugin = this.pluginExtensions.find((p) => p.id === pluginId)
const extension = plugin.extensions.find((ext) => ext.name === pluginAction)
if (extension.prompt) {
const payload = {
message: extension.prompt.message,
formFields: extension.prompt.formFields || [],
yesButtonText: this.$strings.ButtonSubmit,
callback: (confirmed, promptData) => {
if (confirmed) {
this.sendPluginAction(pluginId, pluginAction, promptData)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
} else {
this.sendPluginAction(pluginId, pluginAction)
}
},
sendPluginAction(pluginId, pluginAction, promptData = null) {
this.$axios
.$post(`/api/plugins/${pluginId}/action`, {
pluginAction,
target: 'item.detail.actions',
data: {
entityId: this.libraryItemId,
entityType: 'libraryItem',
userId: this.$store.state.user.user.id,
promptData
}
})
.then((data) => {
console.log('Plugin action response', data)
})
.catch((error) => {
const errorMsg = error.response?.data || 'Plugin action failed'
console.error('Plugin action failed:', error)
this.$toast.error(errorMsg)
})
}
},
mounted() {

View File

@@ -166,10 +166,14 @@ export default {
location.reload()
},
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) {
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices, plugins }) {
this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('setSource', Source)
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
if (plugins !== undefined) {
this.$store.commit('setPlugins', plugins)
}
this.$setServerLanguageCode(serverSettings.language)
if (serverSettings.chromecastEnabled) {

View File

@@ -110,84 +110,6 @@ export default {
}
},
methods: {
mediaSessionPlay() {
console.log('Media session play')
this.play()
},
mediaSessionPause() {
console.log('Media session pause')
this.pause()
},
mediaSessionStop() {
console.log('Media session stop')
this.pause()
},
mediaSessionSeekBackward() {
console.log('Media session seek backward')
this.jumpBackward()
},
mediaSessionSeekForward() {
console.log('Media session seek forward')
this.jumpForward()
},
mediaSessionSeekTo(e) {
console.log('Media session seek to', e)
if (e.seekTime !== null && !isNaN(e.seekTime)) {
this.seek(e.seekTime)
}
},
mediaSessionPreviousTrack() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.prevChapter()
}
},
mediaSessionNextTrack() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.nextChapter()
}
},
updateMediaSessionPlaybackState() {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
}
},
setMediaSession() {
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
if ('mediaSession' in navigator) {
const chapterInfo = []
if (this.chapters.length > 0) {
this.chapters.forEach((chapter) => {
chapterInfo.push({
title: chapter.title,
startTime: chapter.start
})
})
}
navigator.mediaSession.metadata = new MediaMetadata({
title: this.mediaItemShare.playbackSession.displayTitle || 'No title',
artist: this.mediaItemShare.playbackSession.displayAuthor || 'Unknown',
artwork: [
{
src: this.coverUrl
}
],
chapterInfo
})
console.log('Set media session metadata', navigator.mediaSession.metadata)
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
} else {
console.warn('Media session not available')
}
},
async coverImageLoaded(e) {
if (!this.playbackSession.coverPath) return
const fac = new FastAverageColor()
@@ -204,19 +126,8 @@ export default {
})
},
playPause() {
if (this.isPlaying) {
this.pause()
} else {
this.play()
}
},
play() {
if (!this.localAudioPlayer || !this.hasLoaded) return
this.localAudioPlayer.play()
},
pause() {
if (!this.localAudioPlayer || !this.hasLoaded) return
this.localAudioPlayer.pause()
this.localAudioPlayer.playPause()
},
jumpForward() {
if (!this.localAudioPlayer || !this.hasLoaded) return
@@ -302,7 +213,6 @@ export default {
} else {
this.stopPlayInterval()
}
this.updateMediaSessionPlaybackState()
},
playerTimeUpdate(time) {
this.setCurrentTime(time)
@@ -366,8 +276,6 @@ export default {
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
this.localAudioPlayer.on('error', this.playerError.bind(this))
this.localAudioPlayer.on('finished', this.playerFinished.bind(this))
this.setMediaSession()
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)

View File

@@ -69,22 +69,17 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true, showSeconds = t
let hours = Math.floor(minutes / 60)
minutes -= hours * 60
// Handle rollovers before days calculation
if (minutes && seconds && !showSeconds) {
if (seconds >= 30) minutes++
if (minutes >= 60) {
hours++ // Increment hours if minutes roll over
minutes -= 60 // adjust minutes
}
}
// Now calculate days with the final hours value
let days = 0
if (useDays || Math.floor(hours / 24) >= 100) {
days = Math.floor(hours / 24)
hours -= days * 24
}
// If not showing seconds then round minutes up
if (minutes && seconds && !showSeconds) {
if (seconds >= 30) minutes++
}
const strs = []
if (days) strs.push(`${days}d`)
if (hours) strs.push(`${hours}h`)

View File

@@ -28,7 +28,9 @@ export const state = () => ({
openModal: null,
innerModalOpen: false,
lastBookshelfScrollData: {},
routerBasePath: '/'
routerBasePath: '/',
plugins: [],
pluginsEnabled: false
})
export const getters = {
@@ -61,6 +63,20 @@ export const getters = {
getHomeBookshelfView: (state) => {
if (!state.serverSettings || isNaN(state.serverSettings.homeBookshelfView)) return Constants.BookshelfView.STANDARD
return state.serverSettings.homeBookshelfView
},
getPluginExtensions: (state) => (target) => {
if (!state.pluginsEnabled) return []
return state.plugins
.map((pext) => {
const extensionsMatchingTarget = pext.extensions?.filter((ext) => ext.target === target) || []
if (!extensionsMatchingTarget.length) return null
return {
id: pext.id,
name: pext.name,
extensions: extensionsMatchingTarget
}
})
.filter(Boolean)
}
}
@@ -239,5 +255,9 @@ export const mutations = {
},
setInnerModalOpen(state, val) {
state.innerModalOpen = val
},
setPlugins(state, val) {
state.plugins = val
state.pluginsEnabled = true
}
}

View File

@@ -1,117 +1 @@
{
"ButtonAdd": "Дадаць",
"ButtonAddChapters": "Дадаць раздзелы",
"ButtonAddDevice": "Дадаць прыладу",
"ButtonAddLibrary": "Дадаць бібліятэку",
"ButtonAddPodcasts": "Дадаць падкасты",
"ButtonAddUser": "Дадаць карыстальніка",
"ButtonAddYourFirstLibrary": "Дадайце сваю першую бібліятэку",
"ButtonApply": "Ужыць",
"ButtonApplyChapters": "Ужыць раздзелы",
"ButtonAuthors": "Аўтары",
"ButtonBack": "Назад",
"ButtonBrowseForFolder": "Знайсці тэчку",
"ButtonCancel": "Адмяніць",
"ButtonCancelEncode": "Адмяніць кадзіраванне",
"ButtonChangeRootPassword": "Зменіце Root пароль",
"ButtonCheckAndDownloadNewEpisodes": "Праверыць і спампаваць новыя эпізоды",
"ButtonChooseAFolder": "Выбраць тэчку",
"ButtonChooseFiles": "Выбраць файлы",
"ButtonClearFilter": "Ачысціць фільтр",
"ButtonCloseFeed": "Закрыць стужку",
"ButtonCloseSession": "Закрыць адкрыты сеанс",
"ButtonCollections": "Калекцыі",
"ButtonConfigureScanner": "Наладзіць сканер",
"ButtonCreate": "Ствараць",
"ButtonCreateBackup": "Стварыць рэзервовую копію",
"ButtonDelete": "Выдаліць",
"ButtonDownloadQueue": "Чарга",
"ButtonEdit": "Рэдагаваць",
"ButtonEditChapters": "Рэдагаваць раздзелы",
"ButtonEditPodcast": "Рэдагаваць падкаст",
"ButtonEnable": "Уключыць",
"ButtonFireAndFail": "Агонь і няўдача",
"ButtonFireOnTest": "Тэст на вогнеўстойлівасць",
"ButtonForceReScan": "Прымусовае паўторнае сканаванне",
"ButtonFullPath": "Поўны шлях",
"ButtonHide": "Схаваць",
"ButtonIssues": "Праблемы",
"ButtonJumpBackward": "Перайсці назад",
"ButtonJumpForward": "Перайсці наперад",
"ButtonLibrary": "Бібліятэка",
"ButtonLogout": "Выйсці",
"ButtonLookup": "",
"ButtonMapChapterTitles": "Супаставіць назвы раздзелаў",
"ButtonMatchAllAuthors": "Супадзенне ўсіх аўтараў",
"ButtonNevermind": "Няважна",
"ButtonNext": "Далей",
"ButtonNextChapter": "Наступны раздзел",
"ButtonNextItemInQueue": "Наступны элемент у чарзе",
"ButtonOk": "Добра",
"ButtonOpenFeed": "Адкрыць стужку",
"ButtonOpenManager": "Адкрыць менеджар",
"ButtonPause": "Паўза",
"ButtonPlay": "Прайграць",
"ButtonPlayAll": "Прайграць усё",
"ButtonPlaying": "Прайграваецца",
"ButtonPlaylists": "Плэйлісты",
"ButtonPrevious": "Папярэдні",
"ButtonPreviousChapter": "Папярэдні раздзел",
"ButtonProbeAudioFile": "Праверыць аўдыяфайл",
"ButtonPurgeAllCache": "Ачысціць увесь кэш",
"ButtonPurgeItemsCache": "Ачысціць кэш элементаў",
"ButtonQueueAddItem": "Дадаць у чаргу",
"ButtonQueueRemoveItem": "Выдаліць з чаргі",
"ButtonQuickEmbed": "Хуткае ўбудаванне",
"ButtonQuickEmbedMetadata": "Хуткае ўбудаванне метаданых",
"ButtonQuickMatch": "Хуткі пошук",
"ButtonReScan": "Паўторнае сканаванне",
"ButtonRead": "Чытаць",
"ButtonRefresh": "Абнавіць",
"ButtonRemove": "Выдаліць",
"ButtonRemoveAll": "Выдаліць усе",
"ButtonRemoveAllLibraryItems": "Выдаліць усе элементы бібліятэкі",
"ButtonReset": "Скінуць",
"ButtonResetToDefault": "Скінуць па змаўчанні",
"ButtonRestore": "Аднавіць",
"ButtonSave": "Захаваць",
"ButtonSaveAndClose": "Захаваць і зачыніць",
"ButtonSaveTracklist": "Захаваць спіс трэкаў",
"ButtonScan": "Сканаваць",
"ButtonScanLibrary": "Сканіраваць бібліятэку",
"ButtonScrollLeft": "Пракруціць улева",
"ButtonScrollRight": "Пракруціць направа",
"ButtonSearch": "Пошук",
"ButtonSelectFolderPath": "Выбраць шлях да тэчкі",
"ButtonSeries": "Серыі",
"ButtonSetChaptersFromTracks": "Усталяваць раздзелы з трэкаў",
"ButtonShare": "Падзяліцца",
"ButtonStartM4BEncode": "Пачаць кадзіраванне ў M4B",
"ButtonStartMetadataEmbed": "Пачаць убудаванне метаданых",
"ButtonStats": "Статыстыка",
"ButtonSubmit": "Адправіць",
"ButtonTest": "Тэст",
"ButtonUnlinkOpenId": "Адвязаць OpenID",
"ButtonUpload": "Загрузіць",
"ButtonUploadBackup": "Загрузіць рэзервовую копію",
"ButtonUploadCover": "Загрузіць вокладку",
"ButtonUploadOPMLFile": "Загрузіць OPML файл",
"ButtonUserDelete": "Выдаліць карыстальніка {0}",
"ButtonUserEdit": "Рэдагаваць карыстальніка {0}",
"ButtonViewAll": "Прагледзець усе",
"ButtonYes": "Так",
"HeaderAccount": "Уліковы запіс",
"HeaderAddCustomMetadataProvider": "Дадаць карыстальніцкага пастаўшчыка метаданных",
"HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise",
"HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг",
"HeaderAuthentication": "Аўтэнтыфікацыя",
"HeaderBackups": "Рэзервовыя копіі",
"HeaderChangePassword": "Змяніць пароль",
"HeaderChapters": "Раздзелы",
"HeaderChooseAFolder": "Выбраць тэчку",
"HeaderCollection": "Калекцыя",
"HeaderCollectionItems": "Элементы калекцыі",
"HeaderCover": "Вокладка",
"HeaderCurrentDownloads": "Бягучыя загрузкі",
"HeaderCustomMessageOnLogin": "Карыстальніцкае паведамленне пры ўваходзе"
}
{}

View File

@@ -629,6 +629,7 @@
"MessageItemsSelected": "{0} избрани",
"MessageItemsUpdated": "{0} елемента обновени",
"MessageJoinUsOn": "Присъединете се към нас",
"MessageListeningSessionsInTheLastYear": "{0} слушателски сесии през последната година",
"MessageLoading": "Зареждане...",
"MessageLoadingFolders": "Зареждане на Папки...",
"MessageM4BFailed": "M4B Провалено!",
@@ -725,8 +726,10 @@
"ToastBookmarkCreateFailed": "Неуспешно създаване на отметка",
"ToastBookmarkCreateSuccess": "Отметката е създадена",
"ToastBookmarkRemoveSuccess": "Отметката е премахната",
"ToastBookmarkUpdateSuccess": "Отметката е обновена",
"ToastChaptersHaveErrors": "Главите имат грешки",
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
"ToastCollectionItemsRemoveSuccess": "Елемент(и) премахнати от колекция",
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",

View File

@@ -88,8 +88,6 @@
"ButtonSaveTracklist": "ট্র্যাকলিস্ট সংরক্ষণ করুন",
"ButtonScan": "স্ক্যান",
"ButtonScanLibrary": "স্ক্যান লাইব্রেরি",
"ButtonScrollLeft": "বাম দিকে স্ক্রল করুন",
"ButtonScrollRight": "ডানদিকে স্ক্রল করুন",
"ButtonSearch": "অনুসন্ধান",
"ButtonSelectFolderPath": "ফোল্ডারের পথ নির্বাচন করুন",
"ButtonSeries": "সিরিজ",
@@ -192,7 +190,6 @@
"HeaderSettingsExperimental": "পরীক্ষামূলক ফিচার",
"HeaderSettingsGeneral": "সাধারণ",
"HeaderSettingsScanner": "স্ক্যানার",
"HeaderSettingsWebClient": "ওয়েব ক্লায়েন্ট",
"HeaderSleepTimer": "স্লিপ টাইমার",
"HeaderStatsLargestItems": "সবচেয়ে বড় আইটেম",
"HeaderStatsLongestItems": "দীর্ঘতম আইটেম (ঘন্টা)",
@@ -300,7 +297,6 @@
"LabelDiscover": "আবিষ্কার",
"LabelDownload": "ডাউনলোড করুন",
"LabelDownloadNEpisodes": "{0}টি পর্ব ডাউনলোড করুন",
"LabelDownloadable": "ডাউনলোডযোগ্য",
"LabelDuration": "সময়কাল",
"LabelDurationComparisonExactMatch": "(সঠিক মিল)",
"LabelDurationComparisonLonger": "({0} দীর্ঘ)",
@@ -546,7 +542,6 @@
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
"LabelSettingsAllowIframe": "আইফ্রেমে এম্বেড করার অনুমতি দিন",
"LabelSettingsAudiobooksOnly": "শুধুমাত্র অডিও বই",
"LabelSettingsAudiobooksOnlyHelp": "এই সেটিংটি সক্ষম করা ই-বই ফাইলগুলিকে উপেক্ষা করবে যদি না সেগুলি একটি অডিওবই ফোল্ডারের মধ্যে থাকে যে ক্ষেত্রে সেগুলিকে সম্পূরক ই-বই হিসাবে সেট করা হবে",
"LabelSettingsBookshelfViewHelp": "কাঠের তাক সহ স্কুমরফিক ডিজাইন",
@@ -589,7 +584,6 @@
"LabelSettingsStoreMetadataWithItemHelp": "ডিফল্টরূপে মেটাডেটা ফাইলগুলি /মেটাডাটা/আইটেমগুলি -এ সংরক্ষণ করা হয়, এই সেটিংটি সক্ষম করলে মেটাডেটা ফাইলগুলি আপনার লাইব্রেরি আইটেম ফোল্ডারে সংরক্ষণ করা হবে",
"LabelSettingsTimeFormat": "সময় বিন্যাস",
"LabelShare": "শেয়ার করুন",
"LabelShareDownloadableHelp": "শেয়ার লিঙ্ক সহ ব্যবহারকারীদের লাইব্রেরি আইটেমের একটি জিপ ফাইল ডাউনলোড করার অনুমতি দিন।",
"LabelShareOpen": "শেয়ার খোলা",
"LabelShareURL": "শেয়ার ইউআরএল",
"LabelShowAll": "সব দেখান",
@@ -598,8 +592,6 @@
"LabelSize": "আকার",
"LabelSleepTimer": "স্লিপ টাইমার",
"LabelSlug": "স্লাগ",
"LabelSortAscending": "আরোহী",
"LabelSortDescending": "অবরোহী",
"LabelStart": "শুরু",
"LabelStartTime": "শুরুর সময়",
"LabelStarted": "শুরু হয়েছে",
@@ -687,8 +679,6 @@
"LabelViewPlayerSettings": "প্লেয়ার সেটিংস দেখুন",
"LabelViewQueue": "প্লেয়ার সারি দেখুন",
"LabelVolume": "ভলিউম",
"LabelWebRedirectURLsDescription": "লগইন করার পরে ওয়েব অ্যাপে পুনঃনির্দেশের অনুমতি দেওয়ার জন্য আপনার OAuth প্রদানকারীতে এই URLগুলোকে অনুমোদন করুন:",
"LabelWebRedirectURLsSubfolder": "রিডাইরেক্ট URL এর জন্য সাবফোল্ডার",
"LabelWeekdaysToRun": "চলতে হবে সপ্তাহের দিন",
"LabelXBooks": "{0}টি বই",
"LabelXItems": "{0}টি আইটেম",
@@ -773,6 +763,7 @@
"MessageItemsSelected": "{0}টি আইটেম নির্বাচিত",
"MessageItemsUpdated": "{0}টি আইটেম আপডেট করা হয়েছে",
"MessageJoinUsOn": "আমাদের সাথে যোগ দিন",
"MessageListeningSessionsInTheLastYear": "গত বছরে {0}টি শোনার সেশন",
"MessageLoading": "লোড হচ্ছে.।",
"MessageLoadingFolders": "ফোল্ডার লোড হচ্ছে...",
"MessageLogsDescription": "লগগুলি JSON ফাইল হিসাবে <code>/metadata/logs</code>-এ সংরক্ষণ করা হয়। ক্র্যাশ লগগুলি <code>/metadata/logs/crash_logs.txt</code>-এ সংরক্ষণ করা হয়।",
@@ -952,6 +943,7 @@
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
"ToastBookmarkCreateSuccess": "বুকমার্ক যোগ করা হয়েছে",
"ToastBookmarkRemoveSuccess": "বুকমার্ক সরানো হয়েছে",
"ToastBookmarkUpdateSuccess": "বুকমার্ক আপডেট করা হয়েছে",
"ToastCachePurgeFailed": "ক্যাশে পরিষ্কার করতে ব্যর্থ হয়েছে",
"ToastCachePurgeSuccess": "ক্যাশে সফলভাবে পরিষ্কার করা হয়েছে",
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
@@ -959,6 +951,8 @@
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
"ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে",
"ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে",
"ToastCoverUpdateFailed": "কভার আপডেট ব্যর্থ হয়েছে",

View File

@@ -88,8 +88,6 @@
"ButtonSaveTracklist": "Desa Pistes",
"ButtonScan": "Escaneja",
"ButtonScanLibrary": "Escaneja Biblioteca",
"ButtonScrollLeft": "Mou a l'esquerra",
"ButtonScrollRight": "Mou a la dreta",
"ButtonSearch": "Cerca",
"ButtonSelectFolderPath": "Selecciona Ruta de Carpeta",
"ButtonSeries": "Sèries",
@@ -898,6 +896,7 @@
"ToastBookmarkCreateFailed": "Error en crear marcador",
"ToastBookmarkCreateSuccess": "Marcador afegit",
"ToastBookmarkRemoveSuccess": "Marcador eliminat",
"ToastBookmarkUpdateSuccess": "Marcador actualitzat",
"ToastCachePurgeFailed": "Error en purgar la memòria cau",
"ToastCachePurgeSuccess": "Memòria cau purgada amb èxit",
"ToastChaptersHaveErrors": "Els capítols tenen errors",
@@ -905,6 +904,8 @@
"ToastChaptersRemoved": "Capítols eliminats",
"ToastChaptersUpdated": "Capítols actualitzats",
"ToastCollectionItemsAddFailed": "Error en afegir elements a la col·lecció",
"ToastCollectionItemsAddSuccess": "Elements afegits a la col·lecció",
"ToastCollectionItemsRemoveSuccess": "Elements eliminats de la col·lecció",
"ToastCollectionRemoveSuccess": "Col·lecció eliminada",
"ToastCollectionUpdateSuccess": "Col·lecció actualitzada",
"ToastCoverUpdateFailed": "Error en actualitzar la portada",

View File

@@ -572,7 +572,7 @@
"LabelSettingsLibraryMarkAsFinishedWhen": "Označit položku médií jako dokončenou, když",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Přeskočit předchozí knihy v pokračování série",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polička Pokračovat v sérii na domovské stránce zobrazuje první nezačatou knihu v sériích, které mají alespoň jednu knihu dokončenou a žádnou rozečtenou. Povolením tohoto nastavení budou série pokračovat od poslední dokončené knihy namísto první nezačaté knihy.",
"LabelSettingsParseSubtitles": "Analyzovat podtitul",
"LabelSettingsParseSubtitles": "Analzyovat podtitul",
"LabelSettingsParseSubtitlesHelp": "Rozparsovat podtitul z názvů složek audioknih.<br>Podtiul musí být oddělen znakem \" - \"<br>tj. \"Název knihy - Zde Podtitul\" má podtitul \"Zde podtitul\"",
"LabelSettingsPreferMatchedMetadata": "Preferovat spárovaná metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Spárovaná data budou mít při použití funkce Rychlé párování přednost před údaji o položce. Ve výchozím nastavení funkce Rychlé párování pouze doplní chybějící údaje.",
@@ -756,7 +756,6 @@
"MessageConfirmResetProgress": "Opravdu chcete zahodit svůj pokrok?",
"MessageConfirmSendEbookToDevice": "Opravdu chcete odeslat e-knihu {0} {1}\" do zařízení \"{2}\"?",
"MessageConfirmUnlinkOpenId": "Opravdu chcete odpojit tohoto uživatele z OpenID?",
"MessageDaysListenedInTheLastYear": "{0} poslechových dní v minulém roce",
"MessageDownloadingEpisode": "Stahuji epizodu",
"MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop",
"MessageEmbedFailed": "Vložení selhalo!",
@@ -772,6 +771,7 @@
"MessageItemsSelected": "{0} vybraných položek",
"MessageItemsUpdated": "{0} položky byly aktualizovány",
"MessageJoinUsOn": "Přidejte se k nám",
"MessageListeningSessionsInTheLastYear": "{0} poslechových relací za poslední rok",
"MessageLoading": "Načítá se...",
"MessageLoadingFolders": "Načítám složky...",
"MessageLogsDescription": "Protokoly se ukládají do souborů JSON v <code>/metadata/logs</code>. Protokoly o pádech jsou uloženy v <code>/metadata/logs/crash_logs.txt</code>.",
@@ -867,8 +867,6 @@
"MessageTaskOpmlImportFeedPodcastExists": "Podcast se stejnou cestou již existuje",
"MessageTaskOpmlImportFeedPodcastFailed": "Vytváření podcastu selhalo",
"MessageTaskOpmlImportFinished": "Přidáno {0} podcastů",
"MessageTaskOpmlParseFailed": "Selhalo parsování OPML souboru",
"MessageTaskOpmlParseFastFail": "Neplatný OPML soubor <opml> tag nenalezen NEBO <outline> tag nenalezen",
"MessageTaskScanItemsAdded": "{0} přidáno",
"MessageTaskScanItemsMissing": "{0} chybí",
"MessageTaskScanItemsUpdated": "{0} aktualizováno",
@@ -893,10 +891,6 @@
"NoteUploaderFoldersWithMediaFiles": "Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.",
"NoteUploaderOnlyAudioFiles": "Pokud nahráváte pouze zvukové soubory, bude s každým zvukovým souborem zacházeno jako se samostatnou audioknihou.",
"NoteUploaderUnsupportedFiles": "Nepodporované soubory jsou ignorovány. Při výběru nebo přetažení složky jsou ostatní soubory, které nejsou ve složce položek, ignorovány.",
"NotificationOnBackupCompletedDescription": "Spuštěno po dokončení zálohování",
"NotificationOnBackupFailedDescription": "Spuštěno pokud zálohování selže",
"NotificationOnEpisodeDownloadedDescription": "Spuštěno při automatickém stažení epizody podcastu",
"NotificationOnTestDescription": "Akce pro otestování upozorňovacího systému",
"PlaceholderNewCollection": "Nový název kolekce",
"PlaceholderNewFolderPath": "Nová cesta ke složce",
"PlaceholderNewPlaylist": "Nový název seznamu přehrávání",
@@ -907,7 +901,6 @@
"StatsBooksAdditional": "Některé další zahrnují…",
"StatsBooksFinished": "dokončené knihy",
"StatsBooksFinishedThisYear": "Některé knihy dokončené tento rok…",
"StatsBooksListenedTo": "knih poslechnuto",
"StatsCollectionGrewTo": "Vaše kolekce knih se rozrostla na…",
"StatsSessions": "sezení",
"StatsSpentListening": "stráveno posloucháním",
@@ -916,13 +909,10 @@
"StatsTopGenre": "TOP ŽÁNR",
"StatsTopGenres": "TOP ŽÁNRY",
"StatsTopMonth": "TOP MĚSÍC",
"StatsTopNarrator": "NEJLEPŠÍ VYPRAVĚČ",
"StatsTopNarrators": "NEJLEPŠÍ VYPRAVĚČI",
"StatsTotalDuration": "S celkovou dobou…",
"StatsYearInReview": "ROK V PŘEHLEDU",
"ToastAccountUpdateSuccess": "Účet aktualizován",
"ToastAppriseUrlRequired": "Je nutné zadat Apprise URL",
"ToastAsinRequired": "ASIN vyžadován",
"ToastAuthorImageRemoveSuccess": "Obrázek autora odstraněn",
"ToastAuthorNotFound": "Author \"{0}\" nenalezen",
"ToastAuthorRemoveSuccess": "Autor odstraněn",
@@ -947,11 +937,13 @@
"ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo",
"ToastBookmarkCreateSuccess": "Přidána záložka",
"ToastBookmarkRemoveSuccess": "Záložka odstraněna",
"ToastBookmarkUpdateSuccess": "Záložka aktualizována",
"ToastCachePurgeFailed": "Nepodařilo se vyčistit mezipaměť",
"ToastCachePurgeSuccess": "Vyrovnávací paměť úspěšně vyčištěna",
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
"ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy",
"ToastChaptersRemoved": "Kapitoly odstraněny",
"ToastCollectionItemsRemoveSuccess": "Položky odstraněny z kolekce",
"ToastCollectionRemoveSuccess": "Kolekce odstraněna",
"ToastCollectionUpdateSuccess": "Kolekce aktualizována",
"ToastCoverUpdateFailed": "Aktualizace obálky selhala",

View File

@@ -37,8 +37,6 @@
"ButtonHide": "Skjul",
"ButtonHome": "Hjem",
"ButtonIssues": "Problemer",
"ButtonJumpBackward": "Hop Tilbage",
"ButtonJumpForward": "Hop Fremad",
"ButtonLatest": "Seneste",
"ButtonLibrary": "Bibliotek",
"ButtonLogout": "Log ud",
@@ -48,30 +46,20 @@
"ButtonMatchAllAuthors": "Match alle forfattere",
"ButtonMatchBooks": "Match bøger",
"ButtonNevermind": "Glem det",
"ButtonNext": "Næste",
"ButtonNextChapter": "Næste Kapitel",
"ButtonNextItemInQueue": "Næste Element i Køen",
"ButtonOk": "OK",
"ButtonOpenFeed": "Åbn feed",
"ButtonOpenManager": "Åbn manager",
"ButtonPause": "Pause",
"ButtonPlay": "Afspil",
"ButtonPlayAll": "Afspil Alle",
"ButtonPlaying": "Afspiller",
"ButtonPlaylists": "Afspilningslister",
"ButtonPrevious": "Sidste",
"ButtonPreviousChapter": "Sidste Kapitel",
"ButtonProbeAudioFile": "Undersøg Lydfil",
"ButtonPurgeAllCache": "Ryd al cache",
"ButtonPurgeItemsCache": "Ryd elementcache",
"ButtonQueueAddItem": "Tilføj til kø",
"ButtonQueueRemoveItem": "Fjern fra kø",
"ButtonQuickEmbed": "Hurtig Indlejring",
"ButtonQuickEmbedMetadata": "Hurtig Indlejring af Metadata",
"ButtonQuickMatch": "Hurtig Match",
"ButtonReScan": "Gen-scan",
"ButtonRead": "Læs",
"ButtonRefresh": "Genindlæs",
"ButtonRemove": "Fjern",
"ButtonRemoveAll": "Fjern Alle",
"ButtonRemoveAllLibraryItems": "Fjern Alle Bibliotekselementer",
@@ -79,46 +67,31 @@
"ButtonRemoveFromContinueReading": "Fjern fra Fortsæt Læsning",
"ButtonRemoveSeriesFromContinueSeries": "Fjern Serie fra Fortsæt Serie",
"ButtonReset": "Nulstil",
"ButtonResetToDefault": "Nulstil til standard",
"ButtonRestore": "Gendan",
"ButtonSave": "Gem",
"ButtonSaveAndClose": "Gem & Luk",
"ButtonSaveTracklist": "Gem Sporliste",
"ButtonScan": "Scan",
"ButtonScanLibrary": "Scan Bibliotek",
"ButtonScrollLeft": "Rul til Venstre",
"ButtonScrollRight": "Rul til Højre",
"ButtonSearch": "Søg",
"ButtonSelectFolderPath": "Vælg Mappen Sti",
"ButtonSeries": "Serier",
"ButtonSetChaptersFromTracks": "Sæt kapitler fra spor",
"ButtonShare": "Del",
"ButtonShiftTimes": "Skift Tider",
"ButtonShow": "Vis",
"ButtonStartM4BEncode": "Start M4B Kode",
"ButtonStartMetadataEmbed": "Start Metadata Indlejring",
"ButtonStats": "Statistik",
"ButtonSubmit": "Send",
"ButtonTest": "Test",
"ButtonUnlinkOpenId": "Afkobl OpenID",
"ButtonUpload": "Upload",
"ButtonUploadBackup": "Upload Backup",
"ButtonUploadCover": "Upload Omslag",
"ButtonUploadOPMLFile": "Upload OPML Fil",
"ButtonUserDelete": "Slet bruger {0}",
"ButtonUserEdit": "Rediger bruger {0}",
"ButtonViewAll": "Vis Alle",
"ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Fejl henter metadata",
"ErrorUploadFetchMetadataNoResults": "Kunne ikke hente metadata - prøv at uploade title og/eller forfatter",
"ErrorUploadLacksTitle": "Skal have en title",
"HeaderAccount": "Konto",
"HeaderAddCustomMetadataProvider": "Tilføj Brugerdefineret Metadataudbyder",
"HeaderAdvanced": "Avanceret",
"HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger",
"HeaderAudioTracks": "Lydspor",
"HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer",
"HeaderAuthentication": "Autentificering",
"HeaderBackups": "Sikkerhedskopier",
"HeaderChangePassword": "Skift Adgangskode",
"HeaderChapters": "Kapitler",
@@ -127,12 +100,9 @@
"HeaderCollectionItems": "Samlingselementer",
"HeaderCover": "Omslag",
"HeaderCurrentDownloads": "Nuværende Downloads",
"HeaderCustomMessageOnLogin": "Brugerdefineret Besked ved Login",
"HeaderCustomMetadataProviders": "Brugerdefineret Metadataudbyder",
"HeaderDetails": "Detaljer",
"HeaderDownloadQueue": "Download Kø",
"HeaderEbookFiles": "E-bogsfiler",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Indstillinger",
"HeaderEpisodes": "Episoder",
"HeaderEreaderDevices": "E-læser Enheder",
@@ -150,47 +120,33 @@
"HeaderListeningSessions": "Lyttesessioner",
"HeaderListeningStats": "Lyttestatistik",
"HeaderLogin": "Log ind",
"HeaderLogs": "Logs",
"HeaderManageGenres": "Administrer Genrer",
"HeaderManageTags": "Administrer Tags",
"HeaderMapDetails": "Kort Detaljer",
"HeaderMatch": "Match",
"HeaderMetadataOrderOfPrecedence": "Metadata-prioritet",
"HeaderMetadataToEmbed": "Metadata til indlejring",
"HeaderNewAccount": "Ny Konto",
"HeaderNewLibrary": "Nyt Bibliotek",
"HeaderNotificationCreate": "Opret Notifikation",
"HeaderNotificationUpdate": "Updater Notifikation",
"HeaderNotifications": "Meddelelser",
"HeaderOpenIDConnectAuthentication": "OpenID Connect-autentificering",
"HeaderOpenListeningSessions": "Åbne lyttesessioner",
"HeaderOpenRSSFeed": "Åbn RSS Feed",
"HeaderOtherFiles": "Andre Filer",
"HeaderPasswordAuthentication": "Adgangskodeautentificering",
"HeaderPermissions": "Tilladelser",
"HeaderPlayerQueue": "Afspilningskø",
"HeaderPlayerSettings": "Afspiller Indstillinger",
"HeaderPlaylist": "Afspilningsliste",
"HeaderPlaylistItems": "Afspilningsliste Elementer",
"HeaderPodcastsToAdd": "Podcasts til Tilføjelse",
"HeaderPreviewCover": "Forhåndsvis Omslag",
"HeaderRSSFeedGeneral": "RSS Detaljer",
"HeaderRSSFeedIsOpen": "RSS Feed er Åben",
"HeaderRSSFeeds": "RSS-Feeds",
"HeaderRemoveEpisode": "Fjern Episode",
"HeaderRemoveEpisodes": "Fjern {0} Episoder",
"HeaderSavedMediaProgress": "Gemt Medieforløb",
"HeaderSchedule": "Planlæg",
"HeaderScheduleEpisodeDownloads": "Planlæg Automatisk Episode-Download",
"HeaderScheduleLibraryScans": "Planlæg Automatiske Biblioteksscanninger",
"HeaderSession": "Session",
"HeaderSetBackupSchedule": "Indstil Sikkerhedskopieringsplan",
"HeaderSettings": "Indstillinger",
"HeaderSettingsDisplay": "Skærm",
"HeaderSettingsExperimental": "Eksperimentelle Funktioner",
"HeaderSettingsGeneral": "Generelt",
"HeaderSettingsScanner": "Scanner",
"HeaderSettingsWebClient": "Webklient",
"HeaderSleepTimer": "Søvntimer",
"HeaderStatsLargestItems": "Største Elementer",
"HeaderStatsLongestItems": "Længste Elementer (timer)",
@@ -205,12 +161,7 @@
"HeaderUpdateDetails": "Opdater Detaljer",
"HeaderUpdateLibrary": "Opdater Bibliotek",
"HeaderUsers": "Brugere",
"HeaderYearReview": "Gennemgang af År {0}",
"HeaderYourStats": "Dine Statistikker",
"LabelAbridged": "Forkortet",
"LabelAbridgedChecked": "Forkortet (kontrolleret)",
"LabelAbridgedUnchecked": "Uforkortet (ikke kontrolleret)",
"LabelAccessibleBy": "Tilgængelig af",
"LabelAccountType": "Kontotype",
"LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gæst",
@@ -221,26 +172,15 @@
"LabelAddToPlaylist": "Tilføj til Afspilningsliste",
"LabelAddToPlaylistBatch": "Tilføj {0} Elementer til Afspilningsliste",
"LabelAddedAt": "Tilføjet Kl.",
"LabelAdminUsersOnly": "Kun Administratorbrugere",
"LabelAll": "Alle",
"LabelAllUsers": "Alle Brugere",
"LabelAllUsersExcludingGuests": "Alle bruger eksklusiv gæster",
"LabelAllUsersIncludingGuests": "Alle bruger inklusiv gæster",
"LabelAlreadyInYourLibrary": "Allerede i dit bibliotek",
"LabelApiToken": "API Token",
"LabelAppend": "Tilføj",
"LabelAudioBitrate": "Lydbitrate (f.eks. 128k)",
"LabelAudioChannels": "Lydkanaler (1 eller 2)",
"LabelAudioCodec": "Lydkodek",
"LabelAuthor": "Forfatter",
"LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)",
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
"LabelAuthors": "Forfattere",
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
"LabelAutoFetchMetadata": "Automatisk Hent Metadata",
"LabelAutoFetchMetadataHelp": "Henter metadata for titler, forfatter og serier for at strømligne uploading. Ekstra metadata har måske brug for at blive matchet efter upload.",
"LabelAutoLaunch": "Åben Automatisk",
"LabelAutoLaunchDescription": "Viderestil automatisk til login-udbyderen ved navigation til login-siden (manuel overstyring via <code>/login?autoLaunch=0</code>)",
"LabelBackToUser": "Tilbage til Bruger",
"LabelBackupLocation": "Backup Placering",
"LabelBackupsEnableAutomaticBackups": "Aktivér automatisk sikkerhedskopiering",
@@ -250,7 +190,6 @@
"LabelBackupsNumberToKeep": "Antal sikkerhedskopier at beholde",
"LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.",
"LabelBooks": "Bøger",
"LabelByAuthor": "af {0}",
"LabelChangePassword": "Ændre Adgangskode",
"LabelChannels": "Kanaler",
"LabelChapterTitle": "Kapitel Titel",
@@ -600,6 +539,7 @@
"MessageItemsSelected": "{0} elementer valgt",
"MessageItemsUpdated": "{0} elementer opdateret",
"MessageJoinUsOn": "Deltag i os på",
"MessageListeningSessionsInTheLastYear": "{0} lyttesessioner i det sidste år",
"MessageLoading": "Indlæser...",
"MessageLoadingFolders": "Indlæser mapper...",
"MessageM4BFailed": "M4B mislykkedes!",
@@ -697,8 +637,10 @@
"ToastBookmarkCreateFailed": "Mislykkedes oprettelse af bogmærke",
"ToastBookmarkCreateSuccess": "Bogmærke tilføjet",
"ToastBookmarkRemoveSuccess": "Bogmærke fjernet",
"ToastBookmarkUpdateSuccess": "Bogmærke opdateret",
"ToastChaptersHaveErrors": "Kapitler har fejl",
"ToastChaptersMustHaveTitles": "Kapitler skal have titler",
"ToastCollectionItemsRemoveSuccess": "Element(er) fjernet fra samlingen",
"ToastCollectionRemoveSuccess": "Samling fjernet",
"ToastCollectionUpdateSuccess": "Samling opdateret",
"ToastItemCoverUpdateSuccess": "Varens omslag opdateret",

View File

@@ -51,7 +51,7 @@
"ButtonNext": "Vor",
"ButtonNextChapter": "Nächstes Kapitel",
"ButtonNextItemInQueue": "Das nächste Element in der Warteschlange",
"ButtonOk": "Einverstanden",
"ButtonOk": "OK",
"ButtonOpenFeed": "Feed öffnen",
"ButtonOpenManager": "Manager öffnen",
"ButtonPause": "Pausieren",
@@ -300,7 +300,6 @@
"LabelDiscover": "Entdecken",
"LabelDownload": "Herunterladen",
"LabelDownloadNEpisodes": "Download {0} Episoden",
"LabelDownloadable": "Herunterladbar",
"LabelDuration": "Laufzeit",
"LabelDurationComparisonExactMatch": "(genauer Treffer)",
"LabelDurationComparisonLonger": "({0} länger)",
@@ -589,7 +588,6 @@
"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": "Freigeben",
"LabelShareDownloadableHelp": "Erlaubt es einem Nutzer, mit dem Link, die Dateien des Mediums als ZIP herunterzuladen.",
"LabelShareOpen": "Freigeben",
"LabelShareURL": "Freigabe URL",
"LabelShowAll": "Alles anzeigen",
@@ -758,7 +756,6 @@
"MessageConfirmResetProgress": "Möchtest du Ihren Fortschritt wirklich zurücksetzen?",
"MessageConfirmSendEbookToDevice": "{0} E-Buch „{1}“ wird auf das Gerät „{2}“ gesendet! Bist du dir sicher?",
"MessageConfirmUnlinkOpenId": "Möchtest du die Verknüpfung dieses Benutzers mit OpenID wirklich löschen?",
"MessageDaysListenedInTheLastYear": "{0} Tage in dem letzten Jahr gehört",
"MessageDownloadingEpisode": "Episode wird heruntergeladen",
"MessageDragFilesIntoTrackOrder": "Verschiebe die Dateien in die richtige Reihenfolge",
"MessageEmbedFailed": "Einbetten fehlgeschlagen!",
@@ -774,6 +771,7 @@
"MessageItemsSelected": "{0} ausgewählte Medien",
"MessageItemsUpdated": "{0} Medien aktualisiert",
"MessageJoinUsOn": "Besuche uns auf",
"MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr",
"MessageLoading": "Wird geladen …",
"MessageLoadingFolders": "Lade Ordner...",
"MessageLogsDescription": "Die Logs werdern in <code>/metadata/logs</code> als JSON Dateien gespeichert. Crash logs werden in <code>/metadata/logs/crash_logs.txt</code> gespeichert.",
@@ -837,7 +835,6 @@
"MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?",
"MessageRestoreBackupConfirm": "Bist du dir sicher, dass du die Sicherung wiederherstellen willst, welche am",
"MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in deinen Bibliotheksordnern verändert. Wenn du die Servereinstellungen aktiviert hast, um Cover und Metadaten in deinen Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.",
"MessageScheduleLibraryScanNote": "Für die meisten Nutzer wird empfohlen, diese Funktion deaktiviert zu lassen und stattdessen die Ordnerüberwachung aktiviert zu lassen. Die Ordnerüberwachung erkennt automatisch Änderungen in deinen Bibliotheksordnern. Da die Ordnerüberwachung jedoch nicht mit jedem Dateisystem (z.B. NFS) funktioniert, können alternativ hier geplante Bibliotheks-Scans aktiviert werden.",
"MessageSearchResultsFor": "Suchergebnisse für",
"MessageSelected": "{0} ausgewählt",
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
@@ -913,7 +910,7 @@
"StatsBooksFinished": "Beendete Bücher",
"StatsBooksFinishedThisYear": "Einige Bücher, die dieses Jahr beendet wurden…",
"StatsBooksListenedTo": "gehörte Bücher",
"StatsCollectionGrewTo": "Deine Büchersammlung ist gewachsen auf…",
"StatsCollectionGrewTo": "Deine Bückersammlung ist gewachsen auf…",
"StatsSessions": "Sitzungen",
"StatsSpentListening": "zugehört",
"StatsTopAuthor": "TOP AUTOR",
@@ -954,6 +951,7 @@
"ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
"ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt",
"ToastBookmarkRemoveSuccess": "Lesezeichen entfernt",
"ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
"ToastCachePurgeFailed": "Cache leeren fehlgeschlagen",
"ToastCachePurgeSuccess": "Cache geleert",
"ToastChaptersHaveErrors": "Kapitel sind fehlerhaft",
@@ -961,10 +959,11 @@
"ToastChaptersRemoved": "Kapitel entfernt",
"ToastChaptersUpdated": "Kapitel aktualisiert",
"ToastCollectionItemsAddFailed": "Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen",
"ToastCollectionItemsAddSuccess": "Element(e) erfolgreich zur Sammlung hinzugefügt",
"ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt",
"ToastCollectionRemoveSuccess": "Sammlung entfernt",
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
"ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen",
"ToastDateTimeInvalidOrIncomplete": "Datum und Zeit ist ungültig oder unvollständig",
"ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden",
"ToastDeleteFileSuccess": "Datei gelöscht",
"ToastDeviceAddFailed": "Gerät konnte nicht hinzugefügt werden",
@@ -1017,7 +1016,6 @@
"ToastNewUserTagError": "Mindestens ein Tag muss ausgewählt sein",
"ToastNewUserUsernameError": "Nutzername eingeben",
"ToastNoNewEpisodesFound": "Keine neuen Episoden gefunden",
"ToastNoRSSFeed": "Podcast hat keinen RSS Feed",
"ToastNoUpdatesNecessary": "Keine Änderungen nötig",
"ToastNotificationCreateFailed": "Fehler beim erstellen der Benachrichtig",
"ToastNotificationDeleteFailed": "Fehler beim löschen der Benachrichtigung",

View File

@@ -758,7 +758,6 @@
"MessageConfirmResetProgress": "Are you sure you want to reset your progress?",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageConfirmUnlinkOpenId": "Are you sure you want to unlink this user from OpenID?",
"MessageDaysListenedInTheLastYear": "{0} days listened in the last year",
"MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFailed": "Embed Failed!",
@@ -774,6 +773,7 @@
"MessageItemsSelected": "{0} Items Selected",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Join us on",
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
"MessageLoading": "Loading...",
"MessageLoadingFolders": "Loading folders...",
"MessageLogsDescription": "Logs are stored in <code>/metadata/logs</code> as JSON files. Crash logs are stored in <code>/metadata/logs/crash_logs.txt</code>.",
@@ -837,7 +837,6 @@
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageScheduleLibraryScanNote": "For most users, it is recommended to leave this feature disabled and keep the folder watcher setting enabled. The folder watcher will automatically detect changes in your library folders. The folder watcher doesn't work for every file system (like NFS) so scheduled library scans can be used instead.",
"MessageSearchResultsFor": "Search results for",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Server could not be reached",
@@ -954,6 +953,7 @@
"ToastBookmarkCreateFailed": "Failed to create bookmark",
"ToastBookmarkCreateSuccess": "Bookmark added",
"ToastBookmarkRemoveSuccess": "Bookmark removed",
"ToastBookmarkUpdateSuccess": "Bookmark updated",
"ToastCachePurgeFailed": "Failed to purge cache",
"ToastCachePurgeSuccess": "Cache purged successfully",
"ToastChaptersHaveErrors": "Chapters have errors",
@@ -961,10 +961,11 @@
"ToastChaptersRemoved": "Chapters removed",
"ToastChaptersUpdated": "Chapters updated",
"ToastCollectionItemsAddFailed": "Item(s) added to collection failed",
"ToastCollectionItemsAddSuccess": "Item(s) added to collection success",
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
"ToastCollectionRemoveSuccess": "Collection removed",
"ToastCollectionUpdateSuccess": "Collection updated",
"ToastCoverUpdateFailed": "Cover update failed",
"ToastDateTimeInvalidOrIncomplete": "Date and time is invalid or incomplete",
"ToastDeleteFileFailed": "Failed to delete file",
"ToastDeleteFileSuccess": "File deleted",
"ToastDeviceAddFailed": "Failed to add device",
@@ -1017,7 +1018,6 @@
"ToastNewUserTagError": "Must select at least one tag",
"ToastNewUserUsernameError": "Enter a username",
"ToastNoNewEpisodesFound": "No new episodes found",
"ToastNoRSSFeed": "Podcast does not have an RSS Feed",
"ToastNoUpdatesNecessary": "No updates necessary",
"ToastNotificationCreateFailed": "Failed to create notification",
"ToastNotificationDeleteFailed": "Failed to delete notification",

View File

@@ -300,7 +300,6 @@
"LabelDiscover": "Descubrir",
"LabelDownload": "Descargar",
"LabelDownloadNEpisodes": "Descargar {0} episodios",
"LabelDownloadable": "Descarregable",
"LabelDuration": "Duración",
"LabelDurationComparisonExactMatch": "(coincidencia exacta)",
"LabelDurationComparisonLonger": "({0} más largo)",
@@ -589,7 +588,6 @@
"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",
"LabelShareDownloadableHelp": "Permet als usuaris amb l'enllaç compartit descarregar un arxiu zip amb l'item de la llibreria.",
"LabelShareOpen": "abrir un recurso compartido",
"LabelShareURL": "Compartir la URL",
"LabelShowAll": "Mostrar Todos",
@@ -758,7 +756,6 @@
"MessageConfirmResetProgress": "¿Estás seguro de que quieres reiniciar tu progreso?",
"MessageConfirmSendEbookToDevice": "¿Está seguro de que enviar {0} ebook(s) \"{1}\" al dispositivo \"{2}\"?",
"MessageConfirmUnlinkOpenId": "¿Estás seguro de que deseas desvincular este usuario de OpenID?",
"MessageDaysListenedInTheLastYear": "{0} dies escoltats en l'últim any",
"MessageDownloadingEpisode": "Descargando Capitulo",
"MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas",
"MessageEmbedFailed": "¡Error al insertar!",
@@ -774,6 +771,7 @@
"MessageItemsSelected": "{0} Elementos Seleccionados",
"MessageItemsUpdated": "{0} Elementos Actualizados",
"MessageJoinUsOn": "Únetenos en",
"MessageListeningSessionsInTheLastYear": "{0} sesiones de escucha en el último año",
"MessageLoading": "Cargando...",
"MessageLoadingFolders": "Cargando archivos...",
"MessageLogsDescription": "Logs son almacenados en <code>/metadata/logs</code> en archivos bajo formato JSON. Logs de fallos son almacenados en <code>/metadata/logs/crash_logs.txt</code>.",
@@ -953,6 +951,7 @@
"ToastBookmarkCreateFailed": "Error al crear marcador",
"ToastBookmarkCreateSuccess": "Marcador Agregado",
"ToastBookmarkRemoveSuccess": "Marcador eliminado",
"ToastBookmarkUpdateSuccess": "Marcador actualizado",
"ToastCachePurgeFailed": "Error al purgar el caché",
"ToastCachePurgeSuccess": "Caché purgado de manera exitosa",
"ToastChaptersHaveErrors": "Los capítulos tienen errores",
@@ -960,10 +959,11 @@
"ToastChaptersRemoved": "Capítulos eliminados",
"ToastChaptersUpdated": "Capítulos actualizados",
"ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)",
"ToastCollectionItemsAddSuccess": "Artículo(s) añadido(s) a la colección correctamente",
"ToastCollectionItemsRemoveSuccess": "Elementos(s) removidos de la colección",
"ToastCollectionRemoveSuccess": "Colección removida",
"ToastCollectionUpdateSuccess": "Colección actualizada",
"ToastCoverUpdateFailed": "Error al actualizar la cubierta",
"ToastDateTimeInvalidOrIncomplete": "Fecha y hora inválidas o incompletas",
"ToastDeleteFileFailed": "Error el eliminar archivo",
"ToastDeleteFileSuccess": "Archivo eliminado",
"ToastDeviceAddFailed": "Error al añadir dispositivo",
@@ -1000,7 +1000,7 @@
"ToastLibraryScanFailedToStart": "Error al iniciar el escaneo",
"ToastLibraryScanStarted": "Se inició el escaneo de la biblioteca",
"ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada",
"ToastMatchAllAuthorsFailed": "No se pudo encontrar a todos los autores",
"ToastMatchAllAuthorsFailed": "No coincide con todos los autores",
"ToastMetadataFilesRemovedError": "Error al eliminar metadatos de {0} archivo(s)",
"ToastMetadataFilesRemovedNoneFound": "No hay metadatos.{0} archivo(s) encontrado(s) en la biblioteca",
"ToastMetadataFilesRemovedNoneRemoved": "Sin metadatos.{0} archivo(s) eliminado(s)",
@@ -1016,7 +1016,6 @@
"ToastNewUserTagError": "Debes seleccionar al menos una etiqueta",
"ToastNewUserUsernameError": "Introduce un nombre de usuario",
"ToastNoNewEpisodesFound": "No se encontraron nuevos episodios",
"ToastNoRSSFeed": "El Podcast no tiene una fuente RSS",
"ToastNoUpdatesNecessary": "No es necesario actualizar",
"ToastNotificationCreateFailed": "Error al crear notificación",
"ToastNotificationDeleteFailed": "Error al borrar la notificación",

View File

@@ -611,6 +611,7 @@
"MessageItemsSelected": "{0} Valitud üksust",
"MessageItemsUpdated": "{0} Üksust on uuendatud",
"MessageJoinUsOn": "Liitu meiega",
"MessageListeningSessionsInTheLastYear": "Kuulamissessioone viimase aasta jooksul: {0}",
"MessageLoading": "Laadimine...",
"MessageLoadingFolders": "Kaustade laadimine...",
"MessageM4BFailed": "M4B ebaõnnestus!",
@@ -709,8 +710,10 @@
"ToastBookmarkCreateFailed": "Järjehoidja loomine ebaõnnestus",
"ToastBookmarkCreateSuccess": "Järjehoidja lisatud",
"ToastBookmarkRemoveSuccess": "Järjehoidja eemaldatud",
"ToastBookmarkUpdateSuccess": "Järjehoidja värskendatud",
"ToastChaptersHaveErrors": "Peatükkidel on vigu",
"ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri",
"ToastCollectionItemsRemoveSuccess": "Üksus(ed) eemaldatud kogumist",
"ToastCollectionRemoveSuccess": "Kogum eemaldatud",
"ToastCollectionUpdateSuccess": "Kogum värskendatud",
"ToastItemCoverUpdateSuccess": "Üksuse kaas värskendatud",

View File

@@ -65,13 +65,11 @@
"ButtonPurgeItemsCache": "Tyhjennä kohteiden välimuisti",
"ButtonQueueAddItem": "Lisää jonoon",
"ButtonQueueRemoveItem": "Poista jonosta",
"ButtonQuickEmbed": "Pikaupota",
"ButtonQuickEmbedMetadata": "Upota kuvailutiedot nopeasti",
"ButtonQuickMatch": "Pikatäsmää",
"ButtonReScan": "Uudelleenskannaa",
"ButtonRead": "Lue",
"ButtonReadLess": "Lue vähemmän",
"ButtonReadMore": "Lue enemmän",
"ButtonReadLess": "Näytä vähemmän",
"ButtonReadMore": "Näytä enemmän",
"ButtonRefresh": "Päivitä",
"ButtonRemove": "Poista",
"ButtonRemoveAll": "Poista kaikki",
@@ -87,8 +85,6 @@
"ButtonSaveTracklist": "Tallenna raitalista",
"ButtonScan": "Skannaa",
"ButtonScanLibrary": "Skannaa kirjasto",
"ButtonScrollLeft": "Vieritä vasemmalle",
"ButtonScrollRight": "Vieritä oikealle",
"ButtonSearch": "Etsi",
"ButtonSelectFolderPath": "Valitse kansiopolku",
"ButtonSeries": "Sarjat",
@@ -152,7 +148,6 @@
"HeaderLogs": "Lokit",
"HeaderManageGenres": "Hallitse lajityyppejä",
"HeaderManageTags": "Hallitse tageja",
"HeaderMetadataOrderOfPrecedence": "Metadatan tärkeysjärjestys",
"HeaderMetadataToEmbed": "Sisällytettävä metadata",
"HeaderNewAccount": "Uusi tili",
"HeaderNewLibrary": "Uusi kirjasto",
@@ -161,7 +156,6 @@
"HeaderNotifications": "Ilmoitukset",
"HeaderOpenRSSFeed": "Avaa RSS-syöte",
"HeaderOtherFiles": "Muut tiedostot",
"HeaderPasswordAuthentication": "Salasanan todentaminen",
"HeaderPermissions": "Käyttöoikeudet",
"HeaderPlayerQueue": "Soittimen jono",
"HeaderPlayerSettings": "Soittimen asetukset",
@@ -175,34 +169,24 @@
"HeaderRemoveEpisode": "Poista jakso",
"HeaderRemoveEpisodes": "Poista {0} jaksoa",
"HeaderSchedule": "Ajoita",
"HeaderScheduleEpisodeDownloads": "Ajoita automaattiset jaksolataukset",
"HeaderScheduleLibraryScans": "Ajoita automaattiset kirjastoskannaukset",
"HeaderSession": "Istunto",
"HeaderSetBackupSchedule": "Aseta varmuuskopiointiaikataulu",
"HeaderSettings": "Asetukset",
"HeaderSettingsDisplay": "Näyttö",
"HeaderSettingsExperimental": "Kokeelliset ominaisuudet",
"HeaderSettingsGeneral": "Yleiset",
"HeaderSettingsScanner": "Skannaaja",
"HeaderSleepTimer": "Uniajastin",
"HeaderStatsLargestItems": "Suurimmat kohteet",
"HeaderStatsLongestItems": "Pisimmät kohteet (h)",
"HeaderStatsMinutesListeningChart": "Kuunteluminuutit (viim. 7 pv)",
"HeaderStatsRecentSessions": "Viimeaikaiset istunnot",
"HeaderStatsTop10Authors": "Suosituimmat 10 kirjailijaa",
"HeaderStatsTop5Genres": "Suosituimmat 5 lajityyppiä",
"HeaderStatsTop5Genres": "Top 5 lajityypit",
"HeaderTableOfContents": "Sisällysluettelo",
"HeaderTools": "Työkalut",
"HeaderUpdateAccount": "Päivitä tili",
"HeaderUpdateAuthor": "Päivitä kirjailija",
"HeaderUpdateDetails": "Päivitä yksityiskohdat",
"HeaderUpdateLibrary": "Päivitä kirjasto",
"HeaderUsers": "Käyttäjät",
"HeaderYearReview": "Vuosi {0} tarkasteltuna",
"HeaderYourStats": "Tilastosi",
"LabelAbridged": "Lyhennetty",
"LabelAbridgedChecked": "Lyhennetty (tarkistettu)",
"LabelAbridgedUnchecked": "Lyhentämätön (tarkistamaton)",
"LabelAccountType": "Tilin tyyppi",
"LabelAccountTypeAdmin": "Järjestelmänvalvoja",
"LabelAccountTypeGuest": "Vieras",
@@ -220,40 +204,24 @@
"LabelAllUsersExcludingGuests": "Kaikki käyttäjät vieraita lukuun ottamatta",
"LabelAllUsersIncludingGuests": "Kaikki käyttäjät mukaan lukien vieraat",
"LabelAlreadyInYourLibrary": "Jo kirjastossasi",
"LabelApiToken": "Sovellusliittymätunnus",
"LabelAudioBitrate": "Äänen bittinopeus (esim. 128k)",
"LabelAudioChannels": "Äänikanavat (1 tai 2)",
"LabelAudioCodec": "Äänikoodekki",
"LabelAuthor": "Tekijä",
"LabelAuthorFirstLast": "Tekijä (Etunimi Sukunimi)",
"LabelAuthorLastFirst": "Tekijä (Sukunimi, Etunimi)",
"LabelAuthors": "Tekijät",
"LabelAutoDownloadEpisodes": "Lataa jaksot automaattisesti",
"LabelAutoFetchMetadata": "Etsi metadata automaattisesti",
"LabelAutoLaunch": "Automaattinen käynnistys",
"LabelAutoRegister": "Automaattinen rekisteröinti",
"LabelAutoRegisterDescription": "Luo automaattisesti uusia käyttäjiä kirjautumisen jälkeen",
"LabelBackToUser": "Takaisin käyttäjään",
"LabelBackupAudioFiles": "Varmuuskopioi äänitiedostot",
"LabelBackupLocation": "Varmuuskopiointipaikka",
"LabelBackupsEnableAutomaticBackups": "Ota automaattinen varmuuskopiointi käyttöön",
"LabelBackupsEnableAutomaticBackupsHelp": "Varmuuskopiot tallennettu kansioon /metadata/backups",
"LabelBackupsMaxBackupSize": "Varmuuskopion enimmäiskoko (Gt) (0 rajaton)",
"LabelBackupsMaxBackupSizeHelp": "Virheellisten asetusten estämiseksi varmuuskopiot epäonnistuvat, jos ne ovat asetettua kokoa suurempia.",
"LabelBackupsNumberToKeep": "Säilytettävien varmuuskopioiden määrä",
"LabelBackupsNumberToKeepHelp": "Varmuuskopiot poistetaan yksi kerrallaan, joten jos niitä on enemmän kuin yksi, ne on poistettava manuaalisesti.",
"LabelBitrate": "Bittinopeus",
"LabelBonus": "Bonus",
"LabelBooks": "Kirjat",
"LabelButtonText": "Painikkeen teksti",
"LabelChangePassword": "Vaihda salasana",
"LabelChannels": "Kanavat",
"LabelChapterCount": "{0} lukua",
"LabelChapterTitle": "Luvun nimi",
"LabelChapters": "Luvut",
"LabelChaptersFound": "lukua löydetty",
"LabelClickForMoreInfo": "Napsauta saadaksesi lisätietoja",
"LabelClickToUseCurrentValue": "Käytä nykyistä arvoa napsauttamalla",
"LabelClosePlayer": "Sulje soitin",
"LabelCodec": "Koodekki",
"LabelCollapseSeries": "Pienennä sarja",
@@ -268,85 +236,45 @@
"LabelCoverImageURL": "Kansikuvan URL-osoite",
"LabelCreatedAt": "Luotu",
"LabelCurrent": "Nykyinen",
"LabelCurrently": "Nyt:",
"LabelDays": "Päivää",
"LabelDeleteFromFileSystemCheckbox": "Poista tiedostojärjestelmästä (poista merkintä, jos haluat poistaa vain tietokannasta)",
"LabelDescription": "Kuvaus",
"LabelDeselectAll": "Poista valinta kaikista",
"LabelDevice": "Laite",
"LabelDeviceInfo": "Laitteen tiedot",
"LabelDeviceIsAvailableTo": "Laite on saatavilla...",
"LabelDirectory": "Kansio",
"LabelDiscover": "Löydä",
"LabelDownload": "Lataa",
"LabelDownloadNEpisodes": "Lataa {0} jaksoa",
"LabelDownloadable": "Ladattavissa",
"LabelDuration": "Kesto",
"LabelDurationComparisonExactMatch": "(tarkka vastaavuus)",
"LabelDurationComparisonLonger": "({0} pidempi)",
"LabelDurationComparisonShorter": "({0} lyhyempi)",
"LabelDurationFound": "Kesto löydetty:",
"LabelEbook": "E-kirja",
"LabelEbooks": "E-kirjat",
"LabelEdit": "Muokkaa",
"LabelEmail": "Sähköposti",
"LabelEmailSettingsFromAddress": "Osoitteesta",
"LabelEmailSettingsRejectUnauthorized": "Hylkää luvattomat sertifikaatit",
"LabelEmailSettingsRejectUnauthorizedHelp": "SSL-sertifikaatin varmentamisen käytöstä poistaminen saattaa vaarantaa yhteytesti turvallisuusriskeihin, kuten man-in-the-middle hyökkäyksiin. Poista käytöstä vain jos ymmärrät vaaran ja luotat yhdistämääsi sähköpostipalvelimeen.",
"LabelEmailSettingsSecure": "Turvallinen",
"LabelEmailSettingsTestAddress": "Testiosoite",
"LabelEmbeddedCover": "Upotettu kansikuva",
"LabelEnable": "Ota käyttöön",
"LabelEncodingBackupLocation": "Alkuperäisistä audiotiedostoistasi tallennetaan varmuuskopio osoitteessa:",
"LabelEncodingChaptersNotEmbedded": "Lukuja ei upoteta moniraitaisiin äänikirjoihin.",
"LabelEncodingInfoEmbedded": "Kuvailutiedot upotetaan äänikirjakansion ääniraitoihin.",
"LabelEncodingStartedNavigation": "Voit poistua sivulta kun tehtävä on aloitettu.",
"LabelEncodingTimeWarning": "Koodaus saattaa kestää 30 minuuttiin asti.",
"LabelEncodingWarningAdvancedSettings": "Varoitus: Älä päivitä näitä asetuksia ellet ymmärrä ffmpeg-koodausasetuksia.",
"LabelEnd": "Loppu",
"LabelEndOfChapter": "Luvun loppu",
"LabelEpisode": "Jakso",
"LabelEpisodeNotLinkedToRssFeed": "Jakso ei yhdistetty RSS-syötteeseen",
"LabelEpisodeNumber": "Jakso #{0}",
"LabelEpisodeTitle": "Jakson nimi",
"LabelEpisodeType": "Jakson tyyppi",
"LabelEpisodeUrlFromRssFeed": "Jakson URL RSS-syötteestä",
"LabelEpisodes": "Jaksot",
"LabelExample": "Esimerkki",
"LabelExpandSeries": "Laajenna sarja",
"LabelExpandSubSeries": "Laajenna alisarja",
"LabelExportOPML": "Vie OPML",
"LabelFeedURL": "Syötteen URL",
"LabelFetchingMetadata": "Noudetaan kuvailutietoja",
"LabelFile": "Tiedosto",
"LabelFileBirthtime": "Tiedoston syntymäaika",
"LabelFileBornDate": "Syntynyt {0}",
"LabelFileModified": "Muutettu tiedosto",
"LabelFileModifiedDate": "Muokattu {0}",
"LabelFilename": "Tiedostonimi",
"LabelFilterByUser": "Suodata käyttäjien perusteella",
"LabelFindEpisodes": "Etsi jaksoja",
"LabelFinished": "Valmis",
"LabelFolder": "Kansio",
"LabelFolders": "Kansiot",
"LabelFontBold": "Lihavoitu",
"LabelFontBoldness": "Kirjasintyyppien lihavointi",
"LabelFontFamily": "Kirjasinperhe",
"LabelFontItalic": "Kursiivi",
"LabelFontScale": "Kirjasintyyppien skaalautuminen",
"LabelFontStrikethrough": "Yliviivattu",
"LabelFull": "Täynnä",
"LabelGenre": "Lajityyppi",
"LabelGenres": "Lajityypit",
"LabelHighestPriority": "Tärkein",
"LabelHost": "Isäntä",
"LabelHours": "Tunnit",
"LabelIcon": "Kuvake",
"LabelImageURLFromTheWeb": "Kuvan verkko-osoite",
"LabelInProgress": "Kesken",
"LabelIncomplete": "Keskeneräinen",
"LabelInterval": "Väli",
"LabelIntervalCustomDailyWeekly": "Mukautettu päivittäinen/viikoittainen",
"LabelIntervalEvery12Hours": "12 tunnin välein",
"LabelIntervalEvery15Minutes": "15 minuutin välein",
"LabelIntervalEvery2Hours": "2 tunnin välein",
@@ -359,36 +287,12 @@
"LabelLanguageDefaultServer": "Palvelimen oletuskieli",
"LabelLanguages": "Kielet",
"LabelLastBookAdded": "Viimeisin lisätty kirja",
"LabelLastBookUpdated": "Viimeisin päivitetty kirja",
"LabelLastSeen": "Nähty viimeksi",
"LabelLastUpdate": "Viimeisin päivitys",
"LabelLayout": "Asettelu",
"LabelLayoutSinglePage": "Yksi sivu",
"LabelLayoutSplitPage": "Jaa sivu osiin",
"LabelLess": "Vähemmän",
"LabelLibrariesAccessibleToUser": "Käyttäjälle saatavilla olevat kirjastot",
"LabelLibrary": "Kirjasto",
"LabelLibraryName": "Kirjaston nimi",
"LabelLimit": "Raja",
"LabelLineSpacing": "Riviväli",
"LabelListenAgain": "Kuuntele uudelleen",
"LabelLogLevelInfo": "Tiedot",
"LabelLogLevelWarn": "Varoita",
"LabelLookForNewEpisodesAfterDate": "Etsi uusia jaksoja tämän päivämäärän jälkeen",
"LabelLowestPriority": "Vähiten tärkeä",
"LabelMaxEpisodesToDownload": "Jaksojen maksimilatausmäärä. 0 poistaa rajoituksen.",
"LabelMaxEpisodesToKeep": "Säilytettävien jaksojen enimmäismäärä",
"LabelMaxEpisodesToKeepHelp": "Jos arvona on 0, enimmäisrajaa ei ole. Kun uusi jakso ladataan automaattisesti, vanhin jakso poistetaan, jos jaksoja on yli X. Tämä poistaa vain yhden jakson uutta latauskertaa kohden.",
"LabelMediaPlayer": "Mediasoitin",
"LabelMediaType": "Mediatyyppi",
"LabelMetaTag": "Metatunniste",
"LabelMetaTags": "Metatunnisteet",
"LabelMetadataOrderOfPrecedenceDescription": "Tärkeämmät kuvailutietojen lähteet ohittavat vähemmän tärkeät lähteet",
"LabelMetadataProvider": "Kuvailutietojen toimittaja",
"LabelMinute": "Minuutti",
"LabelMinutes": "Minuutit",
"LabelMissing": "Puuttuu",
"LabelMissingEbook": "Ei e-kirjaa",
"LabelMore": "Lisää",
"LabelMoreInfo": "Lisätietoja",
"LabelName": "Nimi",
@@ -398,62 +302,31 @@
"LabelNewPassword": "Uusi salasana",
"LabelNewestAuthors": "Uusimmat kirjailijat",
"LabelNewestEpisodes": "Uusimmat jaksot",
"LabelNextBackupDate": "Seuraava varmuuskopiointipäivämäärä",
"LabelNextScheduledRun": "Seuraava ajastettu suorittaminen",
"LabelNoCustomMetadataProviders": "Ei mukautettuja kuvailutietojen toimittajia",
"LabelNoEpisodesSelected": "Jaksoja ei ole valittu",
"LabelNotFinished": "Ei valmis",
"LabelNotStarted": "Ei aloitettu",
"LabelNotes": "Muistiinpanoja",
"LabelNotificationAvailableVariables": "Käytettävissä olevat muuttujat",
"LabelNotificationEvent": "Ilmoitustapahtuma",
"LabelNotificationsMaxFailedAttempts": "Epäonnistuneiden yritysten enimmäismäärä",
"LabelNotificationsMaxFailedAttemptsHelp": "Ilmoitukset poistetaan käytöstä, jos niiden lähettäminen epäonnistuu näin monta kertaa",
"LabelNotificationsMaxQueueSize": "Ilmoitustapahtumajonon enimmäispituus",
"LabelNumberOfBooks": "Kirjojen määrä",
"LabelNumberOfEpisodes": "Jaksojen määrä",
"LabelOverwrite": "Korvaa",
"LabelPaginationPageXOfY": "Sivu {0}/{1}",
"LabelPassword": "Salasana",
"LabelPath": "Polku",
"LabelPermanent": "Pysyvä",
"LabelPermissionsAccessAllLibraries": "Käyttöoikeudet kaikkiin kirjastoihin",
"LabelPermissionsAccessAllTags": "Saa käyttää kaikkia tunnisteita",
"LabelPermissionsAccessExplicitContent": "Saa käyttää aikuisille tarkoitettua sisältöä",
"LabelPermissionsDelete": "Voi poistaa",
"LabelPermissionsDownload": "Voi ladata",
"LabelPermissionsUpdate": "Voi päivittää",
"LabelPermissionsUpload": "Voi lähettää",
"LabelPlayMethod": "Toistotapa",
"LabelPlayerChapterNumberMarker": "{0}/{1}",
"LabelPlaylists": "Soittolistat",
"LabelPodcast": "Podcast",
"LabelPodcastSearchRegion": "Podcastien hakualue",
"LabelPodcastType": "Podcastien tyyppi",
"LabelPodcasts": "Podcastit",
"LabelPort": "Portti",
"LabelPrimaryEbook": "Ensisijainen e-kirja",
"LabelProgress": "Edistyminen",
"LabelProvider": "Toimittaja",
"LabelPubDate": "Julkaisupäivä",
"LabelPublishYear": "Julkaisuvuosi",
"LabelPublishedDate": "Julkaistu {0}",
"LabelPublisher": "Julkaisija",
"LabelPublishers": "Julkaisijat",
"LabelRSSFeedPreventIndexing": "Estä indeksointi",
"LabelRandomly": "Satunnaisesti",
"LabelRead": "Lue",
"LabelReadAgain": "Lue uudelleen",
"LabelReadEbookWithoutProgress": "Lue e-kirja tallentamatta edistymistietoja",
"LabelRecentSeries": "Viimeisimmät sarjat",
"LabelRecentlyAdded": "Viimeeksi lisätyt",
"LabelRecommended": "Suositeltu",
"LabelRedo": "Tee uudelleen",
"LabelRegion": "Alue",
"LabelReleaseDate": "Julkaisupäivä",
"LabelRemoveCover": "Poista kansikuva",
"LabelRowsPerPage": "Rivejä sivulla",
"LabelSearchTerm": "Hakusana",
"LabelSeason": "Kausi",
"LabelSelectAll": "Valitse kaikki",
"LabelSelectUsers": "Valitse käyttäjät",

View File

@@ -51,7 +51,7 @@
"ButtonNext": "Suivant",
"ButtonNextChapter": "Chapitre suivant",
"ButtonNextItemInQueue": "Élément suivant dans la file dattente",
"ButtonOk": "D'accord",
"ButtonOk": "Daccord",
"ButtonOpenFeed": "Ouvrir le flux",
"ButtonOpenManager": "Ouvrir le gestionnaire",
"ButtonPause": "Pause",
@@ -459,7 +459,7 @@
"LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente",
"LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Les notifications seront ignorées si la file dattente est à son maximum. Cela empêche un flot trop important.",
"LabelNumberOfBooks": "Nombre de livres",
"LabelNumberOfEpisodes": "Nombre d'épisodes",
"LabelNumberOfEpisodes": "Nombre dépisodes",
"LabelOpenIDAdvancedPermsClaimDescription": "Nom de la demande OpenID qui contient des autorisations avancées pour les actions de lutilisateur dans lapplication, qui sappliqueront à des rôles autres que celui dadministrateur (<b>sil est configuré</b>). Si la demande est absente de la réponse, laccès à ABS sera refusé. Si une seule option est manquante, elle sera considérée comme <code>false</code>. Assurez-vous que la demande du fournisseur didentité correspond à la structure attendue :",
"LabelOpenIDClaims": "Laissez les options suivantes vides pour désactiver lattribution avancée de groupes et dautorisations, en attribuant alors automatiquement le groupe « Utilisateur ».",
"LabelOpenIDGroupClaimDescription": "Nom de la demande OpenID qui contient une liste des groupes de lutilisateur. Communément appelé <code>groups</code>. <b>Si elle est configurée</b>, lapplication attribuera automatiquement des rôles en fonction de lappartenance de lutilisateur à un groupe, à condition que ces groupes soient nommés -sensible à la casse- tel que « admin », « user » ou « guest » dans la demande. Elle doit contenir une liste, et si un utilisateur appartient à plusieurs groupes, lapplication attribuera le rôle correspondant au niveau daccès le plus élevé. Si aucun groupe ne correspond, laccès sera refusé.",
@@ -765,6 +765,7 @@
"MessageItemsSelected": "{0} éléments sélectionnés",
"MessageItemsUpdated": "{0} éléments mis à jour",
"MessageJoinUsOn": "Rejoignez-nous sur",
"MessageListeningSessionsInTheLastYear": "{0} sessions découte lan dernier",
"MessageLoading": "Chargement…",
"MessageLoadingFolders": "Chargement des dossiers…",
"MessageLogsDescription": "Les journaux sont stockés dans <code>/metadata/logs</code> sous forme de fichiers JSON. Les journaux dincidents sont stockés dans <code>/metadata/logs/crash_logs.txt</code>.",
@@ -944,6 +945,7 @@
"ToastBookmarkCreateFailed": "Échec de la création de signet",
"ToastBookmarkCreateSuccess": "Signet ajouté",
"ToastBookmarkRemoveSuccess": "Signet supprimé",
"ToastBookmarkUpdateSuccess": "Signet mis à jour",
"ToastCachePurgeFailed": "Échec de la purge du cache",
"ToastCachePurgeSuccess": "Cache purgé avec succès",
"ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs",
@@ -951,6 +953,8 @@
"ToastChaptersRemoved": "Chapitres supprimés",
"ToastChaptersUpdated": "Chapitres mis à jour",
"ToastCollectionItemsAddFailed": "Échec de lajout de(s) élément(s) à la collection",
"ToastCollectionItemsAddSuccess": "Ajout de(s) élément(s) à la collection réussi",
"ToastCollectionItemsRemoveSuccess": "Élément(s) supprimé(s) de la collection",
"ToastCollectionRemoveSuccess": "Collection supprimée",
"ToastCollectionUpdateSuccess": "Collection mise à jour",
"ToastCoverUpdateFailed": "Échec de la mise à jour de la couverture",

View File

@@ -642,6 +642,7 @@
"MessageItemsSelected": "{0} פריטים נבחרו",
"MessageItemsUpdated": "{0} פריטים עודכנו",
"MessageJoinUsOn": "הצטרף אלינו ב-",
"MessageListeningSessionsInTheLastYear": "{0} מפגשי האזנה בשנה האחרונה",
"MessageLoading": "טוען...",
"MessageLoadingFolders": "טוען תיקיות...",
"MessageM4BFailed": "M4B נכשל!",
@@ -740,8 +741,10 @@
"ToastBookmarkCreateFailed": "יצירת סימניה נכשלה",
"ToastBookmarkCreateSuccess": "הסימניה נוספה בהצלחה",
"ToastBookmarkRemoveSuccess": "הסימניה הוסרה בהצלחה",
"ToastBookmarkUpdateSuccess": "הסימניה עודכנה בהצלחה",
"ToastChaptersHaveErrors": "פרקים מכילים שגיאות",
"ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות",
"ToastCollectionItemsRemoveSuccess": "הפריט(ים) הוסרו מהאוסף בהצלחה",
"ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה",
"ToastCollectionUpdateSuccess": "האוסף עודכן בהצלחה",
"ToastItemCoverUpdateSuccess": "כריכת הפריט עודכנה בהצלחה",

View File

@@ -300,7 +300,6 @@
"LabelDiscover": "Otkrij",
"LabelDownload": "Preuzmi",
"LabelDownloadNEpisodes": "Preuzmi {0} nastavak/a",
"LabelDownloadable": "Moguće preuzimanje",
"LabelDuration": "Trajanje",
"LabelDurationComparisonExactMatch": "(točno podudaranje)",
"LabelDurationComparisonLonger": "({0} duže)",
@@ -367,7 +366,7 @@
"LabelFull": "Cijeli",
"LabelGenre": "Žanr",
"LabelGenres": "Žanrovi",
"LabelHardDeleteFile": "Izbriši datoteku zauvijek",
"LabelHardDeleteFile": "Obriši datoteku zauvijek",
"LabelHasEbook": "Ima e-knjigu",
"LabelHasSupplementaryEbook": "Ima dopunsku e-knjigu",
"LabelHideSubtitles": "Skrij podnaslove",
@@ -589,7 +588,6 @@
"LabelSettingsStoreMetadataWithItemHelp": "Meta-podatci se obično spremaju u /metadata/items; ako uključite ovu postavku meta-podatci će se čuvati u mapama knjižničkih stavki",
"LabelSettingsTimeFormat": "Format vremena",
"LabelShare": "Podijeli",
"LabelShareDownloadableHelp": "Korisnicima s poveznicom za dijeljenje omogućuje preuzimanje stavke.",
"LabelShareOpen": "Dijeljenje otvoreno",
"LabelShareURL": "URL za dijeljenje",
"LabelShowAll": "Prikaži sve",
@@ -717,15 +715,15 @@
"MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja zvučne knjige",
"MessageCheckingCron": "Provjeravam cron...",
"MessageConfirmCloseFeed": "Sigurno želite zatvoriti ovaj izvor?",
"MessageConfirmDeleteBackup": "Sigurno želite izbrisati sigurnosnu kopiju za {0}?",
"MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?",
"MessageConfirmDeleteDevice": "Sigurno želite izbrisati e-čitač \"{0}\"?",
"MessageConfirmDeleteFile": "Ovo će izbrisati datoteke s datotečnog sustava. Jeste li sigurni?",
"MessageConfirmDeleteLibrary": "Sigurno želite trajno izbrisati knjižnicu \"{0}\"?",
"MessageConfirmDeleteLibrary": "Sigurno želite trajno obrisati knjižnicu \"{0}\"?",
"MessageConfirmDeleteLibraryItem": "Ovo će izbrisati knjižničku stavku iz datoteke i vašeg datotečnog sustava. Jeste li sigurni?",
"MessageConfirmDeleteLibraryItems": "Ovo će izbrisati {0} knjižničkih stavki iz baze podataka i datotečnog sustava. Jeste li sigurni?",
"MessageConfirmDeleteMetadataProvider": "Sigurno želite izbrisati prilagođenog pružatelja meta-podataka \"{0}\"?",
"MessageConfirmDeleteNotification": "Sigurno želite izbrisati ovu obavijest?",
"MessageConfirmDeleteSession": "Sigurno želite izbrisati ovu sesiju?",
"MessageConfirmDeleteSession": "Sigurno želite obrisati ovu sesiju?",
"MessageConfirmEmbedMetadataInAudioFiles": "Sigurno želite ugraditi meta-podatke u {0} zvučnih datoteka?",
"MessageConfirmForceReScan": "Sigurno želite ponovno pokrenuti skeniranje?",
"MessageConfirmMarkAllEpisodesFinished": "Sigurno želite označiti sve nastavke dovršenima?",
@@ -756,9 +754,8 @@
"MessageConfirmRenameTagMergeNote": "Napomena: Ova oznaka već postoji, stoga će biti pripojena.",
"MessageConfirmRenameTagWarning": "Pažnja! Slična oznaka s drugačijim velikim i malim slovima već postoji \"{0}\".",
"MessageConfirmResetProgress": "Sigurno želite resetirati napredak?",
"MessageConfirmSendEbookToDevice": "Sigurno želite poslati {0} e-knjigu \"{1}\" na uređaj \"{2}\"?",
"MessageConfirmSendEbookToDevice": "Sigurno želite poslati {0} e-knjiga/u \"{1}\" na uređaj \"{2}\"?",
"MessageConfirmUnlinkOpenId": "Sigurno želite odspojiti ovog korisnika s OpenID-ja?",
"MessageDaysListenedInTheLastYear": "{0} dana slušanja u posljednjih godinu dana",
"MessageDownloadingEpisode": "Preuzimam nastavak",
"MessageDragFilesIntoTrackOrder": "Prevlačenjem datoteka složite pravilan redoslijed",
"MessageEmbedFailed": "Ugrađivanje nije uspjelo!",
@@ -774,6 +771,7 @@
"MessageItemsSelected": "{0} odabranih stavki",
"MessageItemsUpdated": "{0} stavki ažurirano",
"MessageJoinUsOn": "Pridruži nam se na",
"MessageListeningSessionsInTheLastYear": "{0} slušanja u prošloj godini",
"MessageLoading": "Učitavam...",
"MessageLoadingFolders": "Učitavam mape...",
"MessageLogsDescription": "Zapisnici se čuvaju u <code>/metadata/logs</code> u obliku JSON datoteka. Zapisnici pada sustava čuvaju se u datoteci <code>/metadata/logs/crash_logs.txt</code>.",
@@ -832,12 +830,11 @@
"MessageRemoveChapter": "Ukloni poglavlje",
"MessageRemoveEpisodes": "Ukloni {0} nastavaka",
"MessageRemoveFromPlayerQueue": "Ukloni iz redoslijeda izvođenja",
"MessageRemoveUserWarning": "Sigurno želite trajno izbrisati korisnika \"{0}\"?",
"MessageRemoveUserWarning": "Sigurno želite trajno obrisati korisnika \"{0}\"?",
"MessageReportBugsAndContribute": "Prijavite pogreške, zatražite funkcionalnosti i doprinesite na",
"MessageResetChaptersConfirm": "Sigurno želite vratiti poglavlja na prethodno stanje i poništiti učinjene promjene?",
"MessageRestoreBackupConfirm": "Sigurno želite vratiti sigurnosnu kopiju izrađenu",
"MessageRestoreBackupWarning": "Vraćanjem sigurnosne kopije prepisat ćete cijelu bazu podataka koja se nalazi u /config i slike naslovnice u /metadata/items i /metadata/authors.<br /><br />Sigurnosne kopije ne mijenjaju datoteke koje se nalaze u mapama vaših knjižnica. Ako ste u postavkama poslužitelja uključili mogućnost spremanja naslovnica i meta-podataka u mape knjižnice, te se datoteke neće niti sigurnosno pohraniti niti prepisati. <br /><br />Svi klijenti koji se spajaju na vaš poslužitelj automatski će se osvježiti.",
"MessageScheduleLibraryScanNote": "Za većinu korisnika se preporučuje ostaviti ovu funkciju deaktiviranom i ostaviti postavku promatrača mape aktiviranom. Promatrač mapa će automatski otkriti promjene u mapama vaše knjižnice. Promatrač mapa ne radi na svakom datotečnom sustavu (kao što je NFS) pa se umjesto njega mogu koristiti planirana pretraživanja knjižnice.",
"MessageSearchResultsFor": "Rezultati pretrage za",
"MessageSelected": "{0} odabrano",
"MessageServerCouldNotBeReached": "Nije moguće pristupiti poslužitelju",
@@ -913,7 +910,7 @@
"StatsBooksFinished": "knjiga dovršeno",
"StatsBooksFinishedThisYear": "Neke knjige dovršene ove godine…",
"StatsBooksListenedTo": "knjiga slušano",
"StatsCollectionGrewTo": "Vaša je zbirka knjiga narasla na…",
"StatsCollectionGrewTo": "Vaša zbirka knjiga narasla je na…",
"StatsSessions": "sesija",
"StatsSpentListening": "provedeno u slušanju",
"StatsTopAuthor": "NAJPOPULARNIJI AUTOR",
@@ -936,7 +933,7 @@
"ToastAuthorUpdateSuccess": "Autor ažuriran",
"ToastAuthorUpdateSuccessNoImageFound": "Autor ažuriran (slika nije pronađena)",
"ToastBackupAppliedSuccess": "Sigurnosna kopija vraćena",
"ToastBackupCreateFailed": "Izrada sigurnosne kopije nije uspjela",
"ToastBackupCreateFailed": "Neuspješno kreiranje backupa",
"ToastBackupCreateSuccess": "Izrađena sigurnosna kopija",
"ToastBackupDeleteFailed": "Brisanje sigurnosne kopije nije uspjelo",
"ToastBackupDeleteSuccess": "Sigurnosna kopija izbrisana",
@@ -946,7 +943,7 @@
"ToastBackupUploadFailed": "Učitavanje sigurnosne kopije nije uspjelo",
"ToastBackupUploadSuccess": "Sigurnosna kopija učitana",
"ToastBatchDeleteFailed": "Grupno brisanje nije uspjelo",
"ToastBatchDeleteSuccess": "Grupno brisanje je uspjelo",
"ToastBatchDeleteSuccess": "Grupno brisanje je uspješno dovršeno",
"ToastBatchQuickMatchFailed": "Grupno brzo prepoznavanje nije uspjelo!",
"ToastBatchQuickMatchStarted": "Započelo je brzo prepoznavanje {0} knjiga!",
"ToastBatchUpdateFailed": "Skupno ažuriranje nije uspjelo",
@@ -954,6 +951,7 @@
"ToastBookmarkCreateFailed": "Izrada knjižne oznake nije uspjela",
"ToastBookmarkCreateSuccess": "Knjižna oznaka dodana",
"ToastBookmarkRemoveSuccess": "Knjižna oznaka uklonjena",
"ToastBookmarkUpdateSuccess": "Knjižna oznaka ažurirana",
"ToastCachePurgeFailed": "Čišćenje predmemorije nije uspjelo",
"ToastCachePurgeSuccess": "Predmemorija uspješno očišćena",
"ToastChaptersHaveErrors": "Poglavlja imaju pogreške",
@@ -961,10 +959,11 @@
"ToastChaptersRemoved": "Poglavlja uklonjena",
"ToastChaptersUpdated": "Poglavlja su ažurirana",
"ToastCollectionItemsAddFailed": "Neuspješno dodavanje stavki u zbirku",
"ToastCollectionItemsAddSuccess": "Uspješno dodavanje stavki u zbirku",
"ToastCollectionItemsRemoveSuccess": "Stavke izbrisane iz zbirke",
"ToastCollectionRemoveSuccess": "Zbirka izbrisana",
"ToastCollectionUpdateSuccess": "Zbirka ažurirana",
"ToastCoverUpdateFailed": "Ažuriranje naslovnice nije uspjelo",
"ToastDateTimeInvalidOrIncomplete": "Datum i vrijeme su neispravni ili nepotpuni",
"ToastDeleteFileFailed": "Brisanje datoteke nije uspjelo",
"ToastDeleteFileSuccess": "Datoteka izbrisana",
"ToastDeviceAddFailed": "Dodavanje uređaja nije uspjelo",
@@ -1017,7 +1016,6 @@
"ToastNewUserTagError": "Potrebno je odabrati najmanje jednu oznaku",
"ToastNewUserUsernameError": "Upišite korisničko ime",
"ToastNoNewEpisodesFound": "Nisu pronađeni novi nastavci",
"ToastNoRSSFeed": "Podcast nema RSS izvor",
"ToastNoUpdatesNecessary": "Ažuriranja nisu potrebna",
"ToastNotificationCreateFailed": "Stvaranje obavijesti nije uspjelo",
"ToastNotificationDeleteFailed": "Brisanje obavijesti nije uspjelo",
@@ -1044,7 +1042,7 @@
"ToastRSSFeedCloseFailed": "RSS izvor nije uspješno zatvoren",
"ToastRSSFeedCloseSuccess": "RSS izvor zatvoren",
"ToastRemoveFailed": "Uklanjanje nije uspjelo",
"ToastRemoveItemFromCollectionFailed": "Uklanjanje stavke iz zbirke nije uspjelo",
"ToastRemoveItemFromCollectionFailed": "Neuspješno uklanjanje stavke iz zbirke",
"ToastRemoveItemFromCollectionSuccess": "Stavka uklonjena iz zbirke",
"ToastRemoveItemsWithIssuesFailed": "Uklanjanje knjižničkih stavki s problemima nije uspjelo",
"ToastRemoveItemsWithIssuesSuccess": "Uspješno uklonjene knjižničke stavke s problemima",
@@ -1061,8 +1059,8 @@
"ToastSeriesUpdateSuccess": "Serijal uspješno ažuriran",
"ToastServerSettingsUpdateSuccess": "Postavke poslužitelja ažurirane",
"ToastSessionCloseFailed": "Zatvaranje sesije nije uspjelo",
"ToastSessionDeleteFailed": "Brisanje sesije nije uspjelo",
"ToastSessionDeleteSuccess": "Sesija izbrisana",
"ToastSessionDeleteFailed": "Neuspješno brisanje serije",
"ToastSessionDeleteSuccess": "Sesija obrisana",
"ToastSleepTimerDone": "Timer za spavanje istječe... zZzzZz",
"ToastSlugMustChange": "Slug sadrži nedozvoljene znakove",
"ToastSlugRequired": "Slug je obavezan",
@@ -1075,8 +1073,8 @@
"ToastUnknownError": "Nepoznata pogreška",
"ToastUnlinkOpenIdFailed": "Uklanjanje OpenID veze korisnika nije uspjelo",
"ToastUnlinkOpenIdSuccess": "Korisnik odspojen od OpenID-ja",
"ToastUserDeleteFailed": "Brisanje korisnika nije uspjelo",
"ToastUserDeleteSuccess": "Korisnik izbrisan",
"ToastUserDeleteFailed": "Neuspješno brisanje korisnika",
"ToastUserDeleteSuccess": "Korisnik obrisan",
"ToastUserPasswordChangeSuccess": "Zaporka je uspješno promijenjena",
"ToastUserPasswordMismatch": "Zaporke se ne podudaraju",
"ToastUserPasswordMustChange": "Nova zaporka ne smije biti jednaka staroj",

View File

@@ -100,7 +100,7 @@
"ButtonStartM4BEncode": "M4B kódolás indítása",
"ButtonStartMetadataEmbed": "Metaadatok beágyazásának indítása",
"ButtonStats": "Statisztikák",
"ButtonSubmit": "Küldés",
"ButtonSubmit": "Beküldés",
"ButtonTest": "Teszt",
"ButtonUnlinkOpenId": "OpenID szétkapcsolása",
"ButtonUpload": "Feltöltés",
@@ -143,7 +143,7 @@
"HeaderFindChapters": "Fejezetek keresése",
"HeaderIgnoredFiles": "Figyelmen kívül hagyott fájlok",
"HeaderItemFiles": "Elemfájlok",
"HeaderItemMetadataUtils": "Metaadatok eszközei",
"HeaderItemMetadataUtils": "Elem metaadat eszközök",
"HeaderLastListeningSession": "Utolsó hallgatási munkamenet",
"HeaderLatestEpisodes": "Legújabb epizódok",
"HeaderLibraries": "Könyvtárak",
@@ -165,7 +165,6 @@
"HeaderNotificationUpdate": "Értesítés frissítése",
"HeaderNotifications": "Értesítések",
"HeaderOpenIDConnectAuthentication": "OpenID Connect hitelesítés",
"HeaderOpenListeningSessions": "Hallgatási menetek megnyitása",
"HeaderOpenRSSFeed": "RSS hírcsatorna megnyitása",
"HeaderOtherFiles": "Egyéb fájlok",
"HeaderPasswordAuthentication": "Jelszó hitelesítés",
@@ -195,7 +194,7 @@
"HeaderSettingsWebClient": "Webkliens",
"HeaderSleepTimer": "Alvásidőzítő",
"HeaderStatsLargestItems": "Legnagyobb elemek",
"HeaderStatsLongestItems": "Leghosszabb elemek (órában)",
"HeaderStatsLongestItems": "Leghosszabb elemek (órákban)",
"HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)",
"HeaderStatsRecentSessions": "Legutóbbi munkamenetek",
"HeaderStatsTop10Authors": "Top 10 szerző",
@@ -207,7 +206,7 @@
"HeaderUpdateDetails": "Részletek frissítése",
"HeaderUpdateLibrary": "Könyvtár frissítése",
"HeaderUsers": "Felhasználók",
"HeaderYearReview": "Visszatekintés {0} -ra/re",
"HeaderYearReview": "{0} év visszatekintése",
"HeaderYourStats": "Saját statisztikák",
"LabelAbridged": "Tömörített",
"LabelAbridgedChecked": "Rövidített (ellenőrizve)",
@@ -238,7 +237,7 @@
"LabelAuthor": "Szerző",
"LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)",
"LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)",
"LabelAuthors": "Szerző",
"LabelAuthors": "Szerzők",
"LabelAutoDownloadEpisodes": "Epizódok automatikus letöltése",
"LabelAutoFetchMetadata": "Metaadatok automatikus lekérése",
"LabelAutoFetchMetadataHelp": "Cím, szerző és sorozat metaadatok automatikus lekérése a feltöltés megkönnyítése érdekében. További metaadatok egyeztetése szükséges lehet a feltöltés után.",
@@ -273,7 +272,7 @@
"LabelCollapseSeries": "Sorozat összecsukása",
"LabelCollapseSubSeries": "Alszéria összecsukása",
"LabelCollection": "Gyűjtemény",
"LabelCollections": "Gyűjtemény",
"LabelCollections": "Gyűjtemények",
"LabelComplete": "Kész",
"LabelConfirmPassword": "Jelszó megerősítése",
"LabelContinueListening": "Hallgatás folytatása",
@@ -300,7 +299,6 @@
"LabelDiscover": "Felfedezés",
"LabelDownload": "Letöltés",
"LabelDownloadNEpisodes": "{0} epizód letöltése",
"LabelDownloadable": "Letölthető",
"LabelDuration": "Időtartam",
"LabelDurationComparisonExactMatch": "(pontos egyezés)",
"LabelDurationComparisonLonger": "({0}-val hosszabb)",
@@ -322,7 +320,6 @@
"LabelEncodingChaptersNotEmbedded": "A fejezetek nincsenek beágyazva a többsávos hangoskönyvekbe.",
"LabelEncodingClearItemCache": "Győződjön meg róla, hogy rendszeresen tisztítja az elemek gyorsítótárát.",
"LabelEncodingFinishedM4B": "A kész M4B a hangoskönyv mappádba kerül:",
"LabelEncodingInfoEmbedded": "A metaadatok beépülnek a hangsávokba a hangoskönyv mappáján belül.",
"LabelEncodingStartedNavigation": "Ha a feladat elindult, el lehet navigálni erről az oldalról.",
"LabelEncodingTimeWarning": "A kódolás akár 30 percet is igénybe vehet.",
"LabelEncodingWarningAdvancedSettings": "Figyelmeztetés: Ne frissítse ezeket a beállításokat, hacsak nem ismeri az ffmpeg kódolási beállításait.",
@@ -444,7 +441,7 @@
"LabelNarrators": "Előadók",
"LabelNew": "Új",
"LabelNewPassword": "Új jelszó",
"LabelNewestAuthors": "A legújabb szerzők",
"LabelNewestAuthors": "Legújabb szerzők",
"LabelNewestEpisodes": "Legújabb epizódok",
"LabelNextBackupDate": "Következő biztonsági másolat dátuma",
"LabelNextScheduledRun": "Következő ütemezett futtatás",
@@ -481,7 +478,7 @@
"LabelPermissionsDownload": "Letölthet",
"LabelPermissionsUpdate": "Frissíthet",
"LabelPermissionsUpload": "Feltölthet",
"LabelPersonalYearReview": "Az éved összefoglalása ({0})",
"LabelPersonalYearReview": "Az évvisszatekintésed ({0})",
"LabelPhotoPathURL": "Fénykép útvonal/URL",
"LabelPlayMethod": "Lejátszási módszer",
"LabelPlayerChapterNumberMarker": "{0} a {1} -ből",
@@ -538,12 +535,11 @@
"LabelSelectUsers": "Felhasználók kiválasztása",
"LabelSendEbookToDevice": "E-könyv küldése...",
"LabelSequence": "Sorozat",
"LabelSerial": "Sorozat",
"LabelSeries": "Sorozat",
"LabelSeriesName": "Sorozat neve",
"LabelSeriesProgress": "Sorozat haladása",
"LabelServerLogLevel": "Kiszolgáló naplózási szint",
"LabelServerYearReview": "Szerver éves visszatekintése ({0})",
"LabelServerYearReview": "Szerver évvisszatekintés ({0})",
"LabelSetEbookAsPrimary": "Beállítás elsődlegesként",
"LabelSetEbookAsSupplementary": "Beállítás kiegészítőként",
"LabelSettingsAllowIframe": "A beágyazás engedélyezése egy iframe-be",
@@ -589,11 +585,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Alapértelmezés szerint a metaadatfájlok a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a metaadatfájlokat a könyvtári elem mappáiban tárolja",
"LabelSettingsTimeFormat": "Időformátum",
"LabelShare": "Megosztás",
"LabelShareDownloadableHelp": "Lehetővé teszi a megosztási linket birtokló felhasználók számára, hogy letöltsék a könyvtári elem zip-fájlját.",
"LabelShareOpen": "Megosztás megnyitása",
"LabelShareURL": "URL megosztása",
"LabelShowAll": "Mindent mutat",
"LabelShowSeconds": "Másodperc megjelenítése",
"LabelShowSubtitles": "Felirat megjelenítése",
"LabelSize": "Méret",
"LabelSleepTimer": "Alvásidőzítő",
@@ -604,8 +596,8 @@
"LabelStartTime": "Kezdési idő",
"LabelStarted": "Elkezdődött",
"LabelStartedAt": "Kezdés ideje",
"LabelStatsAudioTracks": "Audiósáv",
"LabelStatsAuthors": "Szerző",
"LabelStatsAudioTracks": "Audiósávok",
"LabelStatsAuthors": "Szerzők",
"LabelStatsBestDay": "Legjobb nap",
"LabelStatsDailyAverage": "Napi átlag",
"LabelStatsDays": "Napok",
@@ -613,7 +605,7 @@
"LabelStatsHours": "Órák",
"LabelStatsInARow": "egymás után",
"LabelStatsItemsFinished": "Befejezett elem",
"LabelStatsItemsInLibrary": "Elem a könyvtárban",
"LabelStatsItemsInLibrary": "Elemek a könyvtárban",
"LabelStatsMinutes": "perc",
"LabelStatsMinutesListening": "Hallgatási perc",
"LabelStatsOverallDays": "Összes nap",
@@ -692,8 +684,8 @@
"LabelWeekdaysToRun": "Futás napjai",
"LabelXBooks": "{0} könyv",
"LabelXItems": "{0} elem",
"LabelYearReviewHide": "Visszatekintés az évre elrejtése",
"LabelYearReviewShow": "Visszatekintés az évre megtekintése",
"LabelYearReviewHide": "Az évvisszatekintés elrejtése",
"LabelYearReviewShow": "Évvisszatekintés megtekintése",
"LabelYourAudiobookDuration": "Hangoskönyv időtartama",
"LabelYourBookmarks": "Könyvjelzőid",
"LabelYourPlaylists": "Lejátszási listáid",
@@ -758,12 +750,10 @@
"MessageConfirmResetProgress": "Biztos, hogy vissza akarja állítani a haladási folyamatát?",
"MessageConfirmSendEbookToDevice": "Biztosan el szeretné küldeni a(z) {0} e-könyvet a(z) \"{1}\" eszközre?",
"MessageConfirmUnlinkOpenId": "Biztos, hogy el akarja távolítani ezt a felhasználót az OpenID-ból?",
"MessageDaysListenedInTheLastYear": "{0} napot hallgatott az elmúlt évben",
"MessageDownloadingEpisode": "Epizód letöltése",
"MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe",
"MessageEmbedFailed": "A beágyazás sikertelen!",
"MessageEmbedFinished": "Beágyazás befejeződött!",
"MessageEmbedQueue": "Metaadatok beágyazására várakozik ({0} a sorban)",
"MessageEpisodesQueuedForDownload": "{0} epizód letöltésre vár",
"MessageEreaderDevices": "Az e-könyvek kézbesítésének biztosítása érdekében a fenti e-mail címet az alább felsorolt minden egyes eszközhöz, mint érvényes feladót kell hozzáadnia.",
"MessageFeedURLWillBe": "A hírcsatorna URL-je {0} lesz",
@@ -774,6 +764,7 @@
"MessageItemsSelected": "{0} kiválasztott elem",
"MessageItemsUpdated": "{0} frissített elem",
"MessageJoinUsOn": "Csatlakozzon hozzánk a",
"MessageListeningSessionsInTheLastYear": "{0} hallgatási munkamenet az elmúlt évben",
"MessageLoading": "Betöltés...",
"MessageLoadingFolders": "Mappák betöltése...",
"MessageLogsDescription": "A naplók a <code>/metadata/logs</code> mappában JSON-fájlokként tárolódnak. Az összeomlási naplók a <code>/metadata/logs/crash_logs.txt</code> fájlban tárolódnak.",
@@ -826,7 +817,6 @@
"MessagePodcastHasNoRSSFeedForMatching": "A podcastnak nincs RSS hírcsatorna URL-je az egyeztetéshez",
"MessagePodcastSearchField": "Adja meg a keresési kifejezést vagy az RSS hírcsatorna URL-címét",
"MessageQuickEmbedInProgress": "Gyors beágyazás folyamatban",
"MessageQuickEmbedQueue": "Gyors beágyazásra várakozik ({0} a sorban)",
"MessageQuickMatchAllEpisodes": "Minden epizód gyors egyeztetése",
"MessageQuickMatchDescription": "Üres elem részletek és borító feltöltése az első találati eredménnyel a(z) '{0}'-ból. Nem írja felül a részleteket, kivéve, ha a 'Preferált egyeztetett metaadatok' szerverbeállítás engedélyezve van.",
"MessageRemoveChapter": "Fejezet eltávolítása",
@@ -837,14 +827,12 @@
"MessageResetChaptersConfirm": "Biztosan alaphelyzetbe szeretné állítani a fejezeteket és visszavonni a módosításokat?",
"MessageRestoreBackupConfirm": "Biztosan vissza szeretné állítani a biztonsági másolatot, amely ekkor készült:",
"MessageRestoreBackupWarning": "A biztonsági mentés visszaállítása felülírja az egész adatbázist, amely a /config mappában található, valamint a borítóképeket a /metadata/items és /metadata/authors mappákban.<br /><br />A biztonsági mentések nem módosítják a könyvtár mappáiban található fájlokat. Ha engedélyezte a szerverbeállításokat a borítóképek és a metaadatok könyvtármappákban való tárolására, akkor ezek nem kerülnek biztonsági mentésre vagy felülírásra.<br /><br />A szerver használó összes kliens automatikusan frissül.",
"MessageScheduleLibraryScanNote": "A legtöbb felhasználó számára ajánlott ezt a funkciót kikapcsolva hagyni, és engedélyezni a mappafigyelő beállítást. A mappafigyelő automatikusan észleli a könyvtári mappák változásait. A mappafigyelő nem működik minden fájlrendszernél (mint például az NFS), ezért helyette ütemezett könyvtárellenőrzéseket lehet használni.",
"MessageSearchResultsFor": "Keresési eredmények",
"MessageSelected": "{0} kiválasztva",
"MessageServerCouldNotBeReached": "A szervert nem lehet elérni",
"MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként",
"MessageShareExpirationWillBe": "A lejárat: <strong>{0}</strong>",
"MessageShareExpiresIn": "{0} múlva jár le",
"MessageShareURLWillBe": "A megosztási URL <strong>{0}</strong> lesz",
"MessageStartPlaybackAtTime": "\"{0}\" lejátszásának kezdése {1} -tól?",
"MessageTaskAudioFileNotWritable": "A/Az „{0}” hangfájl nem írható",
"MessageTaskCanceledByUser": "Felhasználó törölte a feladatot",
@@ -950,16 +938,17 @@
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
"ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva",
"ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva",
"ToastBookmarkUpdateSuccess": "Könyvjelző frissítve",
"ToastCachePurgeFailed": "A gyorsítótár törlése sikertelen",
"ToastCachePurgeSuccess": "A gyorsítótár sikeresen törölve",
"ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
"ToastChaptersRemoved": "Fejezetek eltávolítva",
"ToastChaptersUpdated": "Fejezetek frissítve",
"ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből",
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
"ToastCoverUpdateFailed": "A borító frissítése nem sikerült",
"ToastDateTimeInvalidOrIncomplete": "A dátum és az időpont érvénytelen vagy hiányos",
"ToastDeleteFileFailed": "Nem sikerült törölni a fájlt",
"ToastDeleteFileSuccess": "Fájl törölve",
"ToastDeviceAddFailed": "Nem sikerült eszközt hozzáadni",
@@ -967,11 +956,9 @@
"ToastDeviceTestEmailFailed": "Teszt email küldése sikertelen",
"ToastDeviceTestEmailSuccess": "Teszt email elküldve",
"ToastEmailSettingsUpdateSuccess": "Email beállítások frissítve",
"ToastEncodeCancelFailed": "A kódolás törlése sikertelen volt",
"ToastEncodeCancelSucces": "Kódolás törölve",
"ToastEpisodeDownloadQueueClearFailed": "Nem sikerült törölni a várólistát",
"ToastEpisodeUpdateSuccess": "{0} epizód frissítve",
"ToastErrorCannotShare": "Ezen az eszközön nem lehet natívan megosztani",
"ToastFailedToLoadData": "Sikertelen adatbetöltés",
"ToastFailedToMatch": "Nem sikerült egyezőséget találni",
"ToastFailedToShare": "Nem sikerült megosztani",
@@ -1007,15 +994,10 @@
"ToastNewUserCreatedSuccess": "Új fiók létrehozva",
"ToastNewUserLibraryError": "Legalább egy könyvtárat ki kell választani",
"ToastNewUserPasswordError": "Kötelező a jelszó, csak a root felhasználónak lehet üres jelszava",
"ToastNewUserTagError": "Legalább egy címkét ki kell választania",
"ToastNewUserUsernameError": "Adjon meg egy felhasználónevet",
"ToastNoNewEpisodesFound": "Nincs új epizód",
"ToastNoRSSFeed": "A podcastnak nincs RSS hírcsatornája",
"ToastNoUpdatesNecessary": "Nincs szükség frissítésre",
"ToastNotificationCreateFailed": "Értesítés létrehozása sikertelen",
"ToastNotificationDeleteFailed": "Értesítés törlése sikertelen",
"ToastNotificationSettingsUpdateSuccess": "Értesítési beállítások frissítve",
"ToastNotificationTestTriggerFailed": "Nem sikerült a tesztértesítést elindítani",
"ToastNotificationUpdateSuccess": "Értesítés frissítve",
"ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen",
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
@@ -1025,37 +1007,22 @@
"ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva",
"ToastPodcastNoEpisodesInFeed": "Nincsenek epizódok az RSS hírcsatornában",
"ToastPodcastNoRssFeed": "A podcastnak nincs RSS-hírcsatornája",
"ToastProgressIsNotBeingSynced": "Az előrehaladás nem szinkronizálódik, a lejátszás újraindul",
"ToastProviderCreatedFailed": "Hiba a szolgáltató hozzáadásakor",
"ToastProviderCreatedSuccess": "Új szolgáltató hozzáadva",
"ToastProviderNameAndUrlRequired": "Név és Url kötelező",
"ToastProviderRemoveSuccess": "Szolgáltató eltávolítva",
"ToastRSSFeedCloseFailed": "Az RSS hírcsatorna bezárása sikertelen",
"ToastRSSFeedCloseSuccess": "RSS hírfolyam leállítva",
"ToastRemoveFailed": "Sikertelen eltávolítás",
"ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen",
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
"ToastRenameFailed": "Sikertelen átnevezés",
"ToastSelectAtLeastOneUser": "Válasszon legalább egy felhasználót",
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
"ToastSendEbookToDeviceSuccess": "E-könyv elküldve az eszközre \"{0}\"",
"ToastSeriesUpdateFailed": "Sorozat frissítése sikertelen",
"ToastSeriesUpdateSuccess": "Sorozat frissítése sikeres",
"ToastServerSettingsUpdateSuccess": "Szerver beállítások frissítve",
"ToastSessionCloseFailed": "A munkamenet bezárása sikertelen",
"ToastSessionDeleteFailed": "Munkamenet törlése sikertelen",
"ToastSessionDeleteSuccess": "Munkamenet törölve",
"ToastSleepTimerDone": "Alvásidőzítő kész... zZzzZZz",
"ToastSocketConnected": "Socket csatlakoztatva",
"ToastSocketDisconnected": "Socket lecsatlakoztatva",
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
"ToastSortingPrefixesEmptyError": "Legalább 1 rendezési előtaggal kell rendelkeznie",
"ToastSortingPrefixesUpdateSuccess": "Rendezési előtagok frissítése ({0} elem)",
"ToastTitleRequired": "A cím kötelező",
"ToastUnknownError": "Ismeretlen hiba",
"ToastUserDeleteFailed": "Felhasználó törlése sikertelen",
"ToastUserDeleteSuccess": "Felhasználó törölve",
"ToastUserPasswordChangeSuccess": "Jelszó sikeresen megváltoztatva",
"ToastUserPasswordMustChange": "Az új jelszó nem egyezik a régi jelszóval",
"ToastUserRootRequireName": "Egy root felhasználónevet kell megadnia"
}

View File

@@ -51,7 +51,7 @@
"ButtonNext": "Prossimo",
"ButtonNextChapter": "Prossimo Capitolo",
"ButtonNextItemInQueue": "Elemento successivo in coda",
"ButtonOk": "D'accordo",
"ButtonOk": "Daccordo",
"ButtonOpenFeed": "Apri il flusso",
"ButtonOpenManager": "Apri Manager",
"ButtonPause": "Pausa",
@@ -762,6 +762,7 @@
"MessageItemsSelected": "{0} oggetti Selezionati",
"MessageItemsUpdated": "{0} Oggetti aggiornati",
"MessageJoinUsOn": "Unisciti a noi su",
"MessageListeningSessionsInTheLastYear": "{0} sessioni di ascolto nell'ultimo anno",
"MessageLoading": "Caricamento…",
"MessageLoadingFolders": "Caricamento Cartelle...",
"MessageLogsDescription": "I log vengono archiviati nel percorso <code>/metadata/logs</code> as JSON files. I registri degli arresti anomali vengono archiviati nel percorso <code>/metadata/logs/crash_logs.txt</code>.",
@@ -941,6 +942,7 @@
"ToastBookmarkCreateFailed": "Creazione segnalibro fallita",
"ToastBookmarkCreateSuccess": "Segnalibro creato",
"ToastBookmarkRemoveSuccess": "Segnalibro Rimosso",
"ToastBookmarkUpdateSuccess": "Segnalibro aggiornato",
"ToastCachePurgeFailed": "Impossibile eliminare la cache",
"ToastCachePurgeSuccess": "Cache eliminata correttamente",
"ToastChaptersHaveErrors": "I capitoli contengono errori",
@@ -948,6 +950,8 @@
"ToastChaptersRemoved": "Capitoli rimossi",
"ToastChaptersUpdated": "Capitoli aggiornati",
"ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito",
"ToastCollectionItemsAddSuccess": "L'aggiunta dell'elemento(i) alla raccolta è riuscito",
"ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta",
"ToastCollectionRemoveSuccess": "Collezione rimossa",
"ToastCollectionUpdateSuccess": "Raccolta aggiornata",
"ToastCoverUpdateFailed": "Aggiornamento cover fallito",

View File

@@ -563,6 +563,7 @@
"MessageItemsSelected": "Pasirinkti {0} elementai (-ų)",
"MessageItemsUpdated": "Atnaujinti {0} elementai (-ų)",
"MessageJoinUsOn": "Prisijunkite prie mūsų",
"MessageListeningSessionsInTheLastYear": "{0} klausymo sesijų per paskutinius metus",
"MessageLoading": "Kraunama...",
"MessageLoadingFolders": "Kraunami aplankai...",
"MessageM4BFailed": "M4B Nepavyko!",
@@ -660,10 +661,13 @@
"ToastBookmarkCreateFailed": "Žymos sukurti nepavyko",
"ToastBookmarkCreateSuccess": "Žyma pridėta",
"ToastBookmarkRemoveSuccess": "Žyma pašalinta",
"ToastBookmarkUpdateSuccess": "Žyma atnaujinta",
"ToastChaptersHaveErrors": "Skyriai turi klaidų",
"ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus",
"ToastChaptersRemoved": "Skyriai pašalinti",
"ToastCollectionItemsAddFailed": "Nepavyko pridėti į kolekciją",
"ToastCollectionItemsAddSuccess": "Pridėta į kolekciją",
"ToastCollectionItemsRemoveSuccess": "Elementai pašalinti iš kolekcijos",
"ToastCollectionRemoveSuccess": "Kolekcija pašalinta",
"ToastCollectionUpdateSuccess": "Kolekcija atnaujinta",
"ToastCoverUpdateFailed": "Viršelio atnaujinimas nepavyko",

View File

@@ -88,8 +88,6 @@
"ButtonSaveTracklist": "Afspeellijst opslaan",
"ButtonScan": "Scannen",
"ButtonScanLibrary": "Scan bibliotheek",
"ButtonScrollLeft": "Scroll Links",
"ButtonScrollRight": "Scroll Rechts",
"ButtonSearch": "Zoeken",
"ButtonSelectFolderPath": "Maplocatie selecteren",
"ButtonSeries": "Series",
@@ -155,7 +153,7 @@
"HeaderLogs": "Logboek",
"HeaderManageGenres": "Genres beheren",
"HeaderManageTags": "Tags beheren",
"HeaderMapDetails": "Details map",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Vergelijken",
"HeaderMetadataOrderOfPrecedence": "Metadata volgorde",
"HeaderMetadataToEmbed": "In te sluiten metadata",
@@ -192,7 +190,6 @@
"HeaderSettingsExperimental": "Experimentele functies",
"HeaderSettingsGeneral": "Algemeen",
"HeaderSettingsScanner": "Scanner",
"HeaderSettingsWebClient": "Web Client",
"HeaderSleepTimer": "Slaaptimer",
"HeaderStatsLargestItems": "Grootste items",
"HeaderStatsLongestItems": "Langste items (uren)",
@@ -300,7 +297,6 @@
"LabelDiscover": "Ontdekken",
"LabelDownload": "Download",
"LabelDownloadNEpisodes": "Download {0} afleveringen",
"LabelDownloadable": "Downloadbaar",
"LabelDuration": "Duur",
"LabelDurationComparisonExactMatch": "(exacte overeenkomst)",
"LabelDurationComparisonLonger": "({0} langer)",
@@ -476,7 +472,6 @@
"LabelPermissionsAccessAllLibraries": "Heeft toegang tot all bibliotheken",
"LabelPermissionsAccessAllTags": "Heeft toegang tot alle tags",
"LabelPermissionsAccessExplicitContent": "Heeft toegang tot expliciete inhoud",
"LabelPermissionsCreateEreader": "Kan Ereader Aanmaken",
"LabelPermissionsDelete": "Kan verwijderen",
"LabelPermissionsDownload": "Kan downloaden",
"LabelPermissionsUpdate": "Kan bijwerken",
@@ -546,7 +541,6 @@
"LabelServerYearReview": "Server Jaar in Review ({0})",
"LabelSetEbookAsPrimary": "Stel in als primair",
"LabelSetEbookAsSupplementary": "Stel in als supplementair",
"LabelSettingsAllowIframe": "Insluiten in iframe toestaan",
"LabelSettingsAudiobooksOnly": "Alleen audiobooks",
"LabelSettingsAudiobooksOnlyHelp": "Deze instelling inschakelen zorgt ervoor dat ebook-bestanden genegeerd worden tenzij ze in een audiobook-map staan, in welk geval ze worden ingesteld als supplementaire ebooks",
"LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
@@ -568,9 +562,6 @@
"LabelSettingsHideSingleBookSeriesHelp": "Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.",
"LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina",
"LabelSettingsLibraryBookshelfView": "Boekenplank-view voor bibliotheek",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Voltooid percentage is groter dan",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Resterende tijd is kleiner dan (seconden)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Markeer media item wanneer voltooid",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sla eedere boeken in Serie Verderzetten over",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "De Continue Series home page shelf toont het eerste boek dat nog niet is begonnen in series waarvan er minstens één is voltooid en er geen boeken in uitvoering zijn. Als u deze instelling inschakelt, wordt de serie voortgezet vanaf het boek dat het verst is voltooid in plaats van het eerste boek dat nog niet is begonnen.",
"LabelSettingsParseSubtitles": "Parseer subtitel",
@@ -589,7 +580,6 @@
"LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden",
"LabelSettingsTimeFormat": "Tijdformat",
"LabelShare": "Delen",
"LabelShareDownloadableHelp": "Gebruikers toestaan met share link om zip bestand te downloaden van het bibliotheek item.",
"LabelShareOpen": "Delen Open",
"LabelShareURL": "URL Delen",
"LabelShowAll": "Toon alle",
@@ -598,8 +588,6 @@
"LabelSize": "Grootte",
"LabelSleepTimer": "Slaaptimer",
"LabelSlug": "Slak",
"LabelSortAscending": "Oplopend",
"LabelSortDescending": "Aflopend",
"LabelStart": "Start",
"LabelStartTime": "Starttijd",
"LabelStarted": "Gestart",
@@ -671,7 +659,6 @@
"LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden",
"LabelUpdatedAt": "Bijgewerkt op",
"LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen",
"LabelUploaderDragAndDropFilesOnly": "Drag & drop bestanden",
"LabelUploaderDropFiles": "Bestanden neerzetten",
"LabelUploaderItemFetchMetadataHelp": "Automatisch titel, auteur en serie ophalen",
"LabelUseAdvancedOptions": "Gebruik Geavanceerde Instellingen",
@@ -687,8 +674,6 @@
"LabelViewPlayerSettings": "Laat spelerinstellingen zien",
"LabelViewQueue": "Bekijk afspeelwachtrij",
"LabelVolume": "Volume",
"LabelWebRedirectURLsDescription": "Autoriseer deze URL's in uw OAuth-provider om na het inloggen omleiding terug naar de web-app toe te staan:",
"LabelWebRedirectURLsSubfolder": "Subfolder voor Redirect URLs",
"LabelWeekdaysToRun": "Weekdagen om te draaien",
"LabelXBooks": "{0} boeken",
"LabelXItems": "{0} items",
@@ -758,7 +743,6 @@
"MessageConfirmResetProgress": "Bet u zeker dat u uw voortgang wil resetten?",
"MessageConfirmSendEbookToDevice": "Weet je zeker dat je {0} ebook \"{1}\" naar apparaat \"{2}\" wil sturen?",
"MessageConfirmUnlinkOpenId": "Bent u zeker dat u deze gebruiker wil ontkoppelen van OpenID?",
"MessageDaysListenedInTheLastYear": "{0} dagen geluisterd in het voorbije jaar",
"MessageDownloadingEpisode": "Aflevering aan het dowloaden",
"MessageDragFilesIntoTrackOrder": "Sleep bestanden in de juiste trackvolgorde",
"MessageEmbedFailed": "Insluiten Mislukt!",
@@ -774,6 +758,7 @@
"MessageItemsSelected": "{0} onderdelen geselecteerd",
"MessageItemsUpdated": "{0} onderdelen bijgewerkt",
"MessageJoinUsOn": "Doe mee op",
"MessageListeningSessionsInTheLastYear": "{0} luistersessies in het laatste jaar",
"MessageLoading": "Aan het laden...",
"MessageLoadingFolders": "Mappen aan het laden...",
"MessageLogsDescription": "Logs worden opgeslagen in <code>/metadata/logs</code> als JSON-bestanden. Crashlogs worden opgeslagen in <code>/metadata/logs/crash_logs.txt</code>.",
@@ -837,7 +822,6 @@
"MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?",
"MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
"MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle clients die van je server gebruik maken zullen automatisch worden ververst.",
"MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het raadzaam om deze functie uitgeschakeld te laten en de folder watcher-instelling ingeschakeld te houden. De folder watcher detecteert automatisch wijzigingen in uw bibliotheekmappen. De folder watcher werkt niet voor elk bestandssysteem (zoals NFS), dus geplande bibliotheekscans kunnen in plaats daarvan worden gebruikt.",
"MessageSearchResultsFor": "Zoekresultaten voor",
"MessageSelected": "{0} geselecteerd",
"MessageServerCouldNotBeReached": "Server niet bereikbaar",
@@ -954,6 +938,7 @@
"ToastBookmarkCreateFailed": "Aanmaken boekwijzer mislukt",
"ToastBookmarkCreateSuccess": "boekwijzer toegevoegd",
"ToastBookmarkRemoveSuccess": "Boekwijzer verwijderd",
"ToastBookmarkUpdateSuccess": "Boekwijzer bijgewerkt",
"ToastCachePurgeFailed": "Cache wissen is mislukt",
"ToastCachePurgeSuccess": "Cache succesvol verwijderd",
"ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten",
@@ -961,10 +946,11 @@
"ToastChaptersRemoved": "Hoofdstukken verwijderd",
"ToastChaptersUpdated": "Hoofdstukken bijgewerkt",
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
"ToastCollectionItemsAddSuccess": "Item(s) toegevoegd aan collectie gelukt",
"ToastCollectionItemsRemoveSuccess": "Onderdeel (of onderdelen) verwijderd uit collectie",
"ToastCollectionRemoveSuccess": "Collectie verwijderd",
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
"ToastCoverUpdateFailed": "Cover update mislukt",
"ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig",
"ToastDeleteFileFailed": "Bestand verwijderen mislukt",
"ToastDeleteFileSuccess": "Bestand verwijderd",
"ToastDeviceAddFailed": "Apparaat toevoegen mislukt",
@@ -1017,7 +1003,6 @@
"ToastNewUserTagError": "Moet ten minste een tag selecteren",
"ToastNewUserUsernameError": "Voer een gebruikersnaam in",
"ToastNoNewEpisodesFound": "Geen nieuwe afleveringen gevonden",
"ToastNoRSSFeed": "Podcast heeft geen RSS Feed",
"ToastNoUpdatesNecessary": "Geen updates nodig",
"ToastNotificationCreateFailed": "Nieuwe melding aanmaken mislukt",
"ToastNotificationDeleteFailed": "Melding verwijderen mislukt",

View File

@@ -12,7 +12,7 @@
"ButtonBack": "Tilbake",
"ButtonBrowseForFolder": "Bla gjennom mappe",
"ButtonCancel": "Avbryt",
"ButtonCancelEncode": "Avbryt konvertering",
"ButtonCancelEncode": "Avbryt Encode",
"ButtonChangeRootPassword": "Bytt Root-bruker passord",
"ButtonCheckAndDownloadNewEpisodes": "Sjekk og last ned nye episoder",
"ButtonChooseAFolder": "Velg mappe",
@@ -97,10 +97,10 @@
"ButtonShare": "Del",
"ButtonShiftTimes": "Forskyv tider",
"ButtonShow": "Vis",
"ButtonStartM4BEncode": "Start konvertering til M4B",
"ButtonStartM4BEncode": "Start M4B Koding",
"ButtonStartMetadataEmbed": "Start Metadata innbaking",
"ButtonStats": "Statistikk",
"ButtonSubmit": "Lagre",
"ButtonSubmit": "Send inn",
"ButtonTest": "Test",
"ButtonUnlinkOpenId": "Koble fra OpenID",
"ButtonUpload": "Last opp",
@@ -143,12 +143,12 @@
"HeaderFindChapters": "Finn Kapittel",
"HeaderIgnoredFiles": "Ignorerte filer",
"HeaderItemFiles": "Elementfiler",
"HeaderItemMetadataUtils": "Element Metadata verktøy",
"HeaderItemMetadataUtils": "Enhet Metadata verktøy",
"HeaderLastListeningSession": "Siste lyttesesjon",
"HeaderLatestEpisodes": "Siste episoder",
"HeaderLibraries": "Biblioteker",
"HeaderLibraryFiles": "Bibliotek filer",
"HeaderLibraryStats": "Bibliotekstatistikk",
"HeaderLibraryStats": "Bibliotek statistikk",
"HeaderListeningSessions": "Lyttesesjoner",
"HeaderListeningStats": "Lyttestatistikk",
"HeaderLogin": "Logg inn",
@@ -300,7 +300,6 @@
"LabelDiscover": "Oppdag",
"LabelDownload": "Last ned",
"LabelDownloadNEpisodes": "Last ned {0} episoder",
"LabelDownloadable": "Nedlastbar",
"LabelDuration": "Varighet",
"LabelDurationComparisonExactMatch": "(nøyaktig treff)",
"LabelDurationComparisonLonger": "({0} lenger)",
@@ -366,11 +365,11 @@
"LabelFormat": "Format",
"LabelFull": "Full",
"LabelGenre": "Sjanger",
"LabelGenres": "Sjangre",
"LabelGenres": "Sjangers",
"LabelHardDeleteFile": "Tving sletting av fil",
"LabelHasEbook": "Har e-bok",
"LabelHasSupplementaryEbook": "Har komplimentær e-bok",
"LabelHideSubtitles": "Skjul undertitler",
"LabelHideSubtitles": "Skjul undertekster",
"LabelHighestPriority": "Høyeste prioritet",
"LabelHost": "Tjener",
"LabelHour": "Time",
@@ -407,7 +406,7 @@
"LabelLess": "Mindre",
"LabelLibrariesAccessibleToUser": "Biblioteker tilgjengelig for bruker",
"LabelLibrary": "Bibliotek",
"LabelLibraryFilterSublistEmpty": "Ingen {0}",
"LabelLibraryFilterSublistEmpty": "",
"LabelLibraryItem": "Bibliotek enhet",
"LabelLibraryName": "Bibliotek navn",
"LabelLimit": "Begrensning",
@@ -571,7 +570,7 @@
"LabelSettingsLibraryMarkAsFinishedWhen": "Marker som ferdig når",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hopp over tidligere bøker i \"Fortsett serien\"",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "\"Fortsett serie\"-siden viser første bok som ikke er påbegynt i serier der en bok er lest og ingen bøker leses nå. Ved å slå på denne innstillingen så vil man fortsette på serien etter siste leste bok, fremfor første bok som ikke er startet på i en serie.",
"LabelSettingsParseSubtitles": "Analyser undertitler",
"LabelSettingsParseSubtitles": "Analyser undertekster",
"LabelSettingsParseSubtitlesHelp": "Hent undertittel fra lydbokens mappenavn.<br>Undertittel må være separert med \" - \"<br>f.eks. \"Boktittel - En lengre tittel\" har undertittel \"En lengre tittel\".",
"LabelSettingsPreferMatchedMetadata": "Foretrekk funnet metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Funnet data vil overskrive enhetens detaljene når man bruker Kjapt søk. Som standard vil Kjapt søk kun fylle inn manglende detaljer.",
@@ -587,7 +586,6 @@
"LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden",
"LabelSettingsTimeFormat": "Tid format",
"LabelShare": "Dele",
"LabelShareDownloadableHelp": "Tillat brukere med en delt link å laste ned en zip-fil av elementet.",
"LabelShareOpen": "Åpne deling",
"LabelShareURL": "Dele URL",
"LabelShowAll": "Vis alle",
@@ -617,7 +615,7 @@
"LabelStatsOverallDays": "Totale dager",
"LabelStatsOverallHours": "Totale timer",
"LabelStatsWeekListening": "Uker lyttet",
"LabelSubtitle": "Undertittel",
"LabelSubtitle": "undertekster",
"LabelSupportedFileTypes": "Støttede filtyper",
"LabelTag": "Tag",
"LabelTags": "Tagger",
@@ -642,11 +640,11 @@
"LabelTimeRemaining": "{0} gjennstående",
"LabelTimeToShift": "Tid å forflytte i sekunder",
"LabelTitle": "Tittel",
"LabelToolsEmbedMetadata": "Bygg inn metadata",
"LabelToolsEmbedMetadata": "Bak inn metadata",
"LabelToolsEmbedMetadataDescription": "Bak inn metadata i lydfilen, inkludert omslagsbilde og kapitler.",
"LabelToolsM4bEncoder": "M4B enkoder",
"LabelToolsMakeM4b": "Lag M4B Lydbokfil",
"LabelToolsMakeM4bDescription": "Lager en M4B lydbokfil med innbakt omslagsbilde og kapitler.",
"LabelToolsMakeM4bDescription": "Lager en.M4B lydbokfil med innbakte omslagsbilde og kapitler.",
"LabelToolsSplitM4b": "Del M4B inn i MP3er",
"LabelToolsSplitM4bDescription": "Lager MP3er fra en M4B inndelt i kapitler med innbakt metadata, omslagsbilde og kapitler.",
"LabelTotalDuration": "Total lengde",
@@ -756,7 +754,6 @@
"MessageConfirmResetProgress": "Er du sikkert på at du vil tilbakestille fremgangen?",
"MessageConfirmSendEbookToDevice": "Er du sikker på at du vil sende {0} ebok \"{1}\" til enhet \"{2}\"?",
"MessageConfirmUnlinkOpenId": "Er du sikker på at du vil koble denne brukeren fra OpenID?",
"MessageDaysListenedInTheLastYear": "{0} dager med lytting siste året",
"MessageDownloadingEpisode": "Laster ned episode",
"MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge",
"MessageEmbedFailed": "Innbygging feilet!",
@@ -772,9 +769,9 @@
"MessageItemsSelected": "{0} Gjenstander valgt",
"MessageItemsUpdated": "{0} Gjenstander oppdatert",
"MessageJoinUsOn": "Følg oss nå",
"MessageListeningSessionsInTheLastYear": "{0} Lyttesesjoner iløpet av siste året",
"MessageLoading": "Laster...",
"MessageLoadingFolders": "Laster mapper...",
"MessageLogsDescription": "Logger lagres i <code>/metadata/logs</code> som JSON-filer. Krasjlogger lagres i <code>/metadata/logs/crash_logs.txt</code>.",
"MessageM4BFailed": "M4B mislykkes!",
"MessageM4BFinished": "M4B fullført!",
"MessageMapChapterTitles": "Bruk kapittel titler fra din eksisterende lydbok kapitler uten å justere tidsstempel",
@@ -791,7 +788,6 @@
"MessageNoCollections": "Ingen samlinger",
"MessageNoCoversFound": "Ingen omslagsbilde funnet",
"MessageNoDescription": "Ingen beskrivelse",
"MessageNoDevices": "Ingen enheter",
"MessageNoDownloadsInProgress": "Ingen aktive nedlastinger",
"MessageNoDownloadsQueued": "Ingen nedlastinger i kø",
"MessageNoEpisodeMatchesFound": "Ingen lik episode funnet",
@@ -805,7 +801,6 @@
"MessageNoLogs": "Ingen logger",
"MessageNoMediaProgress": "Ingen mediefremgang",
"MessageNoNotifications": "Ingen varslinger",
"MessageNoPodcastFeed": "Ugyldig podcast: Ingen feed",
"MessageNoPodcastsFound": "Ingen podcaster funnet",
"MessageNoResults": "Ingen resultat",
"MessageNoSearchResultsFor": "Ingen søkeresultat for \"{0}\"",
@@ -815,17 +810,11 @@
"MessageNoUpdatesWereNecessary": "Ingen oppdatering var nødvendig",
"MessageNoUserPlaylists": "Du har ingen spillelister",
"MessageNotYetImplemented": "Ikke implementert ennå",
"MessageOpmlPreviewNote": "PS: Dette er en forhåndvisning av en OPML-fil. Den faktiske podcast-tittelen hentes direkte fra RSS-feeden.",
"MessageOr": "eller",
"MessagePauseChapter": "Pause avspilling av kapittel",
"MessagePlayChapter": "Lytter på begynnelsen av kapittel",
"MessagePlaylistCreateFromCollection": "Lag spilleliste fra samling",
"MessagePleaseWait": "Vennligst vent...",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast har ingen RSS feed url til bruk av sammenligning",
"MessagePodcastSearchField": "Skriv inn søkeord eller RSS-feed URL",
"MessageQuickEmbedInProgress": "Hurtiginnbygging pågår",
"MessageQuickEmbedQueue": "Kø for hurtiginnbygging ({0} i kø)",
"MessageQuickMatchAllEpisodes": "Kjapp matching av alle episoder",
"MessageQuickMatchDescription": "Fyll inn tomme gjenstandsdetaljer og omslagsbilde med første resultat fra '{0}'. Overskriver ikke detaljene utenom 'Foretrekk funnet metadata' tjenerinstilling er aktivert.",
"MessageRemoveChapter": "fjerne kapittel",
"MessageRemoveEpisodes": "fjerne {0} kapitler",
@@ -835,29 +824,10 @@
"MessageResetChaptersConfirm": "Er du sikker på at du vil nullstille kapitler og angre endringene du har gjort?",
"MessageRestoreBackupConfirm": "Er du sikker på at du vil gjenopprette sikkerhetskopien som var laget",
"MessageRestoreBackupWarning": "gjenoppretting av sikkerhetskopi vil overskrive hele databasen under /config og omslagsbilde under /metadata/items og /metadata/authors.<br /><br />Sikkerhetskopier endrer ikke noen filer under dine bibliotekmapper. Hvis du har aktivert tjenerinstillingen for å lagre omslagsbilder og metadata i bibliotekmapper så vil ikke de filene bli tatt sikkerhetskopi eller overskrevet.<br /><br />Alle klientene som bruker din tjener vil bli fornyet automatisk.",
"MessageScheduleLibraryScanNote": "For de fleste brukere er det anbefalt å la denne funksjonen være slått av, og la mappeovervåkeren stå på. Mappeovervåkeren oppdager automatisk endringer i biblioteksmappene. Mappeovervåkeren fungerer ikke med alle filsystemer (f.eks. NFS) og da kan planlagt skanning av bibliotekene brukes i steden for.",
"MessageSearchResultsFor": "Søk resultat for",
"MessageSelected": "{0} valgt",
"MessageServerCouldNotBeReached": "Tjener kunne ikke bli nådd",
"MessageSetChaptersFromTracksDescription": "Sett kapitler ved å bruke hver lydfil som kapittel og kapitteltittel som lydfilnavnet",
"MessageShareExpirationWillBe": "Utløp vil være <strong>{0}</strong>",
"MessageShareExpiresIn": "Utløper om {0}",
"MessageShareURLWillBe": "URL for deling blir <strong>{0}</strong>",
"MessageStartPlaybackAtTime": "Start avspilling av \"{0}\" ved {1}?",
"MessageTaskAudioFileNotWritable": "Lydfilen \"{0}\" kan ikke skrives til",
"MessageTaskCanceledByUser": "Oppgave kansellert av bruker",
"MessageTaskDownloadingEpisodeDescription": "Laster ned episode \"{0}\"",
"MessageTaskEmbeddingMetadata": "Bygger inn metadata",
"MessageTaskEmbeddingMetadataDescription": "Bygger inn metadata i lydboken \"{0}\"",
"MessageTaskEncodingM4b": "Konverterer til M4B",
"MessageTaskEncodingM4bDescription": "Konverterer lydboken \"{0}\" til én M4B-fil",
"MessageTaskFailed": "Feilet",
"MessageTaskFailedToBackupAudioFile": "Feil ved sikkerhetskopiering av lydfilen \"{0}\"",
"MessageTaskFailedToCreateCacheDirectory": "Kunne ikke opprette mappe for mellomlagring (cache)",
"MessageTaskFailedToEmbedMetadataInFile": "Kunne ikke bygge inn metadata i filen \"{0}\"",
"MessageTaskFailedToMergeAudioFiles": "Kunne ikke slå sammen lydfiler",
"MessageTaskFailedToMoveM4bFile": "Kunne ikke flytte M4B-fil",
"MessageTaskFailedToWriteMetadataFile": "Kunne ikke lagre metadata-fil",
"MessageThinking": "Tenker...",
"MessageUploaderItemFailed": "Opplastning mislykkes",
"MessageUploaderItemSuccess": "Opplastning fullført!",
@@ -904,6 +874,7 @@
"ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke",
"ToastBookmarkCreateSuccess": "Bokmerke lagt til",
"ToastBookmarkRemoveSuccess": "Bokmerke fjernet",
"ToastBookmarkUpdateSuccess": "Bokmerke oppdatert",
"ToastCachePurgeFailed": "Kunne ikke å slette mellomlager",
"ToastCachePurgeSuccess": "Mellomlager slettet",
"ToastChaptersHaveErrors": "Kapittel har feil",
@@ -911,6 +882,8 @@
"ToastChaptersRemoved": "Kapitler fjernet",
"ToastChaptersUpdated": "Kapitler oppdatert",
"ToastCollectionItemsAddFailed": "Feil med å legge til element(er)",
"ToastCollectionItemsAddSuccess": "Element(er) lagt til samlingen",
"ToastCollectionItemsRemoveSuccess": "Gjenstand(er) fjernet fra samling",
"ToastCollectionRemoveSuccess": "Samling fjernet",
"ToastCollectionUpdateSuccess": "samlingupdated",
"ToastCoverUpdateFailed": "Oppdatering av bilde feilet",

View File

@@ -30,7 +30,6 @@
"ButtonEditChapters": "Edytuj rozdziały",
"ButtonEditPodcast": "Edytuj podcast",
"ButtonEnable": "Włącz",
"ButtonFireAndFail": "Fail start",
"ButtonForceReScan": "Wymuś ponowne skanowanie",
"ButtonFullPath": "Pełna ścieżka",
"ButtonHide": "Ukryj",
@@ -658,6 +657,7 @@
"MessageInsertChapterBelow": "Wstaw rozdział poniżej",
"MessageItemsSelected": "{0} zaznaczone elementy",
"MessageJoinUsOn": "Dołącz do nas na",
"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>.",
@@ -771,6 +771,8 @@
"ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki",
"ToastBookmarkCreateSuccess": "Dodano zakładkę",
"ToastBookmarkRemoveSuccess": "Zakładka została usunięta",
"ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę",
"ToastCollectionItemsRemoveSuccess": "Przedmiot(y) zostały usunięte z kolekcji",
"ToastCollectionRemoveSuccess": "Kolekcja usunięta",
"ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję",
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",

View File

@@ -630,6 +630,7 @@
"MessageItemsSelected": "{0} Itens Selecionados",
"MessageItemsUpdated": "{0} Itens Atualizados",
"MessageJoinUsOn": "Junte-se a nós",
"MessageListeningSessionsInTheLastYear": "{0} sessões de escuta no ano anterior",
"MessageLoading": "Carregando...",
"MessageLoadingFolders": "Carregando pastas...",
"MessageLogsDescription": "Os logs estão armazenados em <code>/metadata/logs</code> como arquivos JSON. Logs de crash estão armazenados em <code>/metadata/logs/crash_logs.txt</code>.",
@@ -729,10 +730,12 @@
"ToastBookmarkCreateFailed": "Falha ao criar marcador",
"ToastBookmarkCreateSuccess": "Marcador adicionado",
"ToastBookmarkRemoveSuccess": "Marcador removido",
"ToastBookmarkUpdateSuccess": "Marcador atualizado",
"ToastCachePurgeFailed": "Falha ao apagar o cache",
"ToastCachePurgeSuccess": "Cache apagado com sucesso",
"ToastChaptersHaveErrors": "Capítulos com erro",
"ToastChaptersMustHaveTitles": "Capítulos precisam ter títulos",
"ToastCollectionItemsRemoveSuccess": "Item(ns) removidos da coleção",
"ToastCollectionRemoveSuccess": "Coleção removida",
"ToastCollectionUpdateSuccess": "Coleção atualizada",
"ToastDeleteFileFailed": "Falha ao apagar arquivo",

View File

@@ -51,7 +51,7 @@
"ButtonNext": "Следующий",
"ButtonNextChapter": "Следующая глава",
"ButtonNextItemInQueue": "Следующий элемент в очереди",
"ButtonOk": "Ок",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Открыть канал",
"ButtonOpenManager": "Открыть менеджер",
"ButtonPause": "Пауза",
@@ -300,7 +300,6 @@
"LabelDiscover": "Не начато",
"LabelDownload": "Скачать",
"LabelDownloadNEpisodes": "Скачать {0} эпизодов",
"LabelDownloadable": "Загружаемый",
"LabelDuration": "Длина",
"LabelDurationComparisonExactMatch": "(точное совпадение)",
"LabelDurationComparisonLonger": "({0} дольше)",
@@ -348,7 +347,7 @@
"LabelFetchingMetadata": "Извлечение метаданных",
"LabelFile": "Файл",
"LabelFileBirthtime": "Дата создания",
"LabelFileBornDate": "Создан {0}",
"LabelFileBornDate": "Родился {0}",
"LabelFileModified": "Дата модификации",
"LabelFileModifiedDate": "Изменено {0}",
"LabelFilename": "Имя файла",
@@ -589,7 +588,6 @@
"LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента",
"LabelSettingsTimeFormat": "Формат времени",
"LabelShare": "Поделиться",
"LabelShareDownloadableHelp": "Позволяет пользователям с помощью ссылки загрузить zip-файл элемента библиотеки.",
"LabelShareOpen": "Общедоступно",
"LabelShareURL": "Общедоступный URL",
"LabelShowAll": "Показать все",
@@ -758,7 +756,6 @@
"MessageConfirmResetProgress": "Вы уверены, что хотите сбросить свой прогресс?",
"MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?",
"MessageConfirmUnlinkOpenId": "Вы уверены, что хотите отвязать этого пользователя от OpenID?",
"MessageDaysListenedInTheLastYear": "{0} дней прослушивания за последний год",
"MessageDownloadingEpisode": "Эпизод скачивается",
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
"MessageEmbedFailed": "Вставка не удалась!",
@@ -774,6 +771,7 @@
"MessageItemsSelected": "{0} Элементов выделено",
"MessageItemsUpdated": "{0} Элементов обновлено",
"MessageJoinUsOn": "Присоединяйтесь к нам в",
"MessageListeningSessionsInTheLastYear": "{0} сеансов прослушивания в прошлом году",
"MessageLoading": "Загрузка...",
"MessageLoadingFolders": "Загрузка каталогов...",
"MessageLogsDescription": "Журналы хранятся в <code>/metadata/logs</code> в виде JSON-файлов. Журналы сбоев хранятся в <code>/metadata/logs/crash_logs.txt</code>.",
@@ -837,7 +835,6 @@
"MessageResetChaptersConfirm": "Вы уверены, что хотите сбросить главы и отменить внесенные изменения?",
"MessageRestoreBackupConfirm": "Вы уверены, что хотите восстановить резервную копию, созданную",
"MessageRestoreBackupWarning": "Восстановление резервной копии перезапишет всю базу данных, расположенную в /config, и обложки изображений в /metadata/items и /metadata/authors.<br/><br/>Бэкапы не изменяют файлы в папках библиотеки. Если вы включили параметры сервера для хранения обложек и метаданных в папках библиотеки, то они не резервируются и не перезаписываются.<br/><br/>Все клиенты, использующие ваш сервер, будут автоматически обновлены.",
"MessageScheduleLibraryScanNote": "Большинству пользователей рекомендуется отключить эту функцию и включить функцию просмотра папок. Программа просмотра папок автоматически обнаружит изменения в папках вашей библиотеки. Программа просмотра папок работает не для каждой файловой системы (например, NFS), поэтому вместо этого можно использовать запланированные проверки библиотеки.",
"MessageSearchResultsFor": "Результаты поиска для",
"MessageSelected": "{0} выбрано",
"MessageServerCouldNotBeReached": "Не удалось связаться с сервером",
@@ -954,6 +951,7 @@
"ToastBookmarkCreateFailed": "Не удалось создать закладку",
"ToastBookmarkCreateSuccess": "Добавлена закладка",
"ToastBookmarkRemoveSuccess": "Закладка удалена",
"ToastBookmarkUpdateSuccess": "Закладка обновлена",
"ToastCachePurgeFailed": "Не удалось очистить кэш",
"ToastCachePurgeSuccess": "Кэш успешно очищен",
"ToastChaptersHaveErrors": "Главы имеют ошибки",
@@ -961,10 +959,11 @@
"ToastChaptersRemoved": "Удалены главы",
"ToastChaptersUpdated": "Обновленные главы",
"ToastCollectionItemsAddFailed": "Не удалось добавить элемент(ы) в коллекцию",
"ToastCollectionItemsAddSuccess": "Элемент(ы) добавлены в коллекцию",
"ToastCollectionItemsRemoveSuccess": "Элемент(ы), удалены из коллекции",
"ToastCollectionRemoveSuccess": "Коллекция удалена",
"ToastCollectionUpdateSuccess": "Коллекция обновлена",
"ToastCoverUpdateFailed": "Не удалось обновить обложку",
"ToastDateTimeInvalidOrIncomplete": "Дата и время указаны неверно или не до конца",
"ToastDeleteFileFailed": "Не удалось удалить файл",
"ToastDeleteFileSuccess": "Файл удален",
"ToastDeviceAddFailed": "Не удалось добавить устройство",
@@ -1017,7 +1016,6 @@
"ToastNewUserTagError": "Необходимо выбрать хотя бы один тег",
"ToastNewUserUsernameError": "Введите имя пользователя",
"ToastNoNewEpisodesFound": "Новых эпизодов не найдено",
"ToastNoRSSFeed": "У подкаста нет RSS-канала",
"ToastNoUpdatesNecessary": "Обновления не требуются",
"ToastNotificationCreateFailed": "Не удалось создать уведомление",
"ToastNotificationDeleteFailed": "Не удалось удалить уведомление",

View File

@@ -300,7 +300,6 @@
"LabelDiscover": "Odkrij",
"LabelDownload": "Prenos",
"LabelDownloadNEpisodes": "Prenesi {0} epizod",
"LabelDownloadable": "Možen prenos",
"LabelDuration": "Trajanje",
"LabelDurationComparisonExactMatch": "(natančno ujemanje)",
"LabelDurationComparisonLonger": "({0} dlje)",
@@ -463,7 +462,7 @@
"LabelNotificationsMaxQueueSize": "Največja velikost čakalne vrste za dogodke obvestil",
"LabelNotificationsMaxQueueSizeHelp": "Dogodki so omejeni na sprožitev 1 na sekundo. Dogodki bodo prezrti, če je čakalna vrsta najvišja. To preprečuje neželeno pošiljanje obvestil.",
"LabelNumberOfBooks": "Število knjig",
"LabelNumberOfEpisodes": "# epizod",
"LabelNumberOfEpisodes": "število epizod",
"LabelOpenIDAdvancedPermsClaimDescription": "Ime zahtevka OpenID, ki vsebuje napredna dovoljenja za uporabniška dejanja v aplikaciji, ki bodo veljala za neskrbniške vloge (<b>če je konfigurirano</b>). Če trditev manjka v odgovoru, bo dostop do ABS zavrnjen. Če ena možnost manjka, bo obravnavana kot <code>false</code>. Zagotovite, da se zahtevek ponudnika identitete ujema s pričakovano strukturo:",
"LabelOpenIDClaims": "Pustite naslednje možnosti prazne, da onemogočite napredno dodeljevanje skupin in dovoljenj, nato pa samodejno dodelite skupino 'Uporabnik'.",
"LabelOpenIDGroupClaimDescription": "Ime zahtevka OpenID, ki vsebuje seznam uporabnikovih skupin. Običajno imenovane <code>skupine</code>. <b>Če je konfigurirana</b>, bo aplikacija samodejno dodelila vloge na podlagi članstva v skupini uporabnika, pod pogojem, da so te skupine v zahtevku poimenovane 'admin', 'user' ali 'guest' brez razlikovanja med velikimi in malimi črkami. Zahtevek mora vsebovati seznam in če uporabnik pripada več skupinam, mu aplikacija dodeli vlogo, ki ustreza najvišjemu nivoju dostopa. Če se nobena skupina ne ujema, bo dostop zavrnjen.",
@@ -589,7 +588,6 @@
"LabelSettingsStoreMetadataWithItemHelp": "Datoteke z metapodatki so privzeto shranjene v /metadata/items, če omogočite to nastavitev, boste datoteke z metapodatki shranili v mape elementov vaše knjižnice",
"LabelSettingsTimeFormat": "Oblika časa",
"LabelShare": "Deli",
"LabelShareDownloadableHelp": "Omogoča uporabnikom s povezavo skupne rabe, da prenesejo zip datoteko elementa knjižnice.",
"LabelShareOpen": "Deli odprto",
"LabelShareURL": "Deli URL",
"LabelShowAll": "Prikaži vse",
@@ -758,7 +756,6 @@
"MessageConfirmResetProgress": "Ali ste prepričani, da želite ponastaviti svoj napredek?",
"MessageConfirmSendEbookToDevice": "Ali ste prepričani, da želite poslati {0} e-knjigo \"{1}\" v napravo \"{2}\"?",
"MessageConfirmUnlinkOpenId": "Ali ste prepričani, da želite prekiniti povezavo tega uporabnika z OpenID?",
"MessageDaysListenedInTheLastYear": "{0} dni poslušanja v zadnjem letu",
"MessageDownloadingEpisode": "Prenašam epizodo",
"MessageDragFilesIntoTrackOrder": "Povlecite datoteke v pravilen vrstni red posnetkov",
"MessageEmbedFailed": "Vdelava ni uspela!",
@@ -774,6 +771,7 @@
"MessageItemsSelected": "{0} izbranih elementov",
"MessageItemsUpdated": "Št. posodobljenih elementov: {0}",
"MessageJoinUsOn": "Pridružite se nam",
"MessageListeningSessionsInTheLastYear": "{0} sej poslušanja v zadnjem letu",
"MessageLoading": "Nalagam...",
"MessageLoadingFolders": "Nalagam mape...",
"MessageLogsDescription": "Dnevniki so shranjeni v <code>/metadata/logs</code> kot datoteke JSON. Dnevniki zrušitev so shranjeni v <code>/metadata/logs/crash_logs.txt</code>.",
@@ -837,7 +835,6 @@
"MessageResetChaptersConfirm": "Ali ste prepričani, da želite ponastaviti poglavja in razveljaviti spremembe, ki ste jih naredili?",
"MessageRestoreBackupConfirm": "Ali ste prepričani, da želite obnoviti varnostno kopijo, ustvarjeno ob",
"MessageRestoreBackupWarning": "Obnovitev varnostne kopije bo prepisala celotno zbirko podatkov, ki se nahaja v /config, in zajema slike v /metadata/items in /metadata/authors.<br /><br />Varnostne kopije ne spreminjajo nobenih datotek v mapah vaše knjižnice. Če ste omogočili nastavitve strežnika za shranjevanje naslovnic in metapodatkov v mapah vaše knjižnice, potem ti niso varnostno kopirani ali prepisani.<br /><br />Vsi odjemalci, ki uporabljajo vaš strežnik, bodo samodejno osveženi.",
"MessageScheduleLibraryScanNote": "Za večino uporabnikov je priporočljivo, da to funkcijo pustite onemogočeno in ohranite nastavitev pregledovalnika map omogočeno. Pregledovalnik map bo samodejno zaznal spremembe v mapah vaše knjižnice. Pregledovalnik map ne deluje za vse datotečne sisteme (na primer NFS), zato lahko namesto tega uporabite načrtovane preglede knjižnic.",
"MessageSearchResultsFor": "Rezultati iskanja za",
"MessageSelected": "{0} izbrano",
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
@@ -954,6 +951,7 @@
"ToastBookmarkCreateFailed": "Zaznamka ni bilo mogoče ustvariti",
"ToastBookmarkCreateSuccess": "Zaznamek dodan",
"ToastBookmarkRemoveSuccess": "Zaznamek odstranjen",
"ToastBookmarkUpdateSuccess": "Zaznamek posodobljen",
"ToastCachePurgeFailed": "Čiščenje predpomnilnika ni uspelo",
"ToastCachePurgeSuccess": "Predpomnilnik je bil uspešno očiščen",
"ToastChaptersHaveErrors": "Poglavja imajo napake",
@@ -961,10 +959,11 @@
"ToastChaptersRemoved": "Poglavja so odstranjena",
"ToastChaptersUpdated": "Poglavja so posodobljena",
"ToastCollectionItemsAddFailed": "Dodajanje elementov v zbirko ni uspelo",
"ToastCollectionItemsAddSuccess": "Dodajanje elementov v zbirko je bilo uspešno",
"ToastCollectionItemsRemoveSuccess": "Elementi so bili odstranjeni iz zbirke",
"ToastCollectionRemoveSuccess": "Zbirka je bila odstranjena",
"ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena",
"ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela",
"ToastDateTimeInvalidOrIncomplete": "Datum in čas sta neveljavna ali nepopolna",
"ToastDeleteFileFailed": "Brisanje datoteke ni uspelo",
"ToastDeleteFileSuccess": "Datoteka je bila izbrisana",
"ToastDeviceAddFailed": "Naprave ni bilo mogoče dodati",
@@ -1017,7 +1016,6 @@
"ToastNewUserTagError": "Izbrati morate vsaj eno oznako",
"ToastNewUserUsernameError": "Vnesite uporabniško ime",
"ToastNoNewEpisodesFound": "Ni novih epizod",
"ToastNoRSSFeed": "Podcast nima RSS vira",
"ToastNoUpdatesNecessary": "Posodobitve niso potrebne",
"ToastNotificationCreateFailed": "Obvestila ni bilo mogoče ustvariti",
"ToastNotificationDeleteFailed": "Brisanje obvestila ni uspelo",

View File

@@ -19,7 +19,6 @@
"ButtonChooseFiles": "Välj filer",
"ButtonClearFilter": "Rensa filter",
"ButtonCloseFeed": "Stäng flöde",
"ButtonCloseSession": "Stäng öppen session",
"ButtonCollections": "Samlingar",
"ButtonConfigureScanner": "Konfigurera skanner",
"ButtonCreate": "Skapa",
@@ -29,14 +28,11 @@
"ButtonEdit": "Redigera",
"ButtonEditChapters": "Redigera kapitel",
"ButtonEditPodcast": "Redigera podcast",
"ButtonEnable": "Aktivera",
"ButtonForceReScan": "Tvinga omstart",
"ButtonFullPath": "Fullständig sökväg",
"ButtonHide": "Dölj",
"ButtonHome": "Hem",
"ButtonIssues": "Problem",
"ButtonJumpBackward": "Hoppa bakåt",
"ButtonJumpForward": "Hoppa framåt",
"ButtonLatest": "Senaste",
"ButtonLibrary": "Bibliotek",
"ButtonLogout": "Logga ut",
@@ -48,7 +44,6 @@
"ButtonNevermind": "Glöm det",
"ButtonNext": "Nästa",
"ButtonNextChapter": "Nästa kapitel",
"ButtonNextItemInQueue": "Nästa objekt i Kö",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Öppna flöde",
"ButtonOpenManager": "Öppna Manager",
@@ -59,9 +54,8 @@
"ButtonPlaylists": "Spellistor",
"ButtonPrevious": "Föregående",
"ButtonPreviousChapter": "Föregående kapitel",
"ButtonProbeAudioFile": "Analysera ljudfil",
"ButtonPurgeAllCache": "Rensa all cache",
"ButtonPurgeItemsCache": "Rensa cache för föremål",
"ButtonPurgeItemsCache": "Rensa föremåls-cache",
"ButtonQueueAddItem": "Lägg till i kön",
"ButtonQueueRemoveItem": "Ta bort från kön",
"ButtonQuickMatch": "Snabb matchning",
@@ -72,10 +66,10 @@
"ButtonRefresh": "Uppdatera",
"ButtonRemove": "Ta bort",
"ButtonRemoveAll": "Ta bort alla",
"ButtonRemoveAllLibraryItems": "Ta bort alla objekt i biblioteket",
"ButtonRemoveFromContinueListening": "Radera från 'Fortsätt läsa/lyssna'",
"ButtonRemoveAllLibraryItems": "Ta bort alla biblioteksobjekt",
"ButtonRemoveFromContinueListening": "Ta bort från Fortsätt lyssna",
"ButtonRemoveFromContinueReading": "Ta bort från Fortsätt läsa",
"ButtonRemoveSeriesFromContinueSeries": "Radera från 'Fortsätt med serien'",
"ButtonRemoveSeriesFromContinueSeries": "Ta bort serie från Fortsätt serie",
"ButtonReset": "Återställ",
"ButtonResetToDefault": "Återställ till standard",
"ButtonRestore": "Återställ",
@@ -88,60 +82,53 @@
"ButtonSelectFolderPath": "Välj mappens sökväg",
"ButtonSeries": "Serier",
"ButtonSetChaptersFromTracks": "Ställ in kapitel från spår",
"ButtonShare": "Dela",
"ButtonShiftTimes": "Förskjut tider",
"ButtonShow": "Visa",
"ButtonStartM4BEncode": "Starta M4B-kodning",
"ButtonStartMetadataEmbed": "Starta inbäddning av metadata",
"ButtonStats": "Statistik",
"ButtonSubmit": "Spara",
"ButtonSubmit": "Skicka",
"ButtonTest": "Testa",
"ButtonUpload": "Ladda upp",
"ButtonUploadBackup": "Ladda upp säkerhetskopia",
"ButtonUploadCover": "Ladda upp bokomslag",
"ButtonUploadCover": "Ladda upp omslag",
"ButtonUploadOPMLFile": "Ladda upp OPML-fil",
"ButtonUserDelete": "Radera användare {0}",
"ButtonUserEdit": "Redigera användare {0}",
"ButtonViewAll": "Visa alla",
"ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Fel vid hämtning av metadata",
"ErrorUploadFetchMetadataNoResults": "Metadata kunde inte hämtas - försök att ändra titel och/eller författare",
"ErrorUploadLacksTitle": "En titel måste anges",
"HeaderAccount": "Konto",
"HeaderAddCustomMetadataProvider": "Addera egen källa för metadata",
"HeaderAdvanced": "Avancerad",
"HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar",
"HeaderAudioTracks": "Ljudspår",
"HeaderAudiobookTools": "Hantering av ljudboksfil",
"HeaderAuthentication": "Autentisering",
"HeaderAudiobookTools": "Ljudbokshantering",
"HeaderBackups": "Säkerhetskopior",
"HeaderChangePassword": "Ändra lösenord",
"HeaderChapters": "Kapitel",
"HeaderChooseAFolder": "Välj en mapp",
"HeaderCollection": "Samling",
"HeaderCollectionItems": "Böcker i samlingen",
"HeaderCover": "Bokomslag",
"HeaderCollectionItems": "Samlingselement",
"HeaderCover": "Omslag",
"HeaderCurrentDownloads": "Aktuella nedladdningar",
"HeaderCustomMetadataProviders": "Egen källa för metadata",
"HeaderDetails": "Detaljer",
"HeaderDownloadQueue": "Nedladdningskö",
"HeaderEbookFiles": "E-boksfiler",
"HeaderEmail": "E-postadress",
"HeaderEmailSettings": "Inställningar för e-post",
"HeaderEmail": "E-post",
"HeaderEmailSettings": "E-postinställningar",
"HeaderEpisodes": "Avsnitt",
"HeaderEreaderDevices": "Enheter för att läsa e-böcker",
"HeaderEreaderDevices": "E-boksläsarenheter",
"HeaderEreaderSettings": "E-boksinställningar",
"HeaderFiles": "Filer",
"HeaderFindChapters": "Hitta kapitel",
"HeaderIgnoredFiles": "Ignorerade filer",
"HeaderItemFiles": "Föremålsfiler",
"HeaderItemMetadataUtils": "Metadataverktyg för föremål",
"HeaderLastListeningSession": "Senaste lyssningstillfället",
"HeaderLatestEpisodes": "Senaste avsnitten",
"HeaderLastListeningSession": "Senaste lyssningssession",
"HeaderLatestEpisodes": "Senaste avsnitt",
"HeaderLibraries": "Bibliotek",
"HeaderLibraryFiles": "Filer i biblioteket",
"HeaderLibraryFiles": "Biblioteksfiler",
"HeaderLibraryStats": "Biblioteksstatistik",
"HeaderListeningSessions": "Lyssningstillfällen",
"HeaderListeningSessions": "Lyssningssessioner",
"HeaderListeningStats": "Lyssningsstatistik",
"HeaderLogin": "Logga in",
"HeaderLogs": "Loggar",
@@ -149,31 +136,27 @@
"HeaderManageTags": "Hantera taggar",
"HeaderMapDetails": "Karta detaljer",
"HeaderMatch": "Matcha",
"HeaderMetadataOrderOfPrecedence": "Prioriteringsordning vid inläsning av metadata",
"HeaderMetadataOrderOfPrecedence": "Metadataordning av företräde",
"HeaderMetadataToEmbed": "Metadata att bädda in",
"HeaderNewAccount": "Nytt konto",
"HeaderNewLibrary": "Nytt bibliotek",
"HeaderNotifications": "Meddelanden",
"HeaderOpenRSSFeed": "Öppna RSS-flöde",
"HeaderOtherFiles": "Andra filer",
"HeaderPasswordAuthentication": "Lösenordsautentisering",
"HeaderPermissions": "Behörigheter",
"HeaderPlayerQueue": "Spellista",
"HeaderPlayerSettings": "Inställningar för uppspelning",
"HeaderPlayerQueue": "Spelarkö",
"HeaderPlaylist": "Spellista",
"HeaderPlaylistItems": "Böcker i spellistan",
"HeaderPlaylistItems": "Spellistobjekt",
"HeaderPodcastsToAdd": "Podcaster att lägga till",
"HeaderPreviewCover": "Förhandsgranska bokomslag",
"HeaderPreviewCover": "Förhandsgranska omslag",
"HeaderRSSFeedGeneral": "RSS-information",
"HeaderRSSFeedIsOpen": "RSS-flödet är öppet",
"HeaderRSSFeeds": "RSS-flöden",
"HeaderRemoveEpisode": "Ta bort avsnitt",
"HeaderRemoveEpisodes": "Ta bort {0} avsnitt",
"HeaderSavedMediaProgress": "Sparad historik",
"HeaderSavedMediaProgress": "Sparad medieförlopp",
"HeaderSchedule": "Schema",
"HeaderScheduleEpisodeDownloads": "Schemalägg automatiska avsnittsnedladdningar",
"HeaderScheduleLibraryScans": "Schema för skanning av biblioteket",
"HeaderSession": "Session",
"HeaderScheduleLibraryScans": "Schemalagda biblioteksskanningar",
"HeaderSetBackupSchedule": "Ange schemaläggning för säkerhetskopia",
"HeaderSettings": "Inställningar",
"HeaderSettingsDisplay": "Visning",
@@ -181,62 +164,55 @@
"HeaderSettingsGeneral": "Allmänt",
"HeaderSettingsScanner": "Skanner",
"HeaderSettingsWebClient": "Webklient",
"HeaderSleepTimer": "Timer för att sova",
"HeaderStatsLargestItems": "Största objekten",
"HeaderStatsLongestItems": "Längsta objekten (timmar)",
"HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagarna)",
"HeaderStatsRecentSessions": "Senaste tillfällena",
"HeaderSleepTimer": "Sovtidtagare",
"HeaderStatsLargestItems": "Största objekt",
"HeaderStatsLongestItems": "Längsta objekt (tim)",
"HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagar)",
"HeaderStatsRecentSessions": "Senaste sessioner",
"HeaderStatsTop10Authors": "10 populäraste författarna",
"HeaderStatsTop5Genres": "5 populäraste kategorierna",
"HeaderTableOfContents": "Innehållsförteckning",
"HeaderTools": "Verktyg",
"HeaderUpdateAccount": "Uppdatera konto",
"HeaderUpdateAuthor": "Uppdatera författare",
"HeaderUpdateDetails": "Uppdatera detaljer om boken",
"HeaderUpdateDetails": "Uppdatera detaljer",
"HeaderUpdateLibrary": "Uppdatera bibliotek",
"HeaderUsers": "Användare",
"HeaderYearReview": "Sammanställning av {0}",
"HeaderYearReview": "Sammanställning för {0}",
"HeaderYourStats": "Din statistik",
"LabelAbridged": "Förkortad",
"LabelAccessibleBy": "Tillgänglig för",
"LabelAccountType": "Kontotyp",
"LabelAccountTypeAdmin": "Administratör",
"LabelAccountTypeGuest": "Gäst",
"LabelAccountTypeUser": "Användare",
"LabelActivity": "Aktivitet",
"LabelAddToCollection": "Lägg till i en Samling",
"LabelAddToCollectionBatch": "Lägg till {0} böcker i en Samling",
"LabelAddToCollection": "Lägg till i Samling",
"LabelAddToCollectionBatch": "Lägg till {0} böcker i Samlingen",
"LabelAddToPlaylist": "Lägg till i Spellista",
"LabelAddToPlaylistBatch": "Lägg till {0} objekt i Spellistan",
"LabelAddedAt": "Datum adderad",
"LabelAddedDate": "Adderad {0}",
"LabelAddedAt": "Tillagd vid",
"LabelAdminUsersOnly": "Endast administratörer",
"LabelAll": "Alla",
"LabelAllUsers": "Alla användare",
"LabelAllUsersExcludingGuests": "Alla användare utom gäster",
"LabelAllUsersIncludingGuests": "Alla användare inklusive gäster",
"LabelAlreadyInYourLibrary": "Redan i din samling",
"LabelApiToken": "API-token",
"LabelAppend": "Lägg till",
"LabelAuthor": "Författare",
"LabelAuthorFirstLast": "Författare (För-, Efternamn)",
"LabelAuthorLastFirst": "Författare (Efter-, Förnamn)",
"LabelAuthorFirstLast": "Författare (Förnamn Efternamn)",
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)",
"LabelAuthors": "Författare",
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
"LabelAutoFetchMetadata": "Automatisk nedladdning av metadata",
"LabelAutoFetchMetadataHelp": "Hämtar metadata för titel, författare och serier. Kompletterande metadata kan manuellt adderas efter uppladdningen.",
"LabelAutoRegisterDescription": "Skapa automatiskt nya användare efter inloggning",
"LabelAutoFetchMetadataHelp": "Hämtar metadata för titel, författare och serier. Kompletterande metadata får adderas efter uppladdningen.",
"LabelBackToUser": "Tillbaka till användaren",
"LabelBackupAudioFiles": "Säkerhetskopiera ljudfiler",
"LabelBackupLocation": "Plats för säkerhetskopia",
"LabelBackupsEnableAutomaticBackups": "Aktivera automatisk säkerhetskopiering",
"LabelBackupsEnableAutomaticBackups": "Aktivera automatiska säkerhetskopior",
"LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i \"/metadata/backups\"",
"LabelBackupsMaxBackupSize": "Maximal storlek på säkerhetskopia (i GB) (0 = obegränsad)",
"LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot en felaktig konfiguration kommer säkerhetskopior inte att genomföras om de överskrider den konfigurerade storleken.",
"LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot felkonfiguration kommer säkerhetskopior att misslyckas om de överskrider den konfigurerade storleken.",
"LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla",
"LabelBackupsNumberToKeepHelp": "Endast en gammal säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än det angivna beloppet bör du ta bort dem manuellt.",
"LabelBackupsNumberToKeepHelp": "Endast en säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än detta bör du ta bort dem manuellt.",
"LabelBitrate": "Bitfrekvens",
"LabelBonus": "Bonus",
"LabelBooks": "Böcker",
"LabelButtonText": "Knapptext",
"LabelByAuthor": "av {0}",
@@ -247,26 +223,23 @@
"LabelChapters": "Kapitel",
"LabelChaptersFound": "hittade kapitel",
"LabelClickForMoreInfo": "Klicka för mer information",
"LabelClickToUseCurrentValue": "Klicka för att använda aktuellt värde",
"LabelClosePlayer": "Stäng spelaren",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Komprimera serier",
"LabelCollapseSeries": "Fäll ihop serie",
"LabelCollection": "Samling",
"LabelCollections": "Samlingar",
"LabelComplete": "Komplett",
"LabelConfirmPassword": "Bekräfta lösenord",
"LabelContinueListening": "Fortsätt läsa/lyssna",
"LabelContinueListening": "Fortsätt Lyssna",
"LabelContinueReading": "Fortsätt Läsa",
"LabelContinueSeries": "Fortsätt med serien",
"LabelCover": "Bokomslag",
"LabelContinueSeries": "Fortsätt Serie",
"LabelCover": "Omslag",
"LabelCoverImageURL": "URL till omslagsbild",
"LabelCreatedAt": "Skapad",
"LabelCronExpression": "Schemaläggning med hjälp av Cron (Cron Expression)",
"LabelCreatedAt": "Skapad vid",
"LabelCronExpression": "Cron-uttryck",
"LabelCurrent": "Nuvarande",
"LabelCurrently": "För närvarande:",
"LabelCustomCronExpression": "Anpassat Cron-uttryck:",
"LabelDatetime": "Datum och klockslag",
"LabelDays": "Dagar",
"LabelDatetime": "Datum och tid",
"LabelDeleteFromFileSystemCheckbox": "Ta bort från filsystem (avmarkera för att endast ta bort från databasen)",
"LabelDescription": "Beskrivning",
"LabelDeselectAll": "Avmarkera alla",
@@ -279,126 +252,103 @@
"LabelDiscover": "Upptäck",
"LabelDownload": "Ladda ner",
"LabelDownloadNEpisodes": "Ladda ner {0} avsnitt",
"LabelDownloadable": "Nedladdningsbar",
"LabelDuration": "Varaktighet",
"LabelDurationComparisonExactMatch": "(exakt matchning)",
"LabelDurationFound": "Varaktighet hittad:",
"LabelEbook": "E-bok",
"LabelEbooks": "E-böcker",
"LabelEbooks": "Eböcker",
"LabelEdit": "Redigera",
"LabelEmail": "E-postadress",
"LabelEmailSettingsFromAddress": "Från e-postadress",
"LabelEmailSettingsRejectUnauthorized": "Avvisa icke-autentiserade certifikat",
"LabelEmailSettingsRejectUnauthorizedHelp": "Inaktivering av SSL-certifikatsvalidering kan exponera din anslutning för säkerhetsrisker, såsom man-in-the-middle-attacker. Inaktivera bara denna inställning om du förstår implikationerna och litar på den epostserver du ansluter till.",
"LabelEmail": "E-post",
"LabelEmailSettingsFromAddress": "Från adress",
"LabelEmailSettingsSecure": "Säker",
"LabelEmailSettingsSecureHelp": "Om sant kommer anslutningen att använda TLS vid anslutning till servern. Om falskt används TLS om servern stöder STARTTLS-tillägget. I de flesta fall, om du ansluter till port 465, bör du ställa in detta värde till sant. För port 587 eller 25, låt det vara falskt. (från nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "E-postadress för test",
"LabelEmbeddedCover": "Inbäddat bokomslag",
"LabelEmailSettingsTestAddress": "Testadress",
"LabelEmbeddedCover": "Inbäddat omslag",
"LabelEnable": "Aktivera",
"LabelEncodingBackupLocation": "En säkerhetskopia av dina orginalljudfiler kommer att lagras i:",
"LabelEnd": "Slut",
"LabelEndOfChapter": "Slut av kapitel",
"LabelEpisode": "Avsnitt",
"LabelEpisodeTitle": "Avsnittsrubrik",
"LabelEpisodeType": "Avsnittstyp",
"LabelExample": "Exempel",
"LabelExpandSeries": "Expandera serier",
"LabelFeedURL": "Flödes-URL",
"LabelFetchingMetadata": "Hämtar metadata",
"LabelFile": "Fil",
"LabelFileBirthtime": "Tidpunkt, filen skapades",
"LabelFileModified": "Tidpunkt, filen ändrades",
"LabelFileModifiedDate": "Ändrad {0}",
"LabelFileBirthtime": "Födelse-tidpunkt för fil",
"LabelFileModified": "Fil ändrad",
"LabelFilename": "Filnamn",
"LabelFilterByUser": "Välj användare",
"LabelFilterByUser": "Filtrera efter användare",
"LabelFindEpisodes": "Hitta avsnitt",
"LabelFinished": "Avslutad",
"LabelFolder": "Mapp",
"LabelFolders": "Mappar",
"LabelFontBold": "Fetstil",
"LabelFontBoldness": "Fetstil",
"LabelFontFamily": "Typsnittsfamilj",
"LabelFontItalic": "Kursiverad",
"LabelFontScale": "Skala på typsnitt",
"LabelFontStrikethrough": "Genomstruken",
"LabelFontFamily": "Teckensnittsfamilj",
"LabelFontScale": "Teckensnittsskala",
"LabelGenre": "Kategori",
"LabelGenres": "Kategorier",
"LabelHardDeleteFile": "Hård radering av fil",
"LabelHasEbook": "Har e-bok",
"LabelHasSupplementaryEbook": "Har kompletterande e-bok",
"LabelHideSubtitles": "Dölj underrubriker",
"LabelHighestPriority": "Högst prioritet",
"LabelHasEbook": "Har E-bok",
"LabelHasSupplementaryEbook": "Har komplimenterande E-bok",
"LabelHost": "Värd",
"LabelHour": "Timme",
"LabelHours": "Timmar",
"LabelIcon": "Ikon",
"LabelImageURLFromTheWeb": "Skriv URL-adressen till bilden på webben",
"LabelImageURLFromTheWeb": "Bild-URL från webben",
"LabelInProgress": "Pågående",
"LabelIncludeInTracklist": "Inkludera i spårlista",
"LabelIncomplete": "Ofullständig",
"LabelInterval": "Intervall",
"LabelIntervalCustomDailyWeekly": "Anpassad daglig/veckovis",
"LabelIntervalCustomDailyWeekly": "Anpassat dagligt/veckovis",
"LabelIntervalEvery12Hours": "Var 12:e timme",
"LabelIntervalEvery15Minutes": "Var 15:e minut",
"LabelIntervalEvery2Hours": "Varannan timme",
"LabelIntervalEvery2Hours": "Var 2:e timme",
"LabelIntervalEvery30Minutes": "Var 30:e minut",
"LabelIntervalEvery6Hours": "Var 6:e timme",
"LabelIntervalEveryDay": "Varje dag",
"LabelIntervalEveryHour": "Varje timme",
"LabelInvert": "Invertera",
"LabelItem": "Objekt",
"LabelJumpBackwardAmount": "Inställning för \"hopp bakåt\"",
"LabelJumpForwardAmount": "Inställning för \"hopp framåt\"",
"LabelLanguage": "Språk",
"LabelLanguageDefaultServer": "Standardspråk för server",
"LabelLanguages": "Språk",
"LabelLastBookAdded": "Bok senast tillagd",
"LabelLastBookUpdated": "Bok senast uppdaterad",
"LabelLastSeen": "Senast inloggad",
"LabelLastTime": "Senaste tillfället",
"LabelLastUpdate": "Senast uppdaterad",
"LabelLastBookAdded": "Senaste bok tillagd",
"LabelLastBookUpdated": "Senaste bok uppdaterad",
"LabelLastSeen": "Senast sedd",
"LabelLastTime": "Senaste gången",
"LabelLastUpdate": "Senaste uppdatering",
"LabelLayout": "Layout",
"LabelLayoutSinglePage": "En sida",
"LabelLayoutSplitPage": "Uppslag",
"LabelLayoutSplitPage": "Dela sida",
"LabelLess": "Mindre",
"LabelLibrariesAccessibleToUser": "Bibliotek användaren har tillgång till",
"LabelLibrariesAccessibleToUser": "Åtkomliga bibliotek för användare",
"LabelLibrary": "Bibliotek",
"LabelLibraryItem": "Objekt",
"LabelLibraryItem": "Biblioteksobjekt",
"LabelLibraryName": "Biblioteksnamn",
"LabelLimit": "Begränsning",
"LabelLineSpacing": "Radavstånd",
"LabelListenAgain": "Läs/Lyssna igen",
"LabelLogLevelDebug": "Felsökning",
"LabelLogLevelInfo": "Information",
"LabelLogLevelWarn": "Varningar",
"LabelListenAgain": "Lyssna igen",
"LabelLogLevelDebug": "Felsökningsnivå: Felsökning",
"LabelLogLevelInfo": "Felsökningsnivå: Information",
"LabelLogLevelWarn": "Felsökningsnivå: Varning",
"LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum",
"LabelLowestPriority": "Lägst prioritet",
"LabelMediaPlayer": "Mediaspelare",
"LabelMediaType": "Mediatyp",
"LabelMetaTag": "Metamärke",
"LabelMetaTags": "Metamärken",
"LabelMetadataOrderOfPrecedenceDescription": "Källor för metadata med högre prioritet har företräde före källor med lägre prioritet",
"LabelMetadataProvider": "Källa för metadata",
"LabelMinute": "Minut",
"LabelMinutes": "Minuter",
"LabelMissing": "Saknar",
"LabelMissingEbook": "Saknar e-bok",
"LabelMissingSupplementaryEbook": "Saknar kompletterande e-bok",
"LabelMissing": "Saknad",
"LabelMore": "Mer",
"LabelMoreInfo": "Mer information",
"LabelName": "Namn",
"LabelNarrator": "Uppläsare",
"LabelNarrators": "Uppläsare",
"LabelNew": "Nytt",
"LabelNew": "Ny",
"LabelNewPassword": "Nytt lösenord",
"LabelNewestAuthors": "Senast adderade författare",
"LabelNewestAuthors": "Senast tillagda författare",
"LabelNewestEpisodes": "Senast tillagda avsnitt",
"LabelNextBackupDate": "Nästa datum för säkerhetskopiering",
"LabelNextBackupDate": "Nästa datum för säkerhetskopia",
"LabelNextScheduledRun": "Nästa schemalagda körning",
"LabelNoCustomMetadataProviders": "Ingen egen källa för metadata",
"LabelNoEpisodesSelected": "Inga avsnitt valda",
"LabelNotFinished": "Ej avslutad",
"LabelNotStarted": "Ej påbörjad",
"LabelNotStarted": "Inte påbörjad",
"LabelNotes": "Anteckningar",
"LabelNotificationAppriseURL": "Apprise URL(er)",
"LabelNotificationAvailableVariables": "Tillgängliga variabler",
@@ -413,27 +363,22 @@
"LabelNumberOfEpisodes": "Antal avsnitt",
"LabelOpenRSSFeed": "Öppna RSS-flöde",
"LabelOverwrite": "Skriv över",
"LabelPaginationPageXOfY": "Sida {0} av {1}",
"LabelPassword": "Lösenord",
"LabelPath": "Sökväg",
"LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek",
"LabelPermissionsAccessAllTags": "Kan komma åt alla taggar",
"LabelPermissionsAccessExplicitContent": "Kan komma åt explicit innehåll",
"LabelPermissionsCreateEreader": "Kan addera e-läsarenhet",
"LabelPermissionsDelete": "Kan radera",
"LabelPermissionsDownload": "Kan ladda ner",
"LabelPermissionsUpdate": "Kan uppdatera",
"LabelPermissionsUpload": "Kan ladda upp",
"LabelPersonalYearReview": "En sammanställning av ditt år, sidan {0}",
"LabelPhotoPathURL": "Bildsökväg/URL",
"LabelPlayMethod": "Spelläge",
"LabelPlayerChapterNumberMarker": "{0} av {1}",
"LabelPlaylists": "Spellistor",
"LabelPodcast": "Podcast",
"LabelPodcastSearchRegion": "Podcast-sökområde",
"LabelPodcastType": "Podcasttyp",
"LabelPodcasts": "Podcasts",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)",
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer",
"LabelPrimaryEbook": "Primär e-bok",
@@ -441,7 +386,6 @@
"LabelProvider": "Källa",
"LabelPubDate": "Publiceringsdatum",
"LabelPublishYear": "Publiceringsår",
"LabelPublishedDecade": "Årtionde för publicering",
"LabelPublisher": "Utgivare",
"LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post",
"LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn",
@@ -453,22 +397,15 @@
"LabelRead": "Läst",
"LabelReadAgain": "Läs igen",
"LabelReadEbookWithoutProgress": "Läs e-bok utan att behålla framsteg",
"LabelRecentSeries": "Senaste serierna",
"LabelRecentlyAdded": "Nyligen tillagda",
"LabelRecentSeries": "Senaste serier",
"LabelRecentlyAdded": "Nyligen tillagd",
"LabelRecommended": "Rekommenderad",
"LabelRegion": "Region",
"LabelReleaseDate": "Utgivningsdatum",
"LabelRemoveAllMetadataAbs": "Radera alla 'metadata.abs' filer",
"LabelRemoveAllMetadataJson": "Radera alla 'metadata.json' filer",
"LabelRemoveCover": "Ta bort bokomslag",
"LabelRemoveMetadataFile": "Radera metadata-filer i alla mappar i biblioteket",
"LabelRemoveMetadataFileHelp": "Radera alla 'metadata.json' och 'metadata.abs' filer i dina {0} mappar.",
"LabelRowsPerPage": "Antal rader per sida",
"LabelSearchTerm": "Sökbegrepp",
"LabelSearchTitle": "Titel",
"LabelRemoveCover": "Ta bort omslag",
"LabelSearchTerm": "Sökterm",
"LabelSearchTitle": "Sök titel",
"LabelSearchTitleOrASIN": "Sök titel eller ASIN-kod",
"LabelSeason": "Säsong",
"LabelSelectAll": "Välj alla",
"LabelSelectAllEpisodes": "Välj alla avsnitt",
"LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas",
"LabelSelectUsers": "Välj användare",
@@ -476,77 +413,62 @@
"LabelSequence": "Sekvens",
"LabelSeries": "Serier",
"LabelSeriesName": "Serienamn",
"LabelSeriesProgress": "Status för serier",
"LabelServerLogLevel": "Nivå på loggning",
"LabelServerYearReview": "En sammanställning av ditt bibliotek, sidan {0}",
"LabelSetEbookAsPrimary": "Ange som primär fil",
"LabelSeriesProgress": "Serieframsteg",
"LabelSetEbookAsPrimary": "Ange som primär",
"LabelSetEbookAsSupplementary": "Ange som kompletterande",
"LabelSettingsAllowIframe": "Tillåt att Audiobookshelf får visas i en iframe",
"LabelSettingsAudiobooksOnly": "Endast ljudböcker",
"LabelSettingsAudiobooksOnlyHelp": "När detta alternativ aktiveras kommer filer med e-böcker<br>att ignoreras om de inte lagras i en mapp med en ljudbok.<br>I det fallet kommer de att anges som en kompletterande e-bok",
"LabelSettingsBookshelfViewHelp": "Bakgrund med ett utseende liknande en bokhylla i trä",
"LabelSettingsAudiobooksOnlyHelp": "Aktivera detta alternativ kommer att ignorera e-boksfiler om de inte finns inom en ljudboksmapp, i vilket fall de kommer att anges som kompletterande e-böcker",
"LabelSettingsBookshelfViewHelp": "Skeumorfisk design med trähyllor",
"LabelSettingsChromecastSupport": "Stöd för Chromecast",
"LabelSettingsDateFormat": "Datumformat",
"LabelSettingsDisableWatcher": "Inaktivera Watcher",
"LabelSettingsDisableWatcherForLibrary": "Inaktivera bevakning med Watcher för biblioteket",
"LabelSettingsDisableWatcherHelp": "Inaktiverar automatik att addera/uppdatera objekt<br>när ändringar av filer genomförs.<br>OBS: Kräver en omstart av servern",
"LabelSettingsDisableWatcherForLibrary": "Inaktivera mappbevakning för bibliotek",
"LabelSettingsDisableWatcherHelp": "Inaktiverar automatiskt lägga till/uppdatera objekt när filändringar upptäcks. *Kräver omstart av servern",
"LabelSettingsEnableWatcher": "Aktivera Watcher",
"LabelSettingsEnableWatcherForLibrary": "Aktivera bevakning med Watcher för biblioteket",
"LabelSettingsEnableWatcherHelp": "Aktiverar automatik att addera/uppdatera objekt<br>när ändringar av filer genomförs.<br>OBS: Kräver en omstart av servern",
"LabelSettingsEpubsAllowScriptedContent": "Tillåt e-böcker i epubs-format som innehåller script",
"LabelSettingsEpubsAllowScriptedContentHelp": "Tillåt att epub-filer får använda script.<br>Det rekommenderas att denna inställning är<br>avstängd när du inte litar på källan för epub-filerna.",
"LabelSettingsEnableWatcherForLibrary": "Aktivera mappbevakning för bibliotek",
"LabelSettingsEnableWatcherHelp": "Aktiverar automatiskt lägga till/uppdatera objekt när filändringar upptäcks. *Kräver omstart av servern",
"LabelSettingsExperimentalFeatures": "Experimentella funktioner",
"LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.",
"LabelSettingsFindCovers": "Hitta ett bokomslag",
"LabelSettingsFindCoversHelp": "Om din bok inte har ett bokomslag inbäddat i filen eller<br>en fil med bokomslaget i mappen kommer<br>skannern att försöka hitta ett omslag.<br>OBS: Detta kommer att förlänga inläsningstiden",
"LabelSettingsHideSingleBookSeries": "Dölj serier som endast innehåller en bok",
"LabelSettingsHideSingleBookSeriesHelp": "Serier som endast har en bok kommer att<br>döljas från sidan 'Serier' och hyllorna på startsidan.",
"LabelSettingsHomePageBookshelfView": "Använd vy liknande en bokhylla på startsidan",
"LabelSettingsLibraryBookshelfView": "Använd vy liknande en bokhylla i biblioteket",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Procent genomfört är större än",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Återstående tid är mindre än (sekunder)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Markera objekt som avslutade när",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hoppa över tidigare böcker i en serie",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Sektionen 'Fortsätt med serien' på startsidan visar \"nästa bok\" i serien,<br>där åtminstone en bok avslutats, och ingen bok i serien har påbörjats.<br>Om detta alternativ aktiveras kommer efterföljande bok till den<br>avslutade att föreslås - istället för den första ej avslutade boken i serien.",
"LabelSettingsParseSubtitles": "Hämta undertitel från bokens mapp",
"LabelSettingsParseSubtitlesHelp": "Hämtar undertiteln från namnet på mappen där boken lagras.<br>Undertiteln måste vara åtskilda med ett bindestreck ' - '.<br>En mapp med namnet 'Boktitel - Bokens undertitel'<br> får undertiteln \"Bokens undertitel\"",
"LabelSettingsPreferMatchedMetadata": "Prioritera matchad metadata",
"LabelSettingsFindCovers": "Hitta omslag",
"LabelSettingsFindCoversHelp": "Om din ljudbok inte har ett inbäddat omslag eller en omslagsbild i mappen kommer skannern att försöka hitta ett omslag.<br>Observera: Detta kommer att förlänga skannningstiden",
"LabelSettingsHideSingleBookSeries": "Dölj serier med en bok",
"LabelSettingsHideSingleBookSeriesHelp": "Serier som har en enda bok kommer att döljas från seriesidan och hyllsidan på startsidan.",
"LabelSettingsHomePageBookshelfView": "Startsida använd bokhyllvy",
"LabelSettingsLibraryBookshelfView": "Bibliotek använd bokhyllvy",
"LabelSettingsParseSubtitles": "Analysera undertexter",
"LabelSettingsParseSubtitlesHelp": "Extrahera undertitlar från namnet på mappar för ljudböcker.<br>Undertiteln måste vara åtskilda med ett bindestreck \" - \".<br>Mappen \"Boktitel - En undertitel här\" har undertiteln \"En undertitel här\"",
"LabelSettingsPreferMatchedMetadata": "Föredra matchad metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att åsidosätta objektdetaljer vid snabbmatchning. Som standard kommer snabbmatchning endast att fylla i saknade detaljer.",
"LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker som har en ASIN-kod",
"LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker som har en ISBN-kod",
"LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker med ASIN-kod",
"LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker med ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering",
"LabelSettingsSortingIgnorePrefixesHelp": "För prefix som t.ex. \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "Använd kvadratiska bokomslag",
"LabelSettingsSquareBookCoversHelp": "Föredrar att använda kvadratiska bokomslag<br>före standardformatet 1.6:1",
"LabelSettingsStoreCoversWithItem": "Lagra bokomslag med objektet",
"LabelSettingsStoreCoversWithItemHelp": "Som standard lagras bokomslag i mappen '/metadata/items'.<br>Genom att aktivera detta alternativ kommer<br>omslagen att lagra i din biblioteksmapp.<br>Endast en fil med namnet 'cover' kommer att behållas",
"LabelSettingsStoreMetadataWithItem": "Lagra metadata med objektet",
"LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen '/metadata/items'. Genom att aktivera detta alternativ kommer metadatafilerna att lagra i din biblioteksmapp",
"LabelSettingsSquareBookCovers": "Använd fyrkantiga bokomslag",
"LabelSettingsSquareBookCoversHelp": "Föredrar att använda fyrkantiga omslag över standard 1.6:1 bokomslag",
"LabelSettingsStoreCoversWithItem": "Lagra omslag med objekt",
"LabelSettingsStoreCoversWithItemHelp": "Som standard lagras bokomslag i mappen /metadata/items. Genom att aktivera detta alternativ kommer omslagen att lagra i din biblioteksmapp. Endast en fil med namnet \"cover\" kommer att behållas",
"LabelSettingsStoreMetadataWithItem": "Lagra metadata med objekt",
"LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen /metadata/items. Genom att aktivera detta alternativ kommer metadatafilerna att lagras i dina biblioteksmappar",
"LabelSettingsTimeFormat": "Tidsformat",
"LabelShare": "Dela",
"LabelShowAll": "Visa alla",
"LabelShowSeconds": "Visa sekunder",
"LabelShowSubtitles": "Visa underrubriker",
"LabelSize": "Storlek",
"LabelSleepTimer": "Timer för sova",
"LabelSortAscending": "Stigande",
"LabelSortDescending": "Fallande",
"LabelSleepTimer": "Sleeptimer",
"LabelStart": "Starta",
"LabelStartTime": "Starttid",
"LabelStarted": "Startad",
"LabelStartedAt": "Startades",
"LabelStartedAt": "Startad vid",
"LabelStatsAudioTracks": "Ljudspår",
"LabelStatsAuthors": "Författare",
"LabelStatsBestDay": "Bästa dag",
"LabelStatsDailyAverage": "Dagligt genomsnitt",
"LabelStatsDays": "Dagar",
"LabelStatsDaysListened": "dagars lyssnande",
"LabelStatsDaysListened": "Dagar lyssnade",
"LabelStatsHours": "Timmar",
"LabelStatsInARow": "i rad",
"LabelStatsItemsFinished": "böcker avslutade",
"LabelStatsItemsFinished": "Objekt avslutade",
"LabelStatsItemsInLibrary": "Objekt i biblioteket",
"LabelStatsMinutes": "minuter",
"LabelStatsMinutesListening": "minuters lyssnande",
"LabelStatsMinutesListening": "Minuter av lyssnande",
"LabelStatsOverallDays": "Totalt antal dagar",
"LabelStatsOverallHours": "Totalt antal timmar",
"LabelStatsWeekListening": "Veckans lyssnande",
@@ -554,12 +476,10 @@
"LabelSupportedFileTypes": "Filtyper som accepteras",
"LabelTag": "Tagg",
"LabelTags": "Taggar",
"LabelTagsAccessibleToUser": "Taggar användaren har tillgång till",
"LabelTagsAccessibleToUser": "Taggar tillgängliga för användaren",
"LabelTagsNotAccessibleToUser": "Taggar inte tillgängliga för användaren",
"LabelTasks": "Körande uppgifter",
"LabelTextEditorBulletedList": "Punktlista",
"LabelTextEditorNumberedList": "Numrerad lista",
"LabelTheme": "Utseende",
"LabelTheme": "Tema",
"LabelThemeDark": "Mörkt",
"LabelThemeLight": "Ljust",
"LabelTimeBase": "Tidsbas",
@@ -576,7 +496,7 @@
"LabelToolsEmbedMetadata": "Bädda in metadata",
"LabelToolsEmbedMetadataDescription": "Bädda in metadata i ljudfiler, inklusive omslagsbild och kapitel.",
"LabelToolsMakeM4b": "Skapa M4B ljudbok",
"LabelToolsMakeM4bDescription": "Skapa en ljudboksfil i M4B-format med inbäddad metadata, omslagsbild och kapitel.",
"LabelToolsMakeM4bDescription": "Skapa en .M4B ljudboksfil med inbäddad metadata, omslagsbild och kapitel.",
"LabelToolsSplitM4b": "Dela upp M4B-fil i MP3-filer",
"LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B-fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.",
"LabelTotalDuration": "Total varaktighet",
@@ -590,100 +510,78 @@
"LabelTrailer": "Trailer",
"LabelType": "Typ",
"LabelUnabridged": "Oavkortad",
"LabelUndo": "Ångra",
"LabelUnknown": "Okänd",
"LabelUpdateCover": "Uppdatera bokomslag",
"LabelUpdateCoverHelp": "Tillåt att befintliga bokomslag för de valda böckerna ersätts när en matchning hittas",
"LabelUpdateCover": "Uppdatera omslag",
"LabelUpdateCoverHelp": "Tillåt överskrivning av befintliga omslag för de valda böckerna när en matchning hittas",
"LabelUpdateDetails": "Uppdatera detaljer",
"LabelUpdateDetailsHelp": "Tillåt att befintliga detaljer för de valda böckerna ersätts när en matchning hittas",
"LabelUpdatedAt": "Uppdaterades",
"LabelUpdateDetailsHelp": "Tillåt överskrivning av befintliga detaljer för de valda böckerna när en matchning hittas",
"LabelUpdatedAt": "Uppdaterad vid",
"LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar",
"LabelUploaderDragAndDropFilesOnly": "Dra & släpp filer",
"LabelUploaderDropFiles": "Släpp filer",
"LabelUploaderItemFetchMetadataHelp": "Hämtar automatiskt titel, författare och serier",
"LabelUseAdvancedOptions": "Använd avancerade inställningar",
"LabelUploaderItemFetchMetadataHelp": "Hämtar automatiskt titel, författare och serier.",
"LabelUseChapterTrack": "Använd kapitelspår",
"LabelUseFullTrack": "Använd hela spåret",
"LabelUseZeroForUnlimited": "0 = Obegränsad",
"LabelUser": "Användare",
"LabelUsername": "Användarnamn",
"LabelValue": "Värde",
"LabelVersion": "Version",
"LabelViewBookmarks": "Bokmärken",
"LabelViewBookmarks": "Visa bokmärken",
"LabelViewChapters": "Visa kapitel",
"LabelViewPlayerSettings": "Visa inställningar för uppspelning",
"LabelViewQueue": "Visa spellista",
"LabelVolume": "Volym",
"LabelWeekdaysToRun": "Veckodagar att köra skanning",
"LabelXBooks": "{0} böcker",
"LabelXItems": "{0} objekt",
"LabelYearReviewHide": "Dölj årets sammanställning",
"LabelYearReviewShow": "Visa årets sammanställning",
"LabelWeekdaysToRun": "Vardagar att köra",
"LabelYearReviewHide": "Dölj sammanställning för året",
"LabelYearReviewShow": "Visa sammanställning för året",
"LabelYourAudiobookDuration": "Din ljudboks varaktighet",
"LabelYourBookmarks": "Dina bokmärken",
"LabelYourPlaylists": "Dina spellistor",
"LabelYourProgress": "Framsteg",
"LabelYourProgress": "Din framsteg",
"MessageAddToPlayerQueue": "Lägg till i spellistan",
"MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> igång eller en API som hanterar dessa begäranden. <br />Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på <code>http://192.168.1.1:8337</code>, bör du ange <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Säkerhetskopior inkluderar användare, användarnas framsteg, biblioteksobjekt, serverinställningar<br>och bilder lagrade i <code>/metadata/items</code> & <code>/metadata/authors</code>.<br>De inkluderar <strong>INTE</strong> några filer lagrade i dina biblioteksmappar.",
"MessageBackupsLocationEditNote": "OBS: När du ändrar plats för säkerhetskopiorna så flyttas INTE gamla säkerhetskopior dit",
"MessageBackupsLocationNoEditNote": "OBS: Platsen där säkerhetskopiorna lagras bestäms av en central inställning och kan inte ändras här.",
"MessageBackupsLocationPathEmpty": "Uppgiften om platsen för lagring av säkerhetskopior kan inte lämnas tom",
"MessageBackupsDescription": "Säkerhetskopieringar inkluderar användare, användares framsteg, biblioteksföremål, serverinställningar och bilder lagrade i <code>/metadata/items</code> & <code>/metadata/authors</code>. Säkerhetskopieringar inkluderar <strong>inte</strong> några filer lagrade i dina biblioteksmappar.",
"MessageBatchQuickMatchDescription": "Quick Match kommer försöka lägga till saknade omslag och metadata för de valda föremålen. Aktivera alternativen nedan för att tillåta Quick Match att överskriva befintliga omslag och/eller metadata.",
"MessageBookshelfNoCollections": "Du har ännu inte skapat några samlingar",
"MessageBookshelfNoRSSFeeds": "Inga RSS-flöden är öppna",
"MessageBookshelfNoResultsForFilter": "Inga resultat för filter \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Sökningen gav inget resultat",
"MessageBookshelfNoSeries": "Du har inga serier",
"MessageChapterEndIsAfter": "Kapitelns slut är efter din ljudboks slut",
"MessageChapterErrorFirstNotZero": "Första kapitlet måste börja vid 0",
"MessageChapterErrorStartGteDuration": "Ogiltig starttid, måste vara mindre än ljudbokens varaktighet",
"MessageChapterErrorStartLtPrev": "Ogiltig starttid, måste vara större än eller lika med föregående kapitlets starttid",
"MessageChapterErrorStartGteDuration": "Ogiltig starttid måste vara mindre än ljudbokens varaktighet",
"MessageChapterErrorStartLtPrev": "Ogiltig starttid måste vara större än eller lika med tidigare kapitels starttid",
"MessageChapterStartIsAfter": "Kapitlets start är efter din ljudboks slut",
"MessageCheckingCron": "Kontrollerar cron...",
"MessageConfirmCloseFeed": "Är du säker på att du vill stänga detta flöde?",
"MessageConfirmDeleteBackup": "Är du säker på att du vill radera säkerhetskopian för {0}?",
"MessageConfirmDeleteDevice": "Är du säkert på att du vill radera enheten för e-böcker \"{0}\"?",
"MessageConfirmDeleteFile": "Detta kommer att radera filen från ditt filsystem. Är du säker?",
"MessageConfirmDeleteLibrary": "Är du säker på att du vill radera biblioteket '{0}'?",
"MessageConfirmDeleteLibraryItem": "Detta kommer att radera biblioteksobjektet från databasen och ditt filsystem. Är du säker?",
"MessageConfirmDeleteLibraryItems": "Detta kommer att radera {0} biblioteksobjekt från databasen och ditt filsystem. Är du säker?",
"MessageConfirmDeleteMetadataProvider": "Är du säker på att du vill radera din egen källa för metadata \"{0}\"?",
"MessageConfirmDeleteSession": "Är du säker på att du vill radera detta lyssningstillfälle?",
"MessageConfirmDeleteLibrary": "Är du säker på att du vill radera biblioteket \"{0}\"?",
"MessageConfirmDeleteLibraryItem": "Detta kommer att radera biblioteksföremålet från databasen och ditt filsystem. Är du säker?",
"MessageConfirmDeleteLibraryItems": "Detta kommer att radera {0} biblioteksföremål från databasen och ditt filsystem. Är du säker?",
"MessageConfirmDeleteSession": "Är du säker på att du vill radera denna session?",
"MessageConfirmForceReScan": "Är du säker på att du vill tvinga omgenomsökning?",
"MessageConfirmMarkAllEpisodesFinished": "Är du säker på att du vill markera alla avsnitt som avslutade?",
"MessageConfirmMarkAllEpisodesNotFinished": "Är du säker på att du vill markera alla avsnitt som ej avslutade?",
"MessageConfirmMarkItemFinished": "Är du säker på att du vill markera \"{0}\" som avslutad?",
"MessageConfirmMarkItemNotFinished": "Är du säker på att du vill markera \"{0}\" som ej avslutad?",
"MessageConfirmMarkAllEpisodesNotFinished": "Är du säker på att du vill markera alla avsnitt som inte avslutade?",
"MessageConfirmMarkSeriesFinished": "Är du säker på att du vill markera alla böcker i denna serie som avslutade?",
"MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som ej avslutade?",
"MessageConfirmPurgeCache": "När du rensar cashen kommer katalogen <code>/metadata/cache</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
"MessageConfirmPurgeItemsCache": "När du rensar cashen för föremål kommer katalogen <code>/metadata/cache/items</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
"MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?",
"MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra en ny genomsökning för {0} objekt?",
"MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra omgenomsökning för {0} objekt?",
"MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?",
"MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?",
"MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?",
"MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?",
"MessageConfirmRemoveListeningSessions": "Är du säker på att du vill radera {0} lyssningstillfällen?",
"MessageConfirmRemoveMetadataFiles": "Är du säker på att du vill radera 'metadata.{0}' filerna i alla mappar i ditt bibliotek?",
"MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort uppläsaren \"{0}\"?",
"MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?",
"MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på kategorin \"{0}\" till \"{1}\" för alla objekt?",
"MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på kategori \"{0}\" till \"{1}\" för alla objekt?",
"MessageConfirmRenameGenreMergeNote": "OBS: Den här kategorin finns redan, så de kommer att slås samman.",
"MessageConfirmRenameGenreWarning": "Varning! En liknande kategori med annat skrivsätt finns redan \"{0}\".",
"MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?",
"MessageConfirmRenameTagMergeNote": "OBS: Den här taggen finns redan, så de kommer att slås samman.",
"MessageConfirmRenameTagWarning": "VARNING! En liknande tagg med annat skrivsätt finns redan \"{0}\".",
"MessageConfirmResetProgress": "Är du säker på att du vill nollställa ditt framsteg?",
"MessageConfirmSendEbookToDevice": "Är du säker på att du vill skicka {0} e-bok \"{1}\" till enheten \"{2}\"?",
"MessageDaysListenedInTheLastYear": "{0} dagars lyssnande det senaste året",
"MessageDownloadingEpisode": "Laddar ner avsnitt",
"MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning",
"MessageEmbedFinished": "Inbäddning genomförd!",
"MessageEmbedFinished": "Inbäddning klar!",
"MessageEpisodesQueuedForDownload": "{0} avsnitt i kö för nedladdning",
"MessageEreaderDevices": "För att säkerställa överföring av e-böcker kan du bli tvungen<br>att addera ovanstående e-postadress som godkänd<br>avsändare för varje enhet angiven nedan.",
"MessageFeedURLWillBe": "Flödes-URL kommer att vara {0}",
"MessageFetching": "Hämtar...",
"MessageForceReScanDescription": "kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.",
@@ -692,26 +590,25 @@
"MessageItemsSelected": "{0} Objekt markerade",
"MessageItemsUpdated": "{0} Objekt uppdaterade",
"MessageJoinUsOn": "Anslut dig till oss på",
"MessageListeningSessionsInTheLastYear": "{0} lyssningssessioner det senaste året",
"MessageLoading": "Laddar...",
"MessageLoadingFolders": "Laddar mappar...",
"MessageLogsDescription": "Filer med loggar sparas i mappen <code>/metadata/logs</code> som JSON-filer.<br>Filer med information om krascher sparas i <code>/metadata/logs/crash_logs.txt</code>.",
"MessageM4BFailed": "M4B misslyckades!",
"MessageM4BFinished": "M4B klar!",
"MessageMapChapterTitles": "Kartlägg kapitelrubriker till dina befintliga ljudbokskapitel utan att justera tidstämplar",
"MessageMarkAllEpisodesFinished": "Markera alla avsnitt som avslutade",
"MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som ej avslutade",
"MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som inte avslutade",
"MessageMarkAsFinished": "Markera som avslutad",
"MessageMarkAsNotFinished": "Markera som ej avslutad",
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från<br>den valda källan och fylla i uppgifter som saknas och bokomslag.<br>Inga befintliga uppgifter kommer att ersättas.",
"MessageMarkAsNotFinished": "Markera som inte avslutad",
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda källan och fylla i uppgifter som saknas och bokomslag. Inga befintliga uppgifter kommer att ersättas.",
"MessageNoAudioTracks": "Inga ljudspår",
"MessageNoAuthors": "Inga författare",
"MessageNoBackups": "Inga säkerhetskopior",
"MessageNoBookmarks": "Inga bokmärken",
"MessageNoChapters": "Inga kapitel",
"MessageNoCollections": "Inga samlingar",
"MessageNoCoversFound": "Inga bokomslag hittades",
"MessageNoCoversFound": "Inga omslag hittade",
"MessageNoDescription": "Ingen beskrivning",
"MessageNoDevices": "Inga enheter angivna",
"MessageNoDownloadsInProgress": "Inga nedladdningar pågår för närvarande",
"MessageNoDownloadsQueued": "Inga nedladdningar i kö",
"MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades",
@@ -721,7 +618,7 @@
"MessageNoIssues": "Inga problem",
"MessageNoItems": "Inga objekt",
"MessageNoItemsFound": "Inga objekt hittades",
"MessageNoListeningSessions": "Inga lyssningstillfällen",
"MessageNoListeningSessions": "Inga lyssningssessioner",
"MessageNoLogs": "Inga loggar",
"MessageNoMediaProgress": "Ingen medieförlopp",
"MessageNoNotifications": "Inga aviseringar",
@@ -738,67 +635,51 @@
"MessagePauseChapter": "Pausa kapiteluppspelning",
"MessagePlayChapter": "Lyssna på kapitlets början",
"MessagePlaylistCreateFromCollection": "Skapa spellista från samling",
"MessagePleaseWait": "Vänta ett ögonblick...",
"MessagePodcastHasNoRSSFeedForMatching": "Podcasten har ingen RSS-flödes-URL att använda för matchning",
"MessageQuickMatchDescription": "Adderar uppgifter som saknas samt en omslagsbild från<br>första träffen i resultatet vid sökningen från '{0}'.<br>Skriver inte över befintliga uppgifter om inte<br>inställningen 'Prioritera matchad metadata' är aktiverad.",
"MessageQuickMatchDescription": "Fyll tomma objektdetaljer och omslag med första matchningsresultat från '{0}'. Överskriver inte detaljer om inte serverinställningen 'Föredra matchad metadata' är aktiverad.",
"MessageRemoveChapter": "Ta bort kapitel",
"MessageRemoveEpisodes": "Ta bort {0} avsnitt",
"MessageRemoveFromPlayerQueue": "Ta bort från spellistan",
"MessageRemoveUserWarning": "Är du säker på att du vill radera användaren \"{0}\"?",
"MessageRemoveUserWarning": "Är du säker på att du vill radera användaren \"{0}\" permanent?",
"MessageReportBugsAndContribute": "Rapportera buggar, begär funktioner och bidra på",
"MessageResetChaptersConfirm": "Är du säker på att du vill återställa alla kapitel och ångra de ändringarna du gjort?",
"MessageRestoreBackupConfirm": "Är du säker på att du vill läsa in säkerhetskopian som skapades den",
"MessageResetChaptersConfirm": "Är du säker på att du vill återställa kapitel och ångra ändringarna du gjort?",
"MessageRestoreBackupConfirm": "Är du säker på att du vill återställa säkerhetskopian som skapades den",
"MessageRestoreBackupWarning": "Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.<br /><br />Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.<br /><br />Alla klienter som använder din server kommer att uppdateras automatiskt.",
"MessageSearchResultsFor": "Sökresultat för",
"MessageSelected": "{0} valda",
"MessageServerCouldNotBeReached": "Servern kunde inte nås",
"MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn",
"MessageStartPlaybackAtTime": "Starta uppspelning av \"{0}\" vid tidpunkt {1}?",
"MessageTaskCanceledByUser": "Uppgiften avslutades av användaren",
"MessageTaskFailed": "Misslyckades",
"MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen",
"MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"",
"MessageStartPlaybackAtTime": "Starta uppspelning för \"{0}\" kl. {1}?",
"MessageThinking": "Tänker...",
"MessageUploaderItemFailed": "Misslyckades med att ladda upp",
"MessageUploaderItemSuccess": "Uppladdning lyckades!",
"MessageUploading": "Laddar upp...",
"MessageValidCronExpression": "Giltigt cron-uttryck",
"MessageWatcherIsDisabledGlobally": "Watcher är inaktiverad centralt under rubriken 'Inställningar'",
"MessageXLibraryIsEmpty": "Biblioteket {0} är tomt!",
"MessageWatcherIsDisabledGlobally": "Vakten är inaktiverad globalt i serverinställningarna",
"MessageXLibraryIsEmpty": "{0} biblioteket är tomt!",
"MessageYourAudiobookDurationIsLonger": "Varaktigheten på din ljudbok är längre än den hittade varaktigheten",
"MessageYourAudiobookDurationIsShorter": "Varaktigheten på din ljudbok är kortare än den hittade varaktigheten",
"NoteChangeRootPassword": "Rotanvändaren är den enda användaren som kan ha ett tomt lösenord",
"NoteChapterEditorTimes": "OBS: Starttiden för första kapitlet måste vara 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens totala varaktighet.",
"NoteChapterEditorTimes": "Obs: Starttiden för första kapitlet måste förbli 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens varaktighet.",
"NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas",
"NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.",
"NoteUploaderFoldersWithMediaFiles": "Mappar med flera mediefiler hanteras som separata objekt i biblioteket.",
"NoteUploaderOnlyAudioFiles": "<br>Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.",
"NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.",
"NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.",
"PlaceholderNewCollection": "Nytt samlingsnamn",
"PlaceholderNewFolderPath": "Nytt sökväg till mappen",
"PlaceholderNewPlaylist": "Nytt namn på spellistan",
"PlaceholderNewFolderPath": "Nytt mappväg",
"PlaceholderNewPlaylist": "Nytt spellistanamn",
"PlaceholderSearch": "Sök...",
"PlaceholderSearchEpisode": "Sök avsnitt...",
"StatsAuthorsAdded": "författare har adderats",
"StatsBooksAdded": "böcker har adderats",
"StatsBooksAdditional": "Några exempel på det som adderats…",
"StatsBooksFinished": "avslutade böcker",
"StatsBooksFinishedThisYear": "Några böcker som avslutats under året…",
"StatsBooksListenedTo": "böcker, lyssnat på",
"StatsCollectionGrewTo": "Ditt biblioteks storlek ökade till…",
"StatsSessions": "lyssningstillfällen",
"StatsSpentListening": "tid, som lyssnats",
"StatsTopAuthor": "Populäraste författare",
"StatsTopAuthors": "Populäraste författarna",
"StatsTopGenre": "Populäraste kategori",
"StatsTopAuthor": "POPULÄRAST FÖRFATTAREN",
"StatsTopAuthors": "POPULÄRASTE FÖRFATTARNA",
"StatsTopGenre": "Populäraste kategorin",
"StatsTopGenres": "Populäraste kategorierna",
"StatsTopMonth": "Bästa månad",
"StatsTopNarrator": "Populäraste uppläsare",
"StatsTopNarrators": "Populäraste uppläsarna",
"StatsTotalDuration": "Med en total varaktighet…",
"StatsYearInReview": "- SAMMANSTÄLLNING AV ÅRET",
"ToastAccountUpdateSuccess": "Kontot har uppdaterats",
"StatsTopMonth": "Bästa månaden",
"StatsTopNarrator": "Populärast uppläsarna",
"StatsTopNarrators": "Populäraste uppläsaren",
"StatsYearInReview": "SAMMANSTÄLLNING AV ÅRET",
"ToastAccountUpdateSuccess": "Kontot uppdaterat",
"ToastAsinRequired": "En ASIN-kod krävs",
"ToastAuthorImageRemoveSuccess": "Författarens bild borttagen",
"ToastAuthorNotFound": "Författaren \"{0}\" kunde inte identifieras",
@@ -821,77 +702,44 @@
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
"ToastBookmarkCreateSuccess": "Bokmärket har adderats",
"ToastBookmarkRemoveSuccess": "Bokmärket har raderats",
"ToastCachePurgeFailed": "Misslyckades med att rensa cachen",
"ToastCachePurgeSuccess": "Rensning av cachen har genomförts",
"ToastBookmarkUpdateSuccess": "Bokmärket har uppdaterats",
"ToastChaptersHaveErrors": "Kapitlen har fel",
"ToastChaptersMustHaveTitles": "Kapitel måste ha titlar",
"ToastCollectionItemsRemoveSuccess": "Objekt borttagna från samlingen",
"ToastCollectionRemoveSuccess": "Samlingen har raderats",
"ToastCollectionUpdateSuccess": "Samlingen har uppdaterats",
"ToastCoverUpdateFailed": "Uppdatering av bokomslag misslyckades",
"ToastDateTimeInvalidOrIncomplete": "Datum och klockslag är felaktigt eller ej komplett",
"ToastDeleteFileFailed": "Misslyckades att radera filen",
"ToastDeleteFileSuccess": "Filen har raderats",
"ToastDeviceTestEmailFailed": "Misslyckades med att skicka ett testmail",
"ToastDeviceTestEmailSuccess": "Ett testmail har skickats",
"ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats",
"ToastFailedToLoadData": "Misslyckades med att ladda data",
"ToastInvalidImageUrl": "Felaktig URL-adress till omslagsbilden",
"ToastInvalidMaxEpisodesToDownload": "Ogiltigt maximalt antal avsnitt att ladda ner",
"ToastInvalidUrl": "Felaktig URL-adress",
"ToastItemCoverUpdateSuccess": "Objektets bokomslag har uppdaterats",
"ToastItemDetailsUpdateSuccess": "Detaljerna om boken har uppdaterats",
"ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera den som avslutad",
"ToastItemMarkedAsFinishedSuccess": "Den har markerat som avslutad",
"ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera den som ej avslutad",
"ToastItemMarkedAsNotFinishedSuccess": "Den har markerats som ej avslutad",
"ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat",
"ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade",
"ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera som färdig",
"ToastItemMarkedAsFinishedSuccess": "Objekt markerat som färdig",
"ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera som ej färdig",
"ToastItemMarkedAsNotFinishedSuccess": "Objekt markerat som ej färdig",
"ToastLibraryCreateFailed": "Det gick inte att skapa biblioteket",
"ToastLibraryCreateSuccess": "Biblioteket \"{0}\" har skapats",
"ToastLibraryCreateSuccess": "Biblioteket \"{0}\" skapat",
"ToastLibraryDeleteFailed": "Det gick inte att ta bort biblioteket",
"ToastLibraryDeleteSuccess": "Biblioteket borttaget",
"ToastLibraryScanFailedToStart": "Misslyckades med att starta skanningen",
"ToastLibraryScanStarted": "Skanning av biblioteket påbörjad",
"ToastLibraryUpdateSuccess": "Biblioteket \"{0}\" har uppdaterats",
"ToastMetadataFilesRemovedError": "Misslyckades med att radera 'metadata.{0}' filerna",
"ToastMetadataFilesRemovedNoneFound": "Inga 'metadata.{0}' filer hittades i biblioteket",
"ToastMetadataFilesRemovedNoneRemoved": "Inga 'metadata.{0}' filer raderades",
"ToastMetadataFilesRemovedSuccess": "{0} 'metadata.{1}' raderades",
"ToastNameEmailRequired": "Ett namn och en e-postadress måste anges",
"ToastNameRequired": "Ett namn måste anges",
"ToastNewUserCreatedFailed": "Misslyckades med att skapa kontot \"{0}\"",
"ToastNewUserCreatedSuccess": "Ett nytt konto har skapats",
"ToastNoUpdatesNecessary": "Inga uppdateringar var nödvändiga",
"ToastLibraryUpdateSuccess": "Biblioteket \"{0}\" uppdaterat",
"ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan",
"ToastPlaylistCreateSuccess": "Spellistan skapad",
"ToastPlaylistRemoveSuccess": "Spellistan har tagits bort",
"ToastPlaylistRemoveSuccess": "Spellistan borttagen",
"ToastPlaylistUpdateSuccess": "Spellistan uppdaterad",
"ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten",
"ToastPodcastCreateSuccess": "Podcasten skapad framgångsrikt",
"ToastProviderCreatedFailed": "Misslyckades med att addera en källa",
"ToastProviderCreatedSuccess": "En ny källa har adderats",
"ToastProviderRemoveSuccess": "Källan har tagits bort",
"ToastRSSFeedCloseFailed": "Misslyckades med att stänga RSS-flödet",
"ToastRSSFeedCloseSuccess": "RSS-flödet stängt",
"ToastRemoveItemFromCollectionFailed": "Misslyckades med att ta bort objektet från samlingen",
"ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen",
"ToastSelectAtLeastOneUser": "Åtminstone en användare måste väljas",
"ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten",
"ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"",
"ToastSeriesUpdateFailed": "Uppdateringen av serier misslyckades",
"ToastSeriesUpdateSuccess": "Uppdateringen av serierna lyckades",
"ToastServerSettingsUpdateSuccess": "Inställningarna för servern har uppdaterats",
"ToastSessionDeleteFailed": "Misslyckades med att ta bort sessionen",
"ToastSessionDeleteSuccess": "Sessionen borttagen",
"ToastSleepTimerDone": "Timer har stängt av lyssning. Sov gott... zZzzZz",
"ToastSocketConnected": "Socket ansluten",
"ToastSocketDisconnected": "Socket frånkopplad",
"ToastSocketFailedToConnect": "Socket misslyckades med att ansluta",
"ToastSortingPrefixesEmptyError": "Åtminstone ett sorteringsbegrepp måste anges",
"ToastSortingPrefixesUpdateSuccess": "{0} begrepp för sortering har uppdateras",
"ToastTitleRequired": "En titel måste anges",
"ToastUnknownError": "Ett okänt fel inträffade",
"ToastUserDeleteFailed": "Misslyckades med att ta bort användaren",
"ToastUserDeleteSuccess": "Användaren borttagen",
"ToastUserPasswordChangeSuccess": "Lösenordet har ändrats",
"ToastUserPasswordMismatch": "Lösenorden är inte identiska",
"ToastUserPasswordMustChange": "Det nya lösenordet kan inte vara samma som det gamla"
"ToastUserDeleteSuccess": "Användaren borttagen"
}

View File

@@ -51,7 +51,7 @@
"ButtonNext": "Наступний",
"ButtonNextChapter": "Наступна глава",
"ButtonNextItemInQueue": "Наступний елемент у черзі",
"ButtonOk": "Добре",
"ButtonOk": "Гаразд",
"ButtonOpenFeed": "Відкрити стрічку",
"ButtonOpenManager": "Відкрити менеджер",
"ButtonPause": "Пауза",
@@ -300,7 +300,6 @@
"LabelDiscover": "Огляд",
"LabelDownload": "Завантажити",
"LabelDownloadNEpisodes": "Завантажити епізодів: {0}",
"LabelDownloadable": "Можна завантажити",
"LabelDuration": "Тривалість",
"LabelDurationComparisonExactMatch": "(повний збіг)",
"LabelDurationComparisonLonger": "(на {0} довше)",
@@ -463,7 +462,7 @@
"LabelNotificationsMaxQueueSize": "Ліміт розміру черги сповіщень",
"LabelNotificationsMaxQueueSizeHelp": "Події обмежені до 1 на секунду. Події буде проігноровано, якщо ліміт черги досягнуто. Це запобігає спаму сповіщеннями.",
"LabelNumberOfBooks": "Кількість книг",
"LabelNumberOfEpisodes": "Кількість серій",
"LabelNumberOfEpisodes": "Кількість епізодів",
"LabelOpenIDAdvancedPermsClaimDescription": "Назва OpenID claim, що містить розширені дозволи на дії користувачів у додатку, які будуть застосовуватися до ролей, що не є адміністраторами (<b>якщо налаштовано</b>). Якщо у відповіді нема claim, у доступі до Audiobookshelf буде відмовлено. Якщо відсутня хоча б одна опція, відповідь буде вважатися <code>хибною</code>. Переконайтеся, що запит постачальника ідентифікаційних даних відповідає очікуваній структурі:",
"LabelOpenIDClaims": "Не змінюйте наступні параметри, аби вимкнути розширене призначення груп і дозволів, автоматично призначаючи групу 'Користувач'.",
"LabelOpenIDGroupClaimDescription": "Ім'я OpenID claim, що містить список груп користувачів. Зазвичай їх називають <code>групами</code>. <b>Якщо налаштовано</b>, застосунок автоматично призначатиме ролі на основі членства користувача в групах, за умови, що ці групи названі в claim'і без урахування реєстру 'admin', 'user' або 'guest'. Claim мусить містити список, і якщо користувач належить до кількох груп, програма призначить йому роль, що відповідає найвищому рівню доступу. Якщо жодна група не збігається, у доступі буде відмовлено.",
@@ -589,7 +588,6 @@
"LabelSettingsStoreMetadataWithItemHelp": "За замовчуванням файли метаданих зберігаються у /metadata/items. Цей параметр увімкне збереження метаданих у теці елемента бібліотеки",
"LabelSettingsTimeFormat": "Формат часу",
"LabelShare": "Поділитися",
"LabelShareDownloadableHelp": "Дозволяє користувачам із посиланням для спільного доступу завантажувати zip-файл елемента бібліотеки.",
"LabelShareOpen": "Поділитися відкрито",
"LabelShareURL": "Поділитися URL",
"LabelShowAll": "Показати все",
@@ -758,7 +756,6 @@
"MessageConfirmResetProgress": "Ви впевнені, що хочете скинути свій прогрес?",
"MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?",
"MessageConfirmUnlinkOpenId": "Ви впевнені, що хочете відв'язати цього користувача від OpenID?",
"MessageDaysListenedInTheLastYear": "{0} днів, прослуханих за останній рік",
"MessageDownloadingEpisode": "Завантаження епізоду",
"MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку",
"MessageEmbedFailed": "Не вдалося вбудувати!",
@@ -774,6 +771,7 @@
"MessageItemsSelected": "Обрано елементів: {0}",
"MessageItemsUpdated": "Оновлено елементів: {0}",
"MessageJoinUsOn": "Приєднуйтесь до",
"MessageListeningSessionsInTheLastYear": "Сесій прослуховування минулого року: {0}",
"MessageLoading": "Завантаження...",
"MessageLoadingFolders": "Завантаження тек...",
"MessageLogsDescription": "Журнали зберігаються у <code>/metadata/logs</code> як JSON-файли. Журнали збоїв зберігаються у <code>/metadata/logs/crash_logs.txt</code>.",
@@ -837,7 +835,6 @@
"MessageResetChaptersConfirm": "Ви дійсно бажаєте скинути глави та скасувати внесені зміни?",
"MessageRestoreBackupConfirm": "Ви дійсно бажаєте відновити резервну копію від",
"MessageRestoreBackupWarning": "Відновлення резервної копії перезапише всю базу даних, розташовану в /config, і зображення обкладинок в /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють жодних файлів у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються..<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.",
"MessageScheduleLibraryScanNote": "Для більшості користувачів рекомендується залишити цю функцію вимкненою та залишити параметр перегляду папок увімкненим. Засіб спостереження за папками автоматично виявить зміни в папках вашої бібліотеки. Засіб спостереження за папками не працює для кожної файлової системи (наприклад, NFS), тому замість нього можна використовувати сканування бібліотек за розкладом.",
"MessageSearchResultsFor": "Результати пошуку для",
"MessageSelected": "Вибрано: {0}",
"MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера",
@@ -954,6 +951,7 @@
"ToastBookmarkCreateFailed": "Не вдалося створити закладку",
"ToastBookmarkCreateSuccess": "Закладку додано",
"ToastBookmarkRemoveSuccess": "Закладку видалено",
"ToastBookmarkUpdateSuccess": "Закладку оновлено",
"ToastCachePurgeFailed": "Не вдалося очистити кеш",
"ToastCachePurgeSuccess": "Кеш очищено",
"ToastChaptersHaveErrors": "Глави містять помилки",
@@ -961,10 +959,11 @@
"ToastChaptersRemoved": "Розділи видалені",
"ToastChaptersUpdated": "Розділи оновлені",
"ToastCollectionItemsAddFailed": "Не вдалося додати елемент(и) до колекції",
"ToastCollectionItemsAddSuccess": "Елемент(и) успішно додано до колекції",
"ToastCollectionItemsRemoveSuccess": "Елемент(и) видалено з добірки",
"ToastCollectionRemoveSuccess": "Добірку видалено",
"ToastCollectionUpdateSuccess": "Добірку оновлено",
"ToastCoverUpdateFailed": "Не вдалося оновити обкладинку",
"ToastDateTimeInvalidOrIncomplete": "Дата й час недійсні або неповні",
"ToastDeleteFileFailed": "Не вдалося видалити файл",
"ToastDeleteFileSuccess": "Файл видалено",
"ToastDeviceAddFailed": "Не вдалося додати пристрій",
@@ -1017,7 +1016,6 @@
"ToastNewUserTagError": "Потрібно вибрати хоча б один тег",
"ToastNewUserUsernameError": "Введіть ім'я користувача",
"ToastNoNewEpisodesFound": "Нових епізодів не знайдено",
"ToastNoRSSFeed": "Подкаст не має RSS-канал",
"ToastNoUpdatesNecessary": "Оновлення не потрібні",
"ToastNotificationCreateFailed": "Не вдалося створити сповіщення",
"ToastNotificationDeleteFailed": "Не вдалося видалити сповіщення",

View File

@@ -581,6 +581,7 @@
"MessageItemsSelected": "{0} Mục Đã Chọn",
"MessageItemsUpdated": "{0} Mục Đã Cập Nhật",
"MessageJoinUsOn": "Tham gia cùng chúng tôi trên",
"MessageListeningSessionsInTheLastYear": "{0} phiên nghe trong năm qua",
"MessageLoading": "Đang tải...",
"MessageLoadingFolders": "Đang tải các thư mục...",
"MessageM4BFailed": "M4B thất bại!",
@@ -679,8 +680,10 @@
"ToastBookmarkCreateFailed": "Tạo đánh dấu thất bại",
"ToastBookmarkCreateSuccess": "Đã thêm đánh dấu",
"ToastBookmarkRemoveSuccess": "Đánh dấu đã được xóa",
"ToastBookmarkUpdateSuccess": "Đánh dấu đã được cập nhật",
"ToastChaptersHaveErrors": "Các chương có lỗi",
"ToastChaptersMustHaveTitles": "Các chương phải có tiêu đề",
"ToastCollectionItemsRemoveSuccess": "Mục đã được xóa khỏi bộ sưu tập",
"ToastCollectionRemoveSuccess": "Bộ sưu tập đã được xóa",
"ToastCollectionUpdateSuccess": "Bộ sưu tập đã được cập nhật",
"ToastItemCoverUpdateSuccess": "Ảnh bìa mục đã được cập nhật",

View File

@@ -300,7 +300,6 @@
"LabelDiscover": "发现",
"LabelDownload": "下载",
"LabelDownloadNEpisodes": "下载 {0} 集",
"LabelDownloadable": "可下载",
"LabelDuration": "持续时间",
"LabelDurationComparisonExactMatch": "(完全匹配)",
"LabelDurationComparisonLonger": "({0} 更长)",
@@ -589,7 +588,6 @@
"LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中",
"LabelSettingsTimeFormat": "时间格式",
"LabelShare": "分享",
"LabelShareDownloadableHelp": "允许用户通过共享链接的下载库项目为 zip 文件.",
"LabelShareOpen": "打开分享",
"LabelShareURL": "分享 URL",
"LabelShowAll": "全部显示",
@@ -758,7 +756,6 @@
"MessageConfirmResetProgress": "你确定要重置进度吗?",
"MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?",
"MessageConfirmUnlinkOpenId": "你确定要取消该用户与 OpenID 的链接吗?",
"MessageDaysListenedInTheLastYear": "去年收听了 {0} 天",
"MessageDownloadingEpisode": "正在下载剧集",
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
"MessageEmbedFailed": "嵌入失败!",
@@ -774,6 +771,7 @@
"MessageItemsSelected": "已选定 {0} 个项目",
"MessageItemsUpdated": "已更新 {0} 个项目",
"MessageJoinUsOn": "加入我们",
"MessageListeningSessionsInTheLastYear": "去年收听 {0} 个会话",
"MessageLoading": "正在加载...",
"MessageLoadingFolders": "加载文件夹...",
"MessageLogsDescription": "日志以 JSON 文件形式存储在 <code>/metadata/logs</code> 目录中. 崩溃日志存储在 <code>/metadata/logs/crash_logs.txt</code> 目录中.",
@@ -837,7 +835,6 @@
"MessageResetChaptersConfirm": "你确定要重置章节并撤消你所做的更改吗?",
"MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份",
"MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果你已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.",
"MessageScheduleLibraryScanNote": "对于大多数用户, 建议禁用此功能并保持文件夹监视程序设置启用. 文件夹监视程序将自动检测库文件夹中的更改. 文件夹监视程序不适用于每个文件系统 (如 NFS), 因此可以使用计划库扫描.",
"MessageSearchResultsFor": "搜索结果",
"MessageSelected": "{0} 已选择",
"MessageServerCouldNotBeReached": "无法访问服务器",
@@ -954,6 +951,7 @@
"ToastBookmarkCreateFailed": "创建书签失败",
"ToastBookmarkCreateSuccess": "书签已添加",
"ToastBookmarkRemoveSuccess": "书签已删除",
"ToastBookmarkUpdateSuccess": "书签已更新",
"ToastCachePurgeFailed": "清除缓存失败",
"ToastCachePurgeSuccess": "缓存清除成功",
"ToastChaptersHaveErrors": "章节有错误",
@@ -961,10 +959,11 @@
"ToastChaptersRemoved": "已删除章节",
"ToastChaptersUpdated": "章节已更新",
"ToastCollectionItemsAddFailed": "项目添加到收藏夹失败",
"ToastCollectionItemsAddSuccess": "项目添加到收藏夹成功",
"ToastCollectionItemsRemoveSuccess": "项目从收藏夹移除",
"ToastCollectionRemoveSuccess": "收藏夹已删除",
"ToastCollectionUpdateSuccess": "收藏夹已更新",
"ToastCoverUpdateFailed": "封面更新失败",
"ToastDateTimeInvalidOrIncomplete": "日期和时间无效或不完整",
"ToastDeleteFileFailed": "删除文件失败",
"ToastDeleteFileSuccess": "文件已删除",
"ToastDeviceAddFailed": "添加设备失败",
@@ -1017,7 +1016,6 @@
"ToastNewUserTagError": "必须至少选择一个标签",
"ToastNewUserUsernameError": "输入用户名",
"ToastNoNewEpisodesFound": "没有找到新剧集",
"ToastNoRSSFeed": "播客没有 RSS 订阅",
"ToastNoUpdatesNecessary": "无需更新",
"ToastNotificationCreateFailed": "无法创建通知",
"ToastNotificationDeleteFailed": "删除通知失败",

View File

@@ -625,6 +625,7 @@
"MessageItemsSelected": "已選定 {0} 個項目",
"MessageItemsUpdated": "已更新 {0} 個項目",
"MessageJoinUsOn": "加入我們",
"MessageListeningSessionsInTheLastYear": "去年收聽 {0} 個會話",
"MessageLoading": "讀取...",
"MessageLoadingFolders": "讀取資料夾...",
"MessageM4BFailed": "M4B 失敗!",
@@ -723,8 +724,10 @@
"ToastBookmarkCreateFailed": "創建書簽失敗",
"ToastBookmarkCreateSuccess": "書籤已新增",
"ToastBookmarkRemoveSuccess": "書籤已刪除",
"ToastBookmarkUpdateSuccess": "書籤已更新",
"ToastChaptersHaveErrors": "章節有錯誤",
"ToastChaptersMustHaveTitles": "章節必須有標題",
"ToastCollectionItemsRemoveSuccess": "項目從收藏夾移除",
"ToastCollectionRemoveSuccess": "收藏夾已刪除",
"ToastCollectionUpdateSuccess": "收藏夾已更新",
"ToastItemCoverUpdateSuccess": "項目封面已更新",

View File

@@ -1,18 +1,3 @@
const optionDefinitions = [
{ name: 'config', alias: 'c', type: String },
{ name: 'metadata', alias: 'm', type: String },
{ name: 'port', alias: 'p', type: String },
{ name: 'host', alias: 'h', type: String },
{ name: 'source', alias: 's', type: String },
{ name: 'dev', alias: 'd', type: Boolean }
]
const commandLineArgs = require('./server/libs/commandLineArgs')
const options = commandLineArgs(optionDefinitions)
const Path = require('path')
process.env.NODE_ENV = options.dev ? 'development' : process.env.NODE_ENV || 'production'
const server = require('./server/Server')
global.appRoot = __dirname
@@ -28,23 +13,20 @@ if (isDev) {
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
if (devEnv.AllowPlugins) process.env.ALLOW_PLUGINS = '1'
if (devEnv.DevPluginsPath) process.env.DEV_PLUGINS_PATH = devEnv.DevPluginsPath
process.env.SOURCE = 'local'
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
}
const inputConfig = options.config ? Path.resolve(options.config) : null
const inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
const PORT = process.env.PORT || 80
const HOST = process.env.HOST
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
const SOURCE = process.env.SOURCE || 'docker'
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || ''
const PORT = options.port || process.env.PORT || 3333
const HOST = options.host || process.env.HOST
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
const SOURCE = options.source || process.env.SOURCE || 'debian'
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || '/audiobookshelf'
console.log(`Running in ${process.env.NODE_ENV} mode.`)
console.log(`Options: CONFIG_PATH=${CONFIG_PATH}, METADATA_PATH=${METADATA_PATH}, PORT=${PORT}, HOST=${HOST}, SOURCE=${SOURCE}, ROUTER_BASE_PATH=${ROUTER_BASE_PATH}`)
console.log('Config', CONFIG_PATH, METADATA_PATH)
const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH)
Server.start()

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.18.0",
"version": "2.17.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.18.0",
"version": "2.17.6",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
@@ -30,7 +30,7 @@
"xml2js": "^0.5.0"
},
"bin": {
"audiobookshelf": "index.js"
"audiobookshelf": "prod.js"
},
"devDependencies": {
"chai": "^4.3.10",

View File

@@ -1,14 +1,14 @@
{
"name": "audiobookshelf",
"version": "2.18.0",
"version": "2.17.6",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {
"dev": "nodemon --watch server index.js -- --dev",
"dev": "nodemon --watch server index.js",
"start": "node index.js",
"client": "cd client && npm ci && npm run generate",
"prod": "npm run client && npm ci && node index.js",
"prod": "npm run client && npm ci && node prod.js",
"build-win": "npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
"build-linux": "build/linuxpackager",
"docker": "docker buildx build --platform linux/amd64,linux/arm64 --push . -t advplyr/audiobookshelf",
@@ -18,7 +18,7 @@
"test": "mocha",
"coverage": "nyc mocha"
},
"bin": "index.js",
"bin": "prod.js",
"pkg": {
"assets": [
"client/dist/**/*",
@@ -26,7 +26,7 @@
"server/migrations/*.js"
],
"scripts": [
"index.js",
"prod.js",
"server/**/*.js"
]
},

View File

@@ -16,8 +16,8 @@ const server = require('./server/Server')
global.appRoot = __dirname
var inputConfig = options.config ? Path.resolve(options.config) : null
var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
const inputConfig = options.config ? Path.resolve(options.config) : null
const inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
const PORT = options.port || process.env.PORT || 3333
const HOST = options.host || process.env.HOST
@@ -25,7 +25,7 @@ const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('conf
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
const SOURCE = options.source || process.env.SOURCE || 'debian'
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || '/audiobookshelf'
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || ''
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH)

View File

@@ -111,8 +111,8 @@ server {
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

View File

@@ -16,6 +16,8 @@ const Logger = require('./Logger')
*/
class Auth {
constructor() {
this.pluginManifests = []
// Map of openId sessions indexed by oauth2 state-variable
this.openIdAuthSession = new Map()
this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/]
@@ -933,11 +935,28 @@ class Auth {
*/
async getUserLoginResponsePayload(user) {
const libraryIds = await Database.libraryModel.getAllLibraryIds()
let plugins = undefined
if (process.env.ALLOW_PLUGINS === '1') {
// TODO: Should be better handled by the PluginManager
// restrict plugin extensions that are not allowed for the user type
plugins = this.pluginManifests.map((manifest) => {
const manifestExtensions = (manifest.extensions || []).filter((ext) => {
if (ext.restrictToAccountTypes?.length) {
return ext.restrictToAccountTypes.includes(user.type)
}
return true
})
return { ...manifest, extensions: manifestExtensions }
})
}
return {
user: user.toOldJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
serverSettings: Database.serverSettings.toJSONForBrowser(),
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
plugins,
Source: global.Source
}
}

View File

@@ -152,6 +152,11 @@ class Database {
return this.models.device
}
/** @type {typeof import('./models/Plugin')} */
get pluginModel() {
return this.models.plugin
}
/**
* Check if db file exists
* @returns {boolean}
@@ -305,6 +310,7 @@ class Database {
require('./models/Setting').init(this.sequelize)
require('./models/CustomMetadataProvider').init(this.sequelize)
require('./models/MediaItemShare').init(this.sequelize)
require('./models/Plugin').init(this.sequelize)
return this.sequelize.sync({ force, alter: false })
}
@@ -401,6 +407,60 @@ class Database {
return this.models.setting.updateSettingObj(settings.toJSON())
}
updateBulkBooks(oldBooks) {
if (!this.sequelize) return false
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
}
createBulkCollectionBooks(collectionBooks) {
if (!this.sequelize) return false
return this.models.collectionBook.bulkCreate(collectionBooks)
}
createPlaylistMediaItem(playlistMediaItem) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.create(playlistMediaItem)
}
createBulkPlaylistMediaItems(playlistMediaItems) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
}
async createLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false
await oldLibraryItem.saveMetadata()
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
}
/**
* Save metadata file and update library item
*
* @param {import('./objects/LibraryItem')} oldLibraryItem
* @returns {Promise<boolean>}
*/
async updateLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false
await oldLibraryItem.saveMetadata()
const updated = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
// Clear library filter data cache
if (updated) {
delete this.libraryFilterData[oldLibraryItem.libraryId]
}
return updated
}
async createBulkBookAuthors(bookAuthors) {
if (!this.sequelize) return false
await this.models.bookAuthor.bulkCreate(bookAuthors)
}
async removeBulkBookAuthors(authorId = null, bookId = null) {
if (!this.sequelize) return false
if (!authorId && !bookId) return
await this.models.bookAuthor.removeByIds(authorId, bookId)
}
getPlaybackSessions(where = null) {
if (!this.sequelize) return false
return this.models.playbackSession.getOldPlaybackSessions(where)
@@ -626,7 +686,7 @@ class Database {
/**
* Clean invalid records in database
* Series should have atleast one Book
* Book and Podcast must have an associated LibraryItem (and vice versa)
* Book and Podcast must have an associated LibraryItem
* Remove playback sessions that are 3 seconds or less
*/
async cleanDatabase() {
@@ -656,49 +716,6 @@ class Database {
await book.destroy()
}
// Remove invalid LibraryItem records
const libraryItemsWithNoMedia = await this.libraryItemModel.findAll({
include: [
{
model: this.bookModel,
attributes: ['id']
},
{
model: this.podcastModel,
attributes: ['id']
}
],
where: {
'$book.id$': null,
'$podcast.id$': null
}
})
for (const libraryItem of libraryItemsWithNoMedia) {
Logger.warn(`Found libraryItem "${libraryItem.id}" with no media - removing it`)
await libraryItem.destroy()
}
const playlistMediaItemsWithNoMediaItem = await this.playlistMediaItemModel.findAll({
include: [
{
model: this.bookModel,
attributes: ['id']
},
{
model: this.podcastEpisodeModel,
attributes: ['id']
}
],
where: {
'$book.id$': null,
'$podcastEpisode.id$': null
}
})
for (const playlistMediaItem of playlistMediaItemsWithNoMediaItem) {
Logger.warn(`Found playlistMediaItem with no book or podcastEpisode - removing it`)
await playlistMediaItem.destroy()
}
// Remove empty series
const emptySeries = await this.seriesModel.findAll({
include: {

View File

@@ -6,7 +6,6 @@ const util = require('util')
const fs = require('./libs/fsExtra')
const fileUpload = require('./libs/expressFileupload')
const cookieParser = require('cookie-parser')
const axios = require('axios')
const { version } = require('../package.json')
@@ -29,7 +28,6 @@ const AbMergeManager = require('./managers/AbMergeManager')
const CacheManager = require('./managers/CacheManager')
const BackupManager = require('./managers/BackupManager')
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
const PodcastManager = require('./managers/PodcastManager')
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager')
@@ -37,6 +35,7 @@ const ApiCacheManager = require('./managers/ApiCacheManager')
const BinaryManager = require('./managers/BinaryManager')
const ShareManager = require('./managers/ShareManager')
const LibraryScanner = require('./scanner/LibraryScanner')
const PluginManager = require('./managers/PluginManager')
//Import the main Passport and Express-Session library
const passport = require('passport')
@@ -55,26 +54,7 @@ class Server {
global.XAccel = process.env.USE_X_ACCEL
global.AllowCors = process.env.ALLOW_CORS === '1'
if (process.env.EXP_PROXY_SUPPORT === '1') {
// https://github.com/advplyr/audiobookshelf/pull/3754
Logger.info(`[Server] Experimental Proxy Support Enabled, SSRF Request Filter was Disabled`)
global.DisableSsrfRequestFilter = () => true
axios.defaults.maxRedirects = 0
axios.interceptors.response.use(
(response) => response,
(error) => {
if ([301, 302].includes(error.response?.status)) {
return axios({
...error.config,
url: error.response.headers.location
})
}
return Promise.reject(error)
}
)
} else if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') {
if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') {
Logger.info(`[Server] SSRF Request Filter Disabled`)
global.DisableSsrfRequestFilter = () => true
} else if (process.env.SSRF_REQUEST_FILTER_WHITELIST?.length) {
@@ -85,12 +65,6 @@ class Server {
}
}
if (process.env.PODCAST_DOWNLOAD_TIMEOUT) {
global.PodcastDownloadTimeout = process.env.PODCAST_DOWNLOAD_TIMEOUT
} else {
global.PodcastDownloadTimeout = 30000
}
if (!fs.pathExistsSync(global.ConfigPath)) {
fs.mkdirSync(global.ConfigPath)
}
@@ -105,9 +79,8 @@ class Server {
this.backupManager = new BackupManager()
this.abMergeManager = new AbMergeManager()
this.playbackSessionManager = new PlaybackSessionManager()
this.podcastManager = new PodcastManager()
this.audioMetadataManager = new AudioMetadataMangaer()
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
this.cronManager = new CronManager(this.playbackSessionManager)
this.apiCacheManager = new ApiCacheManager()
this.binaryManager = new BinaryManager()
@@ -187,6 +160,15 @@ class Server {
LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask)
})
}
if (process.env.ALLOW_PLUGINS === '1') {
Logger.info(`[Server] Experimental plugin support enabled`)
// Initialize plugins
await PluginManager.init()
// TODO: Prevents circular dependency for SocketAuthority
this.auth.pluginManifests = PluginManager.pluginManifests
}
}
/**

View File

@@ -44,21 +44,16 @@ class AuthorController {
// Used on author landing page to include library items and items grouped in series
if (include.includes('items')) {
const libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
authorJson.libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
if (include.includes('series')) {
const seriesMap = {}
// Group items into series
libraryItems.forEach((li) => {
if (li.media.series?.length) {
li.media.series.forEach((series) => {
const itemWithSeries = li.toOldJSONMinified()
itemWithSeries.media.metadata.series = {
id: series.id,
name: series.name,
nameIgnorePrefix: series.nameIgnorePrefix,
sequence: series.bookSeries.sequence
}
authorJson.libraryItems.forEach((li) => {
if (li.media.metadata.series) {
li.media.metadata.series.forEach((series) => {
const itemWithSeries = li.toJSONMinified()
itemWithSeries.media.metadata.series = series
if (seriesMap[series.id]) {
seriesMap[series.id].items.push(itemWithSeries)
@@ -81,7 +76,7 @@ class AuthorController {
}
// Minify library items
authorJson.libraryItems = libraryItems.map((li) => li.toOldJSONMinified())
authorJson.libraryItems = authorJson.libraryItems.map((li) => li.toJSONMinified())
}
return res.json(authorJson)
@@ -130,7 +125,7 @@ class AuthorController {
const bookAuthorsToCreate = []
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
const libraryItems = []
const oldLibraryItems = []
allItemsWithAuthor.forEach((libraryItem) => {
// Replace old author with merging author for each book
libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)
@@ -139,22 +134,23 @@ class AuthorController {
name: existingAuthor.name
})
libraryItems.push(libraryItem)
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
oldLibraryItems.push(oldLibraryItem)
bookAuthorsToCreate.push({
bookId: libraryItem.media.id,
authorId: existingAuthor.id
})
})
if (libraryItems.length) {
await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor
for (const libraryItem of libraryItems) {
if (oldLibraryItems.length) {
await Database.removeBulkBookAuthors(req.author.id) // Remove all old BookAuthor
await Database.createBulkBookAuthors(bookAuthorsToCreate) // Create all new BookAuthor
for (const libraryItem of allItemsWithAuthor) {
await libraryItem.saveMetadataFile()
}
SocketAuthority.emitter(
'items_updated',
libraryItems.map((li) => li.toOldJSONExpanded())
oldLibraryItems.map((li) => li.toJSONExpanded())
)
}
@@ -194,7 +190,7 @@ class AuthorController {
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
numBooksForAuthor = allItemsWithAuthor.length
const libraryItems = []
const oldLibraryItems = []
// Update author name on all books
for (const libraryItem of allItemsWithAuthor) {
libraryItem.media.authors = libraryItem.media.authors.map((au) => {
@@ -203,16 +199,16 @@ class AuthorController {
}
return au
})
libraryItems.push(libraryItem)
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
oldLibraryItems.push(oldLibraryItem)
await libraryItem.saveMetadataFile()
}
if (libraryItems.length) {
if (oldLibraryItems.length) {
SocketAuthority.emitter(
'items_updated',
libraryItems.map((li) => li.toOldJSONExpanded())
oldLibraryItems.map((li) => li.toJSONExpanded())
)
}
} else {
@@ -242,18 +238,8 @@ class AuthorController {
await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
// Load library items so that metadata file can be updated
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
allItemsWithAuthor.forEach((libraryItem) => {
libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)
})
await req.author.destroy()
for (const libraryItem of allItemsWithAuthor) {
await libraryItem.saveMetadataFile()
}
SocketAuthority.emitter('author_removed', req.author.toOldJSON())
// Update filter data

View File

@@ -5,17 +5,13 @@ const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const RssFeedManager = require('../managers/RssFeedManager')
const Collection = require('../objects/Collection')
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/Collection')} collection
*
* @typedef {RequestWithUser & RequestEntityObject} CollectionControllerRequest
*/
class CollectionController {
@@ -29,71 +25,36 @@ class CollectionController {
* @param {Response} res
*/
async create(req, res) {
const reqBody = req.body || {}
// Validation
if (!reqBody.name || !reqBody.libraryId) {
const newCollection = new Collection()
req.body.userId = req.user.id
if (!newCollection.setData(req.body)) {
return res.status(400).send('Invalid collection data')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid collection description')
}
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
if (!libraryItemIds.length) {
return res.status(400).send('Invalid collection data. No books')
}
// Load library items
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
where: {
id: libraryItemIds,
libraryId: reqBody.libraryId,
mediaType: 'book'
}
})
if (libraryItems.length !== libraryItemIds.length) {
return res.status(400).send('Invalid collection data. Invalid books')
}
// Create collection record
await Database.collectionModel.createFromOld(newCollection)
/** @type {import('../models/Collection')} */
let newCollection = null
// Get library items in collection
const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection)
const transaction = await Database.sequelize.transaction()
try {
// Create collection
newCollection = await Database.collectionModel.create(
{
libraryId: reqBody.libraryId,
name: reqBody.name,
description: reqBody.description || null
},
{ transaction }
)
// Create collectionBooks
const collectionBookPayloads = libraryItemIds.map((llid, index) => {
const libraryItem = libraryItems.find((li) => li.id === llid)
return {
// Create collectionBook records
let order = 1
const collectionBooksToAdd = []
for (const libraryItemId of newCollection.books) {
const libraryItem = libraryItemsInCollection.find((li) => li.id === libraryItemId)
if (libraryItem) {
collectionBooksToAdd.push({
collectionId: newCollection.id,
bookId: libraryItem.mediaId,
order: index + 1
}
})
await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction })
await transaction.commit()
} catch (error) {
await transaction.rollback()
Logger.error('[CollectionController] create:', error)
return res.status(500).send('Failed to create collection')
bookId: libraryItem.media.id,
order: order++
})
}
}
if (collectionBooksToAdd.length) {
await Database.createBulkCollectionBooks(collectionBooksToAdd)
}
// Load books expanded
newCollection.books = await newCollection.getBooksExpandedWithLibraryItem()
// Note: The old collection model stores expanded libraryItems in the books property
const jsonExpanded = newCollection.toOldJSONExpanded()
const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection)
SocketAuthority.emitter('collection_added', jsonExpanded)
res.json(jsonExpanded)
}
@@ -114,7 +75,7 @@ class CollectionController {
/**
* GET: /api/collections/:id
*
* @param {CollectionControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async findOne(req, res) {
@@ -133,7 +94,7 @@ class CollectionController {
* PATCH: /api/collections/:id
* Update collection
*
* @param {CollectionControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async update(req, res) {
@@ -197,7 +158,7 @@ class CollectionController {
*
* @this {import('../routers/ApiRouter')}
*
* @param {CollectionControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async delete(req, res) {
@@ -217,13 +178,11 @@ class CollectionController {
* Add a single book to a collection
* Req.body { id: <library item id> }
*
* @param {CollectionControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async addBook(req, res) {
const libraryItem = await Database.libraryItemModel.findByPk(req.body.id, {
attributes: ['libraryId', 'mediaId']
})
const libraryItem = await Database.libraryItemModel.getOldById(req.body.id)
if (!libraryItem) {
return res.status(404).send('Book not found')
}
@@ -233,14 +192,14 @@ class CollectionController {
// Check if book is already in collection
const collectionBooks = await req.collection.getCollectionBooks()
if (collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {
if (collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) {
return res.status(400).send('Book already in collection')
}
// Create collectionBook record
await Database.collectionBookModel.create({
collectionId: req.collection.id,
bookId: libraryItem.mediaId,
bookId: libraryItem.media.id,
order: collectionBooks.length + 1
})
const jsonExpanded = await req.collection.getOldJsonExpanded()
@@ -253,13 +212,11 @@ class CollectionController {
* Remove a single book from a collection. Re-order books
* TODO: bookId is actually libraryItemId. Clients need updating to use bookId
*
* @param {CollectionControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async removeBook(req, res) {
const libraryItem = await Database.libraryItemModel.findByPk(req.params.bookId, {
attributes: ['mediaId']
})
const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId)
if (!libraryItem) {
return res.sendStatus(404)
}
@@ -270,7 +227,7 @@ class CollectionController {
})
let jsonExpanded = null
const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.mediaId)
const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.media.id)
if (collectionBookToRemove) {
// Remove collection book record
await collectionBookToRemove.destroy()
@@ -278,7 +235,7 @@ class CollectionController {
// Update order on collection books
let order = 1
for (const collectionBook of collectionBooks) {
if (collectionBook.bookId === libraryItem.mediaId) continue
if (collectionBook.bookId === libraryItem.media.id) continue
if (collectionBook.order !== order) {
await collectionBook.update({
order
@@ -300,31 +257,29 @@ class CollectionController {
* Add multiple books to collection
* Req.body { books: <Array of library item ids> }
*
* @param {CollectionControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async addBatch(req, res) {
// filter out invalid libraryItemIds
const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string')
if (!bookIdsToAdd.length) {
return res.status(400).send('Invalid request body')
return res.status(500).send('Invalid request body')
}
// Get library items associated with ids
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
where: {
id: bookIdsToAdd,
libraryId: req.collection.libraryId,
mediaType: 'book'
id: {
[Sequelize.Op.in]: bookIdsToAdd
}
},
include: {
model: Database.bookModel
}
})
if (!libraryItems.length) {
return res.status(400).send('Invalid request body. No valid books')
}
// Get collection books already in collection
/** @type {import('../models/CollectionBook')[]} */
const collectionBooks = await req.collection.getCollectionBooks()
let order = collectionBooks.length + 1
@@ -333,10 +288,10 @@ class CollectionController {
// Check and set new collection books to add
for (const libraryItem of libraryItems) {
if (!collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {
if (!collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) {
collectionBooksToAdd.push({
collectionId: req.collection.id,
bookId: libraryItem.mediaId,
bookId: libraryItem.media.id,
order: order++
})
hasUpdated = true
@@ -347,8 +302,7 @@ class CollectionController {
let jsonExpanded = null
if (hasUpdated) {
await Database.collectionBookModel.bulkCreate(collectionBooksToAdd)
await Database.createBulkCollectionBooks(collectionBooksToAdd)
jsonExpanded = await req.collection.getOldJsonExpanded()
SocketAuthority.emitter('collection_updated', jsonExpanded)
} else {
@@ -362,7 +316,7 @@ class CollectionController {
* Remove multiple books from collection
* Req.body { books: <Array of library item ids> }
*
* @param {CollectionControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async removeBatch(req, res) {
@@ -375,7 +329,9 @@ class CollectionController {
// Get library items associated with ids
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: bookIdsToRemove
id: {
[Sequelize.Op.in]: bookIdsToRemove
}
},
include: {
model: Database.bookModel
@@ -383,7 +339,6 @@ class CollectionController {
})
// Get collection books already in collection
/** @type {import('../models/CollectionBook')[]} */
const collectionBooks = await req.collection.getCollectionBooks({
order: [['order', 'ASC']]
})

View File

@@ -106,7 +106,7 @@ class EmailController {
return res.sendStatus(403)
}
const libraryItem = await Database.libraryItemModel.getExpandedById(req.body.libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId)
if (!libraryItem) {
return res.status(404).send('Library item not found')
}

View File

@@ -19,6 +19,7 @@ const Scanner = require('../scanner/Scanner')
const Database = require('../Database')
const Watcher = require('../Watcher')
const RssFeedManager = require('../managers/RssFeedManager')
const PodcastManager = require('../managers/PodcastManager')
const libraryFilters = require('../utils/queries/libraryFilters')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
@@ -100,15 +101,6 @@ class LibraryController {
return res.status(400).send(`Invalid request. Settings "${key}" must be a string`)
}
newLibraryPayload.settings[key] = req.body.settings[key]
} else if (key === 'markAsFinishedPercentComplete' || key === 'markAsFinishedTimeRemaining') {
if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {
return res.status(400).send(`Invalid request. Setting "${key}" must be a number`)
} else if (key === 'markAsFinishedPercentComplete' && req.body.settings[key] !== null && (Number(req.body.settings[key]) < 0 || Number(req.body.settings[key]) > 100)) {
return res.status(400).send(`Invalid request. Setting "${key}" must be between 0 and 100`)
} else if (key === 'markAsFinishedTimeRemaining' && req.body.settings[key] !== null && Number(req.body.settings[key]) < 0) {
return res.status(400).send(`Invalid request. Setting "${key}" must be greater than or equal to 0`)
}
newLibraryPayload.settings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])
} else {
if (typeof req.body.settings[key] !== typeof newLibraryPayload.settings[key]) {
return res.status(400).send(`Invalid request. Setting "${key}" must be of type ${typeof newLibraryPayload.settings[key]}`)
@@ -179,34 +171,21 @@ class LibraryController {
* GET: /api/libraries
* Get all libraries
*
* ?include=stats to load library stats - used in android auto to filter out libraries with no audio
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async findAll(req, res) {
let libraries = await Database.libraryModel.getAllWithFolders()
const libraries = await Database.libraryModel.getAllWithFolders()
const librariesAccessible = req.user.permissions?.librariesAccessible || []
if (librariesAccessible.length) {
libraries = libraries.filter((lib) => librariesAccessible.includes(lib.id))
}
libraries = libraries.map((lib) => lib.toOldJSON())
const includeArray = (req.query.include || '').split(',')
if (includeArray.includes('stats')) {
for (const library of libraries) {
if (library.mediaType === 'book') {
library.stats = await libraryItemsBookFilters.getBookLibraryStats(library.id)
} else if (library.mediaType === 'podcast') {
library.stats = await libraryItemsPodcastFilters.getPodcastLibraryStats(library.id)
}
}
return res.json({
libraries: libraries.filter((lib) => librariesAccessible.includes(lib.id)).map((lib) => lib.toOldJSON())
})
}
res.json({
libraries
libraries: libraries.map((lib) => lib.toOldJSON())
})
}
@@ -241,7 +220,7 @@ class LibraryController {
* @param {Response} res
*/
async getEpisodeDownloadQueue(req, res) {
const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id)
const libraryDownloadQueueDetails = PodcastManager.getDownloadQueueDetails(req.library.id)
res.json(libraryDownloadQueueDetails)
}
@@ -334,7 +313,7 @@ class LibraryController {
}
if (req.body.settings[key] !== updatedSettings[key]) {
hasUpdates = true
updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])
updatedSettings[key] = Number(req.body.settings[key])
Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
}
} else if (key === 'markAsFinishedTimeRemaining') {
@@ -347,7 +326,7 @@ class LibraryController {
}
if (req.body.settings[key] !== updatedSettings[key]) {
hasUpdates = true
updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])
updatedSettings[key] = Number(req.body.settings[key])
Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
}
} else {
@@ -1167,14 +1146,14 @@ class LibraryController {
await libraryItem.media.update({
narrators: libraryItem.media.narrators
})
itemsUpdated.push(libraryItem)
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
itemsUpdated.push(oldLibraryItem)
}
if (itemsUpdated.length) {
SocketAuthority.emitter(
'items_updated',
itemsUpdated.map((li) => li.toOldJSONExpanded())
itemsUpdated.map((li) => li.toJSONExpanded())
)
}
@@ -1211,14 +1190,14 @@ class LibraryController {
await libraryItem.media.update({
narrators: libraryItem.media.narrators
})
itemsUpdated.push(libraryItem)
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
itemsUpdated.push(oldLibraryItem)
}
if (itemsUpdated.length) {
SocketAuthority.emitter(
'items_updated',
itemsUpdated.map((li) => li.toOldJSONExpanded())
itemsUpdated.map((li) => li.toJSONExpanded())
)
}
@@ -1310,7 +1289,7 @@ class LibraryController {
}
})
const opmlText = this.podcastManager.generateOPMLFileText(podcasts)
const opmlText = PodcastManager.generateOPMLFileText(podcasts)
res.type('application/xml')
res.send(opmlText)
}

View File

@@ -18,22 +18,13 @@ const RssFeedManager = require('../managers/RssFeedManager')
const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager')
const ShareManager = require('../managers/ShareManager')
const PodcastManager = require('../managers/PodcastManager')
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/LibraryItem')} libraryItem
*
* @typedef {RequestWithUser & RequestEntityObject} LibraryItemControllerRequest
*
* @typedef RequestLibraryFileObject
* @property {import('../objects/files/LibraryFile')} libraryFile
*
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
*/
class LibraryItemController {
@@ -45,17 +36,17 @@ class LibraryItemController {
* ?include=progress,rssfeed,downloads,share
* ?expanded=1
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',')
if (req.query.expanded == 1) {
const item = req.libraryItem.toOldJSONExpanded()
var item = req.libraryItem.toJSONExpanded()
// Include users media progress
if (includeEntities.includes('progress')) {
const episodeId = req.query.episode || null
var episodeId = req.query.episode || null
item.userMediaProgress = req.user.getOldMediaProgress(item.id, episodeId)
}
@@ -69,16 +60,37 @@ class LibraryItemController {
}
if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) {
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
const downloadsInQueue = PodcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
item.episodeDownloadsQueued = downloadsInQueue.map((d) => d.toJSONForClient())
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()]
if (PodcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
item.episodesDownloading = [PodcastManager.currentDownload.toJSONForClient()]
}
}
return res.json(item)
}
res.json(req.libraryItem.toOldJSON())
res.json(req.libraryItem)
}
/**
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async update(req, res) {
var libraryItem = req.libraryItem
// Item has cover and update is removing cover so purge it from cache
if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
await CacheManager.purgeCoverCache(libraryItem.id)
}
const hasUpdates = libraryItem.update(req.body)
if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated now saving`)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json(libraryItem.toJSON())
}
/**
@@ -89,7 +101,7 @@ class LibraryItemController {
*
* @this {import('../routers/ApiRouter')}
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async delete(req, res) {
@@ -100,14 +112,14 @@ class LibraryItemController {
const authorIds = []
const seriesIds = []
if (req.libraryItem.isPodcast) {
mediaItemIds.push(...req.libraryItem.media.podcastEpisodes.map((ep) => ep.id))
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id))
} else {
mediaItemIds.push(req.libraryItem.media.id)
if (req.libraryItem.media.authors?.length) {
authorIds.push(...req.libraryItem.media.authors.map((au) => au.id))
if (req.libraryItem.media.metadata.authors?.length) {
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id))
}
if (req.libraryItem.media.series?.length) {
seriesIds.push(...req.libraryItem.media.series.map((se) => se.id))
if (req.libraryItem.media.metadata.series?.length) {
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id))
}
}
@@ -144,7 +156,7 @@ class LibraryItemController {
* GET: /api/items/:id/download
* Download library item. Zip file if multiple files.
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async download(req, res) {
@@ -153,7 +165,7 @@ class LibraryItemController {
return res.sendStatus(403)
}
const libraryItemPath = req.libraryItem.path
const itemTitle = req.libraryItem.media.title
const itemTitle = req.libraryItem.media.metadata.title
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
@@ -183,10 +195,11 @@ class LibraryItemController {
*
* @this {import('../routers/ApiRouter')}
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async updateMedia(req, res) {
const libraryItem = req.libraryItem
const mediaPayload = req.body
if (mediaPayload.url) {
@@ -194,79 +207,69 @@ class LibraryItemController {
if (res.writableEnded || res.headersSent) return
}
// Book specific
if (libraryItem.isBook) {
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
}
// Podcast specific
let isPodcastAutoDownloadUpdated = false
if (req.libraryItem.isPodcast) {
if (mediaPayload.autoDownloadEpisodes !== undefined && req.libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
if (libraryItem.isPodcast) {
if (mediaPayload.autoDownloadEpisodes !== undefined && libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
isPodcastAutoDownloadUpdated = true
} else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
} else if (mediaPayload.autoDownloadSchedule !== undefined && libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
isPodcastAutoDownloadUpdated = true
}
}
let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
const seriesUpdateData = await req.libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, req.libraryItem.libraryId)
if (seriesUpdateData?.seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
await this.checkRemoveEmptySeries(seriesUpdateData.seriesRemoved.map((se) => se.id))
}
if (seriesUpdateData?.seriesAdded.length) {
// Add series to filter data
seriesUpdateData.seriesAdded.forEach((se) => {
Database.addSeriesToFilterData(req.libraryItem.libraryId, se.name, se.id)
})
}
if (seriesUpdateData?.hasUpdates) {
hasUpdates = true
}
// Book specific - Get all series being removed from this item
let seriesRemoved = []
if (libraryItem.isBook && mediaPayload.metadata?.series) {
const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || []
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
}
if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {
const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)
const authorUpdateData = await req.libraryItem.media.updateAuthorsFromRequest(authorNames, req.libraryItem.libraryId)
if (authorUpdateData?.authorsRemoved.length) {
// Check remove empty authors
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
await this.checkRemoveAuthorsWithNoBooks(authorUpdateData.authorsRemoved.map((au) => au.id))
hasUpdates = true
}
if (authorUpdateData?.authorsAdded.length) {
// Add authors to filter data
authorUpdateData.authorsAdded.forEach((au) => {
Database.addAuthorToFilterData(req.libraryItem.libraryId, au.name, au.id)
})
hasUpdates = true
}
let authorsRemoved = []
if (libraryItem.isBook && mediaPayload.metadata?.authors) {
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
}
const hasUpdates = libraryItem.media.update(mediaPayload) || mediaPayload.url
if (hasUpdates) {
req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save()
await req.libraryItem.saveMetadataFile()
libraryItem.updatedAt = Date.now()
if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(req.libraryItem)
this.cronManager.checkUpdatePodcastCron(libraryItem)
}
Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`)
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
if (authorsRemoved.length) {
// Check remove empty authors
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
}
if (seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id))
}
}
res.json({
updated: hasUpdates,
libraryItem: req.libraryItem.toOldJSON()
libraryItem
})
}
/**
* POST: /api/items/:id/cover
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
* @param {boolean} [updateAndReturnJson=true] - Allows the function to be used for both direct API calls and internally
* @param {boolean} [updateAndReturnJson=true]
*/
async uploadCover(req, res, updateAndReturnJson = true) {
if (!req.user.canUpload) {
@@ -274,13 +277,15 @@ class LibraryItemController {
return res.sendStatus(403)
}
let libraryItem = req.libraryItem
let result = null
if (req.body?.url) {
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
result = await CoverManager.downloadCoverFromUrlNew(req.body.url, req.libraryItem.id, req.libraryItem.isFile ? null : req.libraryItem.path)
result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url)
} else if (req.files?.cover) {
Logger.debug(`[LibraryItemController] Handling uploaded cover`)
result = await CoverManager.uploadCover(req.libraryItem, req.files.cover)
result = await CoverManager.uploadCover(libraryItem, req.files.cover)
} else {
return res.status(400).send('Invalid request no file or url')
}
@@ -291,16 +296,9 @@ class LibraryItemController {
return res.status(500).send('Unknown error occurred')
}
req.libraryItem.media.coverPath = result.cover
req.libraryItem.media.changed('coverPath', true)
await req.libraryItem.media.save()
if (updateAndReturnJson) {
// client uses updatedAt timestamp in URL to force refresh cover
req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
res.json({
success: true,
cover: result.cover
@@ -311,28 +309,22 @@ class LibraryItemController {
/**
* PATCH: /api/items/:id/cover
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async updateCover(req, res) {
const libraryItem = req.libraryItem
if (!req.body.cover) {
return res.status(400).send('Invalid request no cover path')
}
const validationResult = await CoverManager.validateCoverPath(req.body.cover, req.libraryItem)
const validationResult = await CoverManager.validateCoverPath(req.body.cover, libraryItem)
if (validationResult.error) {
return res.status(500).send(validationResult.error)
}
if (validationResult.updated) {
req.libraryItem.media.coverPath = validationResult.cover
req.libraryItem.media.changed('coverPath', true)
await req.libraryItem.media.save()
// client uses updatedAt timestamp in URL to force refresh cover
req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json({
success: true,
@@ -343,22 +335,17 @@ class LibraryItemController {
/**
* DELETE: /api/items/:id/cover
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async removeCover(req, res) {
if (req.libraryItem.media.coverPath) {
req.libraryItem.media.coverPath = null
req.libraryItem.media.changed('coverPath', true)
await req.libraryItem.media.save()
var libraryItem = req.libraryItem
// client uses updatedAt timestamp in URL to force refresh cover
req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save()
await CacheManager.purgeCoverCache(req.libraryItem.id)
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
if (libraryItem.media.coverPath) {
libraryItem.updateMediaCover('')
await CacheManager.purgeCoverCache(libraryItem.id)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.sendStatus(200)
@@ -367,7 +354,7 @@ class LibraryItemController {
/**
* GET: /api/items/:id/cover
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async getCover(req, res) {
@@ -409,11 +396,11 @@ class LibraryItemController {
*
* @this {import('../routers/ApiRouter')}
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
startPlaybackSession(req, res) {
if (!req.libraryItem.hasAudioTracks) {
if (!req.libraryItem.media.numTracks) {
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
return res.sendStatus(404)
}
@@ -426,18 +413,18 @@ class LibraryItemController {
*
* @this {import('../routers/ApiRouter')}
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
startEpisodePlaybackSession(req, res) {
if (!req.libraryItem.isPodcast) {
Logger.error(`[LibraryItemController] startEpisodePlaybackSession invalid media type ${req.libraryItem.id}`)
return res.sendStatus(400)
var libraryItem = req.libraryItem
if (!libraryItem.media.numTracks) {
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${libraryItem.id}`)
return res.sendStatus(404)
}
const episodeId = req.params.episodeId
if (!req.libraryItem.media.podcastEpisodes.some((ep) => ep.id === episodeId)) {
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${req.libraryItem.id}`)
var episodeId = req.params.episodeId
if (!libraryItem.media.episodes.find((ep) => ep.id === episodeId)) {
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${libraryItem.id}`)
return res.sendStatus(404)
}
@@ -447,55 +434,30 @@ class LibraryItemController {
/**
* PATCH: /api/items/:id/tracks
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async updateTracks(req, res) {
const orderedFileData = req.body?.orderedFileData
if (!req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] updateTracks invalid media type ${req.libraryItem.id}`)
return res.sendStatus(400)
var libraryItem = req.libraryItem
var orderedFileData = req.body.orderedFileData
if (!libraryItem.media.updateAudioTracks) {
Logger.error(`[LibraryItemController] updateTracks invalid media type ${libraryItem.id}`)
return res.sendStatus(500)
}
if (!Array.isArray(orderedFileData) || !orderedFileData.length) {
Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)
return res.sendStatus(400)
}
// Ensure that each orderedFileData has a valid ino and is in the book audioFiles
if (orderedFileData.some((fileData) => !fileData?.ino || !req.libraryItem.media.audioFiles.some((af) => af.ino === fileData.ino))) {
Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)
return res.sendStatus(400)
}
let index = 1
const updatedAudioFiles = orderedFileData.map((fileData) => {
const audioFile = req.libraryItem.media.audioFiles.find((af) => af.ino === fileData.ino)
audioFile.manuallyVerified = true
audioFile.exclude = !!fileData.exclude
if (audioFile.exclude) {
audioFile.index = -1
} else {
audioFile.index = index++
}
return audioFile
})
updatedAudioFiles.sort((a, b) => a.index - b.index)
req.libraryItem.media.audioFiles = updatedAudioFiles
req.libraryItem.media.changed('audioFiles', true)
await req.libraryItem.media.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.json(req.libraryItem.toOldJSON())
libraryItem.media.updateAudioTracks(orderedFileData)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSON())
}
/**
* POST /api/items/:id/match
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async match(req, res) {
const libraryItem = req.libraryItem
const reqBody = req.body || {}
const options = {}
@@ -512,7 +474,7 @@ class LibraryItemController {
options.overrideDetails = !!reqBody.overrideDetails
}
const matchResult = await Scanner.quickMatchLibraryItem(this, req.libraryItem, options)
var matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
res.json(matchResult)
}
@@ -535,11 +497,11 @@ class LibraryItemController {
const hardDelete = req.query.hard == 1 // Delete files from filesystem
const { libraryItemIds } = req.body
if (!libraryItemIds?.length || !Array.isArray(libraryItemIds)) {
if (!libraryItemIds?.length) {
return res.status(400).send('Invalid request body')
}
const itemsToDelete = await Database.libraryItemModel.findAllExpandedWhere({
const itemsToDelete = await Database.libraryItemModel.getAllOldLibraryItems({
id: libraryItemIds
})
@@ -550,19 +512,19 @@ class LibraryItemController {
const libraryId = itemsToDelete[0].libraryId
for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
const mediaItemIds = []
const seriesIds = []
const authorIds = []
if (libraryItem.isPodcast) {
mediaItemIds.push(...libraryItem.media.podcastEpisodes.map((ep) => ep.id))
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id))
} else {
mediaItemIds.push(libraryItem.media.id)
if (libraryItem.media.series?.length) {
seriesIds.push(...libraryItem.media.series.map((se) => se.id))
if (libraryItem.media.metadata.series?.length) {
seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id))
}
if (libraryItem.media.authors?.length) {
authorIds.push(...libraryItem.media.authors.map((au) => au.id))
if (libraryItem.media.metadata.authors?.length) {
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id))
}
}
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
@@ -607,7 +569,7 @@ class LibraryItemController {
}
// Get all library items to update
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
id: libraryItemIds
})
if (updatePayloads.length !== libraryItems.length) {
@@ -624,46 +586,26 @@ class LibraryItemController {
const mediaPayload = updatePayload.mediaPayload
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
const seriesUpdateData = await libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, libraryItem.libraryId)
if (seriesUpdateData?.seriesRemoved.length) {
seriesIdsRemoved.push(...seriesUpdateData.seriesRemoved.map((se) => se.id))
if (libraryItem.isBook) {
if (Array.isArray(mediaPayload.metadata?.series)) {
const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
}
if (seriesUpdateData?.seriesAdded.length) {
seriesUpdateData.seriesAdded.forEach((se) => {
Database.addSeriesToFilterData(libraryItem.libraryId, se.name, se.id)
})
}
if (seriesUpdateData?.hasUpdates) {
hasUpdates = true
if (Array.isArray(mediaPayload.metadata?.authors)) {
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id))
}
}
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {
const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)
const authorUpdateData = await libraryItem.media.updateAuthorsFromRequest(authorNames, libraryItem.libraryId)
if (authorUpdateData?.authorsRemoved.length) {
authorIdsRemoved.push(...authorUpdateData.authorsRemoved.map((au) => au.id))
hasUpdates = true
}
if (authorUpdateData?.authorsAdded.length) {
authorUpdateData.authorsAdded.forEach((au) => {
Database.addAuthorToFilterData(libraryItem.libraryId, au.name, au.id)
})
hasUpdates = true
}
}
if (libraryItem.media.update(mediaPayload)) {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
if (hasUpdates) {
libraryItem.changed('updatedAt', true)
await libraryItem.save()
await libraryItem.saveMetadataFile()
Logger.debug(`[LibraryItemController] Updated library item media "${libraryItem.media.title}"`)
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
itemsUpdated++
}
}
@@ -692,11 +634,11 @@ class LibraryItemController {
if (!libraryItemIds.length) {
return res.status(403).send('Invalid payload')
}
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
id: libraryItemIds
})
res.json({
libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
libraryItems: libraryItems.map((li) => li.toJSONExpanded())
})
}
@@ -719,7 +661,7 @@ class LibraryItemController {
return res.sendStatus(400)
}
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
id: req.body.libraryItemIds
})
if (!libraryItems?.length) {
@@ -800,7 +742,7 @@ class LibraryItemController {
/**
* POST: /api/items/:id/scan
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async scan(req, res) {
@@ -824,7 +766,7 @@ class LibraryItemController {
/**
* GET: /api/items/:id/metadata-object
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
getMetadataObject(req, res) {
@@ -833,7 +775,7 @@ class LibraryItemController {
return res.sendStatus(403)
}
if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.includedAudioFiles.length) {
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500)
}
@@ -844,7 +786,7 @@ class LibraryItemController {
/**
* POST: /api/items/:id/chapters
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async updateMediaChapters(req, res) {
@@ -853,53 +795,26 @@ class LibraryItemController {
return res.sendStatus(403)
}
if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.hasAudioTracks) {
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500)
}
if (!Array.isArray(req.body.chapters) || req.body.chapters.some((c) => !c.title || typeof c.title !== 'string' || c.start === undefined || typeof c.start !== 'number' || c.end === undefined || typeof c.end !== 'number')) {
if (!req.body.chapters) {
Logger.error(`[LibraryItemController] Invalid payload`)
return res.sendStatus(400)
}
const chapters = req.body.chapters || []
let hasUpdates = false
if (chapters.length !== req.libraryItem.media.chapters.length) {
req.libraryItem.media.chapters = chapters.map((c, index) => {
return {
id: index,
title: c.title,
start: c.start,
end: c.end
}
})
hasUpdates = true
} else {
for (const [index, chapter] of chapters.entries()) {
const currentChapter = req.libraryItem.media.chapters[index]
if (currentChapter.title !== chapter.title || currentChapter.start !== chapter.start || currentChapter.end !== chapter.end) {
currentChapter.title = chapter.title
currentChapter.start = chapter.start
currentChapter.end = chapter.end
hasUpdates = true
}
}
}
if (hasUpdates) {
req.libraryItem.media.changed('chapters', true)
await req.libraryItem.media.save()
await req.libraryItem.saveMetadataFile()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
if (wasUpdated) {
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
}
res.json({
success: true,
updated: hasUpdates
updated: wasUpdated
})
}
@@ -907,7 +822,7 @@ class LibraryItemController {
* GET: /api/items/:id/ffprobe/:fileid
* FFProbe JSON result from audio file
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async getFFprobeData(req, res) {
@@ -915,21 +830,25 @@ class LibraryItemController {
Logger.error(`[LibraryItemController] Non-admin user "${req.user.username}" attempted to get ffprobe data`)
return res.sendStatus(403)
}
if (req.libraryFile.fileType !== 'audio') {
Logger.error(`[LibraryItemController] Invalid filetype "${req.libraryFile.fileType}" for fileid "${req.params.fileid}". Expected audio file`)
return res.sendStatus(400)
}
const audioFile = req.libraryItem.getAudioFileWithIno(req.params.fileid)
const audioFile = req.libraryItem.media.findFileWithInode(req.params.fileid)
if (!audioFile) {
Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`)
return res.sendStatus(404)
}
const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile.metadata.path)
const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile)
res.json(ffprobeData)
}
/**
* GET api/items/:id/file/:fileid
*
* @param {LibraryItemControllerRequestWithFile} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async getLibraryFile(req, res) {
@@ -952,7 +871,7 @@ class LibraryItemController {
/**
* DELETE api/items/:id/file/:fileid
*
* @param {LibraryItemControllerRequestWithFile} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async deleteLibraryFile(req, res) {
@@ -963,49 +882,17 @@ class LibraryItemController {
await fs.remove(libraryFile.metadata.path).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error)
})
req.libraryItem.removeLibraryFile(req.params.fileid)
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((lf) => lf.ino !== req.params.fileid)
req.libraryItem.changed('libraryFiles', true)
if (req.libraryItem.isBook) {
if (req.libraryItem.media.audioFiles.some((af) => af.ino === req.params.fileid)) {
req.libraryItem.media.audioFiles = req.libraryItem.media.audioFiles.filter((af) => af.ino !== req.params.fileid)
req.libraryItem.media.changed('audioFiles', true)
} else if (req.libraryItem.media.ebookFile?.ino === req.params.fileid) {
req.libraryItem.media.ebookFile = null
req.libraryItem.media.changed('ebookFile', true)
if (req.libraryItem.media.removeFileWithInode(req.params.fileid)) {
// If book has no more media files then mark it as missing
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
req.libraryItem.setMissing()
}
if (!req.libraryItem.media.hasMediaFiles) {
req.libraryItem.isMissing = true
}
} else if (req.libraryItem.media.podcastEpisodes.some((ep) => ep.audioFile.ino === req.params.fileid)) {
const episodeToRemove = req.libraryItem.media.podcastEpisodes.find((ep) => ep.audioFile.ino === req.params.fileid)
// Remove episode from all playlists
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id])
// Remove episode media progress
const numProgressRemoved = await Database.mediaProgressModel.destroy({
where: {
mediaItemId: episodeToRemove.id
}
})
if (numProgressRemoved > 0) {
Logger.info(`[LibraryItemController] Removed media progress for episode ${episodeToRemove.id}`)
}
// Remove episode
await episodeToRemove.destroy()
req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.audioFile.ino !== req.params.fileid)
}
if (req.libraryItem.media.changed()) {
await req.libraryItem.media.save()
}
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
req.libraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
res.sendStatus(200)
}
@@ -1013,7 +900,7 @@ class LibraryItemController {
* GET api/items/:id/file/:fileid/download
* Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads
*
* @param {LibraryItemControllerRequestWithFile} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async downloadLibraryFile(req, res) {
@@ -1025,7 +912,7 @@ class LibraryItemController {
return res.sendStatus(403)
}
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.title}" file at "${libraryFile.metadata.path}"`)
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" file at "${libraryFile.metadata.path}"`)
if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path)
@@ -1061,13 +948,13 @@ class LibraryItemController {
* fileid is only required when reading a supplementary ebook
* when no fileid is passed in the primary ebook will be returned
*
* @param {LibraryItemControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async getEBookFile(req, res) {
let ebookFile = null
if (req.params.fileid) {
ebookFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid)
ebookFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
if (!ebookFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id')
@@ -1077,12 +964,12 @@ class LibraryItemController {
}
if (!ebookFile) {
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.title}"`)
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.metadata.title}"`)
return res.sendStatus(404)
}
const ebookFilePath = ebookFile.metadata.path
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.title}" ebook at "${ebookFilePath}"`)
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" ebook at "${ebookFilePath}"`)
if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + ebookFilePath)
@@ -1105,55 +992,28 @@ class LibraryItemController {
* if an ebook file is the primary ebook, then it will be changed to supplementary
* if an ebook file is supplementary, then it will be changed to primary
*
* @param {LibraryItemControllerRequestWithFile} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async updateEbookFileStatus(req, res) {
if (!req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] Invalid media type for ebook file status update`)
return res.sendStatus(400)
}
if (!req.libraryFile?.isEBookFile) {
const ebookLibraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
if (!ebookLibraryFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id')
}
const ebookLibraryFile = req.libraryFile
let primaryEbookFile = null
const ebookLibraryFileInos = req.libraryItem
.getLibraryFiles()
.filter((lf) => lf.isEBookFile)
.map((lf) => lf.ino)
if (ebookLibraryFile.isSupplementary) {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`)
primaryEbookFile = ebookLibraryFile.toJSON()
delete primaryEbookFile.isSupplementary
delete primaryEbookFile.fileType
primaryEbookFile.ebookFormat = ebookLibraryFile.metadata.format
req.libraryItem.setPrimaryEbook(ebookLibraryFile)
} else {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`)
ebookLibraryFile.isSupplementary = true
req.libraryItem.setPrimaryEbook(null)
}
req.libraryItem.media.ebookFile = primaryEbookFile
req.libraryItem.media.changed('ebookFile', true)
await req.libraryItem.media.save()
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.map((lf) => {
if (ebookLibraryFileInos.includes(lf.ino)) {
lf.isSupplementary = lf.ino !== primaryEbookFile?.ino
}
return lf
})
req.libraryItem.changed('libraryFiles', true)
req.libraryItem.isMissing = !req.libraryItem.media.hasMediaFiles
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
req.libraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
res.sendStatus(200)
}
@@ -1164,7 +1024,7 @@ class LibraryItemController {
* @param {NextFunction} next
*/
async middleware(req, res, next) {
req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
if (!req.libraryItem?.media) return res.sendStatus(404)
// Check user can access this library item
@@ -1174,7 +1034,7 @@ class LibraryItemController {
// For library file routes, get the library file
if (req.params.fileid) {
req.libraryFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid)
req.libraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
if (!req.libraryFile) {
Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`)
return res.sendStatus(404)

View File

@@ -66,7 +66,7 @@ class MeController {
const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId)
const episode = await Database.podcastEpisodeModel.findByPk(req.params.episodeId)
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
if (!libraryItem || (libraryItem.mediaType === 'podcast' && !episode)) {
Logger.error(`[MeController] Media item not found for library item id "${req.params.libraryItemId}"`)
return res.sendStatus(404)
}
@@ -296,7 +296,7 @@ class MeController {
const mediaProgressesInProgress = req.user.mediaProgresses.filter((mp) => !mp.isFinished && (mp.currentTime > 0 || mp.ebookProgress > 0))
const libraryItemsIds = [...new Set(mediaProgressesInProgress.map((mp) => mp.extraData?.libraryItemId).filter((id) => id))]
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: libraryItemsIds })
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: libraryItemsIds })
let itemsInProgress = []
@@ -304,19 +304,19 @@ class MeController {
const oldMediaProgress = mediaProgress.getOldMediaProgress()
const libraryItem = libraryItems.find((li) => li.id === oldMediaProgress.libraryItemId)
if (libraryItem) {
if (oldMediaProgress.episodeId && libraryItem.isPodcast) {
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === oldMediaProgress.episodeId)
if (oldMediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
const episode = libraryItem.media.episodes.find((ep) => ep.id === oldMediaProgress.episodeId)
if (episode) {
const libraryItemWithEpisode = {
...libraryItem.toOldJSONMinified(),
recentEpisode: episode.toOldJSON(libraryItem.id),
...libraryItem.toJSONMinified(),
recentEpisode: episode.toJSON(),
progressLastUpdate: oldMediaProgress.lastUpdate
}
itemsInProgress.push(libraryItemWithEpisode)
}
} else if (!oldMediaProgress.episodeId) {
itemsInProgress.push({
...libraryItem.toOldJSONMinified(),
...libraryItem.toJSONMinified(),
progressLastUpdate: oldMediaProgress.lastUpdate
})
}

View File

@@ -342,8 +342,8 @@ class MiscController {
tags: libraryItem.media.tags
})
await libraryItem.saveMetadataFile()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++
}
}
@@ -385,8 +385,8 @@ class MiscController {
tags: libraryItem.media.tags
})
await libraryItem.saveMetadataFile()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++
}
@@ -480,8 +480,8 @@ class MiscController {
genres: libraryItem.media.genres
})
await libraryItem.saveMetadataFile()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++
}
}
@@ -523,8 +523,8 @@ class MiscController {
genres: libraryItem.media.genres
})
await libraryItem.saveMetadataFile()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++
}

View File

@@ -3,16 +3,13 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const Playlist = require('../objects/Playlist')
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/Playlist')} playlist
*
* @typedef {RequestWithUser & RequestEntityObject} PlaylistControllerRequest
*/
class PlaylistController {
@@ -26,103 +23,48 @@ class PlaylistController {
* @param {Response} res
*/
async create(req, res) {
const reqBody = req.body || {}
// Validation
if (!reqBody.name || !reqBody.libraryId) {
return res.status(400).send('Invalid playlist data')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid playlist description')
}
const items = reqBody.items || []
const isPodcast = items.some((i) => i.episodeId)
const libraryItemIds = new Set()
for (const item of items) {
if (!item.libraryItemId || typeof item.libraryItemId !== 'string') {
return res.status(400).send('Invalid playlist item')
}
if (isPodcast && (!item.episodeId || typeof item.episodeId !== 'string')) {
return res.status(400).send('Invalid playlist item episodeId')
} else if (!isPodcast && item.episodeId) {
return res.status(400).send('Invalid playlist item episodeId')
}
libraryItemIds.add(item.libraryItemId)
const oldPlaylist = new Playlist()
req.body.userId = req.user.id
const success = oldPlaylist.setData(req.body)
if (!success) {
return res.status(400).send('Invalid playlist request data')
}
// Load library items
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
// Create Playlist record
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
// Lookup all library items in playlist
const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId).filter((i) => i)
const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
where: {
id: Array.from(libraryItemIds),
libraryId: reqBody.libraryId,
mediaType: isPodcast ? 'podcast' : 'book'
id: libraryItemIds
}
})
if (libraryItems.length !== libraryItemIds.size) {
return res.status(400).send('Invalid playlist data. Invalid items')
}
// Validate podcast episodes
if (isPodcast) {
const podcastEpisodeIds = items.map((i) => i.episodeId)
const podcastEpisodes = await Database.podcastEpisodeModel.findAll({
attributes: ['id'],
where: {
id: podcastEpisodeIds
}
// Create playlistMediaItem records
const mediaItemsToAdd = []
let order = 1
for (const mediaItemObj of oldPlaylist.items) {
const libraryItem = libraryItemsInPlaylist.find((li) => li.id === mediaItemObj.libraryItemId)
if (!libraryItem) continue
mediaItemsToAdd.push({
mediaItemId: mediaItemObj.episodeId || libraryItem.mediaId,
mediaItemType: mediaItemObj.episodeId ? 'podcastEpisode' : 'book',
playlistId: oldPlaylist.id,
order: order++
})
if (podcastEpisodes.length !== podcastEpisodeIds.length) {
return res.status(400).send('Invalid playlist data. Invalid podcast episodes')
}
}
if (mediaItemsToAdd.length) {
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
}
const transaction = await Database.sequelize.transaction()
try {
// Create playlist
const newPlaylist = await Database.playlistModel.create(
{
libraryId: reqBody.libraryId,
userId: req.user.id,
name: reqBody.name,
description: reqBody.description || null
},
{ transaction }
)
// Create playlistMediaItems
const playlistItemPayloads = []
for (const [index, item] of items.entries()) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
playlistItemPayloads.push({
playlistId: newPlaylist.id,
mediaItemId: item.episodeId || libraryItem.mediaId,
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: index + 1
})
}
await Database.playlistMediaItemModel.bulkCreate(playlistItemPayloads, { transaction })
await transaction.commit()
newPlaylist.playlistMediaItems = await newPlaylist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = newPlaylist.toOldJSONExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
} catch (error) {
await transaction.rollback()
Logger.error('[PlaylistController] create:', error)
res.status(500).send('Failed to create playlist')
}
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
}
/**
* @deprecated - Use /api/libraries/:libraryId/playlists
* This is not used by Abs web client or mobile apps
* TODO: Remove this endpoint or make it the primary
*
* GET: /api/playlists
* Get all playlists for user
*
@@ -130,89 +72,68 @@ class PlaylistController {
* @param {Response} res
*/
async findAllForUser(req, res) {
const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)
const playlistsForUser = await Database.playlistModel.findAll({
where: {
userId: req.user.id
}
})
const playlists = []
for (const playlist of playlistsForUser) {
const jsonExpanded = await playlist.getOldJsonExpanded()
playlists.push(jsonExpanded)
}
res.json({
playlists: playlistsForUser
playlists
})
}
/**
* GET: /api/playlists/:id
*
* @param {PlaylistControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async findOne(req, res) {
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
res.json(req.playlist.toOldJSONExpanded())
const jsonExpanded = await req.playlist.getOldJsonExpanded()
res.json(jsonExpanded)
}
/**
* PATCH: /api/playlists/:id
* Update playlist
*
* Used for updating name and description or reordering items
*
* @param {PlaylistControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async update(req, res) {
// Validation
const reqBody = req.body || {}
if (reqBody.libraryId || reqBody.userId) {
// Could allow support for this if needed with additional validation
return res.status(400).send('Invalid playlist data. Cannot update libraryId or userId')
}
if (reqBody.name && typeof reqBody.name !== 'string') {
return res.status(400).send('Invalid playlist name')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid playlist description')
}
if (reqBody.items && (!Array.isArray(reqBody.items) || reqBody.items.some((i) => !i.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string')))) {
return res.status(400).send('Invalid playlist items')
}
const playlistUpdatePayload = {}
if (reqBody.name) playlistUpdatePayload.name = reqBody.name
if (reqBody.description) playlistUpdatePayload.description = reqBody.description
// Update name and description
const updatedPlaylist = req.playlist.set(req.body)
let wasUpdated = false
if (Object.keys(playlistUpdatePayload).length) {
req.playlist.set(playlistUpdatePayload)
const changed = req.playlist.changed()
if (changed?.length) {
await req.playlist.save()
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
wasUpdated = true
}
const changed = updatedPlaylist.changed()
if (changed?.length) {
await req.playlist.save()
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
wasUpdated = true
}
// If array of items is set then update order of playlist media items
if (reqBody.items?.length) {
const libraryItemIds = Array.from(new Set(reqBody.items.map((i) => i.libraryItemId)))
// If array of items is passed in then update order of playlist media items
const libraryItemIds = req.body.items?.map((i) => i.libraryItemId).filter((i) => i) || []
if (libraryItemIds.length) {
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType'],
where: {
id: libraryItemIds
}
})
if (libraryItems.length !== libraryItemIds.length) {
return res.status(400).send('Invalid playlist items. Items not found')
}
/** @type {import('../models/PlaylistMediaItem')[]} */
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
const existingPlaylistMediaItems = await updatedPlaylist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
if (existingPlaylistMediaItems.length !== reqBody.items.length) {
return res.status(400).send('Invalid playlist items. Length mismatch')
}
// Set an array of mediaItemId
const newMediaItemIdOrder = []
for (const item of reqBody.items) {
for (const item of req.body.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
if (!libraryItem) {
continue
}
const mediaItemId = item.episodeId || libraryItem.mediaId
newMediaItemIdOrder.push(mediaItemId)
}
@@ -225,21 +146,21 @@ class PlaylistController {
})
// Update order on playlistMediaItem records
for (const [index, playlistMediaItem] of existingPlaylistMediaItems.entries()) {
if (playlistMediaItem.order !== index + 1) {
let order = 1
for (const playlistMediaItem of existingPlaylistMediaItems) {
if (playlistMediaItem.order !== order) {
await playlistMediaItem.update({
order: index + 1
order
})
wasUpdated = true
}
order++
}
}
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = req.playlist.toOldJSONExpanded()
const jsonExpanded = await updatedPlaylist.getOldJsonExpanded()
if (wasUpdated) {
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
@@ -248,13 +169,11 @@ class PlaylistController {
* DELETE: /api/playlists/:id
* Remove playlist
*
* @param {PlaylistControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async delete(req, res) {
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = req.playlist.toOldJSONExpanded()
const jsonExpanded = await req.playlist.getOldJsonExpanded()
await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
res.sendStatus(200)
@@ -264,64 +183,43 @@ class PlaylistController {
* POST: /api/playlists/:id/item
* Add item to playlist
*
* This is not used by Abs web client or mobile apps. Only the batch endpoints are used.
*
* @param {PlaylistControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async addItem(req, res) {
const itemToAdd = req.body || {}
const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
const itemToAdd = req.body
if (!itemToAdd.libraryItemId) {
return res.status(400).send('Request body has no libraryItemId')
}
const libraryItem = await Database.libraryItemModel.getExpandedById(itemToAdd.libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId)
if (!libraryItem) {
return res.status(400).send('Library item not found')
}
if (libraryItem.libraryId !== req.playlist.libraryId) {
if (libraryItem.libraryId !== oldPlaylist.libraryId) {
return res.status(400).send('Library item in different library')
}
if (oldPlaylist.containsItem(itemToAdd)) {
return res.status(400).send('Item already in playlist')
}
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
return res.status(400).send('Invalid item to add for this library type')
}
if (itemToAdd.episodeId && !libraryItem.media.podcastEpisodes.some((pe) => pe.id === itemToAdd.episodeId)) {
if (itemToAdd.episodeId && !libraryItem.media.checkHasEpisode(itemToAdd.episodeId)) {
return res.status(400).send('Episode not found in library item')
}
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
if (req.playlist.checkHasMediaItem(itemToAdd.libraryItemId, itemToAdd.episodeId)) {
return res.status(400).send('Item already in playlist')
}
const jsonExpanded = req.playlist.toOldJSONExpanded()
const playlistMediaItem = {
playlistId: req.playlist.id,
playlistId: oldPlaylist.id,
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
order: req.playlist.playlistMediaItems.length + 1
}
await Database.playlistMediaItemModel.create(playlistMediaItem)
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
if (itemToAdd.episodeId) {
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === itemToAdd.episodeId)
jsonExpanded.items.push({
episodeId: itemToAdd.episodeId,
episode: episode.toOldJSONExpanded(libraryItem.id),
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toOldJSONMinified()
})
} else {
jsonExpanded.items.push({
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toOldJSONExpanded()
})
order: oldPlaylist.items.length + 1
}
await Database.createPlaylistMediaItem(playlistMediaItem)
const jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
res.json(jsonExpanded)
}
@@ -330,36 +228,43 @@ class PlaylistController {
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
* Remove item from playlist
*
* @param {PlaylistControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async removeItem(req, res) {
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
let playlistMediaItem = null
if (req.params.episodeId) {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === req.params.episodeId)
} else {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === req.params.libraryItemId)
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
if (!oldLibraryItem) {
return res.status(404).send('Library item not found')
}
if (!playlistMediaItem) {
// Get playlist media items
const mediaItemId = req.params.episodeId || oldLibraryItem.media.id
const playlistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
// Check if media item to delete is in playlist
const mediaItemToRemove = playlistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
if (!mediaItemToRemove) {
return res.status(404).send('Media item not found in playlist')
}
// Remove record
await playlistMediaItem.destroy()
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
await mediaItemToRemove.destroy()
// Update playlist media items order
for (const [index, mediaItem] of req.playlist.playlistMediaItems.entries()) {
if (mediaItem.order !== index + 1) {
let order = 1
for (const mediaItem of playlistMediaItems) {
if (mediaItem.mediaItemId === mediaItemId) continue
if (mediaItem.order !== order) {
await mediaItem.update({
order: index + 1
order
})
}
order++
}
const jsonExpanded = req.playlist.toOldJSONExpanded()
const jsonExpanded = await req.playlist.getOldJsonExpanded()
// Playlist is removed when there are no items
if (!jsonExpanded.items.length) {
@@ -377,68 +282,64 @@ class PlaylistController {
* POST: /api/playlists/:id/batch/add
* Batch add playlist items
*
* @param {PlaylistControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async addBatch(req, res) {
if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
return res.status(400).send('Invalid request body items')
if (!req.body.items?.length) {
return res.status(400).send('Invalid request body')
}
const itemsToAdd = req.body.items
const libraryItemIds = itemsToAdd.map((i) => i.libraryItemId).filter((i) => i)
if (!libraryItemIds.length) {
return res.status(400).send('Invalid request body')
}
// Find all library items
const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
})
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) })
if (libraryItems.length !== libraryItemIds.size) {
return res.status(400).send('Invalid request body items')
}
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
// Get all existing playlist media items
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
const mediaItemsToAdd = []
const jsonExpanded = req.playlist.toOldJSONExpanded()
// Setup array of playlistMediaItem records to add
let order = req.playlist.playlistMediaItems.length + 1
for (const item of req.body.items) {
let order = existingPlaylistMediaItems.length + 1
for (const item of itemsToAdd) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
const mediaItemId = item.episodeId || libraryItem.media.id
if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
// Already exists in playlist
continue
if (!libraryItem) {
return res.status(404).send('Item not found with id ' + item.libraryItemId)
} else {
mediaItemsToAdd.push({
playlistId: req.playlist.id,
mediaItemId,
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
})
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
if (item.episodeId) {
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === item.episodeId)
jsonExpanded.items.push({
episodeId: item.episodeId,
episode: episode.toOldJSONExpanded(libraryItem.id),
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toOldJSONMinified()
})
const mediaItemId = item.episodeId || libraryItem.mediaId
if (existingPlaylistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
// Already exists in playlist
continue
} else {
jsonExpanded.items.push({
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toOldJSONExpanded()
mediaItemsToAdd.push({
playlistId: req.playlist.id,
mediaItemId,
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
})
}
}
}
let jsonExpanded = null
if (mediaItemsToAdd.length) {
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd)
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
} else {
jsonExpanded = await req.playlist.getOldJsonExpanded()
}
res.json(jsonExpanded)
}
@@ -446,40 +347,50 @@ class PlaylistController {
* POST: /api/playlists/:id/batch/remove
* Batch remove playlist items
*
* @param {PlaylistControllerRequest} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async removeBatch(req, res) {
if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
return res.status(400).send('Invalid request body items')
if (!req.body.items?.length) {
return res.status(400).send('Invalid request body')
}
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const itemsToRemove = req.body.items
const libraryItemIds = itemsToRemove.map((i) => i.libraryItemId).filter((i) => i)
if (!libraryItemIds.length) {
return res.status(400).send('Invalid request body')
}
// Find all library items
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
})
// Get all existing playlist media items for playlist
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
let numMediaItems = existingPlaylistMediaItems.length
// Remove playlist media items
let hasUpdated = false
for (const item of req.body.items) {
let playlistMediaItem = null
if (item.episodeId) {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === item.episodeId)
} else {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === item.libraryItemId)
}
if (!playlistMediaItem) {
Logger.warn(`[PlaylistController] Playlist item not found in playlist ${req.playlist.id}`, item)
continue
}
await playlistMediaItem.destroy()
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
for (const item of itemsToRemove) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
if (!libraryItem) continue
const mediaItemId = item.episodeId || libraryItem.mediaId
const existingMediaItem = existingPlaylistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
if (!existingMediaItem) continue
await existingMediaItem.destroy()
hasUpdated = true
numMediaItems--
}
const jsonExpanded = req.playlist.toOldJSONExpanded()
const jsonExpanded = await req.playlist.getOldJsonExpanded()
if (hasUpdated) {
// Playlist is removed when there are no items
if (!req.playlist.playlistMediaItems.length) {
if (!numMediaItems) {
Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
@@ -514,41 +425,33 @@ class PlaylistController {
return res.status(400).send('Collection has no books')
}
const transaction = await Database.sequelize.transaction()
try {
const playlist = await Database.playlistModel.create(
{
userId: req.user.id,
libraryId: collection.libraryId,
name: collection.name,
description: collection.description || null
},
{ transaction }
)
const oldPlaylist = new Playlist()
oldPlaylist.setData({
userId: req.user.id,
libraryId: collection.libraryId,
name: collection.name,
description: collection.description || null
})
const mediaItemsToAdd = []
for (const [index, libraryItem] of collectionExpanded.books.entries()) {
mediaItemsToAdd.push({
playlistId: playlist.id,
mediaItemId: libraryItem.media.id,
mediaItemType: 'book',
order: index + 1
})
}
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd, { transaction })
// Create Playlist record
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
await transaction.commit()
playlist.playlistMediaItems = await playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = playlist.toOldJSONExpanded()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
} catch (error) {
await transaction.rollback()
Logger.error('[PlaylistController] createFromCollection:', error)
res.status(500).send('Failed to create playlist')
// Create PlaylistMediaItem records
const mediaItemsToAdd = []
let order = 1
for (const libraryItem of collectionExpanded.books) {
mediaItemsToAdd.push({
playlistId: newPlaylist.id,
mediaItemId: libraryItem.media.id,
mediaItemType: 'book',
order: order++
})
}
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
}
/**

View File

@@ -0,0 +1,82 @@
const { Request, Response, NextFunction } = require('express')
const PluginManager = require('../managers/PluginManager')
const Logger = require('../Logger')
class PluginController {
constructor() {}
/**
*
* @param {Request} req
* @param {Response} res
*/
getConfig(req, res) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
}
res.json({
config: req.pluginData.instance.config
})
}
/**
* POST: /api/plugins/:id/action
*
* @param {Request} req
* @param {Response} res
*/
async handleAction(req, res) {
const actionName = req.body.pluginAction
const target = req.body.target
const data = req.body.data
Logger.info(`[PluginController] Handle plugin "${req.pluginData.manifest.name}" action ${actionName} ${target}`, data)
const actionData = await PluginManager.onAction(req.pluginData, actionName, target, data)
if (!actionData || actionData.error) {
return res.status(400).send(actionData?.error || 'Error performing action')
}
res.sendStatus(200)
}
/**
* POST: /api/plugins/:id/config
*
* @param {Request} req
* @param {Response} res
*/
async handleConfigSave(req, res) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
}
if (!req.body.config || typeof req.body.config !== 'object') {
return res.status(400).send('Invalid config')
}
const config = req.body.config
Logger.info(`[PluginController] Handle save config for plugin ${req.pluginData.manifest.name}`, config)
const saveData = await PluginManager.onConfigSave(req.pluginData, config)
if (!saveData || saveData.error) {
return res.status(400).send(saveData?.error || 'Error saving config')
}
res.sendStatus(200)
}
/**
*
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
async middleware(req, res, next) {
if (req.params.id) {
const pluginData = PluginManager.getPluginDataById(req.params.id)
if (!pluginData) {
return res.sendStatus(404)
}
await pluginData.instance.reload()
req.pluginData = pluginData
}
next()
}
}
module.exports = new PluginController()

View File

@@ -1,4 +1,3 @@
const Path = require('path')
const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
@@ -12,17 +11,15 @@ const { validateUrl } = require('../utils/index')
const Scanner = require('../scanner/Scanner')
const CoverManager = require('../managers/CoverManager')
const PodcastManager = require('../managers/PodcastManager')
const LibraryItem = require('../objects/LibraryItem')
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/LibraryItem')} libraryItem
*
* @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem
*/
class PodcastController {
@@ -41,9 +38,6 @@ class PodcastController {
return res.sendStatus(403)
}
const payload = req.body
if (!payload.media || !payload.media.metadata) {
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required')
}
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
if (!library) {
@@ -85,87 +79,48 @@ class PodcastController {
let relPath = payload.path.replace(folder.fullPath, '')
if (relPath.startsWith('/')) relPath = relPath.slice(1)
let newLibraryItem = null
const transaction = await Database.sequelize.transaction()
try {
const podcast = await Database.podcastModel.createFromRequest(payload.media, transaction)
newLibraryItem = await Database.libraryItemModel.create(
{
ino: libraryItemFolderStats.ino,
path: podcastPath,
relPath,
mediaId: podcast.id,
mediaType: 'podcast',
isFile: false,
isMissing: false,
isInvalid: false,
mtime: libraryItemFolderStats.mtimeMs || 0,
ctime: libraryItemFolderStats.ctimeMs || 0,
birthtime: libraryItemFolderStats.birthtimeMs || 0,
size: 0,
libraryFiles: [],
extraData: {},
libraryId: library.id,
libraryFolderId: folder.id
},
{ transaction }
)
await transaction.commit()
} catch (error) {
Logger.error(`[PodcastController] Failed to create podcast: ${error}`)
await transaction.rollback()
return res.status(500).send('Failed to create podcast')
const libraryItemPayload = {
path: podcastPath,
relPath,
folderId: payload.folderId,
libraryId: payload.libraryId,
ino: libraryItemFolderStats.ino,
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
media: payload.media
}
newLibraryItem.media = await newLibraryItem.getMediaExpanded()
const libraryItem = new LibraryItem()
libraryItem.setData('podcast', libraryItemPayload)
// Download and save cover image
if (typeof payload.media.metadata.imageUrl === 'string' && payload.media.metadata.imageUrl.startsWith('http')) {
if (payload.media.metadata.imageUrl) {
// TODO: Scan cover image to library files
// Podcast cover will always go into library item folder
const coverResponse = await CoverManager.downloadCoverFromUrlNew(payload.media.metadata.imageUrl, newLibraryItem.id, newLibraryItem.path, true)
if (coverResponse.error) {
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
} else if (coverResponse.cover) {
const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
if (!coverImageFileStats) {
Logger.error(`[PodcastController] Failed to get cover image stats for "${coverResponse.cover}"`)
} else {
// Add libraryFile to libraryItem and coverPath to podcast
const newLibraryFile = {
ino: coverImageFileStats.ino,
fileType: 'image',
addedAt: Date.now(),
updatedAt: Date.now(),
metadata: {
filename: Path.basename(coverResponse.cover),
ext: Path.extname(coverResponse.cover).slice(1),
path: coverResponse.cover,
relPath: Path.basename(coverResponse.cover),
size: coverImageFileStats.size,
mtimeMs: coverImageFileStats.mtimeMs || 0,
ctimeMs: coverImageFileStats.ctimeMs || 0,
birthtimeMs: coverImageFileStats.birthtimeMs || 0
}
}
newLibraryItem.libraryFiles.push(newLibraryFile)
newLibraryItem.changed('libraryFiles', true)
await newLibraryItem.save()
newLibraryItem.media.coverPath = coverResponse.cover
await newLibraryItem.media.save()
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
if (coverResponse) {
if (coverResponse.error) {
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
} else if (coverResponse.cover) {
libraryItem.media.coverPath = coverResponse.cover
}
}
}
SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
await Database.createLibraryItem(libraryItem)
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
res.json(newLibraryItem.toOldJSONExpanded())
res.json(libraryItem.toJSONExpanded())
if (payload.episodesToDownload?.length) {
Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`)
PodcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
}
// Turn on podcast auto download cron if not already on
if (newLibraryItem.media.autoDownloadEpisodes) {
this.cronManager.checkUpdatePodcastCron(newLibraryItem)
if (libraryItem.media.autoDownloadEpisodes) {
this.cronManager.checkUpdatePodcastCron(libraryItem)
}
}
@@ -215,7 +170,7 @@ class PodcastController {
}
res.json({
feeds: this.podcastManager.getParsedOPMLFileFeeds(req.body.opmlText)
feeds: PodcastManager.getParsedOPMLFileFeeds(req.body.opmlText)
})
}
@@ -249,7 +204,7 @@ class PodcastController {
return res.status(404).send('Folder not found')
}
const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes
this.podcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)
PodcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)
res.sendStatus(200)
}
@@ -259,7 +214,7 @@ class PodcastController {
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithLibraryItem} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async checkNewEpisodes(req, res) {
@@ -268,14 +223,15 @@ class PodcastController {
return res.sendStatus(403)
}
if (!req.libraryItem.media.feedURL) {
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${req.libraryItem.id}`)
return res.status(400).send('Podcast has no rss feed url')
var libraryItem = req.libraryItem
if (!libraryItem.media.metadata.feedUrl) {
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`)
return res.status(500).send('Podcast has no rss feed url')
}
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3
const newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(req.libraryItem, maxEpisodesToDownload)
var newEpisodes = await PodcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload)
res.json({
episodes: newEpisodes || []
})
@@ -284,8 +240,6 @@ class PodcastController {
/**
* GET: /api/podcasts/:id/clear-queue
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {Response} res
*/
@@ -294,37 +248,30 @@ class PodcastController {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempting to clear download queue`)
return res.sendStatus(403)
}
this.podcastManager.clearDownloadQueue(req.params.id)
PodcastManager.clearDownloadQueue(req.params.id)
res.sendStatus(200)
}
/**
* GET: /api/podcasts/:id/downloads
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithLibraryItem} req
* @param {RequestWithUser} req
* @param {Response} res
*/
getEpisodeDownloads(req, res) {
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
var libraryItem = req.libraryItem
var downloadsInQueue = PodcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
res.json({
downloads: downloadsInQueue.map((d) => d.toJSONForClient())
})
}
/**
* GET: /api/podcasts/:id/search-episode
* Search for an episode in a podcast
*
* @param {RequestWithLibraryItem} req
* @param {Response} res
*/
async findEpisode(req, res) {
const rssFeedUrl = req.libraryItem.media.feedURL
const rssFeedUrl = req.libraryItem.media.metadata.feedUrl
if (!rssFeedUrl) {
Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`)
return res.status(400).send('Podcast does not have an RSS feed URL')
return res.status(500).send('Podcast does not have an RSS feed URL')
}
const searchTitle = req.query.title
@@ -340,9 +287,7 @@ class PodcastController {
/**
* POST: /api/podcasts/:id/download-episodes
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithLibraryItem} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async downloadEpisodes(req, res) {
@@ -350,13 +295,13 @@ class PodcastController {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`)
return res.sendStatus(403)
}
const libraryItem = req.libraryItem
const episodes = req.body
if (!Array.isArray(episodes) || !episodes.length) {
if (!episodes?.length) {
return res.sendStatus(400)
}
this.podcastManager.downloadPodcastEpisodes(req.libraryItem, episodes)
PodcastManager.downloadPodcastEpisodes(libraryItem, episodes)
res.sendStatus(200)
}
@@ -365,7 +310,7 @@ class PodcastController {
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithLibraryItem} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async quickMatchEpisodes(req, res) {
@@ -377,7 +322,8 @@ class PodcastController {
const overrideDetails = req.query.override === '1'
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
if (episodesUpdated) {
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
}
res.json({
@@ -388,82 +334,61 @@ class PodcastController {
/**
* PATCH: /api/podcasts/:id/episode/:episodeId
*
* @param {RequestWithLibraryItem} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async updateEpisode(req, res) {
/** @type {import('../models/PodcastEpisode')} */
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === req.params.episodeId)
if (!episode) {
const libraryItem = req.libraryItem
var episodeId = req.params.episodeId
if (!libraryItem.media.checkHasEpisode(episodeId)) {
return res.status(404).send('Episode not found')
}
const updatePayload = {}
const supportedStringKeys = ['title', 'subtitle', 'description', 'pubDate', 'episode', 'season', 'episodeType']
for (const key in req.body) {
if (supportedStringKeys.includes(key) && typeof req.body[key] === 'string') {
updatePayload[key] = req.body[key]
} else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {
updatePayload[key] = req.body[key]
} else if (key === 'publishedAt' && typeof req.body[key] === 'number') {
updatePayload[key] = req.body[key]
}
if (libraryItem.media.updateEpisode(episodeId, req.body)) {
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
if (Object.keys(updatePayload).length) {
episode.set(updatePayload)
if (episode.changed()) {
Logger.info(`[PodcastController] Updated episode "${episode.title}" keys`, episode.changed())
await episode.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
} else {
Logger.info(`[PodcastController] No changes to episode "${episode.title}"`)
}
}
res.json(req.libraryItem.toOldJSONExpanded())
res.json(libraryItem.toJSONExpanded())
}
/**
* GET: /api/podcasts/:id/episode/:episodeId
*
* @param {RequestWithLibraryItem} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async getEpisode(req, res) {
const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
/** @type {import('../models/PodcastEpisode')} */
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
return res.sendStatus(404)
}
res.json(episode.toOldJSON(req.libraryItem.id))
res.json(episode)
}
/**
* DELETE: /api/podcasts/:id/episode/:episodeId
*
* @param {RequestWithLibraryItem} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async removeEpisode(req, res) {
const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
const hardDelete = req.query.hard === '1'
/** @type {import('../models/PodcastEpisode')} */
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
return res.sendStatus(404)
}
// Remove it from the podcastEpisodes array
req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeId)
if (hardDelete) {
const audioFile = episode.audioFile
// TODO: this will trigger the watcher. should maybe handle this gracefully
@@ -477,8 +402,36 @@ class PodcastController {
})
}
// Remove episode from playlists
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeId])
// Remove episode from Podcast and library file
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
if (episodeRemoved?.audioFile) {
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
}
// Update/remove playlists that had this podcast episode
const playlistMediaItems = await Database.playlistMediaItemModel.findAll({
where: {
mediaItemId: episodeId
},
include: {
model: Database.playlistModel,
include: Database.playlistMediaItemModel
}
})
for (const pmi of playlistMediaItems) {
const numItems = pmi.playlist.playlistMediaItems.length - 1
if (!numItems) {
Logger.info(`[PodcastController] Playlist "${pmi.playlist.name}" has no more items - removing it`)
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded)
await pmi.playlist.destroy()
} else {
await pmi.destroy()
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_updated', jsonExpanded)
}
}
// Remove media progress for this episode
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
@@ -490,16 +443,9 @@ class PodcastController {
Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`)
}
// Remove episode
await episode.destroy()
// Remove library file
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((file) => file.ino !== episode.audioFile.ino)
req.libraryItem.changed('libraryFiles', true)
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.json(req.libraryItem.toOldJSON())
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSON())
}
/**
@@ -509,15 +455,15 @@ class PodcastController {
* @param {NextFunction} next
*/
async middleware(req, res, next) {
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!libraryItem?.media) return res.sendStatus(404)
const item = await Database.libraryItemModel.getOldById(req.params.id)
if (!item?.media) return res.sendStatus(404)
if (!libraryItem.isPodcast) {
if (!item.isPodcast) {
return res.sendStatus(500)
}
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
if (!req.user.checkCanAccessLibraryItem(item)) {
return res.sendStatus(403)
}
@@ -529,7 +475,7 @@ class PodcastController {
return res.sendStatus(403)
}
req.libraryItem = libraryItem
req.libraryItem = item
next()
}
}

View File

@@ -24,7 +24,7 @@ class SearchController {
*/
async findBooks(req, res) {
const id = req.query.id
const libraryItem = await Database.libraryItemModel.getExpandedById(id)
const libraryItem = await Database.libraryItemModel.getOldById(id)
const provider = req.query.provider || 'google'
const title = req.query.title || ''
const author = req.query.author || ''

View File

@@ -149,7 +149,7 @@ class SessionController {
* @param {Response} res
*/
async getOpenSession(req, res) {
const libraryItem = await Database.libraryItemModel.getExpandedById(req.playbackSession.libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(req.playbackSession.libraryItemId)
const sessionForClient = req.playbackSession.toJSONForClient(libraryItem)
res.json(sessionForClient)
}

View File

@@ -70,13 +70,14 @@ class ShareController {
}
try {
const libraryItem = await Database.mediaItemShareModel.getMediaItemsLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
if (!libraryItem) {
const oldLibraryItem = await Database.mediaItemShareModel.getMediaItemsOldLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
if (!oldLibraryItem) {
return res.status(404).send('Media item not found')
}
let startOffset = 0
const publicTracks = libraryItem.media.includedAudioFiles.map((audioFile) => {
const publicTracks = oldLibraryItem.media.includedAudioFiles.map((audioFile) => {
const audioTrack = {
index: audioFile.index,
startOffset,
@@ -85,7 +86,7 @@ class ShareController {
contentUrl: `${global.RouterBasePath}/public/share/${slug}/track/${audioFile.index}`,
mimeType: audioFile.mimeType,
codec: audioFile.codec || null,
metadata: structuredClone(audioFile.metadata)
metadata: audioFile.metadata.clone()
}
startOffset += audioTrack.duration
return audioTrack
@@ -104,12 +105,12 @@ class ShareController {
const deviceInfo = await this.playbackSessionManager.getDeviceInfo(req, clientDeviceInfo)
const newPlaybackSession = new PlaybackSession()
newPlaybackSession.setData(libraryItem, null, 'web-share', deviceInfo, startTime)
newPlaybackSession.setData(oldLibraryItem, null, 'web-share', deviceInfo, startTime)
newPlaybackSession.audioTracks = publicTracks
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
newPlaybackSession.shareSessionId = shareSessionId
newPlaybackSession.mediaItemShareId = mediaItemShare.id
newPlaybackSession.coverAspectRatio = libraryItem.library.settings.coverAspectRatio
newPlaybackSession.coverAspectRatio = oldLibraryItem.librarySettings.coverAspectRatio
mediaItemShare.playbackSession = newPlaybackSession.toJSONForClient()
ShareManager.addOpenSharePlaybackSession(newPlaybackSession)

View File

@@ -7,11 +7,6 @@ const Database = require('../Database')
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/LibraryItem')} libraryItem
*
* @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem
*/
class ToolsController {
@@ -23,7 +18,7 @@ class ToolsController {
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithLibraryItem} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async encodeM4b(req, res) {
@@ -32,12 +27,12 @@ class ToolsController {
return res.status(404).send('Audiobook not found')
}
if (!req.libraryItem.isBook) {
if (req.libraryItem.mediaType !== 'book') {
Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`)
return res.status(400).send('Invalid library item: not a book')
}
if (!req.libraryItem.hasAudioTracks) {
if (req.libraryItem.media.tracks.length <= 0) {
Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`)
return res.status(400).send('Invalid audiobook: no audio tracks')
}
@@ -77,11 +72,11 @@ class ToolsController {
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithLibraryItem} req
* @param {RequestWithUser} req
* @param {Response} res
*/
async embedAudioFileMetadata(req, res) {
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioTracks || !req.libraryItem.isBook) {
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
Logger.error(`[ToolsController] Invalid library item`)
return res.sendStatus(400)
}
@@ -116,7 +111,7 @@ class ToolsController {
const libraryItems = []
for (const libraryItemId of libraryItemIds) {
const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
if (!libraryItem) {
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
return res.sendStatus(404)
@@ -128,7 +123,7 @@ class ToolsController {
return res.sendStatus(403)
}
if (libraryItem.isMissing || !libraryItem.hasAudioTracks || !libraryItem.isBook) {
if (libraryItem.isMissing || !libraryItem.hasAudioFiles || !libraryItem.isBook) {
Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`)
return res.sendStatus(400)
}
@@ -162,7 +157,7 @@ class ToolsController {
}
if (req.params.id) {
const item = await Database.libraryItemModel.getExpandedById(req.params.id)
const item = await Database.libraryItemModel.getOldById(req.params.id)
if (!item?.media) return res.sendStatus(404)
// Check user can access this library item

View File

@@ -361,7 +361,7 @@ class BookFinder {
/**
* Search for books including fuzzy searches
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {Object} libraryItem
* @param {string} provider
* @param {string} title
* @param {string} author

View File

@@ -51,7 +51,7 @@ class AbMergeManager {
/**
*
* @param {string} userId
* @param {import('../models/LibraryItem')} libraryItem
* @param {import('../objects/LibraryItem')} libraryItem
* @param {AbMergeEncodeOptions} [options={}]
*/
async startAudiobookMerge(userId, libraryItem, options = {}) {
@@ -67,7 +67,7 @@ class AbMergeManager {
libraryItemId: libraryItem.id,
libraryItemDir,
userId,
originalTrackPaths: libraryItem.media.includedAudioFiles.map((t) => t.metadata.path),
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path),
inos: libraryItem.media.includedAudioFiles.map((f) => f.ino),
tempFilepath,
targetFilename,
@@ -86,9 +86,9 @@ class AbMergeManager {
key: 'MessageTaskEncodingM4b'
}
const taskDescriptionString = {
text: `Encoding audiobook "${libraryItem.media.title}" into a single m4b file.`,
text: `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`,
key: 'MessageTaskEncodingM4bDescription',
subs: [libraryItem.media.title]
subs: [libraryItem.media.metadata.title]
}
task.setData('encode-m4b', taskTitleString, taskDescriptionString, false, taskData)
TaskManager.addTask(task)
@@ -103,7 +103,7 @@ class AbMergeManager {
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {import('../objects/LibraryItem')} libraryItem
* @param {Task} task
* @param {AbMergeEncodeOptions} encodingOptions
*/
@@ -141,7 +141,7 @@ class AbMergeManager {
const embedFraction = 1 - encodeFraction
try {
const trackProgressMonitor = new TrackProgressMonitor(
libraryItem.media.includedAudioFiles.map((t) => t.duration),
libraryItem.media.tracks.map((t) => t.duration),
(trackIndex) => SocketAuthority.adminEmitter('track_started', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }),
(trackIndex, progressInTrack, taskProgress) => {
SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack })
@@ -150,7 +150,7 @@ class AbMergeManager {
(trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] })
)
task.data.ffmpeg = new Ffmpeg()
await ffmpegHelpers.mergeAudioFiles(libraryItem.media.includedAudioFiles, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
await ffmpegHelpers.mergeAudioFiles(libraryItem.media.tracks, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
delete task.data.ffmpeg
trackProgressMonitor.finish()
} catch (error) {

View File

@@ -42,7 +42,6 @@ class ApiCacheManager {
Logger.debug(`[ApiCacheManager] Skipping cache for random sort`)
return next()
}
const key = { user: req.user.username, url: req.url }
const stringifiedKey = JSON.stringify(key)
Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`)

View File

@@ -34,11 +34,6 @@ class AudioMetadataMangaer {
return this.tasksQueued.some((t) => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some((t) => t.data.libraryItemId === libraryItemId)
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @returns
*/
getMetadataObjectForApi(libraryItem) {
return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length)
}
@@ -46,8 +41,8 @@ class AudioMetadataMangaer {
/**
*
* @param {string} userId
* @param {import('../models/LibraryItem')[]} libraryItems
* @param {UpdateMetadataOptions} options
* @param {*} libraryItems
* @param {*} options
*/
handleBatchEmbed(userId, libraryItems, options = {}) {
libraryItems.forEach((li) => {
@@ -58,7 +53,7 @@ class AudioMetadataMangaer {
/**
*
* @param {string} userId
* @param {import('../models/LibraryItem')} libraryItem
* @param {import('../objects/LibraryItem')} libraryItem
* @param {UpdateMetadataOptions} [options={}]
*/
async updateMetadataForItem(userId, libraryItem, options = {}) {
@@ -108,14 +103,14 @@ class AudioMetadataMangaer {
key: 'MessageTaskEmbeddingMetadata'
}
const taskDescriptionString = {
text: `Embedding metadata in audiobook "${libraryItem.media.title}".`,
text: `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`,
key: 'MessageTaskEmbeddingMetadataDescription',
subs: [libraryItem.media.title]
subs: [libraryItem.media.metadata.title]
}
task.setData('embed-metadata', taskTitleString, taskDescriptionString, false, taskData)
if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.title}"`)
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`)
SocketAuthority.adminEmitter('metadata_embed_queue_update', {
libraryItemId: libraryItem.id,
queued: true

View File

@@ -79,12 +79,6 @@ class CoverManager {
return imgType
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} coverFile - file object from req.files
* @returns {Promise<{error:string}|{cover:string}>}
*/
async uploadCover(libraryItem, coverFile) {
const extname = Path.extname(coverFile.name.toLowerCase())
if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) {
@@ -116,19 +110,62 @@ class CoverManager {
await this.removeOldCovers(coverDirPath, extname)
await CacheManager.purgeCoverCache(libraryItem.id)
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.title}"`)
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`)
libraryItem.updateMediaCover(coverFullPath)
return {
cover: coverFullPath
}
}
/**
*
* @param {string} coverPath
* @param {import('../models/LibraryItem')} libraryItem
* @returns {Promise<{error:string}|{cover:string,updated:boolean}>}
*/
async downloadCoverFromUrl(libraryItem, url, forceLibraryItemFolder = false) {
try {
// Force save cover with library item is used for adding new podcasts
var coverDirPath = forceLibraryItemFolder ? libraryItem.path : this.getCoverDirectory(libraryItem)
await fs.ensureDir(coverDirPath)
var temppath = Path.posix.join(coverDirPath, 'cover')
let errorMsg = ''
let success = await downloadImageFile(url, temppath)
.then(() => true)
.catch((err) => {
errorMsg = err.message || 'Unknown error'
Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
return false
})
if (!success) {
return {
error: 'Failed to download image from url: ' + errorMsg
}
}
var imgtype = await this.checkFileIsValidImage(temppath, true)
if (imgtype.error) {
return imgtype
}
var coverFilename = `cover.${imgtype.ext}`
var coverFullPath = Path.posix.join(coverDirPath, coverFilename)
await fs.rename(temppath, coverFullPath)
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
await CacheManager.purgeCoverCache(libraryItem.id)
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`)
libraryItem.updateMediaCover(coverFullPath)
return {
cover: coverFullPath
}
} catch (error) {
Logger.error(`[CoverManager] Fetch cover image from url "${url}" failed`, error)
return {
error: 'Failed to fetch image from url'
}
}
}
async validateCoverPath(coverPath, libraryItem) {
// Invalid cover path
if (!coverPath || coverPath.startsWith('http:') || coverPath.startsWith('https:')) {
@@ -198,6 +235,7 @@ class CoverManager {
await CacheManager.purgeCoverCache(libraryItem.id)
libraryItem.updateMediaCover(coverPath)
return {
cover: coverPath,
updated: true
@@ -283,14 +321,13 @@ class CoverManager {
*
* @param {string} url
* @param {string} libraryItemId
* @param {string} [libraryItemPath] - null if library item isFile
* @param {boolean} [forceLibraryItemFolder=false] - force save cover with library item (used for adding new podcasts)
* @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast
* @returns {Promise<{error:string}|{cover:string}>}
*/
async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath, forceLibraryItemFolder = false) {
async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath) {
try {
let coverDirPath = null
if ((global.ServerSettings.storeCoverWithItem || forceLibraryItemFolder) && libraryItemPath) {
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
coverDirPath = libraryItemPath
} else {
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)

View File

@@ -5,11 +5,10 @@ const Database = require('../Database')
const LibraryScanner = require('../scanner/LibraryScanner')
const ShareManager = require('./ShareManager')
const PodcastManager = require('./PodcastManager')
class CronManager {
constructor(podcastManager, playbackSessionManager) {
/** @type {import('./PodcastManager')} */
this.podcastManager = podcastManager
constructor(playbackSessionManager) {
/** @type {import('./PlaybackSessionManager')} */
this.playbackSessionManager = playbackSessionManager
@@ -163,7 +162,7 @@ class CronManager {
task
})
} catch (error) {
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
Logger.error(`[CronManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
}
}
@@ -181,7 +180,7 @@ class CronManager {
// Get podcast library items to check
const libraryItems = []
for (const libraryItemId of libraryItemIds) {
const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
if (!libraryItem) {
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItemId) // Filter it out
@@ -192,7 +191,7 @@ class CronManager {
// Run episode checks
for (const libraryItem of libraryItems) {
const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem)
const keepAutoDownloading = await PodcastManager.runEpisodeCheck(libraryItem)
if (!keepAutoDownloading) {
// auto download was disabled
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItem.id) // Filter it out
@@ -215,10 +214,6 @@ class CronManager {
this.podcastCrons = this.podcastCrons.filter((pc) => pc.expression !== podcastCron.expression)
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
*/
checkUpdatePodcastCron(libraryItem) {
// Remove from old cron by library item id
const existingCron = this.podcastCrons.find((pc) => pc.libraryItemIds.includes(libraryItem.id))
@@ -234,10 +229,7 @@ class CronManager {
const cronMatchingExpression = this.podcastCrons.find((pc) => pc.expression === libraryItem.media.autoDownloadSchedule)
if (cronMatchingExpression) {
cronMatchingExpression.libraryItemIds.push(libraryItem.id)
// TODO: Update after old model removed
const podcastTitle = libraryItem.media.title || libraryItem.media.metadata?.title
Logger.info(`[CronManager] Added podcast "${podcastTitle}" to auto dl episode cron "${cronMatchingExpression.expression}"`)
Logger.info(`[CronManager] Added podcast "${libraryItem.media.metadata.title}" to auto dl episode cron "${cronMatchingExpression.expression}"`)
} else {
this.startPodcastCron(libraryItem.media.autoDownloadSchedule, [libraryItem.id])
}

View File

@@ -14,11 +14,6 @@ class NotificationManager {
return notificationData
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {import('../models/PodcastEpisode')} episode
*/
async onPodcastEpisodeDownloaded(libraryItem, episode) {
if (!Database.notificationSettings.isUseable) return
@@ -27,17 +22,17 @@ class NotificationManager {
return
}
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.title}`)
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
const eventData = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
libraryName: library?.name || 'Unknown',
mediaTags: (libraryItem.media.tags || []).join(', '),
podcastTitle: libraryItem.media.title,
podcastAuthor: libraryItem.media.author || '',
podcastDescription: libraryItem.media.description || '',
podcastGenres: (libraryItem.media.genres || []).join(', '),
podcastTitle: libraryItem.media.metadata.title,
podcastAuthor: libraryItem.media.metadata.author || '',
podcastDescription: libraryItem.media.metadata.description || '',
podcastGenres: (libraryItem.media.metadata.genres || []).join(', '),
episodeId: episode.id,
episodeTitle: episode.title,
episodeSubtitle: episode.subtitle || '',

View File

@@ -39,7 +39,7 @@ class PlaybackSessionManager {
/**
*
* @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req
* @param {import('../controllers/SessionController').RequestWithUser} req
* @param {Object} [clientDeviceInfo]
* @returns {Promise<DeviceInfo>}
*/
@@ -67,7 +67,7 @@ class PlaybackSessionManager {
/**
*
* @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req
* @param {import('../controllers/SessionController').RequestWithUser} req
* @param {import('express').Response} res
* @param {string} [episodeId]
*/
@@ -120,8 +120,8 @@ class PlaybackSessionManager {
*/
async syncLocalSession(user, sessionJson, deviceInfo) {
// TODO: Combine libraryItem query with library query
const libraryItem = await Database.libraryItemModel.getExpandedById(sessionJson.libraryItemId)
const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.podcastEpisodes.find((pe) => pe.id === sessionJson.episodeId) : null
const libraryItem = await Database.libraryItemModel.getOldById(sessionJson.libraryItemId)
const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
return {
@@ -175,8 +175,7 @@ class PlaybackSessionManager {
// New session from local
session = new PlaybackSession(sessionJson)
session.deviceInfo = deviceInfo
session.duration = libraryItem.media.getPlaybackDuration(sessionJson.episodeId)
session.setDuration(libraryItem, sessionJson.episodeId)
Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`)
await Database.createPlaybackSession(session)
} else {
@@ -280,7 +279,7 @@ class PlaybackSessionManager {
*
* @param {import('../models/User')} user
* @param {DeviceInfo} deviceInfo
* @param {import('../models/LibraryItem')} libraryItem
* @param {import('../objects/LibraryItem')} libraryItem
* @param {string|null} episodeId
* @param {{forceDirectPlay?:boolean, forceTranscode?:boolean, mediaPlayer:string, supportedMimeTypes?:string[]}} options
* @returns {Promise<PlaybackSession>}
@@ -293,7 +292,7 @@ class PlaybackSessionManager {
await this.closeSession(user, session, null)
}
const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options.supportedMimeTypes, episodeId))
const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
const mediaPlayer = options.mediaPlayer || 'unknown'
const mediaItemId = episodeId || libraryItem.media.id
@@ -301,7 +300,7 @@ class PlaybackSessionManager {
let userStartTime = 0
if (userProgress) {
if (userProgress.isFinished) {
Logger.info(`[PlaybackSessionManager] Starting session for user "${user.username}" and resetting progress for finished item "${libraryItem.media.title}"`)
Logger.info(`[PlaybackSessionManager] Starting session for user "${user.username}" and resetting progress for finished item "${libraryItem.media.metadata.title}"`)
// Keep userStartTime as 0 so the client restarts the media
} else {
userStartTime = Number.parseFloat(userProgress.currentTime) || 0
@@ -313,7 +312,7 @@ class PlaybackSessionManager {
let audioTracks = []
if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`)
audioTracks = libraryItem.getTrackList(episodeId)
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`)
@@ -343,20 +342,20 @@ class PlaybackSessionManager {
* @param {import('../models/User')} user
* @param {*} session
* @param {*} syncData
* @returns {Promise<boolean>}
* @returns
*/
async syncSession(user, session, syncData) {
// TODO: Combine libraryItem query with library query
const libraryItem = await Database.libraryItemModel.getExpandedById(session.libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId)
if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return false
return null
}
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
if (!library) {
Logger.error(`[PlaybackSessionManager] syncSession Library not found "${libraryItem.libraryId}"`)
return false
return null
}
session.currentTime = syncData.currentTime
@@ -382,8 +381,9 @@ class PlaybackSessionManager {
})
}
this.saveSession(session)
return true
return {
libraryItem
}
}
/**

View File

@@ -0,0 +1,274 @@
const Path = require('path')
const Logger = require('../Logger')
const Database = require('../Database')
const SocketAuthority = require('../SocketAuthority')
const TaskManager = require('../managers/TaskManager')
const ShareManager = require('../managers/ShareManager')
const RssFeedManager = require('../managers/RssFeedManager')
const PodcastManager = require('../managers/PodcastManager')
const fsExtra = require('../libs/fsExtra')
const { isUUID, parseSemverStrict } = require('../utils')
/**
* @typedef PluginContext
* @property {import('../Logger')} Logger
* @property {import('../Database')} Database
* @property {import('../SocketAuthority')} SocketAuthority
* @property {import('../managers/TaskManager')} TaskManager
* @property {import('../models/Plugin')} pluginInstance
* @property {import('../managers/ShareManager')} ShareManager
* @property {import('../managers/RssFeedManager')} RssFeedManager
* @property {import('../managers/PodcastManager')} PodcastManager
*/
/**
* @typedef PluginData
* @property {string} id
* @property {Object} manifest
* @property {import('../models/Plugin')} instance
* @property {Function} init
* @property {Function} onAction
* @property {Function} onConfigSave
*/
class PluginManager {
constructor() {
/** @type {PluginData[]} */
this.plugins = []
}
get pluginMetadataPath() {
return Path.posix.join(global.MetadataPath, 'plugins')
}
get pluginManifests() {
return this.plugins.map((plugin) => plugin.manifest)
}
/**
*
* @param {import('../models/Plugin')} pluginInstance
* @returns {PluginContext}
*/
getPluginContext(pluginInstance) {
return {
Logger,
Database,
SocketAuthority,
TaskManager,
pluginInstance,
ShareManager,
RssFeedManager,
PodcastManager
}
}
/**
*
* @param {string} id
* @returns {PluginData}
*/
getPluginDataById(id) {
return this.plugins.find((plugin) => plugin.manifest.id === id)
}
/**
* Validate and load a plugin from a directory
* TODO: Validatation
*
* @param {string} dirname
* @param {string} pluginPath
* @returns {Promise<PluginData>}
*/
async loadPlugin(dirname, pluginPath) {
const pluginFiles = await fsExtra.readdir(pluginPath, { withFileTypes: true }).then((files) => files.filter((file) => !file.isDirectory()))
if (!pluginFiles.length) {
Logger.error(`No files found in plugin ${pluginPath}`)
return null
}
const manifestFile = pluginFiles.find((file) => file.name === 'manifest.json')
if (!manifestFile) {
Logger.error(`No manifest found for plugin ${pluginPath}`)
return null
}
const indexFile = pluginFiles.find((file) => file.name === 'index.js')
if (!indexFile) {
Logger.error(`No index file found for plugin ${pluginPath}`)
return null
}
let manifestJson = null
try {
manifestJson = await fsExtra.readFile(Path.join(pluginPath, manifestFile.name), 'utf8').then((data) => JSON.parse(data))
} catch (error) {
Logger.error(`Error parsing manifest file for plugin ${pluginPath}`, error)
return null
}
// TODO: Validate manifest json
if (!isUUID(manifestJson.id)) {
Logger.error(`Invalid plugin ID in manifest for plugin ${pluginPath}`)
return null
}
if (!parseSemverStrict(manifestJson.version)) {
Logger.error(`Invalid plugin version in manifest for plugin ${pluginPath}`)
return null
}
// TODO: Enforcing plugin name to be the same as the directory name? Ensures plugins are identifiable in the file system. May have issues with unicode characters.
if (dirname !== manifestJson.name) {
Logger.error(`Plugin directory name "${dirname}" does not match manifest name "${manifestJson.name}"`)
return null
}
let pluginContents = null
try {
pluginContents = require(Path.join(pluginPath, indexFile.name))
} catch (error) {
Logger.error(`Error loading plugin ${pluginPath}`, error)
return null
}
if (typeof pluginContents.init !== 'function') {
Logger.error(`Plugin ${pluginPath} does not have an init function`)
return null
}
return {
id: manifestJson.id,
manifest: manifestJson,
init: pluginContents.init,
onAction: pluginContents.onAction,
onConfigSave: pluginContents.onConfigSave
}
}
/**
* Get all plugins from the /metadata/plugins directory
*/
async getPluginsFromDirPath(pluginsPath) {
// Get all directories in the plugins directory
const pluginDirs = await fsExtra.readdir(pluginsPath, { withFileTypes: true }).then((files) => files.filter((file) => file.isDirectory()))
const pluginsFound = []
for (const pluginDir of pluginDirs) {
Logger.debug(`[PluginManager] Checking if directory "${pluginDir.name}" is a plugin`)
const plugin = await this.loadPlugin(pluginDir.name, Path.join(pluginsPath, pluginDir.name))
if (plugin) {
Logger.debug(`[PluginManager] Found plugin "${plugin.manifest.name}"`)
pluginsFound.push(plugin)
}
}
return pluginsFound
}
/**
* Load plugins from the /metadata/plugins directory and update the database
*/
async loadPlugins() {
await fsExtra.ensureDir(this.pluginMetadataPath)
const pluginsFound = await this.getPluginsFromDirPath(this.pluginMetadataPath)
if (process.env.DEV_PLUGINS_PATH) {
const devPluginsFound = await this.getPluginsFromDirPath(process.env.DEV_PLUGINS_PATH)
if (!devPluginsFound.length) {
Logger.warn(`[PluginManager] No plugins found in DEV_PLUGINS_PATH: ${process.env.DEV_PLUGINS_PATH}`)
} else {
pluginsFound.push(...devPluginsFound)
}
}
const existingPlugins = await Database.pluginModel.findAll()
// Add new plugins or update existing plugins
for (const plugin of pluginsFound) {
const existingPlugin = existingPlugins.find((p) => p.id === plugin.manifest.id)
if (existingPlugin) {
// TODO: Should automatically update?
if (existingPlugin.version !== plugin.manifest.version) {
Logger.info(`[PluginManager] Updating plugin "${plugin.manifest.name}" version from "${existingPlugin.version}" to version "${plugin.manifest.version}"`)
await existingPlugin.update({ version: plugin.manifest.version, isMissing: false })
} else if (existingPlugin.isMissing) {
Logger.info(`[PluginManager] Plugin "${plugin.manifest.name}" was missing but is now found`)
await existingPlugin.update({ isMissing: false })
} else {
Logger.debug(`[PluginManager] Plugin "${plugin.manifest.name}" already exists in the database with version "${plugin.manifest.version}"`)
}
plugin.instance = existingPlugin
} else {
plugin.instance = await Database.pluginModel.create({
id: plugin.manifest.id,
name: plugin.manifest.name,
version: plugin.manifest.version
})
Logger.info(`[PluginManager] Added plugin "${plugin.manifest.name}" to the database`)
}
}
// Mark missing plugins
for (const plugin of existingPlugins) {
const foundPlugin = pluginsFound.find((p) => p.manifest.id === plugin.id)
if (!foundPlugin && !plugin.isMissing) {
Logger.info(`[PluginManager] Plugin "${plugin.name}" not found or invalid - marking as missing`)
await plugin.update({ isMissing: true })
}
}
this.plugins = pluginsFound
}
/**
* Load and initialize all plugins
*/
async init() {
await this.loadPlugins()
for (const plugin of this.plugins) {
Logger.info(`[PluginManager] Initializing plugin ${plugin.manifest.name}`)
plugin.init(this.getPluginContext(plugin.instance))
}
}
/**
*
* @param {PluginData} plugin
* @param {string} actionName
* @param {string} target
* @param {Object} data
* @returns {Promise<boolean|{error:string}>}
*/
onAction(plugin, actionName, target, data) {
if (!plugin.onAction) {
Logger.error(`[PluginManager] onAction not implemented for plugin ${plugin.manifest.name}`)
return false
}
const pluginExtension = plugin.manifest.extensions.find((extension) => extension.name === actionName)
if (!pluginExtension) {
Logger.error(`[PluginManager] Extension ${actionName} not found for plugin ${plugin.manifest.name}`)
return false
}
Logger.info(`[PluginManager] Calling onAction for plugin ${plugin.manifest.name}`)
return plugin.onAction(this.getPluginContext(plugin.instance), actionName, target, data)
}
/**
*
* @param {PluginData} plugin
* @param {Object} config
* @returns {Promise<boolean|{error:string}>}
*/
onConfigSave(plugin, config) {
if (!plugin.onConfigSave) {
Logger.error(`[PluginManager] onConfigSave not implemented for plugin ${plugin.manifest.name}`)
return false
}
Logger.info(`[PluginManager] Calling onConfigSave for plugin ${plugin.manifest.name}`)
return plugin.onConfigSave(this.getPluginContext(plugin.instance), config)
}
}
module.exports = new PluginManager()

View File

@@ -1,4 +1,3 @@
const Path = require('path')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
@@ -20,7 +19,9 @@ const NotificationManager = require('../managers/NotificationManager')
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() {
@@ -51,16 +52,15 @@ class PodcastManager {
}
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {import('../utils/podcastUtils').RssPodcastEpisode[]} episodesToDownload
* @param {boolean} isAutoDownload - If this download was triggered by auto download
*/
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
let index = Math.max(...libraryItem.media.episodes.filter((ep) => ep.index == null || isNaN(ep.index)).map((ep) => Number(ep.index))) + 1
for (const ep of episodesToDownload) {
const newPe = new PodcastEpisode()
newPe.setData(ep, index++)
newPe.libraryItemId = libraryItem.id
newPe.podcastId = libraryItem.media.id
const newPeDl = new PodcastEpisodeDownload()
newPeDl.setData(ep, libraryItem, isAutoDownload, libraryItem.libraryId)
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId)
this.startPodcastEpisodeDownload(newPeDl)
}
}
@@ -86,20 +86,20 @@ class PodcastManager {
key: 'MessageDownloadingEpisode'
}
const taskDescriptionString = {
text: `Downloading episode "${podcastEpisodeDownload.episodeTitle}".`,
text: `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`,
key: 'MessageTaskDownloadingEpisodeDescription',
subs: [podcastEpisodeDownload.episodeTitle]
subs: [podcastEpisodeDownload.podcastEpisode.title]
}
const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData)
SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
this.currentDownload = podcastEpisodeDownload
// If this file already exists then append a uuid to the filename
// If this file already exists then append the episode id to the filename
// e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3"
// this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802)
if (await fs.pathExists(this.currentDownload.targetPath)) {
this.currentDownload.appendRandomId = true
this.currentDownload.appendEpisodeId = true
}
// Ignores all added files to this dir
@@ -115,24 +115,10 @@ class PodcastManager {
let success = false
if (this.currentDownload.isMp3) {
// Download episode and tag it
const ffmpegDownloadResponse = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
return false
})
success = !!ffmpegDownloadResponse?.success
// If failed due to ffmpeg error, retry without tagging
// e.g. RSS feed may have incorrect file extension and file type
// See https://github.com/advplyr/audiobookshelf/issues/3837
if (!success && ffmpegDownloadResponse?.isFfmpegError) {
Logger.info(`[PodcastManager] Retrying episode download without tagging`)
// Download episode only
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath)
.then(() => true)
.catch((error) => {
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
return false
})
}
} else {
// Download episode only
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath)
@@ -154,7 +140,7 @@ class PodcastManager {
}
task.setFailed(taskFailedString)
} else {
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.episodeTitle}"`)
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`)
this.currentDownload.setFinished(true)
task.setFinished()
}
@@ -180,61 +166,47 @@ class PodcastManager {
}
}
/**
* Scans the downloaded audio file, create the podcast episode, remove oldest episode if necessary
* @returns {Promise<boolean>} - Returns true if added
*/
async scanAddPodcastEpisodeAudioFile() {
const libraryFile = new LibraryFile()
await libraryFile.setDataFromPath(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
const libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
const audioFile = await this.probeAudioFile(libraryFile)
if (!audioFile) {
return false
}
const libraryItem = await Database.libraryItemModel.getExpandedById(this.currentDownload.libraryItem.id)
const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id)
if (!libraryItem) {
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
return false
}
const podcastEpisode = await Database.podcastEpisodeModel.createFromRssPodcastEpisode(this.currentDownload.rssPodcastEpisode, libraryItem.media.id, audioFile)
const podcastEpisode = this.currentDownload.podcastEpisode
podcastEpisode.audioFile = audioFile
libraryItem.libraryFiles.push(libraryFile.toJSON())
libraryItem.changed('libraryFiles', true)
if (audioFile.chapters?.length) {
podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch }))
}
libraryItem.media.podcastEpisodes.push(podcastEpisode)
libraryItem.media.addPodcastEpisode(podcastEpisode)
if (libraryItem.isInvalid) {
// First episode added to an empty podcast
libraryItem.isInvalid = false
}
libraryItem.libraryFiles.push(libraryFile)
if (this.currentDownload.isAutoDownload) {
// Check setting maxEpisodesToKeep and remove episode if necessary
const numEpisodesWithPubDate = libraryItem.media.podcastEpisodes.filter((ep) => !!ep.publishedAt).length
if (libraryItem.media.maxEpisodesToKeep && numEpisodesWithPubDate > libraryItem.media.maxEpisodesToKeep) {
Logger.info(`[PodcastManager] # of episodes (${numEpisodesWithPubDate}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`)
const episodeToRemove = await this.getRemoveOldestEpisode(libraryItem, podcastEpisode.id)
if (episodeToRemove) {
// Remove episode from playlists
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id])
// Remove media progress for this episode
await Database.mediaProgressModel.destroy({
where: {
mediaItemId: episodeToRemove.id
}
})
await episodeToRemove.destroy()
libraryItem.media.podcastEpisodes = libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeToRemove.id)
// Remove library file
libraryItem.libraryFiles = libraryItem.libraryFiles.filter((lf) => lf.ino !== episodeToRemove.audioFile.ino)
}
if (libraryItem.media.maxEpisodesToKeep && libraryItem.media.episodesWithPubDate.length > libraryItem.media.maxEpisodesToKeep) {
Logger.info(`[PodcastManager] # of episodes (${libraryItem.media.episodesWithPubDate.length}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`)
await this.removeOldestEpisode(libraryItem, podcastEpisode.id)
}
}
await libraryItem.save()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id)
podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded()
libraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
const podcastEpisodeExpanded = podcastEpisode.toJSONExpanded()
podcastEpisodeExpanded.libraryItem = libraryItem.toJSONExpanded()
SocketAuthority.emitter('episode_added', podcastEpisodeExpanded)
if (this.currentDownload.isAutoDownload) {
@@ -245,53 +217,45 @@ class PodcastManager {
return true
}
/**
* Find oldest episode publishedAt and delete the audio file
*
* @param {import('../models/LibraryItem').LibraryItemExpanded} libraryItem
* @param {string} episodeIdJustDownloaded
* @returns {Promise<import('../models/PodcastEpisode')|null>} - Returns the episode to remove
*/
async getRemoveOldestEpisode(libraryItem, episodeIdJustDownloaded) {
let smallestPublishedAt = 0
/** @type {import('../models/PodcastEpisode')} */
let oldestEpisode = null
/** @type {import('../models/PodcastEpisode')[]} */
const podcastEpisodes = libraryItem.media.podcastEpisodes
for (const ep of podcastEpisodes) {
if (ep.id === episodeIdJustDownloaded || !ep.publishedAt) continue
if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) {
smallestPublishedAt = ep.publishedAt
oldestEpisode = ep
}
}
async removeOldestEpisode(libraryItem, episodeIdJustDownloaded) {
var smallestPublishedAt = 0
var oldestEpisode = null
libraryItem.media.episodesWithPubDate
.filter((ep) => ep.id !== episodeIdJustDownloaded)
.forEach((ep) => {
if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) {
smallestPublishedAt = ep.publishedAt
oldestEpisode = ep
}
})
// TODO: Should we check for open playback sessions for this episode?
// TODO: remove all user progress for this episode
if (oldestEpisode?.audioFile) {
Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`)
const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path)
if (successfullyDeleted) {
return oldestEpisode
libraryItem.media.removeEpisode(oldestEpisode.id)
libraryItem.removeLibraryFile(oldestEpisode.audioFile.ino)
return true
} else {
Logger.warn(`[PodcastManager] Failed to remove oldest episode "${oldestEpisode.title}"`)
}
}
return null
return false
}
async getLibraryFile(path, relPath) {
var newLibFile = new LibraryFile()
await newLibFile.setDataFromPath(path, relPath)
return newLibFile
}
/**
*
* @param {LibraryFile} libraryFile
* @returns {Promise<AudioFile|null>}
*/
async probeAudioFile(libraryFile) {
const path = libraryFile.metadata.path
const mediaProbeData = await prober.probe(path)
if (mediaProbeData.error) {
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
return null
return false
}
const newAudioFile = new AudioFile()
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
@@ -299,23 +263,18 @@ class PodcastManager {
return newAudioFile
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @returns {Promise<boolean>} - Returns false if auto download episodes was disabled (disabled if reaches max failed checks)
*/
// Returns false if auto download episodes was disabled (disabled if reaches max failed checks)
async runEpisodeCheck(libraryItem) {
const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
const latestEpisodePublishedAt = libraryItem.media.getLatestEpisodePublishedAt()
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" | Last check: ${new Date(lastEpisodeCheck)} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
// lastEpisodeCheckDate will be the current time when adding a new podcast
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheck
// lastEpisodeCheck will be the current time when adding a new podcast
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheck
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)
Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`)
if (!newEpisodes) {
@@ -324,48 +283,37 @@ class PodcastManager {
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
this.failedCheckMap[libraryItem.id]++
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`)
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`)
libraryItem.media.autoDownloadEpisodes = false
delete this.failedCheckMap[libraryItem.id]
} else {
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}"`)
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
}
} else if (newEpisodes.length) {
delete this.failedCheckMap[libraryItem.id]
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`)
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
this.downloadPodcastEpisodes(libraryItem, newEpisodes, true)
} else {
delete this.failedCheckMap[libraryItem.id]
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.title}"`)
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
}
libraryItem.media.lastEpisodeCheck = new Date()
await libraryItem.media.save()
libraryItem.changed('updatedAt', true)
await libraryItem.save()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
libraryItem.media.lastEpisodeCheck = Date.now()
libraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
return libraryItem.media.autoDownloadEpisodes
}
/**
*
* @param {import('../models/LibraryItem')} podcastLibraryItem
* @param {number} dateToCheckForEpisodesAfter - Unix timestamp
* @param {number} maxNewEpisodes
* @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]|null>}
*/
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) {
if (!podcastLibraryItem.media.feedURL) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`)
return null
if (!podcastLibraryItem.media.metadata.feedUrl) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
return false
}
const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL)
const feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
if (!feed?.episodes) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed)
return null
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed)
return false
}
// Filter new and not already has
@@ -378,34 +326,23 @@ class PodcastManager {
return newEpisodes
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} maxEpisodesToDownload
* @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]>}
*/
async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) {
const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem.media.lastEpisodeCheck : 'Never'
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.title}" - Last episode check: ${lastEpisodeCheckDate}`)
const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, lastEpisodeCheck, maxEpisodesToDownload)
if (newEpisodes?.length) {
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`)
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck, maxEpisodesToDownload)
if (newEpisodes.length) {
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
this.downloadPodcastEpisodes(libraryItem, newEpisodes, false)
} else {
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.title}"`)
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.metadata.title}"`)
}
libraryItem.media.lastEpisodeCheck = new Date()
await libraryItem.media.save()
libraryItem.media.lastEpisodeCheck = Date.now()
libraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
libraryItem.changed('updatedAt', true)
await libraryItem.save()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
return newEpisodes || []
return newEpisodes
}
async findEpisode(rssFeedUrl, searchTitle) {
@@ -581,123 +518,64 @@ class PodcastManager {
continue
}
let newLibraryItem = null
const transaction = await Database.sequelize.transaction()
try {
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
const podcastPayload = {
autoDownloadEpisodes,
metadata: {
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 podcast = await Database.podcastModel.createFromRequest(podcastPayload, transaction)
newLibraryItem = await Database.libraryItemModel.create(
{
ino: libraryItemFolderStats.ino,
path: podcastPath,
relPath: podcastFilename,
mediaId: podcast.id,
mediaType: 'podcast',
isFile: false,
isMissing: false,
isInvalid: false,
mtime: libraryItemFolderStats.mtimeMs || 0,
ctime: libraryItemFolderStats.ctimeMs || 0,
birthtime: libraryItemFolderStats.birthtimeMs || 0,
size: 0,
libraryFiles: [],
extraData: {},
libraryId: folder.libraryId,
libraryFolderId: folder.id
},
{ transaction }
)
await transaction.commit()
} catch (error) {
await transaction.rollback()
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast library item for "${feed.metadata.title}"`, error)
const taskTitleStringFeed = {
text: 'OPML import feed',
key: 'MessageTaskOpmlImportFeed'
}
const taskDescriptionStringPodcast = {
text: `Creating podcast "${feed.metadata.title}"`,
key: 'MessageTaskOpmlImportFeedPodcastDescription',
subs: [feed.metadata.title]
}
const taskErrorString = {
text: 'Failed to create podcast library item',
key: 'MessageTaskOpmlImportFeedPodcastFailed'
}
TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString)
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
}
newLibraryItem.media = await newLibraryItem.getMediaExpanded()
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 (typeof feed.metadata.image === 'string' && feed.metadata.image.startsWith('http')) {
if (newPodcastMetadata.imageUrl) {
// TODO: Scan cover image to library files
// Podcast cover will always go into library item folder
const coverResponse = await CoverManager.downloadCoverFromUrlNew(feed.metadata.image, newLibraryItem.id, newLibraryItem.path, true)
if (coverResponse.error) {
Logger.error(`[PodcastManager] Download cover error from "${feed.metadata.image}": ${coverResponse.error}`)
} else if (coverResponse.cover) {
const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
if (!coverImageFileStats) {
Logger.error(`[PodcastManager] Failed to get cover image stats for "${coverResponse.cover}"`)
} else {
// Add libraryFile to libraryItem and coverPath to podcast
const newLibraryFile = {
ino: coverImageFileStats.ino,
fileType: 'image',
addedAt: Date.now(),
updatedAt: Date.now(),
metadata: {
filename: Path.basename(coverResponse.cover),
ext: Path.extname(coverResponse.cover).slice(1),
path: coverResponse.cover,
relPath: Path.basename(coverResponse.cover),
size: coverImageFileStats.size,
mtimeMs: coverImageFileStats.mtimeMs || 0,
ctimeMs: coverImageFileStats.ctimeMs || 0,
birthtimeMs: coverImageFileStats.birthtimeMs || 0
}
}
newLibraryItem.libraryFiles.push(newLibraryFile)
newLibraryItem.changed('libraryFiles', true)
await newLibraryItem.save()
newLibraryItem.media.coverPath = coverResponse.cover
await newLibraryItem.media.save()
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
}
}
}
SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
await Database.createLibraryItem(libraryItem)
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
// Turn on podcast auto download cron if not already on
if (newLibraryItem.media.autoDownloadEpisodes) {
cronManager.checkUpdatePodcastCron(newLibraryItem)
if (libraryItem.media.autoDownloadEpisodes) {
cronManager.checkUpdatePodcastCron(libraryItem)
}
numPodcastsAdded++
}
const taskFinishedString = {
text: `Added ${numPodcastsAdded} podcasts`,
key: 'MessageTaskOpmlImportFinished',
@@ -708,4 +586,4 @@ class PodcastManager {
Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`)
}
}
module.exports = PodcastManager
module.exports = new PodcastManager()

View File

@@ -98,22 +98,11 @@ class RssFeedManager {
podcastId: feed.entity.mediaId
},
attributes: ['id', 'updatedAt'],
order: [['updatedAt', 'DESC']]
order: [['createdAt', 'DESC']]
})
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt
}
} else {
const book = await Database.bookModel.findOne({
where: {
id: feed.entity.mediaId
},
attributes: ['id', 'updatedAt']
})
if (book && book.updatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = book.updatedAt
}
}
return newEntityUpdatedAt > feed.entityUpdatedAt
@@ -122,7 +111,7 @@ class RssFeedManager {
attributes: ['id', 'updatedAt'],
include: {
model: Database.bookModel,
attributes: ['id', 'audioFiles', 'updatedAt'],
attributes: ['id'],
through: {
attributes: []
},
@@ -133,16 +122,13 @@ class RssFeedManager {
}
})
const totalBookTracks = feed.entity.books.reduce((total, book) => total + book.includedAudioFiles.length, 0)
if (feed.feedEpisodes.length !== totalBookTracks) {
return true
}
let newEntityUpdatedAt = feed.entity.updatedAt
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
let updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
return updatedAt > mostRecent ? updatedAt : mostRecent
if (book.libraryItem.updatedAt > mostRecent) {
return book.libraryItem.updatedAt
}
return mostRecent
}, 0)
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
@@ -165,9 +151,6 @@ class RssFeedManager {
let feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
},
include: {
model: Database.feedEpisodeModel
}
})
if (!feed) {
@@ -180,6 +163,8 @@ class RssFeedManager {
if (feedRequiresUpdate) {
Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
feed = await feed.updateFeedForEntity()
} else {
feed.feedEpisodes = await feed.getFeedEpisodes()
}
const xml = feed.buildXml(req.originalHostPrefix)

View File

@@ -12,4 +12,3 @@ Please add a record of every database migration that you create to this file. Th
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables |
| v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table |
| v2.17.7 | v2.17.7-add-indices | Adds indices to the libraryItems and books tables to reduce query times |

View File

@@ -1,83 +0,0 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
const migrationVersion = '2.17.7'
const migrationName = `${migrationVersion}-add-indices`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This upward migration adds some indices to the libraryItems and books tables to improve query performance
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])
await addIndex(queryInterface, logger, 'books', ['duration'])
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This downward migration script removes the indices added in the upward migration script
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])
await removeIndex(queryInterface, logger, 'books', ['duration'])
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
/**
* Utility function to add an index to a table. If the index already exists, it logs a message and continues.
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {import ('../Logger')} logger
* @param {string} tableName
* @param {string[]} columns
*/
async function addIndex(queryInterface, logger, tableName, columns) {
try {
logger.info(`${loggerPrefix} adding index [${columns.join(', ')}] to table "${tableName}"`)
await queryInterface.addIndex(tableName, columns)
logger.info(`${loggerPrefix} added index [${columns.join(', ')}] to table "${tableName}"`)
} catch (error) {
if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) {
logger.info(`${loggerPrefix} index [${columns.join(', ')}] for table "${tableName}" already exists`)
} else {
throw error
}
}
}
/**
* Utility function to remove an index from a table.
* Sequelize implemets it using DROP INDEX IF EXISTS, so it won't throw an error if the index doesn't exist.
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {import ('../Logger')} logger
* @param {string} tableName
* @param {string[]} columns
*/
async function removeIndex(queryInterface, logger, tableName, columns) {
logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`)
await queryInterface.removeIndex(tableName, columns)
logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`)
}
module.exports = { up, down }

Some files were not shown because too many files have changed in this diff Show More