Merge branch 'master' into multi-select-keyboard-navigation

This commit is contained in:
advplyr 2024-12-25 09:04:43 -06:00
commit 13f73cc79d
138 changed files with 5440 additions and 1974 deletions

33
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,33 @@
<!--
For Work In Progress Pull Requests, please use the Draft PR feature,
see https://github.blog/2019-02-14-introducing-draft-pull-requests/ for further details.
If you do not follow this template, the PR may be closed without review.
Please ensure all checks pass.
If you are a new contributor, the workflows will need to be manually approved before they run.
-->
## Brief summary
<!-- Please provide a brief summary of what your PR attempts to achieve. -->
## Which issue is fixed?
<!-- Which issue number does this PR fix? Ex: "Fixes #1234" -->
## In-depth Description
<!--
Describe your solution in more depth.
How does it work? Why is this the best solution?
Does it solve a problem that affects multiple users or is this an edge case for your setup?
-->
## How have you tested this?
<!-- Please describe in detail with reproducible steps how you tested your changes. -->
## Screenshots
<!-- If your PR includes any changes to the web client, please include screenshots or a short video from before and after your changes. -->

1
.gitignore vendored
View File

@ -7,6 +7,7 @@
/podcasts/ /podcasts/
/media/ /media/
/metadata/ /metadata/
/plugins/
/client/.nuxt/ /client/.nuxt/
/client/dist/ /client/dist/
/dist/ /dist/

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-16 bg-primary relative"> <div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60"> <div id="appbar" role="toolbar" aria-label="Appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<nuxt-link to="/"> <nuxt-link to="/">
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" /> <img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />

View File

@ -17,7 +17,7 @@
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e"> <div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
<template v-for="(shelf, index) in supportedShelves"> <template v-for="(shelf, index) in supportedShelves">
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)"> <widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
<p class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</p> <h2 class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</h2>
</widgets-item-slider> </widgets-item-slider>
</template> </template>
</div> </div>

View File

@ -37,18 +37,18 @@
<div class="relative"> <div class="relative">
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md"> <div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
<p :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</p> <h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</h2>
</div> </div>
</div> </div>
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div> <div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
</div> </div>
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft"> <button v-show="canScrollLeft && !isScrolling" :aria-label="$strings.ButtonScrollLeft" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span> <span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
</div> </button>
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight"> <button v-show="canScrollRight && !isScrolling" :aria-label="$strings.ButtonScrollRight" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span> <span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
</div> </button>
</div> </div>
</template> </template>

View File

@ -42,8 +42,11 @@
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonAdd }}</p> <p class="text-sm">{{ $strings.ButtonAdd }}</p>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonDownloadQueue }}</p>
</nuxt-link>
</div> </div>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8"> <div id="toolbar" role="toolbar" aria-label="Library Toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
<!-- Series books page --> <!-- Series books page -->
<template v-if="selectedSeries"> <template v-if="selectedSeries">
<p class="pl-2 text-base md:text-lg"> <p class="pl-2 text-base md:text-lg">
@ -265,6 +268,9 @@ export default {
isPodcastLatestPage() { isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest' return this.$route.name === 'library-library-podcast-latest'
}, },
isPodcastDownloadQueuePage() {
return this.$route.name === 'library-library-podcast-download-queue'
},
isAuthorsPage() { isAuthorsPage() {
return this.page === 'authors' return this.page === 'authors'
}, },

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div role="toolbar" aria-orientation="vertical" aria-label="Config Sidebar">
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside"> <div role="navigation" aria-label="Config Navigation" class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer"> <div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-symbols text-2xl">arrow_back</span> <span class="material-symbols text-2xl">arrow_back</span>
</div> </div>

View File

@ -53,7 +53,6 @@
@showBookmarks="showBookmarks" @showBookmarks="showBookmarks"
@showSleepTimer="showSleepTimerModal = true" @showSleepTimer="showSleepTimerModal = true"
@showPlayerQueueItems="showPlayerQueueItemsModal = true" @showPlayerQueueItems="showPlayerQueueItemsModal = true"
@showPlayerSettings="showPlayerSettingsModal = true"
/> />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" /> <modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
@ -61,8 +60,6 @@
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" /> <modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" /> <modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div> </div>
</template> </template>
@ -81,7 +78,6 @@ export default {
currentTime: 0, currentTime: 0,
showSleepTimerModal: false, showSleepTimerModal: false,
showPlayerQueueItemsModal: false, showPlayerQueueItemsModal: false,
showPlayerSettingsModal: false,
sleepTimerSet: false, sleepTimerSet: false,
sleepTimerRemaining: 0, sleepTimerRemaining: 0,
sleepTimerType: null, sleepTimerType: null,

View File

@ -1,9 +1,9 @@
<template> <template>
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }"> <div role="toolbar" aria-orientation="vertical" aria-label="Library Sidebar" class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar --> <!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" /> <div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<div id="siderail-buttons-container" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden"> <div id="siderail-buttons-container" role="navigation" aria-label="Library Navigation" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />

View File

@ -1,5 +1,5 @@
<template> <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}`"> <nuxt-link :to="`/author/${author?.id}`">
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave"> <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"> <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>
</div> </div>
</nuxt-link> </nuxt-link>
</div> </article>
</template> </template>
<script> <script>

View File

@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`book-card-${index}`" :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 ' }"> <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 --> <!-- 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"> <div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
@ -14,21 +14,21 @@
</div> </div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10"> <div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }"> <div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" aria-hidden="true" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p> <p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
</div> </div>
<!-- Cover Image --> <!-- Cover Image -->
<img cy-id="coverImage" v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" /> <img cy-id="coverImage" v-show="libraryItem" :alt="`${displayTitle}, ${$strings.LabelCover}`" ref="cover" aria-hidden="true" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Placeholder Cover Title & Author --> <!-- Placeholder Cover Title & Author -->
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }"> <div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
<div> <div>
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p> <p cy-id="placeholderTitleText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
</div> </div>
</div> </div>
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }"> <div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p> <p cy-id="placeholderAuthorText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
</div> </div>
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f"> <div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f">
@ -93,11 +93,11 @@
<!-- rss feed icon --> <!-- rss feed icon -->
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }"> <div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span> <span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
</div> </div>
<!-- media item shared icon --> <!-- media item shared icon -->
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }"> <div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">public</span> <span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">public</span>
</div> </div>
<!-- Series sequence --> <!-- Series sequence -->
@ -114,7 +114,7 @@
<!-- Podcast Num Episodes --> <!-- Podcast Num Episodes -->
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }"> <div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodes }}</p> <p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfEpisodes">{{ numEpisodes }}</p>
</div> </div>
<!-- Podcast Num Episodes --> <!-- Podcast Num Episodes -->
@ -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 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' }"> <div :style="{ fontSize: 0.9 + 'em' }">
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center"> <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" /> <widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
</ui-tooltip> </ui-tooltip>
</div> </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="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> <p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
</div> </div>
</div> </article>
</template> </template>
<script> <script>

View File

@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`collection-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <div ref="card" :id="`collection-card-${index}`" role="button" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="relative" :style="{ height: coverHeight + 'px' }"> <div class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <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"> <div class="w-full h-full bg-primary relative rounded overflow-hidden">

View File

@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <div ref="card" :id="`playlist-card-${index}`" role="button" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="relative" :style="{ height: coverHeight + 'px' }"> <div class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <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"> <div class="w-full h-full bg-primary relative rounded overflow-hidden">

View File

@ -1,5 +1,5 @@
<template> <template>
<div cy-id="card" ref="card" :id="`series-card-${index}`" :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 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="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"> <div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
@ -7,12 +7,12 @@
</div> </div>
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd"> <div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
<p :style="{ fontSize: 0.8 + 'em' }">{{ books.length }}</p> <p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfBooks">{{ books.length }}</p>
</div> </div>
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" /> <div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }"> <div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" aria-hidden="true" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p> <p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
</div> </div>
@ -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 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` }"> <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> </div>
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center"> <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> <p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
</div> </div>
</div> </article>
</template> </template>
<script> <script>

View File

@ -1,13 +1,13 @@
<template> <template>
<div class=""> <div class="">
<div class="w-full relative sm:w-80"> <div class="w-full relative sm:w-80">
<form @submit.prevent="submitSearch"> <form role="search" @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" /> <ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form> </form>
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear"> <button :aria-hidden="!search" class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
<span v-if="!search" class="material-symbols" style="font-size: 1.2rem">&#xe8b6;</span> <span v-if="!search" class="material-symbols" style="font-size: 1.2rem">&#xe8b6;</span>
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span> <span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
</div> </button>
</div> </div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu" @mousedown.stop.prevent> <div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu" @mousedown.stop.prevent>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">

View File

@ -1,28 +1,30 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu"> <div class="relative h-7">
<span class="flex items-center justify-between"> <button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <span class="flex items-center justify-between">
</span> <span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span>
</button>
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg> </svg>
</span> </span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected"> <button v-else :aria-label="$strings.ButtonClearFilter" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<span class="material-symbols" style="font-size: 1.1rem">close</span> <span class="material-symbols" style="font-size: 1.1rem">close</span>
</div> </button>
</button> </div>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu"> <div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul v-show="!sublist" class="h-full w-full" role="menu">
<template v-for="item in selectItems"> <template v-for="item in selectItems">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" :aria-haspopup="item.sublist ? '' : 'menu'" @click="clickedOption(item)">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span> <span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
</div> </div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center"> <div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-symbols text-2xl">arrow_right</span> <span class="material-symbols text-2xl" :aria-label="$strings.LabelMore">arrow_right</span>
</div> </div>
<!-- selected checkmark icon --> <!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none"> <div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
@ -31,8 +33,8 @@
</li> </li>
</template> </template>
</ul> </ul>
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul v-show="sublist" class="h-full w-full" role="menu">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null"> <li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="menuitem" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center"> <div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-symbols text-2xl">arrow_left</span> <span class="material-symbols text-2xl">arrow_left</span>
</div> </div>
@ -40,13 +42,13 @@
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span> <span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
</div> </div>
</li> </li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option"> <li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="menuitem">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span> <span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
</div> </div>
</li> </li>
<template v-for="item in sublistItems"> <template v-for="item in sublistItems">
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)"> <li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedSublistOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span> <span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div> </div>

View File

@ -1,20 +1,20 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu"> <button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between"> <span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span> </span>
</button> </button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label"> <ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
<template v-for="item in selectItems"> <template v-for="item in selectItems">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span> <span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div> </div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span> </span>
</li> </li>
</template> </template>

View File

@ -1,20 +1,20 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu"> <button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between"> <span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span> </span>
</button> </button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label"> <ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span> <span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div> </div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span> </span>
</li> </li>
</template> </template>

View File

@ -121,6 +121,8 @@ export default {
var img = document.createElement('img') var img = document.createElement('img')
img.src = src img.src = src
img.alt = `${this.name}, ${this.$strings.LabelCover}`
img.ariaHidden = true
img.className = 'absolute top-0 left-0 w-full h-full' img.className = 'absolute top-0 left-0 w-full h-full'
img.style.objectFit = showCoverBg ? 'contain' : 'cover' img.style.objectFit = showCoverBg ? 'contain' : 'cover'

View File

@ -54,8 +54,7 @@ export default {
options: { options: {
provider: undefined, provider: undefined,
overrideDetails: true, overrideDetails: true,
overrideCover: true, overrideCover: true
overrideDefaults: true
} }
} }
}, },
@ -99,8 +98,8 @@ export default {
init() { init() {
// If we don't have a set provider (first open of dialog) or we've switched library, set // If we don't have a set provider (first open of dialog) or we've switched library, set
// the selected provider to the current library default provider // the selected provider to the current library default provider
if (!this.options.provider || this.options.lastUsedLibrary != this.currentLibraryId) { if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
this.options.lastUsedLibrary = this.currentLibraryId this.lastUsedLibrary = this.currentLibraryId
this.options.provider = this.libraryProvider this.options.provider = this.libraryProvider
} }
}, },

View File

@ -1,12 +1,12 @@
<template> <template>
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`"> <div ref="wrapper" role="dialog" aria-modal="true" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" /> <div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose"> <button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
<span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span> <span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
</button> </button>
<slot name="outer" /> <slot name="outer" />
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg"> <div ref="content" tabindex="0" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white outline-none" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<slot /> <slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center"> <div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator /> <ui-loading-indicator />
@ -126,6 +126,9 @@ export default {
this.$eventBus.$on('modal-hotkey', this.hotkey) this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$store.commit('setOpenModal', this.name) this.$store.commit('setOpenModal', this.name)
// Set focus to the modal content
this.content.focus()
}, },
setHide() { setHide() {
if (this.content) this.content.style.transform = 'scale(0)' if (this.content) this.content.style.transform = 'scale(0)'

View File

@ -59,12 +59,19 @@ export default {
setJumpBackwardAmount(val) { setJumpBackwardAmount(val) {
this.jumpBackwardAmount = val this.jumpBackwardAmount = val
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val }) this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
},
settingsUpdated() {
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
} }
}, },
mounted() { mounted() {
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') this.settingsUpdated()
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') this.$eventBus.$on('user-settings', this.settingsUpdated)
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') },
beforeDestroy() {
this.$eventBus.$off('user-settings', this.settingsUpdated)
} }
} }
</script> </script>

View File

@ -2,7 +2,7 @@
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'"> <modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">Changelog</p> <h1 class="text-3xl text-white truncate">Changelog</h1>
</div> </div>
</template> </template>
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh"> <div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
@ -13,7 +13,7 @@
</p> </p>
<div class="custom-text" v-html="getChangelog(release)" /> <div class="custom-text" v-html="getChangelog(release)" />
</div> </div>
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" class="border-b border-black-300 my-8" /> <div v-if="release !== releasesToShow[releasesToShow.length - 1]" :key="`${release.name}-divider`" class="border-b border-black-300 my-8" />
</template> </template>
</div> </div>
</modals-modal> </modals-modal>

View File

@ -18,6 +18,23 @@
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p> <p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
<div v-if="description" dir="auto" class="default-style" v-html="description" /> <div v-if="description" dir="auto" class="default-style" v-html="description" />
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p> <p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
<div class="w-full h-px bg-white/5 my-4" />
<div class="flex items-center">
<div class="flex-grow">
<p class="font-semibold text-xs mb-1">{{ $strings.LabelFilename }}</p>
<p class="mb-2 text-xs">
{{ audioFileFilename }}
</p>
</div>
<div class="flex-grow">
<p class="font-semibold text-xs mb-1">{{ $strings.LabelSize }}</p>
<p class="mb-2 text-xs">
{{ audioFileSize }}
</p>
</div>
</div>
</div> </div>
</modals-modal> </modals-modal>
</template> </template>
@ -54,7 +71,7 @@ export default {
return this.episode.description || '' return this.episode.description || ''
}, },
media() { media() {
return this.libraryItem ? this.libraryItem.media || {} : {} return this.libraryItem?.media || {}
}, },
mediaMetadata() { mediaMetadata() {
return this.media.metadata || {} return this.media.metadata || {}
@ -65,6 +82,14 @@ export default {
podcastAuthor() { podcastAuthor() {
return this.mediaMetadata.author return this.mediaMetadata.author
}, },
audioFileFilename() {
return this.episode.audioFile?.metadata?.filename || ''
},
audioFileSize() {
const size = this.episode.audioFile?.metadata?.size || 0
return this.$bytesPretty(size)
},
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
} }

View File

@ -10,9 +10,9 @@
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p> <p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
<div class="w-full relative"> <div class="w-full relative">
<ui-text-input v-model="currentFeed.feedUrl" readonly /> <ui-text-input :value="feedUrl" readonly />
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span> <span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
</div> </div>
<div v-if="currentFeed.meta" class="mt-5"> <div v-if="currentFeed.meta" class="mt-5">
@ -111,8 +111,11 @@ export default {
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
feedUrl() {
return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : ''
},
demoFeedUrl() { demoFeedUrl() {
return `${window.origin}/feed/${this.newFeedSlug}` return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}`
}, },
isHttp() { isHttp() {
return window.origin.startsWith('http://') return window.origin.startsWith('http://')

View File

@ -5,8 +5,8 @@
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p> <p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
<div class="w-full relative"> <div class="w-full relative">
<ui-text-input v-model="feed.feedUrl" readonly /> <ui-text-input :value="feedUrl" readonly />
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span> <span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
</div> </div>
<div v-if="feed.meta" class="mt-5"> <div v-if="feed.meta" class="mt-5">
@ -70,6 +70,9 @@ export default {
}, },
_feed() { _feed() {
return this.feed || {} return this.feed || {}
},
feedUrl() {
return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : ''
} }
}, },
methods: { methods: {

View File

@ -37,7 +37,7 @@
</ui-tooltip> </ui-tooltip>
<ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings"> <ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerSettings')"> <button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="showPlayerSettings">
<span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span> <span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
</button> </button>
</ui-tooltip> </ui-tooltip>
@ -64,6 +64,8 @@
</div> </div>
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div> </div>
</template> </template>
@ -96,6 +98,7 @@ export default {
audioEl: null, audioEl: null,
seekLoading: false, seekLoading: false,
showChaptersModal: false, showChaptersModal: false,
showPlayerSettingsModal: false,
currentTime: 0, currentTime: 0,
duration: 0 duration: 0
} }
@ -315,6 +318,9 @@ export default {
if (!this.chapters.length) return if (!this.chapters.length) return
this.showChaptersModal = !this.showChaptersModal this.showChaptersModal = !this.showChaptersModal
}, },
showPlayerSettings() {
this.showPlayerSettingsModal = !this.showPlayerSettingsModal
},
init() { init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1

View File

@ -1,9 +1,9 @@
<template> <template>
<div> <div>
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center"> <div v-if="processing" role="img" :aria-label="$strings.MessageLoading" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner /> <widgets-loading-spinner />
</div> </div>
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" /> <img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" :aria-label="$getString('LabelPersonalYearReview', [variant + 1])" />
</div> </div>
</template> </template>

View File

@ -7,7 +7,7 @@
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<p class="hidden md:block text-xl font-semibold">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</p> <h1 class="hidden md:block text-xl font-semibold">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</h1>
<div class="hidden md:block flex-grow" /> <div class="hidden md:block flex-grow" />
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide : $strings.LabelYearReviewShow }}</ui-btn> <ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide : $strings.LabelYearReviewShow }}</ui-btn>
</div> </div>
@ -16,17 +16,22 @@
<div v-if="showYearInReview"> <div v-if="showYearInReview">
<div class="w-full h-px bg-slate-200/10 my-4" /> <div class="w-full h-px bg-slate-200/10 my-4" />
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto"> <div v-if="availableYears.length > 1" class="mb-2 py-2 max-w-[800px] mx-auto">
<!-- year selector -->
<ui-dropdown v-model="yearInReviewYear" small :items="availableYears" :disabled="processingYearInReview" class="max-w-24" @input="yearInReviewYearChanged" />
</div>
<div role="toolbar" class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
<!-- previous button --> <!-- previous button -->
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--"> <ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" :aria-label="$strings.ButtonPrevious" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span> <span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span> <span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
</ui-btn> </ui-btn>
<!-- share button --> <!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview">{{ $strings.ButtonShare }} </ui-btn> <ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview">{{ $strings.ButtonShare }} </ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelPersonalYearReview', [yearInReviewVariant + 1]) }}</p> <h2 class="hidden sm:block text-lg font-semibold">{{ $getString('LabelPersonalYearReview', [yearInReviewVariant + 1]) }}</h2>
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p> <p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
@ -36,7 +41,7 @@
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span> <span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
</ui-btn> </ui-btn>
<!-- next button --> <!-- next button -->
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++"> <ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" :aria-label="$strings.ButtonNext" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span> <span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span> <span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn> </ui-btn>
@ -46,23 +51,23 @@
<!-- your year in review short --> <!-- your year in review short -->
<div class="w-full max-w-[800px] mx-auto my-4"> <div class="w-full max-w-[800px] mx-auto my-4">
<!-- share button --> <!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort">{{ $strings.ButtonShare }}</ui-btn> <ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex items-center font-semibold mb-1" @click="shareYearInReviewShort">{{ $strings.ButtonShare }}</ui-btn>
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" /> <stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
</div> </div>
<!-- your server in review --> <!-- your server in review -->
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10"> <div v-if="isAdminOrUp" role="toolbar" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
<div class="flex items-center justify-center mb-2"> <div class="flex items-center justify-center mb-2">
<!-- previous button --> <!-- previous button -->
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--"> <ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" :aria-label="$strings.ButtonPrevious" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span> <span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span> <span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
</ui-btn> </ui-btn>
<!-- share button --> <!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer">{{ $strings.ButtonShare }} </ui-btn> <ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer">{{ $strings.ButtonShare }} </ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelServerYearReview', [yearInReviewServerVariant + 1]) }}</p> <h2 class="hidden sm:block text-lg font-semibold">{{ $getString('LabelServerYearReview', [yearInReviewServerVariant + 1]) }}</h2>
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p> <p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
@ -72,7 +77,7 @@
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span> <span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
</ui-btn> </ui-btn>
<!-- next button --> <!-- next button -->
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++"> <ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" :aria-label="$strings.ButtonNext" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span> <span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span> <span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn> </ui-btn>
@ -88,6 +93,7 @@ export default {
data() { data() {
return { return {
showYearInReview: false, showYearInReview: false,
availableYears: [],
yearInReviewYear: 0, yearInReviewYear: 0,
yearInReviewVariant: 0, yearInReviewVariant: 0,
yearInReviewServerVariant: 0, yearInReviewServerVariant: 0,
@ -100,6 +106,9 @@ export default {
computed: { computed: {
isAdminOrUp() { isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
},
user() {
return this.$store.state.user.user
} }
}, },
methods: { methods: {
@ -112,25 +121,57 @@ export default {
shareYearInReviewShort() { shareYearInReviewShort() {
this.$refs.yearInReviewShort.share() this.$refs.yearInReviewShort.share()
}, },
yearInReviewYearChanged() {
this.$nextTick(() => {
this.refreshYearInReview()
this.refreshYearInReviewServer()
})
},
refreshYearInReviewServer() { refreshYearInReviewServer() {
this.$refs.yearInReviewServer.refresh() if (this.$refs.yearInReviewServer != null) {
this.$refs.yearInReviewServer.refresh()
}
}, },
refreshYearInReview() { refreshYearInReview() {
this.$refs.yearInReview.refresh() if (this.$refs.yearInReview != null && this.$refs.yearInReviewShort != null) {
this.$refs.yearInReviewShort.refresh() this.$refs.yearInReview.refresh()
this.$refs.yearInReviewShort.refresh()
}
}, },
clickShowYearInReview() { clickShowYearInReview() {
this.showYearInReview = !this.showYearInReview this.showYearInReview = !this.showYearInReview
},
getAvailableYears() {
if (this.user) {
const oldestDate = this.user.createdAt
if (oldestDate) {
const date = new Date(oldestDate)
const oldestYear = date.getFullYear()
const currentYear = new Date().getFullYear()
const years = []
for (let year = currentYear; year >= oldestYear; year--) {
years.push({ value: year, text: year.toString() })
}
return years
}
}
// Fallback on error
return [{ value: this.yearInReviewYear, text: this.yearInReviewYear.toString() }]
} }
}, },
beforeMount() { beforeMount() {
this.yearInReviewYear = new Date().getFullYear() this.yearInReviewYear = new Date().getFullYear()
// When not December show previous year // When not December show previous year
if (new Date().getMonth() < 11) { if (new Date().getMonth() < 11) {
this.yearInReviewYear-- this.yearInReviewYear--
} }
}, },
mounted() { mounted() {
this.availableYears = this.getAvailableYears()
if (typeof navigator.share !== 'undefined' && navigator.share) { if (typeof navigator.share !== 'undefined' && navigator.share) {
this.showShareButton = true this.showShareButton = true
} else { } else {

View File

@ -1,9 +1,9 @@
<template> <template>
<div> <div>
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center"> <div v-if="processing" role="img" :aria-label="$strings.MessageLoading" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner /> <widgets-loading-spinner />
</div> </div>
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" /> <img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" :aria-label="$getString('LabelServerYearReview', [variant + 1])" />
</div> </div>
</template> </template>

View File

@ -120,6 +120,7 @@ export default {
this.users = res.users.sort((a, b) => { this.users = res.users.sort((a, b) => {
return a.createdAt - b.createdAt return a.createdAt - b.createdAt
}) })
this.$emit('numUsers', this.users.length)
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)

View File

@ -25,7 +25,6 @@
</template> </template>
</div> </div>
</div> </div>
<!-- <p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p> -->
<div v-if="episodes.length" class="w-full py-3 mx-auto flex"> <div v-if="episodes.length" class="w-full py-3 mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow"> <form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" /> <ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
@ -515,6 +514,10 @@ export default {
} }
}, },
filterSortChanged() { filterSortChanged() {
// Save filterKey and sortKey to local storage
localStorage.setItem('podcastEpisodesFilter', this.filterKey)
localStorage.setItem('podcastEpisodesSortBy', this.sortKey + (this.sortDesc ? '-desc' : ''))
this.init() this.init()
}, },
refresh() { refresh() {
@ -537,6 +540,11 @@ export default {
} }
}, },
mounted() { mounted() {
this.filterKey = localStorage.getItem('podcastEpisodesFilter') || 'incomplete'
const sortBy = localStorage.getItem('podcastEpisodesSortBy') || 'publishedAt-desc'
this.sortKey = sortBy.split('-')[0]
this.sortDesc = sortBy.split('-')[1] === 'desc'
this.episodesCopy = this.episodes.map((ep) => ({ ...ep })) this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
this.initListeners() this.initListeners()
this.init() this.init()

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj"> <div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing"> <slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu"> <button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" :aria-label="$strings.LabelMore" aria-haspopup="menu" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-symbols text-2xl" :class="iconClass">&#xe5d4;</span> <span class="material-symbols text-2xl" :class="iconClass">&#xe5d4;</span>
</button> </button>
<div v-else class="h-full w-full flex items-center justify-center"> <div v-else class="h-full w-full flex items-center justify-center">
@ -10,12 +10,12 @@
</slot> </slot>
<transition name="menu"> <transition name="menu">
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }"> <div v-show="showMenu" ref="menuWrapper" role="menu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
<template v-for="(item, index) in items"> <template v-for="(item, index) in items">
<template v-if="item.subitems"> <template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop> <button :key="index" role="menuitem" aria-haspopup="menu" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default w-full" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p> <p>{{ item.text }}</p>
</div> </button>
<div <div
v-if="mouseoverItemIndex === index" v-if="mouseoverItemIndex === index"
:key="`subitems-${index}`" :key="`subitems-${index}`"
@ -25,14 +25,14 @@
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'" :class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }" :style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
> >
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)"> <button v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" 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(subitem.action, subitem.data)">
<p>{{ subitem.text }}</p> <p>{{ subitem.text }}</p>
</div> </button>
</div> </div>
</template> </template>
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)"> <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)">
<p class="text-left">{{ item.text }}</p> <p class="text-left">{{ item.text }}</p>
</div> </button>
</template> </template>
</div> </div>
</transition> </transition>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="relative w-full" v-click-outside="clickOutsideObj"> <div class="relative w-full" v-click-outside="clickOutsideObj">
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p> <p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="menu" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="flex items-center"> <span class="flex items-center">
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span> <span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
<span v-if="selectedSubtext">:&nbsp;</span> <span v-if="selectedSubtext">:&nbsp;</span>
@ -13,9 +13,9 @@
</button> </button>
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox" :style="{ maxHeight: menuMaxHeight }"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="menu" :style="{ maxHeight: menuMaxHeight }">
<template v-for="item in itemsToShow"> <template v-for="item in itemsToShow">
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)"> <li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" role="menuitem" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span> <span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
<span v-if="item.subtext">:&nbsp;</span> <span v-if="item.subtext">:&nbsp;</span>
@ -119,4 +119,4 @@ export default {
}, },
mounted() {} mounted() {}
} }
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<button class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn"> <button :aria-label="ariaLabel" class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24"> <svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" /> <path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
@ -28,7 +28,8 @@ export default {
size: { size: {
type: Number, type: Number,
default: 9 default: 9
} },
ariaLabel: String
}, },
data() { data() {
return {} return {}

View File

@ -4,7 +4,7 @@
type="button" type="button"
:disabled="disabled" :disabled="disabled"
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
aria-haspopup="listbox" aria-haspopup="menu"
:aria-expanded="showMenu" :aria-expanded="showMenu"
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name" :aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
@click.stop.prevent="clickShowMenu" @click.stop.prevent="clickShowMenu"
@ -16,9 +16,9 @@
</button> </button>
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="menu">
<template v-for="library in librariesFiltered"> <template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)"> <li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="menuitem" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
<div class="flex items-center px-2"> <div class="flex items-center px-2">
<ui-library-icon :icon="library.icon" class="mr-1.5" /> <ui-library-icon :icon="library.icon" class="mr-1.5" />
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span> <span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>

View File

@ -1,5 +1,5 @@
<template> <template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn"> <button :aria-label="isRead ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
<div class="w-5 h-5 text-white relative"> <div class="w-5 h-5 text-white relative">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)"> <svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" /> <path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<button :aria-labelledby="labeledBy" role="checkbox" type="button" class="border rounded-full border-black-100 flex items-center cursor-pointer justify-start" :style="{ width: buttonWidth + 'px' }" :aria-checked="toggleValue" :class="className" @click="clickToggle"> <button :aria-labelledby="labeledBy" :aria-label="label" role="checkbox" type="button" class="border rounded-full border-black-100 flex items-center cursor-pointer justify-start" :style="{ width: buttonWidth + 'px' }" :aria-checked="toggleValue" :class="className" @click="clickToggle">
<span class="rounded-full border border-black-50 shadow transform transition-transform duration-100" :style="{ width: cursorHeightWidth + 'px', height: cursorHeightWidth + 'px' }" :class="switchClassName"></span> <span class="rounded-full border border-black-50 shadow transform transition-transform duration-100" :style="{ width: cursorHeightWidth + 'px', height: cursorHeightWidth + 'px' }" :class="switchClassName"></span>
</button> </button>
</div> </div>
@ -20,6 +20,7 @@ export default {
}, },
disabled: Boolean, disabled: Boolean,
labeledBy: String, labeledBy: String,
label: String,
size: { size: {
type: String, type: String,
default: 'md' default: 'md'

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent> <div aria-hidden="true" class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-symbols" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button">&#xe15b;</span> <span class="material-symbols" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button">&#xe15b;</span>
<p class="px-2 font-mono" style="font-size: 1rem">{{ bookCoverWidth }}</p> <p class="px-2 font-mono" style="font-size: 1rem">{{ bookCoverWidth }}</p>
<span class="material-symbols" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button">&#xe145;</span> <span class="material-symbols" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button">&#xe145;</span>

View File

@ -3,10 +3,10 @@
<div class="flex items-center py-3e"> <div class="flex items-center py-3e">
<slot /> <slot />
<div class="flex-grow" /> <div class="flex-grow" />
<button cy-id="leftScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft"> <button cy-id="leftScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollLeft" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_left</span> <span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_left</span>
</button> </button>
<button cy-id="rightScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight"> <button cy-id="rightScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollRight" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_right</span> <span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_right</span>
</button> </button>
</div> </div>

View File

@ -71,8 +71,6 @@ export default {
this.showSeriesForm = true this.showSeriesForm = true
}, },
submitSeriesForm() { submitSeriesForm() {
console.log('submit series form', this.value, this.selectedSeries)
if (!this.selectedSeries.name) { if (!this.selectedSeries.name) {
this.$toast.error('Must enter a series') this.$toast.error('Must enter a series')
return return

View File

@ -28,10 +28,8 @@ export default {
var validOtherFiles = [] var validOtherFiles = []
var ignoredFiles = [] var ignoredFiles = []
files.forEach((file) => { files.forEach((file) => {
// var filetype = this.checkFileType(file.name)
if (!file.filetype) ignoredFiles.push(file) if (!file.filetype) ignoredFiles.push(file)
else { else {
// file.filetype = filetype
if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file) if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
else validOtherFiles.push(file) else validOtherFiles.push(file)
} }
@ -165,7 +163,7 @@ export default {
var firstBookPath = Path.dirname(firstBookFile.filepath) var firstBookPath = Path.dirname(firstBookFile.filepath)
var dirs = firstBookPath.split('/').filter(d => !!d && d !== '.') var dirs = firstBookPath.split('/').filter((d) => !!d && d !== '.')
if (dirs.length) { if (dirs.length) {
audiobook.title = dirs.pop() audiobook.title = dirs.pop()
if (dirs.length > 1) { if (dirs.length > 1) {
@ -189,7 +187,7 @@ export default {
var firstAudioFile = podcast.itemFiles[0] var firstAudioFile = podcast.itemFiles[0]
if (!firstAudioFile.filepath) return podcast // No path if (!firstAudioFile.filepath) return podcast // No path
var firstPath = Path.dirname(firstAudioFile.filepath) var firstPath = Path.dirname(firstAudioFile.filepath)
var dirs = firstPath.split('/').filter(d => !!d && d !== '.') var dirs = firstPath.split('/').filter((d) => !!d && d !== '.')
if (dirs.length) { if (dirs.length) {
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0] podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
} else { } else {
@ -212,13 +210,15 @@ export default {
} }
var ignoredFiles = itemData.ignoredFiles var ignoredFiles = itemData.ignoredFiles
var index = 1 var index = 1
var items = itemData.items.filter((ab) => { var items = itemData.items
if (!ab.itemFiles.length) { .filter((ab) => {
if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles) if (!ab.itemFiles.length) {
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles) if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles)
} if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
return ab.itemFiles.length }
}).map(ab => this.cleanItem(ab, mediaType, index++)) return ab.itemFiles.length
})
.map((ab) => this.cleanItem(ab, mediaType, index++))
return { return {
items, items,
ignoredFiles ignoredFiles
@ -259,7 +259,7 @@ export default {
otherFiles.forEach((file) => { otherFiles.forEach((file) => {
var dir = Path.dirname(file.filepath) var dir = Path.dirname(file.filepath)
var findItem = Object.values(itemMap).find(b => dir.startsWith(b.path)) var findItem = Object.values(itemMap).find((b) => dir.startsWith(b.path))
if (findItem) { if (findItem) {
findItem.otherFiles.push(file) findItem.otherFiles.push(file)
} else { } else {
@ -270,18 +270,18 @@ export default {
var items = [] var items = []
var index = 1 var index = 1
// If book media type and all files are audio files then treat each one as an audiobook // If book media type and all files are audio files then treat each one as an audiobook
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) { if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some((f) => f.filetype !== 'audio')) {
items = itemMap[''].itemFiles.map((audioFile) => { items = itemMap[''].itemFiles.map((audioFile) => {
return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++) return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++)
}) })
} else { } else {
items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++)) items = Object.values(itemMap).map((i) => this.cleanItem(i, mediaType, index++))
} }
return { return {
items, items,
ignoredFiles: ignoredFiles ignoredFiles: ignoredFiles
} }
}, }
} }
} }

View File

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

View File

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

View File

@ -64,6 +64,20 @@
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" /> <ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" /> <p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
<div class="w-44">
<ui-dropdown v-model="newAuthSettings.authOpenIDSubfolderForRedirectURLs" small :items="subfolderOptions" :label="$strings.LabelWebRedirectURLsSubfolder" :disabled="savingSettings" />
</div>
<div class="mt-2 sm:mt-5">
<p class="sm:pl-4 text-sm text-gray-300">{{ $strings.LabelWebRedirectURLsDescription }}</p>
<p class="sm:pl-4 text-sm text-gray-300 mb-2">
<code>{{ webCallbackURL }}</code>
<br />
<code>{{ mobileAppCallbackURL }}</code>
</p>
</div>
</div>
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" /> <ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2"> <div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
@ -164,6 +178,27 @@ export default {
value: 'username' value: 'username'
} }
] ]
},
subfolderOptions() {
const options = [
{
text: 'None',
value: ''
}
]
if (this.$config.routerBasePath) {
options.push({
text: this.$config.routerBasePath,
value: this.$config.routerBasePath
})
}
return options
},
webCallbackURL() {
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback`
},
mobileAppCallbackURL() {
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect`
} }
}, },
methods: { methods: {
@ -325,7 +360,8 @@ export default {
}, },
init() { init() {
this.newAuthSettings = { this.newAuthSettings = {
...this.authSettings ...this.authSettings,
authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs
} }
this.enableLocalAuth = this.authMethods.includes('local') this.enableLocalAuth = this.authMethods.includes('local')
this.enableOpenIDAuth = this.authMethods.includes('openid') this.enableOpenIDAuth = this.authMethods.includes('openid')

View File

@ -6,9 +6,9 @@
<div class="pt-4"> <div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2> <h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2>
</div> </div>
<div class="flex items-end py-2"> <div role="article" :aria-label="$strings.LabelSettingsStoreCoversWithItemHelp" class="flex items-end py-2">
<ui-toggle-switch labeledBy="settings-store-cover-with-items" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" /> <ui-toggle-switch :label="$strings.LabelSettingsStoreCoversWithItem" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
<ui-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreCoversWithItemHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span> <span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
@ -16,9 +16,9 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div role="article" :aria-label="$strings.LabelSettingsStoreMetadataWithItemHelp" class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-store-metadata-with-items" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" /> <ui-toggle-switch :label="$strings.LabelSettingsStoreMetadataWithItem" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-tooltip :text="$strings.LabelSettingsStoreMetadataWithItemHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-store-metadata-with-items">{{ $strings.LabelSettingsStoreMetadataWithItem }}</span> <span id="settings-store-metadata-with-items">{{ $strings.LabelSettingsStoreMetadataWithItem }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
@ -26,9 +26,9 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div role="article" :aria-label="$strings.LabelSettingsSortingIgnorePrefixesHelp" class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-sorting-ignore-prefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" /> <ui-toggle-switch :label="$strings.LabelSettingsSortingIgnorePrefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip :text="$strings.LabelSettingsSortingIgnorePrefixesHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-sorting-ignore-prefixes">{{ $strings.LabelSettingsSortingIgnorePrefixes }}</span> <span id="settings-sorting-ignore-prefixes">{{ $strings.LabelSettingsSortingIgnorePrefixes }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
@ -42,18 +42,13 @@
</div> </div>
</div> </div>
<div class="flex items-center py-2 mb-2">
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="pt-4"> <div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2> <h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
</div> </div>
<div class="flex items-center py-2"> <div role="article" :aria-label="$strings.LabelSettingsParseSubtitlesHelp" class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-parse-subtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" /> <ui-toggle-switch :label="$strings.LabelSettingsParseSubtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsParseSubtitlesHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span> <span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
@ -61,9 +56,9 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div role="article" :aria-label="$strings.LabelSettingsFindCoversHelp" class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-find-covers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" /> <ui-toggle-switch :label="$strings.LabelSettingsFindCovers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-tooltip :text="$strings.LabelSettingsFindCoversHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsFindCoversHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span> <span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
@ -75,9 +70,9 @@
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" /> <ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
</div> </div>
<div class="flex items-center py-2"> <div role="article" :aria-label="$strings.LabelSettingsPreferMatchedMetadataHelp" class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-prefer-matched-metadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" /> <ui-toggle-switch :label="$strings.LabelSettingsPreferMatchedMetadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span> <span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
@ -85,15 +80,29 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div role="article" :aria-label="$strings.LabelSettingsEnableWatcherHelp" class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" /> <ui-toggle-switch :label="$strings.LabelSettingsEnableWatcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsEnableWatcherHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span> <span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsWebClient }}</h2>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :label="$strings.LabelSettingsChromecastSupport" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p aria-hidden="true" class="pl-4">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="flex items-center py-2 mb-2">
<ui-toggle-switch v-model="newServerSettings.allowIframe" :label="$strings.LabelSettingsAllowIframe" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('allowIframe', val)" />
<p aria-hidden="true" class="pl-4">{{ $strings.LabelSettingsAllowIframe }}</p>
</div>
</div> </div>
<div class="flex-1"> <div class="flex-1">
@ -324,21 +333,21 @@ export default {
}, },
updateServerSettings(payload) { updateServerSettings(payload) {
this.updatingServerSettings = true this.updatingServerSettings = true
this.$store this.$store.dispatch('updateServerSettings', payload).then((response) => {
.dispatch('updateServerSettings', payload) this.updatingServerSettings = false
.then(() => {
this.updatingServerSettings = false
if (payload.language) { if (response.error) {
// Updating language after save allows for re-rendering console.error('Failed to update server settins', response.error)
this.$setLanguageCode(payload.language) this.$toast.error(response.error)
} this.initServerSettings()
}) return
.catch((error) => { }
console.error('Failed to update server settings', error)
this.updatingServerSettings = false if (payload.language) {
this.$toast.error(this.$strings.ToastFailedToUpdate) // Updating language after save allows for re-rendering
}) this.$setLanguageCode(payload.language)
}
})
}, },
initServerSettings() { initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {} this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}

View File

@ -126,7 +126,7 @@ export default {
}, },
coverUrl(feed) { coverUrl(feed) {
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png` if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
return `${feed.feedUrl}/cover` return `${this.$config.routerBasePath}${feed.feedUrl}/cover`
}, },
async loadFeeds() { async loadFeeds() {
const data = await this.$axios.$get(`/api/feeds`).catch((err) => { const data = await this.$axios.$get(`/api/feeds`).catch((err) => {

View File

@ -2,6 +2,10 @@
<div> <div>
<app-settings-content :header-text="$strings.HeaderUsers"> <app-settings-content :header-text="$strings.HeaderUsers">
<template #header-items> <template #header-items>
<div v-if="numUsers" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
<span>{{ numUsers }}</span>
</div>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2"> <ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex"> <a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span> <span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
@ -13,7 +17,7 @@
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn> <ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
</template> </template>
<tables-users-table class="pt-2" @edit="setShowUserModal" /> <tables-users-table class="pt-2" @edit="setShowUserModal" @numUsers="(count) => (numUsers = count)" />
</app-settings-content> </app-settings-content>
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" /> <modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
</div> </div>
@ -29,7 +33,8 @@ export default {
data() { data() {
return { return {
selectedAccount: null, selectedAccount: null,
showAccountModal: false showAccountModal: false,
numUsers: 0
} }
}, },
computed: {}, computed: {},

View File

@ -12,12 +12,12 @@
<!-- Item Cover Overlay --> <!-- Item Cover Overlay -->
<div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none"> <div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none">
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none"> <div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem"> <button class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" :aria-label="$strings.ButtonPlay" @click.stop.prevent="playItem">
<span class="material-symbols fill text-4xl">play_arrow</span> <span class="material-symbols fill text-4xl">play_arrow</span>
</div> </button>
</div> </div>
<span class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" @click="showEditCover">edit</span> <button class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" :aria-label="$strings.ButtonEdit" @click="showEditCover">edit</button>
</div> </div>
</div> </div>
</div> </div>
@ -87,7 +87,7 @@
</ui-btn> </ui-btn>
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2"> <ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span v-show="!isStreaming" class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span> <span class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }} {{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
</ui-btn> </ui-btn>
@ -96,12 +96,12 @@
</ui-tooltip> </ui-tooltip>
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook"> <ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-symbols text-2xl -ml-2 pr-2 text-white">auto_stories</span> <span class="material-symbols text-2xl -ml-2 pr-2 text-white" aria-hidden="true">auto_stories</span>
{{ $strings.ButtonRead }} {{ $strings.ButtonRead }}
</ui-btn> </ui-btn>
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top"> <ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
<ui-icon-btn icon="&#xe3c9;" outlined class="mx-0.5" @click="editClick" /> <ui-icon-btn icon="&#xe3c9;" outlined class="mx-0.5" :aria-label="$strings.LabelEdit" @click="editClick" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top"> <ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
@ -110,12 +110,12 @@
<!-- Only admin or root user can download new episodes --> <!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top"> <ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" /> <ui-icon-btn icon="search" class="mx-0.5" :aria-label="$strings.LabelFindEpisodes" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip> </ui-tooltip>
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction"> <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
<template #default="{ showMenu, clickShowMenu, disabled }"> <template #default="{ showMenu, clickShowMenu, disabled }">
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu"> <button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.LabelMore" @click.stop.prevent="clickShowMenu">
<span class="material-symbols text-2xl">&#xe5d3;</span> <span class="material-symbols text-2xl">&#xe5d3;</span>
</button> </button>
</template> </template>

View File

@ -126,12 +126,14 @@ export default {
if (!this.localAudioPlayer || !this.hasLoaded) return if (!this.localAudioPlayer || !this.hasLoaded) return
const currentTime = this.localAudioPlayer.getCurrentTime() const currentTime = this.localAudioPlayer.getCurrentTime()
const duration = this.localAudioPlayer.getDuration() const duration = this.localAudioPlayer.getDuration()
this.seek(Math.min(currentTime + 10, duration)) const jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') || 10
this.seek(Math.min(currentTime + jumpForwardAmount, duration))
}, },
jumpBackward() { jumpBackward() {
if (!this.localAudioPlayer || !this.hasLoaded) return if (!this.localAudioPlayer || !this.hasLoaded) return
const currentTime = this.localAudioPlayer.getCurrentTime() const currentTime = this.localAudioPlayer.getCurrentTime()
this.seek(Math.max(currentTime - 10, 0)) const jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') || 10
this.seek(Math.max(currentTime - jumpBackwardAmount, 0))
}, },
setVolume(volume) { setVolume(volume) {
if (!this.localAudioPlayer || !this.hasLoaded) return if (!this.localAudioPlayer || !this.hasLoaded) return
@ -248,6 +250,8 @@ export default {
} }
}, },
mounted() { mounted() {
this.$store.dispatch('user/loadUserSettings')
this.resize() this.resize()
window.addEventListener('resize', this.resize) window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown) window.addEventListener('keydown', this.keyDown)

View File

@ -1,20 +1,20 @@
<template> <template>
<div id="page-wrapper" class="page p-0 sm:p-6 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''"> <div id="page-wrapper" class="page p-1 sm:p-6 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
<div class="w-full max-w-6xl mx-auto"> <div class="w-full max-w-6xl mx-auto">
<!-- Library & folder picker --> <!-- Library & folder picker -->
<div class="flex my-6 -mx-2"> <div class="flex flex-wrap my-6 md:-mx-2">
<div class="w-1/5 px-2"> <div class="w-full md:w-1/5 px-2">
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" :label="$strings.LabelLibrary" :disabled="!!items.length" @input="libraryChanged" /> <ui-dropdown v-model="selectedLibraryId" :items="libraryItems" :label="$strings.LabelLibrary" :disabled="!!items.length" @input="libraryChanged" />
</div> </div>
<div class="w-3/5 px-2"> <div class="w-full md:w-3/5 px-2">
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId || !!items.length" :label="$strings.LabelFolder" /> <ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId || !!items.length" :label="$strings.LabelFolder" />
</div> </div>
<div class="w-1/5 px-2"> <div class="w-full md:w-1/5 px-2">
<ui-text-input-with-label :value="selectedLibraryMediaType" readonly :label="$strings.LabelMediaType" /> <ui-text-input-with-label :value="selectedLibraryMediaType" readonly :label="$strings.LabelMediaType" />
</div> </div>
</div> </div>
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6"> <div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6 px-2 md:px-0">
<label class="flex cursor-pointer pt-4"> <label class="flex cursor-pointer pt-4">
<ui-toggle-switch v-model="fetchMetadata.enabled" class="inline-flex" /> <ui-toggle-switch v-model="fetchMetadata.enabled" class="inline-flex" />
<span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span> <span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span>
@ -33,13 +33,13 @@
</widgets-alert> </widgets-alert>
<!-- Picker display --> <!-- Picker display -->
<div v-if="!items.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'"> <div v-if="!items.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-4 md:px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
<p class="text-2xl text-center">{{ isDragging ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop }}</p> <p class="text-2xl text-center">{{ isDragging ? $strings.LabelUploaderDropFiles : isIOS ? $strings.LabelUploaderDragAndDropFilesOnly : $strings.LabelUploaderDragAndDrop }}</p>
<p class="text-center text-sm my-5">{{ $strings.MessageOr }}</p> <p class="text-center text-sm my-5">{{ $strings.MessageOr }}</p>
<div class="w-full max-w-xl mx-auto"> <div class="w-full max-w-xl mx-auto">
<div class="flex"> <div class="flex">
<ui-btn class="w-full mx-1" @click="openFilePicker">{{ $strings.ButtonChooseFiles }}</ui-btn> <ui-btn class="w-full mx-1" @click="openFilePicker">{{ $strings.ButtonChooseFiles }}</ui-btn>
<ui-btn class="w-full mx-1" @click="openFolderPicker">{{ $strings.ButtonChooseAFolder }}</ui-btn> <ui-btn v-if="!isIOS" class="w-full mx-1" @click="openFolderPicker">{{ $strings.ButtonChooseAFolder }} </ui-btn>
</div> </div>
</div> </div>
<div class="pt-8 text-center"> <div class="pt-8 text-center">
@ -48,7 +48,7 @@
</p> </p>
<p class="text-sm text-white text-opacity-70"> <p class="text-sm text-white text-opacity-70">
{{ $strings.NoteUploaderFoldersWithMediaFiles }} <span v-if="selectedLibraryMediaType === 'book'">{{ $strings.NoteUploaderOnlyAudioFiles }}</span> <span v-if="!isIOS">{{ $strings.NoteUploaderFoldersWithMediaFiles }}</span> <span v-if="selectedLibraryMediaType === 'book'">{{ $strings.NoteUploaderOnlyAudioFiles }}</span>
</p> </p>
</div> </div>
</div> </div>
@ -84,8 +84,8 @@
</div> </div>
</div> </div>
<input ref="fileInput" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" /> <input ref="fileInput" type="file" multiple :accept="isIOS ? '' : inputAccept" class="hidden" @change="inputChanged" />
<input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" /> <input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" v-if="!isIOS" />
</div> </div>
</template> </template>
@ -127,6 +127,10 @@ export default {
}) })
return extensions return extensions
}, },
isIOS() {
const ua = window.navigator.userAgent
return /iPad|iPhone|iPod/.test(ua) && !window.MSStream
},
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
}, },

View File

@ -7,6 +7,7 @@ const defaultCode = 'en-us'
const languageCodeMap = { const languageCodeMap = {
bg: { label: 'Български', dateFnsLocale: 'bg' }, bg: { label: 'Български', dateFnsLocale: 'bg' },
bn: { label: 'বাংলা', dateFnsLocale: 'bn' }, bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
ca: { label: 'Català', dateFnsLocale: 'ca' },
cs: { label: 'Čeština', dateFnsLocale: 'cs' }, cs: { label: 'Čeština', dateFnsLocale: 'cs' },
da: { label: 'Dansk', dateFnsLocale: 'da' }, da: { label: 'Dansk', dateFnsLocale: 'da' },
de: { label: 'Deutsch', dateFnsLocale: 'de' }, de: { label: 'Deutsch', dateFnsLocale: 'de' },
@ -41,6 +42,7 @@ Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map((code) =>
// iTunes search API uses ISO 3166 country codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 // iTunes search API uses ISO 3166 country codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
const podcastSearchRegionMap = { const podcastSearchRegionMap = {
au: { label: 'Australia' },
br: { label: 'Brasil' }, br: { label: 'Brasil' },
be: { label: 'België / Belgique / Belgien' }, be: { label: 'België / Belgique / Belgien' },
cz: { label: 'Česko' }, cz: { label: 'Česko' },
@ -56,6 +58,7 @@ const podcastSearchRegionMap = {
hu: { label: 'Magyarország' }, hu: { label: 'Magyarország' },
nl: { label: 'Nederland' }, nl: { label: 'Nederland' },
no: { label: 'Norge' }, no: { label: 'Norge' },
nz: { label: 'New Zealand' },
at: { label: 'Österreich' }, at: { label: 'Österreich' },
pl: { label: 'Polska' }, pl: { label: 'Polska' },
pt: { label: 'Portugal' }, pt: { label: 'Portugal' },

View File

@ -72,16 +72,17 @@ export const actions = {
return this.$axios return this.$axios
.$patch('/api/settings', updatePayload) .$patch('/api/settings', updatePayload)
.then((result) => { .then((result) => {
if (result.success) { if (result.serverSettings) {
commit('setServerSettings', result.serverSettings) commit('setServerSettings', result.serverSettings)
return true
} else {
return false
} }
return result
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to update server settings', error) console.error('Failed to update server settings', error)
return false const errorMsg = error.response?.data || 'Unknown error'
return {
error: errorMsg
}
}) })
}, },
checkForUpdate({ commit }) { checkForUpdate({ commit }) {

View File

@ -1 +1,156 @@
{} {
"ButtonAdd": "إضافة",
"ButtonAddChapters": "إضافة الفصول",
"ButtonAddDevice": "إضافة جهاز",
"ButtonAddLibrary": "إضافة مكتبة",
"ButtonAddPodcasts": "إضافة بودكاست",
"ButtonAddUser": "إضافة مستخدم",
"ButtonAddYourFirstLibrary": "أضف مكتبتك الأولى",
"ButtonApply": "حفظ",
"ButtonApplyChapters": "حفظ الفصول",
"ButtonAuthors": "المؤلفون",
"ButtonBack": "الرجوع",
"ButtonBrowseForFolder": "البحث عن المجلد",
"ButtonCancel": "إلغاء",
"ButtonCancelEncode": "إلغاء الترميز",
"ButtonChangeRootPassword": "تغيير كلمة المرور الرئيسية",
"ButtonCheckAndDownloadNewEpisodes": "التحقق من الحلقات الجديدة وتنزيلها",
"ButtonChooseAFolder": "اختر المجلد",
"ButtonChooseFiles": "اختر الملفات",
"ButtonClearFilter": "تصفية الفرز",
"ButtonCloseFeed": "إغلاق",
"ButtonCloseSession": "إغلاق الجلسة المفتوحة",
"ButtonCollections": "المجموعات",
"ButtonConfigureScanner": "إعدادات الماسح الضوئي",
"ButtonCreate": "إنشاء",
"ButtonCreateBackup": "إنشاء نسخة احتياطية",
"ButtonDelete": "حذف",
"ButtonDownloadQueue": "قائمة",
"ButtonEdit": "تعديل",
"ButtonEditChapters": "تعديل الفصول",
"ButtonEditPodcast": "تعديل البودكاست",
"ButtonEnable": "تفعيل",
"ButtonFireAndFail": "النار والفشل",
"ButtonFireOnTest": "حادثة إطلاق النار",
"ButtonForceReScan": "فرض إعادة المسح",
"ButtonFullPath": "المسار الكامل",
"ButtonHide": "إخفاء",
"ButtonHome": "الرئيسية",
"ButtonIssues": "مشاكل",
"ButtonJumpBackward": "اقفز للخلف",
"ButtonJumpForward": "اقفز للأمام",
"ButtonLatest": "أحدث",
"ButtonLibrary": "المكتبة",
"ButtonLogout": "تسجيل الخروج",
"ButtonLookup": "البحث",
"ButtonManageTracks": "إدارة المقاطع",
"ButtonMapChapterTitles": "مطابقة عناوين الفصول",
"ButtonMatchAllAuthors": "مطابقة كل المؤلفون",
"ButtonMatchBooks": "مطابقة الكتب",
"ButtonNevermind": "لا تهتم",
"ButtonNext": "التالي",
"ButtonNextChapter": "الفصل التالي",
"ButtonNextItemInQueue": "العنصر التالي في قائمة الانتظار",
"ButtonOk": "نعم",
"ButtonOpenFeed": "فتح التغذية",
"ButtonOpenManager": "فتح الإدارة",
"ButtonPause": "تَوَقَّف",
"ButtonPlay": "تشغيل",
"ButtonPlayAll": "تشغيل الكل",
"ButtonPlaying": "مشغل الآن",
"ButtonPlaylists": "قوائم التشغيل",
"ButtonPrevious": "سابِق",
"ButtonPreviousChapter": "الفصل السابق",
"ButtonProbeAudioFile": "فحص ملف الصوت",
"ButtonPurgeAllCache": "مسح كافة ذاكرة التخزين المؤقتة",
"ButtonPurgeItemsCache": "مسح ذاكرة التخزين المؤقتة للعناصر",
"ButtonQueueAddItem": "أضف إلى قائمة الانتظار",
"ButtonQueueRemoveItem": "إزالة من قائمة الانتظار",
"ButtonQuickEmbed": "التضمين السريع",
"ButtonQuickEmbedMetadata": "إدراج سريع للبيانات الوصفية",
"ButtonQuickMatch": "مطابقة سريعة",
"ButtonReScan": "إعادة البحث",
"ButtonRead": "اقرأ",
"ButtonReadLess": "قلص",
"ButtonReadMore": "المزيد",
"ButtonRefresh": "تحديث",
"ButtonRemove": "إزالة",
"ButtonRemoveAll": "إزالة الكل",
"ButtonRemoveAllLibraryItems": "إزالة كافة عناصر المكتبة",
"ButtonRemoveFromContinueListening": "إزالة من متابعة الاستماع",
"ButtonRemoveFromContinueReading": "إزالة من متابعة القراءة",
"ButtonRemoveSeriesFromContinueSeries": "إزالة السلسلة من استمرار السلسلة",
"ButtonReset": "إعادة ضبط",
"ButtonResetToDefault": "إعادة ضبط إلى الوضع الافتراضي",
"ButtonRestore": "إستِعادة",
"ButtonSave": "حفظ",
"ButtonSaveAndClose": "حفظ و إغلاق",
"ButtonSaveTracklist": "حفظ قائمة التشغيل",
"ButtonScan": "تَحَقُق",
"ButtonScanLibrary": "تَحَقُق من المكتبة",
"ButtonSearch": "بحث",
"ButtonSelectFolderPath": "حدد مسار المجلد",
"ButtonSeries": "سلسلة",
"ButtonSetChaptersFromTracks": "تعيين الفصول من الملفات",
"ButtonShare": "نشر",
"ButtonShiftTimes": "أوقات العمل",
"ButtonShow": "عرض",
"ButtonStartM4BEncode": "ابدأ ترميز M4B",
"ButtonStartMetadataEmbed": "ابدأ تضمين البيانات الوصفية",
"ButtonStats": "الإحصائيات",
"ButtonSubmit": "تقديم",
"ButtonTest": "اختبار",
"ButtonUnlinkOpenId": "إلغاء ربط المعرف",
"ButtonUpload": "رفع",
"ButtonUploadBackup": "تحميل النسخة الاحتياطية",
"ButtonUploadCover": "ارفق الغلاف",
"ButtonUploadOPMLFile": "رفع ملف OPML",
"ButtonUserDelete": "حذف المستخدم {0}",
"ButtonUserEdit": "تعديل المستخدم {0}",
"ButtonViewAll": "عرض الكل",
"ButtonYes": "نعم",
"ErrorUploadFetchMetadataAPI": "خطأ في جلب البيانات الوصفية",
"ErrorUploadFetchMetadataNoResults": "لم يتم العثور على البيانات الوصفية - حاول تحديث العنوان و/أو المؤلف",
"ErrorUploadLacksTitle": "يجب أن يكون له عنوان",
"HeaderAccount": "الحساب",
"HeaderAddCustomMetadataProvider": "إضافة موفر بيانات تعريفية مخصص",
"HeaderAdvanced": "متقدم",
"HeaderAppriseNotificationSettings": "إعدادات الإشعارات",
"HeaderAudioTracks": "المسارات الصوتية",
"HeaderAudiobookTools": "أدوات إدارة ملفات الكتب الصوتية",
"HeaderAuthentication": "المصادقة",
"HeaderBackups": "النسخ الاحتياطية",
"HeaderChangePassword": "تغيير كلمة المرور",
"HeaderChapters": "الفصول",
"HeaderChooseAFolder": "اختيار المجلد",
"HeaderCollection": "مجموعة",
"HeaderCollectionItems": "عناصر المجموعة",
"HeaderCover": "الغلاف",
"HeaderCurrentDownloads": "التنزيلات الجارية",
"HeaderCustomMessageOnLogin": "رسالة مخصصة عند تسجيل الدخول",
"HeaderCustomMetadataProviders": "مقدمو البيانات الوصفية المخصصة",
"HeaderDetails": "التفاصيل",
"HeaderDownloadQueue": "تنزيل قائمة الانتظار",
"HeaderEbookFiles": "ملفات الكتب الإلكترونية",
"HeaderEmail": "البريد الإلكتروني",
"HeaderEmailSettings": "إعدادات البريد الإلكتروني",
"HeaderEpisodes": "الحلقات",
"HeaderEreaderDevices": "أجهزة قراءة الكتب الإلكترونية",
"HeaderEreaderSettings": "إعدادات القارئ الإلكتروني",
"HeaderFiles": "ملفات",
"HeaderFindChapters": "البحث عن الفصول",
"HeaderIgnoredFiles": "الملفات المتجاهلة",
"HeaderItemFiles": "ملفات العنصر",
"HeaderItemMetadataUtils": "بيانات تعريف العنصر",
"HeaderLastListeningSession": "آخر جلسة استماع",
"HeaderLatestEpisodes": "أحدث الحلقات",
"HeaderLibraries": "المكتبات",
"HeaderLibraryFiles": "ملفات المكتبة",
"HeaderLibraryStats": "إحصائيات المكتبة",
"HeaderListeningSessions": "جلسات الاستماع",
"HeaderListeningStats": "جلسات الاستماع",
"HeaderLogin": "تسجيل الدخول",
"HeaderLogs": "السجلات",
"HeaderManageGenres": "إدارة الانواع",
"HeaderManageTags": "إدارة العلامات"
}

1
client/strings/be.json Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -66,6 +66,7 @@
"ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন", "ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন",
"ButtonQueueAddItem": "সারিতে যোগ করুন", "ButtonQueueAddItem": "সারিতে যোগ করুন",
"ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন", "ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন",
"ButtonQuickEmbed": "দ্রুত এম্বেড করুন",
"ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন", "ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন",
"ButtonQuickMatch": "দ্রুত ম্যাচ", "ButtonQuickMatch": "দ্রুত ম্যাচ",
"ButtonReScan": "পুনরায় স্ক্যান", "ButtonReScan": "পুনরায় স্ক্যান",
@ -162,6 +163,7 @@
"HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন", "HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন",
"HeaderNotifications": "বিজ্ঞপ্তি", "HeaderNotifications": "বিজ্ঞপ্তি",
"HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ", "HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ",
"HeaderOpenListeningSessions": "শোনার সেশন খুলুন",
"HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন", "HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন",
"HeaderOtherFiles": "অন্যান্য ফাইল", "HeaderOtherFiles": "অন্যান্য ফাইল",
"HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ", "HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ",
@ -179,6 +181,7 @@
"HeaderRemoveEpisodes": "{0}টি পর্ব সরান", "HeaderRemoveEpisodes": "{0}টি পর্ব সরান",
"HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি", "HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি",
"HeaderSchedule": "সময়সূচী", "HeaderSchedule": "সময়সূচী",
"HeaderScheduleEpisodeDownloads": "স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন",
"HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী", "HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী",
"HeaderSession": "সেশন", "HeaderSession": "সেশন",
"HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন", "HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন",
@ -224,7 +227,11 @@
"LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী", "LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী",
"LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী", "LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী",
"LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে", "LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে",
"LabelApiToken": "API টোকেন",
"LabelAppend": "সংযোজন", "LabelAppend": "সংযোজন",
"LabelAudioBitrate": "অডিও বিটরেট (যেমন- 128k)",
"LabelAudioChannels": "অডিও চ্যানেল (১ বা ২)",
"LabelAudioCodec": "অডিও কোডেক",
"LabelAuthor": "লেখক", "LabelAuthor": "লেখক",
"LabelAuthorFirstLast": "লেখক (প্রথম শেষ)", "LabelAuthorFirstLast": "লেখক (প্রথম শেষ)",
"LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)", "LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)",
@ -237,6 +244,7 @@
"LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন", "LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন",
"LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন", "LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন",
"LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান", "LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান",
"LabelBackupAudioFiles": "অডিও ফাইলগুলো ব্যাকআপ",
"LabelBackupLocation": "ব্যাকআপ অবস্থান", "LabelBackupLocation": "ব্যাকআপ অবস্থান",
"LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন", "LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন",
"LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত", "LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত",
@ -245,15 +253,18 @@
"LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন", "LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন",
"LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।", "LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।",
"LabelBitrate": "বিটরেট", "LabelBitrate": "বিটরেট",
"LabelBonus": "উপরিলাভ",
"LabelBooks": "বইগুলো", "LabelBooks": "বইগুলো",
"LabelButtonText": "ঘর পাঠ্য", "LabelButtonText": "ঘর পাঠ্য",
"LabelByAuthor": "দ্বারা {0}", "LabelByAuthor": "দ্বারা {0}",
"LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন", "LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন",
"LabelChannels": "চ্যানেল", "LabelChannels": "চ্যানেল",
"LabelChapterCount": "{0} অধ্যায়",
"LabelChapterTitle": "অধ্যায়ের শিরোনাম", "LabelChapterTitle": "অধ্যায়ের শিরোনাম",
"LabelChapters": "অধ্যায়", "LabelChapters": "অধ্যায়",
"LabelChaptersFound": "অধ্যায় পাওয়া গেছে", "LabelChaptersFound": "অধ্যায় পাওয়া গেছে",
"LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন", "LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন",
"LabelClickToUseCurrentValue": "বর্তমান মান ব্যবহার করতে ক্লিক করুন",
"LabelClosePlayer": "প্লেয়ার বন্ধ করুন", "LabelClosePlayer": "প্লেয়ার বন্ধ করুন",
"LabelCodec": "কোডেক", "LabelCodec": "কোডেক",
"LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন", "LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন",
@ -303,12 +314,25 @@
"LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা", "LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা",
"LabelEmbeddedCover": "এম্বেডেড কভার", "LabelEmbeddedCover": "এম্বেডেড কভার",
"LabelEnable": "সক্ষম করুন", "LabelEnable": "সক্ষম করুন",
"LabelEncodingBackupLocation": "আপনার আসল অডিও ফাইলগুলোর একটি ব্যাকআপ এখানে সংরক্ষণ করা হবে:",
"LabelEncodingChaptersNotEmbedded": "মাল্টি-ট্র্যাক অডিওবুকগুলোতে অধ্যায় এম্বেড করা হয় না।",
"LabelEncodingClearItemCache": "পর্যায়ক্রমে আইটেম ক্যাশে পরিষ্কার করতে ভুলবেন না।",
"LabelEncodingFinishedM4B": "সমাপ্ত হওয়া M4B-গুলো আপনার অডিওবুক ফোল্ডারে এখানে রাখা হবে:",
"LabelEncodingInfoEmbedded": "আপনার অডিওবুক ফোল্ডারের ভিতরে অডিও ট্র্যাকগুলোতে মেটাডেটা এমবেড করা হবে।",
"LabelEncodingStartedNavigation": "একবার টাস্ক শুরু হলে আপনি এই পৃষ্ঠা থেকে অন্যত্র যেতে পারেন।",
"LabelEncodingTimeWarning": "এনকোডিং ৩০ মিনিট পর্যন্ত সময় নিতে পারে।",
"LabelEncodingWarningAdvancedSettings": "সতর্কতা: এই সেটিংস আপডেট করবেন না, যদি না আপনি ffmpeg এনকোডিং বিকল্পগুলোর সাথে পরিচিত হন।",
"LabelEncodingWatcherDisabled": "আপনার যদি পর্যবেক্ষক অক্ষম থাকে তবে আপনাকে পরে এই অডিওবুকটি পুনরায় স্ক্যান করতে হবে।",
"LabelEnd": "সমাপ্ত", "LabelEnd": "সমাপ্ত",
"LabelEndOfChapter": "অধ্যায়ের সমাপ্তি", "LabelEndOfChapter": "অধ্যায়ের সমাপ্তি",
"LabelEpisode": "পর্ব", "LabelEpisode": "পর্ব",
"LabelEpisodeNotLinkedToRssFeed": "পর্বটি আরএসএস ফিডের সাথে সংযুক্ত করা হয়নি",
"LabelEpisodeNumber": "পর্ব #{0}",
"LabelEpisodeTitle": "পর্বের শিরোনাম", "LabelEpisodeTitle": "পর্বের শিরোনাম",
"LabelEpisodeType": "পর্বের ধরন", "LabelEpisodeType": "পর্বের ধরন",
"LabelEpisodeUrlFromRssFeed": "আরএসএস ফিড থেকে পর্ব URL",
"LabelEpisodes": "পর্বগুলো", "LabelEpisodes": "পর্বগুলো",
"LabelEpisodic": "প্রাসঙ্গিক",
"LabelExample": "উদাহরণ", "LabelExample": "উদাহরণ",
"LabelExpandSeries": "সিরিজ প্রসারিত করুন", "LabelExpandSeries": "সিরিজ প্রসারিত করুন",
"LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন", "LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন",
@ -336,6 +360,7 @@
"LabelFontScale": "ফন্ট স্কেল", "LabelFontScale": "ফন্ট স্কেল",
"LabelFontStrikethrough": "অবচ্ছেদন রেখা", "LabelFontStrikethrough": "অবচ্ছেদন রেখা",
"LabelFormat": "ফরম্যাট", "LabelFormat": "ফরম্যাট",
"LabelFull": "পূর্ণ",
"LabelGenre": "ঘরানা", "LabelGenre": "ঘরানা",
"LabelGenres": "ঘরানাগুলো", "LabelGenres": "ঘরানাগুলো",
"LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন", "LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন",
@ -391,6 +416,10 @@
"LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার", "LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার",
"LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন", "LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন",
"LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে", "LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে",
"LabelMaxEpisodesToDownload": "সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।",
"LabelMaxEpisodesToDownloadPerCheck": "প্রতি কিস্তিতে সর্বাধিক # টি নতুন পর্ব ডাউনলোড করা হবে",
"LabelMaxEpisodesToKeep": "সর্বোচ্চ # টি পর্ব রাখা হবে",
"LabelMaxEpisodesToKeepHelp": " কোন সর্বোচ্চ সীমা সেট করে না। একটি নতুন পর্ব স্বয়ংক্রিয়-ডাউনলোড হওয়ার পরে আপনার যদি X-এর বেশি পর্ব থাকে তবে এটি সবচেয়ে পুরানো পর্বটি মুছে ফেলবে। এটি প্রতি নতুন ডাউনলোডের জন্য শুধুমাত্র ১ টি পর্ব মুছে ফেলবে।",
"LabelMediaPlayer": "মিডিয়া প্লেয়ার", "LabelMediaPlayer": "মিডিয়া প্লেয়ার",
"LabelMediaType": "মিডিয়ার ধরন", "LabelMediaType": "মিডিয়ার ধরন",
"LabelMetaTag": "মেটা ট্যাগ", "LabelMetaTag": "মেটা ট্যাগ",
@ -436,12 +465,14 @@
"LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।", "LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।",
"LabelOpenRSSFeed": "আরএসএস ফিড খুলুন", "LabelOpenRSSFeed": "আরএসএস ফিড খুলুন",
"LabelOverwrite": "পুনঃলিখিত", "LabelOverwrite": "পুনঃলিখিত",
"LabelPaginationPageXOfY": "{1} টির মধ্যে {0} পৃষ্ঠা",
"LabelPassword": "পাসওয়ার্ড", "LabelPassword": "পাসওয়ার্ড",
"LabelPath": "পথ", "LabelPath": "পথ",
"LabelPermanent": "স্থায়ী", "LabelPermanent": "স্থায়ী",
"LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে", "LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে",
"LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে", "LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে",
"LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে", "LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে",
"LabelPermissionsCreateEreader": "ইরিডার তৈরি করতে পারেন",
"LabelPermissionsDelete": "মুছে দিতে পারবে", "LabelPermissionsDelete": "মুছে দিতে পারবে",
"LabelPermissionsDownload": "ডাউনলোড করতে পারবে", "LabelPermissionsDownload": "ডাউনলোড করতে পারবে",
"LabelPermissionsUpdate": "আপডেট করতে পারবে", "LabelPermissionsUpdate": "আপডেট করতে পারবে",
@ -465,6 +496,8 @@
"LabelPubDate": "প্রকাশের তারিখ", "LabelPubDate": "প্রকাশের তারিখ",
"LabelPublishYear": "প্রকাশের বছর", "LabelPublishYear": "প্রকাশের বছর",
"LabelPublishedDate": "প্রকাশিত {0}", "LabelPublishedDate": "প্রকাশিত {0}",
"LabelPublishedDecade": "প্রকাশনার দশক",
"LabelPublishedDecades": "প্রকাশনার দশকগুলো",
"LabelPublisher": "প্রকাশক", "LabelPublisher": "প্রকাশক",
"LabelPublishers": "প্রকাশকরা", "LabelPublishers": "প্রকাশকরা",
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল", "LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
@ -484,21 +517,28 @@
"LabelRedo": "পুনরায় করুন", "LabelRedo": "পুনরায় করুন",
"LabelRegion": "অঞ্চল", "LabelRegion": "অঞ্চল",
"LabelReleaseDate": "উন্মোচনের তারিখ", "LabelReleaseDate": "উন্মোচনের তারিখ",
"LabelRemoveAllMetadataAbs": "সমস্ত metadata.abs ফাইল সরান",
"LabelRemoveAllMetadataJson": "সমস্ত metadata.json ফাইল সরান",
"LabelRemoveCover": "কভার সরান", "LabelRemoveCover": "কভার সরান",
"LabelRemoveMetadataFile": "লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান",
"LabelRemoveMetadataFileHelp": "আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।",
"LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি", "LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি",
"LabelSearchTerm": "অনুসন্ধান শব্দ", "LabelSearchTerm": "অনুসন্ধান শব্দ",
"LabelSearchTitle": "অনুসন্ধান শিরোনাম", "LabelSearchTitle": "অনুসন্ধান শিরোনাম",
"LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN", "LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN",
"LabelSeason": "সেশন", "LabelSeason": "সেশন",
"LabelSeasonNumber": "মরসুম #{0}",
"LabelSelectAll": "সব নির্বাচন করুন", "LabelSelectAll": "সব নির্বাচন করুন",
"LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন", "LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন",
"LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন", "LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন",
"LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন", "LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন",
"LabelSendEbookToDevice": "ই-বই পাঠান...", "LabelSendEbookToDevice": "ই-বই পাঠান...",
"LabelSequence": "ক্রম", "LabelSequence": "ক্রম",
"LabelSerial": "ধারাবাহিক",
"LabelSeries": "সিরিজ", "LabelSeries": "সিরিজ",
"LabelSeriesName": "সিরিজের নাম", "LabelSeriesName": "সিরিজের নাম",
"LabelSeriesProgress": "সিরিজের অগ্রগতি", "LabelSeriesProgress": "সিরিজের অগ্রগতি",
"LabelServerLogLevel": "সার্ভার লগ লেভেল",
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})", "LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন", "LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন", "LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
@ -523,6 +563,9 @@
"LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।", "LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।",
"LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন", "LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন",
"LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন", "LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "শতকরা সম্পূর্ণ এর চেয়ে বেশি",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "বাকি সময় (সেকেন্ড) এর চেয়ে কম",
"LabelSettingsLibraryMarkAsFinishedWhen": "মিডিয়া আইটেমকে সমাপ্ত হিসাবে চিহ্নিত করুন যখন",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।",
"LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন", "LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন",
@ -587,6 +630,7 @@
"LabelTimeDurationXMinutes": "{0} মিনিট", "LabelTimeDurationXMinutes": "{0} মিনিট",
"LabelTimeDurationXSeconds": "{0} সেকেন্ড", "LabelTimeDurationXSeconds": "{0} সেকেন্ড",
"LabelTimeInMinutes": "মিনিটে সময়", "LabelTimeInMinutes": "মিনিটে সময়",
"LabelTimeLeft": "{0} বাকি",
"LabelTimeListened": "সময় শোনা হয়েছে", "LabelTimeListened": "সময় শোনা হয়েছে",
"LabelTimeListenedToday": "আজ শোনার সময়", "LabelTimeListenedToday": "আজ শোনার সময়",
"LabelTimeRemaining": "{0}টি অবশিষ্ট", "LabelTimeRemaining": "{0}টি অবশিষ্ট",
@ -594,6 +638,7 @@
"LabelTitle": "শিরোনাম", "LabelTitle": "শিরোনাম",
"LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন", "LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন",
"LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।", "LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।",
"LabelToolsM4bEncoder": "M4B এনকোডার",
"LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন", "LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন",
"LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।", "LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।",
"LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন", "LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন",
@ -606,6 +651,7 @@
"LabelTracksMultiTrack": "মাল্টি-ট্র্যাক", "LabelTracksMultiTrack": "মাল্টি-ট্র্যাক",
"LabelTracksNone": "কোন ট্র্যাক নেই", "LabelTracksNone": "কোন ট্র্যাক নেই",
"LabelTracksSingleTrack": "একক-ট্র্যাক", "LabelTracksSingleTrack": "একক-ট্র্যাক",
"LabelTrailer": "আনুগমিক",
"LabelType": "টাইপ", "LabelType": "টাইপ",
"LabelUnabridged": "অসংলগ্ন", "LabelUnabridged": "অসংলগ্ন",
"LabelUndo": "পূর্বাবস্থা", "LabelUndo": "পূর্বাবস্থা",
@ -617,10 +663,13 @@
"LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন", "LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন",
"LabelUpdatedAt": "আপডেট করা হয়েছে", "LabelUpdatedAt": "আপডেট করা হয়েছে",
"LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন", "LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন",
"LabelUploaderDragAndDropFilesOnly": "ফাইল টেনে আনুন",
"LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন", "LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন",
"LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন", "LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন",
"LabelUseAdvancedOptions": "উন্নত বিকল্প ব্যবহার করুন",
"LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন", "LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন",
"LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন", "LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন",
"LabelUseZeroForUnlimited": "অসীমের জন্য 0 ব্যবহার করুন",
"LabelUser": "ব্যবহারকারী", "LabelUser": "ব্যবহারকারী",
"LabelUsername": "ব্যবহারকারীর নাম", "LabelUsername": "ব্যবহারকারীর নাম",
"LabelValue": "মান", "LabelValue": "মান",
@ -667,6 +716,7 @@
"MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?", "MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?",
"MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?", "MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?",
"MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?", "MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?",
"MessageConfirmEmbedMetadataInAudioFiles": "আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?",
"MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?", "MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?",
"MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?", "MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
"MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?", "MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
@ -678,6 +728,7 @@
"MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?", "MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?",
"MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?", "MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?",
"MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?", "MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?",
"MessageConfirmQuickMatchEpisodes": "একটি মিল পাওয়া গেলে দ্রুত ম্যাচিং পর্বগুলি বিস্তারিত ওভাররাইট করবে। শুধুমাত্র অতুলনীয় পর্ব আপডেট করা হবে। আপনি কি নিশ্চিত?",
"MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?", "MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?",
"MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?", "MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?",
"MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?", "MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?",
@ -685,6 +736,7 @@
"MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?", "MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?",
"MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?", "MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?",
"MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?", "MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?",
"MessageConfirmRemoveMetadataFiles": "আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?",
"MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?", "MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?",
"MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?", "MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?",
"MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?", "MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?",
@ -700,6 +752,7 @@
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন", "MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
"MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!", "MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!",
"MessageEmbedFinished": "এম্বেড করা শেষ!", "MessageEmbedFinished": "এম্বেড করা শেষ!",
"MessageEmbedQueue": "মেটাডেটা এম্বেডের জন্য সারিবদ্ধ ({0} সারিতে)",
"MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ", "MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ",
"MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।", "MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।",
"MessageFeedURLWillBe": "ফিড URL হবে {0}", "MessageFeedURLWillBe": "ফিড URL হবে {0}",
@ -744,6 +797,7 @@
"MessageNoLogs": "কোনও লগ নেই", "MessageNoLogs": "কোনও লগ নেই",
"MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই", "MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই",
"MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই", "MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই",
"MessageNoPodcastFeed": "অবৈধ পডকাস্ট: কোনো ফিড নেই",
"MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি", "MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি",
"MessageNoResults": "কোন ফলাফল নেই", "MessageNoResults": "কোন ফলাফল নেই",
"MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই", "MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই",
@ -760,6 +814,10 @@
"MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন", "MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন",
"MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।", "MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।",
"MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই", "MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই",
"MessagePodcastSearchField": "অনুসন্ধান শব্দ বা RSS ফিড URL লিখুন",
"MessageQuickEmbedInProgress": "দ্রুত এম্বেড করা হচ্ছে",
"MessageQuickEmbedQueue": "দ্রুত এম্বেড করার জন্য সারিবদ্ধ ({0} সারিতে)",
"MessageQuickMatchAllEpisodes": "দ্রুত ম্যাচ সব পর্ব",
"MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।", "MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।",
"MessageRemoveChapter": "অধ্যায় সরান", "MessageRemoveChapter": "অধ্যায় সরান",
"MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান", "MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান",
@ -802,6 +860,9 @@
"MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান", "MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান",
"MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ", "MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ",
"MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে", "MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে",
"MessageTaskOpmlParseFailed": "OPML ফাইল পার্স করতে ব্যর্থ হয়েছে",
"MessageTaskOpmlParseFastFail": "অবৈধ OPML ফাইল <opml> ট্যাগ পাওয়া যায়নি বা একটি <outline> ট্যাগ পাওয়া যায়নি",
"MessageTaskOpmlParseNoneFound": "OPML ফাইলে কোনো ফিড পাওয়া যায়নি",
"MessageTaskScanItemsAdded": "{0}টি করা হয়েছে", "MessageTaskScanItemsAdded": "{0}টি করা হয়েছে",
"MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত", "MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত",
"MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে", "MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে",
@ -826,6 +887,10 @@
"NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।", "NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।",
"NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।", "NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।",
"NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।", "NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।",
"NotificationOnBackupCompletedDescription": "ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে",
"NotificationOnBackupFailedDescription": "ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে",
"NotificationOnEpisodeDownloadedDescription": "একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে",
"NotificationOnTestDescription": "বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট",
"PlaceholderNewCollection": "নতুন সংগ্রহের নাম", "PlaceholderNewCollection": "নতুন সংগ্রহের নাম",
"PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ", "PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ",
"PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম", "PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম",
@ -851,6 +916,7 @@
"StatsYearInReview": "বাৎসরিক পর্যালোচনা", "StatsYearInReview": "বাৎসরিক পর্যালোচনা",
"ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে", "ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে",
"ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে", "ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে",
"ToastAsinRequired": "ASIN প্রয়োজন",
"ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে", "ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে",
"ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি", "ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি",
"ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে", "ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে",
@ -870,6 +936,8 @@
"ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে", "ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে",
"ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে", "ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে",
"ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে", "ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে",
"ToastBatchQuickMatchFailed": "ব্যাচ কুইক ম্যাচ ব্যর্থ!",
"ToastBatchQuickMatchStarted": "{0}টি বইয়ের ব্যাচ কুইক ম্যাচ শুরু হয়েছে!",
"ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে", "ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে",
"ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য", "ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য",
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ", "ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
@ -881,6 +949,7 @@
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে", "ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
"ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে", "ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে",
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে", "ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে", "ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে", "ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে", "ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
@ -898,11 +967,14 @@
"ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে", "ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে",
"ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে", "ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে",
"ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে", "ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে",
"ToastEpisodeUpdateSuccess": "{0}টি পর্ব আপডেট করা হয়েছে",
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না", "ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি", "ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
"ToastFailedToMatch": "মেলাতে ব্যর্থ হয়েছে",
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ", "ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
"ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে", "ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে",
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল", "ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
"ToastInvalidMaxEpisodesToDownload": "ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব",
"ToastInvalidUrl": "অকার্যকর ইউআরএল", "ToastInvalidUrl": "অকার্যকর ইউআরএল",
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে", "ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
"ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ", "ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ",
@ -920,14 +992,22 @@
"ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ", "ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ",
"ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে", "ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে",
"ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে", "ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে",
"ToastMatchAllAuthorsFailed": "সমস্ত লেখকের সাথে মিলতে ব্যর্থ হয়েছে",
"ToastMetadataFilesRemovedError": "মেটাডেটা সরানোর সময় ত্রুটি {0} ফাইল",
"ToastMetadataFilesRemovedNoneFound": "কোনো মেটাডেটা নেই।লাইব্রেরিতে {0} ফাইল পাওয়া গেছে",
"ToastMetadataFilesRemovedNoneRemoved": "কোনো মেটাডেটা নেই।{0} ফাইল সরানো হয়েছে",
"ToastMetadataFilesRemovedSuccess": "{0} মেটাডেটা৷{1} ফাইল সরানো হয়েছে",
"ToastMustHaveAtLeastOnePath": "অন্তত একটি পথ থাকতে হবে",
"ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক", "ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক",
"ToastNameRequired": "নাম আবশ্যক", "ToastNameRequired": "নাম আবশ্যক",
"ToastNewEpisodesFound": "{0}টি নতুন পর্ব পাওয়া গেছে",
"ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"", "ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"",
"ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে", "ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে",
"ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে", "ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে",
"ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে", "ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে",
"ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে", "ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে",
"ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন", "ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন",
"ToastNoNewEpisodesFound": "কোন নতুন পর্ব পাওয়া যায়নি",
"ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই", "ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই",
"ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ", "ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ",
"ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ", "ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ",
@ -946,6 +1026,7 @@
"ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে", "ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে",
"ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি", "ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি",
"ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই", "ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই",
"ToastProgressIsNotBeingSynced": "অগ্রগতি সিঙ্ক হচ্ছে না, প্লেব্যাক পুনরায় চালু করুন",
"ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে", "ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে",
"ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে", "ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে",
"ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক", "ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক",
@ -972,6 +1053,7 @@
"ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে", "ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে",
"ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ", "ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ",
"ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে", "ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে",
"ToastSleepTimerDone": "স্লিপ টাইমার হয়ে গেছে... zZzzZz",
"ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে", "ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে",
"ToastSlugRequired": "স্লাগ আবশ্যক", "ToastSlugRequired": "স্লাগ আবশ্যক",
"ToastSocketConnected": "সকেট সংযুক্ত", "ToastSocketConnected": "সকেট সংযুক্ত",

1027
client/strings/ca.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Uložit seznam skladeb", "ButtonSaveTracklist": "Uložit seznam skladeb",
"ButtonScan": "Prohledat", "ButtonScan": "Prohledat",
"ButtonScanLibrary": "Prohledat Knihovnu", "ButtonScanLibrary": "Prohledat Knihovnu",
"ButtonScrollLeft": "Posunout vlevo",
"ButtonScrollRight": "Posunout vpravo",
"ButtonSearch": "Hledat", "ButtonSearch": "Hledat",
"ButtonSelectFolderPath": "Vybrat cestu ke složce", "ButtonSelectFolderPath": "Vybrat cestu ke složce",
"ButtonSeries": "Série", "ButtonSeries": "Série",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Experimentální funkce", "HeaderSettingsExperimental": "Experimentální funkce",
"HeaderSettingsGeneral": "Obecné", "HeaderSettingsGeneral": "Obecné",
"HeaderSettingsScanner": "Skener", "HeaderSettingsScanner": "Skener",
"HeaderSettingsWebClient": "Webový klient",
"HeaderSleepTimer": "Časovač vypnutí", "HeaderSleepTimer": "Časovač vypnutí",
"HeaderStatsLargestItems": "Největší položky", "HeaderStatsLargestItems": "Největší položky",
"HeaderStatsLongestItems": "Nejdelší položky (hod.)", "HeaderStatsLongestItems": "Nejdelší položky (hod.)",
@ -264,6 +267,7 @@
"LabelChapters": "Kapitoly", "LabelChapters": "Kapitoly",
"LabelChaptersFound": "Kapitoly nalezeny", "LabelChaptersFound": "Kapitoly nalezeny",
"LabelClickForMoreInfo": "Klikněte pro více informací", "LabelClickForMoreInfo": "Klikněte pro více informací",
"LabelClickToUseCurrentValue": "Klikni pro použití aktuální hodnoty",
"LabelClosePlayer": "Zavřít přehrávač", "LabelClosePlayer": "Zavřít přehrávač",
"LabelCodec": "Kodek", "LabelCodec": "Kodek",
"LabelCollapseSeries": "Sbalit sérii", "LabelCollapseSeries": "Sbalit sérii",
@ -313,12 +317,25 @@
"LabelEmailSettingsTestAddress": "Testovací adresa", "LabelEmailSettingsTestAddress": "Testovací adresa",
"LabelEmbeddedCover": "Vložená obálka", "LabelEmbeddedCover": "Vložená obálka",
"LabelEnable": "Povolit", "LabelEnable": "Povolit",
"LabelEncodingBackupLocation": "Záloha původních audio souborů bude uložena v:",
"LabelEncodingChaptersNotEmbedded": "Kapitoly nejsou vloženy ve vícestopých audioknihách.",
"LabelEncodingClearItemCache": "Nezapomeňte pravidelně promazávat mezipaměť položek.",
"LabelEncodingFinishedM4B": "Výsledné M4B bude uloženo do složky s audioknihou v:",
"LabelEncodingInfoEmbedded": "Metadata budou vložena do audio stop ve složce s audioknihou.",
"LabelEncodingStartedNavigation": "Po spuštění úlohy můžete opustit tuto stránku.",
"LabelEncodingTimeWarning": "Encoding může zabrat až 30 minut.",
"LabelEncodingWarningAdvancedSettings": "Varování: Neměňte toto nastavení pokud neznáte možnosti encodingu ffmpeg.",
"LabelEncodingWatcherDisabled": "Pokud máte zakázaný watcher, budete po skončení muset znovu naskenovat tuto audioknihu.",
"LabelEnd": "Konec", "LabelEnd": "Konec",
"LabelEndOfChapter": "Konec kapitoly", "LabelEndOfChapter": "Konec kapitoly",
"LabelEpisode": "Epizoda", "LabelEpisode": "Epizoda",
"LabelEpisodeNotLinkedToRssFeed": "Epizoda není propojená s RSS feed",
"LabelEpisodeNumber": "Epizoda #{0}",
"LabelEpisodeTitle": "Název epizody", "LabelEpisodeTitle": "Název epizody",
"LabelEpisodeType": "Typ epizody", "LabelEpisodeType": "Typ epizody",
"LabelEpisodeUrlFromRssFeed": "URL epizody z RSS feed",
"LabelEpisodes": "Epizody", "LabelEpisodes": "Epizody",
"LabelEpisodic": "Epizodické",
"LabelExample": "Příklad", "LabelExample": "Příklad",
"LabelExpandSeries": "Rozbalit série", "LabelExpandSeries": "Rozbalit série",
"LabelExpandSubSeries": "Rozbalit podsérie", "LabelExpandSubSeries": "Rozbalit podsérie",
@ -346,6 +363,7 @@
"LabelFontScale": "Měřítko písma", "LabelFontScale": "Měřítko písma",
"LabelFontStrikethrough": "Přeškrtnutí", "LabelFontStrikethrough": "Přeškrtnutí",
"LabelFormat": "Formát", "LabelFormat": "Formát",
"LabelFull": "Plné",
"LabelGenre": "Žánr", "LabelGenre": "Žánr",
"LabelGenres": "Žánry", "LabelGenres": "Žánry",
"LabelHardDeleteFile": "Trvale smazat soubor", "LabelHardDeleteFile": "Trvale smazat soubor",
@ -388,6 +406,7 @@
"LabelLess": "Méně", "LabelLess": "Méně",
"LabelLibrariesAccessibleToUser": "Knihovny přístupné uživateli", "LabelLibrariesAccessibleToUser": "Knihovny přístupné uživateli",
"LabelLibrary": "Knihovna", "LabelLibrary": "Knihovna",
"LabelLibraryFilterSublistEmpty": "Žádné {0}",
"LabelLibraryItem": "Položka knihovny", "LabelLibraryItem": "Položka knihovny",
"LabelLibraryName": "Název knihovny", "LabelLibraryName": "Název knihovny",
"LabelLimit": "Omezit", "LabelLimit": "Omezit",
@ -400,6 +419,9 @@
"LabelLowestPriority": "Nejnižší priorita", "LabelLowestPriority": "Nejnižší priorita",
"LabelMatchExistingUsersBy": "Přiřadit stávající uživatele podle", "LabelMatchExistingUsersBy": "Přiřadit stávající uživatele podle",
"LabelMatchExistingUsersByDescription": "Slouží k propojení stávajících uživatelů. Po propojení budou uživatelé přiřazeni k jedinečnému ID od poskytovatele SSO.", "LabelMatchExistingUsersByDescription": "Slouží k propojení stávajících uživatelů. Po propojení budou uživatelé přiřazeni k jedinečnému ID od poskytovatele SSO.",
"LabelMaxEpisodesToDownload": "Maximální # epizod pro stažení. Použijte 0 pro bez omezení.",
"LabelMaxEpisodesToKeep": "Maximální počet epizod k zachování",
"LabelMaxEpisodesToKeepHelp": "Hodnotou 0 není nastaven žádný maximální limit. Po automatickém stažení nové epizody se odstraní nejstarší epizoda, pokud máte více než X epizod. Při každém novém stažení se odstraní pouze 1 epizoda.",
"LabelMediaPlayer": "Přehrávač médií", "LabelMediaPlayer": "Přehrávač médií",
"LabelMediaType": "Typ média", "LabelMediaType": "Typ média",
"LabelMetaTag": "Metaznačka", "LabelMetaTag": "Metaznačka",
@ -445,12 +467,14 @@
"LabelOpenIDGroupClaimDescription": "Název požadavku OpenID, který obsahuje seznam uživatelských skupin. Běžně se označuje jako <code>groups</code>. <b>Je-li nakonfigurováno</b>, plikace automaticky přiřadí role na základě členství uživatele ve skupinách, pokud jsou tyto skupiny v požadavku pojmenovány case-insensitive 'admin', 'user' nebo 'guest'. Požadavek by měl obsahovat seznam, a pokud uživatel patří do více skupin, aplikace přiřadí roli odpovídající nejvyšší úrovni práva přístupu. Pokud žádná skupina není shodná, bude přístup odepřen.", "LabelOpenIDGroupClaimDescription": "Název požadavku OpenID, který obsahuje seznam uživatelských skupin. Běžně se označuje jako <code>groups</code>. <b>Je-li nakonfigurováno</b>, plikace automaticky přiřadí role na základě členství uživatele ve skupinách, pokud jsou tyto skupiny v požadavku pojmenovány case-insensitive 'admin', 'user' nebo 'guest'. Požadavek by měl obsahovat seznam, a pokud uživatel patří do více skupin, aplikace přiřadí roli odpovídající nejvyšší úrovni práva přístupu. Pokud žádná skupina není shodná, bude přístup odepřen.",
"LabelOpenRSSFeed": "Otevřít RSS kanál", "LabelOpenRSSFeed": "Otevřít RSS kanál",
"LabelOverwrite": "Přepsat", "LabelOverwrite": "Přepsat",
"LabelPaginationPageXOfY": "Strana {0} z {1}",
"LabelPassword": "Heslo", "LabelPassword": "Heslo",
"LabelPath": "Cesta", "LabelPath": "Cesta",
"LabelPermanent": "Trvalé", "LabelPermanent": "Trvalé",
"LabelPermissionsAccessAllLibraries": "Má přístup ke všem knihovnám", "LabelPermissionsAccessAllLibraries": "Má přístup ke všem knihovnám",
"LabelPermissionsAccessAllTags": "Má přístup ke všem značkám", "LabelPermissionsAccessAllTags": "Má přístup ke všem značkám",
"LabelPermissionsAccessExplicitContent": "Má přístup k explicitnímu obsahu", "LabelPermissionsAccessExplicitContent": "Má přístup k explicitnímu obsahu",
"LabelPermissionsCreateEreader": "Může vytvořit Ereader",
"LabelPermissionsDelete": "Může mazat", "LabelPermissionsDelete": "Může mazat",
"LabelPermissionsDownload": "Může stahovat", "LabelPermissionsDownload": "Může stahovat",
"LabelPermissionsUpdate": "Může aktualizovat", "LabelPermissionsUpdate": "Může aktualizovat",
@ -474,6 +498,8 @@
"LabelPubDate": "Datum vydání", "LabelPubDate": "Datum vydání",
"LabelPublishYear": "Rok vydání", "LabelPublishYear": "Rok vydání",
"LabelPublishedDate": "Vydáno {0}", "LabelPublishedDate": "Vydáno {0}",
"LabelPublishedDecade": "Publikováno (dekáda)",
"LabelPublishedDecades": "Publikováno (dekády)",
"LabelPublisher": "Vydavatel", "LabelPublisher": "Vydavatel",
"LabelPublishers": "Vydavatelé", "LabelPublishers": "Vydavatelé",
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka", "LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
@ -493,24 +519,32 @@
"LabelRedo": "Přepracovat", "LabelRedo": "Přepracovat",
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Datum vydání", "LabelReleaseDate": "Datum vydání",
"LabelRemoveAllMetadataAbs": "Odebrat všechny soubory metadata.abs",
"LabelRemoveAllMetadataJson": "Smazat všechny soubory metadata.json",
"LabelRemoveCover": "Odstranit obálku", "LabelRemoveCover": "Odstranit obálku",
"LabelRemoveMetadataFile": "Odstranit soubory metadat ve složkách položek knihovny",
"LabelRemoveMetadataFileHelp": "Odstraníte všechny soubory metadata.json a metadata.abs ve svých složkách {0}.",
"LabelRowsPerPage": "Řádky na stránku", "LabelRowsPerPage": "Řádky na stránku",
"LabelSearchTerm": "Vyhledat termín", "LabelSearchTerm": "Vyhledat termín",
"LabelSearchTitle": "Vyhledat název", "LabelSearchTitle": "Vyhledat název",
"LabelSearchTitleOrASIN": "Vyhledat název nebo ASIN", "LabelSearchTitleOrASIN": "Vyhledat název nebo ASIN",
"LabelSeason": "Sezóna", "LabelSeason": "Sezóna",
"LabelSeasonNumber": "Sezóna č.{0}",
"LabelSelectAll": "Vybrat vše", "LabelSelectAll": "Vybrat vše",
"LabelSelectAllEpisodes": "Vybrat všechny epizody", "LabelSelectAllEpisodes": "Vybrat všechny epizody",
"LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují", "LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují",
"LabelSelectUsers": "Vybrat uživatele", "LabelSelectUsers": "Vybrat uživatele",
"LabelSendEbookToDevice": "Odeslat e-knihu do...", "LabelSendEbookToDevice": "Odeslat e-knihu do...",
"LabelSequence": "Sekvence", "LabelSequence": "Sekvence",
"LabelSerial": "Sériové",
"LabelSeries": "Série", "LabelSeries": "Série",
"LabelSeriesName": "Název série", "LabelSeriesName": "Název série",
"LabelSeriesProgress": "Průběh série", "LabelSeriesProgress": "Průběh série",
"LabelServerLogLevel": "Úroveň protokolu serveru",
"LabelServerYearReview": "Přehled roku na serveru ({0})", "LabelServerYearReview": "Přehled roku na serveru ({0})",
"LabelSetEbookAsPrimary": "Nastavit jako primární", "LabelSetEbookAsPrimary": "Nastavit jako primární",
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkové", "LabelSetEbookAsSupplementary": "Nastavit jako doplňkové",
"LabelSettingsAllowIframe": "Povolit vložení do rámce iframe",
"LabelSettingsAudiobooksOnly": "Pouze audioknihy", "LabelSettingsAudiobooksOnly": "Pouze audioknihy",
"LabelSettingsAudiobooksOnlyHelp": "Povolením tohoto nastavení budou soubory e-knih ignorovány, pokud nejsou ve složce audioknih, v takovém případě budou nastaveny jako doplňkové e-knihy", "LabelSettingsAudiobooksOnlyHelp": "Povolením tohoto nastavení budou soubory e-knih ignorovány, pokud nejsou ve složce audioknih, v takovém případě budou nastaveny jako doplňkové e-knihy",
"LabelSettingsBookshelfViewHelp": "Skeumorfní design s dřevěnými policemi", "LabelSettingsBookshelfViewHelp": "Skeumorfní design s dřevěnými policemi",
@ -532,6 +566,9 @@
"LabelSettingsHideSingleBookSeriesHelp": "Série, které mají jedinou knihu, budou skryty na stránce série a na domovské stránce.", "LabelSettingsHideSingleBookSeriesHelp": "Série, které mají jedinou knihu, budou skryty na stránce série a na domovské stránce.",
"LabelSettingsHomePageBookshelfView": "Domovská stránka používá zobrazení police s knihami", "LabelSettingsHomePageBookshelfView": "Domovská stránka používá zobrazení police s knihami",
"LabelSettingsLibraryBookshelfView": "Knihovna používá zobrazení police s knihami", "LabelSettingsLibraryBookshelfView": "Knihovna používá zobrazení police s knihami",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Procento dokončení je vyšší než",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Zbývající čas je kratší než (sekund)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Označit položku médií jako dokončenou, když",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Přeskočit předchozí knihy v pokračování série", "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.", "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": "Analzyovat podtitul", "LabelSettingsParseSubtitles": "Analzyovat podtitul",
@ -550,12 +587,16 @@
"LabelSettingsStoreMetadataWithItemHelp": "Ve výchozím nastavení jsou soubory metadat uloženy v adresáři /metadata/items, povolením tohoto nastavení budou soubory metadat uloženy ve složkách položek knihovny", "LabelSettingsStoreMetadataWithItemHelp": "Ve výchozím nastavení jsou soubory metadat uloženy v adresáři /metadata/items, povolením tohoto nastavení budou soubory metadat uloženy ve složkách položek knihovny",
"LabelSettingsTimeFormat": "Formát času", "LabelSettingsTimeFormat": "Formát času",
"LabelShare": "Sdílet", "LabelShare": "Sdílet",
"LabelShareOpen": "Otevřít sdílení",
"LabelShareURL": "Sdílet URL", "LabelShareURL": "Sdílet URL",
"LabelShowAll": "Zobrazit vše", "LabelShowAll": "Zobrazit vše",
"LabelShowSeconds": "Zobrazit sekundy", "LabelShowSeconds": "Zobrazit sekundy",
"LabelShowSubtitles": "Zobrazit titulky", "LabelShowSubtitles": "Zobrazit titulky",
"LabelSize": "Velikost", "LabelSize": "Velikost",
"LabelSleepTimer": "Časovač vypnutí", "LabelSleepTimer": "Časovač vypnutí",
"LabelSlug": "URL název",
"LabelSortAscending": "Vzestupně",
"LabelSortDescending": "Sestupně",
"LabelStart": "Spustit", "LabelStart": "Spustit",
"LabelStartTime": "Čas Spuštění", "LabelStartTime": "Čas Spuštění",
"LabelStarted": "Spuštěno", "LabelStarted": "Spuštěno",
@ -594,6 +635,7 @@
"LabelTimeDurationXMinutes": "{0} minut", "LabelTimeDurationXMinutes": "{0} minut",
"LabelTimeDurationXSeconds": "{0} sekund", "LabelTimeDurationXSeconds": "{0} sekund",
"LabelTimeInMinutes": "Čas v minutách", "LabelTimeInMinutes": "Čas v minutách",
"LabelTimeLeft": "{0} zbývá",
"LabelTimeListened": "Čas poslechu", "LabelTimeListened": "Čas poslechu",
"LabelTimeListenedToday": "Čas poslechu dnes", "LabelTimeListenedToday": "Čas poslechu dnes",
"LabelTimeRemaining": "{0} zbývá", "LabelTimeRemaining": "{0} zbývá",
@ -601,6 +643,7 @@
"LabelTitle": "Název", "LabelTitle": "Název",
"LabelToolsEmbedMetadata": "Vložit metadata", "LabelToolsEmbedMetadata": "Vložit metadata",
"LabelToolsEmbedMetadataDescription": "Vložit metadata do zvukových souborů včetně obálky a kapitol.", "LabelToolsEmbedMetadataDescription": "Vložit metadata do zvukových souborů včetně obálky a kapitol.",
"LabelToolsM4bEncoder": "Enkodér M4B",
"LabelToolsMakeM4b": "Vytvořit soubor audioknihy M4B", "LabelToolsMakeM4b": "Vytvořit soubor audioknihy M4B",
"LabelToolsMakeM4bDescription": "Vygenerovat soubor audioknihy M4B s vloženými metadaty, obálkou a kapitolami.", "LabelToolsMakeM4bDescription": "Vygenerovat soubor audioknihy M4B s vloženými metadaty, obálkou a kapitolami.",
"LabelToolsSplitM4b": "Rozdělit M4B na MP3", "LabelToolsSplitM4b": "Rozdělit M4B na MP3",
@ -613,6 +656,7 @@
"LabelTracksMultiTrack": "Více stop", "LabelTracksMultiTrack": "Více stop",
"LabelTracksNone": "Žádné stopy", "LabelTracksNone": "Žádné stopy",
"LabelTracksSingleTrack": "Jedna stopa", "LabelTracksSingleTrack": "Jedna stopa",
"LabelTrailer": "Upoutávka",
"LabelType": "Typ", "LabelType": "Typ",
"LabelUnabridged": "Nezkráceno", "LabelUnabridged": "Nezkráceno",
"LabelUndo": "Zpět", "LabelUndo": "Zpět",
@ -624,10 +668,13 @@
"LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda", "LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda",
"LabelUpdatedAt": "Aktualizováno v", "LabelUpdatedAt": "Aktualizováno v",
"LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky", "LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky",
"LabelUploaderDragAndDropFilesOnly": "Přetáhnout a upustit soubory",
"LabelUploaderDropFiles": "Odstranit soubory", "LabelUploaderDropFiles": "Odstranit soubory",
"LabelUploaderItemFetchMetadataHelp": "Automaticky načíst název, autora a sérii", "LabelUploaderItemFetchMetadataHelp": "Automaticky načíst název, autora a sérii",
"LabelUseAdvancedOptions": "Použít pokročilé možnosti",
"LabelUseChapterTrack": "Použít stopu kapitoly", "LabelUseChapterTrack": "Použít stopu kapitoly",
"LabelUseFullTrack": "Použít celou stopu", "LabelUseFullTrack": "Použít celou stopu",
"LabelUseZeroForUnlimited": "Použijte 0 pro neomezené",
"LabelUser": "Uživatel", "LabelUser": "Uživatel",
"LabelUsername": "Uživatelské jméno", "LabelUsername": "Uživatelské jméno",
"LabelValue": "Hodnota", "LabelValue": "Hodnota",
@ -637,6 +684,8 @@
"LabelViewPlayerSettings": "Zobrazit nastavení přehrávače", "LabelViewPlayerSettings": "Zobrazit nastavení přehrávače",
"LabelViewQueue": "Zobrazit frontu přehrávače", "LabelViewQueue": "Zobrazit frontu přehrávače",
"LabelVolume": "Hlasitost", "LabelVolume": "Hlasitost",
"LabelWebRedirectURLsDescription": "Autorizujte tyto adresy URL ve zprostředkovateli OAuth, abyste po přihlášení umožnili přesměrování zpět do webové aplikace:",
"LabelWebRedirectURLsSubfolder": "Podsložka pro přesměrování adres URL",
"LabelWeekdaysToRun": "Dny v týdnu ke spuštění", "LabelWeekdaysToRun": "Dny v týdnu ke spuštění",
"LabelXBooks": "{0} knih", "LabelXBooks": "{0} knih",
"LabelXItems": "{0} položky", "LabelXItems": "{0} položky",
@ -674,6 +723,7 @@
"MessageConfirmDeleteMetadataProvider": "Opravdu chcete vymazat vlastního poskytovatele metadat \"{0}\"?", "MessageConfirmDeleteMetadataProvider": "Opravdu chcete vymazat vlastního poskytovatele metadat \"{0}\"?",
"MessageConfirmDeleteNotification": "Opravdu chcete vymazat tuto notifikaci?", "MessageConfirmDeleteNotification": "Opravdu chcete vymazat tuto notifikaci?",
"MessageConfirmDeleteSession": "Opravdu chcete smazat tuto relaci?", "MessageConfirmDeleteSession": "Opravdu chcete smazat tuto relaci?",
"MessageConfirmEmbedMetadataInAudioFiles": "Jste si jisti, že chcete vložit metadata do {0} zvukových souborů?",
"MessageConfirmForceReScan": "Opravdu chcete vynutit opětovné prohledání?", "MessageConfirmForceReScan": "Opravdu chcete vynutit opětovné prohledání?",
"MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokončené?", "MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokončené?",
"MessageConfirmMarkAllEpisodesNotFinished": "Opravdu chcete označit všechny epizody jako nedokončené?", "MessageConfirmMarkAllEpisodesNotFinished": "Opravdu chcete označit všechny epizody jako nedokončené?",
@ -681,6 +731,7 @@
"MessageConfirmMarkItemNotFinished": "Opravdu chcete označit \"{0}\" jako nedokončené?", "MessageConfirmMarkItemNotFinished": "Opravdu chcete označit \"{0}\" jako nedokončené?",
"MessageConfirmMarkSeriesFinished": "Opravdu chcete označit všechny knihy z této série jako dokončené?", "MessageConfirmMarkSeriesFinished": "Opravdu chcete označit všechny knihy z této série jako dokončené?",
"MessageConfirmMarkSeriesNotFinished": "Opravdu chcete označit všechny knihy z této série jako nedokončené?", "MessageConfirmMarkSeriesNotFinished": "Opravdu chcete označit všechny knihy z této série jako nedokončené?",
"MessageConfirmNotificationTestTrigger": "Spustit toto oznámení s testovacími daty?",
"MessageConfirmPurgeCache": "Vyčistit mezipaměť odstraní celý adresář na adrese <code>/metadata/cache</code>. <br /><br />Určitě chcete odstranit adresář mezipaměti?", "MessageConfirmPurgeCache": "Vyčistit mezipaměť odstraní celý adresář na adrese <code>/metadata/cache</code>. <br /><br />Určitě chcete odstranit adresář mezipaměti?",
"MessageConfirmPurgeItemsCache": "Vyčištění mezipaměti položek odstraní celý adresář <code>/metadata/cache/items</code>.<br />Jste si jistí?", "MessageConfirmPurgeItemsCache": "Vyčištění mezipaměti položek odstraní celý adresář <code>/metadata/cache/items</code>.<br />Jste si jistí?",
"MessageConfirmQuickEmbed": "Varování! Rychlé vložení nezálohuje vaše zvukové soubory. Ujistěte se, že máte zálohu zvukových souborů. <br><br>Chcete pokračovat?", "MessageConfirmQuickEmbed": "Varování! Rychlé vložení nezálohuje vaše zvukové soubory. Ujistěte se, že máte zálohu zvukových souborů. <br><br>Chcete pokračovat?",

View File

@ -71,8 +71,8 @@
"ButtonQuickMatch": "Schnellabgleich", "ButtonQuickMatch": "Schnellabgleich",
"ButtonReScan": "Neu scannen", "ButtonReScan": "Neu scannen",
"ButtonRead": "Lesen", "ButtonRead": "Lesen",
"ButtonReadLess": "Weniger anzeigen", "ButtonReadLess": "weniger Anzeigen",
"ButtonReadMore": "Mehr anzeigen", "ButtonReadMore": "Mehr Anzeigen",
"ButtonRefresh": "Neu Laden", "ButtonRefresh": "Neu Laden",
"ButtonRemove": "Entfernen", "ButtonRemove": "Entfernen",
"ButtonRemoveAll": "Alles entfernen", "ButtonRemoveAll": "Alles entfernen",
@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Speichere die Titelliste", "ButtonSaveTracklist": "Speichere die Titelliste",
"ButtonScan": "Partial-Scan (nur geänderte/neue Medien)", "ButtonScan": "Partial-Scan (nur geänderte/neue Medien)",
"ButtonScanLibrary": "Bibliothek scannen", "ButtonScanLibrary": "Bibliothek scannen",
"ButtonScrollLeft": "Nach Links scrollen",
"ButtonScrollRight": "Nach Rechts scrollen",
"ButtonSearch": "Suchen", "ButtonSearch": "Suchen",
"ButtonSelectFolderPath": "Ordnerpfad auswählen", "ButtonSelectFolderPath": "Ordnerpfad auswählen",
"ButtonSeries": "Serien", "ButtonSeries": "Serien",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Experimentelle Funktionen", "HeaderSettingsExperimental": "Experimentelle Funktionen",
"HeaderSettingsGeneral": "Allgemein", "HeaderSettingsGeneral": "Allgemein",
"HeaderSettingsScanner": "Scanner", "HeaderSettingsScanner": "Scanner",
"HeaderSettingsWebClient": "Web-Client",
"HeaderSleepTimer": "Sleep-Timer", "HeaderSleepTimer": "Sleep-Timer",
"HeaderStatsLargestItems": "Größte Medien", "HeaderStatsLargestItems": "Größte Medien",
"HeaderStatsLongestItems": "Längste Medien (h)", "HeaderStatsLongestItems": "Längste Medien (h)",
@ -220,7 +223,7 @@
"LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen", "LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
"LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu", "LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
"LabelAddedAt": "Hinzugefügt am", "LabelAddedAt": "Hinzugefügt am",
"LabelAddedDate": "Hinzugefügt {0}", "LabelAddedDate": "{0} Hinzugefügt",
"LabelAdminUsersOnly": "Nur Admin Benutzer", "LabelAdminUsersOnly": "Nur Admin Benutzer",
"LabelAll": "Alle", "LabelAll": "Alle",
"LabelAllUsers": "Alle Benutzer", "LabelAllUsers": "Alle Benutzer",
@ -534,6 +537,7 @@
"LabelSelectUsers": "Benutzer auswählen", "LabelSelectUsers": "Benutzer auswählen",
"LabelSendEbookToDevice": "E-Buch senden an …", "LabelSendEbookToDevice": "E-Buch senden an …",
"LabelSequence": "Reihenfolge", "LabelSequence": "Reihenfolge",
"LabelSerial": "fortlaufend",
"LabelSeries": "Serien", "LabelSeries": "Serien",
"LabelSeriesName": "Serienname", "LabelSeriesName": "Serienname",
"LabelSeriesProgress": "Serienfortschritt", "LabelSeriesProgress": "Serienfortschritt",
@ -541,6 +545,7 @@
"LabelServerYearReview": "Server Jahr in Übersicht ({0})", "LabelServerYearReview": "Server Jahr in Übersicht ({0})",
"LabelSetEbookAsPrimary": "Als Hauptbuch setzen", "LabelSetEbookAsPrimary": "Als Hauptbuch setzen",
"LabelSetEbookAsSupplementary": "Als Ergänzung setzen", "LabelSetEbookAsSupplementary": "Als Ergänzung setzen",
"LabelSettingsAllowIframe": "Einbetten in einem iFrame erlauben",
"LabelSettingsAudiobooksOnly": "Nur Hörbücher", "LabelSettingsAudiobooksOnly": "Nur Hörbücher",
"LabelSettingsAudiobooksOnlyHelp": "Wenn du diese Einstellung aktivierst, werden E-Buch-Dateien ignoriert, es sei denn, sie befinden sich in einem Hörbuchordner. In diesem Fall werden sie als zusätzliche E-Bücher festgelegt", "LabelSettingsAudiobooksOnlyHelp": "Wenn du diese Einstellung aktivierst, werden E-Buch-Dateien ignoriert, es sei denn, sie befinden sich in einem Hörbuchordner. In diesem Fall werden sie als zusätzliche E-Bücher festgelegt",
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden", "LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
@ -583,7 +588,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet", "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
"LabelSettingsTimeFormat": "Zeitformat", "LabelSettingsTimeFormat": "Zeitformat",
"LabelShare": "Freigeben", "LabelShare": "Freigeben",
"LabelShareOpen": "Freigabe", "LabelShareOpen": "Freigeben",
"LabelShareURL": "Freigabe URL", "LabelShareURL": "Freigabe URL",
"LabelShowAll": "Alles anzeigen", "LabelShowAll": "Alles anzeigen",
"LabelShowSeconds": "Zeige Sekunden", "LabelShowSeconds": "Zeige Sekunden",
@ -591,6 +596,8 @@
"LabelSize": "Größe", "LabelSize": "Größe",
"LabelSleepTimer": "Schlummerfunktion", "LabelSleepTimer": "Schlummerfunktion",
"LabelSlug": "URL Teil", "LabelSlug": "URL Teil",
"LabelSortAscending": "Aufsteigend",
"LabelSortDescending": "Absteigend",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStartTime": "Startzeit", "LabelStartTime": "Startzeit",
"LabelStarted": "Gestartet", "LabelStarted": "Gestartet",
@ -662,6 +669,7 @@
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird", "LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird",
"LabelUpdatedAt": "Aktualisiert am", "LabelUpdatedAt": "Aktualisiert am",
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern", "LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
"LabelUploaderDragAndDropFilesOnly": "Dateien per Drag & Drop hierher ziehen",
"LabelUploaderDropFiles": "Dateien löschen", "LabelUploaderDropFiles": "Dateien löschen",
"LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie", "LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie",
"LabelUseAdvancedOptions": "Nutze Erweiterte Optionen", "LabelUseAdvancedOptions": "Nutze Erweiterte Optionen",
@ -677,11 +685,13 @@
"LabelViewPlayerSettings": "Zeige player Einstellungen", "LabelViewPlayerSettings": "Zeige player Einstellungen",
"LabelViewQueue": "Player-Warteschlange anzeigen", "LabelViewQueue": "Player-Warteschlange anzeigen",
"LabelVolume": "Lautstärke", "LabelVolume": "Lautstärke",
"LabelWebRedirectURLsDescription": "Autorisiere diese URLs bei deinem OAuth-Anbieter, um die Weiterleitung zurück zur Webanwendung nach dem Login zu ermöglichen:",
"LabelWebRedirectURLsSubfolder": "Unterordner für Weiterleitung-URLs",
"LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelXBooks": "{0} Bücher", "LabelXBooks": "{0} Bücher",
"LabelXItems": "{0} Medien", "LabelXItems": "{0} Medien",
"LabelYearReviewHide": "Verstecke Jahr in Übersicht", "LabelYearReviewHide": "Jahresrückblick verbergen",
"LabelYearReviewShow": "Zeige Jahr in Übersicht", "LabelYearReviewShow": "Jahresrückblick anzeigen",
"LabelYourAudiobookDuration": "Laufzeit deines Mediums", "LabelYourAudiobookDuration": "Laufzeit deines Mediums",
"LabelYourBookmarks": "Lesezeichen", "LabelYourBookmarks": "Lesezeichen",
"LabelYourPlaylists": "Eigene Wiedergabelisten", "LabelYourPlaylists": "Eigene Wiedergabelisten",
@ -726,7 +736,7 @@
"MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis <code>/metadata/cache</code> löschen. <br /><br />Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?", "MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis <code>/metadata/cache</code> löschen. <br /><br />Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?",
"MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter <code>/metadata/cache/items</code> gelöscht.<br />Bist du dir sicher?", "MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter <code>/metadata/cache/items</code> gelöscht.<br />Bist du dir sicher?",
"MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt. <br><br>Möchtest du fortfahren?", "MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt. <br><br>Möchtest du fortfahren?",
"MessageConfirmQuickMatchEpisodes": "Schnelles Zuordnen von Episoden überschreibt die Details, wenn eine Übereinstimmung gefunden wird. Nur nicht zugeordnete Episoden werden aktualisiert. Bist du sicher?", "MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details, wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?",
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?", "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
@ -831,7 +841,7 @@
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird", "MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
"MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab", "MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab",
"MessageShareExpiresIn": "Läuft in {0} ab", "MessageShareExpiresIn": "Läuft in {0} ab",
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein.", "MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein",
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?", "MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
"MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt", "MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt",
"MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen", "MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen",
@ -1039,7 +1049,7 @@
"ToastRenameFailed": "Umbenennen fehlgeschlagen", "ToastRenameFailed": "Umbenennen fehlgeschlagen",
"ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}", "ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}",
"ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt", "ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt",
"ToastRescanUpToDate": "Erneut scannen erledigt, Artikel wahr auf dem neusten Stand", "ToastRescanUpToDate": "Erneut scannen erledigt, Artikel war auf dem neusten Stand",
"ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert", "ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert",
"ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek", "ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek",
"ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus", "ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Save Tracklist", "ButtonSaveTracklist": "Save Tracklist",
"ButtonScan": "Scan", "ButtonScan": "Scan",
"ButtonScanLibrary": "Scan Library", "ButtonScanLibrary": "Scan Library",
"ButtonScrollLeft": "Scroll Left",
"ButtonScrollRight": "Scroll Right",
"ButtonSearch": "Search", "ButtonSearch": "Search",
"ButtonSelectFolderPath": "Select Folder Path", "ButtonSelectFolderPath": "Select Folder Path",
"ButtonSeries": "Series", "ButtonSeries": "Series",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Experimental Features", "HeaderSettingsExperimental": "Experimental Features",
"HeaderSettingsGeneral": "General", "HeaderSettingsGeneral": "General",
"HeaderSettingsScanner": "Scanner", "HeaderSettingsScanner": "Scanner",
"HeaderSettingsWebClient": "Web Client",
"HeaderSleepTimer": "Sleep Timer", "HeaderSleepTimer": "Sleep Timer",
"HeaderStatsLargestItems": "Largest Items", "HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Longest Items (hrs)", "HeaderStatsLongestItems": "Longest Items (hrs)",
@ -542,6 +545,7 @@
"LabelServerYearReview": "Server Year in Review ({0})", "LabelServerYearReview": "Server Year in Review ({0})",
"LabelSetEbookAsPrimary": "Set as primary", "LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary", "LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAllowIframe": "Allow embedding in an iframe",
"LabelSettingsAudiobooksOnly": "Audiobooks only", "LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks", "LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves", "LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
@ -592,6 +596,8 @@
"LabelSize": "Size", "LabelSize": "Size",
"LabelSleepTimer": "Sleep timer", "LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug", "LabelSlug": "Slug",
"LabelSortAscending": "Ascending",
"LabelSortDescending": "Descending",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStartTime": "Start Time", "LabelStartTime": "Start Time",
"LabelStarted": "Started", "LabelStarted": "Started",
@ -663,6 +669,7 @@
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located", "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
"LabelUpdatedAt": "Updated At", "LabelUpdatedAt": "Updated At",
"LabelUploaderDragAndDrop": "Drag & drop files or folders", "LabelUploaderDragAndDrop": "Drag & drop files or folders",
"LabelUploaderDragAndDropFilesOnly": "Drag & drop files",
"LabelUploaderDropFiles": "Drop files", "LabelUploaderDropFiles": "Drop files",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseAdvancedOptions": "Use Advanced Options", "LabelUseAdvancedOptions": "Use Advanced Options",
@ -678,6 +685,8 @@
"LabelViewPlayerSettings": "View player settings", "LabelViewPlayerSettings": "View player settings",
"LabelViewQueue": "View player queue", "LabelViewQueue": "View player queue",
"LabelVolume": "Volume", "LabelVolume": "Volume",
"LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:",
"LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs",
"LabelWeekdaysToRun": "Weekdays to run", "LabelWeekdaysToRun": "Weekdays to run",
"LabelXBooks": "{0} books", "LabelXBooks": "{0} books",
"LabelXItems": "{0} items", "LabelXItems": "{0} items",

View File

@ -1,6 +1,6 @@
{ {
"ButtonAdd": "Agregar", "ButtonAdd": "Agregaro",
"ButtonAddChapters": "Agregar Capitulo", "ButtonAddChapters": "Agregar",
"ButtonAddDevice": "Agregar Dispositivo", "ButtonAddDevice": "Agregar Dispositivo",
"ButtonAddLibrary": "Crear Biblioteca", "ButtonAddLibrary": "Crear Biblioteca",
"ButtonAddPodcasts": "Agregar Podcasts", "ButtonAddPodcasts": "Agregar Podcasts",
@ -71,8 +71,8 @@
"ButtonQuickMatch": "Encontrar Rápido", "ButtonQuickMatch": "Encontrar Rápido",
"ButtonReScan": "Re-Escanear", "ButtonReScan": "Re-Escanear",
"ButtonRead": "Leer", "ButtonRead": "Leer",
"ButtonReadLess": "Lea menos", "ButtonReadLess": "Leer menos",
"ButtonReadMore": "Lea mas", "ButtonReadMore": "Leer más",
"ButtonRefresh": "Refrecar", "ButtonRefresh": "Refrecar",
"ButtonRemove": "Remover", "ButtonRemove": "Remover",
"ButtonRemoveAll": "Remover Todos", "ButtonRemoveAll": "Remover Todos",
@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Guardar Tracklist", "ButtonSaveTracklist": "Guardar Tracklist",
"ButtonScan": "Escanear", "ButtonScan": "Escanear",
"ButtonScanLibrary": "Escanear Biblioteca", "ButtonScanLibrary": "Escanear Biblioteca",
"ButtonScrollLeft": "Desplazarse hacia la izquierda",
"ButtonScrollRight": "Desplazarse hacia la derecha",
"ButtonSearch": "Buscar", "ButtonSearch": "Buscar",
"ButtonSelectFolderPath": "Seleccionar Ruta de Carpeta", "ButtonSelectFolderPath": "Seleccionar Ruta de Carpeta",
"ButtonSeries": "Series", "ButtonSeries": "Series",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Funciones Experimentales", "HeaderSettingsExperimental": "Funciones Experimentales",
"HeaderSettingsGeneral": "General", "HeaderSettingsGeneral": "General",
"HeaderSettingsScanner": "Escáner", "HeaderSettingsScanner": "Escáner",
"HeaderSettingsWebClient": "Cliente web",
"HeaderSleepTimer": "Temporizador de apagado", "HeaderSleepTimer": "Temporizador de apagado",
"HeaderStatsLargestItems": "Artículos mas Grandes", "HeaderStatsLargestItems": "Artículos mas Grandes",
"HeaderStatsLongestItems": "Artículos mas Largos (h)", "HeaderStatsLongestItems": "Artículos mas Largos (h)",
@ -220,7 +223,7 @@
"LabelAddToPlaylist": "Añadido a la lista de reproducción", "LabelAddToPlaylist": "Añadido a la lista de reproducción",
"LabelAddToPlaylistBatch": "Se Añadieron {0} Artículos a la Lista de Reproducción", "LabelAddToPlaylistBatch": "Se Añadieron {0} Artículos a la Lista de Reproducción",
"LabelAddedAt": "Añadido", "LabelAddedAt": "Añadido",
"LabelAddedDate": "Añadido {0}", "LabelAddedDate": "{0} Añadido",
"LabelAdminUsersOnly": "Solamente usuarios administradores", "LabelAdminUsersOnly": "Solamente usuarios administradores",
"LabelAll": "Todos", "LabelAll": "Todos",
"LabelAllUsers": "Todos los Usuarios", "LabelAllUsers": "Todos los Usuarios",
@ -542,6 +545,7 @@
"LabelServerYearReview": "Resumen del año del servidor ({0})", "LabelServerYearReview": "Resumen del año del servidor ({0})",
"LabelSetEbookAsPrimary": "Establecer como primario", "LabelSetEbookAsPrimary": "Establecer como primario",
"LabelSetEbookAsSupplementary": "Establecer como suplementario", "LabelSetEbookAsSupplementary": "Establecer como suplementario",
"LabelSettingsAllowIframe": "Permitir incrustación en un iframe",
"LabelSettingsAudiobooksOnly": "Sólo Audiolibros", "LabelSettingsAudiobooksOnly": "Sólo Audiolibros",
"LabelSettingsAudiobooksOnlyHelp": "Al activar esta opción se ignorarán los archivos de ebook a menos de que estén dentro de la carpeta de un audiolibro, en cuyo caso se marcarán como ebooks suplementarios", "LabelSettingsAudiobooksOnlyHelp": "Al activar esta opción se ignorarán los archivos de ebook a menos de que estén dentro de la carpeta de un audiolibro, en cuyo caso se marcarán como ebooks suplementarios",
"LabelSettingsBookshelfViewHelp": "Diseño Esqueuomorfo con Estantes de Madera", "LabelSettingsBookshelfViewHelp": "Diseño Esqueuomorfo con Estantes de Madera",
@ -592,6 +596,8 @@
"LabelSize": "Tamaño", "LabelSize": "Tamaño",
"LabelSleepTimer": "Temporizador de apagado", "LabelSleepTimer": "Temporizador de apagado",
"LabelSlug": "Slug", "LabelSlug": "Slug",
"LabelSortAscending": "Ascendente",
"LabelSortDescending": "Descendente",
"LabelStart": "Iniciar", "LabelStart": "Iniciar",
"LabelStartTime": "Tiempo de Inicio", "LabelStartTime": "Tiempo de Inicio",
"LabelStarted": "Iniciado", "LabelStarted": "Iniciado",
@ -663,6 +669,7 @@
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados", "LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
"LabelUpdatedAt": "Actualizado En", "LabelUpdatedAt": "Actualizado En",
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas", "LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
"LabelUploaderDragAndDropFilesOnly": "Arrastrar y soltar archivos",
"LabelUploaderDropFiles": "Suelte los Archivos", "LabelUploaderDropFiles": "Suelte los Archivos",
"LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente", "LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente",
"LabelUseAdvancedOptions": "Usar opciones avanzadas", "LabelUseAdvancedOptions": "Usar opciones avanzadas",
@ -678,11 +685,13 @@
"LabelViewPlayerSettings": "Ver los ajustes del reproductor", "LabelViewPlayerSettings": "Ver los ajustes del reproductor",
"LabelViewQueue": "Ver Fila del Reproductor", "LabelViewQueue": "Ver Fila del Reproductor",
"LabelVolume": "Volumen", "LabelVolume": "Volumen",
"LabelWebRedirectURLsDescription": "Autorice estas URL en su proveedor OAuth para permitir la redirección a la aplicación web después de iniciar sesión:",
"LabelWebRedirectURLsSubfolder": "Subcarpeta para URL de redireccionamiento",
"LabelWeekdaysToRun": "Correr en Días de la Semana", "LabelWeekdaysToRun": "Correr en Días de la Semana",
"LabelXBooks": "{0} libros", "LabelXBooks": "{0} libros",
"LabelXItems": "{0} elementos", "LabelXItems": "{0} elementos",
"LabelYearReviewHide": "Ocultar Year in Review", "LabelYearReviewHide": "Ocultar Resumen del año",
"LabelYearReviewShow": "Ver Year in Review", "LabelYearReviewShow": "Resumen del año",
"LabelYourAudiobookDuration": "Duración de tu Audiolibro", "LabelYourAudiobookDuration": "Duración de tu Audiolibro",
"LabelYourBookmarks": "Tus Marcadores", "LabelYourBookmarks": "Tus Marcadores",
"LabelYourPlaylists": "Tus Listas", "LabelYourPlaylists": "Tus Listas",
@ -779,7 +788,7 @@
"MessageNoBackups": "Sin Respaldos", "MessageNoBackups": "Sin Respaldos",
"MessageNoBookmarks": "Sin marcadores", "MessageNoBookmarks": "Sin marcadores",
"MessageNoChapters": "Sin capítulos", "MessageNoChapters": "Sin capítulos",
"MessageNoCollections": "Sin Colecciones", "MessageNoCollections": "Sin colecciones",
"MessageNoCoversFound": "Ninguna Portada Encontrada", "MessageNoCoversFound": "Ninguna Portada Encontrada",
"MessageNoDescription": "Sin Descripción", "MessageNoDescription": "Sin Descripción",
"MessageNoDevices": "Sin dispositivos", "MessageNoDevices": "Sin dispositivos",

View File

@ -592,6 +592,8 @@
"LabelSize": "Taille", "LabelSize": "Taille",
"LabelSleepTimer": "Minuterie de mise en veille", "LabelSleepTimer": "Minuterie de mise en veille",
"LabelSlug": "Identifiant dURL", "LabelSlug": "Identifiant dURL",
"LabelSortAscending": "Croissant",
"LabelSortDescending": "Décroissant",
"LabelStart": "Démarrer", "LabelStart": "Démarrer",
"LabelStartTime": "Heure de démarrage", "LabelStartTime": "Heure de démarrage",
"LabelStarted": "Démarré", "LabelStarted": "Démarré",
@ -663,6 +665,7 @@
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée", "LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée",
"LabelUpdatedAt": "Mis à jour à", "LabelUpdatedAt": "Mis à jour à",
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers", "LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
"LabelUploaderDragAndDropFilesOnly": "Glisser & déposer des fichiers",
"LabelUploaderDropFiles": "Déposer des fichiers", "LabelUploaderDropFiles": "Déposer des fichiers",
"LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, lauteur et la série", "LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, lauteur et la série",
"LabelUseAdvancedOptions": "Utiliser les options avancées", "LabelUseAdvancedOptions": "Utiliser les options avancées",
@ -869,10 +872,10 @@
"MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »", "MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »",
"MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »", "MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »",
"MessageTaskTargetDirectoryNotWritable": "Le répertoire cible nest pas accessible en écriture", "MessageTaskTargetDirectoryNotWritable": "Le répertoire cible nest pas accessible en écriture",
"MessageThinking": "Je cherche…", "MessageThinking": "À la recherche de…",
"MessageUploaderItemFailed": "Échec du téléversement", "MessageUploaderItemFailed": "Échec du téléversement",
"MessageUploaderItemSuccess": "Téléversement effectué !", "MessageUploaderItemSuccess": "Téléversement effectué !",
"MessageUploading": "Téléversement…", "MessageUploading": "Téléchargement…",
"MessageValidCronExpression": "Expression cron valide", "MessageValidCronExpression": "Expression cron valide",
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur", "MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !", "MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !",

View File

@ -8,17 +8,18 @@
"ButtonAddYourFirstLibrary": "הוסף את הספרייה הראשונה שלך", "ButtonAddYourFirstLibrary": "הוסף את הספרייה הראשונה שלך",
"ButtonApply": "החל", "ButtonApply": "החל",
"ButtonApplyChapters": "החל פרקים", "ButtonApplyChapters": "החל פרקים",
"ButtonAuthors": "יוצרים", "ButtonAuthors": "סופרים",
"ButtonBack": "חזור", "ButtonBack": "חזור",
"ButtonBrowseForFolder": "עיין בתיקייה", "ButtonBrowseForFolder": "עיין בתיקייה",
"ButtonCancel": טל", "ButtonCancel": יטול",
"ButtonCancelEncode": "בטל קידוד", "ButtonCancelEncode": "בטל קידוד",
"ButtonChangeRootPassword": "שנה סיסמת root", "ButtonChangeRootPassword": "שנה סיסמת root",
"ButtonCheckAndDownloadNewEpisodes": "בדוק והורד פרקים חדשים", "ButtonCheckAndDownloadNewEpisodes": "בדוק והורד פרקים חדשים",
"ButtonChooseAFolder": "בחר תיקייה", "ButtonChooseAFolder": "בחר תיקייה",
"ButtonChooseFiles": "בחר קבצים", "ButtonChooseFiles": "בחר קבצים",
"ButtonClearFilter": "נקה סינון", "ButtonClearFilter": "נקה סינון",
"ButtonCloseFeed": "סגור פיד", "ButtonCloseFeed": "סגור ערוץ",
"ButtonCloseSession": "סגור סשן פתוח",
"ButtonCollections": "אוספים", "ButtonCollections": "אוספים",
"ButtonConfigureScanner": "הגדר סורק", "ButtonConfigureScanner": "הגדר סורק",
"ButtonCreate": "צור", "ButtonCreate": "צור",
@ -28,6 +29,7 @@
"ButtonEdit": "ערוך", "ButtonEdit": "ערוך",
"ButtonEditChapters": "ערוך פרקים", "ButtonEditChapters": "ערוך פרקים",
"ButtonEditPodcast": "ערוך פודקאסט", "ButtonEditPodcast": "ערוך פודקאסט",
"ButtonEnable": "הפעל",
"ButtonForceReScan": "סרוק מחדש בכוח", "ButtonForceReScan": "סרוק מחדש בכוח",
"ButtonFullPath": "נתיב מלא", "ButtonFullPath": "נתיב מלא",
"ButtonHide": "הסתר", "ButtonHide": "הסתר",
@ -46,19 +48,24 @@
"ButtonNevermind": "לא משנה", "ButtonNevermind": "לא משנה",
"ButtonNext": "הבא", "ButtonNext": "הבא",
"ButtonNextChapter": "פרק הבא", "ButtonNextChapter": "פרק הבא",
"ButtonNextItemInQueue": "פריט הבא בתור",
"ButtonOk": "אישור", "ButtonOk": "אישור",
"ButtonOpenFeed": "פתח פיד", "ButtonOpenFeed": "פתח פיד",
"ButtonOpenManager": "פתח מנהל", "ButtonOpenManager": "פתח מנהל",
"ButtonPause": "השהה", "ButtonPause": "השהה",
"ButtonPlay": "נגן", "ButtonPlay": "נגן",
"ButtonPlayAll": "נגן הכל",
"ButtonPlaying": "מנגן", "ButtonPlaying": "מנגן",
"ButtonPlaylists": "רשימות השמעה", "ButtonPlaylists": "רשימות השמעה",
"ButtonPrevious": "קודם", "ButtonPrevious": "קודם",
"ButtonPreviousChapter": "פרק קודם", "ButtonPreviousChapter": "פרק קודם",
"ButtonProbeAudioFile": "בדוק קובץ אודיו",
"ButtonPurgeAllCache": "נקה את כל המטמון", "ButtonPurgeAllCache": "נקה את כל המטמון",
"ButtonPurgeItemsCache": "נקה את מטמון הפריטים", "ButtonPurgeItemsCache": "נקה את מטמון הפריטים",
"ButtonQueueAddItem": "הוסף לתור", "ButtonQueueAddItem": "הוסף לתור",
"ButtonQueueRemoveItem": "הסר מהתור", "ButtonQueueRemoveItem": "הסר מהתור",
"ButtonQuickEmbed": "הטמעה מהירה",
"ButtonQuickEmbedMetadata": "הטמעת מטא נתונים מהירה",
"ButtonQuickMatch": "התאמה מהירה", "ButtonQuickMatch": "התאמה מהירה",
"ButtonReScan": "סרוק מחדש", "ButtonReScan": "סרוק מחדש",
"ButtonRead": "קרא", "ButtonRead": "קרא",
@ -88,8 +95,10 @@
"ButtonShow": "הצג", "ButtonShow": "הצג",
"ButtonStartM4BEncode": "התחל קידוד M4B", "ButtonStartM4BEncode": "התחל קידוד M4B",
"ButtonStartMetadataEmbed": "התחל הטמעת מטא-נתונים", "ButtonStartMetadataEmbed": "התחל הטמעת מטא-נתונים",
"ButtonStats": "סטטיסטיקות",
"ButtonSubmit": "שלח", "ButtonSubmit": "שלח",
"ButtonTest": "בדיקה", "ButtonTest": "בדיקה",
"ButtonUnlinkOpenId": "נתק OpenID",
"ButtonUpload": "העלה", "ButtonUpload": "העלה",
"ButtonUploadBackup": "העלה גיבוי", "ButtonUploadBackup": "העלה גיבוי",
"ButtonUploadCover": "העלה כריכה", "ButtonUploadCover": "העלה כריכה",
@ -102,6 +111,7 @@
"ErrorUploadFetchMetadataNoResults": "לא ניתן לשלוף מטא-נתונים - נסה לעדכן כותרת ו/או יוצר", "ErrorUploadFetchMetadataNoResults": "לא ניתן לשלוף מטא-נתונים - נסה לעדכן כותרת ו/או יוצר",
"ErrorUploadLacksTitle": "חובה לתת כותרת", "ErrorUploadLacksTitle": "חובה לתת כותרת",
"HeaderAccount": "חשבון", "HeaderAccount": "חשבון",
"HeaderAddCustomMetadataProvider": "הוסף ספק מטא-נתונים מותאם אישית",
"HeaderAdvanced": "מתקדם", "HeaderAdvanced": "מתקדם",
"HeaderAppriseNotificationSettings": "הגדרות התראות של Apprise", "HeaderAppriseNotificationSettings": "הגדרות התראות של Apprise",
"HeaderAudioTracks": "רצועות קול", "HeaderAudioTracks": "רצועות קול",
@ -147,13 +157,17 @@
"HeaderMetadataToEmbed": "מטא-נתונים להטמעה", "HeaderMetadataToEmbed": "מטא-נתונים להטמעה",
"HeaderNewAccount": "חשבון חדש", "HeaderNewAccount": "חשבון חדש",
"HeaderNewLibrary": "ספרייה חדשה", "HeaderNewLibrary": "ספרייה חדשה",
"HeaderNotificationCreate": "צור התראה",
"HeaderNotificationUpdate": "עדכון התראה",
"HeaderNotifications": "התראות", "HeaderNotifications": "התראות",
"HeaderOpenIDConnectAuthentication": "אימות OpenID Connect", "HeaderOpenIDConnectAuthentication": "אימות OpenID Connect",
"HeaderOpenListeningSessions": "פתח הפעלות האזנה",
"HeaderOpenRSSFeed": "פתח ערוץ RSS", "HeaderOpenRSSFeed": "פתח ערוץ RSS",
"HeaderOtherFiles": "קבצים אחרים", "HeaderOtherFiles": "קבצים אחרים",
"HeaderPasswordAuthentication": "אימות סיסמה", "HeaderPasswordAuthentication": "אימות סיסמה",
"HeaderPermissions": "הרשאות", "HeaderPermissions": "הרשאות",
"HeaderPlayerQueue": "תור ניגון", "HeaderPlayerQueue": "תור ניגון",
"HeaderPlayerSettings": "הגדרות נגן",
"HeaderPlaylist": "רשימת השמעה", "HeaderPlaylist": "רשימת השמעה",
"HeaderPlaylistItems": "פריטי רשימת השמעה", "HeaderPlaylistItems": "פריטי רשימת השמעה",
"HeaderPodcastsToAdd": "פודקאסטים להוספה", "HeaderPodcastsToAdd": "פודקאסטים להוספה",
@ -165,6 +179,7 @@
"HeaderRemoveEpisodes": "הסר {0} פרקים", "HeaderRemoveEpisodes": "הסר {0} פרקים",
"HeaderSavedMediaProgress": "התקדמות מדיה שמורה", "HeaderSavedMediaProgress": "התקדמות מדיה שמורה",
"HeaderSchedule": "תיזמון", "HeaderSchedule": "תיזמון",
"HeaderScheduleEpisodeDownloads": "תזמן הורדת פרקים אוטומטית",
"HeaderScheduleLibraryScans": "קבע סריקות ספרייה אוטומטיות", "HeaderScheduleLibraryScans": "קבע סריקות ספרייה אוטומטיות",
"HeaderSession": "הפעלה", "HeaderSession": "הפעלה",
"HeaderSetBackupSchedule": "קבע לוח זמנים לגיבוי", "HeaderSetBackupSchedule": "קבע לוח זמנים לגיבוי",
@ -190,6 +205,9 @@
"HeaderYearReview": "שנת {0} בסקירה", "HeaderYearReview": "שנת {0} בסקירה",
"HeaderYourStats": "הסטטיסטיקות שלך", "HeaderYourStats": "הסטטיסטיקות שלך",
"LabelAbridged": "מקוצר", "LabelAbridged": "מקוצר",
"LabelAbridgedChecked": "מקוצר (מסומן)",
"LabelAbridgedUnchecked": "בלתי מקוצר (לא מסומן)",
"LabelAccessibleBy": "נגיש על ידי",
"LabelAccountType": "סוג חשבון", "LabelAccountType": "סוג חשבון",
"LabelAccountTypeAdmin": "מנהל", "LabelAccountTypeAdmin": "מנהל",
"LabelAccountTypeGuest": "אורח", "LabelAccountTypeGuest": "אורח",
@ -200,13 +218,18 @@
"LabelAddToPlaylist": "הוסף לרשימת השמעה", "LabelAddToPlaylist": "הוסף לרשימת השמעה",
"LabelAddToPlaylistBatch": "הוסף {0} פריטים לרשימת השמעה", "LabelAddToPlaylistBatch": "הוסף {0} פריטים לרשימת השמעה",
"LabelAddedAt": "נוסף בתאריך", "LabelAddedAt": "נוסף בתאריך",
"LabelAddedDate": "נוסף ב-{0}",
"LabelAdminUsersOnly": "רק מנהלים", "LabelAdminUsersOnly": "רק מנהלים",
"LabelAll": "הכל", "LabelAll": "הכל",
"LabelAllUsers": "כל המשתמשים", "LabelAllUsers": "כל המשתמשים",
"LabelAllUsersExcludingGuests": "כל המשתמשים, ללא אורחים", "LabelAllUsersExcludingGuests": "כל המשתמשים, ללא אורחים",
"LabelAllUsersIncludingGuests": "כל המשתמשים כולל אורחים", "LabelAllUsersIncludingGuests": "כל המשתמשים כולל אורחים",
"LabelAlreadyInYourLibrary": "כבר קיים בספרייה שלך", "LabelAlreadyInYourLibrary": "כבר קיים בספרייה שלך",
"LabelApiToken": "טוקן API",
"LabelAppend": "הוסף לסוף", "LabelAppend": "הוסף לסוף",
"LabelAudioBitrate": "קצב סיביות (לדוגמא 128k)",
"LabelAudioChannels": "ערוצי קול (1 או 2)",
"LabelAudioCodec": "קידוד קול",
"LabelAuthor": "יוצר", "LabelAuthor": "יוצר",
"LabelAuthorFirstLast": "יוצר (שם פרטי שם משפחה)", "LabelAuthorFirstLast": "יוצר (שם פרטי שם משפחה)",
"LabelAuthorLastFirst": "יוצר (שם משפחה, שם פרטי)", "LabelAuthorLastFirst": "יוצר (שם משפחה, שם פרטי)",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Spremi popis zvučnih zapisa", "ButtonSaveTracklist": "Spremi popis zvučnih zapisa",
"ButtonScan": "Skeniraj", "ButtonScan": "Skeniraj",
"ButtonScanLibrary": "Skeniraj knjižnicu", "ButtonScanLibrary": "Skeniraj knjižnicu",
"ButtonScrollLeft": "Pomicanje lijevo",
"ButtonScrollRight": "Pomicanje desno",
"ButtonSearch": "Traži", "ButtonSearch": "Traži",
"ButtonSelectFolderPath": "Odaberi putanju mape", "ButtonSelectFolderPath": "Odaberi putanju mape",
"ButtonSeries": "Serijali", "ButtonSeries": "Serijali",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Eksperimentalne značajke", "HeaderSettingsExperimental": "Eksperimentalne značajke",
"HeaderSettingsGeneral": "Općenito", "HeaderSettingsGeneral": "Općenito",
"HeaderSettingsScanner": "Skener", "HeaderSettingsScanner": "Skener",
"HeaderSettingsWebClient": "Web klijent",
"HeaderSleepTimer": "Timer za spavanje", "HeaderSleepTimer": "Timer za spavanje",
"HeaderStatsLargestItems": "Najveće stavke", "HeaderStatsLargestItems": "Najveće stavke",
"HeaderStatsLongestItems": "Najduže stavke (sati)", "HeaderStatsLongestItems": "Najduže stavke (sati)",
@ -271,7 +274,7 @@
"LabelCollapseSubSeries": "Podserijale prikaži sažeto", "LabelCollapseSubSeries": "Podserijale prikaži sažeto",
"LabelCollection": "Zbirka", "LabelCollection": "Zbirka",
"LabelCollections": "Zbirke", "LabelCollections": "Zbirke",
"LabelComplete": "Dovršeno", "LabelComplete": "Potpuno",
"LabelConfirmPassword": "Potvrda zaporke", "LabelConfirmPassword": "Potvrda zaporke",
"LabelContinueListening": "Nastavi slušati", "LabelContinueListening": "Nastavi slušati",
"LabelContinueReading": "Nastavi čitati", "LabelContinueReading": "Nastavi čitati",
@ -532,7 +535,7 @@
"LabelSelectAllEpisodes": "Označi sve nastavke", "LabelSelectAllEpisodes": "Označi sve nastavke",
"LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka", "LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka",
"LabelSelectUsers": "Označi korisnike", "LabelSelectUsers": "Označi korisnike",
"LabelSendEbookToDevice": "Pošalji e-knjigu", "LabelSendEbookToDevice": "Pošalji e-knjigu",
"LabelSequence": "Slijed", "LabelSequence": "Slijed",
"LabelSerial": "Serijal", "LabelSerial": "Serijal",
"LabelSeries": "Serijal", "LabelSeries": "Serijal",
@ -542,6 +545,7 @@
"LabelServerYearReview": "Godišnji pregled poslužitelja ({0})", "LabelServerYearReview": "Godišnji pregled poslužitelja ({0})",
"LabelSetEbookAsPrimary": "Postavi kao primarno", "LabelSetEbookAsPrimary": "Postavi kao primarno",
"LabelSetEbookAsSupplementary": "Postavi kao dopunsko", "LabelSetEbookAsSupplementary": "Postavi kao dopunsko",
"LabelSettingsAllowIframe": "Omogući ugrađivanje u iframeu",
"LabelSettingsAudiobooksOnly": "Samo zvučne knjige", "LabelSettingsAudiobooksOnly": "Samo zvučne knjige",
"LabelSettingsAudiobooksOnlyHelp": "Ako uključite ovu mogućnost, sustav će zanemariti datoteke e-knjiga ukoliko se ne nalaze u mapi zvučne knjige, gdje će se smatrati dopunskim e-knjigama", "LabelSettingsAudiobooksOnlyHelp": "Ako uključite ovu mogućnost, sustav će zanemariti datoteke e-knjiga ukoliko se ne nalaze u mapi zvučne knjige, gdje će se smatrati dopunskim e-knjigama",
"LabelSettingsBookshelfViewHelp": "Skeumorfni dizajn sa drvenim policama", "LabelSettingsBookshelfViewHelp": "Skeumorfni dizajn sa drvenim policama",
@ -567,7 +571,7 @@
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)", "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada", "LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako uključite ovu opciju, serijal će vam se nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako se ova opcija uključi serijal će nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.",
"LabelSettingsParseSubtitles": "Raščlani podnaslove", "LabelSettingsParseSubtitles": "Raščlani podnaslove",
"LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.<br>Podnaslov mora biti odvojen s \" - \"<br>npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"", "LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.<br>Podnaslov mora biti odvojen s \" - \"<br>npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"",
"LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki", "LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki",
@ -592,6 +596,8 @@
"LabelSize": "Veličina", "LabelSize": "Veličina",
"LabelSleepTimer": "Timer za spavanje", "LabelSleepTimer": "Timer za spavanje",
"LabelSlug": "Slug", "LabelSlug": "Slug",
"LabelSortAscending": "Uzlazno",
"LabelSortDescending": "Silazno",
"LabelStart": "Početak", "LabelStart": "Početak",
"LabelStartTime": "Vrijeme početka", "LabelStartTime": "Vrijeme početka",
"LabelStarted": "Započeto", "LabelStarted": "Započeto",
@ -663,6 +669,7 @@
"LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju", "LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju",
"LabelUpdatedAt": "Ažurirano", "LabelUpdatedAt": "Ažurirano",
"LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape", "LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape",
"LabelUploaderDragAndDropFilesOnly": "Pritisni i prevuci datoteke",
"LabelUploaderDropFiles": "Ispusti datoteke", "LabelUploaderDropFiles": "Ispusti datoteke",
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal", "LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama", "LabelUseAdvancedOptions": "Koristi se naprednim opcijama",
@ -678,6 +685,8 @@
"LabelViewPlayerSettings": "Pogledaj postavke reproduktora", "LabelViewPlayerSettings": "Pogledaj postavke reproduktora",
"LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora", "LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora",
"LabelVolume": "Glasnoća", "LabelVolume": "Glasnoća",
"LabelWebRedirectURLsDescription": "Autoriziraj ove URL-ove u svom pružatelju OAuth ovjere kako bi omogućio preusmjeravanje natrag na web-aplikaciju nakon prijave:",
"LabelWebRedirectURLsSubfolder": "Podmapa za URL-ove preusmjeravanja",
"LabelWeekdaysToRun": "Dani u tjednu za pokretanje", "LabelWeekdaysToRun": "Dani u tjednu za pokretanje",
"LabelXBooks": "{0} knjiga", "LabelXBooks": "{0} knjiga",
"LabelXItems": "{0} stavki", "LabelXItems": "{0} stavki",
@ -813,7 +822,7 @@
"MessagePlaylistCreateFromCollection": "Stvori popis za izvođenje od zbirke", "MessagePlaylistCreateFromCollection": "Stvori popis za izvođenje od zbirke",
"MessagePleaseWait": "Molimo pričekajte...", "MessagePleaseWait": "Molimo pričekajte...",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nema adresu RSS izvora za prepoznavanje", "MessagePodcastHasNoRSSFeedForMatching": "Podcast nema adresu RSS izvora za prepoznavanje",
"MessagePodcastSearchField": "Unesite upit za pretragu ili URL RSS izvora", "MessagePodcastSearchField": "Upišite izraz za pretraživanje ili URL RSS izvora",
"MessageQuickEmbedInProgress": "Brzo ugrađivanje u tijeku", "MessageQuickEmbedInProgress": "Brzo ugrađivanje u tijeku",
"MessageQuickEmbedQueue": "Dodano u red za brzo ugrađivanje ({0} u redu izvođenja)", "MessageQuickEmbedQueue": "Dodano u red za brzo ugrađivanje ({0} u redu izvođenja)",
"MessageQuickMatchAllEpisodes": "Brzo prepoznavanje svih nastavaka", "MessageQuickMatchAllEpisodes": "Brzo prepoznavanje svih nastavaka",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Sávlista mentése", "ButtonSaveTracklist": "Sávlista mentése",
"ButtonScan": "Szkennelés", "ButtonScan": "Szkennelés",
"ButtonScanLibrary": "Könyvtár szkennelése", "ButtonScanLibrary": "Könyvtár szkennelése",
"ButtonScrollLeft": "Balra görgetés",
"ButtonScrollRight": "Jobbra görgetés",
"ButtonSearch": "Keresés", "ButtonSearch": "Keresés",
"ButtonSelectFolderPath": "Mappa útvonalának kiválasztása", "ButtonSelectFolderPath": "Mappa útvonalának kiválasztása",
"ButtonSeries": "Sorozatok", "ButtonSeries": "Sorozatok",
@ -180,6 +182,7 @@
"HeaderRemoveEpisodes": "{0} epizód eltávolítása", "HeaderRemoveEpisodes": "{0} epizód eltávolítása",
"HeaderSavedMediaProgress": "Mentett médialejátszási állapot", "HeaderSavedMediaProgress": "Mentett médialejátszási állapot",
"HeaderSchedule": "Ütemezés", "HeaderSchedule": "Ütemezés",
"HeaderScheduleEpisodeDownloads": "Automatikus epizódletöltés ütemezése",
"HeaderScheduleLibraryScans": "Könyvtárak automatikus szkennelésének ütemezése", "HeaderScheduleLibraryScans": "Könyvtárak automatikus szkennelésének ütemezése",
"HeaderSession": "Munkamenet", "HeaderSession": "Munkamenet",
"HeaderSetBackupSchedule": "Biztonsági másolatok ütemezésének beállítása", "HeaderSetBackupSchedule": "Biztonsági másolatok ütemezésének beállítása",
@ -188,13 +191,14 @@
"HeaderSettingsExperimental": "Kísérleti funkciók", "HeaderSettingsExperimental": "Kísérleti funkciók",
"HeaderSettingsGeneral": "Általános", "HeaderSettingsGeneral": "Általános",
"HeaderSettingsScanner": "Szkenner", "HeaderSettingsScanner": "Szkenner",
"HeaderSettingsWebClient": "Webkliens",
"HeaderSleepTimer": "Alvásidőzítő", "HeaderSleepTimer": "Alvásidőzítő",
"HeaderStatsLargestItems": "Legnagyobb elemek", "HeaderStatsLargestItems": "Legnagyobb elemek",
"HeaderStatsLongestItems": "Leghosszabb elemek (órákban)", "HeaderStatsLongestItems": "Leghosszabb elemek (órákban)",
"HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)", "HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)",
"HeaderStatsRecentSessions": "Legutóbbi munkamenetek", "HeaderStatsRecentSessions": "Legutóbbi munkamenetek",
"HeaderStatsTop10Authors": "Top 10 szerzők", "HeaderStatsTop10Authors": "Top 10 szerző",
"HeaderStatsTop5Genres": "Top 5 műfajok", "HeaderStatsTop5Genres": "Top 5 műfaj",
"HeaderTableOfContents": "Tartalomjegyzék", "HeaderTableOfContents": "Tartalomjegyzék",
"HeaderTools": "Eszközök", "HeaderTools": "Eszközök",
"HeaderUpdateAccount": "Fiók frissítése", "HeaderUpdateAccount": "Fiók frissítése",
@ -225,7 +229,11 @@
"LabelAllUsersExcludingGuests": "Minden felhasználó, vendégek kivételével", "LabelAllUsersExcludingGuests": "Minden felhasználó, vendégek kivételével",
"LabelAllUsersIncludingGuests": "Minden felhasználó, beleértve a vendégeket is", "LabelAllUsersIncludingGuests": "Minden felhasználó, beleértve a vendégeket is",
"LabelAlreadyInYourLibrary": "Már a könyvtárában van", "LabelAlreadyInYourLibrary": "Már a könyvtárában van",
"LabelApiToken": "API Token",
"LabelAppend": "Hozzáfűzés", "LabelAppend": "Hozzáfűzés",
"LabelAudioBitrate": "Audió bitráta (pl.128k)",
"LabelAudioChannels": "Audió csatorna (1 vagy 2)",
"LabelAudioCodec": "Audio Codec",
"LabelAuthor": "Szerző", "LabelAuthor": "Szerző",
"LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)", "LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)",
"LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)", "LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)",
@ -238,6 +246,7 @@
"LabelAutoRegister": "Automatikus regisztráció", "LabelAutoRegister": "Automatikus regisztráció",
"LabelAutoRegisterDescription": "Új felhasználók automatikus létrehozása bejelentkezés után", "LabelAutoRegisterDescription": "Új felhasználók automatikus létrehozása bejelentkezés után",
"LabelBackToUser": "Vissza a felhasználóhoz", "LabelBackToUser": "Vissza a felhasználóhoz",
"LabelBackupAudioFiles": "Audiófájlok biztonsági mentése",
"LabelBackupLocation": "Biztonsági másolat helye", "LabelBackupLocation": "Biztonsági másolat helye",
"LabelBackupsEnableAutomaticBackups": "Automatikus biztonsági másolatok engedélyezése", "LabelBackupsEnableAutomaticBackups": "Automatikus biztonsági másolatok engedélyezése",
"LabelBackupsEnableAutomaticBackupsHelp": "Biztonsági másolatok mentése a /metadata/backups mappába", "LabelBackupsEnableAutomaticBackupsHelp": "Biztonsági másolatok mentése a /metadata/backups mappába",
@ -246,15 +255,18 @@
"LabelBackupsNumberToKeep": "Megtartandó biztonsági másolatok száma", "LabelBackupsNumberToKeep": "Megtartandó biztonsági másolatok száma",
"LabelBackupsNumberToKeepHelp": "Egyszerre csak 1 biztonsági másolat kerül eltávolításra, tehát ha már több biztonsági másolat van, mint ez a szám, akkor manuálisan kell eltávolítani őket.", "LabelBackupsNumberToKeepHelp": "Egyszerre csak 1 biztonsági másolat kerül eltávolításra, tehát ha már több biztonsági másolat van, mint ez a szám, akkor manuálisan kell eltávolítani őket.",
"LabelBitrate": "Bitráta", "LabelBitrate": "Bitráta",
"LabelBonus": "Bónusz",
"LabelBooks": "Könyvek", "LabelBooks": "Könyvek",
"LabelButtonText": "Gomb szövege", "LabelButtonText": "Gomb szövege",
"LabelByAuthor": "{} által", "LabelByAuthor": "{} által",
"LabelChangePassword": "Jelszó megváltoztatása", "LabelChangePassword": "Jelszó megváltoztatása",
"LabelChannels": "Csatornák", "LabelChannels": "Csatornák",
"LabelChapterCount": "{0} Fejezet",
"LabelChapterTitle": "Fejezet címe", "LabelChapterTitle": "Fejezet címe",
"LabelChapters": "Fejezetek", "LabelChapters": "Fejezetek",
"LabelChaptersFound": "fejezet található", "LabelChaptersFound": "fejezet található",
"LabelClickForMoreInfo": "További információkért kattintson", "LabelClickForMoreInfo": "További információkért kattintson",
"LabelClickToUseCurrentValue": "Kattintson az aktuális érték használatához",
"LabelClosePlayer": "Lejátszó bezárása", "LabelClosePlayer": "Lejátszó bezárása",
"LabelCodec": "Kodek", "LabelCodec": "Kodek",
"LabelCollapseSeries": "Sorozat összecsukása", "LabelCollapseSeries": "Sorozat összecsukása",
@ -304,16 +316,28 @@
"LabelEmailSettingsTestAddress": "Teszt cím", "LabelEmailSettingsTestAddress": "Teszt cím",
"LabelEmbeddedCover": "Beágyazott borító", "LabelEmbeddedCover": "Beágyazott borító",
"LabelEnable": "Engedélyezés", "LabelEnable": "Engedélyezés",
"LabelEncodingBackupLocation": "Az eredeti hangfájlok biztonsági másolata a következő helyen lesz tárolva:",
"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:",
"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.",
"LabelEncodingWatcherDisabled": "Ha a figyelőt letiltotta, akkor ezt a hangoskönyvet utólag újra be kell olvasnia.",
"LabelEnd": "Vége", "LabelEnd": "Vége",
"LabelEndOfChapter": "Fejezet vége", "LabelEndOfChapter": "Fejezet vége",
"LabelEpisode": "Epizód", "LabelEpisode": "Epizód",
"LabelEpisodeNotLinkedToRssFeed": "Epizód nem kapcsolódik RSS hírcsatonához",
"LabelEpisodeNumber": "Epizód #{0}",
"LabelEpisodeTitle": "Epizód címe", "LabelEpisodeTitle": "Epizód címe",
"LabelEpisodeType": "Epizód típusa", "LabelEpisodeType": "Epizód típusa",
"LabelEpisodeUrlFromRssFeed": "Epizód URL-címe az RSS hírcsatornából",
"LabelEpisodes": "Epizódok", "LabelEpisodes": "Epizódok",
"LabelEpisodic": "Epizódikus",
"LabelExample": "Példa", "LabelExample": "Példa",
"LabelExpandSeries": "Sorozat kinyitása", "LabelExpandSeries": "Sorozat kinyitása",
"LabelExpandSubSeries": "Alsorozat kinyitása", "LabelExpandSubSeries": "Alsorozat kinyitása",
"LabelExplicit": "Explicit", "LabelExplicit": "Szókimondó",
"LabelExplicitChecked": "Explicit (ellenőrizve)", "LabelExplicitChecked": "Explicit (ellenőrizve)",
"LabelExplicitUnchecked": "Nem explicit (nem ellenőrzött)", "LabelExplicitUnchecked": "Nem explicit (nem ellenőrzött)",
"LabelExportOPML": "OPML exportálása", "LabelExportOPML": "OPML exportálása",
@ -337,6 +361,7 @@
"LabelFontScale": "Betűméret skála", "LabelFontScale": "Betűméret skála",
"LabelFontStrikethrough": "Áthúzott", "LabelFontStrikethrough": "Áthúzott",
"LabelFormat": "Formátum", "LabelFormat": "Formátum",
"LabelFull": "Teljes",
"LabelGenre": "Műfaj", "LabelGenre": "Műfaj",
"LabelGenres": "Műfajok", "LabelGenres": "Műfajok",
"LabelHardDeleteFile": "Fájl végleges törlése", "LabelHardDeleteFile": "Fájl végleges törlése",
@ -392,6 +417,10 @@
"LabelLowestPriority": "Legalacsonyabb prioritás", "LabelLowestPriority": "Legalacsonyabb prioritás",
"LabelMatchExistingUsersBy": "Meglévő felhasználók egyeztetése", "LabelMatchExistingUsersBy": "Meglévő felhasználók egyeztetése",
"LabelMatchExistingUsersByDescription": "Meglévő felhasználók összekapcsolására használt. Egyszer összekapcsolva, a felhasználók egyedülálló azonosítóval lesznek egyeztetve az Ön SSO szolgáltatójától", "LabelMatchExistingUsersByDescription": "Meglévő felhasználók összekapcsolására használt. Egyszer összekapcsolva, a felhasználók egyedülálló azonosítóval lesznek egyeztetve az Ön SSO szolgáltatójától",
"LabelMaxEpisodesToDownload": "Letölthető epizódok maximális száma. Használja a 0-t a korlátlan letöltéshez.",
"LabelMaxEpisodesToDownloadPerCheck": "Ellenőrzésenként letölthető új epizódok maximális száma",
"LabelMaxEpisodesToKeep": "Maximálisan megtartható epizódok száma",
"LabelMaxEpisodesToKeepHelp": "A 0 érték nem állít be maximális korlátot. Az új epizód automatikus letöltése után ez a beállítás törli a legrégebbi epizódot, ha X epizódnál több van. Új letöltésenként csak 1 epizódot töröl.",
"LabelMediaPlayer": "Médialejátszó", "LabelMediaPlayer": "Médialejátszó",
"LabelMediaType": "Média típus", "LabelMediaType": "Média típus",
"LabelMetaTag": "Meta címke", "LabelMetaTag": "Meta címke",
@ -399,7 +428,7 @@
"LabelMetadataOrderOfPrecedenceDescription": "A magasabb prioritású metaadat-források felülírják az alacsonyabb prioritásúakat", "LabelMetadataOrderOfPrecedenceDescription": "A magasabb prioritású metaadat-források felülírják az alacsonyabb prioritásúakat",
"LabelMetadataProvider": "Metaadat-szolgáltató", "LabelMetadataProvider": "Metaadat-szolgáltató",
"LabelMinute": "Perc", "LabelMinute": "Perc",
"LabelMinutes": "Percek", "LabelMinutes": "Perc",
"LabelMissing": "Hiányzó", "LabelMissing": "Hiányzó",
"LabelMissingEbook": "Nincs e-könyve", "LabelMissingEbook": "Nincs e-könyve",
"LabelMissingSupplementaryEbook": "Nincs kiegészítő e-könyve", "LabelMissingSupplementaryEbook": "Nincs kiegészítő e-könyve",
@ -434,15 +463,17 @@
"LabelNumberOfEpisodes": "Epizódok száma", "LabelNumberOfEpisodes": "Epizódok száma",
"LabelOpenIDAdvancedPermsClaimDescription": "Az OpenID-igény neve, amely a felhasználói műveletekre vonatkozó haladó jogosultságokat tartalmazza az alkalmazáson belül, és amely a nem adminisztrátori szerepkörökre vonatkozik (<b>ha konfigurálva van</b>). Ha az igény hiányzik a válaszból, az ABS-hez való hozzáférés megtagadásra kerül. Ha egyetlen opció hiányzik, azt <code>false</code>-ként fogja kezelni. Győződj meg arról, hogy az identitásszolgáltató igénye megfelel a várt struktúrának:", "LabelOpenIDAdvancedPermsClaimDescription": "Az OpenID-igény neve, amely a felhasználói műveletekre vonatkozó haladó jogosultságokat tartalmazza az alkalmazáson belül, és amely a nem adminisztrátori szerepkörökre vonatkozik (<b>ha konfigurálva van</b>). Ha az igény hiányzik a válaszból, az ABS-hez való hozzáférés megtagadásra kerül. Ha egyetlen opció hiányzik, azt <code>false</code>-ként fogja kezelni. Győződj meg arról, hogy az identitásszolgáltató igénye megfelel a várt struktúrának:",
"LabelOpenIDClaims": "Hagyd üresen a következő opciókat, hogy letiltsd a haladó csoport- és jogosultság-hozzárendelést, ekkor automatikusan a Felhasználó csoport kerül hozzárendelésre.", "LabelOpenIDClaims": "Hagyd üresen a következő opciókat, hogy letiltsd a haladó csoport- és jogosultság-hozzárendelést, ekkor automatikusan a Felhasználó csoport kerül hozzárendelésre.",
"LabelOpenIDGroupClaimDescription": "Az OpenID-igény neve, amely a felhasználó csoportjainak listáját tartalmazza. Általában groups néven hivatkoznak rá. Ha konfigurálva van, az alkalmazás automatikusan hozzárendeli a szerepköröket a felhasználó csoporttagságai alapján, feltéve, hogy ezek a csoportok az igényben kis- és nagybetűkre érzéketlenül admin, user vagy guest néven szerepelnek. Az igénynek egy listát kell tartalmaznia, és ha egy felhasználó több csoport tagja, az alkalmazás a legmagasabb szintű hozzáféréssel rendelkező szerepkört rendeli hozzá. Ha egyetlen csoport sem felel meg, a hozzáférés megtagadásra kerül.", "LabelOpenIDGroupClaimDescription": "Az OpenID-igény neve, amely a felhasználó csoportjainak listáját tartalmazza. Általában <code>groups<code> néven hivatkoznak rá. <b>Ha konfigurálva van<b>, az alkalmazás automatikusan hozzárendeli a szerepköröket a felhasználó csoporttagságai alapján, feltéve, hogy ezek a csoportok az igényben kis- és nagybetűkre érzéketlenül admin, user vagy guest néven szerepelnek. Az igénynek egy listát kell tartalmaznia, és ha egy felhasználó több csoport tagja, az alkalmazás a legmagasabb szintű hozzáféréssel rendelkező szerepkört rendeli hozzá. Ha egyetlen csoport sem felel meg, a hozzáférés megtagadásra kerül.",
"LabelOpenRSSFeed": "RSS hírcsatorna megnyitása", "LabelOpenRSSFeed": "RSS hírcsatorna megnyitása",
"LabelOverwrite": "Felülírás", "LabelOverwrite": "Felülírás",
"LabelPaginationPageXOfY": "{0} oldal {1}-ból/ből",
"LabelPassword": "Jelszó", "LabelPassword": "Jelszó",
"LabelPath": "Útvonal", "LabelPath": "Útvonal",
"LabelPermanent": "Végleges", "LabelPermanent": "Végleges",
"LabelPermissionsAccessAllLibraries": "Hozzáférhet az összes könyvtárhoz", "LabelPermissionsAccessAllLibraries": "Hozzáférhet az összes könyvtárhoz",
"LabelPermissionsAccessAllTags": "Hozzáférhet az összes címkéhez", "LabelPermissionsAccessAllTags": "Hozzáférhet az összes címkéhez",
"LabelPermissionsAccessExplicitContent": "Hozzáférhet explicit tartalomhoz", "LabelPermissionsAccessExplicitContent": "Hozzáférhet explicit tartalomhoz",
"LabelPermissionsCreateEreader": "Létrehozhat Ereader-t",
"LabelPermissionsDelete": "Törölhet", "LabelPermissionsDelete": "Törölhet",
"LabelPermissionsDownload": "Letölthet", "LabelPermissionsDownload": "Letölthet",
"LabelPermissionsUpdate": "Frissíthet", "LabelPermissionsUpdate": "Frissíthet",
@ -466,6 +497,8 @@
"LabelPubDate": "Kiadás dátuma", "LabelPubDate": "Kiadás dátuma",
"LabelPublishYear": "Kiadás éve", "LabelPublishYear": "Kiadás éve",
"LabelPublishedDate": "Kiadva {0}", "LabelPublishedDate": "Kiadva {0}",
"LabelPublishedDecade": "Közzétett évtized",
"LabelPublishedDecades": "Közzétett évtized",
"LabelPublisher": "Kiadó", "LabelPublisher": "Kiadó",
"LabelPublishers": "Kiadók", "LabelPublishers": "Kiadók",
"LabelRSSFeedCustomOwnerEmail": "Egyéni tulajdonos e-mail", "LabelRSSFeedCustomOwnerEmail": "Egyéni tulajdonos e-mail",
@ -475,6 +508,7 @@
"LabelRSSFeedSlug": "RSS hírcsatorna slug", "LabelRSSFeedSlug": "RSS hírcsatorna slug",
"LabelRSSFeedURL": "RSS hírcsatorna URL", "LabelRSSFeedURL": "RSS hírcsatorna URL",
"LabelRandomly": "Véletlenszerűen", "LabelRandomly": "Véletlenszerűen",
"LabelReAddSeriesToContinueListening": "Sorozat újbóli hozzáadása a folytatáshoz",
"LabelRead": "Olvasás", "LabelRead": "Olvasás",
"LabelReadAgain": "Újraolvasás", "LabelReadAgain": "Újraolvasás",
"LabelReadEbookWithoutProgress": "E-könyv olvasása haladás nélkül", "LabelReadEbookWithoutProgress": "E-könyv olvasása haladás nélkül",
@ -484,12 +518,18 @@
"LabelRedo": "Újra", "LabelRedo": "Újra",
"LabelRegion": "Régió", "LabelRegion": "Régió",
"LabelReleaseDate": "Megjelenés dátuma", "LabelReleaseDate": "Megjelenés dátuma",
"LabelRemoveAllMetadataAbs": "Az összes metadata.abs fájl eltávolítása",
"LabelRemoveAllMetadataJson": "Az összes metadata.json fájl eltávolítása",
"LabelRemoveCover": "Borító eltávolítása", "LabelRemoveCover": "Borító eltávolítása",
"LabelRemoveMetadataFile": "Metaadatfájlok eltávolítása a könyvtár elemek mappáiból",
"LabelRemoveMetadataFileHelp": "A metadata.json és metadata.abs fájlokat eltávolítása a {0} mappáidból.",
"LabelRowsPerPage": "Sorok száma oldalanként", "LabelRowsPerPage": "Sorok száma oldalanként",
"LabelSearchTerm": "Keresési kifejezés", "LabelSearchTerm": "Keresési kifejezés",
"LabelSearchTitle": "Cím keresése", "LabelSearchTitle": "Cím keresése",
"LabelSearchTitleOrASIN": "Cím vagy ASIN keresése", "LabelSearchTitleOrASIN": "Cím vagy ASIN keresése",
"LabelSeason": "Évad", "LabelSeason": "Évad",
"LabelSeasonNumber": "Évad #{0}",
"LabelSelectAll": "Minden kiválasztása",
"LabelSelectAllEpisodes": "Összes epizód kiválasztása", "LabelSelectAllEpisodes": "Összes epizód kiválasztása",
"LabelSelectEpisodesShowing": "Kiválasztás {0} megjelenített epizód", "LabelSelectEpisodesShowing": "Kiválasztás {0} megjelenített epizód",
"LabelSelectUsers": "Felhasználók kiválasztása", "LabelSelectUsers": "Felhasználók kiválasztása",
@ -498,8 +538,11 @@
"LabelSeries": "Sorozat", "LabelSeries": "Sorozat",
"LabelSeriesName": "Sorozat neve", "LabelSeriesName": "Sorozat neve",
"LabelSeriesProgress": "Sorozat haladása", "LabelSeriesProgress": "Sorozat haladása",
"LabelServerLogLevel": "Kiszolgáló naplózási szint",
"LabelServerYearReview": "Szerver évértékelő ({0})",
"LabelSetEbookAsPrimary": "Beállítás elsődlegesként", "LabelSetEbookAsPrimary": "Beállítás elsődlegesként",
"LabelSetEbookAsSupplementary": "Beállítás kiegészítőként", "LabelSetEbookAsSupplementary": "Beállítás kiegészítőként",
"LabelSettingsAllowIframe": "A beágyazás engedélyezése egy iframe-be",
"LabelSettingsAudiobooksOnly": "Csak hangoskönyvek", "LabelSettingsAudiobooksOnly": "Csak hangoskönyvek",
"LabelSettingsAudiobooksOnlyHelp": "Ennek a beállításnak az engedélyezése figyelmen kívül hagyja az e-könyv fájlokat, kivéve, ha azok egy hangoskönyv mappában vannak, ebben az esetben kiegészítő e-könyvként lesznek beállítva", "LabelSettingsAudiobooksOnlyHelp": "Ennek a beállításnak az engedélyezése figyelmen kívül hagyja az e-könyv fájlokat, kivéve, ha azok egy hangoskönyv mappában vannak, ebben az esetben kiegészítő e-könyvként lesznek beállítva",
"LabelSettingsBookshelfViewHelp": "Skeuomorfikus dizájn fa polcokkal", "LabelSettingsBookshelfViewHelp": "Skeuomorfikus dizájn fa polcokkal",
@ -511,6 +554,8 @@
"LabelSettingsEnableWatcher": "Figyelő engedélyezése", "LabelSettingsEnableWatcher": "Figyelő engedélyezése",
"LabelSettingsEnableWatcherForLibrary": "Mappafigyelő engedélyezése a könyvtárban", "LabelSettingsEnableWatcherForLibrary": "Mappafigyelő engedélyezése a könyvtárban",
"LabelSettingsEnableWatcherHelp": "Engedélyezi az automatikus elem hozzáadás/frissítés funkciót, amikor fájlváltozásokat észlel. *Szerver újraindítása szükséges", "LabelSettingsEnableWatcherHelp": "Engedélyezi az automatikus elem hozzáadás/frissítés funkciót, amikor fájlváltozásokat észlel. *Szerver újraindítása szükséges",
"LabelSettingsEpubsAllowScriptedContent": "Szkriptelt tartalmak engedélyezése epub-okban",
"LabelSettingsEpubsAllowScriptedContentHelp": "Megengedi, hogy az epub fájlok szkripteket hajtsanak végre. Ezt a beállítást kikapcsolva ajánlott tartani, kivéve, ha megbízik az epub fájlok forrásában.",
"LabelSettingsExperimentalFeatures": "Kísérleti funkciók", "LabelSettingsExperimentalFeatures": "Kísérleti funkciók",
"LabelSettingsExperimentalFeaturesHelp": "Fejlesztés alatt álló funkciók, amelyek visszajelzésre és tesztelésre szorulnak. Kattintson a github megbeszélés megnyitásához.", "LabelSettingsExperimentalFeaturesHelp": "Fejlesztés alatt álló funkciók, amelyek visszajelzésre és tesztelésre szorulnak. Kattintson a github megbeszélés megnyitásához.",
"LabelSettingsFindCovers": "Borítók keresése", "LabelSettingsFindCovers": "Borítók keresése",
@ -519,6 +564,11 @@
"LabelSettingsHideSingleBookSeriesHelp": "A csak egy könyvet tartalmazó sorozatok el lesznek rejtve a sorozatok oldalról és a kezdőlap polcairól.", "LabelSettingsHideSingleBookSeriesHelp": "A csak egy könyvet tartalmazó sorozatok el lesznek rejtve a sorozatok oldalról és a kezdőlap polcairól.",
"LabelSettingsHomePageBookshelfView": "Kezdőlap használja a könyvespolc nézetet", "LabelSettingsHomePageBookshelfView": "Kezdőlap használja a könyvespolc nézetet",
"LabelSettingsLibraryBookshelfView": "Könyvtár használja a könyvespolc nézetet", "LabelSettingsLibraryBookshelfView": "Könyvtár használja a könyvespolc nézetet",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Százalékos befejezettség nagyobb mint",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "A hátralévő idő kevesebb, mint (másodperc)",
"LabelSettingsLibraryMarkAsFinishedWhen": "A médiaelem befejezettnek jelölése, ha",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Megelőző könyvek kihagyása a Sorozat folytatásában",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "A Sorozat folytatása kezdőlap polcán az első nem megkezdett könyv látható egy olyan sorozatban, amelynek legalább egy könyve befejeződött, és nincs folyamatban lévő rész. Ha engedélyezi ezt a beállítást, akkor a sorozatot a legvégső befejezett könyvtől folytatja az első el nem kezdett könyv helyett.",
"LabelSettingsParseSubtitles": "Feliratok elemzése", "LabelSettingsParseSubtitles": "Feliratok elemzése",
"LabelSettingsParseSubtitlesHelp": "Feliratok kinyerése a hangoskönyv mappaneveiből.<br>A feliratnak el kell különülnie egy \" - \" jellel<br>például: \"Könyv címe - Egy felirat itt\" esetén a felirat \"Egy felirat itt\"", "LabelSettingsParseSubtitlesHelp": "Feliratok kinyerése a hangoskönyv mappaneveiből.<br>A feliratnak el kell különülnie egy \" - \" jellel<br>például: \"Könyv címe - Egy felirat itt\" esetén a felirat \"Egy felirat itt\"",
"LabelSettingsPreferMatchedMetadata": "Preferált egyeztetett metaadatok", "LabelSettingsPreferMatchedMetadata": "Preferált egyeztetett metaadatok",
@ -534,10 +584,14 @@
"LabelSettingsStoreMetadataWithItem": "Metaadatok tárolása az elemmel", "LabelSettingsStoreMetadataWithItem": "Metaadatok tárolása az elemmel",
"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", "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", "LabelSettingsTimeFormat": "Időformátum",
"LabelShare": "Megosztás",
"LabelShowAll": "Mindent mutat", "LabelShowAll": "Mindent mutat",
"LabelShowSubtitles": "Felirat megjelenítése",
"LabelSize": "Méret", "LabelSize": "Méret",
"LabelSleepTimer": "Alvásidőzítő", "LabelSleepTimer": "Alvásidőzítő",
"LabelSlug": "Rövid cím", "LabelSlug": "Rövid cím",
"LabelSortAscending": "Emelkedő",
"LabelSortDescending": "Csökkenő",
"LabelStart": "Kezdés", "LabelStart": "Kezdés",
"LabelStartTime": "Kezdési idő", "LabelStartTime": "Kezdési idő",
"LabelStarted": "Elkezdődött", "LabelStarted": "Elkezdődött",
@ -547,13 +601,13 @@
"LabelStatsBestDay": "Legjobb nap", "LabelStatsBestDay": "Legjobb nap",
"LabelStatsDailyAverage": "Napi átlag", "LabelStatsDailyAverage": "Napi átlag",
"LabelStatsDays": "Napok", "LabelStatsDays": "Napok",
"LabelStatsDaysListened": "Hallgatott napok", "LabelStatsDaysListened": "Napon hallgatva",
"LabelStatsHours": "Órák", "LabelStatsHours": "Órák",
"LabelStatsInARow": "egymás után", "LabelStatsInARow": "egymás után",
"LabelStatsItemsFinished": "Befejezett elemek", "LabelStatsItemsFinished": "Befejezett elem",
"LabelStatsItemsInLibrary": "Elemek a könyvtárban", "LabelStatsItemsInLibrary": "Elemek a könyvtárban",
"LabelStatsMinutes": "percek", "LabelStatsMinutes": "perc",
"LabelStatsMinutesListening": "Hallgatási percek", "LabelStatsMinutesListening": "Hallgatási perc",
"LabelStatsOverallDays": "Összes nap", "LabelStatsOverallDays": "Összes nap",
"LabelStatsOverallHours": "Összes óra", "LabelStatsOverallHours": "Összes óra",
"LabelStatsWeekListening": "Heti hallgatás", "LabelStatsWeekListening": "Heti hallgatás",
@ -565,12 +619,18 @@
"LabelTagsNotAccessibleToUser": "A felhasználó számára nem elérhető címkék", "LabelTagsNotAccessibleToUser": "A felhasználó számára nem elérhető címkék",
"LabelTasks": "Futó feladatok", "LabelTasks": "Futó feladatok",
"LabelTextEditorBulletedList": "Pontozott lista", "LabelTextEditorBulletedList": "Pontozott lista",
"LabelTextEditorLink": "Hivatkozás",
"LabelTextEditorNumberedList": "Számozott lista", "LabelTextEditorNumberedList": "Számozott lista",
"LabelTextEditorUnlink": "Link eltávolítása", "LabelTextEditorUnlink": "Link eltávolítása",
"LabelTheme": "Téma", "LabelTheme": "Téma",
"LabelThemeDark": "Sötét", "LabelThemeDark": "Sötét",
"LabelThemeLight": "Világos", "LabelThemeLight": "Világos",
"LabelTimeBase": "Időalap", "LabelTimeBase": "Időalap",
"LabelTimeDurationXHours": "{0} óra",
"LabelTimeDurationXMinutes": "{0} perc",
"LabelTimeDurationXSeconds": "{0} másodperc",
"LabelTimeInMinutes": "Idő percben",
"LabelTimeLeft": "{0} maradt hátra",
"LabelTimeListened": "Hallgatott idő", "LabelTimeListened": "Hallgatott idő",
"LabelTimeListenedToday": "Ma hallgatott idő", "LabelTimeListenedToday": "Ma hallgatott idő",
"LabelTimeRemaining": "{0} maradt", "LabelTimeRemaining": "{0} maradt",
@ -578,6 +638,7 @@
"LabelTitle": "Cím", "LabelTitle": "Cím",
"LabelToolsEmbedMetadata": "Metaadatok beágyazása", "LabelToolsEmbedMetadata": "Metaadatok beágyazása",
"LabelToolsEmbedMetadataDescription": "Metaadatok beágyazása az audiofájlokba, beleértve a borítóképet és a fejezeteket.", "LabelToolsEmbedMetadataDescription": "Metaadatok beágyazása az audiofájlokba, beleértve a borítóképet és a fejezeteket.",
"LabelToolsM4bEncoder": "M4B kódoló",
"LabelToolsMakeM4b": "M4B Hangoskönyv fájl készítése", "LabelToolsMakeM4b": "M4B Hangoskönyv fájl készítése",
"LabelToolsMakeM4bDescription": ".M4B hangoskönyv fájl generálása beágyazott metaadatokkal, borítóképpel és fejezetekkel.", "LabelToolsMakeM4bDescription": ".M4B hangoskönyv fájl generálása beágyazott metaadatokkal, borítóképpel és fejezetekkel.",
"LabelToolsSplitM4b": "M4B felosztása MP3-ra", "LabelToolsSplitM4b": "M4B felosztása MP3-ra",
@ -590,29 +651,41 @@
"LabelTracksMultiTrack": "Többsávos", "LabelTracksMultiTrack": "Többsávos",
"LabelTracksNone": "Nincsenek sávok", "LabelTracksNone": "Nincsenek sávok",
"LabelTracksSingleTrack": "Egysávos", "LabelTracksSingleTrack": "Egysávos",
"LabelTrailer": "Előzetes",
"LabelType": "Típus", "LabelType": "Típus",
"LabelUnabridged": "Nem tömörített", "LabelUnabridged": "Nem tömörített",
"LabelUndo": "Visszavonás", "LabelUndo": "Visszavonás",
"LabelUnknown": "Ismeretlen", "LabelUnknown": "Ismeretlen",
"LabelUnknownPublishDate": "Ismeretlen megjelenési dátum",
"LabelUpdateCover": "Borító frissítése", "LabelUpdateCover": "Borító frissítése",
"LabelUpdateCoverHelp": "Lehetővé teszi a meglévő borítók felülírását a kiválasztott könyveknél, amikor találatot talál", "LabelUpdateCoverHelp": "Lehetővé teszi a meglévő borítók felülírását a kiválasztott könyveknél, amikor találatot talál",
"LabelUpdateDetails": "Részletek frissítése", "LabelUpdateDetails": "Részletek frissítése",
"LabelUpdateDetailsHelp": "Lehetővé teszi a meglévő részletek felülírását a kiválasztott könyveknél, amikor találatot talál", "LabelUpdateDetailsHelp": "Lehetővé teszi a meglévő részletek felülírását a kiválasztott könyveknél, amikor találatot talál",
"LabelUpdatedAt": "Frissítve", "LabelUpdatedAt": "Frissítve",
"LabelUploaderDragAndDrop": "Fájlok vagy mappák húzása és elengedése", "LabelUploaderDragAndDrop": "Fájlok vagy mappák húzása és elengedése",
"LabelUploaderDragAndDropFilesOnly": "Fájlok húzása és elengedése",
"LabelUploaderDropFiles": "Fájlok elengedése", "LabelUploaderDropFiles": "Fájlok elengedése",
"LabelUploaderItemFetchMetadataHelp": "Cím, szerző és sorozat automatikus lekérése", "LabelUploaderItemFetchMetadataHelp": "Cím, szerző és sorozat automatikus lekérése",
"LabelUseAdvancedOptions": "Haladó beállítások használata",
"LabelUseChapterTrack": "Fejezetsáv használata", "LabelUseChapterTrack": "Fejezetsáv használata",
"LabelUseFullTrack": "Teljes sáv használata", "LabelUseFullTrack": "Teljes sáv használata",
"LabelUseZeroForUnlimited": "Használja a 0-t a korlátlan értékhez",
"LabelUser": "Felhasználó", "LabelUser": "Felhasználó",
"LabelUsername": "Felhasználónév", "LabelUsername": "Felhasználónév",
"LabelValue": "Érték", "LabelValue": "Érték",
"LabelVersion": "Verzió", "LabelVersion": "Verzió",
"LabelViewBookmarks": "Könyvjelzők megtekintése", "LabelViewBookmarks": "Könyvjelzők megtekintése",
"LabelViewChapters": "Fejezetek megtekintése", "LabelViewChapters": "Fejezetek megtekintése",
"LabelViewPlayerSettings": "A lejátszó beállításainak megtekintése",
"LabelViewQueue": "Lejátszó sor megtekintése", "LabelViewQueue": "Lejátszó sor megtekintése",
"LabelVolume": "Hangerő", "LabelVolume": "Hangerő",
"LabelWebRedirectURLsDescription": "Engedélyezze ezeket az URL-címeket az OAuth-szolgáltatóban, hogy a bejelentkezés után vissza lehessen irányítani a webes alkalmazáshoz:",
"LabelWebRedirectURLsSubfolder": "Almappa átirányító URL-ek számára",
"LabelWeekdaysToRun": "Futás napjai", "LabelWeekdaysToRun": "Futás napjai",
"LabelXBooks": "{0} könyv",
"LabelXItems": "{0} elem",
"LabelYearReviewHide": "Évértékelő elrejtése",
"LabelYearReviewShow": "Évértékelés megtekintése",
"LabelYourAudiobookDuration": "Hangoskönyv időtartama", "LabelYourAudiobookDuration": "Hangoskönyv időtartama",
"LabelYourBookmarks": "Könyvjelzőid", "LabelYourBookmarks": "Könyvjelzőid",
"LabelYourPlaylists": "Lejátszási listáid", "LabelYourPlaylists": "Lejátszási listáid",
@ -620,10 +693,14 @@
"MessageAddToPlayerQueue": "Hozzáadás a lejátszó sorhoz", "MessageAddToPlayerQueue": "Hozzáadás a lejátszó sorhoz",
"MessageAppriseDescription": "Ennek a funkció használatához futtatnia kell egy <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> példányt vagy egy olyan API-t, amely kezeli ezeket a kéréseket. <br />Az Apprise API URL-nek a teljes URL útvonalat kell tartalmaznia az értesítés elküldéséhez, például, ha az API példánya a <code>http://192.168.1.1:8337</code> címen szolgáltatva, akkor <code>http://192.168.1.1:8337/notify</code> értéket kell megadnia.", "MessageAppriseDescription": "Ennek a funkció használatához futtatnia kell egy <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> példányt vagy egy olyan API-t, amely kezeli ezeket a kéréseket. <br />Az Apprise API URL-nek a teljes URL útvonalat kell tartalmaznia az értesítés elküldéséhez, például, ha az API példánya a <code>http://192.168.1.1:8337</code> címen szolgáltatva, akkor <code>http://192.168.1.1:8337/notify</code> értéket kell megadnia.",
"MessageBackupsDescription": "A biztonsági másolatok tartalmazzák a felhasználókat, a felhasználói haladást, a könyvtári elem részleteit, a szerver beállításait és a képeket, amelyek a <code>/metadata/items</code> és <code>/metadata/authors</code> mappákban vannak tárolva. A biztonsági másolatok <strong>nem</strong> tartalmazzák a könyvtári mappákban tárolt fájlokat.", "MessageBackupsDescription": "A biztonsági másolatok tartalmazzák a felhasználókat, a felhasználói haladást, a könyvtári elem részleteit, a szerver beállításait és a képeket, amelyek a <code>/metadata/items</code> és <code>/metadata/authors</code> mappákban vannak tárolva. A biztonsági másolatok <strong>nem</strong> tartalmazzák a könyvtári mappákban tárolt fájlokat.",
"MessageBackupsLocationEditNote": "Megjegyzés: A biztonsági mentés helyének frissítése nem mozgatja vagy módosítja a meglévő biztonsági mentéseket",
"MessageBackupsLocationNoEditNote": "Megjegyzés: A biztonsági mentés helye egy környezeti változóval van beállítva, és itt nem módosítható.",
"MessageBackupsLocationPathEmpty": "A biztonsági mentés helyének elérési útvonala nem lehet üres",
"MessageBatchQuickMatchDescription": "A Gyors egyeztetés megpróbálja hozzáadni a hiányzó borítókat és metaadatokat a kiválasztott elemekhez. Engedélyezze az alábbi opciókat, hogy a Gyors egyeztetés felülírhassa a meglévő borítókat és/vagy metaadatokat.", "MessageBatchQuickMatchDescription": "A Gyors egyeztetés megpróbálja hozzáadni a hiányzó borítókat és metaadatokat a kiválasztott elemekhez. Engedélyezze az alábbi opciókat, hogy a Gyors egyeztetés felülírhassa a meglévő borítókat és/vagy metaadatokat.",
"MessageBookshelfNoCollections": "Még nem készített gyűjteményeket", "MessageBookshelfNoCollections": "Még nem készített gyűjteményeket",
"MessageBookshelfNoRSSFeeds": "Nincsenek nyitott RSS hírcsatornák", "MessageBookshelfNoRSSFeeds": "Nincsenek nyitott RSS hírcsatornák",
"MessageBookshelfNoResultsForFilter": "Nincs eredmény a \"{0}: {1}\" szűrőre", "MessageBookshelfNoResultsForFilter": "Nincs eredmény a \"{0}: {1}\" szűrőre",
"MessageBookshelfNoResultsForQuery": "Nincs eredmény a lekérdezéshez",
"MessageBookshelfNoSeries": "Nincsenek sorozatai", "MessageBookshelfNoSeries": "Nincsenek sorozatai",
"MessageChapterEndIsAfter": "A fejezet vége a hangoskönyv végét követi", "MessageChapterEndIsAfter": "A fejezet vége a hangoskönyv végét követi",
"MessageChapterErrorFirstNotZero": "Az első fejezetnek 0:00-kor kell kezdődnie", "MessageChapterErrorFirstNotZero": "Az első fejezetnek 0:00-kor kell kezdődnie",
@ -633,17 +710,27 @@
"MessageCheckingCron": "Cron ellenőrzése...", "MessageCheckingCron": "Cron ellenőrzése...",
"MessageConfirmCloseFeed": "Biztosan be szeretné zárni ezt a hírcsatornát?", "MessageConfirmCloseFeed": "Biztosan be szeretné zárni ezt a hírcsatornát?",
"MessageConfirmDeleteBackup": "Biztosan törölni szeretné a(z) {0} biztonsági másolatot?", "MessageConfirmDeleteBackup": "Biztosan törölni szeretné a(z) {0} biztonsági másolatot?",
"MessageConfirmDeleteDevice": "Biztos, hogy törölni szeretné a „{0}” e-olvasó eszközt?",
"MessageConfirmDeleteFile": "Ez törölni fogja a fájlt a fájlrendszerből. Biztos benne?", "MessageConfirmDeleteFile": "Ez törölni fogja a fájlt a fájlrendszerből. Biztos benne?",
"MessageConfirmDeleteLibrary": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" könyvtárat?", "MessageConfirmDeleteLibrary": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" könyvtárat?",
"MessageConfirmDeleteLibraryItem": "Ez eltávolítja a könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?", "MessageConfirmDeleteLibraryItem": "Ez eltávolítja a könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?",
"MessageConfirmDeleteLibraryItems": "Ez eltávolítja a(z) {0} könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?", "MessageConfirmDeleteLibraryItems": "Ez eltávolítja a(z) {0} könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?",
"MessageConfirmDeleteMetadataProvider": "Biztos, hogy törölni szeretné a „{0}” egyéni metaadat-szolgáltatót?",
"MessageConfirmDeleteNotification": "Biztos, hogy törölni szeretné ezt az értesítést?",
"MessageConfirmDeleteSession": "Biztosan törölni szeretné ezt a munkamenetet?", "MessageConfirmDeleteSession": "Biztosan törölni szeretné ezt a munkamenetet?",
"MessageConfirmEmbedMetadataInAudioFiles": "Biztos, hogy metaadatokat szeretne beágyazni {0} hangfájlba?",
"MessageConfirmForceReScan": "Biztosan kényszeríteni szeretné az újraszkennelést?", "MessageConfirmForceReScan": "Biztosan kényszeríteni szeretné az újraszkennelést?",
"MessageConfirmMarkAllEpisodesFinished": "Biztosan meg szeretné jelölni az összes epizódot befejezettnek?", "MessageConfirmMarkAllEpisodesFinished": "Biztosan meg szeretné jelölni az összes epizódot befejezettnek?",
"MessageConfirmMarkAllEpisodesNotFinished": "Biztosan meg szeretné jelölni az összes epizódot nem befejezettnek?", "MessageConfirmMarkAllEpisodesNotFinished": "Biztosan meg szeretné jelölni az összes epizódot nem befejezettnek?",
"MessageConfirmMarkItemFinished": "Biztos, hogy a „{0}”-t befejezettnek akarja jelölni?",
"MessageConfirmMarkItemNotFinished": "Biztos, hogy a „{0}”-t befejezetlennek akarja jelölni?",
"MessageConfirmMarkSeriesFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét befejezettnek?", "MessageConfirmMarkSeriesFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét befejezettnek?",
"MessageConfirmMarkSeriesNotFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét nem befejezettnek?", "MessageConfirmMarkSeriesNotFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét nem befejezettnek?",
"MessageConfirmNotificationTestTrigger": "Ez az értesítés indítható tesztadatokkal?",
"MessageConfirmPurgeCache": "A gyorsítótár kiürítése törli a teljes könyvtárat a <code>/metadata/cache</code> helyről. <br /><br />Biztosan eltávolítja a gyorsítótár könyvtárát?",
"MessageConfirmPurgeItemsCache": "Az elemek gyorsítótárának kiürítése törli a teljes könyvtárat a <code>/metadata/cache/items</code> helyről.<br />Biztos benne?",
"MessageConfirmQuickEmbed": "Figyelem! A Gyors beágyazás nem készít biztonsági másolatot az audiofájlokról. Győződjön meg arról, hogy van biztonsági másolata az audiofájlokról. <br><br>Szeretné folytatni?", "MessageConfirmQuickEmbed": "Figyelem! A Gyors beágyazás nem készít biztonsági másolatot az audiofájlokról. Győződjön meg arról, hogy van biztonsági másolata az audiofájlokról. <br><br>Szeretné folytatni?",
"MessageConfirmQuickMatchEpisodes": "Az epizódok gyors megfeleltetése felülírja a részleteket, ha egyezést talál. Csak a nem egyező epizódok frissülnek. Biztos benne?",
"MessageConfirmReScanLibraryItems": "Biztosan újra szeretné szkennelni a(z) {0} elemet?", "MessageConfirmReScanLibraryItems": "Biztosan újra szeretné szkennelni a(z) {0} elemet?",
"MessageConfirmRemoveAllChapters": "Biztosan eltávolítja az összes fejezetet?", "MessageConfirmRemoveAllChapters": "Biztosan eltávolítja az összes fejezetet?",
"MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?", "MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?",
@ -651,6 +738,7 @@
"MessageConfirmRemoveEpisode": "Biztosan eltávolítja a(z) \"{0}\" epizódot?", "MessageConfirmRemoveEpisode": "Biztosan eltávolítja a(z) \"{0}\" epizódot?",
"MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?", "MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?",
"MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?", "MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?",
"MessageConfirmRemoveMetadataFiles": "Biztos, hogy az összes metaadatot el akarja távolítani {0} fájl van könyvtár mappáiban?",
"MessageConfirmRemoveNarrator": "Biztosan eltávolítja a(z) \"{0}\" előadót?", "MessageConfirmRemoveNarrator": "Biztosan eltávolítja a(z) \"{0}\" előadót?",
"MessageConfirmRemovePlaylist": "Biztosan eltávolítja a(z) \"{0}\" lejátszási listáját?", "MessageConfirmRemovePlaylist": "Biztosan eltávolítja a(z) \"{0}\" lejátszási listáját?",
"MessageConfirmRenameGenre": "Biztosan át szeretné nevezni a(z) \"{0}\" műfajt \"{1}\"-re az összes elemnél?", "MessageConfirmRenameGenre": "Biztosan át szeretné nevezni a(z) \"{0}\" műfajt \"{1}\"-re az összes elemnél?",
@ -659,11 +747,15 @@
"MessageConfirmRenameTag": "Biztosan át szeretné nevezni a(z) \"{0}\" címkét \"{1}\"-re az összes elemnél?", "MessageConfirmRenameTag": "Biztosan át szeretné nevezni a(z) \"{0}\" címkét \"{1}\"-re az összes elemnél?",
"MessageConfirmRenameTagMergeNote": "Megjegyzés: Ez a címke már létezik, így össze lesznek vonva.", "MessageConfirmRenameTagMergeNote": "Megjegyzés: Ez a címke már létezik, így össze lesznek vonva.",
"MessageConfirmRenameTagWarning": "Figyelem! Egy hasonló, de eltérő nagybetűkkel rendelkező címke már létezik \"{0}\".", "MessageConfirmRenameTagWarning": "Figyelem! Egy hasonló, de eltérő nagybetűkkel rendelkező címke már létezik \"{0}\".",
"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?", "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?",
"MessageDownloadingEpisode": "Epizód letöltése", "MessageDownloadingEpisode": "Epizód letöltése",
"MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe", "MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe",
"MessageEmbedFailed": "A beágyazás sikertelen!",
"MessageEmbedFinished": "Beágyazás befejeződött!", "MessageEmbedFinished": "Beágyazás befejeződött!",
"MessageEpisodesQueuedForDownload": "{0} epizód letöltésre vár", "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", "MessageFeedURLWillBe": "A hírcsatorna URL-je {0} lesz",
"MessageFetching": "Lekérdezés...", "MessageFetching": "Lekérdezés...",
"MessageForceReScanDescription": "minden fájlt újra szkennel, mint egy friss szkennelés. Az audiofájlok ID3 címkéi, OPF fájlok és szövegfájlok újként lesznek szkennelve.", "MessageForceReScanDescription": "minden fájlt újra szkennel, mint egy friss szkennelés. Az audiofájlok ID3 címkéi, OPF fájlok és szövegfájlok újként lesznek szkennelve.",
@ -671,10 +763,11 @@
"MessageInsertChapterBelow": "Fejezet beszúrása alulra", "MessageInsertChapterBelow": "Fejezet beszúrása alulra",
"MessageItemsSelected": "{0} kiválasztott elem", "MessageItemsSelected": "{0} kiválasztott elem",
"MessageItemsUpdated": "{0} frissített elem", "MessageItemsUpdated": "{0} frissített elem",
"MessageJoinUsOn": "Csatlakozzon hozzánk", "MessageJoinUsOn": "Csatlakozzon hozzánk a",
"MessageListeningSessionsInTheLastYear": "{0} hallgatási munkamenet az elmúlt évben", "MessageListeningSessionsInTheLastYear": "{0} hallgatási munkamenet az elmúlt évben",
"MessageLoading": "Betöltés...", "MessageLoading": "Betöltés...",
"MessageLoadingFolders": "Mappák betöltése...", "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.",
"MessageM4BFailed": "M4B sikertelen!", "MessageM4BFailed": "M4B sikertelen!",
"MessageM4BFinished": "M4B befejeződött!", "MessageM4BFinished": "M4B befejeződött!",
"MessageMapChapterTitles": "Fejezetcímek hozzárendelése a meglévő hangoskönyv fejezeteihez anélkül, hogy az időbélyegeket módosítaná", "MessageMapChapterTitles": "Fejezetcímek hozzárendelése a meglévő hangoskönyv fejezeteihez anélkül, hogy az időbélyegeket módosítaná",
@ -691,6 +784,7 @@
"MessageNoCollections": "Nincsenek gyűjtemények", "MessageNoCollections": "Nincsenek gyűjtemények",
"MessageNoCoversFound": "Nem találhatóak borítók", "MessageNoCoversFound": "Nem találhatóak borítók",
"MessageNoDescription": "Nincs leírás", "MessageNoDescription": "Nincs leírás",
"MessageNoDevices": "Nincs eszköz",
"MessageNoDownloadsInProgress": "Jelenleg nincsenek folyamatban lévő letöltések", "MessageNoDownloadsInProgress": "Jelenleg nincsenek folyamatban lévő letöltések",
"MessageNoDownloadsQueued": "Nincsenek várakozó letöltések", "MessageNoDownloadsQueued": "Nincsenek várakozó letöltések",
"MessageNoEpisodeMatchesFound": "Nincs találat az epizódokra", "MessageNoEpisodeMatchesFound": "Nincs találat az epizódokra",
@ -704,6 +798,7 @@
"MessageNoLogs": "Nincsenek naplók", "MessageNoLogs": "Nincsenek naplók",
"MessageNoMediaProgress": "Nincs előrehaladás a médialejátszásban", "MessageNoMediaProgress": "Nincs előrehaladás a médialejátszásban",
"MessageNoNotifications": "Nincsenek értesítések", "MessageNoNotifications": "Nincsenek értesítések",
"MessageNoPodcastFeed": "Érvénytelen podcast: Nincs forrás",
"MessageNoPodcastsFound": "Nem találhatóak podcastok", "MessageNoPodcastsFound": "Nem találhatóak podcastok",
"MessageNoResults": "Nincsenek eredmények", "MessageNoResults": "Nincsenek eredmények",
"MessageNoSearchResultsFor": "Nincs keresési eredmény erre: \"{0}\"", "MessageNoSearchResultsFor": "Nincs keresési eredmény erre: \"{0}\"",
@ -713,11 +808,16 @@
"MessageNoUpdatesWereNecessary": "Nem volt szükség frissítésekre", "MessageNoUpdatesWereNecessary": "Nem volt szükség frissítésekre",
"MessageNoUserPlaylists": "Nincsenek felhasználói lejátszási listák", "MessageNoUserPlaylists": "Nincsenek felhasználói lejátszási listák",
"MessageNotYetImplemented": "Még nem implementált", "MessageNotYetImplemented": "Még nem implementált",
"MessageOpmlPreviewNote": "Megjegyzés: Ez egy előnézeti kép az elemzett OPML fájlról. A podcast tényleges címe az RSS hírcsatornából származik.",
"MessageOr": "vagy", "MessageOr": "vagy",
"MessagePauseChapter": "Fejezet lejátszásának szüneteltetése", "MessagePauseChapter": "Fejezet lejátszásának szüneteltetése",
"MessagePlayChapter": "Fejezet elejének meghallgatása", "MessagePlayChapter": "Fejezet elejének meghallgatása",
"MessagePlaylistCreateFromCollection": "Lejátszási lista létrehozása gyűjteményből", "MessagePlaylistCreateFromCollection": "Lejátszási lista létrehozása gyűjteményből",
"MessagePleaseWait": "Kérem várjon...",
"MessagePodcastHasNoRSSFeedForMatching": "A podcastnak nincs RSS hírcsatorna URL-je az egyeztetéshez", "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",
"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.", "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", "MessageRemoveChapter": "Fejezet eltávolítása",
"MessageRemoveEpisodes": "Epizód(ok) eltávolítása: {0}", "MessageRemoveEpisodes": "Epizód(ok) eltávolítása: {0}",
@ -725,14 +825,49 @@
"MessageRemoveUserWarning": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" felhasználót?", "MessageRemoveUserWarning": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" felhasználót?",
"MessageReportBugsAndContribute": "Hibák jelentése, funkciók kérése és hozzájárulás itt", "MessageReportBugsAndContribute": "Hibák jelentése, funkciók kérése és hozzájárulás itt",
"MessageResetChaptersConfirm": "Biztosan alaphelyzetbe szeretné állítani a fejezeteket és visszavonni a módosításokat?", "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", "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.", "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.",
"MessageSearchResultsFor": "Keresési eredmények", "MessageSearchResultsFor": "Keresési eredmények",
"MessageSelected": "{0} kiválasztva", "MessageSelected": "{0} kiválasztva",
"MessageServerCouldNotBeReached": "A szervert nem lehet elérni", "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", "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",
"MessageStartPlaybackAtTime": "\"{0}\" lejátszásának kezdése {1} -tól?", "MessageStartPlaybackAtTime": "\"{0}\" lejátszásának kezdése {1} -tól?",
"MessageThinking": "Gondolkodás...", "MessageTaskAudioFileNotWritable": "A/Az „{0}” hangfájl nem írható",
"MessageTaskCanceledByUser": "Felhasználó törölte a feladatot",
"MessageTaskDownloadingEpisodeDescription": "„{0}” epizód letöltése",
"MessageTaskEmbeddingMetadata": "Metaadatok beágyazása",
"MessageTaskEmbeddingMetadataDescription": "Metaadatok beágyazása a „{0}” hangoskönyvbe",
"MessageTaskEncodingM4b": "Kódolás M4B-ban",
"MessageTaskEncodingM4bDescription": "„{0}” hangoskönyv kódolása egyetlen m4b fájlba",
"MessageTaskFailed": "Sikertelen",
"MessageTaskFailedToBackupAudioFile": "Nem sikerült a „{0}” hangfájl mentése",
"MessageTaskFailedToCreateCacheDirectory": "Nem sikerült létrehozni a gyorsítótár könyvtárat",
"MessageTaskFailedToEmbedMetadataInFile": "Nem sikerült beágyazni a metaadatokat a „{0}” fájlba",
"MessageTaskFailedToMergeAudioFiles": "A hangfájlok egyesítése nem sikerült",
"MessageTaskFailedToMoveM4bFile": "Nem sikerült m4b fájlt áthelyezni",
"MessageTaskFailedToWriteMetadataFile": "Metaadatfájl írása sikertelen",
"MessageTaskMatchingBooksInLibrary": "Könyvek egyeztetése a \"{0}\" könyvtárban",
"MessageTaskNoFilesToScan": "Nincs beolvasandó fájl",
"MessageTaskOpmlImport": "OPML import",
"MessageTaskOpmlImportDescription": "Podcastok létrehozása {0} RSS hírcsatornából",
"MessageTaskOpmlImportFeedDescription": "RSS feed „{0}” importálása",
"MessageTaskOpmlImportFeedFailed": "Nem sikerült letölteni a podcast feedet",
"MessageTaskOpmlImportFeedPodcastDescription": "„{0}” podcast létrehozása",
"MessageTaskOpmlImportFeedPodcastExists": "Podcast már létezik az elérési útvonalon",
"MessageTaskOpmlImportFeedPodcastFailed": "Nem sikerült podcastot létrehozni",
"MessageTaskOpmlImportFinished": "{0} podcast hozzáadva",
"MessageTaskOpmlParseFailed": "Az OPML fájl elemzése nem sikerült",
"MessageTaskOpmlParseFastFail": "Érvénytelen OPML fájl: <opml> tag nem található VAGY nem találtak <outline> taget",
"MessageTaskScanItemsAdded": "{0} hozzáadva",
"MessageTaskScanItemsMissing": "{0} hiányzik",
"MessageTaskScanItemsUpdated": "{0} frissítve",
"MessageTaskScanNoChangesNeeded": "Nincs szükség változtatásra",
"MessageTaskScanningFileChanges": "Fájlváltozások keresése a „{0}” fájlban",
"MessageTaskScanningLibrary": "„{0}” könyvtár beolvasása",
"MessageTaskTargetDirectoryNotWritable": "A célkönyvtár nem írható",
"MessageThinking": "Gondolkodom...",
"MessageUploaderItemFailed": "A feltöltés sikertelen", "MessageUploaderItemFailed": "A feltöltés sikertelen",
"MessageUploaderItemSuccess": "Sikeresen feltöltve!", "MessageUploaderItemSuccess": "Sikeresen feltöltve!",
"MessageUploading": "Feltöltés...", "MessageUploading": "Feltöltés...",
@ -744,45 +879,101 @@
"NoteChangeRootPassword": "A Root felhasználó az egyetlen felhasználó, akinek lehet üres jelszava", "NoteChangeRootPassword": "A Root felhasználó az egyetlen felhasználó, akinek lehet üres jelszava",
"NoteChapterEditorTimes": "Megjegyzés: Az első fejezet kezdőidejének 0:00 kell lennie, és az utolsó fejezet kezdőideje nem haladhatja meg a hangoskönyv időtartamát.", "NoteChapterEditorTimes": "Megjegyzés: Az első fejezet kezdőidejének 0:00 kell lennie, és az utolsó fejezet kezdőideje nem haladhatja meg a hangoskönyv időtartamát.",
"NoteFolderPicker": "Megjegyzés: azok a mappák, amelyek már hozzá vannak rendelve, nem jelennek meg", "NoteFolderPicker": "Megjegyzés: azok a mappák, amelyek már hozzá vannak rendelve, nem jelennek meg",
"NoteRSSFeedPodcastAppsHttps": "Figyelem: A legtöbb podcast alkalmazás megköveteli, hogy az RSS feed URL HTTPS-t használjon", "NoteRSSFeedPodcastAppsHttps": "Figyelem: A legtöbb podcast alkalmazás megköveteli, hogy az RSS hírcsatorna URL-jában HTTPS-t használjon",
"NoteRSSFeedPodcastAppsPubDate": "Figyelem: Az egy vagy több epizódnak nincs Közzétételi dátuma. Néhány podcast alkalmazás ezt megköveteli.", "NoteRSSFeedPodcastAppsPubDate": "Figyelem: Az egy vagy több epizódnak nincs Közzétételi dátuma. Néhány podcast alkalmazás ezt megköveteli.",
"NoteUploaderFoldersWithMediaFiles": "A médiafájlokat tartalmazó mappák külön könyvtári tételekként lesznek kezelve.", "NoteUploaderFoldersWithMediaFiles": "A médiafájlokat tartalmazó mappák külön könyvtári tételekként lesznek kezelve.",
"NoteUploaderOnlyAudioFiles": "Ha csak hangfájlokat tölt fel, akkor minden egyes hangfájl külön hangoskönyvként lesz kezelve.", "NoteUploaderOnlyAudioFiles": "Ha csak hangfájlokat tölt fel, akkor minden egyes hangfájl külön hangoskönyvként lesz kezelve.",
"NoteUploaderUnsupportedFiles": "A nem támogatott fájlok figyelmen kívül hagyásra kerülnek. Mappa kiválasztása vagy elengedésekor az elem mappáján kívüli egyéb fájlok figyelmen kívül lesznek hagyva.", "NoteUploaderUnsupportedFiles": "A nem támogatott fájlok figyelmen kívül hagyásra kerülnek. Mappa kiválasztása vagy elengedésekor az elem mappáján kívüli egyéb fájlok figyelmen kívül lesznek hagyva.",
"NotificationOnBackupCompletedDescription": "A biztonsági mentés befejezésekor aktiválódik",
"NotificationOnBackupFailedDescription": "A biztonsági mentés sikertelensége esetén aktiválódik",
"NotificationOnEpisodeDownloadedDescription": "Egy podcast epizód automatikus letöltésekor aktiválódik",
"NotificationOnTestDescription": "Esemény az értesítési rendszer teszteléséhez",
"PlaceholderNewCollection": "Új gyűjtemény neve", "PlaceholderNewCollection": "Új gyűjtemény neve",
"PlaceholderNewFolderPath": "Új mappa útvonala", "PlaceholderNewFolderPath": "Új mappa útvonala",
"PlaceholderNewPlaylist": "Új lejátszási lista neve", "PlaceholderNewPlaylist": "Új lejátszási lista neve",
"PlaceholderSearch": "Keresés..", "PlaceholderSearch": "Keresés..",
"PlaceholderSearchEpisode": "Epizód keresése..", "PlaceholderSearchEpisode": "Epizód keresése..",
"StatsAuthorsAdded": "szerző hozzáadva",
"StatsBooksAdded": "könyv hozzáadva",
"StatsBooksAdditional": "Néhány kiegészítés…",
"StatsBooksFinished": "könyv befejezve",
"StatsBooksFinishedThisYear": "Néhány idén befejezett könyv…",
"StatsBooksListenedTo": "hallgatott könyv",
"StatsCollectionGrewTo": "Könyvgyűjtemény nőtt…",
"StatsSessions": "munkamenet",
"StatsSpentListening": "hallgatással töltött idő",
"StatsTopAuthor": "TOP SZERZŐ",
"StatsTopAuthors": "TOP SZERZŐ",
"StatsTopGenre": "TOP MŰFAJ",
"StatsTopGenres": "TOP MŰFAJ",
"StatsTopMonth": "TOP HÓNAP",
"StatsTopNarrator": "TOP ELŐADÓ",
"StatsTopNarrators": "TOP ELŐADÓ",
"StatsTotalDuration": "A teljes időtartam…",
"StatsYearInReview": "ÉVÉRTÉKELÉS",
"ToastAccountUpdateSuccess": "Fiók frissítve", "ToastAccountUpdateSuccess": "Fiók frissítve",
"ToastAppriseUrlRequired": "Meg kell adnia egy Apprise URL-címet",
"ToastAsinRequired": "ASIN kötelező",
"ToastAuthorImageRemoveSuccess": "Szerző képe eltávolítva", "ToastAuthorImageRemoveSuccess": "Szerző képe eltávolítva",
"ToastAuthorNotFound": "A szerző „{0}” nem található",
"ToastAuthorRemoveSuccess": "Szerző eltávolítva",
"ToastAuthorSearchNotFound": "Szerző nem található",
"ToastAuthorUpdateMerged": "Szerző összevonva", "ToastAuthorUpdateMerged": "Szerző összevonva",
"ToastAuthorUpdateSuccess": "Szerző frissítve", "ToastAuthorUpdateSuccess": "Szerző frissítve",
"ToastAuthorUpdateSuccessNoImageFound": "Szerző frissítve (nem található kép)", "ToastAuthorUpdateSuccessNoImageFound": "Szerző frissítve (nem található kép)",
"ToastBackupAppliedSuccess": "Biztonsági mentés alkalmazva",
"ToastBackupCreateFailed": "A biztonsági mentés létrehozása sikertelen", "ToastBackupCreateFailed": "A biztonsági mentés létrehozása sikertelen",
"ToastBackupCreateSuccess": "Biztonsági mentés létrehozva", "ToastBackupCreateSuccess": "Biztonsági mentés létrehozva",
"ToastBackupDeleteFailed": "A biztonsági mentés törlése sikertelen", "ToastBackupDeleteFailed": "A biztonsági mentés törlése sikertelen",
"ToastBackupDeleteSuccess": "Biztonsági mentés törölve", "ToastBackupDeleteSuccess": "Biztonsági mentés törölve",
"ToastBackupInvalidMaxKeep": "A megőrzendő biztonsági másolatok száma érvénytelen",
"ToastBackupInvalidMaxSize": "Érvénytelen maximális mentésméret",
"ToastBackupRestoreFailed": "A biztonsági mentés visszaállítása sikertelen", "ToastBackupRestoreFailed": "A biztonsági mentés visszaállítása sikertelen",
"ToastBackupUploadFailed": "A biztonsági mentés feltöltése sikertelen", "ToastBackupUploadFailed": "A biztonsági mentés feltöltése sikertelen",
"ToastBackupUploadSuccess": "Biztonsági mentés feltöltve", "ToastBackupUploadSuccess": "Biztonsági mentés feltöltve",
"ToastBatchDeleteFailed": "A tömeges törlés nem sikerült",
"ToastBatchDeleteSuccess": "Sikeres tömeges törlés",
"ToastBatchUpdateFailed": "Kötegelt frissítés sikertelen", "ToastBatchUpdateFailed": "Kötegelt frissítés sikertelen",
"ToastBatchUpdateSuccess": "Kötegelt frissítés sikeres", "ToastBatchUpdateSuccess": "Kötegelt frissítés sikeres",
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen", "ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
"ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva", "ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva",
"ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva", "ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva",
"ToastBookmarkUpdateSuccess": "Könyvjelző frissítve", "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", "ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük", "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", "ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből",
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva", "ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve", "ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
"ToastCoverUpdateFailed": "A borító frissítése nem sikerült",
"ToastDeleteFileFailed": "Nem sikerült törölni a fájlt",
"ToastDeleteFileSuccess": "Fájl törölve",
"ToastDeviceAddFailed": "Nem sikerült eszközt hozzáadni",
"ToastDeviceNameAlreadyExists": "Ilyen nevű olvasóeszköz már létezik",
"ToastDeviceTestEmailFailed": "Teszt email küldése sikertelen",
"ToastDeviceTestEmailSuccess": "Teszt email elküldve",
"ToastEmailSettingsUpdateSuccess": "Email beállítások frissítve",
"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",
"ToastFailedToLoadData": "Sikertelen adatbetöltés",
"ToastFailedToMatch": "Nem sikerült egyezőséget találni",
"ToastFailedToShare": "Nem sikerült megosztani",
"ToastFailedToUpdate": "Nem sikerült frissíteni",
"ToastInvalidImageUrl": "Érvénytelen a kép URL címe",
"ToastInvalidUrl": "Érvénytelen URL",
"ToastItemCoverUpdateSuccess": "Elem borítója frissítve", "ToastItemCoverUpdateSuccess": "Elem borítója frissítve",
"ToastItemDeletedFailed": "Nem sikerült törölni az elemet",
"ToastItemDeletedSuccess": "Elem törölve",
"ToastItemDetailsUpdateSuccess": "Elem részletei frissítve", "ToastItemDetailsUpdateSuccess": "Elem részletei frissítve",
"ToastItemMarkedAsFinishedFailed": "Megjelölés Befejezettként sikertelen", "ToastItemMarkedAsFinishedFailed": "Megjelölés Befejezettként sikertelen",
"ToastItemMarkedAsFinishedSuccess": "Elem megjelölve Befejezettként", "ToastItemMarkedAsFinishedSuccess": "Elem megjelölve Befejezettként",
"ToastItemMarkedAsNotFinishedFailed": "Az elem befejezetlennek jelölése sikertelen", "ToastItemMarkedAsNotFinishedFailed": "Az elem befejezetlennek jelölése sikertelen",
"ToastItemMarkedAsNotFinishedSuccess": "Elem megjelölve Nem Befejezettként", "ToastItemMarkedAsNotFinishedSuccess": "Elem megjelölve Nem Befejezettként",
"ToastItemUpdateSuccess": "Elem frissítve",
"ToastLibraryCreateFailed": "Könyvtár létrehozása sikertelen", "ToastLibraryCreateFailed": "Könyvtár létrehozása sikertelen",
"ToastLibraryCreateSuccess": "\"{0}\" könyvtár létrehozva", "ToastLibraryCreateSuccess": "\"{0}\" könyvtár létrehozva",
"ToastLibraryDeleteFailed": "Könyvtár törlése sikertelen", "ToastLibraryDeleteFailed": "Könyvtár törlése sikertelen",
@ -790,14 +981,34 @@
"ToastLibraryScanFailedToStart": "A beolvasás elindítása sikertelen", "ToastLibraryScanFailedToStart": "A beolvasás elindítása sikertelen",
"ToastLibraryScanStarted": "Könyvtár beolvasása elindítva", "ToastLibraryScanStarted": "Könyvtár beolvasása elindítva",
"ToastLibraryUpdateSuccess": "\"{0}\" könyvtár frissítve", "ToastLibraryUpdateSuccess": "\"{0}\" könyvtár frissítve",
"ToastMatchAllAuthorsFailed": "Nem sikerült az összes szerzőt azonosítani",
"ToastMetadataFilesRemovedError": "Hiba a metaadatok eltávolításakor.{0} fájl",
"ToastMetadataFilesRemovedNoneFound": "Nincsenek metaadatok.{0} fájl a könyvtárban",
"ToastMetadataFilesRemovedNoneRemoved": "Nincsenek metaadatok.{0} fájl eltávolítva",
"ToastMetadataFilesRemovedSuccess": "{0} metaadat.{1} fájl eltávolítva",
"ToastMustHaveAtLeastOnePath": "Legalább egy elérési útvonalnak kell lennie",
"ToastNameEmailRequired": "Név és e-mail cím megadása kötelező",
"ToastNameRequired": "A név megadása kötelező",
"ToastNewEpisodesFound": "{0} új epizód",
"ToastNewUserCreatedFailed": "Nem sikerült a fiókot létrehozni: „{0}”",
"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",
"ToastNewUserUsernameError": "Adjon meg egy felhasználónevet",
"ToastNoNewEpisodesFound": "Nincs új epizód",
"ToastNoUpdatesNecessary": "Nincs szükség frissítésre",
"ToastNotificationSettingsUpdateSuccess": "Értesítési beállítások frissítve",
"ToastNotificationUpdateSuccess": "Értesítés frissítve",
"ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen", "ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen",
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva", "ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
"ToastPlaylistRemoveSuccess": "Lejátszási lista eltávolítva", "ToastPlaylistRemoveSuccess": "Lejátszási lista eltávolítva",
"ToastPlaylistUpdateSuccess": "Lejátszási lista frissítve", "ToastPlaylistUpdateSuccess": "Lejátszási lista frissítve",
"ToastPodcastCreateFailed": "Podcast létrehozása sikertelen", "ToastPodcastCreateFailed": "Podcast létrehozása sikertelen",
"ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva", "ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva",
"ToastPodcastNoEpisodesInFeed": "Nincsenek epizódok az RSS hírcsatornában",
"ToastPodcastNoRssFeed": "A podcastnak nincs RSS-hírcsatornája",
"ToastRSSFeedCloseFailed": "Az RSS hírcsatorna bezárása sikertelen", "ToastRSSFeedCloseFailed": "Az RSS hírcsatorna bezárása sikertelen",
"ToastRSSFeedCloseSuccess": "RSS feed bezárva", "ToastRSSFeedCloseSuccess": "RSS hírfolyam leállítva",
"ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen", "ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen",
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből", "ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen", "ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
@ -809,6 +1020,9 @@
"ToastSocketConnected": "Socket csatlakoztatva", "ToastSocketConnected": "Socket csatlakoztatva",
"ToastSocketDisconnected": "Socket lecsatlakoztatva", "ToastSocketDisconnected": "Socket lecsatlakoztatva",
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen", "ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
"ToastUnknownError": "Ismeretlen hiba",
"ToastUserDeleteFailed": "Felhasználó törlése sikertelen", "ToastUserDeleteFailed": "Felhasználó törlése sikertelen",
"ToastUserDeleteSuccess": "Felhasználó törölve" "ToastUserDeleteSuccess": "Felhasználó törölve",
"ToastUserPasswordChangeSuccess": "Jelszó sikeresen megváltoztatva",
"ToastUserRootRequireName": "Egy root felhasználónevet kell megadnia"
} }

View File

@ -66,13 +66,13 @@
"ButtonPurgeItemsCache": "Elimina la Cache selezionata", "ButtonPurgeItemsCache": "Elimina la Cache selezionata",
"ButtonQueueAddItem": "Aggiungi alla Coda", "ButtonQueueAddItem": "Aggiungi alla Coda",
"ButtonQueueRemoveItem": "Rimuovi dalla Coda", "ButtonQueueRemoveItem": "Rimuovi dalla Coda",
"ButtonQuickEmbed": "Quick Embed", "ButtonQuickEmbed": "Incorporazione Rapida",
"ButtonQuickEmbedMetadata": "Incorporamento rapido Metadati", "ButtonQuickEmbedMetadata": "Incorporamento rapido Metadati",
"ButtonQuickMatch": "Controlla Metadata Auto", "ButtonQuickMatch": "Controlla Metadata Auto",
"ButtonReScan": "Ri-scansiona", "ButtonReScan": "Ri-scansiona",
"ButtonRead": "Leggi", "ButtonRead": "Leggi",
"ButtonReadLess": "Leggi di Meno", "ButtonReadLess": "Riduci",
"ButtonReadMore": "Leggi di Più", "ButtonReadMore": "Espandi",
"ButtonRefresh": "Aggiorna", "ButtonRefresh": "Aggiorna",
"ButtonRemove": "Rimuovi", "ButtonRemove": "Rimuovi",
"ButtonRemoveAll": "Rimuovi Tutto", "ButtonRemoveAll": "Rimuovi Tutto",
@ -220,7 +220,7 @@
"LabelAddToPlaylist": "Aggiungi alla playlist", "LabelAddToPlaylist": "Aggiungi alla playlist",
"LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist", "LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
"LabelAddedAt": "Aggiunto il", "LabelAddedAt": "Aggiunto il",
"LabelAddedDate": "{0} aggiunti", "LabelAddedDate": "Aggiunti {0}",
"LabelAdminUsersOnly": "Solo utenti Amministratori", "LabelAdminUsersOnly": "Solo utenti Amministratori",
"LabelAll": "Tutti", "LabelAll": "Tutti",
"LabelAllUsers": "Tutti gli Utenti", "LabelAllUsers": "Tutti gli Utenti",
@ -495,7 +495,7 @@
"LabelProviderAuthorizationValue": "Authorization Header Value", "LabelProviderAuthorizationValue": "Authorization Header Value",
"LabelPubDate": "Data di pubblicazione", "LabelPubDate": "Data di pubblicazione",
"LabelPublishYear": "Anno di pubblicazione", "LabelPublishYear": "Anno di pubblicazione",
"LabelPublishedDate": "{0} pubblicati", "LabelPublishedDate": "Pubblicati {0}",
"LabelPublishedDecade": "Decennio di pubblicazione", "LabelPublishedDecade": "Decennio di pubblicazione",
"LabelPublishedDecades": "Decenni di pubblicazione", "LabelPublishedDecades": "Decenni di pubblicazione",
"LabelPublisher": "Editore", "LabelPublisher": "Editore",
@ -682,7 +682,7 @@
"LabelXBooks": "{0} libri", "LabelXBooks": "{0} libri",
"LabelXItems": "{0} oggetti", "LabelXItems": "{0} oggetti",
"LabelYearReviewHide": "Nascondi Anno in rassegna", "LabelYearReviewHide": "Nascondi Anno in rassegna",
"LabelYearReviewShow": "Vedi Anno in rassegna", "LabelYearReviewShow": "Mostra Anno in rassegna",
"LabelYourAudiobookDuration": "La durata dell'audiolibro", "LabelYourAudiobookDuration": "La durata dell'audiolibro",
"LabelYourBookmarks": "I tuoi preferiti", "LabelYourBookmarks": "I tuoi preferiti",
"LabelYourPlaylists": "le tue Playlist", "LabelYourPlaylists": "le tue Playlist",
@ -779,7 +779,7 @@
"MessageNoBackups": "Nessun Backup", "MessageNoBackups": "Nessun Backup",
"MessageNoBookmarks": "Nessun preferito", "MessageNoBookmarks": "Nessun preferito",
"MessageNoChapters": "Nessun capitolo", "MessageNoChapters": "Nessun capitolo",
"MessageNoCollections": "Nessuna Raccolta", "MessageNoCollections": "Nessuna Collezione",
"MessageNoCoversFound": "Nessuna Cover Trovata", "MessageNoCoversFound": "Nessuna Cover Trovata",
"MessageNoDescription": "Nessuna descrizione", "MessageNoDescription": "Nessuna descrizione",
"MessageNoDevices": "nessun dispositivo", "MessageNoDevices": "nessun dispositivo",

View File

@ -4,6 +4,7 @@
"ButtonAddDevice": "Legg til enhet", "ButtonAddDevice": "Legg til enhet",
"ButtonAddLibrary": "Legg til bibliotek", "ButtonAddLibrary": "Legg til bibliotek",
"ButtonAddPodcasts": "Legg til podcast", "ButtonAddPodcasts": "Legg til podcast",
"ButtonAddUser": "Legg til bruker",
"ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek", "ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek",
"ButtonApply": "Bruk", "ButtonApply": "Bruk",
"ButtonApplyChapters": "Bruk kapittel", "ButtonApplyChapters": "Bruk kapittel",
@ -18,6 +19,7 @@
"ButtonChooseFiles": "Velg filer", "ButtonChooseFiles": "Velg filer",
"ButtonClearFilter": "Bytt filter", "ButtonClearFilter": "Bytt filter",
"ButtonCloseFeed": "Lukk Feed", "ButtonCloseFeed": "Lukk Feed",
"ButtonCloseSession": "Lukk åpen økt",
"ButtonCollections": "Samlinger", "ButtonCollections": "Samlinger",
"ButtonConfigureScanner": "Konfigurer skanner", "ButtonConfigureScanner": "Konfigurer skanner",
"ButtonCreate": "Opprett", "ButtonCreate": "Opprett",

View File

@ -258,12 +258,15 @@
"LabelDiscFromFilename": "Disco a partir do nome do arquivo", "LabelDiscFromFilename": "Disco a partir do nome do arquivo",
"LabelDiscFromMetadata": "Disco a partir dos metadados", "LabelDiscFromMetadata": "Disco a partir dos metadados",
"LabelDiscover": "Descobrir", "LabelDiscover": "Descobrir",
"LabelDownload": "Download",
"LabelDownloadNEpisodes": "Download de {0} Episódios", "LabelDownloadNEpisodes": "Download de {0} Episódios",
"LabelDuration": "Duração", "LabelDuration": "Duração",
"LabelDurationComparisonExactMatch": "(exato)", "LabelDurationComparisonExactMatch": "(exato)",
"LabelDurationComparisonLonger": "({0} maior)", "LabelDurationComparisonLonger": "({0} maior)",
"LabelDurationComparisonShorter": "({0} menor)", "LabelDurationComparisonShorter": "({0} menor)",
"LabelDurationFound": "Duração comprovada:", "LabelDurationFound": "Duração comprovada:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Editar", "LabelEdit": "Editar",
"LabelEmailSettingsFromAddress": "Remetente", "LabelEmailSettingsFromAddress": "Remetente",
"LabelEmailSettingsRejectUnauthorized": "Rejeitar certificados não autorizados", "LabelEmailSettingsRejectUnauthorized": "Rejeitar certificados não autorizados",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Сохранить список треков", "ButtonSaveTracklist": "Сохранить список треков",
"ButtonScan": "Сканировать", "ButtonScan": "Сканировать",
"ButtonScanLibrary": "Сканировать библиотеку", "ButtonScanLibrary": "Сканировать библиотеку",
"ButtonScrollLeft": "Перемотать влево",
"ButtonScrollRight": "Перемотать вправо",
"ButtonSearch": "Поиск", "ButtonSearch": "Поиск",
"ButtonSelectFolderPath": "Выберите путь папки", "ButtonSelectFolderPath": "Выберите путь папки",
"ButtonSeries": "Серии", "ButtonSeries": "Серии",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Экспериментальные функции", "HeaderSettingsExperimental": "Экспериментальные функции",
"HeaderSettingsGeneral": "Основные", "HeaderSettingsGeneral": "Основные",
"HeaderSettingsScanner": "Сканер", "HeaderSettingsScanner": "Сканер",
"HeaderSettingsWebClient": "Веб-клиент",
"HeaderSleepTimer": "Таймер сна", "HeaderSleepTimer": "Таймер сна",
"HeaderStatsLargestItems": "Самые большые элементы", "HeaderStatsLargestItems": "Самые большые элементы",
"HeaderStatsLongestItems": "Самые длинные элементы (часов)", "HeaderStatsLongestItems": "Самые длинные элементы (часов)",
@ -542,6 +545,7 @@
"LabelServerYearReview": "Итоги года всего сервера ({0})", "LabelServerYearReview": "Итоги года всего сервера ({0})",
"LabelSetEbookAsPrimary": "Установить как основную", "LabelSetEbookAsPrimary": "Установить как основную",
"LabelSetEbookAsSupplementary": "Установить как дополнительную", "LabelSetEbookAsSupplementary": "Установить как дополнительную",
"LabelSettingsAllowIframe": "Разрешить встраивание в iframe",
"LabelSettingsAudiobooksOnly": "Только аудиокниги", "LabelSettingsAudiobooksOnly": "Только аудиокниги",
"LabelSettingsAudiobooksOnlyHelp": "Если включить эту настройку, файлы электронных книг будут игнорироваться, за исключением случаев, когда они находятся в папке с аудиокнигами, в этом случае они будут рассматриваться как дополнительные электронные книги", "LabelSettingsAudiobooksOnlyHelp": "Если включить эту настройку, файлы электронных книг будут игнорироваться, за исключением случаев, когда они находятся в папке с аудиокнигами, в этом случае они будут рассматриваться как дополнительные электронные книги",
"LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками", "LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками",
@ -592,6 +596,8 @@
"LabelSize": "Размер", "LabelSize": "Размер",
"LabelSleepTimer": "Таймер сна", "LabelSleepTimer": "Таймер сна",
"LabelSlug": "Слизень", "LabelSlug": "Слизень",
"LabelSortAscending": "По возрастанию",
"LabelSortDescending": "По убыванию",
"LabelStart": "Начало", "LabelStart": "Начало",
"LabelStartTime": "Время начала", "LabelStartTime": "Время начала",
"LabelStarted": "Начат", "LabelStarted": "Начат",
@ -663,6 +669,7 @@
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены", "LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
"LabelUpdatedAt": "Обновлено в", "LabelUpdatedAt": "Обновлено в",
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги", "LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
"LabelUploaderDragAndDropFilesOnly": "Перетаскивание файлов",
"LabelUploaderDropFiles": "Перетащите файлы", "LabelUploaderDropFiles": "Перетащите файлы",
"LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии", "LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии",
"LabelUseAdvancedOptions": "Используйте расширенные опции", "LabelUseAdvancedOptions": "Используйте расширенные опции",
@ -678,6 +685,8 @@
"LabelViewPlayerSettings": "Просмотр настроек плеера", "LabelViewPlayerSettings": "Просмотр настроек плеера",
"LabelViewQueue": "Очередь воспроизведения", "LabelViewQueue": "Очередь воспроизведения",
"LabelVolume": "Громкость", "LabelVolume": "Громкость",
"LabelWebRedirectURLsDescription": "Авторизуйте эти URL в провайдере OAuth, чтобы разрешить перенаправление обратно в веб-приложение после входа:",
"LabelWebRedirectURLsSubfolder": "Вложенная папка для URL-адресов перенаправления",
"LabelWeekdaysToRun": "Дни недели для запуска", "LabelWeekdaysToRun": "Дни недели для запуска",
"LabelXBooks": "{0} книг", "LabelXBooks": "{0} книг",
"LabelXItems": "{0} элементов", "LabelXItems": "{0} элементов",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Shrani seznam skladb", "ButtonSaveTracklist": "Shrani seznam skladb",
"ButtonScan": "Pregledovanje", "ButtonScan": "Pregledovanje",
"ButtonScanLibrary": "Preglej knjižnico", "ButtonScanLibrary": "Preglej knjižnico",
"ButtonScrollLeft": "Premik levo",
"ButtonScrollRight": "Premik desno",
"ButtonSearch": "Poišči", "ButtonSearch": "Poišči",
"ButtonSelectFolderPath": "Izberite pot do mape", "ButtonSelectFolderPath": "Izberite pot do mape",
"ButtonSeries": "Serije", "ButtonSeries": "Serije",
@ -184,12 +186,13 @@
"HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod", "HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod",
"HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice", "HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice",
"HeaderSession": "Seja", "HeaderSession": "Seja",
"HeaderSetBackupSchedule": "Nastavite urnik varnostnega kopiranja", "HeaderSetBackupSchedule": "Nastavi urnik varnostnega kopiranja",
"HeaderSettings": "Nastavitve", "HeaderSettings": "Nastavitve",
"HeaderSettingsDisplay": "Zaslon", "HeaderSettingsDisplay": "Zaslon",
"HeaderSettingsExperimental": "Eksperimentalne funkcije", "HeaderSettingsExperimental": "Eksperimentalne funkcije",
"HeaderSettingsGeneral": "Splošno", "HeaderSettingsGeneral": "Splošno",
"HeaderSettingsScanner": "Pregledovalnik", "HeaderSettingsScanner": "Pregledovalnik",
"HeaderSettingsWebClient": "Spletni odjemalec",
"HeaderSleepTimer": "Časovnik za izklop", "HeaderSleepTimer": "Časovnik za izklop",
"HeaderStatsLargestItems": "Največji elementi", "HeaderStatsLargestItems": "Največji elementi",
"HeaderStatsLongestItems": "Najdaljši elementi (ure)", "HeaderStatsLongestItems": "Najdaljši elementi (ure)",
@ -495,7 +498,7 @@
"LabelProviderAuthorizationValue": "Vrednost glave avtorizacije", "LabelProviderAuthorizationValue": "Vrednost glave avtorizacije",
"LabelPubDate": "Datum objave", "LabelPubDate": "Datum objave",
"LabelPublishYear": "Leto izdaje", "LabelPublishYear": "Leto izdaje",
"LabelPublishedDate": "Izdano {0}", "LabelPublishedDate": "Objavljeno {0}",
"LabelPublishedDecade": "Desetletje izdaje", "LabelPublishedDecade": "Desetletje izdaje",
"LabelPublishedDecades": "Desetletja izdaje", "LabelPublishedDecades": "Desetletja izdaje",
"LabelPublisher": "Izdajatelj", "LabelPublisher": "Izdajatelj",
@ -542,6 +545,7 @@
"LabelServerYearReview": "Pregled leta strežnika ({0})", "LabelServerYearReview": "Pregled leta strežnika ({0})",
"LabelSetEbookAsPrimary": "Nastavi kot primarno", "LabelSetEbookAsPrimary": "Nastavi kot primarno",
"LabelSetEbookAsSupplementary": "Nastavi kot dodatno", "LabelSetEbookAsSupplementary": "Nastavi kot dodatno",
"LabelSettingsAllowIframe": "Dovoli vdelavo v iframu",
"LabelSettingsAudiobooksOnly": "Samo zvočne knjige", "LabelSettingsAudiobooksOnly": "Samo zvočne knjige",
"LabelSettingsAudiobooksOnlyHelp": "Če omogočite to nastavitev, bodo datoteke eknjig prezrte, razen če so znotraj mape zvočnih knjig, v tem primeru bodo nastavljene kot dodatne e-knjige", "LabelSettingsAudiobooksOnlyHelp": "Če omogočite to nastavitev, bodo datoteke eknjig prezrte, razen če so znotraj mape zvočnih knjig, v tem primeru bodo nastavljene kot dodatne e-knjige",
"LabelSettingsBookshelfViewHelp": "Skeuomorfna oblika z lesenimi policami", "LabelSettingsBookshelfViewHelp": "Skeuomorfna oblika z lesenimi policami",
@ -592,6 +596,8 @@
"LabelSize": "Velikost", "LabelSize": "Velikost",
"LabelSleepTimer": "Časovnik za spanje", "LabelSleepTimer": "Časovnik za spanje",
"LabelSlug": "Slug", "LabelSlug": "Slug",
"LabelSortAscending": "Naraščajoče",
"LabelSortDescending": "Padajoče",
"LabelStart": "Začetek", "LabelStart": "Začetek",
"LabelStartTime": "Čas začetka", "LabelStartTime": "Čas začetka",
"LabelStarted": "Začeto", "LabelStarted": "Začeto",
@ -663,6 +669,7 @@
"LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje", "LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje",
"LabelUpdatedAt": "Posodobljeno ob", "LabelUpdatedAt": "Posodobljeno ob",
"LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape", "LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape",
"LabelUploaderDragAndDropFilesOnly": "Povleci in spusti datoteke",
"LabelUploaderDropFiles": "Spusti datoteke", "LabelUploaderDropFiles": "Spusti datoteke",
"LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo", "LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo",
"LabelUseAdvancedOptions": "Uporabi napredne možnosti", "LabelUseAdvancedOptions": "Uporabi napredne možnosti",
@ -678,11 +685,13 @@
"LabelViewPlayerSettings": "Ogled nastavitev predvajalnika", "LabelViewPlayerSettings": "Ogled nastavitev predvajalnika",
"LabelViewQueue": "Ogled čakalno vrsto predvajalnika", "LabelViewQueue": "Ogled čakalno vrsto predvajalnika",
"LabelVolume": "Glasnost", "LabelVolume": "Glasnost",
"LabelWebRedirectURLsDescription": "Avtorizirajte URL-je pri svojem ponudniku OAuth ter s tem omogočite preusmeritev nazaj v spletno aplikacijo po prijavi:",
"LabelWebRedirectURLsSubfolder": "Podmapa za URL-je preusmeritve",
"LabelWeekdaysToRun": "Delovni dnevi predvajanja", "LabelWeekdaysToRun": "Delovni dnevi predvajanja",
"LabelXBooks": "{0} knjig", "LabelXBooks": "{0} knjig",
"LabelXItems": "{0} elementov", "LabelXItems": "{0} elementov",
"LabelYearReviewHide": "Skrij pregled leta", "LabelYearReviewHide": "Skrij pregled leta",
"LabelYearReviewShow": "Poglej pregled leta", "LabelYearReviewShow": "Poglej si pregled leta",
"LabelYourAudiobookDuration": "Trajanje tvojih zvočnih knjig", "LabelYourAudiobookDuration": "Trajanje tvojih zvočnih knjig",
"LabelYourBookmarks": "Tvoji zaznamki", "LabelYourBookmarks": "Tvoji zaznamki",
"LabelYourPlaylists": "Tvoje seznami predvajanj", "LabelYourPlaylists": "Tvoje seznami predvajanj",
@ -829,7 +838,7 @@
"MessageSearchResultsFor": "Rezultati iskanja za", "MessageSearchResultsFor": "Rezultati iskanja za",
"MessageSelected": "{0} izbrano", "MessageSelected": "{0} izbrano",
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči", "MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
"MessageSetChaptersFromTracksDescription": "Nastavite poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke", "MessageSetChaptersFromTracksDescription": "Nastavi poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke",
"MessageShareExpirationWillBe": "Potečeno bo <strong>{0}</strong>", "MessageShareExpirationWillBe": "Potečeno bo <strong>{0}</strong>",
"MessageShareExpiresIn": "Poteče čez {0}", "MessageShareExpiresIn": "Poteče čez {0}",
"MessageShareURLWillBe": "URL za skupno rabo bo <strong>{0}</strong>", "MessageShareURLWillBe": "URL za skupno rabo bo <strong>{0}</strong>",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Зберегти порядок", "ButtonSaveTracklist": "Зберегти порядок",
"ButtonScan": "Сканувати", "ButtonScan": "Сканувати",
"ButtonScanLibrary": "Сканувати бібліотеку", "ButtonScanLibrary": "Сканувати бібліотеку",
"ButtonScrollLeft": "Прокрутити ліворуч",
"ButtonScrollRight": "Прокрутити праворуч",
"ButtonSearch": "Пошук", "ButtonSearch": "Пошук",
"ButtonSelectFolderPath": "Обрати шлях до теки", "ButtonSelectFolderPath": "Обрати шлях до теки",
"ButtonSeries": "Серії", "ButtonSeries": "Серії",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Експериментальні функції", "HeaderSettingsExperimental": "Експериментальні функції",
"HeaderSettingsGeneral": "Основне", "HeaderSettingsGeneral": "Основне",
"HeaderSettingsScanner": "Сканер", "HeaderSettingsScanner": "Сканер",
"HeaderSettingsWebClient": "Вебклієнт",
"HeaderSleepTimer": "Таймер вимкнення", "HeaderSleepTimer": "Таймер вимкнення",
"HeaderStatsLargestItems": "Найбільші елементи", "HeaderStatsLargestItems": "Найбільші елементи",
"HeaderStatsLongestItems": "Найдовші елементи (год)", "HeaderStatsLongestItems": "Найдовші елементи (год)",
@ -542,6 +545,7 @@
"LabelServerYearReview": "Підсумки року сервера ({0})", "LabelServerYearReview": "Підсумки року сервера ({0})",
"LabelSetEbookAsPrimary": "Зробити основною", "LabelSetEbookAsPrimary": "Зробити основною",
"LabelSetEbookAsSupplementary": "Зробити додатковою", "LabelSetEbookAsSupplementary": "Зробити додатковою",
"LabelSettingsAllowIframe": "Дозволити вбудовування у iframe",
"LabelSettingsAudiobooksOnly": "Лише аудіокниги", "LabelSettingsAudiobooksOnly": "Лише аудіокниги",
"LabelSettingsAudiobooksOnlyHelp": "Увімкніть цей параметр, щоб ігнорувати файли електронних книг, якщо вони не знаходяться у теці аудіокниги, тоді вони будуть встановлені як додаткові електронні книги", "LabelSettingsAudiobooksOnlyHelp": "Увімкніть цей параметр, щоб ігнорувати файли електронних книг, якщо вони не знаходяться у теці аудіокниги, тоді вони будуть встановлені як додаткові електронні книги",
"LabelSettingsBookshelfViewHelp": "Імітує вигляд дерев'яних полиць", "LabelSettingsBookshelfViewHelp": "Імітує вигляд дерев'яних полиць",
@ -592,6 +596,8 @@
"LabelSize": "Розмір", "LabelSize": "Розмір",
"LabelSleepTimer": "Таймер вимкнення", "LabelSleepTimer": "Таймер вимкнення",
"LabelSlug": "Назва", "LabelSlug": "Назва",
"LabelSortAscending": "По зростанню",
"LabelSortDescending": "По спаданню",
"LabelStart": "Початок", "LabelStart": "Початок",
"LabelStartTime": "Час початку", "LabelStartTime": "Час початку",
"LabelStarted": "Почато", "LabelStarted": "Почато",
@ -663,6 +669,7 @@
"LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення", "LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення",
"LabelUpdatedAt": "Оновлення", "LabelUpdatedAt": "Оновлення",
"LabelUploaderDragAndDrop": "Перетягніть файли або теки", "LabelUploaderDragAndDrop": "Перетягніть файли або теки",
"LabelUploaderDragAndDropFilesOnly": "Перетягніть і скиньте файли",
"LabelUploaderDropFiles": "Перетягніть файли", "LabelUploaderDropFiles": "Перетягніть файли",
"LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію", "LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію",
"LabelUseAdvancedOptions": "Використовувати розширені налаштування", "LabelUseAdvancedOptions": "Використовувати розширені налаштування",
@ -678,6 +685,8 @@
"LabelViewPlayerSettings": "Переглянути налаштування програвача", "LabelViewPlayerSettings": "Переглянути налаштування програвача",
"LabelViewQueue": "Переглянути чергу відтворення", "LabelViewQueue": "Переглянути чергу відтворення",
"LabelVolume": "Гучність", "LabelVolume": "Гучність",
"LabelWebRedirectURLsDescription": "Авторизуйте ці URL у вашому OAuth постачальнику, щоб дозволити редирекцію назад до веб-додатку після входу:",
"LabelWebRedirectURLsSubfolder": "Підпапка для Redirect URL",
"LabelWeekdaysToRun": "Виконувати у дні", "LabelWeekdaysToRun": "Виконувати у дні",
"LabelXBooks": "{0} книг", "LabelXBooks": "{0} книг",
"LabelXItems": "{0} елементів", "LabelXItems": "{0} елементів",
@ -813,7 +822,7 @@
"MessagePlaylistCreateFromCollection": "Створити список відтворення з добірки", "MessagePlaylistCreateFromCollection": "Створити список відтворення з добірки",
"MessagePleaseWait": "Будь ласка, зачекайте...", "MessagePleaseWait": "Будь ласка, зачекайте...",
"MessagePodcastHasNoRSSFeedForMatching": "Подкаст не має RSS-каналу для пошуку", "MessagePodcastHasNoRSSFeedForMatching": "Подкаст не має RSS-каналу для пошуку",
"MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS фіду", "MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS-стрічки",
"MessageQuickEmbedInProgress": "Швидке вбудовування в процесі", "MessageQuickEmbedInProgress": "Швидке вбудовування в процесі",
"MessageQuickEmbedQueue": "В черзі на швидке вбудовування ({0} в черзі)", "MessageQuickEmbedQueue": "В черзі на швидке вбудовування ({0} в черзі)",
"MessageQuickMatchAllEpisodes": "Швидке співставлення всіх епізодів", "MessageQuickMatchAllEpisodes": "Швидке співставлення всіх епізодів",
@ -878,7 +887,7 @@
"MessageXLibraryIsEmpty": "Бібліотека {0} порожня!", "MessageXLibraryIsEmpty": "Бібліотека {0} порожня!",
"MessageYourAudiobookDurationIsLonger": "Тривалість вашої аудіокниги довша за віднайдену", "MessageYourAudiobookDurationIsLonger": "Тривалість вашої аудіокниги довша за віднайдену",
"MessageYourAudiobookDurationIsShorter": "Тривалість вашої аудіокниги коротша за віднайдену", "MessageYourAudiobookDurationIsShorter": "Тривалість вашої аудіокниги коротша за віднайдену",
"NoteChangeRootPassword": "Кореневий користувач — єдиний, хто може мати порожній пароль", "NoteChangeRootPassword": "Тільки користувач root — єдиний, хто може мати порожній пароль",
"NoteChapterEditorTimes": "Примітка: Перша глава мусить починатися з 0:00, а час початку останньої глави не може бути більшим за зазначену тривалість аудіокниги.", "NoteChapterEditorTimes": "Примітка: Перша глава мусить починатися з 0:00, а час початку останньої глави не може бути більшим за зазначену тривалість аудіокниги.",
"NoteFolderPicker": "Примітка: вже обрані теки не буде показано", "NoteFolderPicker": "Примітка: вже обрані теки не буде показано",
"NoteRSSFeedPodcastAppsHttps": "Попередження: Більшість додатків подкастів вимагатимуть використання протоколу HTTPS від RSS-каналу", "NoteRSSFeedPodcastAppsHttps": "Попередження: Більшість додатків подкастів вимагатимуть використання протоколу HTTPS від RSS-каналу",
@ -928,7 +937,7 @@
"ToastBackupCreateSuccess": "Резервну копію створено", "ToastBackupCreateSuccess": "Резервну копію створено",
"ToastBackupDeleteFailed": "Не вдалося видалити резервну копію", "ToastBackupDeleteFailed": "Не вдалося видалити резервну копію",
"ToastBackupDeleteSuccess": "Резервну копію видалено", "ToastBackupDeleteSuccess": "Резервну копію видалено",
"ToastBackupInvalidMaxKeep": "Невірна кількість резервних копій для зберігання", "ToastBackupInvalidMaxKeep": "Профіль оновленоПрофіль оновлено",
"ToastBackupInvalidMaxSize": "Невірний максимальний розмір резервної копії", "ToastBackupInvalidMaxSize": "Невірний максимальний розмір резервної копії",
"ToastBackupRestoreFailed": "Не вдалося відновити резервну копію", "ToastBackupRestoreFailed": "Не вдалося відновити резервну копію",
"ToastBackupUploadFailed": "Не вдалося завантажити резервну копію", "ToastBackupUploadFailed": "Не вдалося завантажити резервну копію",

View File

@ -71,7 +71,7 @@
"ButtonQuickMatch": "快速匹配", "ButtonQuickMatch": "快速匹配",
"ButtonReScan": "重新扫描", "ButtonReScan": "重新扫描",
"ButtonRead": "读取", "ButtonRead": "读取",
"ButtonReadLess": "阅读少", "ButtonReadLess": "阅读少",
"ButtonReadMore": "阅读更多", "ButtonReadMore": "阅读更多",
"ButtonRefresh": "刷新", "ButtonRefresh": "刷新",
"ButtonRemove": "移除", "ButtonRemove": "移除",
@ -220,7 +220,7 @@
"LabelAddToPlaylist": "添加到播放列表", "LabelAddToPlaylist": "添加到播放列表",
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
"LabelAddedAt": "添加于", "LabelAddedAt": "添加于",
"LabelAddedDate": "添加 {0}", "LabelAddedDate": "添加 {0}",
"LabelAdminUsersOnly": "仅限管理员用户", "LabelAdminUsersOnly": "仅限管理员用户",
"LabelAll": "全部", "LabelAll": "全部",
"LabelAllUsers": "所有用户", "LabelAllUsers": "所有用户",
@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息", "LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
"LabelUpdatedAt": "更新时间", "LabelUpdatedAt": "更新时间",
"LabelUploaderDragAndDrop": "拖放文件或文件夹", "LabelUploaderDragAndDrop": "拖放文件或文件夹",
"LabelUploaderDragAndDropFilesOnly": "拖放文件",
"LabelUploaderDropFiles": "删除文件", "LabelUploaderDropFiles": "删除文件",
"LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列", "LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列",
"LabelUseAdvancedOptions": "使用高级选项", "LabelUseAdvancedOptions": "使用高级选项",
@ -678,6 +679,8 @@
"LabelViewPlayerSettings": "查看播放器设置", "LabelViewPlayerSettings": "查看播放器设置",
"LabelViewQueue": "查看播放列表", "LabelViewQueue": "查看播放列表",
"LabelVolume": "音量", "LabelVolume": "音量",
"LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:",
"LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹",
"LabelWeekdaysToRun": "工作日运行", "LabelWeekdaysToRun": "工作日运行",
"LabelXBooks": "{0} 本书", "LabelXBooks": "{0} 本书",
"LabelXItems": "{0} 项目", "LabelXItems": "{0} 项目",

View File

@ -11,6 +11,7 @@ if (isDev) {
if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath
if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1' 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.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
process.env.SOURCE = 'local' process.env.SOURCE = 'local'
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || '' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.16.2", "version": "2.17.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.16.2", "version": "2.17.5",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.16.2", "version": "2.17.5",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",

View File

@ -41,6 +41,13 @@ Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/
Join us on [Discord](https://discord.gg/HQgCbd6E75) Join us on [Discord](https://discord.gg/HQgCbd6E75)
### Demo
Check out the web client demo: https://audiobooks.dev/ (thanks for hosting [@Vito0912](https://github.com/Vito0912)!)
Username/password: `demo`/`demo` (user account)
### Android App (beta) ### Android App (beta)
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app) Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)

View File

@ -131,7 +131,7 @@ class Auth {
{ {
client: openIdClient, client: openIdClient,
params: { params: {
redirect_uri: '/auth/openid/callback', redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
scope: 'openid profile email' scope: 'openid profile email'
} }
}, },
@ -480,9 +480,9 @@ class Auth {
// for the request to mobile-redirect and as such the session is not shared // for the request to mobile-redirect and as such the session is not shared
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri }) this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString() redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
} else { } else {
redirectUri = new URL('/auth/openid/callback', hostUrl).toString() redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
if (req.query.state) { if (req.query.state) {
Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`) Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
@ -733,7 +733,7 @@ class Auth {
const host = req.get('host') const host = req.get('host')
// TODO: ABS does currently not support subfolders for installation // TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl // If we want to support it we need to include a config for the serverurl
postLogoutRedirectUri = `${protocol}://${host}/login` postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
} }
// else for openid-mobile we keep postLogoutRedirectUri on null // else for openid-mobile we keep postLogoutRedirectUri on null
// nice would be to redirect to the app here, but for example Authentik does not implement // nice would be to redirect to the app here, but for example Authentik does not implement

View File

@ -406,11 +406,6 @@ class Database {
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook))) return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
} }
removeLibrary(libraryId) {
if (!this.sequelize) return false
return this.models.library.removeById(libraryId)
}
createBulkCollectionBooks(collectionBooks) { createBulkCollectionBooks(collectionBooks) {
if (!this.sequelize) return false if (!this.sequelize) return false
return this.models.collectionBook.bulkCreate(collectionBooks) return this.models.collectionBook.bulkCreate(collectionBooks)
@ -449,21 +444,6 @@ class Database {
return updated return updated
} }
async createFeed(oldFeed) {
if (!this.sequelize) return false
await this.models.feed.fullCreateFromOld(oldFeed)
}
updateFeed(oldFeed) {
if (!this.sequelize) return false
return this.models.feed.fullUpdateFromOld(oldFeed)
}
async removeFeed(feedId) {
if (!this.sequelize) return false
await this.models.feed.removeById(feedId)
}
async createBulkBookAuthors(bookAuthors) { async createBulkBookAuthors(bookAuthors) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.bookAuthor.bulkCreate(bookAuthors) await this.models.bookAuthor.bulkCreate(bookAuthors)

View File

@ -53,7 +53,17 @@ class Server {
global.RouterBasePath = ROUTER_BASE_PATH global.RouterBasePath = ROUTER_BASE_PATH
global.XAccel = process.env.USE_X_ACCEL global.XAccel = process.env.USE_X_ACCEL
global.AllowCors = process.env.ALLOW_CORS === '1' global.AllowCors = process.env.ALLOW_CORS === '1'
global.DisableSsrfRequestFilter = 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) {
const whitelistedUrls = process.env.SSRF_REQUEST_FILTER_WHITELIST.split(',').map((url) => url.trim())
if (whitelistedUrls.length) {
Logger.info(`[Server] SSRF Request Filter Whitelisting: ${whitelistedUrls.join(',')}`)
global.DisableSsrfRequestFilter = (url) => whitelistedUrls.includes(new URL(url).hostname)
}
}
if (!fs.pathExistsSync(global.ConfigPath)) { if (!fs.pathExistsSync(global.ConfigPath)) {
fs.mkdirSync(global.ConfigPath) fs.mkdirSync(global.ConfigPath)
@ -71,7 +81,6 @@ class Server {
this.playbackSessionManager = new PlaybackSessionManager() this.playbackSessionManager = new PlaybackSessionManager()
this.podcastManager = new PodcastManager() this.podcastManager = new PodcastManager()
this.audioMetadataManager = new AudioMetadataMangaer() this.audioMetadataManager = new AudioMetadataMangaer()
this.rssFeedManager = new RssFeedManager()
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager) this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
this.apiCacheManager = new ApiCacheManager() this.apiCacheManager = new ApiCacheManager()
this.binaryManager = new BinaryManager() this.binaryManager = new BinaryManager()
@ -84,7 +93,6 @@ class Server {
Logger.logManager = new LogManager() Logger.logManager = new LogManager()
this.server = null this.server = null
this.io = null
} }
/** /**
@ -138,7 +146,7 @@ class Server {
await ShareManager.init() await ShareManager.init()
await this.backupManager.init() await this.backupManager.init()
await this.rssFeedManager.init() await RssFeedManager.init()
const libraries = await Database.libraryModel.getAllWithFolders() const libraries = await Database.libraryModel.getAllWithFolders()
await this.cronManager.init(libraries) await this.cronManager.init(libraries)
@ -194,18 +202,23 @@ class Server {
const app = express() const app = express()
/**
* @temporary
* This is necessary for the ebook & cover API endpoint in the mobile apps
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
* The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors
* @see https://ionicframework.com/docs/troubleshooting/cors
*
* Running in development allows cors to allow testing the mobile apps in the browser
* or env variable ALLOW_CORS = '1'
*/
app.use((req, res, next) => { app.use((req, res, next) => {
if (!global.ServerSettings.allowIframe) {
// Prevent clickjacking by disallowing iframes
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
}
/**
* @temporary
* This is necessary for the ebook & cover API endpoint in the mobile apps
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
* The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors
* @see https://ionicframework.com/docs/troubleshooting/cors
*
* Running in development allows cors to allow testing the mobile apps in the browser
* or env variable ALLOW_CORS = '1'
*/
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) { if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
const allowedOrigins = ['capacitor://localhost', 'http://localhost'] const allowedOrigins = ['capacitor://localhost', 'http://localhost']
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) { if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
@ -246,14 +259,17 @@ class Server {
const router = express.Router() const router = express.Router()
// if RouterBasePath is set, modify all requests to include the base path // if RouterBasePath is set, modify all requests to include the base path
if (global.RouterBasePath) { app.use((req, res, next) => {
app.use((req, res, next) => { const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
if (!req.url.startsWith(global.RouterBasePath)) { const host = req.get('host')
req.url = `${global.RouterBasePath}${req.url}` const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
} const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
next() req.originalHostPrefix = `${protocol}://${host}${prefix}`
}) if (!urlStartsWithRouterBasePath) {
} req.url = `${global.RouterBasePath}${req.url}`
}
next()
})
app.use(global.RouterBasePath, router) app.use(global.RouterBasePath, router)
app.disable('x-powered-by') app.disable('x-powered-by')
@ -284,14 +300,14 @@ class Server {
// RSS Feed temp route // RSS Feed temp route
router.get('/feed/:slug', (req, res) => { router.get('/feed/:slug', (req, res) => {
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`) Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
this.rssFeedManager.getFeed(req, res) RssFeedManager.getFeed(req, res)
}) })
router.get('/feed/:slug/cover*', (req, res) => { router.get('/feed/:slug/cover*', (req, res) => {
this.rssFeedManager.getFeedCover(req, res) RssFeedManager.getFeedCover(req, res)
}) })
router.get('/feed/:slug/item/:episodeId/*', (req, res) => { router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`) Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
this.rssFeedManager.getFeedItem(req, res) RssFeedManager.getFeedItem(req, res)
}) })
// Auth routes // Auth routes
@ -438,18 +454,11 @@ class Server {
async stop() { async stop() {
Logger.info('=== Stopping Server ===') Logger.info('=== Stopping Server ===')
Watcher.close() Watcher.close()
Logger.info('Watcher Closed') Logger.info('[Server] Watcher Closed')
await SocketAuthority.close()
return new Promise((resolve) => { Logger.info('[Server] Closing HTTP Server')
SocketAuthority.close((err) => { await new Promise((resolve) => this.server.close(resolve))
if (err) { Logger.info('[Server] HTTP Server Closed')
Logger.error('Failed to close server', err)
} else {
Logger.info('Server successfully closed')
}
resolve()
})
})
} }
} }
module.exports = Server module.exports = Server

View File

@ -14,7 +14,7 @@ const Auth = require('./Auth')
class SocketAuthority { class SocketAuthority {
constructor() { constructor() {
this.Server = null this.Server = null
this.io = null this.socketIoServers = []
/** @type {Object.<string, SocketClient>} */ /** @type {Object.<string, SocketClient>} */
this.clients = {} this.clients = {}
@ -89,82 +89,104 @@ class SocketAuthority {
* *
* @param {Function} callback * @param {Function} callback
*/ */
close(callback) { async close() {
Logger.info('[SocketAuthority] Shutting down') Logger.info('[SocketAuthority] closing...')
// This will close all open socket connections, and also close the underlying http server const closePromises = this.socketIoServers.map((io) => {
if (this.io) this.io.close(callback) return new Promise((resolve) => {
else callback() Logger.info(`[SocketAuthority] Closing Socket.IO server: ${io.path}`)
io.close(() => {
Logger.info(`[SocketAuthority] Socket.IO server closed: ${io.path}`)
resolve()
})
})
})
await Promise.all(closePromises)
Logger.info('[SocketAuthority] closed')
this.socketIoServers = []
} }
initialize(Server) { initialize(Server) {
this.Server = Server this.Server = Server
this.io = new SocketIO.Server(this.Server.server, { const socketIoOptions = {
cors: { cors: {
origin: '*', origin: '*',
methods: ['GET', 'POST'] methods: ['GET', 'POST']
},
path: `${global.RouterBasePath}/socket.io`
})
this.io.on('connection', (socket) => {
this.clients[socket.id] = {
id: socket.id,
socket,
connected_at: Date.now()
} }
socket.sheepClient = this.clients[socket.id] }
Logger.info('[SocketAuthority] Socket Connected', socket.id) const ioServer = new SocketIO.Server(Server.server, socketIoOptions)
ioServer.path = '/socket.io'
this.socketIoServers.push(ioServer)
// Required for associating a User with a socket if (global.RouterBasePath) {
socket.on('auth', (token) => this.authenticateSocket(socket, token)) // open a separate socket.io server for the router base path, keeping the original server open for legacy clients
const ioBasePath = `${global.RouterBasePath}/socket.io`
const ioBasePathServer = new SocketIO.Server(Server.server, { ...socketIoOptions, path: ioBasePath })
ioBasePathServer.path = ioBasePath
this.socketIoServers.push(ioBasePathServer)
}
// Scanning this.socketIoServers.forEach((io) => {
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId)) io.on('connection', (socket) => {
this.clients[socket.id] = {
// Logs id: socket.id,
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) socket,
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id)) connected_at: Date.now()
// Sent automatically from socket.io clients
socket.on('disconnect', (reason) => {
Logger.removeSocketListener(socket.id)
const _client = this.clients[socket.id]
if (!_client) {
Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
} else if (!_client.user) {
Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
delete this.clients[socket.id]
} else {
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
delete this.clients[socket.id]
} }
}) socket.sheepClient = this.clients[socket.id]
// Logger.info(`[SocketAuthority] Socket Connected to ${io.path}`, socket.id)
// Events for testing
// // Required for associating a User with a socket
socket.on('message_all_users', (payload) => { socket.on('auth', (token) => this.authenticateSocket(socket, token))
// admin user can send a message to all authenticated users
// displays on the web app as a toast // Scanning
const client = this.clients[socket.id] || {} socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
if (client.user?.isAdminOrUp) {
this.emitter('admin_message', payload.message || '') // Logs
} else { socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`) socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
}
}) // Sent automatically from socket.io clients
socket.on('ping', () => { socket.on('disconnect', (reason) => {
const client = this.clients[socket.id] || {} Logger.removeSocketListener(socket.id)
const user = client.user || {}
Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`) const _client = this.clients[socket.id]
socket.emit('pong') if (!_client) {
Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
} else if (!_client.user) {
Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
delete this.clients[socket.id]
} else {
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
delete this.clients[socket.id]
}
})
//
// Events for testing
//
socket.on('message_all_users', (payload) => {
// admin user can send a message to all authenticated users
// displays on the web app as a toast
const client = this.clients[socket.id] || {}
if (client.user?.isAdminOrUp) {
this.emitter('admin_message', payload.message || '')
} else {
Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
}
})
socket.on('ping', () => {
const client = this.clients[socket.id] || {}
const user = client.user || {}
Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)
socket.emit('pong')
})
}) })
}) })
} }

View File

@ -190,7 +190,9 @@ class FolderWatcher extends EventEmitter {
return return
} }
Logger.debug('[Watcher] File Added', path) Logger.debug('[Watcher] File Added', path)
this.addFileUpdate(libraryId, path, 'added') if (!this.addFileUpdate(libraryId, path, 'added')) {
return
}
if (!this.filesBeingAdded.has(path)) { if (!this.filesBeingAdded.has(path)) {
this.filesBeingAdded.add(path) this.filesBeingAdded.add(path)
@ -261,22 +263,23 @@ class FolderWatcher extends EventEmitter {
* @param {string} libraryId * @param {string} libraryId
* @param {string} path * @param {string} path
* @param {string} type * @param {string} type
* @returns {boolean} - If file was added to pending updates
*/ */
addFileUpdate(libraryId, path, type) { addFileUpdate(libraryId, path, type) {
if (this.pendingFilePaths.includes(path)) return if (this.pendingFilePaths.includes(path)) return false
// Get file library // Get file library
const libwatcher = this.libraryWatchers.find((lw) => lw.id === libraryId) const libwatcher = this.libraryWatchers.find((lw) => lw.id === libraryId)
if (!libwatcher) { if (!libwatcher) {
Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`) Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`)
return return false
} }
// Get file folder // Get file folder
const folder = libwatcher.libraryFolders.find((fold) => isSameOrSubPath(fold.path, path)) const folder = libwatcher.libraryFolders.find((fold) => isSameOrSubPath(fold.path, path))
if (!folder) { if (!folder) {
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`) Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
return return false
} }
const folderPath = filePathToPOSIX(folder.path) const folderPath = filePathToPOSIX(folder.path)
@ -285,14 +288,14 @@ class FolderWatcher extends EventEmitter {
if (Path.extname(relPath).toLowerCase() === '.part') { if (Path.extname(relPath).toLowerCase() === '.part') {
Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`) Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`)
return return false
} }
// Ignore files/folders starting with "." // Ignore files/folders starting with "."
const hasDotPath = relPath.split('/').find((p) => p.startsWith('.')) const hasDotPath = relPath.split('/').find((p) => p.startsWith('.'))
if (hasDotPath) { if (hasDotPath) {
Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`) Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
return return false
} }
Logger.debug(`[Watcher] Modified file in library "${libwatcher.name}" and folder "${folder.id}" with relPath "${relPath}"`) Logger.debug(`[Watcher] Modified file in library "${libwatcher.name}" and folder "${folder.id}" with relPath "${relPath}"`)
@ -318,6 +321,7 @@ class FolderWatcher extends EventEmitter {
}) })
this.handlePendingFileUpdatesTimeout() this.handlePendingFileUpdatesTimeout()
return true
} }
/** /**

View File

@ -4,6 +4,7 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const RssFeedManager = require('../managers/RssFeedManager')
const Collection = require('../objects/Collection') const Collection = require('../objects/Collection')
/** /**
@ -115,6 +116,7 @@ class CollectionController {
} }
// If books array is passed in then update order in collection // If books array is passed in then update order in collection
let collectionBooksUpdated = false
if (req.body.books?.length) { if (req.body.books?.length) {
const collectionBooks = await req.collection.getCollectionBooks({ const collectionBooks = await req.collection.getCollectionBooks({
include: { include: {
@ -133,9 +135,15 @@ class CollectionController {
await collectionBooks[i].update({ await collectionBooks[i].update({
order: i + 1 order: i + 1
}) })
wasUpdated = true collectionBooksUpdated = true
} }
} }
if (collectionBooksUpdated) {
req.collection.changed('updatedAt', true)
await req.collection.save()
wasUpdated = true
}
} }
const jsonExpanded = await req.collection.getOldJsonExpanded() const jsonExpanded = await req.collection.getOldJsonExpanded()
@ -148,6 +156,8 @@ class CollectionController {
/** /**
* DELETE: /api/collections/:id * DELETE: /api/collections/:id
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
@ -155,7 +165,7 @@ class CollectionController {
const jsonExpanded = await req.collection.getOldJsonExpanded() const jsonExpanded = await req.collection.getOldJsonExpanded()
// Close rss feed - remove from db and emit socket event // Close rss feed - remove from db and emit socket event
await this.rssFeedManager.closeFeedForEntityId(req.collection.id) await RssFeedManager.closeFeedForEntityId(req.collection.id)
await req.collection.destroy() await req.collection.destroy()

View File

@ -18,6 +18,8 @@ const LibraryScanner = require('../scanner/LibraryScanner')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
const Database = require('../Database') const Database = require('../Database')
const Watcher = require('../Watcher') const Watcher = require('../Watcher')
const RssFeedManager = require('../managers/RssFeedManager')
const libraryFilters = require('../utils/queries/libraryFilters') const libraryFilters = require('../utils/queries/libraryFilters')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
const authorFilters = require('../utils/queries/authorFilters') const authorFilters = require('../utils/queries/authorFilters')
@ -400,19 +402,48 @@ class LibraryController {
model: Database.podcastEpisodeModel, model: Database.podcastEpisodeModel,
attributes: ['id'] attributes: ['id']
} }
},
{
model: Database.bookModel,
attributes: ['id'],
include: [
{
model: Database.bookAuthorModel,
attributes: ['authorId']
},
{
model: Database.bookSeriesModel,
attributes: ['seriesId']
}
]
} }
] ]
}) })
Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`) Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`)
const seriesIds = []
const authorIds = []
for (const libraryItem of libraryItemsInFolder) { for (const libraryItem of libraryItemsInFolder) {
let mediaItemIds = [] let mediaItemIds = []
if (req.library.isPodcast) { if (req.library.isPodcast) {
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
} else { } else {
mediaItemIds.push(libraryItem.mediaId) mediaItemIds.push(libraryItem.mediaId)
if (libraryItem.media.bookAuthors.length) {
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
}
if (libraryItem.media.bookSeries.length) {
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
}
} }
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`) Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
}
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
} }
// Remove folder // Remove folder
@ -501,11 +532,24 @@ class LibraryController {
mediaItemIds.push(libraryItem.mediaId) mediaItemIds.push(libraryItem.mediaId)
} }
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`) Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
} }
// Set PlaybackSessions libraryId to null
const [sessionsUpdated] = await Database.playbackSessionModel.update(
{
libraryId: null
},
{
where: {
libraryId: req.library.id
}
}
)
Logger.info(`[LibraryController] Updated ${sessionsUpdated} playback sessions to remove library id`)
const libraryJson = req.library.toOldJSON() const libraryJson = req.library.toOldJSON()
await Database.removeLibrary(req.library.id) await req.library.destroy()
// Re-order libraries // Re-order libraries
await Database.libraryModel.resetDisplayOrder() await Database.libraryModel.resetDisplayOrder()
@ -567,6 +611,8 @@ class LibraryController {
* DELETE: /api/libraries/:id/issues * DELETE: /api/libraries/:id/issues
* Remove all library items missing or invalid * Remove all library items missing or invalid
* *
* @this {import('../routers/ApiRouter')}
*
* @param {LibraryControllerRequest} req * @param {LibraryControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
@ -592,6 +638,20 @@ class LibraryController {
model: Database.podcastEpisodeModel, model: Database.podcastEpisodeModel,
attributes: ['id'] attributes: ['id']
} }
},
{
model: Database.bookModel,
attributes: ['id'],
include: [
{
model: Database.bookAuthorModel,
attributes: ['authorId']
},
{
model: Database.bookSeriesModel,
attributes: ['seriesId']
}
]
} }
] ]
}) })
@ -602,15 +662,30 @@ class LibraryController {
} }
Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`) Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
const authorIds = []
const seriesIds = []
for (const libraryItem of libraryItemsWithIssues) { for (const libraryItem of libraryItemsWithIssues) {
let mediaItemIds = [] let mediaItemIds = []
if (req.library.isPodcast) { if (req.library.isPodcast) {
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
} else { } else {
mediaItemIds.push(libraryItem.mediaId) mediaItemIds.push(libraryItem.mediaId)
if (libraryItem.media.bookAuthors.length) {
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
}
if (libraryItem.media.bookSeries.length) {
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
}
} }
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`) Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
}
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
} }
// Set numIssues to 0 for library filter data // Set numIssues to 0 for library filter data
@ -686,8 +761,8 @@ class LibraryController {
} }
if (include.includes('rssfeed')) { if (include.includes('rssfeed')) {
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id) const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
} }
res.json(seriesJson) res.json(seriesJson)
@ -1142,7 +1217,7 @@ class LibraryController {
Logger.error(`[LibraryController] Non-root user "${req.user.username}" attempted to match library items`) Logger.error(`[LibraryController] Non-root user "${req.user.username}" attempted to match library items`)
return res.sendStatus(403) return res.sendStatus(403)
} }
Scanner.matchLibraryItems(req.library) Scanner.matchLibraryItems(this, req.library)
res.sendStatus(200) res.sendStatus(200)
} }

View File

@ -13,6 +13,8 @@ const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUti
const LibraryItemScanner = require('../scanner/LibraryItemScanner') const LibraryItemScanner = require('../scanner/LibraryItemScanner')
const AudioFileScanner = require('../scanner/AudioFileScanner') const AudioFileScanner = require('../scanner/AudioFileScanner')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
const RssFeedManager = require('../managers/RssFeedManager')
const CacheManager = require('../managers/CacheManager') const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager') const CoverManager = require('../managers/CoverManager')
const ShareManager = require('../managers/ShareManager') const ShareManager = require('../managers/ShareManager')
@ -48,8 +50,8 @@ class LibraryItemController {
} }
if (includeEntities.includes('rssfeed')) { if (includeEntities.includes('rssfeed')) {
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id) const feedData = await RssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feedData?.toJSONMinified() || null item.rssFeed = feedData?.toOldJSONMinified() || null
} }
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) { if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {
@ -96,6 +98,8 @@ class LibraryItemController {
* Optional query params: * Optional query params:
* ?hard=1 * ?hard=1
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
@ -103,14 +107,36 @@ class LibraryItemController {
const hardDelete = req.query.hard == 1 // Delete from file system const hardDelete = req.query.hard == 1 // Delete from file system
const libraryItemPath = req.libraryItem.path const libraryItemPath = req.libraryItem.path
const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id] const mediaItemIds = []
await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds) const authorIds = []
const seriesIds = []
if (req.libraryItem.isPodcast) {
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id))
} else {
mediaItemIds.push(req.libraryItem.media.id)
if (req.libraryItem.media.metadata.authors?.length) {
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id))
}
if (req.libraryItem.media.metadata.series?.length) {
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id))
}
}
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
if (hardDelete) { if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => { await fs.remove(libraryItemPath).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
}) })
} }
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
}
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId) await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
res.sendStatus(200) res.sendStatus(200)
} }
@ -212,15 +238,6 @@ class LibraryItemController {
if (hasUpdates) { if (hasUpdates) {
libraryItem.updatedAt = Date.now() libraryItem.updatedAt = Date.now()
if (seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
await this.checkRemoveEmptySeries(
libraryItem.media.id,
seriesRemoved.map((se) => se.id)
)
}
if (isPodcastAutoDownloadUpdated) { if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(libraryItem) this.cronManager.checkUpdatePodcastCron(libraryItem)
} }
@ -232,10 +249,12 @@ class LibraryItemController {
if (authorsRemoved.length) { if (authorsRemoved.length) {
// Check remove empty authors // Check remove empty authors
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`) Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
await this.checkRemoveAuthorsWithNoBooks( await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
libraryItem.libraryId, }
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({ res.json({
@ -437,10 +456,24 @@ class LibraryItemController {
* @param {Response} res * @param {Response} res
*/ */
async match(req, res) { async match(req, res) {
var libraryItem = req.libraryItem const libraryItem = req.libraryItem
const reqBody = req.body || {}
var options = req.body || {} const options = {}
var matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options) const matchOptions = ['provider', 'title', 'author', 'isbn', 'asin']
for (const key of matchOptions) {
if (reqBody[key] && typeof reqBody[key] === 'string') {
options[key] = reqBody[key]
}
}
if (reqBody.overrideCover !== undefined) {
options.overrideCover = !!reqBody.overrideCover
}
if (reqBody.overrideDetails !== undefined) {
options.overrideDetails = !!reqBody.overrideDetails
}
var matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
res.json(matchResult) res.json(matchResult)
} }
@ -450,6 +483,8 @@ class LibraryItemController {
* Optional query params: * Optional query params:
* ?hard=1 * ?hard=1
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
@ -477,14 +512,33 @@ class LibraryItemController {
for (const libraryItem of itemsToDelete) { for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`) Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id] const mediaItemIds = []
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) const seriesIds = []
const authorIds = []
if (libraryItem.isPodcast) {
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id))
} else {
mediaItemIds.push(libraryItem.media.id)
if (libraryItem.media.metadata.series?.length) {
seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id))
}
if (libraryItem.media.metadata.authors?.length) {
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id))
}
}
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
if (hardDelete) { if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => { await fs.remove(libraryItemPath).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
}) })
} }
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
}
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
} }
await Database.resetLibraryIssuesFilterData(libraryId) await Database.resetLibraryIssuesFilterData(libraryId)
@ -494,48 +548,74 @@ class LibraryItemController {
/** /**
* POST: /api/items/batch/update * POST: /api/items/batch/update
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
async batchUpdate(req, res) { async batchUpdate(req, res) {
const updatePayloads = req.body const updatePayloads = req.body
if (!updatePayloads?.length) { if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
return res.sendStatus(500) Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
return res.sendStatus(400)
}
// Ensure that each update payload has a unique library item id
const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))]
if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) {
Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`)
return res.sendStatus(400)
}
// Get all library items to update
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
id: libraryItemIds
})
if (updatePayloads.length !== libraryItems.length) {
Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`)
return res.sendStatus(404)
} }
let itemsUpdated = 0 let itemsUpdated = 0
const seriesIdsRemoved = []
const authorIdsRemoved = []
for (const updatePayload of updatePayloads) { for (const updatePayload of updatePayloads) {
const mediaPayload = updatePayload.mediaPayload const mediaPayload = updatePayload.mediaPayload
const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id) const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
if (!libraryItem) return null
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
let seriesRemoved = [] if (libraryItem.isBook) {
if (libraryItem.isBook && mediaPayload.metadata?.series) { if (Array.isArray(mediaPayload.metadata?.series)) {
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id) const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
}
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.media.update(mediaPayload)) { if (libraryItem.media.update(mediaPayload)) {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
if (seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
await this.checkRemoveEmptySeries(
libraryItem.media.id,
seriesRemoved.map((se) => se.id)
)
}
await Database.updateLibraryItem(libraryItem) await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
itemsUpdated++ itemsUpdated++
} }
} }
if (seriesIdsRemoved.length) {
await this.checkRemoveEmptySeries(seriesIdsRemoved)
}
if (authorIdsRemoved.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)
}
res.json({ res.json({
success: true, success: true,
updates: itemsUpdated updates: itemsUpdated
@ -576,7 +656,6 @@ class LibraryItemController {
let itemsUpdated = 0 let itemsUpdated = 0
let itemsUnmatched = 0 let itemsUnmatched = 0
const options = req.body.options || {}
if (!req.body.libraryItemIds?.length) { if (!req.body.libraryItemIds?.length) {
return res.sendStatus(400) return res.sendStatus(400)
} }
@ -590,8 +669,20 @@ class LibraryItemController {
res.sendStatus(200) res.sendStatus(200)
const reqBodyOptions = req.body.options || {}
const options = {}
if (reqBodyOptions.provider && typeof reqBodyOptions.provider === 'string') {
options.provider = reqBodyOptions.provider
}
if (reqBodyOptions.overrideCover !== undefined) {
options.overrideCover = !!reqBodyOptions.overrideCover
}
if (reqBodyOptions.overrideDetails !== undefined) {
options.overrideDetails = !!reqBodyOptions.overrideDetails
}
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
const matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options) const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
if (matchResult.updated) { if (matchResult.updated) {
itemsUpdated++ itemsUpdated++
} else if (matchResult.warning) { } else if (matchResult.warning) {

View File

@ -126,6 +126,10 @@ class MiscController {
if (!isObject(settingsUpdate)) { if (!isObject(settingsUpdate)) {
return res.status(400).send('Invalid settings update object') return res.status(400).send('Invalid settings update object')
} }
if (settingsUpdate.allowIframe == false && process.env.ALLOW_IFRAME === '1') {
Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
}
const madeUpdates = Database.serverSettings.update(settingsUpdate) const madeUpdates = Database.serverSettings.update(settingsUpdate)
if (madeUpdates) { if (madeUpdates) {
@ -137,7 +141,6 @@ class MiscController {
} }
} }
return res.json({ return res.json({
success: true,
serverSettings: Database.serverSettings.toJSONForBrowser() serverSettings: Database.serverSettings.toJSONForBrowser()
}) })
} }
@ -679,9 +682,9 @@ class MiscController {
continue continue
} }
let updatedValue = settingsUpdate[key] let updatedValue = settingsUpdate[key]
if (updatedValue === '') updatedValue = null if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
let currentValue = currentAuthenticationSettings[key] let currentValue = currentAuthenticationSettings[key]
if (currentValue === '') currentValue = null if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null
if (updatedValue !== currentValue) { if (updatedValue !== currentValue) {
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`) Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)

View File

@ -1,7 +1,8 @@
const { Request, Response, NextFunction } = require('express') const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger') const Logger = require('../Logger')
const Database = require('../Database') const Database = require('../Database')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
const RssFeedManager = require('../managers/RssFeedManager')
/** /**
* @typedef RequestUserObject * @typedef RequestUserObject
@ -22,10 +23,10 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async getAll(req, res) { async getAll(req, res) {
const feeds = await this.rssFeedManager.getFeeds() const feeds = await RssFeedManager.getFeeds()
res.json({ res.json({
feeds: feeds.map((f) => f.toJSON()), feeds: feeds.map((f) => f.toOldJSON()),
minified: feeds.map((f) => f.toJSONMinified()) minified: feeds.map((f) => f.toOldJSONMinified())
}) })
} }
@ -38,38 +39,43 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async openRSSFeedForItem(req, res) { async openRSSFeedForItem(req, res) {
const options = req.body || {} const reqBody = req.body || {}
const item = await Database.libraryItemModel.getOldById(req.params.itemId) const itemExpanded = await Database.libraryItemModel.getExpandedById(req.params.itemId)
if (!item) return res.sendStatus(404) if (!itemExpanded) return res.sendStatus(404)
// Check user can access this library item // Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) { if (!req.user.checkCanAccessLibraryItem(itemExpanded)) {
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${item.media.metadata.title}" that they don\'t have access to`) Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${itemExpanded.media.title}" that they don\'t have access to`)
return res.sendStatus(403) return res.sendStatus(403)
} }
// Check request body options exist // Check request body options exist
if (!options.serverAddress || !options.slug) { if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body') return res.status(400).send('Invalid request body')
} }
// Check item has audio tracks // Check item has audio tracks
if (!item.media.numTracks) { if (!itemExpanded.hasAudioTracks()) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${item.media.metadata.title}" because it has no audio tracks`) Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${itemExpanded.media.title}" because it has no audio tracks`)
return res.status(400).send('Item has no audio tracks') return res.status(400).send('Item has no audio tracks')
} }
// Check that this slug is not being used for another feed (slug will also be the Feed id) // Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) { if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const feed = await this.rssFeedManager.openFeedForItem(req.user.id, item, req.body) const feed = await RssFeedManager.openFeedForItem(req.user.id, itemExpanded, reqBody)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for item "${itemExpanded.media.title}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({ res.json({
feed: feed.toJSONMinified() feed: feed.toOldJSONMinified()
}) })
} }
@ -82,35 +88,37 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async openRSSFeedForCollection(req, res) { async openRSSFeedForCollection(req, res) {
const options = req.body || {} const reqBody = req.body || {}
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) return res.sendStatus(404)
// Check request body options exist // Check request body options exist
if (!options.serverAddress || !options.slug) { if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body') return res.status(400).send('Invalid request body')
} }
// Check that this slug is not being used for another feed (slug will also be the Feed id) // Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) { if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const collectionExpanded = await collection.getOldJsonExpanded() const collection = await Database.collectionModel.getExpandedById(req.params.collectionId)
const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length) if (!collection) return res.sendStatus(404)
// Check collection has audio tracks // Check collection has audio tracks
if (!collectionItemsWithTracks.length) { if (!collection.books.some((book) => book.includedAudioFiles.length)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`) Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`)
return res.status(400).send('Collection has no audio tracks') return res.status(400).send('Collection has no audio tracks')
} }
const feed = await this.rssFeedManager.openFeedForCollection(req.user.id, collectionExpanded, req.body) const feed = await RssFeedManager.openFeedForCollection(req.user.id, collection, reqBody)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for collection "${collection.name}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({ res.json({
feed: feed.toJSONMinified() feed: feed.toOldJSONMinified()
}) })
} }
@ -123,37 +131,37 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async openRSSFeedForSeries(req, res) { async openRSSFeedForSeries(req, res) {
const options = req.body || {} const reqBody = req.body || {}
const series = await Database.seriesModel.findByPk(req.params.seriesId)
if (!series) return res.sendStatus(404)
// Check request body options exist // Check request body options exist
if (!options.serverAddress || !options.slug) { if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body') return res.status(400).send('Invalid request body')
} }
// Check that this slug is not being used for another feed (slug will also be the Feed id) // Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) { if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const seriesJson = series.toOldJSON() const series = await Database.seriesModel.getExpandedById(req.params.seriesId)
if (!series) return res.sendStatus(404)
// Get books in series that have audio tracks
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
// Check series has audio tracks // Check series has audio tracks
if (!seriesJson.books.length) { if (!series.books.some((book) => book.includedAudioFiles.length)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${seriesJson.name}" because it has no audio tracks`) Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${series.name}" because it has no audio tracks`)
return res.status(400).send('Series has no audio tracks') return res.status(400).send('Series has no audio tracks')
} }
const feed = await this.rssFeedManager.openFeedForSeries(req.user.id, seriesJson, req.body) const feed = await RssFeedManager.openFeedForSeries(req.user.id, series, req.body)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for series "${series.name}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({ res.json({
feed: feed.toJSONMinified() feed: feed.toOldJSONMinified()
}) })
} }
@ -165,8 +173,16 @@ class RSSFeedController {
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
closeRSSFeed(req, res) { async closeRSSFeed(req, res) {
this.rssFeedManager.closeRssFeed(req, res) const feed = await Database.feedModel.findByPk(req.params.id)
if (!feed) {
Logger.error(`[RSSFeedController] Cannot close RSS feed because feed "${req.params.id}" does not exist`)
return res.sendStatus(404)
}
await RssFeedManager.handleCloseFeed(feed)
res.sendStatus(200)
} }
/** /**

View File

@ -2,6 +2,9 @@ const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const RssFeedManager = require('../managers/RssFeedManager')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters') const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
/** /**
@ -51,8 +54,8 @@ class SeriesController {
} }
if (include.includes('rssfeed')) { if (include.includes('rssfeed')) {
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id) const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
} }
res.json(seriesJson) res.json(seriesJson)

View File

@ -368,6 +368,19 @@ class UserController {
await playlist.destroy() await playlist.destroy()
} }
// Set PlaybackSessions userId to null
const [sessionsUpdated] = await Database.playbackSessionModel.update(
{
userId: null
},
{
where: {
userId: user.id
}
}
)
Logger.info(`[UserController] Updated ${sessionsUpdated} playback sessions to remove user id`)
const userJson = user.toOldJSONForBrowser() const userJson = user.toOldJSONForBrowser()
await user.destroy() await user.destroy()
SocketAuthority.adminEmitter('user_removed', userJson) SocketAuthority.adminEmitter('user_removed', userJson)

View File

@ -86,6 +86,7 @@ class CacheManager {
} }
async purgeEntityCache(entityId, cachePath) { async purgeEntityCache(entityId, cachePath) {
if (!entityId || !cachePath) return []
return Promise.all( return Promise.all(
(await fs.readdir(cachePath)).reduce((promises, file) => { (await fs.readdir(cachePath)).reduce((promises, file) => {
if (file.startsWith(entityId)) { if (file.startsWith(entityId)) {

View File

@ -12,7 +12,7 @@ const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const CacheManager = require('../managers/CacheManager') const CacheManager = require('../managers/CacheManager')
class CoverManager { class CoverManager {
constructor() { } constructor() {}
getCoverDirectory(libraryItem) { getCoverDirectory(libraryItem) {
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) { if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) {
@ -93,10 +93,13 @@ class CoverManager {
const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`) const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`)
// Move cover from temp upload dir to destination // Move cover from temp upload dir to destination
const success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => { const success = await coverFile
Logger.error('[CoverManager] Failed to move cover file', path, error) .mv(coverFullPath)
return false .then(() => true)
}) .catch((error) => {
Logger.error('[CoverManager] Failed to move cover file', coverFullPath, error)
return false
})
if (!success) { if (!success) {
return { return {
@ -124,11 +127,13 @@ class CoverManager {
var temppath = Path.posix.join(coverDirPath, 'cover') var temppath = Path.posix.join(coverDirPath, 'cover')
let errorMsg = '' let errorMsg = ''
let success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { let success = await downloadImageFile(url, temppath)
errorMsg = err.message || 'Unknown error' .then(() => true)
Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg) .catch((err) => {
return false errorMsg = err.message || 'Unknown error'
}) Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
return false
})
if (!success) { if (!success) {
return { return {
error: 'Failed to download image from url: ' + errorMsg error: 'Failed to download image from url: ' + errorMsg
@ -180,7 +185,7 @@ class CoverManager {
} }
// Cover path does not exist // Cover path does not exist
if (!await fs.pathExists(coverPath)) { if (!(await fs.pathExists(coverPath))) {
Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`) Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
return { return {
error: 'Cover path does not exist' error: 'Cover path does not exist'
@ -188,7 +193,7 @@ class CoverManager {
} }
// Cover path is not a file // Cover path is not a file
if (!await checkPathIsFile(coverPath)) { if (!(await checkPathIsFile(coverPath))) {
Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`) Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`)
return { return {
error: 'Cover path is not a file' error: 'Cover path is not a file'
@ -211,10 +216,13 @@ class CoverManager {
var newCoverPath = Path.posix.join(coverDirPath, coverFilename) var newCoverPath = Path.posix.join(coverDirPath, coverFilename)
Logger.debug(`[CoverManager] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`) Logger.debug(`[CoverManager] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`)
var copySuccess = await fs.copy(coverPath, newCoverPath, { overwrite: true }).then(() => true).catch((error) => { var copySuccess = await fs
Logger.error(`[CoverManager] validate cover path failed to copy cover`, error) .copy(coverPath, newCoverPath, { overwrite: true })
return false .then(() => true)
}) .catch((error) => {
Logger.error(`[CoverManager] validate cover path failed to copy cover`, error)
return false
})
if (!copySuccess) { if (!copySuccess) {
return { return {
error: 'Failed to copy cover to dir' error: 'Failed to copy cover to dir'
@ -236,14 +244,14 @@ class CoverManager {
/** /**
* Extract cover art from audio file and save for library item * Extract cover art from audio file and save for library item
* *
* @param {import('../models/Book').AudioFileObject[]} audioFiles * @param {import('../models/Book').AudioFileObject[]} audioFiles
* @param {string} libraryItemId * @param {string} libraryItemId
* @param {string} [libraryItemPath] null for isFile library items * @param {string} [libraryItemPath] null for isFile library items
* @returns {Promise<string>} returns cover path * @returns {Promise<string>} returns cover path
*/ */
async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) { async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) {
let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt) let audioFileWithCover = audioFiles.find((af) => af.embeddedCoverArt)
if (!audioFileWithCover) return null if (!audioFileWithCover) return null
let coverDirPath = null let coverDirPath = null
@ -273,10 +281,10 @@ class CoverManager {
/** /**
* Extract cover art from ebook and save for library item * Extract cover art from ebook and save for library item
* *
* @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
* @param {string} libraryItemId * @param {string} libraryItemId
* @param {string} [libraryItemPath] null for isFile library items * @param {string} [libraryItemPath] null for isFile library items
* @returns {Promise<string>} returns cover path * @returns {Promise<string>} returns cover path
*/ */
async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) { async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) {
@ -310,9 +318,9 @@ class CoverManager {
} }
/** /**
* *
* @param {string} url * @param {string} url
* @param {string} libraryItemId * @param {string} libraryItemId
* @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast * @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast
* @returns {Promise<{error:string}|{cover:string}>} * @returns {Promise<{error:string}|{cover:string}>}
*/ */
@ -328,10 +336,12 @@ class CoverManager {
await fs.ensureDir(coverDirPath) await fs.ensureDir(coverDirPath)
const temppath = Path.posix.join(coverDirPath, 'cover') const temppath = Path.posix.join(coverDirPath, 'cover')
const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { const success = await downloadImageFile(url, temppath)
Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) .then(() => true)
return false .catch((err) => {
}) Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
return false
})
if (!success) { if (!success) {
return { return {
error: 'Failed to download image from url' error: 'Failed to download image from url'
@ -361,4 +371,4 @@ class CoverManager {
} }
} }
} }
module.exports = new CoverManager() module.exports = new CoverManager()

View File

@ -25,7 +25,9 @@ const LibraryItem = require('../objects/LibraryItem')
class PodcastManager { class PodcastManager {
constructor() { constructor() {
/** @type {PodcastEpisodeDownload[]} */
this.downloadQueue = [] this.downloadQueue = []
/** @type {PodcastEpisodeDownload} */
this.currentDownload = null this.currentDownload = null
this.failedCheckMap = {} this.failedCheckMap = {}
@ -63,6 +65,11 @@ class PodcastManager {
} }
} }
/**
*
* @param {PodcastEpisodeDownload} podcastEpisodeDownload
* @returns
*/
async startPodcastEpisodeDownload(podcastEpisodeDownload) { async startPodcastEpisodeDownload(podcastEpisodeDownload) {
if (this.currentDownload) { if (this.currentDownload) {
this.downloadQueue.push(podcastEpisodeDownload) this.downloadQueue.push(podcastEpisodeDownload)
@ -106,7 +113,7 @@ class PodcastManager {
} }
let success = false let success = false
if (this.currentDownload.urlFileExtension === 'mp3') { if (this.currentDownload.isMp3) {
// Download episode and tag it // Download episode and tag it
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => { success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
Logger.error(`[PodcastManager] Podcast Episode download failed`, error) Logger.error(`[PodcastManager] Podcast Episode download failed`, error)

View File

@ -1,3 +1,4 @@
const { Request, Response } = require('express')
const Path = require('path') const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
@ -5,170 +6,190 @@ const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const Feed = require('../objects/Feed')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class RssFeedManager { class RssFeedManager {
constructor() {} constructor() {}
async validateFeedEntity(feedObj) {
if (feedObj.entityType === 'collection') {
const collection = await Database.collectionModel.getOldById(feedObj.entityId)
if (!collection) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'libraryItem') {
const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
if (!libraryItemExists) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'series') {
const series = await Database.seriesModel.findByPk(feedObj.entityId)
if (!series) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
return false
}
} else {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Invalid entityType "${feedObj.entityType}"`)
return false
}
return true
}
/** /**
* Validate all feeds and remove invalid * Remove invalid feeds (invalid if the entity does not exist)
*/ */
async init() { async init() {
const feeds = await Database.feedModel.getOldFeeds() const feeds = await Database.feedModel.findAll({
attributes: ['id', 'entityId', 'entityType', 'title'],
include: [
{
model: Database.libraryItemModel,
attributes: ['id']
},
{
model: Database.collectionModel,
attributes: ['id']
},
{
model: Database.seriesModel,
attributes: ['id']
}
]
})
const feedIdsToRemove = []
for (const feed of feeds) { for (const feed of feeds) {
// Remove invalid feeds if (!feed.entity) {
if (!(await this.validateFeedEntity(feed))) { Logger.error(`[RssFeedManager] Removing feed "${feed.title}". Entity not found`)
await Database.removeFeed(feed.id) feedIdsToRemove.push(feed.id)
} }
} }
if (feedIdsToRemove.length) {
Logger.info(`[RssFeedManager] Removing ${feedIdsToRemove.length} invalid feeds`)
await Database.feedModel.destroy({
where: {
id: feedIdsToRemove
}
})
}
} }
/** /**
* Find open feed for an entity (e.g. collection id, playlist id, library item id) * Find open feed for an entity (e.g. collection id, playlist id, library item id)
* @param {string} entityId * @param {string} entityId
* @returns {Promise<objects.Feed>} oldFeed * @returns {Promise<import('../models/Feed')>}
*/ */
findFeedForEntityId(entityId) { findFeedForEntityId(entityId) {
return Database.feedModel.findOneOld({ entityId }) return Database.feedModel.findOne({
where: {
entityId
}
})
} }
/** /**
* Find open feed for a slug *
* @param {string} slug * @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed * @returns {Promise<boolean>}
*/ */
findFeedBySlug(slug) { checkExistsBySlug(slug) {
return Database.feedModel.findOneOld({ slug }) return Database.feedModel
.count({
where: {
slug
}
})
.then((count) => count > 0)
} }
/** /**
* Find open feed for a slug * Feed requires update if the entity (or child entities) has been updated since the feed was last updated
* @param {string} slug *
* @returns {Promise<objects.Feed>} oldFeed * @param {import('../models/Feed')} feed
* @returns {Promise<boolean>}
*/ */
findFeed(id) { async checkFeedRequiresUpdate(feed) {
return Database.feedModel.findByPkOld(id) if (feed.entityType === 'libraryItem') {
feed.entity = await feed.getEntity({
attributes: ['id', 'updatedAt', 'mediaId', 'mediaType']
})
let newEntityUpdatedAt = feed.entity.updatedAt
if (feed.entity.mediaType === 'podcast') {
const mostRecentPodcastEpisode = await Database.podcastEpisodeModel.findOne({
where: {
podcastId: feed.entity.mediaId
},
attributes: ['id', 'updatedAt'],
order: [['createdAt', 'DESC']]
})
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt
}
}
return newEntityUpdatedAt > feed.entityUpdatedAt
} else if (feed.entityType === 'collection' || feed.entityType === 'series') {
feed.entity = await feed.getEntity({
attributes: ['id', 'updatedAt'],
include: {
model: Database.bookModel,
attributes: ['id'],
through: {
attributes: []
},
include: {
model: Database.libraryItemModel,
attributes: ['id', 'updatedAt']
}
}
})
let newEntityUpdatedAt = feed.entity.updatedAt
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
if (book.libraryItem.updatedAt > mostRecent) {
return book.libraryItem.updatedAt
}
return mostRecent
}, 0)
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = mostRecentItemUpdatedAt
}
return newEntityUpdatedAt > feed.entityUpdatedAt
} else {
throw new Error('Invalid feed entity type')
}
} }
/**
* GET: /feed/:slug
*
* @param {Request} req
* @param {Response} res
*/
async getFeed(req, res) { async getFeed(req, res) {
const feed = await this.findFeedBySlug(req.params.slug) let feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
}
})
if (!feed) { if (!feed) {
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`) Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404) res.sendStatus(404)
return return
} }
// Check if feed needs to be updated const feedRequiresUpdate = await this.checkFeedRequiresUpdate(feed)
if (feed.entityType === 'libraryItem') { if (feedRequiresUpdate) {
const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId) Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
feed = await feed.updateFeedForEntity()
let mostRecentlyUpdatedAt = libraryItem.updatedAt } else {
if (libraryItem.isPodcast) { feed.feedEpisodes = await feed.getFeedEpisodes()
libraryItem.media.episodes.forEach((episode) => {
if (episode.updatedAt > mostRecentlyUpdatedAt) mostRecentlyUpdatedAt = episode.updatedAt
})
}
if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) {
Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`)
feed.updateFromItem(libraryItem)
await Database.updateFeed(feed)
}
} else if (feed.entityType === 'collection') {
const collection = await Database.collectionModel.findByPk(feed.entityId, {
include: Database.collectionBookModel
})
if (collection) {
const collectionExpanded = await collection.getOldJsonExpanded()
// Find most recently updated item in collection
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
// Check for most recently updated book
collectionExpanded.books.forEach((libraryItem) => {
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = libraryItem.updatedAt
}
})
// Check for most recently added collection book
collection.collectionBooks.forEach((collectionBook) => {
if (collectionBook.createdAt.valueOf() > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = collectionBook.createdAt.valueOf()
}
})
const hasBooksRemoved = collection.collectionBooks.length < feed.episodes.length
if (!feed.entityUpdatedAt || hasBooksRemoved || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`)
feed.updateFromCollection(collectionExpanded)
await Database.updateFeed(feed)
}
}
} else if (feed.entityType === 'series') {
const series = await Database.seriesModel.findByPk(feed.entityId)
if (series) {
const seriesJson = series.toOldJSON()
// Get books in series that have audio tracks
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
// Find most recently updated item in series
let mostRecentlyUpdatedAt = seriesJson.updatedAt
let totalTracks = 0 // Used to detect series items removed
seriesJson.books.forEach((libraryItem) => {
totalTracks += libraryItem.media.tracks.length
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = libraryItem.updatedAt
}
})
if (totalTracks !== feed.episodes.length) {
mostRecentlyUpdatedAt = Date.now()
}
if (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
Logger.debug(`[RssFeedManager] Updating RSS feed for series "${seriesJson.name}"`)
feed.updateFromSeries(seriesJson)
await Database.updateFeed(feed)
}
}
} }
const xml = feed.buildXml() const xml = feed.buildXml(req.originalHostPrefix)
res.set('Content-Type', 'text/xml') res.set('Content-Type', 'text/xml')
res.send(xml) res.send(xml)
} }
/**
* GET: /feed/:slug/item/:episodeId/*
*
* @param {Request} req
* @param {Response} res
*/
async getFeedItem(req, res) { async getFeedItem(req, res) {
const feed = await this.findFeedBySlug(req.params.slug) const feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
},
attributes: ['id', 'slug'],
include: {
model: Database.feedEpisodeModel,
attributes: ['id', 'filePath']
}
})
if (!feed) { if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`) Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404) res.sendStatus(404)
@ -183,8 +204,19 @@ class RssFeedManager {
res.sendFile(episodePath) res.sendFile(episodePath)
} }
/**
* GET: /feed/:slug/cover*
*
* @param {Request} req
* @param {Response} res
*/
async getFeedCover(req, res) { async getFeedCover(req, res) {
const feed = await this.findFeedBySlug(req.params.slug) const feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
},
attributes: ['coverPath']
})
if (!feed) { if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`) Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404) res.sendStatus(404)
@ -204,100 +236,142 @@ class RssFeedManager {
/** /**
* *
* @param {string} userId
* @param {*} libraryItem
* @param {*} options * @param {*} options
* @returns * @returns {import('../models/Feed').FeedOptions}
*/
getFeedOptionsFromReqOptions(options) {
const metadataDetails = options.metadataDetails || {}
if (metadataDetails.preventIndexing !== false) {
metadataDetails.preventIndexing = true
}
return {
preventIndexing: metadataDetails.preventIndexing,
ownerName: metadataDetails.ownerName && typeof metadataDetails.ownerName === 'string' ? metadataDetails.ownerName : null,
ownerEmail: metadataDetails.ownerEmail && typeof metadataDetails.ownerEmail === 'string' ? metadataDetails.ownerEmail : null
}
}
/**
*
* @param {string} userId
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} options
* @returns {Promise<import('../models/Feed').FeedExpanded>}
*/ */
async openFeedForItem(userId, libraryItem, options) { async openFeedForItem(userId, libraryItem, options) {
const serverAddress = options.serverAddress const serverAddress = options.serverAddress
const slug = options.slug const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true const feedOptions = this.getFeedOptionsFromReqOptions(options)
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed() Logger.info(`[RssFeedManager] Creating RSS feed for item ${libraryItem.id} "${libraryItem.media.title}"`)
feed.setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail) const feedExpanded = await Database.feedModel.createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
await Database.createFeed(feed) SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) }
return feed return feedExpanded
} }
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {*} collectionExpanded * @param {import('../models/Collection')} collectionExpanded
* @param {*} options * @param {*} options
* @returns * @returns {Promise<import('../models/Feed').FeedExpanded>}
*/ */
async openFeedForCollection(userId, collectionExpanded, options) { async openFeedForCollection(userId, collectionExpanded, options) {
const serverAddress = options.serverAddress const serverAddress = options.serverAddress
const slug = options.slug const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true const feedOptions = this.getFeedOptionsFromReqOptions(options)
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed() Logger.info(`[RssFeedManager] Creating RSS feed for collection "${collectionExpanded.name}"`)
feed.setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail) const feedExpanded = await Database.feedModel.createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
await Database.createFeed(feed) SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) }
return feed return feedExpanded
} }
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {*} seriesExpanded * @param {import('../models/Series')} seriesExpanded
* @param {*} options * @param {*} options
* @returns * @returns {Promise<import('../models/Feed').FeedExpanded>}
*/ */
async openFeedForSeries(userId, seriesExpanded, options) { async openFeedForSeries(userId, seriesExpanded, options) {
const serverAddress = options.serverAddress const serverAddress = options.serverAddress
const slug = options.slug const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true const feedOptions = this.getFeedOptionsFromReqOptions(options)
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed() Logger.info(`[RssFeedManager] Creating RSS feed for series "${seriesExpanded.name}"`)
feed.setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail) const feedExpanded = await Database.feedModel.createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
await Database.createFeed(feed) SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
return feed
}
async handleCloseFeed(feed) {
if (!feed) return
await Database.removeFeed(feed.id)
SocketAuthority.emitter('rss_feed_closed', feed.toJSONMinified())
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedUrl}"`)
}
async closeRssFeed(req, res) {
const feed = await this.findFeed(req.params.id)
if (!feed) {
Logger.error(`[RssFeedManager] RSS feed not found with id "${req.params.id}"`)
return res.sendStatus(404)
} }
await this.handleCloseFeed(feed) return feedExpanded
res.sendStatus(200)
} }
/**
* Close Feed and emit Socket event
*
* @param {import('../models/Feed')} feed
* @returns {Promise<boolean>} - true if feed was closed
*/
async handleCloseFeed(feed) {
if (!feed) return false
const wasRemoved = await Database.feedModel.removeById(feed.id)
SocketAuthority.emitter('rss_feed_closed', feed.toOldJSONMinified())
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedURL}"`)
return wasRemoved
}
/**
*
* @param {string} entityId
* @returns {Promise<boolean>} - true if feed was closed
*/
async closeFeedForEntityId(entityId) { async closeFeedForEntityId(entityId) {
const feed = await this.findFeedForEntityId(entityId) const feed = await Database.feedModel.findOne({
if (!feed) return where: {
entityId
}
})
if (!feed) {
return false
}
return this.handleCloseFeed(feed) return this.handleCloseFeed(feed)
} }
async getFeeds() { /**
const feeds = await Database.models.feed.getOldFeeds() *
Logger.info(`[RssFeedManager] Fetched all feeds`) * @param {string[]} entityIds
return feeds */
async closeFeedsForEntityIds(entityIds) {
const feeds = await Database.feedModel.findAll({
where: {
entityId: entityIds
}
})
for (const feed of feeds) {
await this.handleCloseFeed(feed)
}
}
/**
*
* @returns {Promise<import('../models/Feed').FeedExpanded[]>}
*/
getFeeds() {
return Database.feedModel.findAll({
include: {
model: Database.feedEpisodeModel
}
})
} }
} }
module.exports = RssFeedManager module.exports = new RssFeedManager()

View File

@ -2,8 +2,12 @@
Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.
| Server Version | Migration Script Name | Description | | Server Version | Migration Script Name | Description |
| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | | -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | | v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | | v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | | v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
| 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 |

View File

@ -0,0 +1,102 @@
/**
* @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.
*/
/**
* This upward migration script changes table columns with data type UUIDv4 to UUID to match associated models.
*
* @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('[2.17.0 migration] UPGRADE BEGIN: 2.17.0-uuid-replacement')
logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUID')
await queryInterface.changeColumn('libraryItems', 'mediaId', {
type: 'UUID'
})
logger.info('[2.17.0 migration] Changing feeds.entityId column to UUID')
await queryInterface.changeColumn('feeds', 'entityId', {
type: 'UUID'
})
if (await queryInterface.tableExists('mediaItemShares')) {
logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID')
await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', {
type: 'UUID'
})
} else {
logger.info('[2.17.0 migration] mediaItemShares table does not exist, skipping column change')
}
logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUID')
await queryInterface.changeColumn('playbackSessions', 'mediaItemId', {
type: 'UUID'
})
logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUID')
await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', {
type: 'UUID'
})
logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUID')
await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', {
type: 'UUID'
})
// Completed migration
logger.info('[2.17.0 migration] UPGRADE END: 2.17.0-uuid-replacement')
}
/**
* This downward migration script changes table columns data type back to UUIDv4.
*
* @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('[2.17.0 migration] DOWNGRADE BEGIN: 2.17.0-uuid-replacement')
logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUIDV4')
await queryInterface.changeColumn('libraryItems', 'mediaId', {
type: 'UUIDV4'
})
logger.info('[2.17.0 migration] Changing feeds.entityId column to UUIDV4')
await queryInterface.changeColumn('feeds', 'entityId', {
type: 'UUIDV4'
})
logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUIDV4')
await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', {
type: 'UUIDV4'
})
logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUIDV4')
await queryInterface.changeColumn('playbackSessions', 'mediaItemId', {
type: 'UUIDV4'
})
logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUIDV4')
await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', {
type: 'UUIDV4'
})
logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUIDV4')
await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', {
type: 'UUIDV4'
})
// Completed migration
logger.info('[2.17.0 migration] DOWNGRADE END: 2.17.0-uuid-replacement')
}
module.exports = { up, down }

View File

@ -0,0 +1,259 @@
/**
* @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.
*/
/**
* This upward migration script changes foreign key constraints for the
* libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, and mediaProgresses tables.
*
* @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('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints')
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
// Disable foreign key constraints for the next sequence of operations
await execQuery(`PRAGMA foreign_keys = OFF;`)
try {
await execQuery(`BEGIN TRANSACTION;`)
logger.info('[2.17.3 migration] Updating libraryItems constraints')
const libraryItemsConstraints = [
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
{ field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
]
if (await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints)) {
logger.info('[2.17.3 migration] Finished updating libraryItems constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for libraryItems constraints')
}
logger.info('[2.17.3 migration] Updating feeds constraints')
const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'feeds', feedsConstraints)) {
logger.info('[2.17.3 migration] Finished updating feeds constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for feeds constraints')
}
if (await queryInterface.tableExists('mediaItemShares')) {
logger.info('[2.17.3 migration] Updating mediaItemShares constraints')
const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints)) {
logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for mediaItemShares constraints')
}
} else {
logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change')
}
logger.info('[2.17.3 migration] Updating playbackSessions constraints')
const playbackSessionsConstraints = [
{ field: 'deviceId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
]
if (await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints)) {
logger.info('[2.17.3 migration] Finished updating playbackSessions constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for playbackSessions constraints')
}
logger.info('[2.17.3 migration] Updating playlistMediaItems constraints')
const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints)) {
logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for playlistMediaItems constraints')
}
logger.info('[2.17.3 migration] Updating mediaProgresses constraints')
const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints)) {
logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for mediaProgresses constraints')
}
await execQuery(`COMMIT;`)
} catch (error) {
logger.error(`[2.17.3 migration] Migration failed - rolling back. Error:`, error)
await execQuery(`ROLLBACK;`)
}
await execQuery(`PRAGMA foreign_keys = ON;`)
// Completed migration
logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints')
}
/**
* This downward migration script is a no-op.
*
* @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('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-fk-constraints')
// This migration is a no-op
logger.info('[2.17.3 migration] No action required for downgrade')
// Completed migration
logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-fk-constraints')
}
/**
* @typedef ConstraintUpdateObj
* @property {string} field - The field to update
* @property {string} onDelete - The onDelete constraint
* @property {string} onUpdate - The onUpdate constraint
*/
/**
* @typedef SequelizeFKObj
* @property {{ model: string, key: string }} references
* @property {string} onDelete
* @property {string} onUpdate
*/
/**
* @param {Object} fk - The foreign key object from PRAGMA foreign_key_list
* @returns {SequelizeFKObj} - The foreign key object formatted for Sequelize
*/
const formatFKsPragmaToSequelizeFK = (fk) => {
return {
references: {
model: fk.table,
key: fk.to
},
onDelete: fk['on_delete'],
onUpdate: fk['on_update']
}
}
/**
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {string} tableName
* @param {ConstraintUpdateObj[]} constraints
* @returns {Promise<Record<string, SequelizeFKObj>|null>}
*/
async function getUpdatedForeignKeys(queryInterface, tableName, constraints) {
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
const quotedTableName = queryInterface.quoteIdentifier(tableName)
const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`)
let hasUpdates = false
const foreignKeysByColName = foreignKeys.reduce((prev, curr) => {
const fk = formatFKsPragmaToSequelizeFK(curr)
const constraint = constraints.find((c) => c.field === curr.from)
if (constraint && (constraint.onDelete !== fk.onDelete || constraint.onUpdate !== fk.onUpdate)) {
fk.onDelete = constraint.onDelete
fk.onUpdate = constraint.onUpdate
hasUpdates = true
}
return { ...prev, [curr.from]: fk }
}, {})
return hasUpdates ? foreignKeysByColName : null
}
/**
* Extends the Sequelize describeTable function to include the updated foreign key constraints
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {String} tableName
* @param {Record<string, SequelizeFKObj>} updatedForeignKeys
*/
async function describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) {
const tableDescription = await queryInterface.describeTable(tableName)
const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => {
let extendedAttributes = attributes
if (updatedForeignKeys[col]) {
extendedAttributes = {
...extendedAttributes,
...updatedForeignKeys[col]
}
}
return { ...prev, [col]: extendedAttributes }
}, {})
return tableDescriptionWithFks
}
/**
* @see https://www.sqlite.org/lang_altertable.html#otheralter
* @see https://sequelize.org/docs/v6/other-topics/query-interface/#changing-and-removing-columns-in-sqlite
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {string} tableName
* @param {ConstraintUpdateObj[]} constraints
* @returns {Promise<boolean>} - Return false if no changes are needed, true otherwise
*/
async function changeConstraints(queryInterface, tableName, constraints) {
const updatedForeignKeys = await getUpdatedForeignKeys(queryInterface, tableName, constraints)
if (!updatedForeignKeys) {
return false
}
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
const quotedTableName = queryInterface.quoteIdentifier(tableName)
const backupTableName = `${tableName}_${Math.round(Math.random() * 100)}_backup`
const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName)
try {
const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, updatedForeignKeys)
const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks)
// Create the backup table
await queryInterface.createTable(backupTableName, attributes)
const attributeNames = Object.keys(attributes)
.map((attr) => queryInterface.quoteIdentifier(attr))
.join(', ')
// Copy all data from the target table to the backup table
await execQuery(`INSERT INTO ${quotedBackupTableName} SELECT ${attributeNames} FROM ${quotedTableName};`)
// Drop the old (original) table
await queryInterface.dropTable(tableName)
// Rename the backup table to the original table's name
await queryInterface.renameTable(backupTableName, tableName)
// Validate that all foreign key constraints are correct
const result = await execQuery(`PRAGMA foreign_key_check(${quotedTableName});`, {
type: queryInterface.sequelize.Sequelize.QueryTypes.SELECT
})
// There are foreign key violations, exit
if (result.length) {
return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`)
}
return true
} catch (error) {
return Promise.reject(error)
}
}
module.exports = { up, down }

View File

@ -0,0 +1,84 @@
/**
* @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.
*/
/**
* This upward migration adds an subfolder setting for OIDC redirect URIs.
* It updates existing OIDC setups to set this option to None (empty subfolder), so they continue to work as before.
* IF OIDC is not enabled, no action is taken (i.e. the subfolder is left undefined),
* so that future OIDC setups will use the default subfolder.
*
* @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('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')
const serverSettings = await getServerSettings(queryInterface, logger)
if (serverSettings.authActiveAuthMethods?.includes('openid')) {
logger.info('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')
serverSettings.authOpenIDSubfolderForRedirectURLs = ''
await updateServerSettings(queryInterface, logger, serverSettings)
} else {
logger.info('[2.17.4 migration] OIDC is not enabled, no action required')
}
logger.info('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')
}
/**
* This downward migration script removes the subfolder setting for OIDC redirect URIs.
*
* @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('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
// Remove the OIDC subfolder option from the server settings
const serverSettings = await getServerSettings(queryInterface, logger)
if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) {
logger.info('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')
delete serverSettings.authOpenIDSubfolderForRedirectURLs
await updateServerSettings(queryInterface, logger, serverSettings)
} else {
logger.info('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')
}
logger.info('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
}
async function getServerSettings(queryInterface, logger) {
const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";')
if (!result[0].length) {
logger.error('[2.17.4 migration] Server settings not found')
throw new Error('Server settings not found')
}
let serverSettings = null
try {
serverSettings = JSON.parse(result[0][0].value)
} catch (error) {
logger.error('[2.17.4 migration] Error parsing server settings:', error)
throw error
}
return serverSettings
}
async function updateServerSettings(queryInterface, logger, serverSettings) {
await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = "server-settings";', {
replacements: {
value: JSON.stringify(serverSettings)
}
})
}
module.exports = { up, down }

View File

@ -0,0 +1,74 @@
/**
* @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.5'
const migrationName = `${migrationVersion}-remove-host-from-feed-urls`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This upward migration removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables.
*
* @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}`)
logger.info(`${loggerPrefix} Removing serverAddress from Feeds table URLs`)
await queryInterface.sequelize.query(`
UPDATE Feeds
SET feedUrl = REPLACE(feedUrl, COALESCE(serverAddress, ''), ''),
imageUrl = REPLACE(imageUrl, COALESCE(serverAddress, ''), ''),
siteUrl = REPLACE(siteUrl, COALESCE(serverAddress, ''), '');
`)
logger.info(`${loggerPrefix} Removed serverAddress from Feeds table URLs`)
logger.info(`${loggerPrefix} Removing serverAddress from FeedEpisodes table URLs`)
await queryInterface.sequelize.query(`
UPDATE FeedEpisodes
SET siteUrl = REPLACE(siteUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''),
enclosureUrl = REPLACE(enclosureUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), '');
`)
logger.info(`${loggerPrefix} Removed serverAddress from FeedEpisodes table URLs`)
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This downward migration script adds the host (serverAddress) back to URL columns in the feeds and feedEpisodes tables.
*
* @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}`)
logger.info(`${loggerPrefix} Adding serverAddress back to Feeds table URLs`)
await queryInterface.sequelize.query(`
UPDATE Feeds
SET feedUrl = COALESCE(serverAddress, '') || feedUrl,
imageUrl = COALESCE(serverAddress, '') || imageUrl,
siteUrl = COALESCE(serverAddress, '') || siteUrl;
`)
logger.info(`${loggerPrefix} Added serverAddress back to Feeds table URLs`)
logger.info(`${loggerPrefix} Adding serverAddress back to FeedEpisodes table URLs`)
await queryInterface.sequelize.query(`
UPDATE FeedEpisodes
SET siteUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.siteUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId),
enclosureUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.enclosureUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId);
`)
logger.info(`${loggerPrefix} Added serverAddress back to FeedEpisodes table URLs`)
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }

View File

@ -29,6 +29,12 @@ const Logger = require('../Logger')
* @property {SeriesExpanded[]} series * @property {SeriesExpanded[]} series
* *
* @typedef {Book & BookExpandedProperties} BookExpanded * @typedef {Book & BookExpandedProperties} BookExpanded
*
* Collections use BookExpandedWithLibraryItem
* @typedef BookExpandedWithLibraryItemProperties
* @property {import('./LibraryItem')} libraryItem
*
* @typedef {BookExpanded & BookExpandedWithLibraryItemProperties} BookExpandedWithLibraryItem
*/ */
/** /**
@ -106,6 +112,9 @@ class Book extends Model {
this.updatedAt this.updatedAt
/** @type {Date} */ /** @type {Date} */
this.createdAt this.createdAt
/** @type {import('./Author')[]} - optional if expanded */
this.authors
} }
static getOldBook(libraryItemExpanded) { static getOldBook(libraryItemExpanded) {
@ -320,6 +329,32 @@ class Book extends Model {
} }
) )
} }
/**
* Comma separated array of author names
* Requires authors to be loaded
*
* @returns {string}
*/
get authorName() {
if (this.authors === undefined) {
Logger.error(`[Book] authorName: Cannot get authorName because authors are not loaded`)
return ''
}
return this.authors.map((au) => au.name).join(', ')
}
get includedAudioFiles() {
return this.audioFiles.filter((af) => !af.exclude)
}
get trackList() {
let startOffset = 0
return this.includedAudioFiles.map((af) => {
const track = structuredClone(af)
track.startOffset = startOffset
startOffset += track.duration
return track
})
}
} }
module.exports = Book module.exports = Book

View File

@ -18,6 +18,11 @@ class Collection extends Model {
this.updatedAt this.updatedAt
/** @type {Date} */ /** @type {Date} */
this.createdAt this.createdAt
// Expanded properties
/** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */
this.books
} }
/** /**
@ -107,7 +112,7 @@ class Collection extends Model {
// Map feed if found // Map feed if found
if (c.feeds?.length) { if (c.feeds?.length) {
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0]) collectionExpanded.rssFeed = c.feeds[0].toOldJSON()
} }
return collectionExpanded return collectionExpanded
@ -115,6 +120,39 @@ class Collection extends Model {
.filter((c) => c) .filter((c) => c)
} }
/**
*
* @param {string} collectionId
* @returns {Promise<Collection>}
*/
static async getExpandedById(collectionId) {
return this.findByPk(collectionId, {
include: [
{
model: this.sequelize.models.book,
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
}
],
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
})
}
/** /**
* Get old collection from Collection * Get old collection from Collection
* @param {Collection} collectionExpanded * @param {Collection} collectionExpanded
@ -219,6 +257,34 @@ class Collection extends Model {
Collection.belongsTo(library) Collection.belongsTo(library)
} }
/**
* Get all books in collection expanded with library item
*
* @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}
*/
getBooksExpandedWithLibraryItem() {
return this.getBooks({
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [Sequelize.literal('`collectionBook.order` ASC')]
})
}
/** /**
* Get old collection toJSONExpanded, items filtered for user permissions * Get old collection toJSONExpanded, items filtered for user permissions
* *
@ -282,7 +348,7 @@ class Collection extends Model {
if (include?.includes('rssfeed')) { if (include?.includes('rssfeed')) {
const feeds = await this.getFeeds() const feeds = await this.getFeeds()
if (feeds?.length) { if (feeds?.length) {
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0]) collectionExpanded.rssFeed = feeds[0].toOldJSON()
} }
} }

View File

@ -1,6 +1,22 @@
const Path = require('path')
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const oldFeed = require('../objects/Feed') const Logger = require('../Logger')
const areEquivalent = require('../utils/areEquivalent')
const RSS = require('../libs/rss')
/**
* @typedef FeedOptions
* @property {boolean} preventIndexing
* @property {string} ownerName
* @property {string} ownerEmail
*/
/**
* @typedef FeedExpandedProperties
* @property {import('./FeedEpisode')} feedEpisodes
*
* @typedef {Feed & FeedExpandedProperties} FeedExpanded
*/
class Feed extends Model { class Feed extends Model {
constructor(values, options) { constructor(values, options) {
@ -50,210 +66,288 @@ class Feed extends Model {
this.createdAt this.createdAt
/** @type {Date} */ /** @type {Date} */
this.updatedAt this.updatedAt
}
static async getOldFeeds() { // Expanded properties
const feeds = await this.findAll({
include: { /** @type {import('./FeedEpisode')[]} - only set if expanded */
model: this.sequelize.models.feedEpisode this.feedEpisodes
}
})
return feeds.map((f) => this.getOldFeed(f))
} }
/** /**
* Get old feed from Feed and optionally Feed with FeedEpisodes * @param {string} feedId
* @param {Feed} feedExpanded * @returns {Promise<boolean>} - true if feed was removed
* @returns {oldFeed}
*/ */
static getOldFeed(feedExpanded) { static async removeById(feedId) {
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) return (
return new oldFeed({ (await this.destroy({
id: feedExpanded.id, where: {
slug: feedExpanded.slug, id: feedId
userId: feedExpanded.userId, }
entityType: feedExpanded.entityType, })) > 0
entityId: feedExpanded.entityId, )
entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null,
coverPath: feedExpanded.coverPath || null,
meta: {
title: feedExpanded.title,
description: feedExpanded.description,
author: feedExpanded.author,
imageUrl: feedExpanded.imageURL,
feedUrl: feedExpanded.feedURL,
link: feedExpanded.siteURL,
explicit: feedExpanded.explicit,
type: feedExpanded.podcastType,
language: feedExpanded.language,
preventIndexing: feedExpanded.preventIndexing,
ownerName: feedExpanded.ownerName,
ownerEmail: feedExpanded.ownerEmail
},
serverAddress: feedExpanded.serverAddress,
feedUrl: feedExpanded.feedURL,
episodes: episodes || [],
createdAt: feedExpanded.createdAt.valueOf(),
updatedAt: feedExpanded.updatedAt.valueOf()
})
}
static removeById(feedId) {
return this.destroy({
where: {
id: feedId
}
})
} }
/** /**
* Find all library item ids that have an open feed (used in library filter) *
* @returns {Promise<string[]>} array of library item ids * @param {string} userId
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} [feedOptions=null]
*
* @returns {Feed}
*/ */
static async findAllLibraryItemIds() { static getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions = null) {
const feeds = await this.findAll({ const media = libraryItem.media
attributes: ['entityId'],
where: {
entityType: 'libraryItem'
}
})
return feeds.map((f) => f.entityId).filter((f) => f) || []
}
/** let entityUpdatedAt = libraryItem.updatedAt
* Find feed where and return oldFeed
* @param {Object} where sequelize where object
* @returns {Promise<oldFeed>} oldFeed
*/
static async findOneOld(where) {
if (!where) return null
const feedExpanded = await this.findOne({
where,
include: {
model: this.sequelize.models.feedEpisode
}
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
/** // Podcast feeds should use the most recent episode updatedAt if more recent
* Find feed and return oldFeed if (libraryItem.mediaType === 'podcast') {
* @param {string} id entityUpdatedAt = libraryItem.media.podcastEpisodes.reduce((mostRecent, episode) => {
* @returns {Promise<oldFeed>} oldFeed return episode.updatedAt > mostRecent ? episode.updatedAt : mostRecent
*/ }, entityUpdatedAt)
static async findByPkOld(id) {
if (!id) return null
const feedExpanded = await this.findByPk(id, {
include: {
model: this.sequelize.models.feedEpisode
}
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
static async fullCreateFromOld(oldFeed) {
const feedObj = this.getFromOld(oldFeed)
const newFeed = await this.create(feedObj)
if (oldFeed.episodes?.length) {
for (const oldFeedEpisode of oldFeed.episodes) {
const feedEpisode = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
feedEpisode.feedId = newFeed.id
await this.sequelize.models.feedEpisode.create(feedEpisode)
}
} }
const feedObj = {
slug,
entityType: 'libraryItem',
entityId: libraryItem.id,
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: media.coverPath ? `/feed/${slug}/cover${Path.extname(media.coverPath)}` : `/Logo.png`,
siteURL: `/item/${libraryItem.id}`,
title: media.title,
description: media.description,
author: libraryItem.mediaType === 'podcast' ? media.author : media.authorName,
podcastType: libraryItem.mediaType === 'podcast' ? media.podcastType : 'serial',
language: media.language,
explicit: media.explicit,
coverPath: media.coverPath,
userId
}
if (feedOptions) {
feedObj.preventIndexing = feedOptions.preventIndexing
feedObj.ownerName = feedOptions.ownerName
feedObj.ownerEmail = feedOptions.ownerEmail
}
return feedObj
} }
static async fullUpdateFromOld(oldFeed) { /**
const oldFeedEpisodes = oldFeed.episodes || [] *
const feedObj = this.getFromOld(oldFeed) * @param {string} userId
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} feedOptions
*
* @returns {Promise<FeedExpanded>}
*/
static async createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) {
const feedObj = this.getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
const existingFeed = await this.findByPk(feedObj.id, { /** @type {typeof import('./FeedEpisode')} */
include: this.sequelize.models.feedEpisode const feedEpisodeModel = this.sequelize.models.feedEpisode
})
if (!existingFeed) return false
let hasUpdates = false const transaction = await this.sequelize.transaction()
try {
const feed = await this.create(feedObj, { transaction })
// Remove and update existing feed episodes if (libraryItem.mediaType === 'podcast') {
for (const feedEpisode of existingFeed.feedEpisodes) { feed.feedEpisodes = await feedEpisodeModel.createFromPodcastEpisodes(libraryItem, feed, slug, transaction)
const oldFeedEpisode = oldFeedEpisodes.find((ep) => ep.id === feedEpisode.id)
// Episode removed
if (!oldFeedEpisode) {
feedEpisode.destroy()
} else { } else {
let episodeHasUpdates = false feed.feedEpisodes = await feedEpisodeModel.createFromAudiobookTracks(libraryItem, feed, slug, transaction)
const oldFeedEpisodeCleaned = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
for (const key in oldFeedEpisodeCleaned) {
if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) {
episodeHasUpdates = true
}
}
if (episodeHasUpdates) {
await feedEpisode.update(oldFeedEpisodeCleaned)
hasUpdates = true
}
} }
await transaction.commit()
return feed
} catch (error) {
Logger.error(`[Feed] Error creating feed for library item ${libraryItem.id}`, error)
await transaction.rollback()
return null
} }
// Add new feed episodes
for (const episode of oldFeedEpisodes) {
if (!existingFeed.feedEpisodes.some((fe) => fe.id === episode.id)) {
await this.sequelize.models.feedEpisode.createFromOld(feedObj.id, episode)
hasUpdates = true
}
}
let feedHasUpdates = false
for (const key in feedObj) {
let existingValue = existingFeed[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(existingValue, feedObj[key])) {
feedHasUpdates = true
}
}
if (feedHasUpdates) {
await existingFeed.update(feedObj)
hasUpdates = true
}
return hasUpdates
} }
static getFromOld(oldFeed) { /**
const oldFeedMeta = oldFeed.meta || {} *
* @param {string} userId
* @param {import('./Collection')} collectionExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} [feedOptions=null]
*
* @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }}
*/
static getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions = null) {
const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length)
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
}, collectionExpanded.updatedAt)
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => {
const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name)
return authorNames.concat(bookAuthorsToAdd)
}, [])
let author = allBookAuthorNames.slice(0, 3).join(', ')
if (allBookAuthorNames.length > 3) {
author += ' & more'
}
const feedObj = {
slug,
entityType: 'collection',
entityId: collectionExpanded.id,
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
siteURL: `/collection/${collectionExpanded.id}`,
title: collectionExpanded.name,
description: collectionExpanded.description || '',
author,
podcastType: 'serial',
explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit
coverPath: firstBookWithCover?.coverPath || null,
userId
}
if (feedOptions) {
feedObj.preventIndexing = feedOptions.preventIndexing
feedObj.ownerName = feedOptions.ownerName
feedObj.ownerEmail = feedOptions.ownerEmail
}
return { return {
id: oldFeed.id, feedObj,
slug: oldFeed.slug, booksWithTracks
entityType: oldFeed.entityType,
entityId: oldFeed.entityId,
entityUpdatedAt: oldFeed.entityUpdatedAt,
serverAddress: oldFeed.serverAddress,
feedURL: oldFeed.feedUrl,
coverPath: oldFeed.coverPath || null,
imageURL: oldFeedMeta.imageUrl,
siteURL: oldFeedMeta.link,
title: oldFeedMeta.title,
description: oldFeedMeta.description,
author: oldFeedMeta.author,
podcastType: oldFeedMeta.type || null,
language: oldFeedMeta.language || null,
ownerName: oldFeedMeta.ownerName || null,
ownerEmail: oldFeedMeta.ownerEmail || null,
explicit: !!oldFeedMeta.explicit,
preventIndexing: !!oldFeedMeta.preventIndexing,
userId: oldFeed.userId
} }
} }
getEntity(options) { /**
if (!this.entityType) return Promise.resolve(null) *
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}` * @param {string} userId
return this[mixinMethodName](options) * @param {import('./Collection')} collectionExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} feedOptions
*
* @returns {Promise<FeedExpanded>}
*/
static async createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions) {
const { feedObj, booksWithTracks } = this.getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
/** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode
const transaction = await this.sequelize.transaction()
try {
const feed = await this.create(feedObj, { transaction })
feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)
await transaction.commit()
return feed
} catch (error) {
Logger.error(`[Feed] Error creating feed for collection ${collectionExpanded.id}`, error)
await transaction.rollback()
return null
}
}
/**
*
* @param {string} userId
* @param {import('./Series')} seriesExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} [feedOptions=null]
*
* @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }}
*/
static getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions = null) {
const booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length)
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
}, seriesExpanded.updatedAt)
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => {
const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name)
return authorNames.concat(bookAuthorsToAdd)
}, [])
let author = allBookAuthorNames.slice(0, 3).join(', ')
if (allBookAuthorNames.length > 3) {
author += ' & more'
}
const feedObj = {
slug,
entityType: 'series',
entityId: seriesExpanded.id,
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
siteURL: `/library/${booksWithTracks[0].libraryItem.libraryId}/series/${seriesExpanded.id}`,
title: seriesExpanded.name,
description: seriesExpanded.description || '',
author,
podcastType: 'serial',
explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit
coverPath: firstBookWithCover?.coverPath || null,
userId
}
if (feedOptions) {
feedObj.preventIndexing = feedOptions.preventIndexing
feedObj.ownerName = feedOptions.ownerName
feedObj.ownerEmail = feedOptions.ownerEmail
}
return {
feedObj,
booksWithTracks
}
}
/**
*
* @param {string} userId
* @param {import('./Series')} seriesExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} feedOptions
*
* @returns {Promise<FeedExpanded>}
*/
static async createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions) {
const { feedObj, booksWithTracks } = this.getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
/** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode
const transaction = await this.sequelize.transaction()
try {
const feed = await this.create(feedObj, { transaction })
feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)
await transaction.commit()
return feed
} catch (error) {
Logger.error(`[Feed] Error creating feed for series ${seriesExpanded.id}`, error)
await transaction.rollback()
return null
}
} }
/** /**
@ -274,7 +368,7 @@ class Feed extends Model {
}, },
slug: DataTypes.STRING, slug: DataTypes.STRING,
entityType: DataTypes.STRING, entityType: DataTypes.STRING,
entityId: DataTypes.UUIDV4, entityId: DataTypes.UUID,
entityUpdatedAt: DataTypes.DATE, entityUpdatedAt: DataTypes.DATE,
serverAddress: DataTypes.STRING, serverAddress: DataTypes.STRING,
feedURL: DataTypes.STRING, feedURL: DataTypes.STRING,
@ -369,6 +463,192 @@ class Feed extends Model {
} }
}) })
} }
/**
*
* @returns {Promise<FeedExpanded>}
*/
async updateFeedForEntity() {
/** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode
let feedObj = null
let feedEpisodeCreateFunc = null
let feedEpisodeCreateFuncEntity = null
if (this.entityType === 'libraryItem') {
/** @type {typeof import('./LibraryItem')} */
const libraryItemModel = this.sequelize.models.libraryItem
const itemExpanded = await libraryItemModel.getExpandedById(this.entityId)
feedObj = Feed.getFeedObjForLibraryItem(this.userId, itemExpanded, this.slug, this.serverAddress)
feedEpisodeCreateFuncEntity = itemExpanded
if (itemExpanded.mediaType === 'podcast') {
feedEpisodeCreateFunc = feedEpisodeModel.createFromPodcastEpisodes.bind(feedEpisodeModel)
} else {
feedEpisodeCreateFunc = feedEpisodeModel.createFromAudiobookTracks.bind(feedEpisodeModel)
}
} else if (this.entityType === 'collection') {
/** @type {typeof import('./Collection')} */
const collectionModel = this.sequelize.models.collection
const collectionExpanded = await collectionModel.getExpandedById(this.entityId)
const feedObjData = Feed.getFeedObjForCollection(this.userId, collectionExpanded, this.slug, this.serverAddress)
feedObj = feedObjData.feedObj
feedEpisodeCreateFuncEntity = feedObjData.booksWithTracks
feedEpisodeCreateFunc = feedEpisodeModel.createFromBooks.bind(feedEpisodeModel)
} else if (this.entityType === 'series') {
/** @type {typeof import('./Series')} */
const seriesModel = this.sequelize.models.series
const seriesExpanded = await seriesModel.getExpandedById(this.entityId)
const feedObjData = Feed.getFeedObjForSeries(this.userId, seriesExpanded, this.slug, this.serverAddress)
feedObj = feedObjData.feedObj
feedEpisodeCreateFuncEntity = feedObjData.booksWithTracks
feedEpisodeCreateFunc = feedEpisodeModel.createFromBooks.bind(feedEpisodeModel)
} else {
Logger.error(`[Feed] Invalid entity type ${this.entityType} for feed ${this.id}`)
return null
}
const transaction = await this.sequelize.transaction()
try {
const updatedFeed = await this.update(feedObj, { transaction })
// Remove existing feed episodes
await feedEpisodeModel.destroy({
where: {
feedId: this.id
},
transaction
})
// Create new feed episodes
updatedFeed.feedEpisodes = await feedEpisodeCreateFunc(feedEpisodeCreateFuncEntity, updatedFeed, this.slug, transaction)
await transaction.commit()
return updatedFeed
} catch (error) {
Logger.error(`[Feed] Error updating feed ${this.entityId}`, error)
await transaction.rollback()
return null
}
}
getEntity(options) {
if (!this.entityType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
return this[mixinMethodName](options)
}
/**
*
* @param {string} hostPrefix
*/
buildXml(hostPrefix) {
const blockTags = [{ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }]
const rssData = {
title: this.title,
description: this.description || '',
generator: 'Audiobookshelf',
feed_url: `${hostPrefix}${this.feedURL}`,
site_url: `${hostPrefix}${this.siteURL}`,
image_url: `${hostPrefix}${this.imageURL}`,
custom_namespaces: {
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
psc: 'http://podlove.org/simple-chapters',
podcast: 'https://podcastindex.org/namespace/1.0',
googleplay: 'http://www.google.com/schemas/play-podcasts/1.0'
},
custom_elements: [
{ language: this.language || 'en' },
{ author: this.author || 'advplyr' },
{ 'itunes:author': this.author || 'advplyr' },
{ 'itunes:summary': this.description || '' },
{ 'itunes:type': this.podcastType },
{
'itunes:image': {
_attr: {
href: `${hostPrefix}${this.imageURL}`
}
}
},
{
'itunes:owner': [{ 'itunes:name': this.ownerName || this.author || '' }, { 'itunes:email': this.ownerEmail || '' }]
},
{ 'itunes:explicit': !!this.explicit },
...(this.preventIndexing ? blockTags : [])
]
}
const rssfeed = new RSS(rssData)
this.feedEpisodes.forEach((ep) => {
rssfeed.item(ep.getRSSData(hostPrefix))
})
return rssfeed.xml()
}
/**
*
* @param {string} id
* @returns {string}
*/
getEpisodePath(id) {
const episode = this.feedEpisodes.find((ep) => ep.id === id)
if (!episode) return null
return episode.filePath
}
toOldJSON() {
const episodes = this.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
return {
id: this.id,
slug: this.slug,
userId: this.userId,
entityType: this.entityType,
entityId: this.entityId,
entityUpdatedAt: this.entityUpdatedAt?.valueOf() || null,
coverPath: this.coverPath || null,
meta: {
title: this.title,
description: this.description,
author: this.author,
imageUrl: this.imageURL,
feedUrl: this.feedURL,
link: this.siteURL,
explicit: this.explicit,
type: this.podcastType,
language: this.language,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
},
serverAddress: this.serverAddress,
feedUrl: this.feedURL,
episodes: episodes || [],
createdAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
}
}
toOldJSONMinified() {
return {
id: this.id,
entityType: this.entityType,
entityId: this.entityId,
feedUrl: this.feedURL,
meta: {
title: this.title,
description: this.description,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
}
}
}
} }
module.exports = Feed module.exports = Feed

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