mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-02-11 15:59:28 +01:00
Merge branch 'master' into multi-select-keyboard-navigation
This commit is contained in:
commit
13f73cc79d
33
.github/pull_request_template.md
vendored
Normal file
33
.github/pull_request_template.md
vendored
Normal 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
1
.gitignore
vendored
@ -7,6 +7,7 @@
|
||||
/podcasts/
|
||||
/media/
|
||||
/metadata/
|
||||
/plugins/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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">
|
||||
<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" />
|
||||
|
@ -17,7 +17,7 @@
|
||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
|
||||
<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)">
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
|
@ -37,18 +37,18 @@
|
||||
<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="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 class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></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>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -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'">
|
||||
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
||||
</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 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 -->
|
||||
<template v-if="selectedSeries">
|
||||
<p class="pl-2 text-base md:text-lg">
|
||||
@ -265,6 +268,9 @@ export default {
|
||||
isPodcastLatestPage() {
|
||||
return this.$route.name === 'library-library-podcast-latest'
|
||||
},
|
||||
isPodcastDownloadQueuePage() {
|
||||
return this.$route.name === 'library-library-podcast-download-queue'
|
||||
},
|
||||
isAuthorsPage() {
|
||||
return this.page === 'authors'
|
||||
},
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<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="toolbar" aria-orientation="vertical" aria-label="Config Sidebar">
|
||||
<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">
|
||||
<span class="material-symbols text-2xl">arrow_back</span>
|
||||
</div>
|
||||
|
@ -53,7 +53,6 @@
|
||||
@showBookmarks="showBookmarks"
|
||||
@showSleepTimer="showSleepTimerModal = true"
|
||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||
@showPlayerSettings="showPlayerSettingsModal = true"
|
||||
/>
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||
@ -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-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
|
||||
|
||||
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -81,7 +78,6 @@ export default {
|
||||
currentTime: 0,
|
||||
showSleepTimerModal: false,
|
||||
showPlayerQueueItemsModal: false,
|
||||
showPlayerSettingsModal: false,
|
||||
sleepTimerSet: false,
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimerType: null,
|
||||
|
@ -1,9 +1,9 @@
|
||||
<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 -->
|
||||
<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'">
|
||||
<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" />
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
||||
<article class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
||||
<nuxt-link :to="`/author/${author?.id}`">
|
||||
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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 ' }">
|
||||
<!-- 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">
|
||||
@ -14,21 +14,21 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<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>
|
||||
<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 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 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 -->
|
||||
<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>
|
||||
<!-- 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' }">
|
||||
<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>
|
||||
|
||||
<!-- Series sequence -->
|
||||
@ -114,7 +114,7 @@
|
||||
|
||||
<!-- 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' }">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodes }}</p>
|
||||
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfEpisodes">{{ numEpisodes }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 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 :style="{ fontSize: 0.9 + 'em' }">
|
||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
||||
<p cy-id="title" ref="displayTitle" aria-hidden="true" class="truncate">{{ displayTitle }}</p>
|
||||
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@ -138,7 +138,7 @@
|
||||
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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 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">
|
||||
@ -7,12 +7,12 @@
|
||||
</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">
|
||||
<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 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>
|
||||
</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 class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
||||
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
<p cy-id="standardBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center">
|
||||
<p cy-id="detailBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
<p cy-id="detailBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<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" />
|
||||
</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"></span>
|
||||
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
|
||||
</div>
|
||||
</button>
|
||||
</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>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
|
@ -1,28 +1,30 @@
|
||||
<template>
|
||||
<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">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
</span>
|
||||
<div class="relative h-7">
|
||||
<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="flex items-center justify-between">
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
</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>
|
||||
</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">
|
||||
<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">
|
||||
<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">
|
||||
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
|
||||
</div>
|
||||
<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>
|
||||
<!-- selected checkmark icon -->
|
||||
<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>
|
||||
</template>
|
||||
</ul>
|
||||
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
|
||||
<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="menuitem" @click="sublist = null">
|
||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-symbols text-2xl">arrow_left</span>
|
||||
</div>
|
||||
@ -40,13 +42,13 @@
|
||||
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
|
||||
</div>
|
||||
</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">
|
||||
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<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">
|
||||
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
||||
</div>
|
||||
|
@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<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="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>
|
||||
</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">
|
||||
<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">
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</li>
|
||||
</template>
|
||||
|
@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<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="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>
|
||||
</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">
|
||||
<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">
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</li>
|
||||
</template>
|
||||
|
@ -121,6 +121,8 @@ export default {
|
||||
|
||||
var img = document.createElement('img')
|
||||
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.style.objectFit = showCoverBg ? 'contain' : 'cover'
|
||||
|
||||
|
@ -54,8 +54,7 @@ export default {
|
||||
options: {
|
||||
provider: undefined,
|
||||
overrideDetails: true,
|
||||
overrideCover: true,
|
||||
overrideDefaults: true
|
||||
overrideCover: true
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -99,8 +98,8 @@ export default {
|
||||
init() {
|
||||
// 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
|
||||
if (!this.options.provider || this.options.lastUsedLibrary != this.currentLibraryId) {
|
||||
this.options.lastUsedLibrary = this.currentLibraryId
|
||||
if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
|
||||
this.lastUsedLibrary = this.currentLibraryId
|
||||
this.options.provider = this.libraryProvider
|
||||
}
|
||||
},
|
||||
|
@ -1,12 +1,12 @@
|
||||
<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" />
|
||||
|
||||
<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>
|
||||
</button>
|
||||
<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 />
|
||||
<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 />
|
||||
@ -126,6 +126,9 @@ export default {
|
||||
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
this.$store.commit('setOpenModal', this.name)
|
||||
|
||||
// Set focus to the modal content
|
||||
this.content.focus()
|
||||
},
|
||||
setHide() {
|
||||
if (this.content) this.content.style.transform = 'scale(0)'
|
||||
|
@ -59,12 +59,19 @@ export default {
|
||||
setJumpBackwardAmount(val) {
|
||||
this.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() {
|
||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
||||
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
||||
this.settingsUpdated()
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
|
||||
<template #outer>
|
||||
<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>
|
||||
</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">
|
||||
@ -13,7 +13,7 @@
|
||||
</p>
|
||||
<div class="custom-text" v-html="getChangelog(release)" />
|
||||
</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>
|
||||
</div>
|
||||
</modals-modal>
|
||||
|
@ -18,6 +18,23 @@
|
||||
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||
<div v-if="description" dir="auto" class="default-style" v-html="description" />
|
||||
<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>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@ -54,7 +71,7 @@ export default {
|
||||
return this.episode.description || ''
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
@ -65,6 +82,14 @@ export default {
|
||||
podcastAuthor() {
|
||||
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() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
}
|
||||
|
@ -10,9 +10,9 @@
|
||||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
|
||||
|
||||
<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 v-if="currentFeed.meta" class="mt-5">
|
||||
@ -111,8 +111,11 @@ export default {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
feedUrl() {
|
||||
return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : ''
|
||||
},
|
||||
demoFeedUrl() {
|
||||
return `${window.origin}/feed/${this.newFeedSlug}`
|
||||
return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}`
|
||||
},
|
||||
isHttp() {
|
||||
return window.origin.startsWith('http://')
|
||||
|
@ -5,8 +5,8 @@
|
||||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
|
||||
|
||||
<div class="w-full relative">
|
||||
<ui-text-input v-model="feed.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>
|
||||
<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(feedUrl)">content_copy</span>
|
||||
</div>
|
||||
|
||||
<div v-if="feed.meta" class="mt-5">
|
||||
@ -70,6 +70,9 @@ export default {
|
||||
},
|
||||
_feed() {
|
||||
return this.feed || {}
|
||||
},
|
||||
feedUrl() {
|
||||
return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -37,7 +37,7 @@
|
||||
</ui-tooltip>
|
||||
|
||||
<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>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
@ -64,6 +64,8 @@
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
|
||||
|
||||
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -96,6 +98,7 @@ export default {
|
||||
audioEl: null,
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
showPlayerSettingsModal: false,
|
||||
currentTime: 0,
|
||||
duration: 0
|
||||
}
|
||||
@ -315,6 +318,9 @@ export default {
|
||||
if (!this.chapters.length) return
|
||||
this.showChaptersModal = !this.showChaptersModal
|
||||
},
|
||||
showPlayerSettings() {
|
||||
this.showPlayerSettingsModal = !this.showPlayerSettingsModal
|
||||
},
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<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 />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
@ -7,7 +7,7 @@
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide : $strings.LabelYearReviewShow }}</ui-btn>
|
||||
</div>
|
||||
@ -16,17 +16,22 @@
|
||||
<div v-if="showYearInReview">
|
||||
<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 -->
|
||||
<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="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
|
||||
</ui-btn>
|
||||
<!-- 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" />
|
||||
<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>
|
||||
<div class="flex-grow" />
|
||||
|
||||
@ -36,7 +41,7 @@
|
||||
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- 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="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
@ -46,23 +51,23 @@
|
||||
<!-- your year in review short -->
|
||||
<div class="w-full max-w-[800px] mx-auto my-4">
|
||||
<!-- 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" />
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<!-- 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="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
|
||||
</ui-btn>
|
||||
<!-- 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" />
|
||||
<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>
|
||||
<div class="flex-grow" />
|
||||
|
||||
@ -72,7 +77,7 @@
|
||||
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- 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="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
@ -88,6 +93,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showYearInReview: false,
|
||||
availableYears: [],
|
||||
yearInReviewYear: 0,
|
||||
yearInReviewVariant: 0,
|
||||
yearInReviewServerVariant: 0,
|
||||
@ -100,6 +106,9 @@ export default {
|
||||
computed: {
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -112,25 +121,57 @@ export default {
|
||||
shareYearInReviewShort() {
|
||||
this.$refs.yearInReviewShort.share()
|
||||
},
|
||||
yearInReviewYearChanged() {
|
||||
this.$nextTick(() => {
|
||||
this.refreshYearInReview()
|
||||
this.refreshYearInReviewServer()
|
||||
})
|
||||
},
|
||||
refreshYearInReviewServer() {
|
||||
this.$refs.yearInReviewServer.refresh()
|
||||
if (this.$refs.yearInReviewServer != null) {
|
||||
this.$refs.yearInReviewServer.refresh()
|
||||
}
|
||||
},
|
||||
refreshYearInReview() {
|
||||
this.$refs.yearInReview.refresh()
|
||||
this.$refs.yearInReviewShort.refresh()
|
||||
if (this.$refs.yearInReview != null && this.$refs.yearInReviewShort != null) {
|
||||
this.$refs.yearInReview.refresh()
|
||||
this.$refs.yearInReviewShort.refresh()
|
||||
}
|
||||
},
|
||||
clickShowYearInReview() {
|
||||
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() {
|
||||
this.yearInReviewYear = new Date().getFullYear()
|
||||
|
||||
// When not December show previous year
|
||||
if (new Date().getMonth() < 11) {
|
||||
this.yearInReviewYear--
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.availableYears = this.getAvailableYears()
|
||||
|
||||
if (typeof navigator.share !== 'undefined' && navigator.share) {
|
||||
this.showShareButton = true
|
||||
} else {
|
||||
|
@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<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 />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
@ -120,6 +120,7 @@ export default {
|
||||
this.users = res.users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
this.$emit('numUsers', this.users.length)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
|
@ -25,7 +25,6 @@
|
||||
</template>
|
||||
</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">
|
||||
<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" />
|
||||
@ -515,6 +514,10 @@ export default {
|
||||
}
|
||||
},
|
||||
filterSortChanged() {
|
||||
// Save filterKey and sortKey to local storage
|
||||
localStorage.setItem('podcastEpisodesFilter', this.filterKey)
|
||||
localStorage.setItem('podcastEpisodesSortBy', this.sortKey + (this.sortDesc ? '-desc' : ''))
|
||||
|
||||
this.init()
|
||||
},
|
||||
refresh() {
|
||||
@ -537,6 +540,11 @@ export default {
|
||||
}
|
||||
},
|
||||
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.initListeners()
|
||||
this.init()
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
||||
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
|
||||
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<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"></span>
|
||||
</button>
|
||||
<div v-else class="h-full w-full flex items-center justify-center">
|
||||
@ -10,12 +10,12 @@
|
||||
</slot>
|
||||
|
||||
<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-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>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
v-if="mouseoverItemIndex === index"
|
||||
:key="`subitems-${index}`"
|
||||
@ -25,14 +25,14 @@
|
||||
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
|
||||
: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>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<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>
|
||||
<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="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
||||
<span v-if="selectedSubtext">: </span>
|
||||
@ -13,9 +13,9 @@
|
||||
</button>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
|
||||
<span v-if="item.subtext">: </span>
|
||||
@ -119,4 +119,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<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" />
|
||||
@ -28,7 +28,8 @@ export default {
|
||||
size: {
|
||||
type: Number,
|
||||
default: 9
|
||||
}
|
||||
},
|
||||
ariaLabel: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
@ -4,7 +4,7 @@
|
||||
type="button"
|
||||
: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"
|
||||
aria-haspopup="listbox"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="showMenu"
|
||||
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
|
||||
@click.stop.prevent="clickShowMenu"
|
||||
@ -16,9 +16,9 @@
|
||||
</button>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<ui-library-icon :icon="library.icon" class="mr-1.5" />
|
||||
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<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" />
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@ -20,6 +20,7 @@ export default {
|
||||
},
|
||||
disabled: Boolean,
|
||||
labeledBy: String,
|
||||
label: String,
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md'
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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"></span>
|
||||
<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"></span>
|
||||
|
@ -3,10 +3,10 @@
|
||||
<div class="flex items-center py-3e">
|
||||
<slot />
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -71,8 +71,6 @@ export default {
|
||||
this.showSeriesForm = true
|
||||
},
|
||||
submitSeriesForm() {
|
||||
console.log('submit series form', this.value, this.selectedSeries)
|
||||
|
||||
if (!this.selectedSeries.name) {
|
||||
this.$toast.error('Must enter a series')
|
||||
return
|
||||
|
@ -28,10 +28,8 @@ export default {
|
||||
var validOtherFiles = []
|
||||
var ignoredFiles = []
|
||||
files.forEach((file) => {
|
||||
// var filetype = this.checkFileType(file.name)
|
||||
if (!file.filetype) ignoredFiles.push(file)
|
||||
else {
|
||||
// file.filetype = filetype
|
||||
if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
|
||||
else validOtherFiles.push(file)
|
||||
}
|
||||
@ -165,7 +163,7 @@ export default {
|
||||
|
||||
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) {
|
||||
audiobook.title = dirs.pop()
|
||||
if (dirs.length > 1) {
|
||||
@ -189,7 +187,7 @@ export default {
|
||||
var firstAudioFile = podcast.itemFiles[0]
|
||||
if (!firstAudioFile.filepath) return podcast // No path
|
||||
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) {
|
||||
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
|
||||
} else {
|
||||
@ -212,13 +210,15 @@ export default {
|
||||
}
|
||||
var ignoredFiles = itemData.ignoredFiles
|
||||
var index = 1
|
||||
var items = itemData.items.filter((ab) => {
|
||||
if (!ab.itemFiles.length) {
|
||||
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++))
|
||||
var items = itemData.items
|
||||
.filter((ab) => {
|
||||
if (!ab.itemFiles.length) {
|
||||
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 {
|
||||
items,
|
||||
ignoredFiles
|
||||
@ -259,7 +259,7 @@ export default {
|
||||
|
||||
otherFiles.forEach((file) => {
|
||||
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) {
|
||||
findItem.otherFiles.push(file)
|
||||
} else {
|
||||
@ -270,18 +270,18 @@ export default {
|
||||
var items = []
|
||||
var index = 1
|
||||
// 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) => {
|
||||
return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++)
|
||||
})
|
||||
} else {
|
||||
items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
|
||||
items = Object.values(itemMap).map((i) => this.cleanItem(i, mediaType, index++))
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
ignoredFiles: ignoredFiles
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.16.2",
|
||||
"version": "2.17.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.16.2",
|
||||
"version": "2.17.5",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.16.2",
|
||||
"version": "2.17.5",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
@ -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" />
|
||||
<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" />
|
||||
|
||||
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
||||
@ -164,6 +178,27 @@ export default {
|
||||
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: {
|
||||
@ -325,7 +360,8 @@ export default {
|
||||
},
|
||||
init() {
|
||||
this.newAuthSettings = {
|
||||
...this.authSettings
|
||||
...this.authSettings,
|
||||
authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs
|
||||
}
|
||||
this.enableLocalAuth = this.authMethods.includes('local')
|
||||
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
||||
|
@ -6,9 +6,9 @@
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2>
|
||||
</div>
|
||||
<div 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-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsStoreCoversWithItemHelp" class="flex items-end py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsStoreCoversWithItem" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreCoversWithItemHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
@ -16,9 +16,9 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div 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-tooltip :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsStoreMetadataWithItemHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsStoreMetadataWithItem" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-store-metadata-with-items">{{ $strings.LabelSettingsStoreMetadataWithItem }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
@ -26,9 +26,9 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div 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-tooltip :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsSortingIgnorePrefixesHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsSortingIgnorePrefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-sorting-ignore-prefixes">{{ $strings.LabelSettingsSortingIgnorePrefixes }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
@ -42,18 +42,13 @@
|
||||
</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">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
|
||||
</div>
|
||||
|
||||
<div 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-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsParseSubtitlesHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsParseSubtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsParseSubtitlesHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
@ -61,9 +56,9 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div 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-tooltip :text="$strings.LabelSettingsFindCoversHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsFindCoversHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsFindCovers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsFindCoversHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</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" />
|
||||
</div>
|
||||
|
||||
<div 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-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsPreferMatchedMetadataHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsPreferMatchedMetadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
@ -85,15 +80,29 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsEnableWatcherHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsEnableWatcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsEnableWatcherHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</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 class="flex-1">
|
||||
@ -324,21 +333,21 @@ export default {
|
||||
},
|
||||
updateServerSettings(payload) {
|
||||
this.updatingServerSettings = true
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then(() => {
|
||||
this.updatingServerSettings = false
|
||||
this.$store.dispatch('updateServerSettings', payload).then((response) => {
|
||||
this.updatingServerSettings = false
|
||||
|
||||
if (payload.language) {
|
||||
// Updating language after save allows for re-rendering
|
||||
this.$setLanguageCode(payload.language)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
if (response.error) {
|
||||
console.error('Failed to update server settins', response.error)
|
||||
this.$toast.error(response.error)
|
||||
this.initServerSettings()
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.language) {
|
||||
// Updating language after save allows for re-rendering
|
||||
this.$setLanguageCode(payload.language)
|
||||
}
|
||||
})
|
||||
},
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
|
@ -126,7 +126,7 @@ export default {
|
||||
},
|
||||
coverUrl(feed) {
|
||||
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
|
||||
return `${feed.feedUrl}/cover`
|
||||
return `${this.$config.routerBasePath}${feed.feedUrl}/cover`
|
||||
},
|
||||
async loadFeeds() {
|
||||
const data = await this.$axios.$get(`/api/feeds`).catch((err) => {
|
||||
|
@ -2,6 +2,10 @@
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderUsers">
|
||||
<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">
|
||||
<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>
|
||||
@ -13,7 +17,7 @@
|
||||
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
|
||||
</template>
|
||||
|
||||
<tables-users-table class="pt-2" @edit="setShowUserModal" />
|
||||
<tables-users-table class="pt-2" @edit="setShowUserModal" @numUsers="(count) => (numUsers = count)" />
|
||||
</app-settings-content>
|
||||
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
||||
</div>
|
||||
@ -29,7 +33,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
selectedAccount: null,
|
||||
showAccountModal: false
|
||||
showAccountModal: false,
|
||||
numUsers: 0
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
|
@ -12,12 +12,12 @@
|
||||
<!-- 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 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>
|
||||
</div>
|
||||
</button>
|
||||
</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>
|
||||
@ -87,7 +87,7 @@
|
||||
</ui-btn>
|
||||
|
||||
<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 }}
|
||||
</ui-btn>
|
||||
|
||||
@ -96,12 +96,12 @@
|
||||
</ui-tooltip>
|
||||
|
||||
<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 }}
|
||||
</ui-btn>
|
||||
|
||||
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
|
||||
<ui-icon-btn icon="" outlined class="mx-0.5" @click="editClick" />
|
||||
<ui-icon-btn icon="" outlined class="mx-0.5" :aria-label="$strings.LabelEdit" @click="editClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<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 -->
|
||||
<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-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
|
||||
<template #default="{ showMenu, clickShowMenu, disabled }">
|
||||
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<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"></span>
|
||||
</button>
|
||||
</template>
|
||||
|
@ -126,12 +126,14 @@ export default {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
const currentTime = this.localAudioPlayer.getCurrentTime()
|
||||
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() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
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) {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
@ -248,6 +250,8 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('user/loadUserSettings')
|
||||
|
||||
this.resize()
|
||||
window.addEventListener('resize', this.resize)
|
||||
window.addEventListener('keydown', this.keyDown)
|
||||
|
@ -1,20 +1,20 @@
|
||||
<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">
|
||||
<!-- Library & folder picker -->
|
||||
<div class="flex my-6 -mx-2">
|
||||
<div class="w-1/5 px-2">
|
||||
<div class="flex flex-wrap my-6 md:-mx-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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</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">
|
||||
<ui-toggle-switch v-model="fetchMetadata.enabled" class="inline-flex" />
|
||||
<span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span>
|
||||
@ -33,13 +33,13 @@
|
||||
</widgets-alert>
|
||||
|
||||
<!-- 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'">
|
||||
<p class="text-2xl text-center">{{ isDragging ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop }}</p>
|
||||
<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 : isIOS ? $strings.LabelUploaderDragAndDropFilesOnly : $strings.LabelUploaderDragAndDrop }}</p>
|
||||
<p class="text-center text-sm my-5">{{ $strings.MessageOr }}</p>
|
||||
<div class="w-full max-w-xl mx-auto">
|
||||
<div class="flex">
|
||||
<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 class="pt-8 text-center">
|
||||
@ -48,7 +48,7 @@
|
||||
</p>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -84,8 +84,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<input ref="fileFolderInput" type="file" webkitdirectory 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" v-if="!isIOS" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -127,6 +127,10 @@ export default {
|
||||
})
|
||||
return extensions
|
||||
},
|
||||
isIOS() {
|
||||
const ua = window.navigator.userAgent
|
||||
return /iPad|iPhone|iPod/.test(ua) && !window.MSStream
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
|
@ -7,6 +7,7 @@ const defaultCode = 'en-us'
|
||||
const languageCodeMap = {
|
||||
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
||||
cs: { label: 'Čeština', dateFnsLocale: 'cs' },
|
||||
da: { label: 'Dansk', dateFnsLocale: 'da' },
|
||||
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
|
||||
const podcastSearchRegionMap = {
|
||||
au: { label: 'Australia' },
|
||||
br: { label: 'Brasil' },
|
||||
be: { label: 'België / Belgique / Belgien' },
|
||||
cz: { label: 'Česko' },
|
||||
@ -56,6 +58,7 @@ const podcastSearchRegionMap = {
|
||||
hu: { label: 'Magyarország' },
|
||||
nl: { label: 'Nederland' },
|
||||
no: { label: 'Norge' },
|
||||
nz: { label: 'New Zealand' },
|
||||
at: { label: 'Österreich' },
|
||||
pl: { label: 'Polska' },
|
||||
pt: { label: 'Portugal' },
|
||||
|
@ -72,16 +72,17 @@ export const actions = {
|
||||
return this.$axios
|
||||
.$patch('/api/settings', updatePayload)
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
if (result.serverSettings) {
|
||||
commit('setServerSettings', result.serverSettings)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
return false
|
||||
const errorMsg = error.response?.data || 'Unknown error'
|
||||
return {
|
||||
error: errorMsg
|
||||
}
|
||||
})
|
||||
},
|
||||
checkForUpdate({ commit }) {
|
||||
|
@ -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
1
client/strings/be.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
@ -66,6 +66,7 @@
|
||||
"ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন",
|
||||
"ButtonQueueAddItem": "সারিতে যোগ করুন",
|
||||
"ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন",
|
||||
"ButtonQuickEmbed": "দ্রুত এম্বেড করুন",
|
||||
"ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন",
|
||||
"ButtonQuickMatch": "দ্রুত ম্যাচ",
|
||||
"ButtonReScan": "পুনরায় স্ক্যান",
|
||||
@ -162,6 +163,7 @@
|
||||
"HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন",
|
||||
"HeaderNotifications": "বিজ্ঞপ্তি",
|
||||
"HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ",
|
||||
"HeaderOpenListeningSessions": "শোনার সেশন খুলুন",
|
||||
"HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন",
|
||||
"HeaderOtherFiles": "অন্যান্য ফাইল",
|
||||
"HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ",
|
||||
@ -179,6 +181,7 @@
|
||||
"HeaderRemoveEpisodes": "{0}টি পর্ব সরান",
|
||||
"HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি",
|
||||
"HeaderSchedule": "সময়সূচী",
|
||||
"HeaderScheduleEpisodeDownloads": "স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন",
|
||||
"HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী",
|
||||
"HeaderSession": "সেশন",
|
||||
"HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন",
|
||||
@ -224,7 +227,11 @@
|
||||
"LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী",
|
||||
"LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী",
|
||||
"LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে",
|
||||
"LabelApiToken": "API টোকেন",
|
||||
"LabelAppend": "সংযোজন",
|
||||
"LabelAudioBitrate": "অডিও বিটরেট (যেমন- 128k)",
|
||||
"LabelAudioChannels": "অডিও চ্যানেল (১ বা ২)",
|
||||
"LabelAudioCodec": "অডিও কোডেক",
|
||||
"LabelAuthor": "লেখক",
|
||||
"LabelAuthorFirstLast": "লেখক (প্রথম শেষ)",
|
||||
"LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)",
|
||||
@ -237,6 +244,7 @@
|
||||
"LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন",
|
||||
"LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন",
|
||||
"LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান",
|
||||
"LabelBackupAudioFiles": "অডিও ফাইলগুলো ব্যাকআপ",
|
||||
"LabelBackupLocation": "ব্যাকআপ অবস্থান",
|
||||
"LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত",
|
||||
@ -245,15 +253,18 @@
|
||||
"LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন",
|
||||
"LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।",
|
||||
"LabelBitrate": "বিটরেট",
|
||||
"LabelBonus": "উপরিলাভ",
|
||||
"LabelBooks": "বইগুলো",
|
||||
"LabelButtonText": "ঘর পাঠ্য",
|
||||
"LabelByAuthor": "দ্বারা {0}",
|
||||
"LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন",
|
||||
"LabelChannels": "চ্যানেল",
|
||||
"LabelChapterCount": "{0} অধ্যায়",
|
||||
"LabelChapterTitle": "অধ্যায়ের শিরোনাম",
|
||||
"LabelChapters": "অধ্যায়",
|
||||
"LabelChaptersFound": "অধ্যায় পাওয়া গেছে",
|
||||
"LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন",
|
||||
"LabelClickToUseCurrentValue": "বর্তমান মান ব্যবহার করতে ক্লিক করুন",
|
||||
"LabelClosePlayer": "প্লেয়ার বন্ধ করুন",
|
||||
"LabelCodec": "কোডেক",
|
||||
"LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন",
|
||||
@ -303,12 +314,25 @@
|
||||
"LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা",
|
||||
"LabelEmbeddedCover": "এম্বেডেড কভার",
|
||||
"LabelEnable": "সক্ষম করুন",
|
||||
"LabelEncodingBackupLocation": "আপনার আসল অডিও ফাইলগুলোর একটি ব্যাকআপ এখানে সংরক্ষণ করা হবে:",
|
||||
"LabelEncodingChaptersNotEmbedded": "মাল্টি-ট্র্যাক অডিওবুকগুলোতে অধ্যায় এম্বেড করা হয় না।",
|
||||
"LabelEncodingClearItemCache": "পর্যায়ক্রমে আইটেম ক্যাশে পরিষ্কার করতে ভুলবেন না।",
|
||||
"LabelEncodingFinishedM4B": "সমাপ্ত হওয়া M4B-গুলো আপনার অডিওবুক ফোল্ডারে এখানে রাখা হবে:",
|
||||
"LabelEncodingInfoEmbedded": "আপনার অডিওবুক ফোল্ডারের ভিতরে অডিও ট্র্যাকগুলোতে মেটাডেটা এমবেড করা হবে।",
|
||||
"LabelEncodingStartedNavigation": "একবার টাস্ক শুরু হলে আপনি এই পৃষ্ঠা থেকে অন্যত্র যেতে পারেন।",
|
||||
"LabelEncodingTimeWarning": "এনকোডিং ৩০ মিনিট পর্যন্ত সময় নিতে পারে।",
|
||||
"LabelEncodingWarningAdvancedSettings": "সতর্কতা: এই সেটিংস আপডেট করবেন না, যদি না আপনি ffmpeg এনকোডিং বিকল্পগুলোর সাথে পরিচিত হন।",
|
||||
"LabelEncodingWatcherDisabled": "আপনার যদি পর্যবেক্ষক অক্ষম থাকে তবে আপনাকে পরে এই অডিওবুকটি পুনরায় স্ক্যান করতে হবে।",
|
||||
"LabelEnd": "সমাপ্ত",
|
||||
"LabelEndOfChapter": "অধ্যায়ের সমাপ্তি",
|
||||
"LabelEpisode": "পর্ব",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "পর্বটি আরএসএস ফিডের সাথে সংযুক্ত করা হয়নি",
|
||||
"LabelEpisodeNumber": "পর্ব #{0}",
|
||||
"LabelEpisodeTitle": "পর্বের শিরোনাম",
|
||||
"LabelEpisodeType": "পর্বের ধরন",
|
||||
"LabelEpisodeUrlFromRssFeed": "আরএসএস ফিড থেকে পর্ব URL",
|
||||
"LabelEpisodes": "পর্বগুলো",
|
||||
"LabelEpisodic": "প্রাসঙ্গিক",
|
||||
"LabelExample": "উদাহরণ",
|
||||
"LabelExpandSeries": "সিরিজ প্রসারিত করুন",
|
||||
"LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন",
|
||||
@ -336,6 +360,7 @@
|
||||
"LabelFontScale": "ফন্ট স্কেল",
|
||||
"LabelFontStrikethrough": "অবচ্ছেদন রেখা",
|
||||
"LabelFormat": "ফরম্যাট",
|
||||
"LabelFull": "পূর্ণ",
|
||||
"LabelGenre": "ঘরানা",
|
||||
"LabelGenres": "ঘরানাগুলো",
|
||||
"LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন",
|
||||
@ -391,6 +416,10 @@
|
||||
"LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার",
|
||||
"LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন",
|
||||
"LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে",
|
||||
"LabelMaxEpisodesToDownload": "সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "প্রতি কিস্তিতে সর্বাধিক # টি নতুন পর্ব ডাউনলোড করা হবে",
|
||||
"LabelMaxEpisodesToKeep": "সর্বোচ্চ # টি পর্ব রাখা হবে",
|
||||
"LabelMaxEpisodesToKeepHelp": "০ কোন সর্বোচ্চ সীমা সেট করে না। একটি নতুন পর্ব স্বয়ংক্রিয়-ডাউনলোড হওয়ার পরে আপনার যদি X-এর বেশি পর্ব থাকে তবে এটি সবচেয়ে পুরানো পর্বটি মুছে ফেলবে। এটি প্রতি নতুন ডাউনলোডের জন্য শুধুমাত্র ১ টি পর্ব মুছে ফেলবে।",
|
||||
"LabelMediaPlayer": "মিডিয়া প্লেয়ার",
|
||||
"LabelMediaType": "মিডিয়ার ধরন",
|
||||
"LabelMetaTag": "মেটা ট্যাগ",
|
||||
@ -436,12 +465,14 @@
|
||||
"LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।",
|
||||
"LabelOpenRSSFeed": "আরএসএস ফিড খুলুন",
|
||||
"LabelOverwrite": "পুনঃলিখিত",
|
||||
"LabelPaginationPageXOfY": "{1} টির মধ্যে {0} পৃষ্ঠা",
|
||||
"LabelPassword": "পাসওয়ার্ড",
|
||||
"LabelPath": "পথ",
|
||||
"LabelPermanent": "স্থায়ী",
|
||||
"LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে",
|
||||
"LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে",
|
||||
"LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে",
|
||||
"LabelPermissionsCreateEreader": "ইরিডার তৈরি করতে পারেন",
|
||||
"LabelPermissionsDelete": "মুছে দিতে পারবে",
|
||||
"LabelPermissionsDownload": "ডাউনলোড করতে পারবে",
|
||||
"LabelPermissionsUpdate": "আপডেট করতে পারবে",
|
||||
@ -465,6 +496,8 @@
|
||||
"LabelPubDate": "প্রকাশের তারিখ",
|
||||
"LabelPublishYear": "প্রকাশের বছর",
|
||||
"LabelPublishedDate": "প্রকাশিত {0}",
|
||||
"LabelPublishedDecade": "প্রকাশনার দশক",
|
||||
"LabelPublishedDecades": "প্রকাশনার দশকগুলো",
|
||||
"LabelPublisher": "প্রকাশক",
|
||||
"LabelPublishers": "প্রকাশকরা",
|
||||
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
|
||||
@ -484,21 +517,28 @@
|
||||
"LabelRedo": "পুনরায় করুন",
|
||||
"LabelRegion": "অঞ্চল",
|
||||
"LabelReleaseDate": "উন্মোচনের তারিখ",
|
||||
"LabelRemoveAllMetadataAbs": "সমস্ত metadata.abs ফাইল সরান",
|
||||
"LabelRemoveAllMetadataJson": "সমস্ত metadata.json ফাইল সরান",
|
||||
"LabelRemoveCover": "কভার সরান",
|
||||
"LabelRemoveMetadataFile": "লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান",
|
||||
"LabelRemoveMetadataFileHelp": "আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।",
|
||||
"LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি",
|
||||
"LabelSearchTerm": "অনুসন্ধান শব্দ",
|
||||
"LabelSearchTitle": "অনুসন্ধান শিরোনাম",
|
||||
"LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN",
|
||||
"LabelSeason": "সেশন",
|
||||
"LabelSeasonNumber": "মরসুম #{0}",
|
||||
"LabelSelectAll": "সব নির্বাচন করুন",
|
||||
"LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন",
|
||||
"LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন",
|
||||
"LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন",
|
||||
"LabelSendEbookToDevice": "ই-বই পাঠান...",
|
||||
"LabelSequence": "ক্রম",
|
||||
"LabelSerial": "ধারাবাহিক",
|
||||
"LabelSeries": "সিরিজ",
|
||||
"LabelSeriesName": "সিরিজের নাম",
|
||||
"LabelSeriesProgress": "সিরিজের অগ্রগতি",
|
||||
"LabelServerLogLevel": "সার্ভার লগ লেভেল",
|
||||
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
|
||||
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
|
||||
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
|
||||
@ -523,6 +563,9 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।",
|
||||
"LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন",
|
||||
"LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন",
|
||||
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "শতকরা সম্পূর্ণ এর চেয়ে বেশি",
|
||||
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "বাকি সময় (সেকেন্ড) এর চেয়ে কম",
|
||||
"LabelSettingsLibraryMarkAsFinishedWhen": "মিডিয়া আইটেমকে সমাপ্ত হিসাবে চিহ্নিত করুন যখন",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।",
|
||||
"LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন",
|
||||
@ -587,6 +630,7 @@
|
||||
"LabelTimeDurationXMinutes": "{0} মিনিট",
|
||||
"LabelTimeDurationXSeconds": "{0} সেকেন্ড",
|
||||
"LabelTimeInMinutes": "মিনিটে সময়",
|
||||
"LabelTimeLeft": "{0} বাকি",
|
||||
"LabelTimeListened": "সময় শোনা হয়েছে",
|
||||
"LabelTimeListenedToday": "আজ শোনার সময়",
|
||||
"LabelTimeRemaining": "{0}টি অবশিষ্ট",
|
||||
@ -594,6 +638,7 @@
|
||||
"LabelTitle": "শিরোনাম",
|
||||
"LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন",
|
||||
"LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।",
|
||||
"LabelToolsM4bEncoder": "M4B এনকোডার",
|
||||
"LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন",
|
||||
"LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।",
|
||||
"LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন",
|
||||
@ -606,6 +651,7 @@
|
||||
"LabelTracksMultiTrack": "মাল্টি-ট্র্যাক",
|
||||
"LabelTracksNone": "কোন ট্র্যাক নেই",
|
||||
"LabelTracksSingleTrack": "একক-ট্র্যাক",
|
||||
"LabelTrailer": "আনুগমিক",
|
||||
"LabelType": "টাইপ",
|
||||
"LabelUnabridged": "অসংলগ্ন",
|
||||
"LabelUndo": "পূর্বাবস্থা",
|
||||
@ -617,10 +663,13 @@
|
||||
"LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন",
|
||||
"LabelUpdatedAt": "আপডেট করা হয়েছে",
|
||||
"LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন",
|
||||
"LabelUploaderDragAndDropFilesOnly": "ফাইল টেনে আনুন",
|
||||
"LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন",
|
||||
"LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন",
|
||||
"LabelUseAdvancedOptions": "উন্নত বিকল্প ব্যবহার করুন",
|
||||
"LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন",
|
||||
"LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন",
|
||||
"LabelUseZeroForUnlimited": "অসীমের জন্য 0 ব্যবহার করুন",
|
||||
"LabelUser": "ব্যবহারকারী",
|
||||
"LabelUsername": "ব্যবহারকারীর নাম",
|
||||
"LabelValue": "মান",
|
||||
@ -667,6 +716,7 @@
|
||||
"MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?",
|
||||
"MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?",
|
||||
"MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
|
||||
@ -678,6 +728,7 @@
|
||||
"MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?",
|
||||
"MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?",
|
||||
"MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?",
|
||||
"MessageConfirmQuickMatchEpisodes": "একটি মিল পাওয়া গেলে দ্রুত ম্যাচিং পর্বগুলি বিস্তারিত ওভাররাইট করবে। শুধুমাত্র অতুলনীয় পর্ব আপডেট করা হবে। আপনি কি নিশ্চিত?",
|
||||
"MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?",
|
||||
"MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?",
|
||||
"MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?",
|
||||
@ -685,6 +736,7 @@
|
||||
"MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?",
|
||||
"MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?",
|
||||
"MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?",
|
||||
"MessageConfirmRemoveMetadataFiles": "আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?",
|
||||
"MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?",
|
||||
@ -700,6 +752,7 @@
|
||||
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
|
||||
"MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!",
|
||||
"MessageEmbedFinished": "এম্বেড করা শেষ!",
|
||||
"MessageEmbedQueue": "মেটাডেটা এম্বেডের জন্য সারিবদ্ধ ({0} সারিতে)",
|
||||
"MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ",
|
||||
"MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।",
|
||||
"MessageFeedURLWillBe": "ফিড URL হবে {0}",
|
||||
@ -744,6 +797,7 @@
|
||||
"MessageNoLogs": "কোনও লগ নেই",
|
||||
"MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই",
|
||||
"MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই",
|
||||
"MessageNoPodcastFeed": "অবৈধ পডকাস্ট: কোনো ফিড নেই",
|
||||
"MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি",
|
||||
"MessageNoResults": "কোন ফলাফল নেই",
|
||||
"MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই",
|
||||
@ -760,6 +814,10 @@
|
||||
"MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন",
|
||||
"MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই",
|
||||
"MessagePodcastSearchField": "অনুসন্ধান শব্দ বা RSS ফিড URL লিখুন",
|
||||
"MessageQuickEmbedInProgress": "দ্রুত এম্বেড করা হচ্ছে",
|
||||
"MessageQuickEmbedQueue": "দ্রুত এম্বেড করার জন্য সারিবদ্ধ ({0} সারিতে)",
|
||||
"MessageQuickMatchAllEpisodes": "দ্রুত ম্যাচ সব পর্ব",
|
||||
"MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।",
|
||||
"MessageRemoveChapter": "অধ্যায় সরান",
|
||||
"MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান",
|
||||
@ -802,6 +860,9 @@
|
||||
"MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান",
|
||||
"MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ",
|
||||
"MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে",
|
||||
"MessageTaskOpmlParseFailed": "OPML ফাইল পার্স করতে ব্যর্থ হয়েছে",
|
||||
"MessageTaskOpmlParseFastFail": "অবৈধ OPML ফাইল <opml> ট্যাগ পাওয়া যায়নি বা একটি <outline> ট্যাগ পাওয়া যায়নি",
|
||||
"MessageTaskOpmlParseNoneFound": "OPML ফাইলে কোনো ফিড পাওয়া যায়নি",
|
||||
"MessageTaskScanItemsAdded": "{0}টি করা হয়েছে",
|
||||
"MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত",
|
||||
"MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে",
|
||||
@ -826,6 +887,10 @@
|
||||
"NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।",
|
||||
"NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।",
|
||||
"NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।",
|
||||
"NotificationOnBackupCompletedDescription": "ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে",
|
||||
"NotificationOnBackupFailedDescription": "ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে",
|
||||
"NotificationOnEpisodeDownloadedDescription": "একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে",
|
||||
"NotificationOnTestDescription": "বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট",
|
||||
"PlaceholderNewCollection": "নতুন সংগ্রহের নাম",
|
||||
"PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ",
|
||||
"PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম",
|
||||
@ -851,6 +916,7 @@
|
||||
"StatsYearInReview": "বাৎসরিক পর্যালোচনা",
|
||||
"ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে",
|
||||
"ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে",
|
||||
"ToastAsinRequired": "ASIN প্রয়োজন",
|
||||
"ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে",
|
||||
"ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি",
|
||||
"ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে",
|
||||
@ -870,6 +936,8 @@
|
||||
"ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে",
|
||||
"ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে",
|
||||
"ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে",
|
||||
"ToastBatchQuickMatchFailed": "ব্যাচ কুইক ম্যাচ ব্যর্থ!",
|
||||
"ToastBatchQuickMatchStarted": "{0}টি বইয়ের ব্যাচ কুইক ম্যাচ শুরু হয়েছে!",
|
||||
"ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে",
|
||||
"ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য",
|
||||
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
|
||||
@ -881,6 +949,7 @@
|
||||
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
|
||||
"ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে",
|
||||
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
|
||||
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
|
||||
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
|
||||
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
|
||||
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
|
||||
@ -898,11 +967,14 @@
|
||||
"ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে",
|
||||
"ToastEpisodeUpdateSuccess": "{0}টি পর্ব আপডেট করা হয়েছে",
|
||||
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
|
||||
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
|
||||
"ToastFailedToMatch": "মেলাতে ব্যর্থ হয়েছে",
|
||||
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
|
||||
"ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে",
|
||||
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
|
||||
"ToastInvalidMaxEpisodesToDownload": "ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব",
|
||||
"ToastInvalidUrl": "অকার্যকর ইউআরএল",
|
||||
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
|
||||
"ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ",
|
||||
@ -920,14 +992,22 @@
|
||||
"ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ",
|
||||
"ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে",
|
||||
"ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে",
|
||||
"ToastMatchAllAuthorsFailed": "সমস্ত লেখকের সাথে মিলতে ব্যর্থ হয়েছে",
|
||||
"ToastMetadataFilesRemovedError": "মেটাডেটা সরানোর সময় ত্রুটি {0} ফাইল",
|
||||
"ToastMetadataFilesRemovedNoneFound": "কোনো মেটাডেটা নেই।লাইব্রেরিতে {0} ফাইল পাওয়া গেছে",
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "কোনো মেটাডেটা নেই।{0} ফাইল সরানো হয়েছে",
|
||||
"ToastMetadataFilesRemovedSuccess": "{0} মেটাডেটা৷{1} ফাইল সরানো হয়েছে",
|
||||
"ToastMustHaveAtLeastOnePath": "অন্তত একটি পথ থাকতে হবে",
|
||||
"ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক",
|
||||
"ToastNameRequired": "নাম আবশ্যক",
|
||||
"ToastNewEpisodesFound": "{0}টি নতুন পর্ব পাওয়া গেছে",
|
||||
"ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"",
|
||||
"ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে",
|
||||
"ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে",
|
||||
"ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে",
|
||||
"ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে",
|
||||
"ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন",
|
||||
"ToastNoNewEpisodesFound": "কোন নতুন পর্ব পাওয়া যায়নি",
|
||||
"ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই",
|
||||
"ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ",
|
||||
"ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ",
|
||||
@ -946,6 +1026,7 @@
|
||||
"ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে",
|
||||
"ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি",
|
||||
"ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই",
|
||||
"ToastProgressIsNotBeingSynced": "অগ্রগতি সিঙ্ক হচ্ছে না, প্লেব্যাক পুনরায় চালু করুন",
|
||||
"ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে",
|
||||
"ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে",
|
||||
"ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক",
|
||||
@ -972,6 +1053,7 @@
|
||||
"ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে",
|
||||
"ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ",
|
||||
"ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে",
|
||||
"ToastSleepTimerDone": "স্লিপ টাইমার হয়ে গেছে... zZzzZz",
|
||||
"ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে",
|
||||
"ToastSlugRequired": "স্লাগ আবশ্যক",
|
||||
"ToastSocketConnected": "সকেট সংযুক্ত",
|
||||
|
1027
client/strings/ca.json
Normal file
1027
client/strings/ca.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "Uložit seznam skladeb",
|
||||
"ButtonScan": "Prohledat",
|
||||
"ButtonScanLibrary": "Prohledat Knihovnu",
|
||||
"ButtonScrollLeft": "Posunout vlevo",
|
||||
"ButtonScrollRight": "Posunout vpravo",
|
||||
"ButtonSearch": "Hledat",
|
||||
"ButtonSelectFolderPath": "Vybrat cestu ke složce",
|
||||
"ButtonSeries": "Série",
|
||||
@ -190,6 +192,7 @@
|
||||
"HeaderSettingsExperimental": "Experimentální funkce",
|
||||
"HeaderSettingsGeneral": "Obecné",
|
||||
"HeaderSettingsScanner": "Skener",
|
||||
"HeaderSettingsWebClient": "Webový klient",
|
||||
"HeaderSleepTimer": "Časovač vypnutí",
|
||||
"HeaderStatsLargestItems": "Největší položky",
|
||||
"HeaderStatsLongestItems": "Nejdelší položky (hod.)",
|
||||
@ -264,6 +267,7 @@
|
||||
"LabelChapters": "Kapitoly",
|
||||
"LabelChaptersFound": "Kapitoly nalezeny",
|
||||
"LabelClickForMoreInfo": "Klikněte pro více informací",
|
||||
"LabelClickToUseCurrentValue": "Klikni pro použití aktuální hodnoty",
|
||||
"LabelClosePlayer": "Zavřít přehrávač",
|
||||
"LabelCodec": "Kodek",
|
||||
"LabelCollapseSeries": "Sbalit sérii",
|
||||
@ -313,12 +317,25 @@
|
||||
"LabelEmailSettingsTestAddress": "Testovací adresa",
|
||||
"LabelEmbeddedCover": "Vložená obálka",
|
||||
"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",
|
||||
"LabelEndOfChapter": "Konec kapitoly",
|
||||
"LabelEpisode": "Epizoda",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "Epizoda není propojená s RSS feed",
|
||||
"LabelEpisodeNumber": "Epizoda #{0}",
|
||||
"LabelEpisodeTitle": "Název epizody",
|
||||
"LabelEpisodeType": "Typ epizody",
|
||||
"LabelEpisodeUrlFromRssFeed": "URL epizody z RSS feed",
|
||||
"LabelEpisodes": "Epizody",
|
||||
"LabelEpisodic": "Epizodické",
|
||||
"LabelExample": "Příklad",
|
||||
"LabelExpandSeries": "Rozbalit série",
|
||||
"LabelExpandSubSeries": "Rozbalit podsérie",
|
||||
@ -346,6 +363,7 @@
|
||||
"LabelFontScale": "Měřítko písma",
|
||||
"LabelFontStrikethrough": "Přeškrtnutí",
|
||||
"LabelFormat": "Formát",
|
||||
"LabelFull": "Plné",
|
||||
"LabelGenre": "Žánr",
|
||||
"LabelGenres": "Žánry",
|
||||
"LabelHardDeleteFile": "Trvale smazat soubor",
|
||||
@ -388,6 +406,7 @@
|
||||
"LabelLess": "Méně",
|
||||
"LabelLibrariesAccessibleToUser": "Knihovny přístupné uživateli",
|
||||
"LabelLibrary": "Knihovna",
|
||||
"LabelLibraryFilterSublistEmpty": "Žádné {0}",
|
||||
"LabelLibraryItem": "Položka knihovny",
|
||||
"LabelLibraryName": "Název knihovny",
|
||||
"LabelLimit": "Omezit",
|
||||
@ -400,6 +419,9 @@
|
||||
"LabelLowestPriority": "Nejnižší priorita",
|
||||
"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.",
|
||||
"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í",
|
||||
"LabelMediaType": "Typ média",
|
||||
"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.",
|
||||
"LabelOpenRSSFeed": "Otevřít RSS kanál",
|
||||
"LabelOverwrite": "Přepsat",
|
||||
"LabelPaginationPageXOfY": "Strana {0} z {1}",
|
||||
"LabelPassword": "Heslo",
|
||||
"LabelPath": "Cesta",
|
||||
"LabelPermanent": "Trvalé",
|
||||
"LabelPermissionsAccessAllLibraries": "Má přístup ke všem knihovnám",
|
||||
"LabelPermissionsAccessAllTags": "Má přístup ke všem značkám",
|
||||
"LabelPermissionsAccessExplicitContent": "Má přístup k explicitnímu obsahu",
|
||||
"LabelPermissionsCreateEreader": "Může vytvořit Ereader",
|
||||
"LabelPermissionsDelete": "Může mazat",
|
||||
"LabelPermissionsDownload": "Může stahovat",
|
||||
"LabelPermissionsUpdate": "Může aktualizovat",
|
||||
@ -474,6 +498,8 @@
|
||||
"LabelPubDate": "Datum vydání",
|
||||
"LabelPublishYear": "Rok vydání",
|
||||
"LabelPublishedDate": "Vydáno {0}",
|
||||
"LabelPublishedDecade": "Publikováno (dekáda)",
|
||||
"LabelPublishedDecades": "Publikováno (dekády)",
|
||||
"LabelPublisher": "Vydavatel",
|
||||
"LabelPublishers": "Vydavatelé",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
||||
@ -493,24 +519,32 @@
|
||||
"LabelRedo": "Přepracovat",
|
||||
"LabelRegion": "Region",
|
||||
"LabelReleaseDate": "Datum vydání",
|
||||
"LabelRemoveAllMetadataAbs": "Odebrat všechny soubory metadata.abs",
|
||||
"LabelRemoveAllMetadataJson": "Smazat všechny soubory metadata.json",
|
||||
"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",
|
||||
"LabelSearchTerm": "Vyhledat termín",
|
||||
"LabelSearchTitle": "Vyhledat název",
|
||||
"LabelSearchTitleOrASIN": "Vyhledat název nebo ASIN",
|
||||
"LabelSeason": "Sezóna",
|
||||
"LabelSeasonNumber": "Sezóna č.{0}",
|
||||
"LabelSelectAll": "Vybrat vše",
|
||||
"LabelSelectAllEpisodes": "Vybrat všechny epizody",
|
||||
"LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují",
|
||||
"LabelSelectUsers": "Vybrat uživatele",
|
||||
"LabelSendEbookToDevice": "Odeslat e-knihu do...",
|
||||
"LabelSequence": "Sekvence",
|
||||
"LabelSerial": "Sériové",
|
||||
"LabelSeries": "Série",
|
||||
"LabelSeriesName": "Název série",
|
||||
"LabelSeriesProgress": "Průběh série",
|
||||
"LabelServerLogLevel": "Úroveň protokolu serveru",
|
||||
"LabelServerYearReview": "Přehled roku na serveru ({0})",
|
||||
"LabelSetEbookAsPrimary": "Nastavit jako primární",
|
||||
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkové",
|
||||
"LabelSettingsAllowIframe": "Povolit vložení do rámce iframe",
|
||||
"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",
|
||||
"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.",
|
||||
"LabelSettingsHomePageBookshelfView": "Domovská stránka 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",
|
||||
"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",
|
||||
@ -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",
|
||||
"LabelSettingsTimeFormat": "Formát času",
|
||||
"LabelShare": "Sdílet",
|
||||
"LabelShareOpen": "Otevřít sdílení",
|
||||
"LabelShareURL": "Sdílet URL",
|
||||
"LabelShowAll": "Zobrazit vše",
|
||||
"LabelShowSeconds": "Zobrazit sekundy",
|
||||
"LabelShowSubtitles": "Zobrazit titulky",
|
||||
"LabelSize": "Velikost",
|
||||
"LabelSleepTimer": "Časovač vypnutí",
|
||||
"LabelSlug": "URL název",
|
||||
"LabelSortAscending": "Vzestupně",
|
||||
"LabelSortDescending": "Sestupně",
|
||||
"LabelStart": "Spustit",
|
||||
"LabelStartTime": "Čas Spuštění",
|
||||
"LabelStarted": "Spuštěno",
|
||||
@ -594,6 +635,7 @@
|
||||
"LabelTimeDurationXMinutes": "{0} minut",
|
||||
"LabelTimeDurationXSeconds": "{0} sekund",
|
||||
"LabelTimeInMinutes": "Čas v minutách",
|
||||
"LabelTimeLeft": "{0} zbývá",
|
||||
"LabelTimeListened": "Čas poslechu",
|
||||
"LabelTimeListenedToday": "Čas poslechu dnes",
|
||||
"LabelTimeRemaining": "{0} zbývá",
|
||||
@ -601,6 +643,7 @@
|
||||
"LabelTitle": "Název",
|
||||
"LabelToolsEmbedMetadata": "Vložit metadata",
|
||||
"LabelToolsEmbedMetadataDescription": "Vložit metadata do zvukových souborů včetně obálky a kapitol.",
|
||||
"LabelToolsM4bEncoder": "Enkodér M4B",
|
||||
"LabelToolsMakeM4b": "Vytvořit soubor audioknihy M4B",
|
||||
"LabelToolsMakeM4bDescription": "Vygenerovat soubor audioknihy M4B s vloženými metadaty, obálkou a kapitolami.",
|
||||
"LabelToolsSplitM4b": "Rozdělit M4B na MP3",
|
||||
@ -613,6 +656,7 @@
|
||||
"LabelTracksMultiTrack": "Více stop",
|
||||
"LabelTracksNone": "Žádné stopy",
|
||||
"LabelTracksSingleTrack": "Jedna stopa",
|
||||
"LabelTrailer": "Upoutávka",
|
||||
"LabelType": "Typ",
|
||||
"LabelUnabridged": "Nezkráceno",
|
||||
"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",
|
||||
"LabelUpdatedAt": "Aktualizováno v",
|
||||
"LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Přetáhnout a upustit soubory",
|
||||
"LabelUploaderDropFiles": "Odstranit soubory",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automaticky načíst název, autora a sérii",
|
||||
"LabelUseAdvancedOptions": "Použít pokročilé možnosti",
|
||||
"LabelUseChapterTrack": "Použít stopu kapitoly",
|
||||
"LabelUseFullTrack": "Použít celou stopu",
|
||||
"LabelUseZeroForUnlimited": "Použijte 0 pro neomezené",
|
||||
"LabelUser": "Uživatel",
|
||||
"LabelUsername": "Uživatelské jméno",
|
||||
"LabelValue": "Hodnota",
|
||||
@ -637,6 +684,8 @@
|
||||
"LabelViewPlayerSettings": "Zobrazit nastavení přehrávače",
|
||||
"LabelViewQueue": "Zobrazit frontu přehrávače",
|
||||
"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í",
|
||||
"LabelXBooks": "{0} knih",
|
||||
"LabelXItems": "{0} položky",
|
||||
@ -674,6 +723,7 @@
|
||||
"MessageConfirmDeleteMetadataProvider": "Opravdu chcete vymazat vlastního poskytovatele metadat \"{0}\"?",
|
||||
"MessageConfirmDeleteNotification": "Opravdu chcete vymazat tuto notifikaci?",
|
||||
"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í?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokonč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é?",
|
||||
"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é?",
|
||||
"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?",
|
||||
"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?",
|
||||
|
@ -71,8 +71,8 @@
|
||||
"ButtonQuickMatch": "Schnellabgleich",
|
||||
"ButtonReScan": "Neu scannen",
|
||||
"ButtonRead": "Lesen",
|
||||
"ButtonReadLess": "Weniger anzeigen",
|
||||
"ButtonReadMore": "Mehr anzeigen",
|
||||
"ButtonReadLess": "weniger Anzeigen",
|
||||
"ButtonReadMore": "Mehr Anzeigen",
|
||||
"ButtonRefresh": "Neu Laden",
|
||||
"ButtonRemove": "Entfernen",
|
||||
"ButtonRemoveAll": "Alles entfernen",
|
||||
@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "Speichere die Titelliste",
|
||||
"ButtonScan": "Partial-Scan (nur geänderte/neue Medien)",
|
||||
"ButtonScanLibrary": "Bibliothek scannen",
|
||||
"ButtonScrollLeft": "Nach Links scrollen",
|
||||
"ButtonScrollRight": "Nach Rechts scrollen",
|
||||
"ButtonSearch": "Suchen",
|
||||
"ButtonSelectFolderPath": "Ordnerpfad auswählen",
|
||||
"ButtonSeries": "Serien",
|
||||
@ -190,6 +192,7 @@
|
||||
"HeaderSettingsExperimental": "Experimentelle Funktionen",
|
||||
"HeaderSettingsGeneral": "Allgemein",
|
||||
"HeaderSettingsScanner": "Scanner",
|
||||
"HeaderSettingsWebClient": "Web-Client",
|
||||
"HeaderSleepTimer": "Sleep-Timer",
|
||||
"HeaderStatsLargestItems": "Größte Medien",
|
||||
"HeaderStatsLongestItems": "Längste Medien (h)",
|
||||
@ -220,7 +223,7 @@
|
||||
"LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
|
||||
"LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
|
||||
"LabelAddedAt": "Hinzugefügt am",
|
||||
"LabelAddedDate": "Hinzugefügt {0}",
|
||||
"LabelAddedDate": "{0} Hinzugefügt",
|
||||
"LabelAdminUsersOnly": "Nur Admin Benutzer",
|
||||
"LabelAll": "Alle",
|
||||
"LabelAllUsers": "Alle Benutzer",
|
||||
@ -534,6 +537,7 @@
|
||||
"LabelSelectUsers": "Benutzer auswählen",
|
||||
"LabelSendEbookToDevice": "E-Buch senden an …",
|
||||
"LabelSequence": "Reihenfolge",
|
||||
"LabelSerial": "fortlaufend",
|
||||
"LabelSeries": "Serien",
|
||||
"LabelSeriesName": "Serienname",
|
||||
"LabelSeriesProgress": "Serienfortschritt",
|
||||
@ -541,6 +545,7 @@
|
||||
"LabelServerYearReview": "Server Jahr in Übersicht ({0})",
|
||||
"LabelSetEbookAsPrimary": "Als Hauptbuch setzen",
|
||||
"LabelSetEbookAsSupplementary": "Als Ergänzung setzen",
|
||||
"LabelSettingsAllowIframe": "Einbetten in einem iFrame erlauben",
|
||||
"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",
|
||||
"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",
|
||||
"LabelSettingsTimeFormat": "Zeitformat",
|
||||
"LabelShare": "Freigeben",
|
||||
"LabelShareOpen": "Freigabe",
|
||||
"LabelShareOpen": "Freigeben",
|
||||
"LabelShareURL": "Freigabe URL",
|
||||
"LabelShowAll": "Alles anzeigen",
|
||||
"LabelShowSeconds": "Zeige Sekunden",
|
||||
@ -591,6 +596,8 @@
|
||||
"LabelSize": "Größe",
|
||||
"LabelSleepTimer": "Schlummerfunktion",
|
||||
"LabelSlug": "URL Teil",
|
||||
"LabelSortAscending": "Aufsteigend",
|
||||
"LabelSortDescending": "Absteigend",
|
||||
"LabelStart": "Start",
|
||||
"LabelStartTime": "Startzeit",
|
||||
"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",
|
||||
"LabelUpdatedAt": "Aktualisiert am",
|
||||
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Dateien per Drag & Drop hierher ziehen",
|
||||
"LabelUploaderDropFiles": "Dateien löschen",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie",
|
||||
"LabelUseAdvancedOptions": "Nutze Erweiterte Optionen",
|
||||
@ -677,11 +685,13 @@
|
||||
"LabelViewPlayerSettings": "Zeige player Einstellungen",
|
||||
"LabelViewQueue": "Player-Warteschlange anzeigen",
|
||||
"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",
|
||||
"LabelXBooks": "{0} Bücher",
|
||||
"LabelXItems": "{0} Medien",
|
||||
"LabelYearReviewHide": "Verstecke Jahr in Übersicht",
|
||||
"LabelYearReviewShow": "Zeige Jahr in Übersicht",
|
||||
"LabelYearReviewHide": "Jahresrückblick verbergen",
|
||||
"LabelYearReviewShow": "Jahresrückblick anzeigen",
|
||||
"LabelYourAudiobookDuration": "Laufzeit deines Mediums",
|
||||
"LabelYourBookmarks": "Lesezeichen",
|
||||
"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?",
|
||||
"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?",
|
||||
"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?",
|
||||
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! 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",
|
||||
"MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> 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}?",
|
||||
"MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt",
|
||||
"MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen",
|
||||
@ -1039,7 +1049,7 @@
|
||||
"ToastRenameFailed": "Umbenennen fehlgeschlagen",
|
||||
"ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}",
|
||||
"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",
|
||||
"ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek",
|
||||
"ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus",
|
||||
|
@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "Save Tracklist",
|
||||
"ButtonScan": "Scan",
|
||||
"ButtonScanLibrary": "Scan Library",
|
||||
"ButtonScrollLeft": "Scroll Left",
|
||||
"ButtonScrollRight": "Scroll Right",
|
||||
"ButtonSearch": "Search",
|
||||
"ButtonSelectFolderPath": "Select Folder Path",
|
||||
"ButtonSeries": "Series",
|
||||
@ -190,6 +192,7 @@
|
||||
"HeaderSettingsExperimental": "Experimental Features",
|
||||
"HeaderSettingsGeneral": "General",
|
||||
"HeaderSettingsScanner": "Scanner",
|
||||
"HeaderSettingsWebClient": "Web Client",
|
||||
"HeaderSleepTimer": "Sleep Timer",
|
||||
"HeaderStatsLargestItems": "Largest Items",
|
||||
"HeaderStatsLongestItems": "Longest Items (hrs)",
|
||||
@ -542,6 +545,7 @@
|
||||
"LabelServerYearReview": "Server Year in Review ({0})",
|
||||
"LabelSetEbookAsPrimary": "Set as primary",
|
||||
"LabelSetEbookAsSupplementary": "Set as supplementary",
|
||||
"LabelSettingsAllowIframe": "Allow embedding in an iframe",
|
||||
"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",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
|
||||
@ -592,6 +596,8 @@
|
||||
"LabelSize": "Size",
|
||||
"LabelSleepTimer": "Sleep timer",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelSortAscending": "Ascending",
|
||||
"LabelSortDescending": "Descending",
|
||||
"LabelStart": "Start",
|
||||
"LabelStartTime": "Start Time",
|
||||
"LabelStarted": "Started",
|
||||
@ -663,6 +669,7 @@
|
||||
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
|
||||
"LabelUpdatedAt": "Updated At",
|
||||
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Drag & drop files",
|
||||
"LabelUploaderDropFiles": "Drop files",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseAdvancedOptions": "Use Advanced Options",
|
||||
@ -678,6 +685,8 @@
|
||||
"LabelViewPlayerSettings": "View player settings",
|
||||
"LabelViewQueue": "View player queue",
|
||||
"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",
|
||||
"LabelXBooks": "{0} books",
|
||||
"LabelXItems": "{0} items",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"ButtonAdd": "Agregar",
|
||||
"ButtonAddChapters": "Agregar Capitulo",
|
||||
"ButtonAdd": "Agregaro",
|
||||
"ButtonAddChapters": "Agregar",
|
||||
"ButtonAddDevice": "Agregar Dispositivo",
|
||||
"ButtonAddLibrary": "Crear Biblioteca",
|
||||
"ButtonAddPodcasts": "Agregar Podcasts",
|
||||
@ -71,8 +71,8 @@
|
||||
"ButtonQuickMatch": "Encontrar Rápido",
|
||||
"ButtonReScan": "Re-Escanear",
|
||||
"ButtonRead": "Leer",
|
||||
"ButtonReadLess": "Lea menos",
|
||||
"ButtonReadMore": "Lea mas",
|
||||
"ButtonReadLess": "Leer menos",
|
||||
"ButtonReadMore": "Leer más",
|
||||
"ButtonRefresh": "Refrecar",
|
||||
"ButtonRemove": "Remover",
|
||||
"ButtonRemoveAll": "Remover Todos",
|
||||
@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "Guardar Tracklist",
|
||||
"ButtonScan": "Escanear",
|
||||
"ButtonScanLibrary": "Escanear Biblioteca",
|
||||
"ButtonScrollLeft": "Desplazarse hacia la izquierda",
|
||||
"ButtonScrollRight": "Desplazarse hacia la derecha",
|
||||
"ButtonSearch": "Buscar",
|
||||
"ButtonSelectFolderPath": "Seleccionar Ruta de Carpeta",
|
||||
"ButtonSeries": "Series",
|
||||
@ -190,6 +192,7 @@
|
||||
"HeaderSettingsExperimental": "Funciones Experimentales",
|
||||
"HeaderSettingsGeneral": "General",
|
||||
"HeaderSettingsScanner": "Escáner",
|
||||
"HeaderSettingsWebClient": "Cliente web",
|
||||
"HeaderSleepTimer": "Temporizador de apagado",
|
||||
"HeaderStatsLargestItems": "Artículos mas Grandes",
|
||||
"HeaderStatsLongestItems": "Artículos mas Largos (h)",
|
||||
@ -220,7 +223,7 @@
|
||||
"LabelAddToPlaylist": "Añadido a la lista de reproducción",
|
||||
"LabelAddToPlaylistBatch": "Se Añadieron {0} Artículos a la Lista de Reproducción",
|
||||
"LabelAddedAt": "Añadido",
|
||||
"LabelAddedDate": "Añadido {0}",
|
||||
"LabelAddedDate": "{0} Añadido",
|
||||
"LabelAdminUsersOnly": "Solamente usuarios administradores",
|
||||
"LabelAll": "Todos",
|
||||
"LabelAllUsers": "Todos los Usuarios",
|
||||
@ -542,6 +545,7 @@
|
||||
"LabelServerYearReview": "Resumen del año del servidor ({0})",
|
||||
"LabelSetEbookAsPrimary": "Establecer como primario",
|
||||
"LabelSetEbookAsSupplementary": "Establecer como suplementario",
|
||||
"LabelSettingsAllowIframe": "Permitir incrustación en un iframe",
|
||||
"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",
|
||||
"LabelSettingsBookshelfViewHelp": "Diseño Esqueuomorfo con Estantes de Madera",
|
||||
@ -592,6 +596,8 @@
|
||||
"LabelSize": "Tamaño",
|
||||
"LabelSleepTimer": "Temporizador de apagado",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelSortAscending": "Ascendente",
|
||||
"LabelSortDescending": "Descendente",
|
||||
"LabelStart": "Iniciar",
|
||||
"LabelStartTime": "Tiempo de Inicio",
|
||||
"LabelStarted": "Iniciado",
|
||||
@ -663,6 +669,7 @@
|
||||
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
|
||||
"LabelUpdatedAt": "Actualizado En",
|
||||
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Arrastrar y soltar archivos",
|
||||
"LabelUploaderDropFiles": "Suelte los Archivos",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente",
|
||||
"LabelUseAdvancedOptions": "Usar opciones avanzadas",
|
||||
@ -678,11 +685,13 @@
|
||||
"LabelViewPlayerSettings": "Ver los ajustes del reproductor",
|
||||
"LabelViewQueue": "Ver Fila del Reproductor",
|
||||
"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",
|
||||
"LabelXBooks": "{0} libros",
|
||||
"LabelXItems": "{0} elementos",
|
||||
"LabelYearReviewHide": "Ocultar Year in Review",
|
||||
"LabelYearReviewShow": "Ver Year in Review",
|
||||
"LabelYearReviewHide": "Ocultar Resumen del año",
|
||||
"LabelYearReviewShow": "Resumen del año",
|
||||
"LabelYourAudiobookDuration": "Duración de tu Audiolibro",
|
||||
"LabelYourBookmarks": "Tus Marcadores",
|
||||
"LabelYourPlaylists": "Tus Listas",
|
||||
@ -779,7 +788,7 @@
|
||||
"MessageNoBackups": "Sin Respaldos",
|
||||
"MessageNoBookmarks": "Sin marcadores",
|
||||
"MessageNoChapters": "Sin capítulos",
|
||||
"MessageNoCollections": "Sin Colecciones",
|
||||
"MessageNoCollections": "Sin colecciones",
|
||||
"MessageNoCoversFound": "Ninguna Portada Encontrada",
|
||||
"MessageNoDescription": "Sin Descripción",
|
||||
"MessageNoDevices": "Sin dispositivos",
|
||||
|
@ -592,6 +592,8 @@
|
||||
"LabelSize": "Taille",
|
||||
"LabelSleepTimer": "Minuterie de mise en veille",
|
||||
"LabelSlug": "Identifiant d’URL",
|
||||
"LabelSortAscending": "Croissant",
|
||||
"LabelSortDescending": "Décroissant",
|
||||
"LabelStart": "Démarrer",
|
||||
"LabelStartTime": "Heure de démarrage",
|
||||
"LabelStarted": "Démarré",
|
||||
@ -663,6 +665,7 @@
|
||||
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée",
|
||||
"LabelUpdatedAt": "Mis à jour à",
|
||||
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Glisser & déposer des fichiers",
|
||||
"LabelUploaderDropFiles": "Déposer des fichiers",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, l’auteur et la série",
|
||||
"LabelUseAdvancedOptions": "Utiliser les options avancées",
|
||||
@ -869,10 +872,10 @@
|
||||
"MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »",
|
||||
"MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »",
|
||||
"MessageTaskTargetDirectoryNotWritable": "Le répertoire cible n’est pas accessible en écriture",
|
||||
"MessageThinking": "Je cherche…",
|
||||
"MessageThinking": "À la recherche de…",
|
||||
"MessageUploaderItemFailed": "Échec du téléversement",
|
||||
"MessageUploaderItemSuccess": "Téléversement effectué !",
|
||||
"MessageUploading": "Téléversement…",
|
||||
"MessageUploading": "Téléchargement…",
|
||||
"MessageValidCronExpression": "Expression cron valide",
|
||||
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
|
||||
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !",
|
||||
|
@ -8,17 +8,18 @@
|
||||
"ButtonAddYourFirstLibrary": "הוסף את הספרייה הראשונה שלך",
|
||||
"ButtonApply": "החל",
|
||||
"ButtonApplyChapters": "החל פרקים",
|
||||
"ButtonAuthors": "יוצרים",
|
||||
"ButtonAuthors": "סופרים",
|
||||
"ButtonBack": "חזור",
|
||||
"ButtonBrowseForFolder": "עיין בתיקייה",
|
||||
"ButtonCancel": "בטל",
|
||||
"ButtonCancel": "ביטול",
|
||||
"ButtonCancelEncode": "בטל קידוד",
|
||||
"ButtonChangeRootPassword": "שנה סיסמת root",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "בדוק והורד פרקים חדשים",
|
||||
"ButtonChooseAFolder": "בחר תיקייה",
|
||||
"ButtonChooseFiles": "בחר קבצים",
|
||||
"ButtonClearFilter": "נקה סינון",
|
||||
"ButtonCloseFeed": "סגור פיד",
|
||||
"ButtonCloseFeed": "סגור ערוץ",
|
||||
"ButtonCloseSession": "סגור סשן פתוח",
|
||||
"ButtonCollections": "אוספים",
|
||||
"ButtonConfigureScanner": "הגדר סורק",
|
||||
"ButtonCreate": "צור",
|
||||
@ -28,6 +29,7 @@
|
||||
"ButtonEdit": "ערוך",
|
||||
"ButtonEditChapters": "ערוך פרקים",
|
||||
"ButtonEditPodcast": "ערוך פודקאסט",
|
||||
"ButtonEnable": "הפעל",
|
||||
"ButtonForceReScan": "סרוק מחדש בכוח",
|
||||
"ButtonFullPath": "נתיב מלא",
|
||||
"ButtonHide": "הסתר",
|
||||
@ -46,19 +48,24 @@
|
||||
"ButtonNevermind": "לא משנה",
|
||||
"ButtonNext": "הבא",
|
||||
"ButtonNextChapter": "פרק הבא",
|
||||
"ButtonNextItemInQueue": "פריט הבא בתור",
|
||||
"ButtonOk": "אישור",
|
||||
"ButtonOpenFeed": "פתח פיד",
|
||||
"ButtonOpenManager": "פתח מנהל",
|
||||
"ButtonPause": "השהה",
|
||||
"ButtonPlay": "נגן",
|
||||
"ButtonPlayAll": "נגן הכל",
|
||||
"ButtonPlaying": "מנגן",
|
||||
"ButtonPlaylists": "רשימות השמעה",
|
||||
"ButtonPrevious": "קודם",
|
||||
"ButtonPreviousChapter": "פרק קודם",
|
||||
"ButtonProbeAudioFile": "בדוק קובץ אודיו",
|
||||
"ButtonPurgeAllCache": "נקה את כל המטמון",
|
||||
"ButtonPurgeItemsCache": "נקה את מטמון הפריטים",
|
||||
"ButtonQueueAddItem": "הוסף לתור",
|
||||
"ButtonQueueRemoveItem": "הסר מהתור",
|
||||
"ButtonQuickEmbed": "הטמעה מהירה",
|
||||
"ButtonQuickEmbedMetadata": "הטמעת מטא נתונים מהירה",
|
||||
"ButtonQuickMatch": "התאמה מהירה",
|
||||
"ButtonReScan": "סרוק מחדש",
|
||||
"ButtonRead": "קרא",
|
||||
@ -88,8 +95,10 @@
|
||||
"ButtonShow": "הצג",
|
||||
"ButtonStartM4BEncode": "התחל קידוד M4B",
|
||||
"ButtonStartMetadataEmbed": "התחל הטמעת מטא-נתונים",
|
||||
"ButtonStats": "סטטיסטיקות",
|
||||
"ButtonSubmit": "שלח",
|
||||
"ButtonTest": "בדיקה",
|
||||
"ButtonUnlinkOpenId": "נתק OpenID",
|
||||
"ButtonUpload": "העלה",
|
||||
"ButtonUploadBackup": "העלה גיבוי",
|
||||
"ButtonUploadCover": "העלה כריכה",
|
||||
@ -102,6 +111,7 @@
|
||||
"ErrorUploadFetchMetadataNoResults": "לא ניתן לשלוף מטא-נתונים - נסה לעדכן כותרת ו/או יוצר",
|
||||
"ErrorUploadLacksTitle": "חובה לתת כותרת",
|
||||
"HeaderAccount": "חשבון",
|
||||
"HeaderAddCustomMetadataProvider": "הוסף ספק מטא-נתונים מותאם אישית",
|
||||
"HeaderAdvanced": "מתקדם",
|
||||
"HeaderAppriseNotificationSettings": "הגדרות התראות של Apprise",
|
||||
"HeaderAudioTracks": "רצועות קול",
|
||||
@ -147,13 +157,17 @@
|
||||
"HeaderMetadataToEmbed": "מטא-נתונים להטמעה",
|
||||
"HeaderNewAccount": "חשבון חדש",
|
||||
"HeaderNewLibrary": "ספרייה חדשה",
|
||||
"HeaderNotificationCreate": "צור התראה",
|
||||
"HeaderNotificationUpdate": "עדכון התראה",
|
||||
"HeaderNotifications": "התראות",
|
||||
"HeaderOpenIDConnectAuthentication": "אימות OpenID Connect",
|
||||
"HeaderOpenListeningSessions": "פתח הפעלות האזנה",
|
||||
"HeaderOpenRSSFeed": "פתח ערוץ RSS",
|
||||
"HeaderOtherFiles": "קבצים אחרים",
|
||||
"HeaderPasswordAuthentication": "אימות סיסמה",
|
||||
"HeaderPermissions": "הרשאות",
|
||||
"HeaderPlayerQueue": "תור ניגון",
|
||||
"HeaderPlayerSettings": "הגדרות נגן",
|
||||
"HeaderPlaylist": "רשימת השמעה",
|
||||
"HeaderPlaylistItems": "פריטי רשימת השמעה",
|
||||
"HeaderPodcastsToAdd": "פודקאסטים להוספה",
|
||||
@ -165,6 +179,7 @@
|
||||
"HeaderRemoveEpisodes": "הסר {0} פרקים",
|
||||
"HeaderSavedMediaProgress": "התקדמות מדיה שמורה",
|
||||
"HeaderSchedule": "תיזמון",
|
||||
"HeaderScheduleEpisodeDownloads": "תזמן הורדת פרקים אוטומטית",
|
||||
"HeaderScheduleLibraryScans": "קבע סריקות ספרייה אוטומטיות",
|
||||
"HeaderSession": "הפעלה",
|
||||
"HeaderSetBackupSchedule": "קבע לוח זמנים לגיבוי",
|
||||
@ -190,6 +205,9 @@
|
||||
"HeaderYearReview": "שנת {0} בסקירה",
|
||||
"HeaderYourStats": "הסטטיסטיקות שלך",
|
||||
"LabelAbridged": "מקוצר",
|
||||
"LabelAbridgedChecked": "מקוצר (מסומן)",
|
||||
"LabelAbridgedUnchecked": "בלתי מקוצר (לא מסומן)",
|
||||
"LabelAccessibleBy": "נגיש על ידי",
|
||||
"LabelAccountType": "סוג חשבון",
|
||||
"LabelAccountTypeAdmin": "מנהל",
|
||||
"LabelAccountTypeGuest": "אורח",
|
||||
@ -200,13 +218,18 @@
|
||||
"LabelAddToPlaylist": "הוסף לרשימת השמעה",
|
||||
"LabelAddToPlaylistBatch": "הוסף {0} פריטים לרשימת השמעה",
|
||||
"LabelAddedAt": "נוסף בתאריך",
|
||||
"LabelAddedDate": "נוסף ב-{0}",
|
||||
"LabelAdminUsersOnly": "רק מנהלים",
|
||||
"LabelAll": "הכל",
|
||||
"LabelAllUsers": "כל המשתמשים",
|
||||
"LabelAllUsersExcludingGuests": "כל המשתמשים, ללא אורחים",
|
||||
"LabelAllUsersIncludingGuests": "כל המשתמשים כולל אורחים",
|
||||
"LabelAlreadyInYourLibrary": "כבר קיים בספרייה שלך",
|
||||
"LabelApiToken": "טוקן API",
|
||||
"LabelAppend": "הוסף לסוף",
|
||||
"LabelAudioBitrate": "קצב סיביות (לדוגמא 128k)",
|
||||
"LabelAudioChannels": "ערוצי קול (1 או 2)",
|
||||
"LabelAudioCodec": "קידוד קול",
|
||||
"LabelAuthor": "יוצר",
|
||||
"LabelAuthorFirstLast": "יוצר (שם פרטי שם משפחה)",
|
||||
"LabelAuthorLastFirst": "יוצר (שם משפחה, שם פרטי)",
|
||||
|
@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "Spremi popis zvučnih zapisa",
|
||||
"ButtonScan": "Skeniraj",
|
||||
"ButtonScanLibrary": "Skeniraj knjižnicu",
|
||||
"ButtonScrollLeft": "Pomicanje lijevo",
|
||||
"ButtonScrollRight": "Pomicanje desno",
|
||||
"ButtonSearch": "Traži",
|
||||
"ButtonSelectFolderPath": "Odaberi putanju mape",
|
||||
"ButtonSeries": "Serijali",
|
||||
@ -190,6 +192,7 @@
|
||||
"HeaderSettingsExperimental": "Eksperimentalne značajke",
|
||||
"HeaderSettingsGeneral": "Općenito",
|
||||
"HeaderSettingsScanner": "Skener",
|
||||
"HeaderSettingsWebClient": "Web klijent",
|
||||
"HeaderSleepTimer": "Timer za spavanje",
|
||||
"HeaderStatsLargestItems": "Najveće stavke",
|
||||
"HeaderStatsLongestItems": "Najduže stavke (sati)",
|
||||
@ -271,7 +274,7 @@
|
||||
"LabelCollapseSubSeries": "Podserijale prikaži sažeto",
|
||||
"LabelCollection": "Zbirka",
|
||||
"LabelCollections": "Zbirke",
|
||||
"LabelComplete": "Dovršeno",
|
||||
"LabelComplete": "Potpuno",
|
||||
"LabelConfirmPassword": "Potvrda zaporke",
|
||||
"LabelContinueListening": "Nastavi slušati",
|
||||
"LabelContinueReading": "Nastavi čitati",
|
||||
@ -532,7 +535,7 @@
|
||||
"LabelSelectAllEpisodes": "Označi sve nastavke",
|
||||
"LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka",
|
||||
"LabelSelectUsers": "Označi korisnike",
|
||||
"LabelSendEbookToDevice": "Pošalji e-knjigu",
|
||||
"LabelSendEbookToDevice": "Pošalji e-knjigu …",
|
||||
"LabelSequence": "Slijed",
|
||||
"LabelSerial": "Serijal",
|
||||
"LabelSeries": "Serijal",
|
||||
@ -542,6 +545,7 @@
|
||||
"LabelServerYearReview": "Godišnji pregled poslužitelja ({0})",
|
||||
"LabelSetEbookAsPrimary": "Postavi kao primarno",
|
||||
"LabelSetEbookAsSupplementary": "Postavi kao dopunsko",
|
||||
"LabelSettingsAllowIframe": "Omogući ugrađivanje u iframeu",
|
||||
"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",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorfni dizajn sa drvenim policama",
|
||||
@ -567,7 +571,7 @@
|
||||
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)",
|
||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada",
|
||||
"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",
|
||||
"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",
|
||||
@ -592,6 +596,8 @@
|
||||
"LabelSize": "Veličina",
|
||||
"LabelSleepTimer": "Timer za spavanje",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelSortAscending": "Uzlazno",
|
||||
"LabelSortDescending": "Silazno",
|
||||
"LabelStart": "Početak",
|
||||
"LabelStartTime": "Vrijeme početka",
|
||||
"LabelStarted": "Započeto",
|
||||
@ -663,6 +669,7 @@
|
||||
"LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju",
|
||||
"LabelUpdatedAt": "Ažurirano",
|
||||
"LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Pritisni i prevuci datoteke",
|
||||
"LabelUploaderDropFiles": "Ispusti datoteke",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
|
||||
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama",
|
||||
@ -678,6 +685,8 @@
|
||||
"LabelViewPlayerSettings": "Pogledaj postavke reproduktora",
|
||||
"LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora",
|
||||
"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",
|
||||
"LabelXBooks": "{0} knjiga",
|
||||
"LabelXItems": "{0} stavki",
|
||||
@ -813,7 +822,7 @@
|
||||
"MessagePlaylistCreateFromCollection": "Stvori popis za izvođenje od zbirke",
|
||||
"MessagePleaseWait": "Molimo pričekajte...",
|
||||
"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",
|
||||
"MessageQuickEmbedQueue": "Dodano u red za brzo ugrađivanje ({0} u redu izvođenja)",
|
||||
"MessageQuickMatchAllEpisodes": "Brzo prepoznavanje svih nastavaka",
|
||||
|
@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "Sávlista mentése",
|
||||
"ButtonScan": "Szkennelés",
|
||||
"ButtonScanLibrary": "Könyvtár szkennelése",
|
||||
"ButtonScrollLeft": "Balra görgetés",
|
||||
"ButtonScrollRight": "Jobbra görgetés",
|
||||
"ButtonSearch": "Keresés",
|
||||
"ButtonSelectFolderPath": "Mappa útvonalának kiválasztása",
|
||||
"ButtonSeries": "Sorozatok",
|
||||
@ -180,6 +182,7 @@
|
||||
"HeaderRemoveEpisodes": "{0} epizód eltávolítása",
|
||||
"HeaderSavedMediaProgress": "Mentett médialejátszási állapot",
|
||||
"HeaderSchedule": "Ütemezés",
|
||||
"HeaderScheduleEpisodeDownloads": "Automatikus epizódletöltés ütemezése",
|
||||
"HeaderScheduleLibraryScans": "Könyvtárak automatikus szkennelésének ütemezése",
|
||||
"HeaderSession": "Munkamenet",
|
||||
"HeaderSetBackupSchedule": "Biztonsági másolatok ütemezésének beállítása",
|
||||
@ -188,13 +191,14 @@
|
||||
"HeaderSettingsExperimental": "Kísérleti funkciók",
|
||||
"HeaderSettingsGeneral": "Általános",
|
||||
"HeaderSettingsScanner": "Szkenner",
|
||||
"HeaderSettingsWebClient": "Webkliens",
|
||||
"HeaderSleepTimer": "Alvásidőzítő",
|
||||
"HeaderStatsLargestItems": "Legnagyobb elemek",
|
||||
"HeaderStatsLongestItems": "Leghosszabb elemek (órákban)",
|
||||
"HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)",
|
||||
"HeaderStatsRecentSessions": "Legutóbbi munkamenetek",
|
||||
"HeaderStatsTop10Authors": "Top 10 szerzők",
|
||||
"HeaderStatsTop5Genres": "Top 5 műfajok",
|
||||
"HeaderStatsTop10Authors": "Top 10 szerző",
|
||||
"HeaderStatsTop5Genres": "Top 5 műfaj",
|
||||
"HeaderTableOfContents": "Tartalomjegyzék",
|
||||
"HeaderTools": "Eszközök",
|
||||
"HeaderUpdateAccount": "Fiók frissítése",
|
||||
@ -225,7 +229,11 @@
|
||||
"LabelAllUsersExcludingGuests": "Minden felhasználó, vendégek kivételével",
|
||||
"LabelAllUsersIncludingGuests": "Minden felhasználó, beleértve a vendégeket is",
|
||||
"LabelAlreadyInYourLibrary": "Már a könyvtárában van",
|
||||
"LabelApiToken": "API Token",
|
||||
"LabelAppend": "Hozzáfűzés",
|
||||
"LabelAudioBitrate": "Audió bitráta (pl.128k)",
|
||||
"LabelAudioChannels": "Audió csatorna (1 vagy 2)",
|
||||
"LabelAudioCodec": "Audio Codec",
|
||||
"LabelAuthor": "Szerző",
|
||||
"LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)",
|
||||
"LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)",
|
||||
@ -238,6 +246,7 @@
|
||||
"LabelAutoRegister": "Automatikus regisztráció",
|
||||
"LabelAutoRegisterDescription": "Új felhasználók automatikus létrehozása bejelentkezés után",
|
||||
"LabelBackToUser": "Vissza a felhasználóhoz",
|
||||
"LabelBackupAudioFiles": "Audiófájlok biztonsági mentése",
|
||||
"LabelBackupLocation": "Biztonsági másolat helye",
|
||||
"LabelBackupsEnableAutomaticBackups": "Automatikus biztonsági másolatok engedélyezése",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Biztonsági másolatok mentése a /metadata/backups mappába",
|
||||
@ -246,15 +255,18 @@
|
||||
"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.",
|
||||
"LabelBitrate": "Bitráta",
|
||||
"LabelBonus": "Bónusz",
|
||||
"LabelBooks": "Könyvek",
|
||||
"LabelButtonText": "Gomb szövege",
|
||||
"LabelByAuthor": "{} által",
|
||||
"LabelChangePassword": "Jelszó megváltoztatása",
|
||||
"LabelChannels": "Csatornák",
|
||||
"LabelChapterCount": "{0} Fejezet",
|
||||
"LabelChapterTitle": "Fejezet címe",
|
||||
"LabelChapters": "Fejezetek",
|
||||
"LabelChaptersFound": "fejezet található",
|
||||
"LabelClickForMoreInfo": "További információkért kattintson",
|
||||
"LabelClickToUseCurrentValue": "Kattintson az aktuális érték használatához",
|
||||
"LabelClosePlayer": "Lejátszó bezárása",
|
||||
"LabelCodec": "Kodek",
|
||||
"LabelCollapseSeries": "Sorozat összecsukása",
|
||||
@ -304,16 +316,28 @@
|
||||
"LabelEmailSettingsTestAddress": "Teszt cím",
|
||||
"LabelEmbeddedCover": "Beágyazott borító",
|
||||
"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",
|
||||
"LabelEndOfChapter": "Fejezet vége",
|
||||
"LabelEpisode": "Epizód",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "Epizód nem kapcsolódik RSS hírcsatonához",
|
||||
"LabelEpisodeNumber": "Epizód #{0}",
|
||||
"LabelEpisodeTitle": "Epizód címe",
|
||||
"LabelEpisodeType": "Epizód típusa",
|
||||
"LabelEpisodeUrlFromRssFeed": "Epizód URL-címe az RSS hírcsatornából",
|
||||
"LabelEpisodes": "Epizódok",
|
||||
"LabelEpisodic": "Epizódikus",
|
||||
"LabelExample": "Példa",
|
||||
"LabelExpandSeries": "Sorozat kinyitása",
|
||||
"LabelExpandSubSeries": "Alsorozat kinyitása",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelExplicit": "Szókimondó",
|
||||
"LabelExplicitChecked": "Explicit (ellenőrizve)",
|
||||
"LabelExplicitUnchecked": "Nem explicit (nem ellenőrzött)",
|
||||
"LabelExportOPML": "OPML exportálása",
|
||||
@ -337,6 +361,7 @@
|
||||
"LabelFontScale": "Betűméret skála",
|
||||
"LabelFontStrikethrough": "Áthúzott",
|
||||
"LabelFormat": "Formátum",
|
||||
"LabelFull": "Teljes",
|
||||
"LabelGenre": "Műfaj",
|
||||
"LabelGenres": "Műfajok",
|
||||
"LabelHardDeleteFile": "Fájl végleges törlése",
|
||||
@ -392,6 +417,10 @@
|
||||
"LabelLowestPriority": "Legalacsonyabb prioritás",
|
||||
"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",
|
||||
"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ó",
|
||||
"LabelMediaType": "Média típus",
|
||||
"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",
|
||||
"LabelMetadataProvider": "Metaadat-szolgáltató",
|
||||
"LabelMinute": "Perc",
|
||||
"LabelMinutes": "Percek",
|
||||
"LabelMinutes": "Perc",
|
||||
"LabelMissing": "Hiányzó",
|
||||
"LabelMissingEbook": "Nincs e-könyve",
|
||||
"LabelMissingSupplementaryEbook": "Nincs kiegészítő e-könyve",
|
||||
@ -434,15 +463,17 @@
|
||||
"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:",
|
||||
"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",
|
||||
"LabelOverwrite": "Felülírás",
|
||||
"LabelPaginationPageXOfY": "{0} oldal {1}-ból/ből",
|
||||
"LabelPassword": "Jelszó",
|
||||
"LabelPath": "Útvonal",
|
||||
"LabelPermanent": "Végleges",
|
||||
"LabelPermissionsAccessAllLibraries": "Hozzáférhet az összes könyvtárhoz",
|
||||
"LabelPermissionsAccessAllTags": "Hozzáférhet az összes címkéhez",
|
||||
"LabelPermissionsAccessExplicitContent": "Hozzáférhet explicit tartalomhoz",
|
||||
"LabelPermissionsCreateEreader": "Létrehozhat Ereader-t",
|
||||
"LabelPermissionsDelete": "Törölhet",
|
||||
"LabelPermissionsDownload": "Letölthet",
|
||||
"LabelPermissionsUpdate": "Frissíthet",
|
||||
@ -466,6 +497,8 @@
|
||||
"LabelPubDate": "Kiadás dátuma",
|
||||
"LabelPublishYear": "Kiadás éve",
|
||||
"LabelPublishedDate": "Kiadva {0}",
|
||||
"LabelPublishedDecade": "Közzétett évtized",
|
||||
"LabelPublishedDecades": "Közzétett évtized",
|
||||
"LabelPublisher": "Kiadó",
|
||||
"LabelPublishers": "Kiadók",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Egyéni tulajdonos e-mail",
|
||||
@ -475,6 +508,7 @@
|
||||
"LabelRSSFeedSlug": "RSS hírcsatorna slug",
|
||||
"LabelRSSFeedURL": "RSS hírcsatorna URL",
|
||||
"LabelRandomly": "Véletlenszerűen",
|
||||
"LabelReAddSeriesToContinueListening": "Sorozat újbóli hozzáadása a folytatáshoz",
|
||||
"LabelRead": "Olvasás",
|
||||
"LabelReadAgain": "Újraolvasás",
|
||||
"LabelReadEbookWithoutProgress": "E-könyv olvasása haladás nélkül",
|
||||
@ -484,12 +518,18 @@
|
||||
"LabelRedo": "Újra",
|
||||
"LabelRegion": "Régió",
|
||||
"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",
|
||||
"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",
|
||||
"LabelSearchTerm": "Keresési kifejezés",
|
||||
"LabelSearchTitle": "Cím keresése",
|
||||
"LabelSearchTitleOrASIN": "Cím vagy ASIN keresése",
|
||||
"LabelSeason": "Évad",
|
||||
"LabelSeasonNumber": "Évad #{0}",
|
||||
"LabelSelectAll": "Minden kiválasztása",
|
||||
"LabelSelectAllEpisodes": "Összes epizód kiválasztása",
|
||||
"LabelSelectEpisodesShowing": "Kiválasztás {0} megjelenített epizód",
|
||||
"LabelSelectUsers": "Felhasználók kiválasztása",
|
||||
@ -498,8 +538,11 @@
|
||||
"LabelSeries": "Sorozat",
|
||||
"LabelSeriesName": "Sorozat neve",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
@ -511,6 +554,8 @@
|
||||
"LabelSettingsEnableWatcher": "Figyelő engedélyezése",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
@ -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.",
|
||||
"LabelSettingsHomePageBookshelfView": "Kezdőlap 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",
|
||||
"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",
|
||||
@ -534,10 +584,14 @@
|
||||
"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",
|
||||
"LabelSettingsTimeFormat": "Időformátum",
|
||||
"LabelShare": "Megosztás",
|
||||
"LabelShowAll": "Mindent mutat",
|
||||
"LabelShowSubtitles": "Felirat megjelenítése",
|
||||
"LabelSize": "Méret",
|
||||
"LabelSleepTimer": "Alvásidőzítő",
|
||||
"LabelSlug": "Rövid cím",
|
||||
"LabelSortAscending": "Emelkedő",
|
||||
"LabelSortDescending": "Csökkenő",
|
||||
"LabelStart": "Kezdés",
|
||||
"LabelStartTime": "Kezdési idő",
|
||||
"LabelStarted": "Elkezdődött",
|
||||
@ -547,13 +601,13 @@
|
||||
"LabelStatsBestDay": "Legjobb nap",
|
||||
"LabelStatsDailyAverage": "Napi átlag",
|
||||
"LabelStatsDays": "Napok",
|
||||
"LabelStatsDaysListened": "Hallgatott napok",
|
||||
"LabelStatsDaysListened": "Napon hallgatva",
|
||||
"LabelStatsHours": "Órák",
|
||||
"LabelStatsInARow": "egymás után",
|
||||
"LabelStatsItemsFinished": "Befejezett elemek",
|
||||
"LabelStatsItemsFinished": "Befejezett elem",
|
||||
"LabelStatsItemsInLibrary": "Elemek a könyvtárban",
|
||||
"LabelStatsMinutes": "percek",
|
||||
"LabelStatsMinutesListening": "Hallgatási percek",
|
||||
"LabelStatsMinutes": "perc",
|
||||
"LabelStatsMinutesListening": "Hallgatási perc",
|
||||
"LabelStatsOverallDays": "Összes nap",
|
||||
"LabelStatsOverallHours": "Összes óra",
|
||||
"LabelStatsWeekListening": "Heti hallgatás",
|
||||
@ -565,12 +619,18 @@
|
||||
"LabelTagsNotAccessibleToUser": "A felhasználó számára nem elérhető címkék",
|
||||
"LabelTasks": "Futó feladatok",
|
||||
"LabelTextEditorBulletedList": "Pontozott lista",
|
||||
"LabelTextEditorLink": "Hivatkozás",
|
||||
"LabelTextEditorNumberedList": "Számozott lista",
|
||||
"LabelTextEditorUnlink": "Link eltávolítása",
|
||||
"LabelTheme": "Téma",
|
||||
"LabelThemeDark": "Sötét",
|
||||
"LabelThemeLight": "Világos",
|
||||
"LabelTimeBase": "Időalap",
|
||||
"LabelTimeDurationXHours": "{0} óra",
|
||||
"LabelTimeDurationXMinutes": "{0} perc",
|
||||
"LabelTimeDurationXSeconds": "{0} másodperc",
|
||||
"LabelTimeInMinutes": "Idő percben",
|
||||
"LabelTimeLeft": "{0} maradt hátra",
|
||||
"LabelTimeListened": "Hallgatott idő",
|
||||
"LabelTimeListenedToday": "Ma hallgatott idő",
|
||||
"LabelTimeRemaining": "{0} maradt",
|
||||
@ -578,6 +638,7 @@
|
||||
"LabelTitle": "Cím",
|
||||
"LabelToolsEmbedMetadata": "Metaadatok beágyazása",
|
||||
"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",
|
||||
"LabelToolsMakeM4bDescription": ".M4B hangoskönyv fájl generálása beágyazott metaadatokkal, borítóképpel és fejezetekkel.",
|
||||
"LabelToolsSplitM4b": "M4B felosztása MP3-ra",
|
||||
@ -590,29 +651,41 @@
|
||||
"LabelTracksMultiTrack": "Többsávos",
|
||||
"LabelTracksNone": "Nincsenek sávok",
|
||||
"LabelTracksSingleTrack": "Egysávos",
|
||||
"LabelTrailer": "Előzetes",
|
||||
"LabelType": "Típus",
|
||||
"LabelUnabridged": "Nem tömörített",
|
||||
"LabelUndo": "Visszavonás",
|
||||
"LabelUnknown": "Ismeretlen",
|
||||
"LabelUnknownPublishDate": "Ismeretlen megjelenési dátum",
|
||||
"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",
|
||||
"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",
|
||||
"LabelUpdatedAt": "Frissítve",
|
||||
"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",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Cím, szerző és sorozat automatikus lekérése",
|
||||
"LabelUseAdvancedOptions": "Haladó beállítások használata",
|
||||
"LabelUseChapterTrack": "Fejezetsáv használata",
|
||||
"LabelUseFullTrack": "Teljes sáv használata",
|
||||
"LabelUseZeroForUnlimited": "Használja a 0-t a korlátlan értékhez",
|
||||
"LabelUser": "Felhasználó",
|
||||
"LabelUsername": "Felhasználónév",
|
||||
"LabelValue": "Érték",
|
||||
"LabelVersion": "Verzió",
|
||||
"LabelViewBookmarks": "Könyvjelzők megtekintése",
|
||||
"LabelViewChapters": "Fejezetek megtekintése",
|
||||
"LabelViewPlayerSettings": "A lejátszó beállításainak megtekintése",
|
||||
"LabelViewQueue": "Lejátszó sor megtekintése",
|
||||
"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",
|
||||
"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",
|
||||
"LabelYourBookmarks": "Könyvjelzőid",
|
||||
"LabelYourPlaylists": "Lejátszási listáid",
|
||||
@ -620,10 +693,14 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"MessageBookshelfNoCollections": "Még nem készített gyűjteményeket",
|
||||
"MessageBookshelfNoRSSFeeds": "Nincsenek nyitott RSS hírcsatornák",
|
||||
"MessageBookshelfNoResultsForFilter": "Nincs eredmény a \"{0}: {1}\" szűrőre",
|
||||
"MessageBookshelfNoResultsForQuery": "Nincs eredmény a lekérdezéshez",
|
||||
"MessageBookshelfNoSeries": "Nincsenek sorozatai",
|
||||
"MessageChapterEndIsAfter": "A fejezet vége a hangoskönyv végét követi",
|
||||
"MessageChapterErrorFirstNotZero": "Az első fejezetnek 0:00-kor kell kezdődnie",
|
||||
@ -633,17 +710,27 @@
|
||||
"MessageCheckingCron": "Cron ellenőrzése...",
|
||||
"MessageConfirmCloseFeed": "Biztosan be szeretné zárni ezt a hírcsatornát?",
|
||||
"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?",
|
||||
"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?",
|
||||
"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?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Biztos, hogy metaadatokat szeretne beágyazni {0} hangfájlba?",
|
||||
"MessageConfirmForceReScan": "Biztosan kényszeríteni szeretné az újraszkennelést?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Biztosan meg szeretné jelölni az összes epizódot 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?",
|
||||
"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?",
|
||||
"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?",
|
||||
"MessageConfirmRemoveAllChapters": "Biztosan eltávolítja az összes fejezetet?",
|
||||
"MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?",
|
||||
@ -651,6 +738,7 @@
|
||||
"MessageConfirmRemoveEpisode": "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?",
|
||||
"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?",
|
||||
"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?",
|
||||
@ -659,11 +747,15 @@
|
||||
"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.",
|
||||
"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?",
|
||||
"MessageConfirmUnlinkOpenId": "Biztos, hogy el akarja távolítani ezt a felhasználót az OpenID-ból?",
|
||||
"MessageDownloadingEpisode": "Epizód letöltése",
|
||||
"MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe",
|
||||
"MessageEmbedFailed": "A beágyazás sikertelen!",
|
||||
"MessageEmbedFinished": "Beágyazás befejeződött!",
|
||||
"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",
|
||||
"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.",
|
||||
@ -671,10 +763,11 @@
|
||||
"MessageInsertChapterBelow": "Fejezet beszúrása alulra",
|
||||
"MessageItemsSelected": "{0} kiválasztott elem",
|
||||
"MessageItemsUpdated": "{0} frissített elem",
|
||||
"MessageJoinUsOn": "Csatlakozzon hozzánk",
|
||||
"MessageJoinUsOn": "Csatlakozzon hozzánk a",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} hallgatási munkamenet az elmúlt évben",
|
||||
"MessageLoading": "Betöltés...",
|
||||
"MessageLoadingFolders": "Mappák betöltése...",
|
||||
"MessageLogsDescription": "A naplók a <code>/metadata/logs</code> mappában JSON-fájlokként tárolódnak. Az összeomlási naplók a <code>/metadata/logs/crash_logs.txt</code> fájlban tárolódnak.",
|
||||
"MessageM4BFailed": "M4B sikertelen!",
|
||||
"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á",
|
||||
@ -691,6 +784,7 @@
|
||||
"MessageNoCollections": "Nincsenek gyűjtemények",
|
||||
"MessageNoCoversFound": "Nem találhatóak borítók",
|
||||
"MessageNoDescription": "Nincs leírás",
|
||||
"MessageNoDevices": "Nincs eszköz",
|
||||
"MessageNoDownloadsInProgress": "Jelenleg nincsenek folyamatban lévő letöltések",
|
||||
"MessageNoDownloadsQueued": "Nincsenek várakozó letöltések",
|
||||
"MessageNoEpisodeMatchesFound": "Nincs találat az epizódokra",
|
||||
@ -704,6 +798,7 @@
|
||||
"MessageNoLogs": "Nincsenek naplók",
|
||||
"MessageNoMediaProgress": "Nincs előrehaladás a médialejátszásban",
|
||||
"MessageNoNotifications": "Nincsenek értesítések",
|
||||
"MessageNoPodcastFeed": "Érvénytelen podcast: Nincs forrás",
|
||||
"MessageNoPodcastsFound": "Nem találhatóak podcastok",
|
||||
"MessageNoResults": "Nincsenek eredmények",
|
||||
"MessageNoSearchResultsFor": "Nincs keresési eredmény erre: \"{0}\"",
|
||||
@ -713,11 +808,16 @@
|
||||
"MessageNoUpdatesWereNecessary": "Nem volt szükség frissítésekre",
|
||||
"MessageNoUserPlaylists": "Nincsenek felhasználói lejátszási listák",
|
||||
"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",
|
||||
"MessagePauseChapter": "Fejezet lejátszásának szüneteltetése",
|
||||
"MessagePlayChapter": "Fejezet elejének meghallgatása",
|
||||
"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",
|
||||
"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.",
|
||||
"MessageRemoveChapter": "Fejezet eltávolítása",
|
||||
"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?",
|
||||
"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?",
|
||||
"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.",
|
||||
"MessageSearchResultsFor": "Keresési eredmények",
|
||||
"MessageSelected": "{0} kiválasztva",
|
||||
"MessageServerCouldNotBeReached": "A szervert nem lehet elérni",
|
||||
"MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként",
|
||||
"MessageShareExpirationWillBe": "A lejárat: <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "{0} múlva jár le",
|
||||
"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",
|
||||
"MessageUploaderItemSuccess": "Sikeresen feltöltve!",
|
||||
"MessageUploading": "Feltöltés...",
|
||||
@ -744,45 +879,101 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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",
|
||||
"PlaceholderNewFolderPath": "Új mappa útvonala",
|
||||
"PlaceholderNewPlaylist": "Új lejátszási lista neve",
|
||||
"PlaceholderSearch": "Keresés..",
|
||||
"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",
|
||||
"ToastAppriseUrlRequired": "Meg kell adnia egy Apprise URL-címet",
|
||||
"ToastAsinRequired": "ASIN kötelező",
|
||||
"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",
|
||||
"ToastAuthorUpdateSuccess": "Szerző frissítve",
|
||||
"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",
|
||||
"ToastBackupCreateSuccess": "Biztonsági mentés létrehozva",
|
||||
"ToastBackupDeleteFailed": "A biztonsági mentés törlése sikertelen",
|
||||
"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",
|
||||
"ToastBackupUploadFailed": "A biztonsági mentés feltöltése sikertelen",
|
||||
"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",
|
||||
"ToastBatchUpdateSuccess": "Kötegelt frissítés sikeres",
|
||||
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
|
||||
"ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva",
|
||||
"ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva",
|
||||
"ToastBookmarkUpdateSuccess": "Könyvjelző frissítve",
|
||||
"ToastCachePurgeFailed": "A gyorsítótár törlése sikertelen",
|
||||
"ToastCachePurgeSuccess": "A gyorsítótár sikeresen törölve",
|
||||
"ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
|
||||
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
|
||||
"ToastChaptersRemoved": "Fejezetek eltávolítva",
|
||||
"ToastChaptersUpdated": "Fejezetek frissítve",
|
||||
"ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből",
|
||||
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
|
||||
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
|
||||
"ToastCoverUpdateFailed": "A borító frissítése nem sikerült",
|
||||
"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",
|
||||
"ToastItemDeletedFailed": "Nem sikerült törölni az elemet",
|
||||
"ToastItemDeletedSuccess": "Elem törölve",
|
||||
"ToastItemDetailsUpdateSuccess": "Elem részletei frissítve",
|
||||
"ToastItemMarkedAsFinishedFailed": "Megjelölés Befejezettként sikertelen",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Elem megjelölve Befejezettként",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Az elem befejezetlennek jelölése sikertelen",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Elem megjelölve Nem Befejezettként",
|
||||
"ToastItemUpdateSuccess": "Elem frissítve",
|
||||
"ToastLibraryCreateFailed": "Könyvtár létrehozása sikertelen",
|
||||
"ToastLibraryCreateSuccess": "\"{0}\" könyvtár létrehozva",
|
||||
"ToastLibraryDeleteFailed": "Könyvtár törlése sikertelen",
|
||||
@ -790,14 +981,34 @@
|
||||
"ToastLibraryScanFailedToStart": "A beolvasás elindítása sikertelen",
|
||||
"ToastLibraryScanStarted": "Könyvtár beolvasása elindítva",
|
||||
"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",
|
||||
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
|
||||
"ToastPlaylistRemoveSuccess": "Lejátszási lista eltávolítva",
|
||||
"ToastPlaylistUpdateSuccess": "Lejátszási lista frissítve",
|
||||
"ToastPodcastCreateFailed": "Podcast létrehozása sikertelen",
|
||||
"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",
|
||||
"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",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
|
||||
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
|
||||
@ -809,6 +1020,9 @@
|
||||
"ToastSocketConnected": "Socket csatlakoztatva",
|
||||
"ToastSocketDisconnected": "Socket lecsatlakoztatva",
|
||||
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
|
||||
"ToastUnknownError": "Ismeretlen hiba",
|
||||
"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"
|
||||
}
|
||||
|
@ -66,13 +66,13 @@
|
||||
"ButtonPurgeItemsCache": "Elimina la Cache selezionata",
|
||||
"ButtonQueueAddItem": "Aggiungi alla Coda",
|
||||
"ButtonQueueRemoveItem": "Rimuovi dalla Coda",
|
||||
"ButtonQuickEmbed": "Quick Embed",
|
||||
"ButtonQuickEmbed": "Incorporazione Rapida",
|
||||
"ButtonQuickEmbedMetadata": "Incorporamento rapido Metadati",
|
||||
"ButtonQuickMatch": "Controlla Metadata Auto",
|
||||
"ButtonReScan": "Ri-scansiona",
|
||||
"ButtonRead": "Leggi",
|
||||
"ButtonReadLess": "Leggi di Meno",
|
||||
"ButtonReadMore": "Leggi di Più",
|
||||
"ButtonReadLess": "Riduci",
|
||||
"ButtonReadMore": "Espandi",
|
||||
"ButtonRefresh": "Aggiorna",
|
||||
"ButtonRemove": "Rimuovi",
|
||||
"ButtonRemoveAll": "Rimuovi Tutto",
|
||||
@ -220,7 +220,7 @@
|
||||
"LabelAddToPlaylist": "Aggiungi alla playlist",
|
||||
"LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
|
||||
"LabelAddedAt": "Aggiunto il",
|
||||
"LabelAddedDate": "{0} aggiunti",
|
||||
"LabelAddedDate": "Aggiunti {0}",
|
||||
"LabelAdminUsersOnly": "Solo utenti Amministratori",
|
||||
"LabelAll": "Tutti",
|
||||
"LabelAllUsers": "Tutti gli Utenti",
|
||||
@ -495,7 +495,7 @@
|
||||
"LabelProviderAuthorizationValue": "Authorization Header Value",
|
||||
"LabelPubDate": "Data di pubblicazione",
|
||||
"LabelPublishYear": "Anno di pubblicazione",
|
||||
"LabelPublishedDate": "{0} pubblicati",
|
||||
"LabelPublishedDate": "Pubblicati {0}",
|
||||
"LabelPublishedDecade": "Decennio di pubblicazione",
|
||||
"LabelPublishedDecades": "Decenni di pubblicazione",
|
||||
"LabelPublisher": "Editore",
|
||||
@ -682,7 +682,7 @@
|
||||
"LabelXBooks": "{0} libri",
|
||||
"LabelXItems": "{0} oggetti",
|
||||
"LabelYearReviewHide": "Nascondi Anno in rassegna",
|
||||
"LabelYearReviewShow": "Vedi Anno in rassegna",
|
||||
"LabelYearReviewShow": "Mostra Anno in rassegna",
|
||||
"LabelYourAudiobookDuration": "La durata dell'audiolibro",
|
||||
"LabelYourBookmarks": "I tuoi preferiti",
|
||||
"LabelYourPlaylists": "le tue Playlist",
|
||||
@ -779,7 +779,7 @@
|
||||
"MessageNoBackups": "Nessun Backup",
|
||||
"MessageNoBookmarks": "Nessun preferito",
|
||||
"MessageNoChapters": "Nessun capitolo",
|
||||
"MessageNoCollections": "Nessuna Raccolta",
|
||||
"MessageNoCollections": "Nessuna Collezione",
|
||||
"MessageNoCoversFound": "Nessuna Cover Trovata",
|
||||
"MessageNoDescription": "Nessuna descrizione",
|
||||
"MessageNoDevices": "nessun dispositivo",
|
||||
|
@ -4,6 +4,7 @@
|
||||
"ButtonAddDevice": "Legg til enhet",
|
||||
"ButtonAddLibrary": "Legg til bibliotek",
|
||||
"ButtonAddPodcasts": "Legg til podcast",
|
||||
"ButtonAddUser": "Legg til bruker",
|
||||
"ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek",
|
||||
"ButtonApply": "Bruk",
|
||||
"ButtonApplyChapters": "Bruk kapittel",
|
||||
@ -18,6 +19,7 @@
|
||||
"ButtonChooseFiles": "Velg filer",
|
||||
"ButtonClearFilter": "Bytt filter",
|
||||
"ButtonCloseFeed": "Lukk Feed",
|
||||
"ButtonCloseSession": "Lukk åpen økt",
|
||||
"ButtonCollections": "Samlinger",
|
||||
"ButtonConfigureScanner": "Konfigurer skanner",
|
||||
"ButtonCreate": "Opprett",
|
||||
|
@ -258,12 +258,15 @@
|
||||
"LabelDiscFromFilename": "Disco a partir do nome do arquivo",
|
||||
"LabelDiscFromMetadata": "Disco a partir dos metadados",
|
||||
"LabelDiscover": "Descobrir",
|
||||
"LabelDownload": "Download",
|
||||
"LabelDownloadNEpisodes": "Download de {0} Episódios",
|
||||
"LabelDuration": "Duração",
|
||||
"LabelDurationComparisonExactMatch": "(exato)",
|
||||
"LabelDurationComparisonLonger": "({0} maior)",
|
||||
"LabelDurationComparisonShorter": "({0} menor)",
|
||||
"LabelDurationFound": "Duração comprovada:",
|
||||
"LabelEbook": "Ebook",
|
||||
"LabelEbooks": "Ebooks",
|
||||
"LabelEdit": "Editar",
|
||||
"LabelEmailSettingsFromAddress": "Remetente",
|
||||
"LabelEmailSettingsRejectUnauthorized": "Rejeitar certificados não autorizados",
|
||||
|
@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "Сохранить список треков",
|
||||
"ButtonScan": "Сканировать",
|
||||
"ButtonScanLibrary": "Сканировать библиотеку",
|
||||
"ButtonScrollLeft": "Перемотать влево",
|
||||
"ButtonScrollRight": "Перемотать вправо",
|
||||
"ButtonSearch": "Поиск",
|
||||
"ButtonSelectFolderPath": "Выберите путь папки",
|
||||
"ButtonSeries": "Серии",
|
||||
@ -190,6 +192,7 @@
|
||||
"HeaderSettingsExperimental": "Экспериментальные функции",
|
||||
"HeaderSettingsGeneral": "Основные",
|
||||
"HeaderSettingsScanner": "Сканер",
|
||||
"HeaderSettingsWebClient": "Веб-клиент",
|
||||
"HeaderSleepTimer": "Таймер сна",
|
||||
"HeaderStatsLargestItems": "Самые большые элементы",
|
||||
"HeaderStatsLongestItems": "Самые длинные элементы (часов)",
|
||||
@ -542,6 +545,7 @@
|
||||
"LabelServerYearReview": "Итоги года всего сервера ({0})",
|
||||
"LabelSetEbookAsPrimary": "Установить как основную",
|
||||
"LabelSetEbookAsSupplementary": "Установить как дополнительную",
|
||||
"LabelSettingsAllowIframe": "Разрешить встраивание в iframe",
|
||||
"LabelSettingsAudiobooksOnly": "Только аудиокниги",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "Если включить эту настройку, файлы электронных книг будут игнорироваться, за исключением случаев, когда они находятся в папке с аудиокнигами, в этом случае они будут рассматриваться как дополнительные электронные книги",
|
||||
"LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками",
|
||||
@ -592,6 +596,8 @@
|
||||
"LabelSize": "Размер",
|
||||
"LabelSleepTimer": "Таймер сна",
|
||||
"LabelSlug": "Слизень",
|
||||
"LabelSortAscending": "По возрастанию",
|
||||
"LabelSortDescending": "По убыванию",
|
||||
"LabelStart": "Начало",
|
||||
"LabelStartTime": "Время начала",
|
||||
"LabelStarted": "Начат",
|
||||
@ -663,6 +669,7 @@
|
||||
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
|
||||
"LabelUpdatedAt": "Обновлено в",
|
||||
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Перетаскивание файлов",
|
||||
"LabelUploaderDropFiles": "Перетащите файлы",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии",
|
||||
"LabelUseAdvancedOptions": "Используйте расширенные опции",
|
||||
@ -678,6 +685,8 @@
|
||||
"LabelViewPlayerSettings": "Просмотр настроек плеера",
|
||||
"LabelViewQueue": "Очередь воспроизведения",
|
||||
"LabelVolume": "Громкость",
|
||||
"LabelWebRedirectURLsDescription": "Авторизуйте эти URL в провайдере OAuth, чтобы разрешить перенаправление обратно в веб-приложение после входа:",
|
||||
"LabelWebRedirectURLsSubfolder": "Вложенная папка для URL-адресов перенаправления",
|
||||
"LabelWeekdaysToRun": "Дни недели для запуска",
|
||||
"LabelXBooks": "{0} книг",
|
||||
"LabelXItems": "{0} элементов",
|
||||
|
@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "Shrani seznam skladb",
|
||||
"ButtonScan": "Pregledovanje",
|
||||
"ButtonScanLibrary": "Preglej knjižnico",
|
||||
"ButtonScrollLeft": "Premik levo",
|
||||
"ButtonScrollRight": "Premik desno",
|
||||
"ButtonSearch": "Poišči",
|
||||
"ButtonSelectFolderPath": "Izberite pot do mape",
|
||||
"ButtonSeries": "Serije",
|
||||
@ -184,12 +186,13 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod",
|
||||
"HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice",
|
||||
"HeaderSession": "Seja",
|
||||
"HeaderSetBackupSchedule": "Nastavite urnik varnostnega kopiranja",
|
||||
"HeaderSetBackupSchedule": "Nastavi urnik varnostnega kopiranja",
|
||||
"HeaderSettings": "Nastavitve",
|
||||
"HeaderSettingsDisplay": "Zaslon",
|
||||
"HeaderSettingsExperimental": "Eksperimentalne funkcije",
|
||||
"HeaderSettingsGeneral": "Splošno",
|
||||
"HeaderSettingsScanner": "Pregledovalnik",
|
||||
"HeaderSettingsWebClient": "Spletni odjemalec",
|
||||
"HeaderSleepTimer": "Časovnik za izklop",
|
||||
"HeaderStatsLargestItems": "Največji elementi",
|
||||
"HeaderStatsLongestItems": "Najdaljši elementi (ure)",
|
||||
@ -495,7 +498,7 @@
|
||||
"LabelProviderAuthorizationValue": "Vrednost glave avtorizacije",
|
||||
"LabelPubDate": "Datum objave",
|
||||
"LabelPublishYear": "Leto izdaje",
|
||||
"LabelPublishedDate": "Izdano {0}",
|
||||
"LabelPublishedDate": "Objavljeno {0}",
|
||||
"LabelPublishedDecade": "Desetletje izdaje",
|
||||
"LabelPublishedDecades": "Desetletja izdaje",
|
||||
"LabelPublisher": "Izdajatelj",
|
||||
@ -542,6 +545,7 @@
|
||||
"LabelServerYearReview": "Pregled leta strežnika ({0})",
|
||||
"LabelSetEbookAsPrimary": "Nastavi kot primarno",
|
||||
"LabelSetEbookAsSupplementary": "Nastavi kot dodatno",
|
||||
"LabelSettingsAllowIframe": "Dovoli vdelavo v iframu",
|
||||
"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",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeuomorfna oblika z lesenimi policami",
|
||||
@ -592,6 +596,8 @@
|
||||
"LabelSize": "Velikost",
|
||||
"LabelSleepTimer": "Časovnik za spanje",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelSortAscending": "Naraščajoče",
|
||||
"LabelSortDescending": "Padajoče",
|
||||
"LabelStart": "Začetek",
|
||||
"LabelStartTime": "Čas začetka",
|
||||
"LabelStarted": "Začeto",
|
||||
@ -663,6 +669,7 @@
|
||||
"LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje",
|
||||
"LabelUpdatedAt": "Posodobljeno ob",
|
||||
"LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Povleci in spusti datoteke",
|
||||
"LabelUploaderDropFiles": "Spusti datoteke",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo",
|
||||
"LabelUseAdvancedOptions": "Uporabi napredne možnosti",
|
||||
@ -678,11 +685,13 @@
|
||||
"LabelViewPlayerSettings": "Ogled nastavitev predvajalnika",
|
||||
"LabelViewQueue": "Ogled čakalno vrsto predvajalnika",
|
||||
"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",
|
||||
"LabelXBooks": "{0} knjig",
|
||||
"LabelXItems": "{0} elementov",
|
||||
"LabelYearReviewHide": "Skrij pregled leta",
|
||||
"LabelYearReviewShow": "Poglej pregled leta",
|
||||
"LabelYearReviewShow": "Poglej si pregled leta",
|
||||
"LabelYourAudiobookDuration": "Trajanje tvojih zvočnih knjig",
|
||||
"LabelYourBookmarks": "Tvoji zaznamki",
|
||||
"LabelYourPlaylists": "Tvoje seznami predvajanj",
|
||||
@ -829,7 +838,7 @@
|
||||
"MessageSearchResultsFor": "Rezultati iskanja za",
|
||||
"MessageSelected": "{0} izbrano",
|
||||
"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>",
|
||||
"MessageShareExpiresIn": "Poteče čez {0}",
|
||||
"MessageShareURLWillBe": "URL za skupno rabo bo <strong>{0}</strong>",
|
||||
|
@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "Зберегти порядок",
|
||||
"ButtonScan": "Сканувати",
|
||||
"ButtonScanLibrary": "Сканувати бібліотеку",
|
||||
"ButtonScrollLeft": "Прокрутити ліворуч",
|
||||
"ButtonScrollRight": "Прокрутити праворуч",
|
||||
"ButtonSearch": "Пошук",
|
||||
"ButtonSelectFolderPath": "Обрати шлях до теки",
|
||||
"ButtonSeries": "Серії",
|
||||
@ -190,6 +192,7 @@
|
||||
"HeaderSettingsExperimental": "Експериментальні функції",
|
||||
"HeaderSettingsGeneral": "Основне",
|
||||
"HeaderSettingsScanner": "Сканер",
|
||||
"HeaderSettingsWebClient": "Вебклієнт",
|
||||
"HeaderSleepTimer": "Таймер вимкнення",
|
||||
"HeaderStatsLargestItems": "Найбільші елементи",
|
||||
"HeaderStatsLongestItems": "Найдовші елементи (год)",
|
||||
@ -542,6 +545,7 @@
|
||||
"LabelServerYearReview": "Підсумки року сервера ({0})",
|
||||
"LabelSetEbookAsPrimary": "Зробити основною",
|
||||
"LabelSetEbookAsSupplementary": "Зробити додатковою",
|
||||
"LabelSettingsAllowIframe": "Дозволити вбудовування у iframe",
|
||||
"LabelSettingsAudiobooksOnly": "Лише аудіокниги",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "Увімкніть цей параметр, щоб ігнорувати файли електронних книг, якщо вони не знаходяться у теці аудіокниги, тоді вони будуть встановлені як додаткові електронні книги",
|
||||
"LabelSettingsBookshelfViewHelp": "Імітує вигляд дерев'яних полиць",
|
||||
@ -592,6 +596,8 @@
|
||||
"LabelSize": "Розмір",
|
||||
"LabelSleepTimer": "Таймер вимкнення",
|
||||
"LabelSlug": "Назва",
|
||||
"LabelSortAscending": "По зростанню",
|
||||
"LabelSortDescending": "По спаданню",
|
||||
"LabelStart": "Початок",
|
||||
"LabelStartTime": "Час початку",
|
||||
"LabelStarted": "Почато",
|
||||
@ -663,6 +669,7 @@
|
||||
"LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення",
|
||||
"LabelUpdatedAt": "Оновлення",
|
||||
"LabelUploaderDragAndDrop": "Перетягніть файли або теки",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Перетягніть і скиньте файли",
|
||||
"LabelUploaderDropFiles": "Перетягніть файли",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію",
|
||||
"LabelUseAdvancedOptions": "Використовувати розширені налаштування",
|
||||
@ -678,6 +685,8 @@
|
||||
"LabelViewPlayerSettings": "Переглянути налаштування програвача",
|
||||
"LabelViewQueue": "Переглянути чергу відтворення",
|
||||
"LabelVolume": "Гучність",
|
||||
"LabelWebRedirectURLsDescription": "Авторизуйте ці URL у вашому OAuth постачальнику, щоб дозволити редирекцію назад до веб-додатку після входу:",
|
||||
"LabelWebRedirectURLsSubfolder": "Підпапка для Redirect URL",
|
||||
"LabelWeekdaysToRun": "Виконувати у дні",
|
||||
"LabelXBooks": "{0} книг",
|
||||
"LabelXItems": "{0} елементів",
|
||||
@ -813,7 +822,7 @@
|
||||
"MessagePlaylistCreateFromCollection": "Створити список відтворення з добірки",
|
||||
"MessagePleaseWait": "Будь ласка, зачекайте...",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Подкаст не має RSS-каналу для пошуку",
|
||||
"MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS фіду",
|
||||
"MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS-стрічки",
|
||||
"MessageQuickEmbedInProgress": "Швидке вбудовування в процесі",
|
||||
"MessageQuickEmbedQueue": "В черзі на швидке вбудовування ({0} в черзі)",
|
||||
"MessageQuickMatchAllEpisodes": "Швидке співставлення всіх епізодів",
|
||||
@ -878,7 +887,7 @@
|
||||
"MessageXLibraryIsEmpty": "Бібліотека {0} порожня!",
|
||||
"MessageYourAudiobookDurationIsLonger": "Тривалість вашої аудіокниги довша за віднайдену",
|
||||
"MessageYourAudiobookDurationIsShorter": "Тривалість вашої аудіокниги коротша за віднайдену",
|
||||
"NoteChangeRootPassword": "Кореневий користувач — єдиний, хто може мати порожній пароль",
|
||||
"NoteChangeRootPassword": "Тільки користувач root — єдиний, хто може мати порожній пароль",
|
||||
"NoteChapterEditorTimes": "Примітка: Перша глава мусить починатися з 0:00, а час початку останньої глави не може бути більшим за зазначену тривалість аудіокниги.",
|
||||
"NoteFolderPicker": "Примітка: вже обрані теки не буде показано",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Попередження: Більшість додатків подкастів вимагатимуть використання протоколу HTTPS від RSS-каналу",
|
||||
@ -928,7 +937,7 @@
|
||||
"ToastBackupCreateSuccess": "Резервну копію створено",
|
||||
"ToastBackupDeleteFailed": "Не вдалося видалити резервну копію",
|
||||
"ToastBackupDeleteSuccess": "Резервну копію видалено",
|
||||
"ToastBackupInvalidMaxKeep": "Невірна кількість резервних копій для зберігання",
|
||||
"ToastBackupInvalidMaxKeep": "Профіль оновленоПрофіль оновлено",
|
||||
"ToastBackupInvalidMaxSize": "Невірний максимальний розмір резервної копії",
|
||||
"ToastBackupRestoreFailed": "Не вдалося відновити резервну копію",
|
||||
"ToastBackupUploadFailed": "Не вдалося завантажити резервну копію",
|
||||
|
@ -71,7 +71,7 @@
|
||||
"ButtonQuickMatch": "快速匹配",
|
||||
"ButtonReScan": "重新扫描",
|
||||
"ButtonRead": "读取",
|
||||
"ButtonReadLess": "阅读更少",
|
||||
"ButtonReadLess": "阅读较少",
|
||||
"ButtonReadMore": "阅读更多",
|
||||
"ButtonRefresh": "刷新",
|
||||
"ButtonRemove": "移除",
|
||||
@ -220,7 +220,7 @@
|
||||
"LabelAddToPlaylist": "添加到播放列表",
|
||||
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
|
||||
"LabelAddedAt": "添加于",
|
||||
"LabelAddedDate": "添加 {0}",
|
||||
"LabelAddedDate": "已添加 {0}",
|
||||
"LabelAdminUsersOnly": "仅限管理员用户",
|
||||
"LabelAll": "全部",
|
||||
"LabelAllUsers": "所有用户",
|
||||
@ -663,6 +663,7 @@
|
||||
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
|
||||
"LabelUpdatedAt": "更新时间",
|
||||
"LabelUploaderDragAndDrop": "拖放文件或文件夹",
|
||||
"LabelUploaderDragAndDropFilesOnly": "拖放文件",
|
||||
"LabelUploaderDropFiles": "删除文件",
|
||||
"LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列",
|
||||
"LabelUseAdvancedOptions": "使用高级选项",
|
||||
@ -678,6 +679,8 @@
|
||||
"LabelViewPlayerSettings": "查看播放器设置",
|
||||
"LabelViewQueue": "查看播放列表",
|
||||
"LabelVolume": "音量",
|
||||
"LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:",
|
||||
"LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹",
|
||||
"LabelWeekdaysToRun": "工作日运行",
|
||||
"LabelXBooks": "{0} 本书",
|
||||
"LabelXItems": "{0} 项目",
|
||||
|
1
index.js
1
index.js
@ -11,6 +11,7 @@ if (isDev) {
|
||||
if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath
|
||||
if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath
|
||||
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
|
||||
process.env.SOURCE = 'local'
|
||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
|
||||
|
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.16.2",
|
||||
"version": "2.17.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.16.2",
|
||||
"version": "2.17.5",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.16.2",
|
||||
"version": "2.17.5",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
|
@ -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)
|
||||
|
||||
### 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)
|
||||
|
||||
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||
|
@ -131,7 +131,7 @@ class Auth {
|
||||
{
|
||||
client: openIdClient,
|
||||
params: {
|
||||
redirect_uri: '/auth/openid/callback',
|
||||
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
|
||||
scope: 'openid profile email'
|
||||
}
|
||||
},
|
||||
@ -480,9 +480,9 @@ class Auth {
|
||||
// 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 })
|
||||
|
||||
redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString()
|
||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
||||
} else {
|
||||
redirectUri = new URL('/auth/openid/callback', hostUrl).toString()
|
||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
|
||||
|
||||
if (req.query.state) {
|
||||
Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
|
||||
@ -733,7 +733,7 @@ class Auth {
|
||||
const host = req.get('host')
|
||||
// TODO: ABS does currently not support subfolders for installation
|
||||
// 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
|
||||
// nice would be to redirect to the app here, but for example Authentik does not implement
|
||||
|
@ -406,11 +406,6 @@ class Database {
|
||||
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) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.collectionBook.bulkCreate(collectionBooks)
|
||||
@ -449,21 +444,6 @@ class Database {
|
||||
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) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.bookAuthor.bulkCreate(bookAuthors)
|
||||
|
@ -53,7 +53,17 @@ class Server {
|
||||
global.RouterBasePath = ROUTER_BASE_PATH
|
||||
global.XAccel = process.env.USE_X_ACCEL
|
||||
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)) {
|
||||
fs.mkdirSync(global.ConfigPath)
|
||||
@ -71,7 +81,6 @@ class Server {
|
||||
this.playbackSessionManager = new PlaybackSessionManager()
|
||||
this.podcastManager = new PodcastManager()
|
||||
this.audioMetadataManager = new AudioMetadataMangaer()
|
||||
this.rssFeedManager = new RssFeedManager()
|
||||
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
|
||||
this.apiCacheManager = new ApiCacheManager()
|
||||
this.binaryManager = new BinaryManager()
|
||||
@ -84,7 +93,6 @@ class Server {
|
||||
Logger.logManager = new LogManager()
|
||||
|
||||
this.server = null
|
||||
this.io = null
|
||||
}
|
||||
|
||||
/**
|
||||
@ -138,7 +146,7 @@ class Server {
|
||||
|
||||
await ShareManager.init()
|
||||
await this.backupManager.init()
|
||||
await this.rssFeedManager.init()
|
||||
await RssFeedManager.init()
|
||||
|
||||
const libraries = await Database.libraryModel.getAllWithFolders()
|
||||
await this.cronManager.init(libraries)
|
||||
@ -194,18 +202,23 @@ class Server {
|
||||
|
||||
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) => {
|
||||
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]+)?/)) {
|
||||
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
|
||||
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
|
||||
@ -246,14 +259,17 @@ class Server {
|
||||
|
||||
const router = express.Router()
|
||||
// if RouterBasePath is set, modify all requests to include the base path
|
||||
if (global.RouterBasePath) {
|
||||
app.use((req, res, next) => {
|
||||
if (!req.url.startsWith(global.RouterBasePath)) {
|
||||
req.url = `${global.RouterBasePath}${req.url}`
|
||||
}
|
||||
next()
|
||||
})
|
||||
}
|
||||
app.use((req, res, next) => {
|
||||
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
||||
const host = req.get('host')
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
|
||||
req.originalHostPrefix = `${protocol}://${host}${prefix}`
|
||||
if (!urlStartsWithRouterBasePath) {
|
||||
req.url = `${global.RouterBasePath}${req.url}`
|
||||
}
|
||||
next()
|
||||
})
|
||||
app.use(global.RouterBasePath, router)
|
||||
app.disable('x-powered-by')
|
||||
|
||||
@ -284,14 +300,14 @@ class Server {
|
||||
// RSS Feed temp route
|
||||
router.get('/feed/:slug', (req, res) => {
|
||||
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) => {
|
||||
this.rssFeedManager.getFeedCover(req, res)
|
||||
RssFeedManager.getFeedCover(req, res)
|
||||
})
|
||||
router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
|
||||
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
|
||||
this.rssFeedManager.getFeedItem(req, res)
|
||||
RssFeedManager.getFeedItem(req, res)
|
||||
})
|
||||
|
||||
// Auth routes
|
||||
@ -438,18 +454,11 @@ class Server {
|
||||
async stop() {
|
||||
Logger.info('=== Stopping Server ===')
|
||||
Watcher.close()
|
||||
Logger.info('Watcher Closed')
|
||||
|
||||
return new Promise((resolve) => {
|
||||
SocketAuthority.close((err) => {
|
||||
if (err) {
|
||||
Logger.error('Failed to close server', err)
|
||||
} else {
|
||||
Logger.info('Server successfully closed')
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
Logger.info('[Server] Watcher Closed')
|
||||
await SocketAuthority.close()
|
||||
Logger.info('[Server] Closing HTTP Server')
|
||||
await new Promise((resolve) => this.server.close(resolve))
|
||||
Logger.info('[Server] HTTP Server Closed')
|
||||
}
|
||||
}
|
||||
module.exports = Server
|
||||
|
@ -14,7 +14,7 @@ const Auth = require('./Auth')
|
||||
class SocketAuthority {
|
||||
constructor() {
|
||||
this.Server = null
|
||||
this.io = null
|
||||
this.socketIoServers = []
|
||||
|
||||
/** @type {Object.<string, SocketClient>} */
|
||||
this.clients = {}
|
||||
@ -89,82 +89,104 @@ class SocketAuthority {
|
||||
*
|
||||
* @param {Function} callback
|
||||
*/
|
||||
close(callback) {
|
||||
Logger.info('[SocketAuthority] Shutting down')
|
||||
// This will close all open socket connections, and also close the underlying http server
|
||||
if (this.io) this.io.close(callback)
|
||||
else callback()
|
||||
async close() {
|
||||
Logger.info('[SocketAuthority] closing...')
|
||||
const closePromises = this.socketIoServers.map((io) => {
|
||||
return new Promise((resolve) => {
|
||||
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) {
|
||||
this.Server = Server
|
||||
|
||||
this.io = new SocketIO.Server(this.Server.server, {
|
||||
const socketIoOptions = {
|
||||
cors: {
|
||||
origin: '*',
|
||||
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
|
||||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||
if (global.RouterBasePath) {
|
||||
// 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
|
||||
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
|
||||
|
||||
// Logs
|
||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
||||
|
||||
// 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]
|
||||
this.socketIoServers.forEach((io) => {
|
||||
io.on('connection', (socket) => {
|
||||
this.clients[socket.id] = {
|
||||
id: socket.id,
|
||||
socket,
|
||||
connected_at: Date.now()
|
||||
}
|
||||
})
|
||||
socket.sheepClient = 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')
|
||||
Logger.info(`[SocketAuthority] Socket Connected to ${io.path}`, socket.id)
|
||||
|
||||
// Required for associating a User with a socket
|
||||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||
|
||||
// Scanning
|
||||
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
|
||||
|
||||
// Logs
|
||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
||||
|
||||
// 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]
|
||||
}
|
||||
})
|
||||
|
||||
//
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -190,7 +190,9 @@ class FolderWatcher extends EventEmitter {
|
||||
return
|
||||
}
|
||||
Logger.debug('[Watcher] File Added', path)
|
||||
this.addFileUpdate(libraryId, path, 'added')
|
||||
if (!this.addFileUpdate(libraryId, path, 'added')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.filesBeingAdded.has(path)) {
|
||||
this.filesBeingAdded.add(path)
|
||||
@ -261,22 +263,23 @@ class FolderWatcher extends EventEmitter {
|
||||
* @param {string} libraryId
|
||||
* @param {string} path
|
||||
* @param {string} type
|
||||
* @returns {boolean} - If file was added to pending updates
|
||||
*/
|
||||
addFileUpdate(libraryId, path, type) {
|
||||
if (this.pendingFilePaths.includes(path)) return
|
||||
if (this.pendingFilePaths.includes(path)) return false
|
||||
|
||||
// Get file library
|
||||
const libwatcher = this.libraryWatchers.find((lw) => lw.id === libraryId)
|
||||
if (!libwatcher) {
|
||||
Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Get file folder
|
||||
const folder = libwatcher.libraryFolders.find((fold) => isSameOrSubPath(fold.path, path))
|
||||
if (!folder) {
|
||||
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const folderPath = filePathToPOSIX(folder.path)
|
||||
@ -285,14 +288,14 @@ class FolderWatcher extends EventEmitter {
|
||||
|
||||
if (Path.extname(relPath).toLowerCase() === '.part') {
|
||||
Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Ignore files/folders starting with "."
|
||||
const hasDotPath = relPath.split('/').find((p) => p.startsWith('.'))
|
||||
if (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}"`)
|
||||
@ -318,6 +321,7 @@ class FolderWatcher extends EventEmitter {
|
||||
})
|
||||
|
||||
this.handlePendingFileUpdatesTimeout()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -4,6 +4,7 @@ const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const Collection = require('../objects/Collection')
|
||||
|
||||
/**
|
||||
@ -115,6 +116,7 @@ class CollectionController {
|
||||
}
|
||||
|
||||
// If books array is passed in then update order in collection
|
||||
let collectionBooksUpdated = false
|
||||
if (req.body.books?.length) {
|
||||
const collectionBooks = await req.collection.getCollectionBooks({
|
||||
include: {
|
||||
@ -133,9 +135,15 @@ class CollectionController {
|
||||
await collectionBooks[i].update({
|
||||
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()
|
||||
@ -148,6 +156,8 @@ class CollectionController {
|
||||
/**
|
||||
* DELETE: /api/collections/:id
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
@ -155,7 +165,7 @@ class CollectionController {
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
|
||||
// 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()
|
||||
|
||||
|
@ -18,6 +18,8 @@ const LibraryScanner = require('../scanner/LibraryScanner')
|
||||
const Scanner = require('../scanner/Scanner')
|
||||
const Database = require('../Database')
|
||||
const Watcher = require('../Watcher')
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
|
||||
const libraryFilters = require('../utils/queries/libraryFilters')
|
||||
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
||||
const authorFilters = require('../utils/queries/authorFilters')
|
||||
@ -400,19 +402,48 @@ class LibraryController {
|
||||
model: Database.podcastEpisodeModel,
|
||||
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`)
|
||||
const seriesIds = []
|
||||
const authorIds = []
|
||||
for (const libraryItem of libraryItemsInFolder) {
|
||||
let mediaItemIds = []
|
||||
if (req.library.isPodcast) {
|
||||
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
|
||||
} else {
|
||||
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}"`)
|
||||
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
|
||||
@ -501,11 +532,24 @@ class LibraryController {
|
||||
mediaItemIds.push(libraryItem.mediaId)
|
||||
}
|
||||
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()
|
||||
await Database.removeLibrary(req.library.id)
|
||||
await req.library.destroy()
|
||||
|
||||
// Re-order libraries
|
||||
await Database.libraryModel.resetDisplayOrder()
|
||||
@ -567,6 +611,8 @@ class LibraryController {
|
||||
* DELETE: /api/libraries/:id/issues
|
||||
* Remove all library items missing or invalid
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {LibraryControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
@ -592,6 +638,20 @@ class LibraryController {
|
||||
model: Database.podcastEpisodeModel,
|
||||
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`)
|
||||
const authorIds = []
|
||||
const seriesIds = []
|
||||
for (const libraryItem of libraryItemsWithIssues) {
|
||||
let mediaItemIds = []
|
||||
if (req.library.isPodcast) {
|
||||
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
|
||||
} else {
|
||||
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`)
|
||||
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
|
||||
@ -686,8 +761,8 @@ class LibraryController {
|
||||
}
|
||||
|
||||
if (include.includes('rssfeed')) {
|
||||
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
|
||||
const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||
seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
|
||||
}
|
||||
|
||||
res.json(seriesJson)
|
||||
@ -1142,7 +1217,7 @@ class LibraryController {
|
||||
Logger.error(`[LibraryController] Non-root user "${req.user.username}" attempted to match library items`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
Scanner.matchLibraryItems(req.library)
|
||||
Scanner.matchLibraryItems(this, req.library)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,8 @@ const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUti
|
||||
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
||||
const AudioFileScanner = require('../scanner/AudioFileScanner')
|
||||
const Scanner = require('../scanner/Scanner')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
const ShareManager = require('../managers/ShareManager')
|
||||
@ -48,8 +50,8 @@ class LibraryItemController {
|
||||
}
|
||||
|
||||
if (includeEntities.includes('rssfeed')) {
|
||||
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id)
|
||||
item.rssFeed = feedData?.toJSONMinified() || null
|
||||
const feedData = await RssFeedManager.findFeedForEntityId(item.id)
|
||||
item.rssFeed = feedData?.toOldJSONMinified() || null
|
||||
}
|
||||
|
||||
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {
|
||||
@ -96,6 +98,8 @@ class LibraryItemController {
|
||||
* Optional query params:
|
||||
* ?hard=1
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
@ -103,14 +107,36 @@ class LibraryItemController {
|
||||
const hardDelete = req.query.hard == 1 // Delete from file system
|
||||
const libraryItemPath = req.libraryItem.path
|
||||
|
||||
const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id]
|
||||
await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds)
|
||||
const 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) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((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)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
@ -212,15 +238,6 @@ class LibraryItemController {
|
||||
if (hasUpdates) {
|
||||
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) {
|
||||
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
||||
}
|
||||
@ -232,10 +249,12 @@ class LibraryItemController {
|
||||
if (authorsRemoved.length) {
|
||||
// Check remove empty authors
|
||||
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
|
||||
await this.checkRemoveAuthorsWithNoBooks(
|
||||
libraryItem.libraryId,
|
||||
authorsRemoved.map((au) => au.id)
|
||||
)
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
|
||||
}
|
||||
if (seriesRemoved.length) {
|
||||
// Check remove empty series
|
||||
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
|
||||
await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id))
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
@ -437,10 +456,24 @@ class LibraryItemController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async match(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
const libraryItem = req.libraryItem
|
||||
const reqBody = req.body || {}
|
||||
|
||||
var options = req.body || {}
|
||||
var matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
const 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)
|
||||
}
|
||||
|
||||
@ -450,6 +483,8 @@ class LibraryItemController {
|
||||
* Optional query params:
|
||||
* ?hard=1
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
@ -477,14 +512,33 @@ class LibraryItemController {
|
||||
for (const libraryItem of itemsToDelete) {
|
||||
const libraryItemPath = libraryItem.path
|
||||
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]
|
||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
||||
const 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) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((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)
|
||||
@ -494,48 +548,74 @@ class LibraryItemController {
|
||||
/**
|
||||
* POST: /api/items/batch/update
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async batchUpdate(req, res) {
|
||||
const updatePayloads = req.body
|
||||
if (!updatePayloads?.length) {
|
||||
return res.sendStatus(500)
|
||||
if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
|
||||
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
|
||||
|
||||
const seriesIdsRemoved = []
|
||||
const authorIdsRemoved = []
|
||||
|
||||
for (const updatePayload of updatePayloads) {
|
||||
const mediaPayload = updatePayload.mediaPayload
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
|
||||
if (!libraryItem) return null
|
||||
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
|
||||
|
||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
||||
|
||||
let seriesRemoved = []
|
||||
if (libraryItem.isBook && mediaPayload.metadata?.series) {
|
||||
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id)
|
||||
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||
if (libraryItem.isBook) {
|
||||
if (Array.isArray(mediaPayload.metadata?.series)) {
|
||||
const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
|
||||
const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
|
||||
}
|
||||
if (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)) {
|
||||
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)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
itemsUpdated++
|
||||
}
|
||||
}
|
||||
|
||||
if (seriesIdsRemoved.length) {
|
||||
await this.checkRemoveEmptySeries(seriesIdsRemoved)
|
||||
}
|
||||
if (authorIdsRemoved.length) {
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
updates: itemsUpdated
|
||||
@ -576,7 +656,6 @@ class LibraryItemController {
|
||||
let itemsUpdated = 0
|
||||
let itemsUnmatched = 0
|
||||
|
||||
const options = req.body.options || {}
|
||||
if (!req.body.libraryItemIds?.length) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
@ -590,8 +669,20 @@ class LibraryItemController {
|
||||
|
||||
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) {
|
||||
const matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
|
||||
if (matchResult.updated) {
|
||||
itemsUpdated++
|
||||
} else if (matchResult.warning) {
|
||||
|
@ -126,6 +126,10 @@ class MiscController {
|
||||
if (!isObject(settingsUpdate)) {
|
||||
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)
|
||||
if (madeUpdates) {
|
||||
@ -137,7 +141,6 @@ class MiscController {
|
||||
}
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
serverSettings: Database.serverSettings.toJSONForBrowser()
|
||||
})
|
||||
}
|
||||
@ -679,9 +682,9 @@ class MiscController {
|
||||
continue
|
||||
}
|
||||
let updatedValue = settingsUpdate[key]
|
||||
if (updatedValue === '') updatedValue = null
|
||||
if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
|
||||
let currentValue = currentAuthenticationSettings[key]
|
||||
if (currentValue === '') currentValue = null
|
||||
if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null
|
||||
|
||||
if (updatedValue !== currentValue) {
|
||||
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
|
||||
|
@ -1,7 +1,8 @@
|
||||
const { Request, Response, NextFunction } = require('express')
|
||||
const Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
@ -22,10 +23,10 @@ class RSSFeedController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getAll(req, res) {
|
||||
const feeds = await this.rssFeedManager.getFeeds()
|
||||
const feeds = await RssFeedManager.getFeeds()
|
||||
res.json({
|
||||
feeds: feeds.map((f) => f.toJSON()),
|
||||
minified: feeds.map((f) => f.toJSONMinified())
|
||||
feeds: feeds.map((f) => f.toOldJSON()),
|
||||
minified: feeds.map((f) => f.toOldJSONMinified())
|
||||
})
|
||||
}
|
||||
|
||||
@ -38,38 +39,43 @@ class RSSFeedController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async openRSSFeedForItem(req, res) {
|
||||
const options = req.body || {}
|
||||
const reqBody = req.body || {}
|
||||
|
||||
const item = await Database.libraryItemModel.getOldById(req.params.itemId)
|
||||
if (!item) return res.sendStatus(404)
|
||||
const itemExpanded = await Database.libraryItemModel.getExpandedById(req.params.itemId)
|
||||
if (!itemExpanded) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||
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`)
|
||||
if (!req.user.checkCanAccessLibraryItem(itemExpanded)) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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`)
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Check item has audio tracks
|
||||
if (!item.media.numTracks) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${item.media.metadata.title}" because it has no audio tracks`)
|
||||
if (!itemExpanded.hasAudioTracks()) {
|
||||
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')
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is 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({
|
||||
feed: feed.toJSONMinified()
|
||||
feed: feed.toOldJSONMinified()
|
||||
})
|
||||
}
|
||||
|
||||
@ -82,35 +88,37 @@ class RSSFeedController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async openRSSFeedForCollection(req, res) {
|
||||
const options = req.body || {}
|
||||
|
||||
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
|
||||
if (!collection) return res.sendStatus(404)
|
||||
const reqBody = req.body || {}
|
||||
|
||||
// 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`)
|
||||
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)
|
||||
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
|
||||
return res.status(400).send('Slug already in use')
|
||||
}
|
||||
|
||||
const collectionExpanded = await collection.getOldJsonExpanded()
|
||||
const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length)
|
||||
const collection = await Database.collectionModel.getExpandedById(req.params.collectionId)
|
||||
if (!collection) return res.sendStatus(404)
|
||||
|
||||
// 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`)
|
||||
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({
|
||||
feed: feed.toJSONMinified()
|
||||
feed: feed.toOldJSONMinified()
|
||||
})
|
||||
}
|
||||
|
||||
@ -123,37 +131,37 @@ class RSSFeedController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async openRSSFeedForSeries(req, res) {
|
||||
const options = req.body || {}
|
||||
|
||||
const series = await Database.seriesModel.findByPk(req.params.seriesId)
|
||||
if (!series) return res.sendStatus(404)
|
||||
const reqBody = req.body || {}
|
||||
|
||||
// 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`)
|
||||
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)
|
||||
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
|
||||
return res.status(400).send('Slug already in use')
|
||||
}
|
||||
|
||||
const seriesJson = series.toOldJSON()
|
||||
|
||||
// Get books in series that have audio tracks
|
||||
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
|
||||
const series = await Database.seriesModel.getExpandedById(req.params.seriesId)
|
||||
if (!series) return res.sendStatus(404)
|
||||
|
||||
// Check series has audio tracks
|
||||
if (!seriesJson.books.length) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${seriesJson.name}" because it has no audio tracks`)
|
||||
if (!series.books.some((book) => book.includedAudioFiles.length)) {
|
||||
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')
|
||||
}
|
||||
|
||||
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({
|
||||
feed: feed.toJSONMinified()
|
||||
feed: feed.toOldJSONMinified()
|
||||
})
|
||||
}
|
||||
|
||||
@ -165,8 +173,16 @@ class RSSFeedController {
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
closeRSSFeed(req, res) {
|
||||
this.rssFeedManager.closeRssFeed(req, res)
|
||||
async 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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -2,6 +2,9 @@ const { Request, Response, NextFunction } = require('express')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
/**
|
||||
@ -51,8 +54,8 @@ class SeriesController {
|
||||
}
|
||||
|
||||
if (include.includes('rssfeed')) {
|
||||
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
|
||||
const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||
seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
|
||||
}
|
||||
|
||||
res.json(seriesJson)
|
||||
|
@ -368,6 +368,19 @@ class UserController {
|
||||
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()
|
||||
await user.destroy()
|
||||
SocketAuthority.adminEmitter('user_removed', userJson)
|
||||
|
@ -86,6 +86,7 @@ class CacheManager {
|
||||
}
|
||||
|
||||
async purgeEntityCache(entityId, cachePath) {
|
||||
if (!entityId || !cachePath) return []
|
||||
return Promise.all(
|
||||
(await fs.readdir(cachePath)).reduce((promises, file) => {
|
||||
if (file.startsWith(entityId)) {
|
||||
|
@ -12,7 +12,7 @@ const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
|
||||
class CoverManager {
|
||||
constructor() { }
|
||||
constructor() {}
|
||||
|
||||
getCoverDirectory(libraryItem) {
|
||||
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) {
|
||||
@ -93,10 +93,13 @@ class CoverManager {
|
||||
const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`)
|
||||
|
||||
// Move cover from temp upload dir to destination
|
||||
const success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
|
||||
Logger.error('[CoverManager] Failed to move cover file', path, error)
|
||||
return false
|
||||
})
|
||||
const success = await coverFile
|
||||
.mv(coverFullPath)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
Logger.error('[CoverManager] Failed to move cover file', coverFullPath, error)
|
||||
return false
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
@ -124,11 +127,13 @@ class CoverManager {
|
||||
var temppath = Path.posix.join(coverDirPath, 'cover')
|
||||
|
||||
let errorMsg = ''
|
||||
let success = await downloadImageFile(url, temppath).then(() => true).catch((err) => {
|
||||
errorMsg = err.message || 'Unknown error'
|
||||
Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
|
||||
return false
|
||||
})
|
||||
let success = await downloadImageFile(url, temppath)
|
||||
.then(() => true)
|
||||
.catch((err) => {
|
||||
errorMsg = err.message || 'Unknown error'
|
||||
Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
|
||||
return false
|
||||
})
|
||||
if (!success) {
|
||||
return {
|
||||
error: 'Failed to download image from url: ' + errorMsg
|
||||
@ -180,7 +185,7 @@ class CoverManager {
|
||||
}
|
||||
|
||||
// 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}"`)
|
||||
return {
|
||||
error: 'Cover path does not exist'
|
||||
@ -188,7 +193,7 @@ class CoverManager {
|
||||
}
|
||||
|
||||
// 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}"`)
|
||||
return {
|
||||
error: 'Cover path is not a file'
|
||||
@ -211,10 +216,13 @@ class CoverManager {
|
||||
var newCoverPath = Path.posix.join(coverDirPath, coverFilename)
|
||||
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) => {
|
||||
Logger.error(`[CoverManager] validate cover path failed to copy cover`, error)
|
||||
return false
|
||||
})
|
||||
var copySuccess = await fs
|
||||
.copy(coverPath, newCoverPath, { overwrite: true })
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
Logger.error(`[CoverManager] validate cover path failed to copy cover`, error)
|
||||
return false
|
||||
})
|
||||
if (!copySuccess) {
|
||||
return {
|
||||
error: 'Failed to copy cover to dir'
|
||||
@ -236,14 +244,14 @@ class CoverManager {
|
||||
|
||||
/**
|
||||
* Extract cover art from audio file and save for library item
|
||||
*
|
||||
* @param {import('../models/Book').AudioFileObject[]} audioFiles
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null for isFile library items
|
||||
*
|
||||
* @param {import('../models/Book').AudioFileObject[]} audioFiles
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null for isFile library items
|
||||
* @returns {Promise<string>} returns cover path
|
||||
*/
|
||||
async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) {
|
||||
let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt)
|
||||
let audioFileWithCover = audioFiles.find((af) => af.embeddedCoverArt)
|
||||
if (!audioFileWithCover) return null
|
||||
|
||||
let coverDirPath = null
|
||||
@ -273,10 +281,10 @@ class CoverManager {
|
||||
|
||||
/**
|
||||
* Extract cover art from ebook and save for library item
|
||||
*
|
||||
* @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null for isFile library items
|
||||
*
|
||||
* @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null for isFile library items
|
||||
* @returns {Promise<string>} returns cover path
|
||||
*/
|
||||
async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) {
|
||||
@ -310,9 +318,9 @@ class CoverManager {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} libraryItemId
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast
|
||||
* @returns {Promise<{error:string}|{cover:string}>}
|
||||
*/
|
||||
@ -328,10 +336,12 @@ class CoverManager {
|
||||
await fs.ensureDir(coverDirPath)
|
||||
|
||||
const temppath = Path.posix.join(coverDirPath, 'cover')
|
||||
const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => {
|
||||
Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
|
||||
return false
|
||||
})
|
||||
const success = await downloadImageFile(url, temppath)
|
||||
.then(() => true)
|
||||
.catch((err) => {
|
||||
Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
|
||||
return false
|
||||
})
|
||||
if (!success) {
|
||||
return {
|
||||
error: 'Failed to download image from url'
|
||||
@ -361,4 +371,4 @@ class CoverManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = new CoverManager()
|
||||
module.exports = new CoverManager()
|
||||
|
@ -25,7 +25,9 @@ const LibraryItem = require('../objects/LibraryItem')
|
||||
|
||||
class PodcastManager {
|
||||
constructor() {
|
||||
/** @type {PodcastEpisodeDownload[]} */
|
||||
this.downloadQueue = []
|
||||
/** @type {PodcastEpisodeDownload} */
|
||||
this.currentDownload = null
|
||||
|
||||
this.failedCheckMap = {}
|
||||
@ -63,6 +65,11 @@ class PodcastManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PodcastEpisodeDownload} podcastEpisodeDownload
|
||||
* @returns
|
||||
*/
|
||||
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
|
||||
if (this.currentDownload) {
|
||||
this.downloadQueue.push(podcastEpisodeDownload)
|
||||
@ -106,7 +113,7 @@ class PodcastManager {
|
||||
}
|
||||
|
||||
let success = false
|
||||
if (this.currentDownload.urlFileExtension === 'mp3') {
|
||||
if (this.currentDownload.isMp3) {
|
||||
// Download episode and tag it
|
||||
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
|
||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||
|
@ -1,3 +1,4 @@
|
||||
const { Request, Response } = require('express')
|
||||
const Path = require('path')
|
||||
|
||||
const Logger = require('../Logger')
|
||||
@ -5,170 +6,190 @@ const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Feed = require('../objects/Feed')
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
class RssFeedManager {
|
||||
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() {
|
||||
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) {
|
||||
// Remove invalid feeds
|
||||
if (!(await this.validateFeedEntity(feed))) {
|
||||
await Database.removeFeed(feed.id)
|
||||
if (!feed.entity) {
|
||||
Logger.error(`[RssFeedManager] Removing feed "${feed.title}". Entity not found`)
|
||||
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)
|
||||
* @param {string} entityId
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
* @returns {Promise<import('../models/Feed')>}
|
||||
*/
|
||||
findFeedForEntityId(entityId) {
|
||||
return Database.feedModel.findOneOld({ entityId })
|
||||
return Database.feedModel.findOne({
|
||||
where: {
|
||||
entityId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Find open feed for a slug
|
||||
*
|
||||
* @param {string} slug
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
findFeedBySlug(slug) {
|
||||
return Database.feedModel.findOneOld({ slug })
|
||||
checkExistsBySlug(slug) {
|
||||
return Database.feedModel
|
||||
.count({
|
||||
where: {
|
||||
slug
|
||||
}
|
||||
})
|
||||
.then((count) => count > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find open feed for a slug
|
||||
* @param {string} slug
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
* Feed requires update if the entity (or child entities) has been updated since the feed was last updated
|
||||
*
|
||||
* @param {import('../models/Feed')} feed
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
findFeed(id) {
|
||||
return Database.feedModel.findByPkOld(id)
|
||||
async checkFeedRequiresUpdate(feed) {
|
||||
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) {
|
||||
const feed = await this.findFeedBySlug(req.params.slug)
|
||||
let feed = await Database.feedModel.findOne({
|
||||
where: {
|
||||
slug: req.params.slug
|
||||
}
|
||||
})
|
||||
if (!feed) {
|
||||
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||
res.sendStatus(404)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if feed needs to be updated
|
||||
if (feed.entityType === 'libraryItem') {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId)
|
||||
|
||||
let mostRecentlyUpdatedAt = libraryItem.updatedAt
|
||||
if (libraryItem.isPodcast) {
|
||||
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 feedRequiresUpdate = await this.checkFeedRequiresUpdate(feed)
|
||||
if (feedRequiresUpdate) {
|
||||
Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
|
||||
feed = await feed.updateFeedForEntity()
|
||||
} else {
|
||||
feed.feedEpisodes = await feed.getFeedEpisodes()
|
||||
}
|
||||
|
||||
const xml = feed.buildXml()
|
||||
const xml = feed.buildXml(req.originalHostPrefix)
|
||||
res.set('Content-Type', 'text/xml')
|
||||
res.send(xml)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /feed/:slug/item/:episodeId/*
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {Response} 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) {
|
||||
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||
res.sendStatus(404)
|
||||
@ -183,8 +204,19 @@ class RssFeedManager {
|
||||
res.sendFile(episodePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /feed/:slug/cover*
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {Response} 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) {
|
||||
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||
res.sendStatus(404)
|
||||
@ -204,100 +236,142 @@ class RssFeedManager {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {*} libraryItem
|
||||
* @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) {
|
||||
const serverAddress = options.serverAddress
|
||||
const slug = options.slug
|
||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
||||
const ownerName = options.metadataDetails?.ownerName
|
||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
||||
const feedOptions = this.getFeedOptionsFromReqOptions(options)
|
||||
|
||||
const feed = new Feed()
|
||||
feed.setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
|
||||
|
||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
||||
await Database.createFeed(feed)
|
||||
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
|
||||
return feed
|
||||
Logger.info(`[RssFeedManager] Creating RSS feed for item ${libraryItem.id} "${libraryItem.media.title}"`)
|
||||
const feedExpanded = await Database.feedModel.createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
|
||||
if (feedExpanded) {
|
||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
|
||||
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
|
||||
}
|
||||
return feedExpanded
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {*} collectionExpanded
|
||||
* @param {import('../models/Collection')} collectionExpanded
|
||||
* @param {*} options
|
||||
* @returns
|
||||
* @returns {Promise<import('../models/Feed').FeedExpanded>}
|
||||
*/
|
||||
async openFeedForCollection(userId, collectionExpanded, options) {
|
||||
const serverAddress = options.serverAddress
|
||||
const slug = options.slug
|
||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
||||
const ownerName = options.metadataDetails?.ownerName
|
||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
||||
const feedOptions = this.getFeedOptionsFromReqOptions(options)
|
||||
|
||||
const feed = new Feed()
|
||||
feed.setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
|
||||
|
||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
||||
await Database.createFeed(feed)
|
||||
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
|
||||
return feed
|
||||
Logger.info(`[RssFeedManager] Creating RSS feed for collection "${collectionExpanded.name}"`)
|
||||
const feedExpanded = await Database.feedModel.createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
|
||||
if (feedExpanded) {
|
||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
|
||||
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
|
||||
}
|
||||
return feedExpanded
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {*} seriesExpanded
|
||||
* @param {import('../models/Series')} seriesExpanded
|
||||
* @param {*} options
|
||||
* @returns
|
||||
* @returns {Promise<import('../models/Feed').FeedExpanded>}
|
||||
*/
|
||||
async openFeedForSeries(userId, seriesExpanded, options) {
|
||||
const serverAddress = options.serverAddress
|
||||
const slug = options.slug
|
||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
||||
const ownerName = options.metadataDetails?.ownerName
|
||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
||||
const feedOptions = this.getFeedOptionsFromReqOptions(options)
|
||||
|
||||
const feed = new Feed()
|
||||
feed.setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
|
||||
|
||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
||||
await Database.createFeed(feed)
|
||||
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)
|
||||
Logger.info(`[RssFeedManager] Creating RSS feed for series "${seriesExpanded.name}"`)
|
||||
const feedExpanded = await Database.feedModel.createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
|
||||
if (feedExpanded) {
|
||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
|
||||
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
|
||||
}
|
||||
await this.handleCloseFeed(feed)
|
||||
res.sendStatus(200)
|
||||
return feedExpanded
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const feed = await this.findFeedForEntityId(entityId)
|
||||
if (!feed) return
|
||||
const feed = await Database.feedModel.findOne({
|
||||
where: {
|
||||
entityId
|
||||
}
|
||||
})
|
||||
if (!feed) {
|
||||
return false
|
||||
}
|
||||
return this.handleCloseFeed(feed)
|
||||
}
|
||||
|
||||
async getFeeds() {
|
||||
const feeds = await Database.models.feed.getOldFeeds()
|
||||
Logger.info(`[RssFeedManager] Fetched all feeds`)
|
||||
return feeds
|
||||
/**
|
||||
*
|
||||
* @param {string[]} entityIds
|
||||
*/
|
||||
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()
|
||||
|
@ -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.
|
||||
|
||||
| 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.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 |
|
||||
| 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.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.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 |
|
||||
|
102
server/migrations/v2.17.0-uuid-replacement.js
Normal file
102
server/migrations/v2.17.0-uuid-replacement.js
Normal 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 }
|
259
server/migrations/v2.17.3-fk-constraints.js
Normal file
259
server/migrations/v2.17.3-fk-constraints.js
Normal 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 }
|
@ -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 }
|
74
server/migrations/v2.17.5-remove-host-from-feed-urls.js
Normal file
74
server/migrations/v2.17.5-remove-host-from-feed-urls.js
Normal 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 }
|
@ -29,6 +29,12 @@ const Logger = require('../Logger')
|
||||
* @property {SeriesExpanded[]} series
|
||||
*
|
||||
* @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
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
|
||||
/** @type {import('./Author')[]} - optional if expanded */
|
||||
this.authors
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -18,6 +18,11 @@ class Collection extends Model {
|
||||
this.updatedAt
|
||||
/** @type {Date} */
|
||||
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
|
||||
if (c.feeds?.length) {
|
||||
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0])
|
||||
collectionExpanded.rssFeed = c.feeds[0].toOldJSON()
|
||||
}
|
||||
|
||||
return collectionExpanded
|
||||
@ -115,6 +120,39 @@ class Collection extends Model {
|
||||
.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
|
||||
* @param {Collection} collectionExpanded
|
||||
@ -219,6 +257,34 @@ class Collection extends Model {
|
||||
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
|
||||
*
|
||||
@ -282,7 +348,7 @@ class Collection extends Model {
|
||||
if (include?.includes('rssfeed')) {
|
||||
const feeds = await this.getFeeds()
|
||||
if (feeds?.length) {
|
||||
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
|
||||
collectionExpanded.rssFeed = feeds[0].toOldJSON()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,22 @@
|
||||
const Path = require('path')
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const oldFeed = require('../objects/Feed')
|
||||
const areEquivalent = require('../utils/areEquivalent')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
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 {
|
||||
constructor(values, options) {
|
||||
@ -50,210 +66,288 @@ class Feed extends Model {
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
static async getOldFeeds() {
|
||||
const feeds = await this.findAll({
|
||||
include: {
|
||||
model: this.sequelize.models.feedEpisode
|
||||
}
|
||||
})
|
||||
return feeds.map((f) => this.getOldFeed(f))
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./FeedEpisode')[]} - only set if expanded */
|
||||
this.feedEpisodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old feed from Feed and optionally Feed with FeedEpisodes
|
||||
* @param {Feed} feedExpanded
|
||||
* @returns {oldFeed}
|
||||
* @param {string} feedId
|
||||
* @returns {Promise<boolean>} - true if feed was removed
|
||||
*/
|
||||
static getOldFeed(feedExpanded) {
|
||||
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
|
||||
return new oldFeed({
|
||||
id: feedExpanded.id,
|
||||
slug: feedExpanded.slug,
|
||||
userId: feedExpanded.userId,
|
||||
entityType: feedExpanded.entityType,
|
||||
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
|
||||
}
|
||||
})
|
||||
static async removeById(feedId) {
|
||||
return (
|
||||
(await this.destroy({
|
||||
where: {
|
||||
id: feedId
|
||||
}
|
||||
})) > 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const feeds = await this.findAll({
|
||||
attributes: ['entityId'],
|
||||
where: {
|
||||
entityType: 'libraryItem'
|
||||
}
|
||||
})
|
||||
return feeds.map((f) => f.entityId).filter((f) => f) || []
|
||||
}
|
||||
static getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions = null) {
|
||||
const media = libraryItem.media
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
let entityUpdatedAt = libraryItem.updatedAt
|
||||
|
||||
/**
|
||||
* Find feed and return oldFeed
|
||||
* @param {string} id
|
||||
* @returns {Promise<oldFeed>} oldFeed
|
||||
*/
|
||||
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)
|
||||
}
|
||||
// Podcast feeds should use the most recent episode updatedAt if more recent
|
||||
if (libraryItem.mediaType === 'podcast') {
|
||||
entityUpdatedAt = libraryItem.media.podcastEpisodes.reduce((mostRecent, episode) => {
|
||||
return episode.updatedAt > mostRecent ? episode.updatedAt : mostRecent
|
||||
}, entityUpdatedAt)
|
||||
}
|
||||
|
||||
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, {
|
||||
include: this.sequelize.models.feedEpisode
|
||||
})
|
||||
if (!existingFeed) return false
|
||||
/** @type {typeof import('./FeedEpisode')} */
|
||||
const feedEpisodeModel = this.sequelize.models.feedEpisode
|
||||
|
||||
let hasUpdates = false
|
||||
const transaction = await this.sequelize.transaction()
|
||||
try {
|
||||
const feed = await this.create(feedObj, { transaction })
|
||||
|
||||
// Remove and update existing feed episodes
|
||||
for (const feedEpisode of existingFeed.feedEpisodes) {
|
||||
const oldFeedEpisode = oldFeedEpisodes.find((ep) => ep.id === feedEpisode.id)
|
||||
// Episode removed
|
||||
if (!oldFeedEpisode) {
|
||||
feedEpisode.destroy()
|
||||
if (libraryItem.mediaType === 'podcast') {
|
||||
feed.feedEpisodes = await feedEpisodeModel.createFromPodcastEpisodes(libraryItem, feed, slug, transaction)
|
||||
} else {
|
||||
let episodeHasUpdates = false
|
||||
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
|
||||
}
|
||||
feed.feedEpisodes = await feedEpisodeModel.createFromAudiobookTracks(libraryItem, feed, slug, transaction)
|
||||
}
|
||||
|
||||
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 {
|
||||
id: oldFeed.id,
|
||||
slug: oldFeed.slug,
|
||||
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
|
||||
feedObj,
|
||||
booksWithTracks
|
||||
}
|
||||
}
|
||||
|
||||
getEntity(options) {
|
||||
if (!this.entityType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
|
||||
return this[mixinMethodName](options)
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @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,
|
||||
entityType: DataTypes.STRING,
|
||||
entityId: DataTypes.UUIDV4,
|
||||
entityId: DataTypes.UUID,
|
||||
entityUpdatedAt: DataTypes.DATE,
|
||||
serverAddress: 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
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user