mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-06-22 18:51:58 +02:00
Merge branch 'master' into lazy-bookshelf-optimizations
This commit is contained in:
commit
780c0dcb99
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,7 @@
|
|||||||
/podcasts/
|
/podcasts/
|
||||||
/media/
|
/media/
|
||||||
/metadata/
|
/metadata/
|
||||||
|
/plugins/
|
||||||
/client/.nuxt/
|
/client/.nuxt/
|
||||||
/client/dist/
|
/client/dist/
|
||||||
/dist/
|
/dist/
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-16 bg-primary relative">
|
<div class="w-full h-16 bg-primary relative">
|
||||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
|
<div id="appbar" role="toolbar" aria-label="Appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
|
||||||
<div class="flex h-full items-center">
|
<div class="flex h-full items-center">
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
|
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
|
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
|
||||||
<template v-for="(shelf, index) in supportedShelves">
|
<template v-for="(shelf, index) in supportedShelves">
|
||||||
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
|
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||||
<p class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</p>
|
<h2 class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</h2>
|
||||||
</widgets-item-slider>
|
</widgets-item-slider>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
@ -37,18 +37,18 @@
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
|
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
||||||
<p :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</p>
|
<h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
|
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
|
<button v-show="canScrollLeft && !isScrolling" :aria-label="$strings.ButtonScrollLeft" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
|
||||||
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
|
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
|
||||||
</div>
|
</button>
|
||||||
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
|
<button v-show="canScrollRight && !isScrolling" :aria-label="$strings.ButtonScrollRight" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
|
||||||
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
|
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -42,8 +42,11 @@
|
|||||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
|
<p class="text-sm">{{ $strings.ButtonDownloadQueue }}</p>
|
||||||
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
|
<div id="toolbar" role="toolbar" aria-label="Library Toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||||
<!-- Series books page -->
|
<!-- Series books page -->
|
||||||
<template v-if="selectedSeries">
|
<template v-if="selectedSeries">
|
||||||
<p class="pl-2 text-base md:text-lg">
|
<p class="pl-2 text-base md:text-lg">
|
||||||
@ -265,6 +268,9 @@ export default {
|
|||||||
isPodcastLatestPage() {
|
isPodcastLatestPage() {
|
||||||
return this.$route.name === 'library-library-podcast-latest'
|
return this.$route.name === 'library-library-podcast-latest'
|
||||||
},
|
},
|
||||||
|
isPodcastDownloadQueuePage() {
|
||||||
|
return this.$route.name === 'library-library-podcast-download-queue'
|
||||||
|
},
|
||||||
isAuthorsPage() {
|
isAuthorsPage() {
|
||||||
return this.page === 'authors'
|
return this.page === 'authors'
|
||||||
},
|
},
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div role="toolbar" aria-orientation="vertical" aria-label="Config Sidebar">
|
||||||
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
|
<div role="navigation" aria-label="Config Navigation" class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
|
||||||
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
|
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
|
||||||
<span class="material-symbols text-2xl">arrow_back</span>
|
<span class="material-symbols text-2xl">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
<div role="toolbar" aria-orientation="vertical" aria-label="Library Sidebar" class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||||
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
||||||
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||||
|
|
||||||
<div id="siderail-buttons-container" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
|
<div id="siderail-buttons-container" role="navigation" aria-label="Library Navigation" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
<article class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
||||||
<nuxt-link :to="`/author/${author?.id}`">
|
<nuxt-link :to="`/author/${author?.id}`">
|
||||||
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||||
@ -34,7 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<article ref="card" :id="`book-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
|
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
|
||||||
<!-- When cover image does not fill -->
|
<!-- When cover image does not fill -->
|
||||||
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||||
@ -14,21 +14,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||||
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
|
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" aria-hidden="true" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
|
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cover Image -->
|
<!-- Cover Image -->
|
||||||
<img cy-id="coverImage" v-if="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-if="libraryItem" :alt="`${displayTitle}, ${$strings.LabelCover}`" ref="cover" aria-hidden="true" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||||
|
|
||||||
<!-- Placeholder Cover Title & Author -->
|
<!-- Placeholder Cover Title & Author -->
|
||||||
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
|
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
|
||||||
<div>
|
<div>
|
||||||
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
<p cy-id="placeholderTitleText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
|
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
|
||||||
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
<p cy-id="placeholderAuthorText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f">
|
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f">
|
||||||
@ -93,11 +93,11 @@
|
|||||||
|
|
||||||
<!-- rss feed icon -->
|
<!-- rss feed icon -->
|
||||||
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
|
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
|
||||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
|
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- media item shared icon -->
|
<!-- media item shared icon -->
|
||||||
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
|
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
|
||||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">public</span>
|
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">public</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series sequence -->
|
<!-- Series sequence -->
|
||||||
@ -114,7 +114,7 @@
|
|||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
<!-- Podcast Num Episodes -->
|
||||||
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
|
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodes }}</p>
|
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfEpisodes">{{ numEpisodes }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
<!-- Podcast Num Episodes -->
|
||||||
@ -128,7 +128,7 @@
|
|||||||
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
|
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
|
||||||
<div :style="{ fontSize: 0.9 + 'em' }">
|
<div :style="{ fontSize: 0.9 + 'em' }">
|
||||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||||
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
<p cy-id="title" ref="displayTitle" aria-hidden="true" class="truncate">{{ displayTitle }}</p>
|
||||||
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
@ -138,7 +138,7 @@
|
|||||||
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || ' ' }}</p>
|
<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>
|
<p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`collection-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<div ref="card" :id="`collection-card-${index}`" role="button" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<div ref="card" :id="`playlist-card-${index}`" role="button" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div cy-id="card" ref="card" :id="`series-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<article cy-id="card" ref="card" :id="`series-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
|
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
|
||||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||||
@ -7,12 +7,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ books.length }}</p>
|
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfBooks">{{ books.length }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||||
|
|
||||||
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
|
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" aria-hidden="true" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
|
||||||
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
|
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -21,14 +21,14 @@
|
|||||||
|
|
||||||
<div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
<div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
||||||
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
<p cy-id="standardBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center">
|
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center">
|
||||||
<p cy-id="detailBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
<p cy-id="detailBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||||
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="w-full relative sm:w-80">
|
<div class="w-full relative sm:w-80">
|
||||||
<form @submit.prevent="submitSearch">
|
<form role="search" @submit.prevent="submitSearch">
|
||||||
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||||
</form>
|
</form>
|
||||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
<button :aria-hidden="!search" class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||||
<span v-if="!search" class="material-symbols" style="font-size: 1.2rem"></span>
|
<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>
|
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu" @mousedown.stop.prevent>
|
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu" @mousedown.stop.prevent>
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
|
@ -1,28 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
<div class="relative h-7">
|
||||||
|
<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="flex items-center justify-between">
|
||||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
</button>
|
||||||
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
<button v-else :aria-label="$strings.ButtonClearFilter" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||||
<span class="material-symbols" style="font-size: 1.1rem">close</span>
|
<span class="material-symbols" style="font-size: 1.1rem">close</span>
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
|
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
|
||||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="!sublist" class="h-full w-full" role="menu">
|
||||||
<template v-for="item in selectItems">
|
<template v-for="item in selectItems">
|
||||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
|
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" :aria-haspopup="item.sublist ? '' : 'menu'" @click="clickedOption(item)">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
||||||
<span class="material-symbols text-2xl">arrow_right</span>
|
<span class="material-symbols text-2xl" :aria-label="$strings.LabelMore">arrow_right</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- selected checkmark icon -->
|
<!-- selected checkmark icon -->
|
||||||
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||||
@ -31,8 +33,8 @@
|
|||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="sublist" class="h-full w-full" role="menu">
|
||||||
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
|
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="menuitem" @click="sublist = null">
|
||||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||||
<span class="material-symbols text-2xl">arrow_left</span>
|
<span class="material-symbols text-2xl">arrow_left</span>
|
||||||
</div>
|
</div>
|
||||||
@ -40,13 +42,13 @@
|
|||||||
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
|
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="menuitem">
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
|
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<template v-for="item in sublistItems">
|
<template v-for="item in sublistItems">
|
||||||
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)">
|
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedSublistOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
|
||||||
<span class="flex items-center justify-between">
|
<span class="flex items-center justify-between">
|
||||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||||
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
|
||||||
<template v-for="item in selectItems">
|
<template v-for="item in selectItems">
|
||||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
|
||||||
<span class="flex items-center justify-between">
|
<span class="flex items-center justify-between">
|
||||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||||
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
|
||||||
<template v-for="item in items">
|
<template v-for="item in items">
|
||||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
@ -121,6 +121,8 @@ export default {
|
|||||||
|
|
||||||
var img = document.createElement('img')
|
var img = document.createElement('img')
|
||||||
img.src = src
|
img.src = src
|
||||||
|
img.alt = `${this.name}, ${this.$strings.LabelCover}`
|
||||||
|
img.ariaHidden = true
|
||||||
img.className = 'absolute top-0 left-0 w-full h-full'
|
img.className = 'absolute top-0 left-0 w-full h-full'
|
||||||
img.style.objectFit = showCoverBg ? 'contain' : 'cover'
|
img.style.objectFit = showCoverBg ? 'contain' : 'cover'
|
||||||
|
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
<div ref="wrapper" role="dialog" aria-modal="true" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
||||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||||
|
|
||||||
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
|
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
|
||||||
<span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
<span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||||
</button>
|
</button>
|
||||||
<slot name="outer" />
|
<slot name="outer" />
|
||||||
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
<div ref="content" tabindex="0" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white outline-none" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||||
<slot />
|
<slot />
|
||||||
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
@ -126,6 +126,9 @@ export default {
|
|||||||
|
|
||||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||||
this.$store.commit('setOpenModal', this.name)
|
this.$store.commit('setOpenModal', this.name)
|
||||||
|
|
||||||
|
// Set focus to the modal content
|
||||||
|
this.content.focus()
|
||||||
},
|
},
|
||||||
setHide() {
|
setHide() {
|
||||||
if (this.content) this.content.style.transform = 'scale(0)'
|
if (this.content) this.content.style.transform = 'scale(0)'
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
|
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="text-3xl text-white truncate">Changelog</p>
|
<h1 class="text-3xl text-white truncate">Changelog</h1>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
|
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
|
||||||
@ -13,7 +13,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="custom-text" v-html="getChangelog(release)" />
|
<div class="custom-text" v-html="getChangelog(release)" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" class="border-b border-black-300 my-8" />
|
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" :key="`${release.name}-divider`" class="border-b border-black-300 my-8" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
<div v-if="processing" role="img" :aria-label="$strings.MessageLoading" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||||
<widgets-loading-spinner />
|
<widgets-loading-spinner />
|
||||||
</div>
|
</div>
|
||||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" :aria-label="$getString('LabelPersonalYearReview', [variant + 1])" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<p class="hidden md:block text-xl font-semibold">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</p>
|
<h1 class="hidden md:block text-xl font-semibold">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</h1>
|
||||||
<div class="hidden md:block flex-grow" />
|
<div class="hidden md:block flex-grow" />
|
||||||
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide : $strings.LabelYearReviewShow }}</ui-btn>
|
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide : $strings.LabelYearReviewShow }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@ -16,17 +16,22 @@
|
|||||||
<div v-if="showYearInReview">
|
<div v-if="showYearInReview">
|
||||||
<div class="w-full h-px bg-slate-200/10 my-4" />
|
<div class="w-full h-px bg-slate-200/10 my-4" />
|
||||||
|
|
||||||
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
|
<div v-if="availableYears.length > 1" class="mb-2 py-2 max-w-[800px] mx-auto">
|
||||||
|
<!-- year selector -->
|
||||||
|
<ui-dropdown v-model="yearInReviewYear" small :items="availableYears" :disabled="processingYearInReview" class="max-w-24" @input="yearInReviewYearChanged" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="toolbar" class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
|
||||||
<!-- previous button -->
|
<!-- previous button -->
|
||||||
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" :aria-label="$strings.ButtonPrevious" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
||||||
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||||
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
|
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
<!-- share button -->
|
<!-- share button -->
|
||||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview">{{ $strings.ButtonShare }} </ui-btn>
|
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview">{{ $strings.ButtonShare }} </ui-btn>
|
||||||
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelPersonalYearReview', [yearInReviewVariant + 1]) }}</p>
|
<h2 class="hidden sm:block text-lg font-semibold">{{ $getString('LabelPersonalYearReview', [yearInReviewVariant + 1]) }}</h2>
|
||||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
|
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
@ -36,7 +41,7 @@
|
|||||||
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
|
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
<!-- next button -->
|
<!-- next button -->
|
||||||
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" :aria-label="$strings.ButtonNext" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
||||||
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
|
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
|
||||||
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
@ -46,23 +51,23 @@
|
|||||||
<!-- your year in review short -->
|
<!-- your year in review short -->
|
||||||
<div class="w-full max-w-[800px] mx-auto my-4">
|
<div class="w-full max-w-[800px] mx-auto my-4">
|
||||||
<!-- share button -->
|
<!-- share button -->
|
||||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort">{{ $strings.ButtonShare }}</ui-btn>
|
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex items-center font-semibold mb-1" @click="shareYearInReviewShort">{{ $strings.ButtonShare }}</ui-btn>
|
||||||
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
|
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- your server in review -->
|
<!-- your server in review -->
|
||||||
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
|
<div v-if="isAdminOrUp" role="toolbar" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
|
||||||
<div class="flex items-center justify-center mb-2">
|
<div class="flex items-center justify-center mb-2">
|
||||||
<!-- previous button -->
|
<!-- previous button -->
|
||||||
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" :aria-label="$strings.ButtonPrevious" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
||||||
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||||
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
|
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
<!-- share button -->
|
<!-- share button -->
|
||||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer">{{ $strings.ButtonShare }} </ui-btn>
|
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer">{{ $strings.ButtonShare }} </ui-btn>
|
||||||
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelServerYearReview', [yearInReviewServerVariant + 1]) }}</p>
|
<h2 class="hidden sm:block text-lg font-semibold">{{ $getString('LabelServerYearReview', [yearInReviewServerVariant + 1]) }}</h2>
|
||||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
|
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
@ -72,7 +77,7 @@
|
|||||||
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
|
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
<!-- next button -->
|
<!-- next button -->
|
||||||
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" :aria-label="$strings.ButtonNext" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
||||||
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
|
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
|
||||||
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
@ -88,6 +93,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showYearInReview: false,
|
showYearInReview: false,
|
||||||
|
availableYears: [],
|
||||||
yearInReviewYear: 0,
|
yearInReviewYear: 0,
|
||||||
yearInReviewVariant: 0,
|
yearInReviewVariant: 0,
|
||||||
yearInReviewServerVariant: 0,
|
yearInReviewServerVariant: 0,
|
||||||
@ -100,6 +106,9 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
isAdminOrUp() {
|
isAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
|
user() {
|
||||||
|
return this.$store.state.user.user
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -112,25 +121,57 @@ export default {
|
|||||||
shareYearInReviewShort() {
|
shareYearInReviewShort() {
|
||||||
this.$refs.yearInReviewShort.share()
|
this.$refs.yearInReviewShort.share()
|
||||||
},
|
},
|
||||||
|
yearInReviewYearChanged() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.refreshYearInReview()
|
||||||
|
this.refreshYearInReviewServer()
|
||||||
|
})
|
||||||
|
},
|
||||||
refreshYearInReviewServer() {
|
refreshYearInReviewServer() {
|
||||||
|
if (this.$refs.yearInReviewServer != null) {
|
||||||
this.$refs.yearInReviewServer.refresh()
|
this.$refs.yearInReviewServer.refresh()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
refreshYearInReview() {
|
refreshYearInReview() {
|
||||||
|
if (this.$refs.yearInReview != null && this.$refs.yearInReviewShort != null) {
|
||||||
this.$refs.yearInReview.refresh()
|
this.$refs.yearInReview.refresh()
|
||||||
this.$refs.yearInReviewShort.refresh()
|
this.$refs.yearInReviewShort.refresh()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
clickShowYearInReview() {
|
clickShowYearInReview() {
|
||||||
this.showYearInReview = !this.showYearInReview
|
this.showYearInReview = !this.showYearInReview
|
||||||
|
},
|
||||||
|
getAvailableYears() {
|
||||||
|
if (this.user) {
|
||||||
|
const oldestDate = this.user.createdAt
|
||||||
|
if (oldestDate) {
|
||||||
|
const date = new Date(oldestDate)
|
||||||
|
const oldestYear = date.getFullYear()
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
|
||||||
|
const years = []
|
||||||
|
for (let year = currentYear; year >= oldestYear; year--) {
|
||||||
|
years.push({ value: year, text: year.toString() })
|
||||||
|
}
|
||||||
|
|
||||||
|
return years
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback on error
|
||||||
|
return [{ value: this.yearInReviewYear, text: this.yearInReviewYear.toString() }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeMount() {
|
beforeMount() {
|
||||||
this.yearInReviewYear = new Date().getFullYear()
|
this.yearInReviewYear = new Date().getFullYear()
|
||||||
|
|
||||||
// When not December show previous year
|
// When not December show previous year
|
||||||
if (new Date().getMonth() < 11) {
|
if (new Date().getMonth() < 11) {
|
||||||
this.yearInReviewYear--
|
this.yearInReviewYear--
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.availableYears = this.getAvailableYears()
|
||||||
|
|
||||||
if (typeof navigator.share !== 'undefined' && navigator.share) {
|
if (typeof navigator.share !== 'undefined' && navigator.share) {
|
||||||
this.showShareButton = true
|
this.showShareButton = true
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
<div v-if="processing" role="img" :aria-label="$strings.MessageLoading" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||||
<widgets-loading-spinner />
|
<widgets-loading-spinner />
|
||||||
</div>
|
</div>
|
||||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" :aria-label="$getString('LabelServerYearReview', [variant + 1])" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
||||||
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
|
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
|
||||||
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" :aria-label="$strings.LabelMore" aria-haspopup="menu" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||||
<span class="material-symbols text-2xl" :class="iconClass"></span>
|
<span class="material-symbols text-2xl" :class="iconClass"></span>
|
||||||
</button>
|
</button>
|
||||||
<div v-else class="h-full w-full flex items-center justify-center">
|
<div v-else class="h-full w-full flex items-center justify-center">
|
||||||
@ -10,12 +10,12 @@
|
|||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
<transition name="menu">
|
<transition name="menu">
|
||||||
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
|
<div v-show="showMenu" ref="menuWrapper" role="menu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
|
||||||
<template v-for="(item, index) in items">
|
<template v-for="(item, index) in items">
|
||||||
<template v-if="item.subitems">
|
<template v-if="item.subitems">
|
||||||
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
|
<button :key="index" role="menuitem" aria-haspopup="menu" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default w-full" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
|
||||||
<p>{{ item.text }}</p>
|
<p>{{ item.text }}</p>
|
||||||
</div>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="mouseoverItemIndex === index"
|
v-if="mouseoverItemIndex === index"
|
||||||
:key="`subitems-${index}`"
|
:key="`subitems-${index}`"
|
||||||
@ -25,14 +25,14 @@
|
|||||||
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
|
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
|
||||||
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
|
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
|
||||||
>
|
>
|
||||||
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)">
|
<button v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(subitem.action, subitem.data)">
|
||||||
<p>{{ subitem.text }}</p>
|
<p>{{ subitem.text }}</p>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
|
<button v-else :key="index" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(item.action)">
|
||||||
<p class="text-left">{{ item.text }}</p>
|
<p class="text-left">{{ item.text }}</p>
|
||||||
</div>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||||
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||||
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="menu" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
||||||
<span v-if="selectedSubtext">: </span>
|
<span v-if="selectedSubtext">: </span>
|
||||||
@ -13,9 +13,9 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<transition name="menu">
|
<transition name="menu">
|
||||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox" :style="{ maxHeight: menuMaxHeight }">
|
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="menu" :style="{ maxHeight: menuMaxHeight }">
|
||||||
<template v-for="item in itemsToShow">
|
<template v-for="item in itemsToShow">
|
||||||
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
|
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" role="menuitem" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
|
<span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
|
||||||
<span v-if="item.subtext">: </span>
|
<span v-if="item.subtext">: </span>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
<button :aria-label="ariaLabel" class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
||||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
@ -28,7 +28,8 @@ export default {
|
|||||||
size: {
|
size: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 9
|
default: 9
|
||||||
}
|
},
|
||||||
|
ariaLabel: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
|
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="menu"
|
||||||
:aria-expanded="showMenu"
|
:aria-expanded="showMenu"
|
||||||
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
|
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
|
||||||
@click.stop.prevent="clickShowMenu"
|
@click.stop.prevent="clickShowMenu"
|
||||||
@ -16,9 +16,9 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<transition name="menu">
|
<transition name="menu">
|
||||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox">
|
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="menu">
|
||||||
<template v-for="library in librariesFiltered">
|
<template v-for="library in librariesFiltered">
|
||||||
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
|
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="menuitem" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
|
||||||
<div class="flex items-center px-2">
|
<div class="flex items-center px-2">
|
||||||
<ui-library-icon :icon="library.icon" class="mr-1.5" />
|
<ui-library-icon :icon="library.icon" class="mr-1.5" />
|
||||||
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
|
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
|
<button :aria-label="isRead ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
|
||||||
<div class="w-5 h-5 text-white relative">
|
<div class="w-5 h-5 text-white relative">
|
||||||
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
|
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
|
||||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<button :aria-labelledby="labeledBy" role="checkbox" type="button" class="border rounded-full border-black-100 flex items-center cursor-pointer justify-start" :style="{ width: buttonWidth + 'px' }" :aria-checked="toggleValue" :class="className" @click="clickToggle">
|
<button :aria-labelledby="labeledBy" :aria-label="label" role="checkbox" type="button" class="border rounded-full border-black-100 flex items-center cursor-pointer justify-start" :style="{ width: buttonWidth + 'px' }" :aria-checked="toggleValue" :class="className" @click="clickToggle">
|
||||||
<span class="rounded-full border border-black-50 shadow transform transition-transform duration-100" :style="{ width: cursorHeightWidth + 'px', height: cursorHeightWidth + 'px' }" :class="switchClassName"></span>
|
<span class="rounded-full border border-black-50 shadow transform transition-transform duration-100" :style="{ width: cursorHeightWidth + 'px', height: cursorHeightWidth + 'px' }" :class="switchClassName"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -20,6 +20,7 @@ export default {
|
|||||||
},
|
},
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
labeledBy: String,
|
labeledBy: String,
|
||||||
|
label: String,
|
||||||
size: {
|
size: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'md'
|
default: 'md'
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
<div aria-hidden="true" class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
||||||
<span class="material-symbols" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button"></span>
|
<span class="material-symbols" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button"></span>
|
||||||
<p class="px-2 font-mono" style="font-size: 1rem">{{ bookCoverWidth }}</p>
|
<p class="px-2 font-mono" style="font-size: 1rem">{{ bookCoverWidth }}</p>
|
||||||
<span class="material-symbols" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button"></span>
|
<span class="material-symbols" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button"></span>
|
||||||
|
@ -3,10 +3,10 @@
|
|||||||
<div class="flex items-center py-3e">
|
<div class="flex items-center py-3e">
|
||||||
<slot />
|
<slot />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<button cy-id="leftScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
<button cy-id="leftScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollLeft" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_left</span>
|
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_left</span>
|
||||||
</button>
|
</button>
|
||||||
<button cy-id="rightScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
<button cy-id="rightScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollRight" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_right</span>
|
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_right</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,9 +6,9 @@
|
|||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2>
|
<h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end py-2">
|
<div role="article" :aria-label="$strings.LabelSettingsStoreCoversWithItemHelp" class="flex items-end py-2">
|
||||||
<ui-toggle-switch labeledBy="settings-store-cover-with-items" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
|
<ui-toggle-switch :label="$strings.LabelSettingsStoreCoversWithItem" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
|
||||||
<ui-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp">
|
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreCoversWithItemHelp">
|
||||||
<p class="pl-4">
|
<p class="pl-4">
|
||||||
<span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span>
|
<span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span>
|
||||||
<span class="material-symbols icon-text">info</span>
|
<span class="material-symbols icon-text">info</span>
|
||||||
@ -16,9 +16,9 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div role="article" :aria-label="$strings.LabelSettingsStoreMetadataWithItemHelp" class="flex items-center py-2">
|
||||||
<ui-toggle-switch labeledBy="settings-store-metadata-with-items" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
|
<ui-toggle-switch :label="$strings.LabelSettingsStoreMetadataWithItem" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
|
||||||
<ui-tooltip :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
|
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
|
||||||
<p class="pl-4">
|
<p class="pl-4">
|
||||||
<span id="settings-store-metadata-with-items">{{ $strings.LabelSettingsStoreMetadataWithItem }}</span>
|
<span id="settings-store-metadata-with-items">{{ $strings.LabelSettingsStoreMetadataWithItem }}</span>
|
||||||
<span class="material-symbols icon-text">info</span>
|
<span class="material-symbols icon-text">info</span>
|
||||||
@ -26,9 +26,9 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div role="article" :aria-label="$strings.LabelSettingsSortingIgnorePrefixesHelp" class="flex items-center py-2">
|
||||||
<ui-toggle-switch labeledBy="settings-sorting-ignore-prefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
<ui-toggle-switch :label="$strings.LabelSettingsSortingIgnorePrefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
||||||
<ui-tooltip :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
|
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
|
||||||
<p class="pl-4">
|
<p class="pl-4">
|
||||||
<span id="settings-sorting-ignore-prefixes">{{ $strings.LabelSettingsSortingIgnorePrefixes }}</span>
|
<span id="settings-sorting-ignore-prefixes">{{ $strings.LabelSettingsSortingIgnorePrefixes }}</span>
|
||||||
<span class="material-symbols icon-text">info</span>
|
<span class="material-symbols icon-text">info</span>
|
||||||
@ -46,9 +46,9 @@
|
|||||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
|
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div role="article" :aria-label="$strings.LabelSettingsParseSubtitlesHelp" class="flex items-center py-2">
|
||||||
<ui-toggle-switch labeledBy="settings-parse-subtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
<ui-toggle-switch :label="$strings.LabelSettingsParseSubtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||||
<ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp">
|
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsParseSubtitlesHelp">
|
||||||
<p class="pl-4">
|
<p class="pl-4">
|
||||||
<span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
|
<span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
|
||||||
<span class="material-symbols icon-text">info</span>
|
<span class="material-symbols icon-text">info</span>
|
||||||
@ -56,9 +56,9 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div role="article" :aria-label="$strings.LabelSettingsFindCoversHelp" class="flex items-center py-2">
|
||||||
<ui-toggle-switch labeledBy="settings-find-covers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
<ui-toggle-switch :label="$strings.LabelSettingsFindCovers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||||
<ui-tooltip :text="$strings.LabelSettingsFindCoversHelp">
|
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsFindCoversHelp">
|
||||||
<p class="pl-4">
|
<p class="pl-4">
|
||||||
<span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span>
|
<span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span>
|
||||||
<span class="material-symbols icon-text">info</span>
|
<span class="material-symbols icon-text">info</span>
|
||||||
@ -70,9 +70,9 @@
|
|||||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div role="article" :aria-label="$strings.LabelSettingsPreferMatchedMetadataHelp" class="flex items-center py-2">
|
||||||
<ui-toggle-switch labeledBy="settings-prefer-matched-metadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
<ui-toggle-switch :label="$strings.LabelSettingsPreferMatchedMetadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||||
<ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
|
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
|
||||||
<p class="pl-4">
|
<p class="pl-4">
|
||||||
<span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
|
<span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
|
||||||
<span class="material-symbols icon-text">info</span>
|
<span class="material-symbols icon-text">info</span>
|
||||||
@ -80,9 +80,9 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div role="article" :aria-label="$strings.LabelSettingsEnableWatcherHelp" class="flex items-center py-2">
|
||||||
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
|
<ui-toggle-switch :label="$strings.LabelSettingsEnableWatcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
|
||||||
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp">
|
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsEnableWatcherHelp">
|
||||||
<p class="pl-4">
|
<p class="pl-4">
|
||||||
<span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
|
<span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
|
||||||
<span class="material-symbols icon-text">info</span>
|
<span class="material-symbols icon-text">info</span>
|
||||||
@ -95,13 +95,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :label="$strings.LabelSettingsChromecastSupport" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||||
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
<p aria-hidden="true" class="pl-4">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2 mb-2">
|
<div class="flex items-center py-2 mb-2">
|
||||||
<ui-toggle-switch labeledBy="settings-allow-iframe" v-model="newServerSettings.allowIframe" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('allowIframe', val)" />
|
<ui-toggle-switch v-model="newServerSettings.allowIframe" :label="$strings.LabelSettingsAllowIframe" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('allowIframe', val)" />
|
||||||
<p class="pl-4" id="settings-allow-iframe">{{ $strings.LabelSettingsAllowIframe }}</p>
|
<p aria-hidden="true" class="pl-4">{{ $strings.LabelSettingsAllowIframe }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -12,12 +12,12 @@
|
|||||||
<!-- Item Cover Overlay -->
|
<!-- Item Cover Overlay -->
|
||||||
<div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none">
|
<div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none">
|
||||||
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
|
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
|
||||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem">
|
<button class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" :aria-label="$strings.ButtonPlay" @click.stop.prevent="playItem">
|
||||||
<span class="material-symbols fill text-4xl">play_arrow</span>
|
<span class="material-symbols fill text-4xl">play_arrow</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" @click="showEditCover">edit</span>
|
<button class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" :aria-label="$strings.ButtonEdit" @click="showEditCover">edit</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -87,7 +87,7 @@
|
|||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
||||||
<span v-show="!isStreaming" class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
|
<span class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
|
||||||
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
|
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
@ -96,12 +96,12 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
||||||
<span class="material-symbols text-2xl -ml-2 pr-2 text-white">auto_stories</span>
|
<span class="material-symbols text-2xl -ml-2 pr-2 text-white" aria-hidden="true">auto_stories</span>
|
||||||
{{ $strings.ButtonRead }}
|
{{ $strings.ButtonRead }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
|
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
|
||||||
<ui-icon-btn icon="" 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>
|
||||||
|
|
||||||
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
|
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
|
||||||
@ -110,12 +110,12 @@
|
|||||||
|
|
||||||
<!-- Only admin or root user can download new episodes -->
|
<!-- Only admin or root user can download new episodes -->
|
||||||
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
|
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
|
||||||
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
<ui-icon-btn icon="search" class="mx-0.5" :aria-label="$strings.LabelFindEpisodes" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
|
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
|
||||||
<template #default="{ showMenu, clickShowMenu, disabled }">
|
<template #default="{ showMenu, clickShowMenu, disabled }">
|
||||||
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.LabelMore" @click.stop.prevent="clickShowMenu">
|
||||||
<span class="material-symbols text-2xl"></span>
|
<span class="material-symbols text-2xl"></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
1
client/strings/be.json
Normal file
1
client/strings/be.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
@ -88,6 +88,8 @@
|
|||||||
"ButtonSaveTracklist": "Uložit seznam skladeb",
|
"ButtonSaveTracklist": "Uložit seznam skladeb",
|
||||||
"ButtonScan": "Prohledat",
|
"ButtonScan": "Prohledat",
|
||||||
"ButtonScanLibrary": "Prohledat Knihovnu",
|
"ButtonScanLibrary": "Prohledat Knihovnu",
|
||||||
|
"ButtonScrollLeft": "Posunout vlevo",
|
||||||
|
"ButtonScrollRight": "Posunout vpravo",
|
||||||
"ButtonSearch": "Hledat",
|
"ButtonSearch": "Hledat",
|
||||||
"ButtonSelectFolderPath": "Vybrat cestu ke složce",
|
"ButtonSelectFolderPath": "Vybrat cestu ke složce",
|
||||||
"ButtonSeries": "Série",
|
"ButtonSeries": "Série",
|
||||||
@ -190,6 +192,7 @@
|
|||||||
"HeaderSettingsExperimental": "Experimentální funkce",
|
"HeaderSettingsExperimental": "Experimentální funkce",
|
||||||
"HeaderSettingsGeneral": "Obecné",
|
"HeaderSettingsGeneral": "Obecné",
|
||||||
"HeaderSettingsScanner": "Skener",
|
"HeaderSettingsScanner": "Skener",
|
||||||
|
"HeaderSettingsWebClient": "Webový klient",
|
||||||
"HeaderSleepTimer": "Časovač vypnutí",
|
"HeaderSleepTimer": "Časovač vypnutí",
|
||||||
"HeaderStatsLargestItems": "Největší položky",
|
"HeaderStatsLargestItems": "Největší položky",
|
||||||
"HeaderStatsLongestItems": "Nejdelší položky (hod.)",
|
"HeaderStatsLongestItems": "Nejdelší položky (hod.)",
|
||||||
@ -264,6 +267,7 @@
|
|||||||
"LabelChapters": "Kapitoly",
|
"LabelChapters": "Kapitoly",
|
||||||
"LabelChaptersFound": "Kapitoly nalezeny",
|
"LabelChaptersFound": "Kapitoly nalezeny",
|
||||||
"LabelClickForMoreInfo": "Klikněte pro více informací",
|
"LabelClickForMoreInfo": "Klikněte pro více informací",
|
||||||
|
"LabelClickToUseCurrentValue": "Klikni pro použití aktuální hodnoty",
|
||||||
"LabelClosePlayer": "Zavřít přehrávač",
|
"LabelClosePlayer": "Zavřít přehrávač",
|
||||||
"LabelCodec": "Kodek",
|
"LabelCodec": "Kodek",
|
||||||
"LabelCollapseSeries": "Sbalit sérii",
|
"LabelCollapseSeries": "Sbalit sérii",
|
||||||
@ -313,12 +317,25 @@
|
|||||||
"LabelEmailSettingsTestAddress": "Testovací adresa",
|
"LabelEmailSettingsTestAddress": "Testovací adresa",
|
||||||
"LabelEmbeddedCover": "Vložená obálka",
|
"LabelEmbeddedCover": "Vložená obálka",
|
||||||
"LabelEnable": "Povolit",
|
"LabelEnable": "Povolit",
|
||||||
|
"LabelEncodingBackupLocation": "Záloha původních audio souborů bude uložena v:",
|
||||||
|
"LabelEncodingChaptersNotEmbedded": "Kapitoly nejsou vloženy ve vícestopých audioknihách.",
|
||||||
|
"LabelEncodingClearItemCache": "Nezapomeňte pravidelně promazávat mezipaměť položek.",
|
||||||
|
"LabelEncodingFinishedM4B": "Výsledné M4B bude uloženo do složky s audioknihou v:",
|
||||||
|
"LabelEncodingInfoEmbedded": "Metadata budou vložena do audio stop ve složce s audioknihou.",
|
||||||
|
"LabelEncodingStartedNavigation": "Po spuštění úlohy můžete opustit tuto stránku.",
|
||||||
|
"LabelEncodingTimeWarning": "Encoding může zabrat až 30 minut.",
|
||||||
|
"LabelEncodingWarningAdvancedSettings": "Varování: Neměňte toto nastavení pokud neznáte možnosti encodingu ffmpeg.",
|
||||||
|
"LabelEncodingWatcherDisabled": "Pokud máte zakázaný watcher, budete po skončení muset znovu naskenovat tuto audioknihu.",
|
||||||
"LabelEnd": "Konec",
|
"LabelEnd": "Konec",
|
||||||
"LabelEndOfChapter": "Konec kapitoly",
|
"LabelEndOfChapter": "Konec kapitoly",
|
||||||
"LabelEpisode": "Epizoda",
|
"LabelEpisode": "Epizoda",
|
||||||
|
"LabelEpisodeNotLinkedToRssFeed": "Epizoda není propojená s RSS feed",
|
||||||
|
"LabelEpisodeNumber": "Epizoda #{0}",
|
||||||
"LabelEpisodeTitle": "Název epizody",
|
"LabelEpisodeTitle": "Název epizody",
|
||||||
"LabelEpisodeType": "Typ epizody",
|
"LabelEpisodeType": "Typ epizody",
|
||||||
|
"LabelEpisodeUrlFromRssFeed": "URL epizody z RSS feed",
|
||||||
"LabelEpisodes": "Epizody",
|
"LabelEpisodes": "Epizody",
|
||||||
|
"LabelEpisodic": "Epizodické",
|
||||||
"LabelExample": "Příklad",
|
"LabelExample": "Příklad",
|
||||||
"LabelExpandSeries": "Rozbalit série",
|
"LabelExpandSeries": "Rozbalit série",
|
||||||
"LabelExpandSubSeries": "Rozbalit podsérie",
|
"LabelExpandSubSeries": "Rozbalit podsérie",
|
||||||
@ -346,6 +363,7 @@
|
|||||||
"LabelFontScale": "Měřítko písma",
|
"LabelFontScale": "Měřítko písma",
|
||||||
"LabelFontStrikethrough": "Přeškrtnutí",
|
"LabelFontStrikethrough": "Přeškrtnutí",
|
||||||
"LabelFormat": "Formát",
|
"LabelFormat": "Formát",
|
||||||
|
"LabelFull": "Plné",
|
||||||
"LabelGenre": "Žánr",
|
"LabelGenre": "Žánr",
|
||||||
"LabelGenres": "Žánry",
|
"LabelGenres": "Žánry",
|
||||||
"LabelHardDeleteFile": "Trvale smazat soubor",
|
"LabelHardDeleteFile": "Trvale smazat soubor",
|
||||||
@ -388,6 +406,7 @@
|
|||||||
"LabelLess": "Méně",
|
"LabelLess": "Méně",
|
||||||
"LabelLibrariesAccessibleToUser": "Knihovny přístupné uživateli",
|
"LabelLibrariesAccessibleToUser": "Knihovny přístupné uživateli",
|
||||||
"LabelLibrary": "Knihovna",
|
"LabelLibrary": "Knihovna",
|
||||||
|
"LabelLibraryFilterSublistEmpty": "Žádné {0}",
|
||||||
"LabelLibraryItem": "Položka knihovny",
|
"LabelLibraryItem": "Položka knihovny",
|
||||||
"LabelLibraryName": "Název knihovny",
|
"LabelLibraryName": "Název knihovny",
|
||||||
"LabelLimit": "Omezit",
|
"LabelLimit": "Omezit",
|
||||||
@ -400,6 +419,9 @@
|
|||||||
"LabelLowestPriority": "Nejnižší priorita",
|
"LabelLowestPriority": "Nejnižší priorita",
|
||||||
"LabelMatchExistingUsersBy": "Přiřadit stávající uživatele podle",
|
"LabelMatchExistingUsersBy": "Přiřadit stávající uživatele podle",
|
||||||
"LabelMatchExistingUsersByDescription": "Slouží k propojení stávajících uživatelů. Po propojení budou uživatelé přiřazeni k jedinečnému ID od poskytovatele SSO.",
|
"LabelMatchExistingUsersByDescription": "Slouží k propojení stávajících uživatelů. Po propojení budou uživatelé přiřazeni k jedinečnému ID od poskytovatele SSO.",
|
||||||
|
"LabelMaxEpisodesToDownload": "Maximální # epizod pro stažení. Použijte 0 pro bez omezení.",
|
||||||
|
"LabelMaxEpisodesToKeep": "Maximální počet epizod k zachování",
|
||||||
|
"LabelMaxEpisodesToKeepHelp": "Hodnotou 0 není nastaven žádný maximální limit. Po automatickém stažení nové epizody se odstraní nejstarší epizoda, pokud máte více než X epizod. Při každém novém stažení se odstraní pouze 1 epizoda.",
|
||||||
"LabelMediaPlayer": "Přehrávač médií",
|
"LabelMediaPlayer": "Přehrávač médií",
|
||||||
"LabelMediaType": "Typ média",
|
"LabelMediaType": "Typ média",
|
||||||
"LabelMetaTag": "Metaznačka",
|
"LabelMetaTag": "Metaznačka",
|
||||||
@ -445,12 +467,14 @@
|
|||||||
"LabelOpenIDGroupClaimDescription": "Název požadavku OpenID, který obsahuje seznam uživatelských skupin. Běžně se označuje jako <code>groups</code>. <b>Je-li nakonfigurováno</b>, plikace automaticky přiřadí role na základě členství uživatele ve skupinách, pokud jsou tyto skupiny v požadavku pojmenovány case-insensitive 'admin', 'user' nebo 'guest'. Požadavek by měl obsahovat seznam, a pokud uživatel patří do více skupin, aplikace přiřadí roli odpovídající nejvyšší úrovni práva přístupu. Pokud žádná skupina není shodná, bude přístup odepřen.",
|
"LabelOpenIDGroupClaimDescription": "Název požadavku OpenID, který obsahuje seznam uživatelských skupin. Běžně se označuje jako <code>groups</code>. <b>Je-li nakonfigurováno</b>, plikace automaticky přiřadí role na základě členství uživatele ve skupinách, pokud jsou tyto skupiny v požadavku pojmenovány case-insensitive 'admin', 'user' nebo 'guest'. Požadavek by měl obsahovat seznam, a pokud uživatel patří do více skupin, aplikace přiřadí roli odpovídající nejvyšší úrovni práva přístupu. Pokud žádná skupina není shodná, bude přístup odepřen.",
|
||||||
"LabelOpenRSSFeed": "Otevřít RSS kanál",
|
"LabelOpenRSSFeed": "Otevřít RSS kanál",
|
||||||
"LabelOverwrite": "Přepsat",
|
"LabelOverwrite": "Přepsat",
|
||||||
|
"LabelPaginationPageXOfY": "Strana {0} z {1}",
|
||||||
"LabelPassword": "Heslo",
|
"LabelPassword": "Heslo",
|
||||||
"LabelPath": "Cesta",
|
"LabelPath": "Cesta",
|
||||||
"LabelPermanent": "Trvalé",
|
"LabelPermanent": "Trvalé",
|
||||||
"LabelPermissionsAccessAllLibraries": "Má přístup ke všem knihovnám",
|
"LabelPermissionsAccessAllLibraries": "Má přístup ke všem knihovnám",
|
||||||
"LabelPermissionsAccessAllTags": "Má přístup ke všem značkám",
|
"LabelPermissionsAccessAllTags": "Má přístup ke všem značkám",
|
||||||
"LabelPermissionsAccessExplicitContent": "Má přístup k explicitnímu obsahu",
|
"LabelPermissionsAccessExplicitContent": "Má přístup k explicitnímu obsahu",
|
||||||
|
"LabelPermissionsCreateEreader": "Může vytvořit Ereader",
|
||||||
"LabelPermissionsDelete": "Může mazat",
|
"LabelPermissionsDelete": "Může mazat",
|
||||||
"LabelPermissionsDownload": "Může stahovat",
|
"LabelPermissionsDownload": "Může stahovat",
|
||||||
"LabelPermissionsUpdate": "Může aktualizovat",
|
"LabelPermissionsUpdate": "Může aktualizovat",
|
||||||
@ -474,6 +498,8 @@
|
|||||||
"LabelPubDate": "Datum vydání",
|
"LabelPubDate": "Datum vydání",
|
||||||
"LabelPublishYear": "Rok vydání",
|
"LabelPublishYear": "Rok vydání",
|
||||||
"LabelPublishedDate": "Vydáno {0}",
|
"LabelPublishedDate": "Vydáno {0}",
|
||||||
|
"LabelPublishedDecade": "Publikováno (dekáda)",
|
||||||
|
"LabelPublishedDecades": "Publikováno (dekády)",
|
||||||
"LabelPublisher": "Vydavatel",
|
"LabelPublisher": "Vydavatel",
|
||||||
"LabelPublishers": "Vydavatelé",
|
"LabelPublishers": "Vydavatelé",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
||||||
@ -493,24 +519,32 @@
|
|||||||
"LabelRedo": "Přepracovat",
|
"LabelRedo": "Přepracovat",
|
||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Datum vydání",
|
"LabelReleaseDate": "Datum vydání",
|
||||||
|
"LabelRemoveAllMetadataAbs": "Odebrat všechny soubory metadata.abs",
|
||||||
|
"LabelRemoveAllMetadataJson": "Smazat všechny soubory metadata.json",
|
||||||
"LabelRemoveCover": "Odstranit obálku",
|
"LabelRemoveCover": "Odstranit obálku",
|
||||||
|
"LabelRemoveMetadataFile": "Odstranit soubory metadat ve složkách položek knihovny",
|
||||||
|
"LabelRemoveMetadataFileHelp": "Odstraníte všechny soubory metadata.json a metadata.abs ve svých složkách {0}.",
|
||||||
"LabelRowsPerPage": "Řádky na stránku",
|
"LabelRowsPerPage": "Řádky na stránku",
|
||||||
"LabelSearchTerm": "Vyhledat termín",
|
"LabelSearchTerm": "Vyhledat termín",
|
||||||
"LabelSearchTitle": "Vyhledat název",
|
"LabelSearchTitle": "Vyhledat název",
|
||||||
"LabelSearchTitleOrASIN": "Vyhledat název nebo ASIN",
|
"LabelSearchTitleOrASIN": "Vyhledat název nebo ASIN",
|
||||||
"LabelSeason": "Sezóna",
|
"LabelSeason": "Sezóna",
|
||||||
|
"LabelSeasonNumber": "Sezóna č.{0}",
|
||||||
"LabelSelectAll": "Vybrat vše",
|
"LabelSelectAll": "Vybrat vše",
|
||||||
"LabelSelectAllEpisodes": "Vybrat všechny epizody",
|
"LabelSelectAllEpisodes": "Vybrat všechny epizody",
|
||||||
"LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují",
|
"LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují",
|
||||||
"LabelSelectUsers": "Vybrat uživatele",
|
"LabelSelectUsers": "Vybrat uživatele",
|
||||||
"LabelSendEbookToDevice": "Odeslat e-knihu do...",
|
"LabelSendEbookToDevice": "Odeslat e-knihu do...",
|
||||||
"LabelSequence": "Sekvence",
|
"LabelSequence": "Sekvence",
|
||||||
|
"LabelSerial": "Sériové",
|
||||||
"LabelSeries": "Série",
|
"LabelSeries": "Série",
|
||||||
"LabelSeriesName": "Název série",
|
"LabelSeriesName": "Název série",
|
||||||
"LabelSeriesProgress": "Průběh série",
|
"LabelSeriesProgress": "Průběh série",
|
||||||
|
"LabelServerLogLevel": "Úroveň protokolu serveru",
|
||||||
"LabelServerYearReview": "Přehled roku na serveru ({0})",
|
"LabelServerYearReview": "Přehled roku na serveru ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Nastavit jako primární",
|
"LabelSetEbookAsPrimary": "Nastavit jako primární",
|
||||||
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkové",
|
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkové",
|
||||||
|
"LabelSettingsAllowIframe": "Povolit vložení do rámce iframe",
|
||||||
"LabelSettingsAudiobooksOnly": "Pouze audioknihy",
|
"LabelSettingsAudiobooksOnly": "Pouze audioknihy",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Povolením tohoto nastavení budou soubory e-knih ignorovány, pokud nejsou ve složce audioknih, v takovém případě budou nastaveny jako doplňkové e-knihy",
|
"LabelSettingsAudiobooksOnlyHelp": "Povolením tohoto nastavení budou soubory e-knih ignorovány, pokud nejsou ve složce audioknih, v takovém případě budou nastaveny jako doplňkové e-knihy",
|
||||||
"LabelSettingsBookshelfViewHelp": "Skeumorfní design s dřevěnými policemi",
|
"LabelSettingsBookshelfViewHelp": "Skeumorfní design s dřevěnými policemi",
|
||||||
@ -532,6 +566,9 @@
|
|||||||
"LabelSettingsHideSingleBookSeriesHelp": "Série, které mají jedinou knihu, budou skryty na stránce série a na domovské stránce.",
|
"LabelSettingsHideSingleBookSeriesHelp": "Série, které mají jedinou knihu, budou skryty na stránce série a na domovské stránce.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Domovská stránka používá zobrazení police s knihami",
|
"LabelSettingsHomePageBookshelfView": "Domovská stránka používá zobrazení police s knihami",
|
||||||
"LabelSettingsLibraryBookshelfView": "Knihovna používá zobrazení police s knihami",
|
"LabelSettingsLibraryBookshelfView": "Knihovna používá zobrazení police s knihami",
|
||||||
|
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Procento dokončení je vyšší než",
|
||||||
|
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Zbývající čas je kratší než (sekund)",
|
||||||
|
"LabelSettingsLibraryMarkAsFinishedWhen": "Označit položku médií jako dokončenou, když",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Přeskočit předchozí knihy v pokračování série",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Přeskočit předchozí knihy v pokračování série",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polička Pokračovat v sérii na domovské stránce zobrazuje první nezačatou knihu v sériích, které mají alespoň jednu knihu dokončenou a žádnou rozečtenou. Povolením tohoto nastavení budou série pokračovat od poslední dokončené knihy namísto první nezačaté knihy.",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polička Pokračovat v sérii na domovské stránce zobrazuje první nezačatou knihu v sériích, které mají alespoň jednu knihu dokončenou a žádnou rozečtenou. Povolením tohoto nastavení budou série pokračovat od poslední dokončené knihy namísto první nezačaté knihy.",
|
||||||
"LabelSettingsParseSubtitles": "Analzyovat podtitul",
|
"LabelSettingsParseSubtitles": "Analzyovat podtitul",
|
||||||
@ -550,12 +587,16 @@
|
|||||||
"LabelSettingsStoreMetadataWithItemHelp": "Ve výchozím nastavení jsou soubory metadat uloženy v adresáři /metadata/items, povolením tohoto nastavení budou soubory metadat uloženy ve složkách položek knihovny",
|
"LabelSettingsStoreMetadataWithItemHelp": "Ve výchozím nastavení jsou soubory metadat uloženy v adresáři /metadata/items, povolením tohoto nastavení budou soubory metadat uloženy ve složkách položek knihovny",
|
||||||
"LabelSettingsTimeFormat": "Formát času",
|
"LabelSettingsTimeFormat": "Formát času",
|
||||||
"LabelShare": "Sdílet",
|
"LabelShare": "Sdílet",
|
||||||
|
"LabelShareOpen": "Otevřít sdílení",
|
||||||
"LabelShareURL": "Sdílet URL",
|
"LabelShareURL": "Sdílet URL",
|
||||||
"LabelShowAll": "Zobrazit vše",
|
"LabelShowAll": "Zobrazit vše",
|
||||||
"LabelShowSeconds": "Zobrazit sekundy",
|
"LabelShowSeconds": "Zobrazit sekundy",
|
||||||
"LabelShowSubtitles": "Zobrazit titulky",
|
"LabelShowSubtitles": "Zobrazit titulky",
|
||||||
"LabelSize": "Velikost",
|
"LabelSize": "Velikost",
|
||||||
"LabelSleepTimer": "Časovač vypnutí",
|
"LabelSleepTimer": "Časovač vypnutí",
|
||||||
|
"LabelSlug": "URL název",
|
||||||
|
"LabelSortAscending": "Vzestupně",
|
||||||
|
"LabelSortDescending": "Sestupně",
|
||||||
"LabelStart": "Spustit",
|
"LabelStart": "Spustit",
|
||||||
"LabelStartTime": "Čas Spuštění",
|
"LabelStartTime": "Čas Spuštění",
|
||||||
"LabelStarted": "Spuštěno",
|
"LabelStarted": "Spuštěno",
|
||||||
@ -594,6 +635,7 @@
|
|||||||
"LabelTimeDurationXMinutes": "{0} minut",
|
"LabelTimeDurationXMinutes": "{0} minut",
|
||||||
"LabelTimeDurationXSeconds": "{0} sekund",
|
"LabelTimeDurationXSeconds": "{0} sekund",
|
||||||
"LabelTimeInMinutes": "Čas v minutách",
|
"LabelTimeInMinutes": "Čas v minutách",
|
||||||
|
"LabelTimeLeft": "{0} zbývá",
|
||||||
"LabelTimeListened": "Čas poslechu",
|
"LabelTimeListened": "Čas poslechu",
|
||||||
"LabelTimeListenedToday": "Čas poslechu dnes",
|
"LabelTimeListenedToday": "Čas poslechu dnes",
|
||||||
"LabelTimeRemaining": "{0} zbývá",
|
"LabelTimeRemaining": "{0} zbývá",
|
||||||
@ -601,6 +643,7 @@
|
|||||||
"LabelTitle": "Název",
|
"LabelTitle": "Název",
|
||||||
"LabelToolsEmbedMetadata": "Vložit metadata",
|
"LabelToolsEmbedMetadata": "Vložit metadata",
|
||||||
"LabelToolsEmbedMetadataDescription": "Vložit metadata do zvukových souborů včetně obálky a kapitol.",
|
"LabelToolsEmbedMetadataDescription": "Vložit metadata do zvukových souborů včetně obálky a kapitol.",
|
||||||
|
"LabelToolsM4bEncoder": "Enkodér M4B",
|
||||||
"LabelToolsMakeM4b": "Vytvořit soubor audioknihy M4B",
|
"LabelToolsMakeM4b": "Vytvořit soubor audioknihy M4B",
|
||||||
"LabelToolsMakeM4bDescription": "Vygenerovat soubor audioknihy M4B s vloženými metadaty, obálkou a kapitolami.",
|
"LabelToolsMakeM4bDescription": "Vygenerovat soubor audioknihy M4B s vloženými metadaty, obálkou a kapitolami.",
|
||||||
"LabelToolsSplitM4b": "Rozdělit M4B na MP3",
|
"LabelToolsSplitM4b": "Rozdělit M4B na MP3",
|
||||||
@ -613,6 +656,7 @@
|
|||||||
"LabelTracksMultiTrack": "Více stop",
|
"LabelTracksMultiTrack": "Více stop",
|
||||||
"LabelTracksNone": "Žádné stopy",
|
"LabelTracksNone": "Žádné stopy",
|
||||||
"LabelTracksSingleTrack": "Jedna stopa",
|
"LabelTracksSingleTrack": "Jedna stopa",
|
||||||
|
"LabelTrailer": "Upoutávka",
|
||||||
"LabelType": "Typ",
|
"LabelType": "Typ",
|
||||||
"LabelUnabridged": "Nezkráceno",
|
"LabelUnabridged": "Nezkráceno",
|
||||||
"LabelUndo": "Zpět",
|
"LabelUndo": "Zpět",
|
||||||
@ -624,10 +668,13 @@
|
|||||||
"LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda",
|
"LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda",
|
||||||
"LabelUpdatedAt": "Aktualizováno v",
|
"LabelUpdatedAt": "Aktualizováno v",
|
||||||
"LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky",
|
"LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Přetáhnout a upustit soubory",
|
||||||
"LabelUploaderDropFiles": "Odstranit soubory",
|
"LabelUploaderDropFiles": "Odstranit soubory",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Automaticky načíst název, autora a sérii",
|
"LabelUploaderItemFetchMetadataHelp": "Automaticky načíst název, autora a sérii",
|
||||||
|
"LabelUseAdvancedOptions": "Použít pokročilé možnosti",
|
||||||
"LabelUseChapterTrack": "Použít stopu kapitoly",
|
"LabelUseChapterTrack": "Použít stopu kapitoly",
|
||||||
"LabelUseFullTrack": "Použít celou stopu",
|
"LabelUseFullTrack": "Použít celou stopu",
|
||||||
|
"LabelUseZeroForUnlimited": "Použijte 0 pro neomezené",
|
||||||
"LabelUser": "Uživatel",
|
"LabelUser": "Uživatel",
|
||||||
"LabelUsername": "Uživatelské jméno",
|
"LabelUsername": "Uživatelské jméno",
|
||||||
"LabelValue": "Hodnota",
|
"LabelValue": "Hodnota",
|
||||||
@ -637,6 +684,8 @@
|
|||||||
"LabelViewPlayerSettings": "Zobrazit nastavení přehrávače",
|
"LabelViewPlayerSettings": "Zobrazit nastavení přehrávače",
|
||||||
"LabelViewQueue": "Zobrazit frontu přehrávače",
|
"LabelViewQueue": "Zobrazit frontu přehrávače",
|
||||||
"LabelVolume": "Hlasitost",
|
"LabelVolume": "Hlasitost",
|
||||||
|
"LabelWebRedirectURLsDescription": "Autorizujte tyto adresy URL ve zprostředkovateli OAuth, abyste po přihlášení umožnili přesměrování zpět do webové aplikace:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "Podsložka pro přesměrování adres URL",
|
||||||
"LabelWeekdaysToRun": "Dny v týdnu ke spuštění",
|
"LabelWeekdaysToRun": "Dny v týdnu ke spuštění",
|
||||||
"LabelXBooks": "{0} knih",
|
"LabelXBooks": "{0} knih",
|
||||||
"LabelXItems": "{0} položky",
|
"LabelXItems": "{0} položky",
|
||||||
@ -674,6 +723,7 @@
|
|||||||
"MessageConfirmDeleteMetadataProvider": "Opravdu chcete vymazat vlastního poskytovatele metadat \"{0}\"?",
|
"MessageConfirmDeleteMetadataProvider": "Opravdu chcete vymazat vlastního poskytovatele metadat \"{0}\"?",
|
||||||
"MessageConfirmDeleteNotification": "Opravdu chcete vymazat tuto notifikaci?",
|
"MessageConfirmDeleteNotification": "Opravdu chcete vymazat tuto notifikaci?",
|
||||||
"MessageConfirmDeleteSession": "Opravdu chcete smazat tuto relaci?",
|
"MessageConfirmDeleteSession": "Opravdu chcete smazat tuto relaci?",
|
||||||
|
"MessageConfirmEmbedMetadataInAudioFiles": "Jste si jisti, že chcete vložit metadata do {0} zvukových souborů?",
|
||||||
"MessageConfirmForceReScan": "Opravdu chcete vynutit opětovné prohledání?",
|
"MessageConfirmForceReScan": "Opravdu chcete vynutit opětovné prohledání?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokončené?",
|
"MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokončené?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "Opravdu chcete označit všechny epizody jako nedokončené?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "Opravdu chcete označit všechny epizody jako nedokončené?",
|
||||||
@ -681,6 +731,7 @@
|
|||||||
"MessageConfirmMarkItemNotFinished": "Opravdu chcete označit \"{0}\" jako nedokončené?",
|
"MessageConfirmMarkItemNotFinished": "Opravdu chcete označit \"{0}\" jako nedokončené?",
|
||||||
"MessageConfirmMarkSeriesFinished": "Opravdu chcete označit všechny knihy z této série jako dokončené?",
|
"MessageConfirmMarkSeriesFinished": "Opravdu chcete označit všechny knihy z této série jako dokončené?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "Opravdu chcete označit všechny knihy z této série jako nedokončené?",
|
"MessageConfirmMarkSeriesNotFinished": "Opravdu chcete označit všechny knihy z této série jako nedokončené?",
|
||||||
|
"MessageConfirmNotificationTestTrigger": "Spustit toto oznámení s testovacími daty?",
|
||||||
"MessageConfirmPurgeCache": "Vyčistit mezipaměť odstraní celý adresář na adrese <code>/metadata/cache</code>. <br /><br />Určitě chcete odstranit adresář mezipaměti?",
|
"MessageConfirmPurgeCache": "Vyčistit mezipaměť odstraní celý adresář na adrese <code>/metadata/cache</code>. <br /><br />Určitě chcete odstranit adresář mezipaměti?",
|
||||||
"MessageConfirmPurgeItemsCache": "Vyčištění mezipaměti položek odstraní celý adresář <code>/metadata/cache/items</code>.<br />Jste si jistí?",
|
"MessageConfirmPurgeItemsCache": "Vyčištění mezipaměti položek odstraní celý adresář <code>/metadata/cache/items</code>.<br />Jste si jistí?",
|
||||||
"MessageConfirmQuickEmbed": "Varování! Rychlé vložení nezálohuje vaše zvukové soubory. Ujistěte se, že máte zálohu zvukových souborů. <br><br>Chcete pokračovat?",
|
"MessageConfirmQuickEmbed": "Varování! Rychlé vložení nezálohuje vaše zvukové soubory. Ujistěte se, že máte zálohu zvukových souborů. <br><br>Chcete pokračovat?",
|
||||||
|
@ -88,6 +88,8 @@
|
|||||||
"ButtonSaveTracklist": "Speichere die Titelliste",
|
"ButtonSaveTracklist": "Speichere die Titelliste",
|
||||||
"ButtonScan": "Partial-Scan (nur geänderte/neue Medien)",
|
"ButtonScan": "Partial-Scan (nur geänderte/neue Medien)",
|
||||||
"ButtonScanLibrary": "Bibliothek scannen",
|
"ButtonScanLibrary": "Bibliothek scannen",
|
||||||
|
"ButtonScrollLeft": "Nach Links scrollen",
|
||||||
|
"ButtonScrollRight": "Nach Rechts scrollen",
|
||||||
"ButtonSearch": "Suchen",
|
"ButtonSearch": "Suchen",
|
||||||
"ButtonSelectFolderPath": "Ordnerpfad auswählen",
|
"ButtonSelectFolderPath": "Ordnerpfad auswählen",
|
||||||
"ButtonSeries": "Serien",
|
"ButtonSeries": "Serien",
|
||||||
@ -190,6 +192,7 @@
|
|||||||
"HeaderSettingsExperimental": "Experimentelle Funktionen",
|
"HeaderSettingsExperimental": "Experimentelle Funktionen",
|
||||||
"HeaderSettingsGeneral": "Allgemein",
|
"HeaderSettingsGeneral": "Allgemein",
|
||||||
"HeaderSettingsScanner": "Scanner",
|
"HeaderSettingsScanner": "Scanner",
|
||||||
|
"HeaderSettingsWebClient": "Web-Client",
|
||||||
"HeaderSleepTimer": "Sleep-Timer",
|
"HeaderSleepTimer": "Sleep-Timer",
|
||||||
"HeaderStatsLargestItems": "Größte Medien",
|
"HeaderStatsLargestItems": "Größte Medien",
|
||||||
"HeaderStatsLongestItems": "Längste Medien (h)",
|
"HeaderStatsLongestItems": "Längste Medien (h)",
|
||||||
@ -542,6 +545,7 @@
|
|||||||
"LabelServerYearReview": "Server Jahr in Übersicht ({0})",
|
"LabelServerYearReview": "Server Jahr in Übersicht ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Als Hauptbuch setzen",
|
"LabelSetEbookAsPrimary": "Als Hauptbuch setzen",
|
||||||
"LabelSetEbookAsSupplementary": "Als Ergänzung setzen",
|
"LabelSetEbookAsSupplementary": "Als Ergänzung setzen",
|
||||||
|
"LabelSettingsAllowIframe": "Einbetten in einem iFrame erlauben",
|
||||||
"LabelSettingsAudiobooksOnly": "Nur Hörbücher",
|
"LabelSettingsAudiobooksOnly": "Nur Hörbücher",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Wenn du diese Einstellung aktivierst, werden E-Buch-Dateien ignoriert, es sei denn, sie befinden sich in einem Hörbuchordner. In diesem Fall werden sie als zusätzliche E-Bücher festgelegt",
|
"LabelSettingsAudiobooksOnlyHelp": "Wenn du diese Einstellung aktivierst, werden E-Buch-Dateien ignoriert, es sei denn, sie befinden sich in einem Hörbuchordner. In diesem Fall werden sie als zusätzliche E-Bücher festgelegt",
|
||||||
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
|
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
|
||||||
@ -592,6 +596,8 @@
|
|||||||
"LabelSize": "Größe",
|
"LabelSize": "Größe",
|
||||||
"LabelSleepTimer": "Schlummerfunktion",
|
"LabelSleepTimer": "Schlummerfunktion",
|
||||||
"LabelSlug": "URL Teil",
|
"LabelSlug": "URL Teil",
|
||||||
|
"LabelSortAscending": "Aufsteigend",
|
||||||
|
"LabelSortDescending": "Absteigend",
|
||||||
"LabelStart": "Start",
|
"LabelStart": "Start",
|
||||||
"LabelStartTime": "Startzeit",
|
"LabelStartTime": "Startzeit",
|
||||||
"LabelStarted": "Gestartet",
|
"LabelStarted": "Gestartet",
|
||||||
@ -679,7 +685,7 @@
|
|||||||
"LabelViewPlayerSettings": "Zeige player Einstellungen",
|
"LabelViewPlayerSettings": "Zeige player Einstellungen",
|
||||||
"LabelViewQueue": "Player-Warteschlange anzeigen",
|
"LabelViewQueue": "Player-Warteschlange anzeigen",
|
||||||
"LabelVolume": "Lautstärke",
|
"LabelVolume": "Lautstärke",
|
||||||
"LabelWebRedirectURLsDescription": "Autorisieren Sie diese URLs bei ihrem OAuth-Anbieter, um die Weiterleitung zurück zur Webanwendung nach dem Login zu ermöglichen:",
|
"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",
|
"LabelWebRedirectURLsSubfolder": "Unterordner für Weiterleitung-URLs",
|
||||||
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
|
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
|
||||||
"LabelXBooks": "{0} Bücher",
|
"LabelXBooks": "{0} Bücher",
|
||||||
|
@ -88,6 +88,8 @@
|
|||||||
"ButtonSaveTracklist": "Save Tracklist",
|
"ButtonSaveTracklist": "Save Tracklist",
|
||||||
"ButtonScan": "Scan",
|
"ButtonScan": "Scan",
|
||||||
"ButtonScanLibrary": "Scan Library",
|
"ButtonScanLibrary": "Scan Library",
|
||||||
|
"ButtonScrollLeft": "Scroll Left",
|
||||||
|
"ButtonScrollRight": "Scroll Right",
|
||||||
"ButtonSearch": "Search",
|
"ButtonSearch": "Search",
|
||||||
"ButtonSelectFolderPath": "Select Folder Path",
|
"ButtonSelectFolderPath": "Select Folder Path",
|
||||||
"ButtonSeries": "Series",
|
"ButtonSeries": "Series",
|
||||||
@ -594,6 +596,8 @@
|
|||||||
"LabelSize": "Size",
|
"LabelSize": "Size",
|
||||||
"LabelSleepTimer": "Sleep timer",
|
"LabelSleepTimer": "Sleep timer",
|
||||||
"LabelSlug": "Slug",
|
"LabelSlug": "Slug",
|
||||||
|
"LabelSortAscending": "Ascending",
|
||||||
|
"LabelSortDescending": "Descending",
|
||||||
"LabelStart": "Start",
|
"LabelStart": "Start",
|
||||||
"LabelStartTime": "Start Time",
|
"LabelStartTime": "Start Time",
|
||||||
"LabelStarted": "Started",
|
"LabelStarted": "Started",
|
||||||
|
@ -88,6 +88,8 @@
|
|||||||
"ButtonSaveTracklist": "Guardar Tracklist",
|
"ButtonSaveTracklist": "Guardar Tracklist",
|
||||||
"ButtonScan": "Escanear",
|
"ButtonScan": "Escanear",
|
||||||
"ButtonScanLibrary": "Escanear Biblioteca",
|
"ButtonScanLibrary": "Escanear Biblioteca",
|
||||||
|
"ButtonScrollLeft": "Desplazarse hacia la izquierda",
|
||||||
|
"ButtonScrollRight": "Desplazarse hacia la derecha",
|
||||||
"ButtonSearch": "Buscar",
|
"ButtonSearch": "Buscar",
|
||||||
"ButtonSelectFolderPath": "Seleccionar Ruta de Carpeta",
|
"ButtonSelectFolderPath": "Seleccionar Ruta de Carpeta",
|
||||||
"ButtonSeries": "Series",
|
"ButtonSeries": "Series",
|
||||||
@ -190,6 +192,7 @@
|
|||||||
"HeaderSettingsExperimental": "Funciones Experimentales",
|
"HeaderSettingsExperimental": "Funciones Experimentales",
|
||||||
"HeaderSettingsGeneral": "General",
|
"HeaderSettingsGeneral": "General",
|
||||||
"HeaderSettingsScanner": "Escáner",
|
"HeaderSettingsScanner": "Escáner",
|
||||||
|
"HeaderSettingsWebClient": "Cliente web",
|
||||||
"HeaderSleepTimer": "Temporizador de apagado",
|
"HeaderSleepTimer": "Temporizador de apagado",
|
||||||
"HeaderStatsLargestItems": "Artículos mas Grandes",
|
"HeaderStatsLargestItems": "Artículos mas Grandes",
|
||||||
"HeaderStatsLongestItems": "Artículos mas Largos (h)",
|
"HeaderStatsLongestItems": "Artículos mas Largos (h)",
|
||||||
@ -542,6 +545,7 @@
|
|||||||
"LabelServerYearReview": "Resumen del año del servidor ({0})",
|
"LabelServerYearReview": "Resumen del año del servidor ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Establecer como primario",
|
"LabelSetEbookAsPrimary": "Establecer como primario",
|
||||||
"LabelSetEbookAsSupplementary": "Establecer como suplementario",
|
"LabelSetEbookAsSupplementary": "Establecer como suplementario",
|
||||||
|
"LabelSettingsAllowIframe": "Permitir incrustación en un iframe",
|
||||||
"LabelSettingsAudiobooksOnly": "Sólo Audiolibros",
|
"LabelSettingsAudiobooksOnly": "Sólo Audiolibros",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Al activar esta opción se ignorarán los archivos de ebook a menos de que estén dentro de la carpeta de un audiolibro, en cuyo caso se marcarán como ebooks suplementarios",
|
"LabelSettingsAudiobooksOnlyHelp": "Al activar esta opción se ignorarán los archivos de ebook a menos de que estén dentro de la carpeta de un audiolibro, en cuyo caso se marcarán como ebooks suplementarios",
|
||||||
"LabelSettingsBookshelfViewHelp": "Diseño Esqueuomorfo con Estantes de Madera",
|
"LabelSettingsBookshelfViewHelp": "Diseño Esqueuomorfo con Estantes de Madera",
|
||||||
@ -592,6 +596,8 @@
|
|||||||
"LabelSize": "Tamaño",
|
"LabelSize": "Tamaño",
|
||||||
"LabelSleepTimer": "Temporizador de apagado",
|
"LabelSleepTimer": "Temporizador de apagado",
|
||||||
"LabelSlug": "Slug",
|
"LabelSlug": "Slug",
|
||||||
|
"LabelSortAscending": "Ascendente",
|
||||||
|
"LabelSortDescending": "Descendente",
|
||||||
"LabelStart": "Iniciar",
|
"LabelStart": "Iniciar",
|
||||||
"LabelStartTime": "Tiempo de Inicio",
|
"LabelStartTime": "Tiempo de Inicio",
|
||||||
"LabelStarted": "Iniciado",
|
"LabelStarted": "Iniciado",
|
||||||
|
@ -592,6 +592,8 @@
|
|||||||
"LabelSize": "Taille",
|
"LabelSize": "Taille",
|
||||||
"LabelSleepTimer": "Minuterie de mise en veille",
|
"LabelSleepTimer": "Minuterie de mise en veille",
|
||||||
"LabelSlug": "Identifiant d’URL",
|
"LabelSlug": "Identifiant d’URL",
|
||||||
|
"LabelSortAscending": "Croissant",
|
||||||
|
"LabelSortDescending": "Décroissant",
|
||||||
"LabelStart": "Démarrer",
|
"LabelStart": "Démarrer",
|
||||||
"LabelStartTime": "Heure de démarrage",
|
"LabelStartTime": "Heure de démarrage",
|
||||||
"LabelStarted": "Démarré",
|
"LabelStarted": "Démarré",
|
||||||
|
@ -88,6 +88,8 @@
|
|||||||
"ButtonSaveTracklist": "Spremi popis zvučnih zapisa",
|
"ButtonSaveTracklist": "Spremi popis zvučnih zapisa",
|
||||||
"ButtonScan": "Skeniraj",
|
"ButtonScan": "Skeniraj",
|
||||||
"ButtonScanLibrary": "Skeniraj knjižnicu",
|
"ButtonScanLibrary": "Skeniraj knjižnicu",
|
||||||
|
"ButtonScrollLeft": "Pomicanje lijevo",
|
||||||
|
"ButtonScrollRight": "Pomicanje desno",
|
||||||
"ButtonSearch": "Traži",
|
"ButtonSearch": "Traži",
|
||||||
"ButtonSelectFolderPath": "Odaberi putanju mape",
|
"ButtonSelectFolderPath": "Odaberi putanju mape",
|
||||||
"ButtonSeries": "Serijali",
|
"ButtonSeries": "Serijali",
|
||||||
@ -190,6 +192,7 @@
|
|||||||
"HeaderSettingsExperimental": "Eksperimentalne značajke",
|
"HeaderSettingsExperimental": "Eksperimentalne značajke",
|
||||||
"HeaderSettingsGeneral": "Općenito",
|
"HeaderSettingsGeneral": "Općenito",
|
||||||
"HeaderSettingsScanner": "Skener",
|
"HeaderSettingsScanner": "Skener",
|
||||||
|
"HeaderSettingsWebClient": "Web klijent",
|
||||||
"HeaderSleepTimer": "Timer za spavanje",
|
"HeaderSleepTimer": "Timer za spavanje",
|
||||||
"HeaderStatsLargestItems": "Najveće stavke",
|
"HeaderStatsLargestItems": "Najveće stavke",
|
||||||
"HeaderStatsLongestItems": "Najduže stavke (sati)",
|
"HeaderStatsLongestItems": "Najduže stavke (sati)",
|
||||||
@ -542,6 +545,7 @@
|
|||||||
"LabelServerYearReview": "Godišnji pregled poslužitelja ({0})",
|
"LabelServerYearReview": "Godišnji pregled poslužitelja ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Postavi kao primarno",
|
"LabelSetEbookAsPrimary": "Postavi kao primarno",
|
||||||
"LabelSetEbookAsSupplementary": "Postavi kao dopunsko",
|
"LabelSetEbookAsSupplementary": "Postavi kao dopunsko",
|
||||||
|
"LabelSettingsAllowIframe": "Omogući ugrađivanje u iframeu",
|
||||||
"LabelSettingsAudiobooksOnly": "Samo zvučne knjige",
|
"LabelSettingsAudiobooksOnly": "Samo zvučne knjige",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Ako uključite ovu mogućnost, sustav će zanemariti datoteke e-knjiga ukoliko se ne nalaze u mapi zvučne knjige, gdje će se smatrati dopunskim e-knjigama",
|
"LabelSettingsAudiobooksOnlyHelp": "Ako uključite ovu mogućnost, sustav će zanemariti datoteke e-knjiga ukoliko se ne nalaze u mapi zvučne knjige, gdje će se smatrati dopunskim e-knjigama",
|
||||||
"LabelSettingsBookshelfViewHelp": "Skeumorfni dizajn sa drvenim policama",
|
"LabelSettingsBookshelfViewHelp": "Skeumorfni dizajn sa drvenim policama",
|
||||||
@ -592,6 +596,8 @@
|
|||||||
"LabelSize": "Veličina",
|
"LabelSize": "Veličina",
|
||||||
"LabelSleepTimer": "Timer za spavanje",
|
"LabelSleepTimer": "Timer za spavanje",
|
||||||
"LabelSlug": "Slug",
|
"LabelSlug": "Slug",
|
||||||
|
"LabelSortAscending": "Uzlazno",
|
||||||
|
"LabelSortDescending": "Silazno",
|
||||||
"LabelStart": "Početak",
|
"LabelStart": "Početak",
|
||||||
"LabelStartTime": "Vrijeme početka",
|
"LabelStartTime": "Vrijeme početka",
|
||||||
"LabelStarted": "Započeto",
|
"LabelStarted": "Započeto",
|
||||||
|
@ -88,6 +88,8 @@
|
|||||||
"ButtonSaveTracklist": "Sávlista mentése",
|
"ButtonSaveTracklist": "Sávlista mentése",
|
||||||
"ButtonScan": "Szkennelés",
|
"ButtonScan": "Szkennelés",
|
||||||
"ButtonScanLibrary": "Könyvtár szkennelése",
|
"ButtonScanLibrary": "Könyvtár szkennelése",
|
||||||
|
"ButtonScrollLeft": "Balra görgetés",
|
||||||
|
"ButtonScrollRight": "Jobbra görgetés",
|
||||||
"ButtonSearch": "Keresés",
|
"ButtonSearch": "Keresés",
|
||||||
"ButtonSelectFolderPath": "Mappa útvonalának kiválasztása",
|
"ButtonSelectFolderPath": "Mappa útvonalának kiválasztása",
|
||||||
"ButtonSeries": "Sorozatok",
|
"ButtonSeries": "Sorozatok",
|
||||||
@ -180,6 +182,7 @@
|
|||||||
"HeaderRemoveEpisodes": "{0} epizód eltávolítása",
|
"HeaderRemoveEpisodes": "{0} epizód eltávolítása",
|
||||||
"HeaderSavedMediaProgress": "Mentett médialejátszási állapot",
|
"HeaderSavedMediaProgress": "Mentett médialejátszási állapot",
|
||||||
"HeaderSchedule": "Ütemezés",
|
"HeaderSchedule": "Ütemezés",
|
||||||
|
"HeaderScheduleEpisodeDownloads": "Automatikus epizódletöltés ütemezése",
|
||||||
"HeaderScheduleLibraryScans": "Könyvtárak automatikus szkennelésének ütemezése",
|
"HeaderScheduleLibraryScans": "Könyvtárak automatikus szkennelésének ütemezése",
|
||||||
"HeaderSession": "Munkamenet",
|
"HeaderSession": "Munkamenet",
|
||||||
"HeaderSetBackupSchedule": "Biztonsági másolatok ütemezésének beállítása",
|
"HeaderSetBackupSchedule": "Biztonsági másolatok ütemezésének beállítása",
|
||||||
@ -188,13 +191,14 @@
|
|||||||
"HeaderSettingsExperimental": "Kísérleti funkciók",
|
"HeaderSettingsExperimental": "Kísérleti funkciók",
|
||||||
"HeaderSettingsGeneral": "Általános",
|
"HeaderSettingsGeneral": "Általános",
|
||||||
"HeaderSettingsScanner": "Szkenner",
|
"HeaderSettingsScanner": "Szkenner",
|
||||||
|
"HeaderSettingsWebClient": "Webkliens",
|
||||||
"HeaderSleepTimer": "Alvásidőzítő",
|
"HeaderSleepTimer": "Alvásidőzítő",
|
||||||
"HeaderStatsLargestItems": "Legnagyobb elemek",
|
"HeaderStatsLargestItems": "Legnagyobb elemek",
|
||||||
"HeaderStatsLongestItems": "Leghosszabb elemek (órákban)",
|
"HeaderStatsLongestItems": "Leghosszabb elemek (órákban)",
|
||||||
"HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)",
|
"HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)",
|
||||||
"HeaderStatsRecentSessions": "Legutóbbi munkamenetek",
|
"HeaderStatsRecentSessions": "Legutóbbi munkamenetek",
|
||||||
"HeaderStatsTop10Authors": "Top 10 szerzők",
|
"HeaderStatsTop10Authors": "Top 10 szerző",
|
||||||
"HeaderStatsTop5Genres": "Top 5 műfajok",
|
"HeaderStatsTop5Genres": "Top 5 műfaj",
|
||||||
"HeaderTableOfContents": "Tartalomjegyzék",
|
"HeaderTableOfContents": "Tartalomjegyzék",
|
||||||
"HeaderTools": "Eszközök",
|
"HeaderTools": "Eszközök",
|
||||||
"HeaderUpdateAccount": "Fiók frissítése",
|
"HeaderUpdateAccount": "Fiók frissítése",
|
||||||
@ -225,7 +229,11 @@
|
|||||||
"LabelAllUsersExcludingGuests": "Minden felhasználó, vendégek kivételével",
|
"LabelAllUsersExcludingGuests": "Minden felhasználó, vendégek kivételével",
|
||||||
"LabelAllUsersIncludingGuests": "Minden felhasználó, beleértve a vendégeket is",
|
"LabelAllUsersIncludingGuests": "Minden felhasználó, beleértve a vendégeket is",
|
||||||
"LabelAlreadyInYourLibrary": "Már a könyvtárában van",
|
"LabelAlreadyInYourLibrary": "Már a könyvtárában van",
|
||||||
|
"LabelApiToken": "API Token",
|
||||||
"LabelAppend": "Hozzáfűzés",
|
"LabelAppend": "Hozzáfűzés",
|
||||||
|
"LabelAudioBitrate": "Audió bitráta (pl.128k)",
|
||||||
|
"LabelAudioChannels": "Audió csatorna (1 vagy 2)",
|
||||||
|
"LabelAudioCodec": "Audio Codec",
|
||||||
"LabelAuthor": "Szerző",
|
"LabelAuthor": "Szerző",
|
||||||
"LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)",
|
"LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)",
|
||||||
"LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)",
|
"LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)",
|
||||||
@ -238,6 +246,7 @@
|
|||||||
"LabelAutoRegister": "Automatikus regisztráció",
|
"LabelAutoRegister": "Automatikus regisztráció",
|
||||||
"LabelAutoRegisterDescription": "Új felhasználók automatikus létrehozása bejelentkezés után",
|
"LabelAutoRegisterDescription": "Új felhasználók automatikus létrehozása bejelentkezés után",
|
||||||
"LabelBackToUser": "Vissza a felhasználóhoz",
|
"LabelBackToUser": "Vissza a felhasználóhoz",
|
||||||
|
"LabelBackupAudioFiles": "Audiófájlok biztonsági mentése",
|
||||||
"LabelBackupLocation": "Biztonsági másolat helye",
|
"LabelBackupLocation": "Biztonsági másolat helye",
|
||||||
"LabelBackupsEnableAutomaticBackups": "Automatikus biztonsági másolatok engedélyezése",
|
"LabelBackupsEnableAutomaticBackups": "Automatikus biztonsági másolatok engedélyezése",
|
||||||
"LabelBackupsEnableAutomaticBackupsHelp": "Biztonsági másolatok mentése a /metadata/backups mappába",
|
"LabelBackupsEnableAutomaticBackupsHelp": "Biztonsági másolatok mentése a /metadata/backups mappába",
|
||||||
@ -246,15 +255,18 @@
|
|||||||
"LabelBackupsNumberToKeep": "Megtartandó biztonsági másolatok száma",
|
"LabelBackupsNumberToKeep": "Megtartandó biztonsági másolatok száma",
|
||||||
"LabelBackupsNumberToKeepHelp": "Egyszerre csak 1 biztonsági másolat kerül eltávolításra, tehát ha már több biztonsági másolat van, mint ez a szám, akkor manuálisan kell eltávolítani őket.",
|
"LabelBackupsNumberToKeepHelp": "Egyszerre csak 1 biztonsági másolat kerül eltávolításra, tehát ha már több biztonsági másolat van, mint ez a szám, akkor manuálisan kell eltávolítani őket.",
|
||||||
"LabelBitrate": "Bitráta",
|
"LabelBitrate": "Bitráta",
|
||||||
|
"LabelBonus": "Bónusz",
|
||||||
"LabelBooks": "Könyvek",
|
"LabelBooks": "Könyvek",
|
||||||
"LabelButtonText": "Gomb szövege",
|
"LabelButtonText": "Gomb szövege",
|
||||||
"LabelByAuthor": "{} által",
|
"LabelByAuthor": "{} által",
|
||||||
"LabelChangePassword": "Jelszó megváltoztatása",
|
"LabelChangePassword": "Jelszó megváltoztatása",
|
||||||
"LabelChannels": "Csatornák",
|
"LabelChannels": "Csatornák",
|
||||||
|
"LabelChapterCount": "{0} Fejezet",
|
||||||
"LabelChapterTitle": "Fejezet címe",
|
"LabelChapterTitle": "Fejezet címe",
|
||||||
"LabelChapters": "Fejezetek",
|
"LabelChapters": "Fejezetek",
|
||||||
"LabelChaptersFound": "fejezet található",
|
"LabelChaptersFound": "fejezet található",
|
||||||
"LabelClickForMoreInfo": "További információkért kattintson",
|
"LabelClickForMoreInfo": "További információkért kattintson",
|
||||||
|
"LabelClickToUseCurrentValue": "Kattintson az aktuális érték használatához",
|
||||||
"LabelClosePlayer": "Lejátszó bezárása",
|
"LabelClosePlayer": "Lejátszó bezárása",
|
||||||
"LabelCodec": "Kodek",
|
"LabelCodec": "Kodek",
|
||||||
"LabelCollapseSeries": "Sorozat összecsukása",
|
"LabelCollapseSeries": "Sorozat összecsukása",
|
||||||
@ -304,16 +316,28 @@
|
|||||||
"LabelEmailSettingsTestAddress": "Teszt cím",
|
"LabelEmailSettingsTestAddress": "Teszt cím",
|
||||||
"LabelEmbeddedCover": "Beágyazott borító",
|
"LabelEmbeddedCover": "Beágyazott borító",
|
||||||
"LabelEnable": "Engedélyezés",
|
"LabelEnable": "Engedélyezés",
|
||||||
|
"LabelEncodingBackupLocation": "Az eredeti hangfájlok biztonsági másolata a következő helyen lesz tárolva:",
|
||||||
|
"LabelEncodingChaptersNotEmbedded": "A fejezetek nincsenek beágyazva a többsávos hangoskönyvekbe.",
|
||||||
|
"LabelEncodingClearItemCache": "Győződjön meg róla, hogy rendszeresen tisztítja az elemek gyorsítótárát.",
|
||||||
|
"LabelEncodingFinishedM4B": "A kész M4B a hangoskönyv mappádba kerül:",
|
||||||
|
"LabelEncodingStartedNavigation": "Ha a feladat elindult, el lehet navigálni erről az oldalról.",
|
||||||
|
"LabelEncodingTimeWarning": "A kódolás akár 30 percet is igénybe vehet.",
|
||||||
|
"LabelEncodingWarningAdvancedSettings": "Figyelmeztetés: Ne frissítse ezeket a beállításokat, hacsak nem ismeri az ffmpeg kódolási beállításait.",
|
||||||
|
"LabelEncodingWatcherDisabled": "Ha a figyelőt letiltotta, akkor ezt a hangoskönyvet utólag újra be kell olvasnia.",
|
||||||
"LabelEnd": "Vége",
|
"LabelEnd": "Vége",
|
||||||
"LabelEndOfChapter": "Fejezet vége",
|
"LabelEndOfChapter": "Fejezet vége",
|
||||||
"LabelEpisode": "Epizód",
|
"LabelEpisode": "Epizód",
|
||||||
|
"LabelEpisodeNotLinkedToRssFeed": "Epizód nem kapcsolódik RSS hírcsatonához",
|
||||||
|
"LabelEpisodeNumber": "Epizód #{0}",
|
||||||
"LabelEpisodeTitle": "Epizód címe",
|
"LabelEpisodeTitle": "Epizód címe",
|
||||||
"LabelEpisodeType": "Epizód típusa",
|
"LabelEpisodeType": "Epizód típusa",
|
||||||
|
"LabelEpisodeUrlFromRssFeed": "Epizód URL-címe az RSS hírcsatornából",
|
||||||
"LabelEpisodes": "Epizódok",
|
"LabelEpisodes": "Epizódok",
|
||||||
|
"LabelEpisodic": "Epizódikus",
|
||||||
"LabelExample": "Példa",
|
"LabelExample": "Példa",
|
||||||
"LabelExpandSeries": "Sorozat kinyitása",
|
"LabelExpandSeries": "Sorozat kinyitása",
|
||||||
"LabelExpandSubSeries": "Alsorozat kinyitása",
|
"LabelExpandSubSeries": "Alsorozat kinyitása",
|
||||||
"LabelExplicit": "Explicit",
|
"LabelExplicit": "Szókimondó",
|
||||||
"LabelExplicitChecked": "Explicit (ellenőrizve)",
|
"LabelExplicitChecked": "Explicit (ellenőrizve)",
|
||||||
"LabelExplicitUnchecked": "Nem explicit (nem ellenőrzött)",
|
"LabelExplicitUnchecked": "Nem explicit (nem ellenőrzött)",
|
||||||
"LabelExportOPML": "OPML exportálása",
|
"LabelExportOPML": "OPML exportálása",
|
||||||
@ -337,6 +361,7 @@
|
|||||||
"LabelFontScale": "Betűméret skála",
|
"LabelFontScale": "Betűméret skála",
|
||||||
"LabelFontStrikethrough": "Áthúzott",
|
"LabelFontStrikethrough": "Áthúzott",
|
||||||
"LabelFormat": "Formátum",
|
"LabelFormat": "Formátum",
|
||||||
|
"LabelFull": "Teljes",
|
||||||
"LabelGenre": "Műfaj",
|
"LabelGenre": "Műfaj",
|
||||||
"LabelGenres": "Műfajok",
|
"LabelGenres": "Műfajok",
|
||||||
"LabelHardDeleteFile": "Fájl végleges törlése",
|
"LabelHardDeleteFile": "Fájl végleges törlése",
|
||||||
@ -392,6 +417,10 @@
|
|||||||
"LabelLowestPriority": "Legalacsonyabb prioritás",
|
"LabelLowestPriority": "Legalacsonyabb prioritás",
|
||||||
"LabelMatchExistingUsersBy": "Meglévő felhasználók egyeztetése",
|
"LabelMatchExistingUsersBy": "Meglévő felhasználók egyeztetése",
|
||||||
"LabelMatchExistingUsersByDescription": "Meglévő felhasználók összekapcsolására használt. Egyszer összekapcsolva, a felhasználók egyedülálló azonosítóval lesznek egyeztetve az Ön SSO szolgáltatójától",
|
"LabelMatchExistingUsersByDescription": "Meglévő felhasználók összekapcsolására használt. Egyszer összekapcsolva, a felhasználók egyedülálló azonosítóval lesznek egyeztetve az Ön SSO szolgáltatójától",
|
||||||
|
"LabelMaxEpisodesToDownload": "Letölthető epizódok maximális száma. Használja a 0-t a korlátlan letöltéshez.",
|
||||||
|
"LabelMaxEpisodesToDownloadPerCheck": "Ellenőrzésenként letölthető új epizódok maximális száma",
|
||||||
|
"LabelMaxEpisodesToKeep": "Maximálisan megtartható epizódok száma",
|
||||||
|
"LabelMaxEpisodesToKeepHelp": "A 0 érték nem állít be maximális korlátot. Az új epizód automatikus letöltése után ez a beállítás törli a legrégebbi epizódot, ha X epizódnál több van. Új letöltésenként csak 1 epizódot töröl.",
|
||||||
"LabelMediaPlayer": "Médialejátszó",
|
"LabelMediaPlayer": "Médialejátszó",
|
||||||
"LabelMediaType": "Média típus",
|
"LabelMediaType": "Média típus",
|
||||||
"LabelMetaTag": "Meta címke",
|
"LabelMetaTag": "Meta címke",
|
||||||
@ -399,7 +428,7 @@
|
|||||||
"LabelMetadataOrderOfPrecedenceDescription": "A magasabb prioritású metaadat-források felülírják az alacsonyabb prioritásúakat",
|
"LabelMetadataOrderOfPrecedenceDescription": "A magasabb prioritású metaadat-források felülírják az alacsonyabb prioritásúakat",
|
||||||
"LabelMetadataProvider": "Metaadat-szolgáltató",
|
"LabelMetadataProvider": "Metaadat-szolgáltató",
|
||||||
"LabelMinute": "Perc",
|
"LabelMinute": "Perc",
|
||||||
"LabelMinutes": "Percek",
|
"LabelMinutes": "Perc",
|
||||||
"LabelMissing": "Hiányzó",
|
"LabelMissing": "Hiányzó",
|
||||||
"LabelMissingEbook": "Nincs e-könyve",
|
"LabelMissingEbook": "Nincs e-könyve",
|
||||||
"LabelMissingSupplementaryEbook": "Nincs kiegészítő e-könyve",
|
"LabelMissingSupplementaryEbook": "Nincs kiegészítő e-könyve",
|
||||||
@ -434,15 +463,17 @@
|
|||||||
"LabelNumberOfEpisodes": "Epizódok száma",
|
"LabelNumberOfEpisodes": "Epizódok száma",
|
||||||
"LabelOpenIDAdvancedPermsClaimDescription": "Az OpenID-igény neve, amely a felhasználói műveletekre vonatkozó haladó jogosultságokat tartalmazza az alkalmazáson belül, és amely a nem adminisztrátori szerepkörökre vonatkozik (<b>ha konfigurálva van</b>). Ha az igény hiányzik a válaszból, az ABS-hez való hozzáférés megtagadásra kerül. Ha egyetlen opció hiányzik, azt <code>false</code>-ként fogja kezelni. Győződj meg arról, hogy az identitásszolgáltató igénye megfelel a várt struktúrának:",
|
"LabelOpenIDAdvancedPermsClaimDescription": "Az OpenID-igény neve, amely a felhasználói műveletekre vonatkozó haladó jogosultságokat tartalmazza az alkalmazáson belül, és amely a nem adminisztrátori szerepkörökre vonatkozik (<b>ha konfigurálva van</b>). Ha az igény hiányzik a válaszból, az ABS-hez való hozzáférés megtagadásra kerül. Ha egyetlen opció hiányzik, azt <code>false</code>-ként fogja kezelni. Győződj meg arról, hogy az identitásszolgáltató igénye megfelel a várt struktúrának:",
|
||||||
"LabelOpenIDClaims": "Hagyd üresen a következő opciókat, hogy letiltsd a haladó csoport- és jogosultság-hozzárendelést, ekkor automatikusan a ‘Felhasználó’ csoport kerül hozzárendelésre.",
|
"LabelOpenIDClaims": "Hagyd üresen a következő opciókat, hogy letiltsd a haladó csoport- és jogosultság-hozzárendelést, ekkor automatikusan a ‘Felhasználó’ csoport kerül hozzárendelésre.",
|
||||||
"LabelOpenIDGroupClaimDescription": "Az OpenID-igény neve, amely a felhasználó csoportjainak listáját tartalmazza. Általában groups néven hivatkoznak rá. Ha konfigurálva van, az alkalmazás automatikusan hozzárendeli a szerepköröket a felhasználó csoporttagságai alapján, feltéve, hogy ezek a csoportok az igényben kis- és nagybetűkre érzéketlenül ‘admin’, ‘user’ vagy ‘guest’ néven szerepelnek. Az igénynek egy listát kell tartalmaznia, és ha egy felhasználó több csoport tagja, az alkalmazás a legmagasabb szintű hozzáféréssel rendelkező szerepkört rendeli hozzá. Ha egyetlen csoport sem felel meg, a hozzáférés megtagadásra kerül.",
|
"LabelOpenIDGroupClaimDescription": "Az OpenID-igény neve, amely a felhasználó csoportjainak listáját tartalmazza. Általában <code>groups<code> néven hivatkoznak rá. <b>Ha konfigurálva van<b>, az alkalmazás automatikusan hozzárendeli a szerepköröket a felhasználó csoporttagságai alapján, feltéve, hogy ezek a csoportok az igényben kis- és nagybetűkre érzéketlenül ‘admin’, ‘user’ vagy ‘guest’ néven szerepelnek. Az igénynek egy listát kell tartalmaznia, és ha egy felhasználó több csoport tagja, az alkalmazás a legmagasabb szintű hozzáféréssel rendelkező szerepkört rendeli hozzá. Ha egyetlen csoport sem felel meg, a hozzáférés megtagadásra kerül.",
|
||||||
"LabelOpenRSSFeed": "RSS hírcsatorna megnyitása",
|
"LabelOpenRSSFeed": "RSS hírcsatorna megnyitása",
|
||||||
"LabelOverwrite": "Felülírás",
|
"LabelOverwrite": "Felülírás",
|
||||||
|
"LabelPaginationPageXOfY": "{0} oldal {1}-ból/ből",
|
||||||
"LabelPassword": "Jelszó",
|
"LabelPassword": "Jelszó",
|
||||||
"LabelPath": "Útvonal",
|
"LabelPath": "Útvonal",
|
||||||
"LabelPermanent": "Végleges",
|
"LabelPermanent": "Végleges",
|
||||||
"LabelPermissionsAccessAllLibraries": "Hozzáférhet az összes könyvtárhoz",
|
"LabelPermissionsAccessAllLibraries": "Hozzáférhet az összes könyvtárhoz",
|
||||||
"LabelPermissionsAccessAllTags": "Hozzáférhet az összes címkéhez",
|
"LabelPermissionsAccessAllTags": "Hozzáférhet az összes címkéhez",
|
||||||
"LabelPermissionsAccessExplicitContent": "Hozzáférhet explicit tartalomhoz",
|
"LabelPermissionsAccessExplicitContent": "Hozzáférhet explicit tartalomhoz",
|
||||||
|
"LabelPermissionsCreateEreader": "Létrehozhat Ereader-t",
|
||||||
"LabelPermissionsDelete": "Törölhet",
|
"LabelPermissionsDelete": "Törölhet",
|
||||||
"LabelPermissionsDownload": "Letölthet",
|
"LabelPermissionsDownload": "Letölthet",
|
||||||
"LabelPermissionsUpdate": "Frissíthet",
|
"LabelPermissionsUpdate": "Frissíthet",
|
||||||
@ -466,6 +497,8 @@
|
|||||||
"LabelPubDate": "Kiadás dátuma",
|
"LabelPubDate": "Kiadás dátuma",
|
||||||
"LabelPublishYear": "Kiadás éve",
|
"LabelPublishYear": "Kiadás éve",
|
||||||
"LabelPublishedDate": "Kiadva {0}",
|
"LabelPublishedDate": "Kiadva {0}",
|
||||||
|
"LabelPublishedDecade": "Közzétett évtized",
|
||||||
|
"LabelPublishedDecades": "Közzétett évtized",
|
||||||
"LabelPublisher": "Kiadó",
|
"LabelPublisher": "Kiadó",
|
||||||
"LabelPublishers": "Kiadók",
|
"LabelPublishers": "Kiadók",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Egyéni tulajdonos e-mail",
|
"LabelRSSFeedCustomOwnerEmail": "Egyéni tulajdonos e-mail",
|
||||||
@ -475,6 +508,7 @@
|
|||||||
"LabelRSSFeedSlug": "RSS hírcsatorna slug",
|
"LabelRSSFeedSlug": "RSS hírcsatorna slug",
|
||||||
"LabelRSSFeedURL": "RSS hírcsatorna URL",
|
"LabelRSSFeedURL": "RSS hírcsatorna URL",
|
||||||
"LabelRandomly": "Véletlenszerűen",
|
"LabelRandomly": "Véletlenszerűen",
|
||||||
|
"LabelReAddSeriesToContinueListening": "Sorozat újbóli hozzáadása a folytatáshoz",
|
||||||
"LabelRead": "Olvasás",
|
"LabelRead": "Olvasás",
|
||||||
"LabelReadAgain": "Újraolvasás",
|
"LabelReadAgain": "Újraolvasás",
|
||||||
"LabelReadEbookWithoutProgress": "E-könyv olvasása haladás nélkül",
|
"LabelReadEbookWithoutProgress": "E-könyv olvasása haladás nélkül",
|
||||||
@ -484,12 +518,18 @@
|
|||||||
"LabelRedo": "Újra",
|
"LabelRedo": "Újra",
|
||||||
"LabelRegion": "Régió",
|
"LabelRegion": "Régió",
|
||||||
"LabelReleaseDate": "Megjelenés dátuma",
|
"LabelReleaseDate": "Megjelenés dátuma",
|
||||||
|
"LabelRemoveAllMetadataAbs": "Az összes metadata.abs fájl eltávolítása",
|
||||||
|
"LabelRemoveAllMetadataJson": "Az összes metadata.json fájl eltávolítása",
|
||||||
"LabelRemoveCover": "Borító eltávolítása",
|
"LabelRemoveCover": "Borító eltávolítása",
|
||||||
|
"LabelRemoveMetadataFile": "Metaadatfájlok eltávolítása a könyvtár elemek mappáiból",
|
||||||
|
"LabelRemoveMetadataFileHelp": "A metadata.json és metadata.abs fájlokat eltávolítása a {0} mappáidból.",
|
||||||
"LabelRowsPerPage": "Sorok száma oldalanként",
|
"LabelRowsPerPage": "Sorok száma oldalanként",
|
||||||
"LabelSearchTerm": "Keresési kifejezés",
|
"LabelSearchTerm": "Keresési kifejezés",
|
||||||
"LabelSearchTitle": "Cím keresése",
|
"LabelSearchTitle": "Cím keresése",
|
||||||
"LabelSearchTitleOrASIN": "Cím vagy ASIN keresése",
|
"LabelSearchTitleOrASIN": "Cím vagy ASIN keresése",
|
||||||
"LabelSeason": "Évad",
|
"LabelSeason": "Évad",
|
||||||
|
"LabelSeasonNumber": "Évad #{0}",
|
||||||
|
"LabelSelectAll": "Minden kiválasztása",
|
||||||
"LabelSelectAllEpisodes": "Összes epizód kiválasztása",
|
"LabelSelectAllEpisodes": "Összes epizód kiválasztása",
|
||||||
"LabelSelectEpisodesShowing": "Kiválasztás {0} megjelenített epizód",
|
"LabelSelectEpisodesShowing": "Kiválasztás {0} megjelenített epizód",
|
||||||
"LabelSelectUsers": "Felhasználók kiválasztása",
|
"LabelSelectUsers": "Felhasználók kiválasztása",
|
||||||
@ -498,8 +538,11 @@
|
|||||||
"LabelSeries": "Sorozat",
|
"LabelSeries": "Sorozat",
|
||||||
"LabelSeriesName": "Sorozat neve",
|
"LabelSeriesName": "Sorozat neve",
|
||||||
"LabelSeriesProgress": "Sorozat haladása",
|
"LabelSeriesProgress": "Sorozat haladása",
|
||||||
|
"LabelServerLogLevel": "Kiszolgáló naplózási szint",
|
||||||
|
"LabelServerYearReview": "Szerver évértékelő ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Beállítás elsődlegesként",
|
"LabelSetEbookAsPrimary": "Beállítás elsődlegesként",
|
||||||
"LabelSetEbookAsSupplementary": "Beállítás kiegészítőként",
|
"LabelSetEbookAsSupplementary": "Beállítás kiegészítőként",
|
||||||
|
"LabelSettingsAllowIframe": "A beágyazás engedélyezése egy iframe-be",
|
||||||
"LabelSettingsAudiobooksOnly": "Csak hangoskönyvek",
|
"LabelSettingsAudiobooksOnly": "Csak hangoskönyvek",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Ennek a beállításnak az engedélyezése figyelmen kívül hagyja az e-könyv fájlokat, kivéve, ha azok egy hangoskönyv mappában vannak, ebben az esetben kiegészítő e-könyvként lesznek beállítva",
|
"LabelSettingsAudiobooksOnlyHelp": "Ennek a beállításnak az engedélyezése figyelmen kívül hagyja az e-könyv fájlokat, kivéve, ha azok egy hangoskönyv mappában vannak, ebben az esetben kiegészítő e-könyvként lesznek beállítva",
|
||||||
"LabelSettingsBookshelfViewHelp": "Skeuomorfikus dizájn fa polcokkal",
|
"LabelSettingsBookshelfViewHelp": "Skeuomorfikus dizájn fa polcokkal",
|
||||||
@ -511,6 +554,8 @@
|
|||||||
"LabelSettingsEnableWatcher": "Figyelő engedélyezése",
|
"LabelSettingsEnableWatcher": "Figyelő engedélyezése",
|
||||||
"LabelSettingsEnableWatcherForLibrary": "Mappafigyelő engedélyezése a könyvtárban",
|
"LabelSettingsEnableWatcherForLibrary": "Mappafigyelő engedélyezése a könyvtárban",
|
||||||
"LabelSettingsEnableWatcherHelp": "Engedélyezi az automatikus elem hozzáadás/frissítés funkciót, amikor fájlváltozásokat észlel. *Szerver újraindítása szükséges",
|
"LabelSettingsEnableWatcherHelp": "Engedélyezi az automatikus elem hozzáadás/frissítés funkciót, amikor fájlváltozásokat észlel. *Szerver újraindítása szükséges",
|
||||||
|
"LabelSettingsEpubsAllowScriptedContent": "Szkriptelt tartalmak engedélyezése epub-okban",
|
||||||
|
"LabelSettingsEpubsAllowScriptedContentHelp": "Megengedi, hogy az epub fájlok szkripteket hajtsanak végre. Ezt a beállítást kikapcsolva ajánlott tartani, kivéve, ha megbízik az epub fájlok forrásában.",
|
||||||
"LabelSettingsExperimentalFeatures": "Kísérleti funkciók",
|
"LabelSettingsExperimentalFeatures": "Kísérleti funkciók",
|
||||||
"LabelSettingsExperimentalFeaturesHelp": "Fejlesztés alatt álló funkciók, amelyek visszajelzésre és tesztelésre szorulnak. Kattintson a github megbeszélés megnyitásához.",
|
"LabelSettingsExperimentalFeaturesHelp": "Fejlesztés alatt álló funkciók, amelyek visszajelzésre és tesztelésre szorulnak. Kattintson a github megbeszélés megnyitásához.",
|
||||||
"LabelSettingsFindCovers": "Borítók keresése",
|
"LabelSettingsFindCovers": "Borítók keresése",
|
||||||
@ -519,6 +564,11 @@
|
|||||||
"LabelSettingsHideSingleBookSeriesHelp": "A csak egy könyvet tartalmazó sorozatok el lesznek rejtve a sorozatok oldalról és a kezdőlap polcairól.",
|
"LabelSettingsHideSingleBookSeriesHelp": "A csak egy könyvet tartalmazó sorozatok el lesznek rejtve a sorozatok oldalról és a kezdőlap polcairól.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Kezdőlap használja a könyvespolc nézetet",
|
"LabelSettingsHomePageBookshelfView": "Kezdőlap használja a könyvespolc nézetet",
|
||||||
"LabelSettingsLibraryBookshelfView": "Könyvtár használja a könyvespolc nézetet",
|
"LabelSettingsLibraryBookshelfView": "Könyvtár használja a könyvespolc nézetet",
|
||||||
|
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Százalékos befejezettség nagyobb mint",
|
||||||
|
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "A hátralévő idő kevesebb, mint (másodperc)",
|
||||||
|
"LabelSettingsLibraryMarkAsFinishedWhen": "A médiaelem befejezettnek jelölése, ha",
|
||||||
|
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Megelőző könyvek kihagyása a Sorozat folytatásában",
|
||||||
|
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "A Sorozat folytatása kezdőlap polcán az első nem megkezdett könyv látható egy olyan sorozatban, amelynek legalább egy könyve befejeződött, és nincs folyamatban lévő rész. Ha engedélyezi ezt a beállítást, akkor a sorozatot a legvégső befejezett könyvtől folytatja az első el nem kezdett könyv helyett.",
|
||||||
"LabelSettingsParseSubtitles": "Feliratok elemzése",
|
"LabelSettingsParseSubtitles": "Feliratok elemzése",
|
||||||
"LabelSettingsParseSubtitlesHelp": "Feliratok kinyerése a hangoskönyv mappaneveiből.<br>A feliratnak el kell különülnie egy \" - \" jellel<br>például: \"Könyv címe - Egy felirat itt\" esetén a felirat \"Egy felirat itt\"",
|
"LabelSettingsParseSubtitlesHelp": "Feliratok kinyerése a hangoskönyv mappaneveiből.<br>A feliratnak el kell különülnie egy \" - \" jellel<br>például: \"Könyv címe - Egy felirat itt\" esetén a felirat \"Egy felirat itt\"",
|
||||||
"LabelSettingsPreferMatchedMetadata": "Preferált egyeztetett metaadatok",
|
"LabelSettingsPreferMatchedMetadata": "Preferált egyeztetett metaadatok",
|
||||||
@ -534,10 +584,14 @@
|
|||||||
"LabelSettingsStoreMetadataWithItem": "Metaadatok tárolása az elemmel",
|
"LabelSettingsStoreMetadataWithItem": "Metaadatok tárolása az elemmel",
|
||||||
"LabelSettingsStoreMetadataWithItemHelp": "Alapértelmezés szerint a metaadatfájlok a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a metaadatfájlokat a könyvtári elem mappáiban tárolja",
|
"LabelSettingsStoreMetadataWithItemHelp": "Alapértelmezés szerint a metaadatfájlok a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a metaadatfájlokat a könyvtári elem mappáiban tárolja",
|
||||||
"LabelSettingsTimeFormat": "Időformátum",
|
"LabelSettingsTimeFormat": "Időformátum",
|
||||||
|
"LabelShare": "Megosztás",
|
||||||
"LabelShowAll": "Mindent mutat",
|
"LabelShowAll": "Mindent mutat",
|
||||||
|
"LabelShowSubtitles": "Felirat megjelenítése",
|
||||||
"LabelSize": "Méret",
|
"LabelSize": "Méret",
|
||||||
"LabelSleepTimer": "Alvásidőzítő",
|
"LabelSleepTimer": "Alvásidőzítő",
|
||||||
"LabelSlug": "Rövid cím",
|
"LabelSlug": "Rövid cím",
|
||||||
|
"LabelSortAscending": "Emelkedő",
|
||||||
|
"LabelSortDescending": "Csökkenő",
|
||||||
"LabelStart": "Kezdés",
|
"LabelStart": "Kezdés",
|
||||||
"LabelStartTime": "Kezdési idő",
|
"LabelStartTime": "Kezdési idő",
|
||||||
"LabelStarted": "Elkezdődött",
|
"LabelStarted": "Elkezdődött",
|
||||||
@ -547,13 +601,13 @@
|
|||||||
"LabelStatsBestDay": "Legjobb nap",
|
"LabelStatsBestDay": "Legjobb nap",
|
||||||
"LabelStatsDailyAverage": "Napi átlag",
|
"LabelStatsDailyAverage": "Napi átlag",
|
||||||
"LabelStatsDays": "Napok",
|
"LabelStatsDays": "Napok",
|
||||||
"LabelStatsDaysListened": "Hallgatott napok",
|
"LabelStatsDaysListened": "Napon hallgatva",
|
||||||
"LabelStatsHours": "Órák",
|
"LabelStatsHours": "Órák",
|
||||||
"LabelStatsInARow": "egymás után",
|
"LabelStatsInARow": "egymás után",
|
||||||
"LabelStatsItemsFinished": "Befejezett elemek",
|
"LabelStatsItemsFinished": "Befejezett elem",
|
||||||
"LabelStatsItemsInLibrary": "Elemek a könyvtárban",
|
"LabelStatsItemsInLibrary": "Elemek a könyvtárban",
|
||||||
"LabelStatsMinutes": "percek",
|
"LabelStatsMinutes": "perc",
|
||||||
"LabelStatsMinutesListening": "Hallgatási percek",
|
"LabelStatsMinutesListening": "Hallgatási perc",
|
||||||
"LabelStatsOverallDays": "Összes nap",
|
"LabelStatsOverallDays": "Összes nap",
|
||||||
"LabelStatsOverallHours": "Összes óra",
|
"LabelStatsOverallHours": "Összes óra",
|
||||||
"LabelStatsWeekListening": "Heti hallgatás",
|
"LabelStatsWeekListening": "Heti hallgatás",
|
||||||
@ -565,12 +619,18 @@
|
|||||||
"LabelTagsNotAccessibleToUser": "A felhasználó számára nem elérhető címkék",
|
"LabelTagsNotAccessibleToUser": "A felhasználó számára nem elérhető címkék",
|
||||||
"LabelTasks": "Futó feladatok",
|
"LabelTasks": "Futó feladatok",
|
||||||
"LabelTextEditorBulletedList": "Pontozott lista",
|
"LabelTextEditorBulletedList": "Pontozott lista",
|
||||||
|
"LabelTextEditorLink": "Hivatkozás",
|
||||||
"LabelTextEditorNumberedList": "Számozott lista",
|
"LabelTextEditorNumberedList": "Számozott lista",
|
||||||
"LabelTextEditorUnlink": "Link eltávolítása",
|
"LabelTextEditorUnlink": "Link eltávolítása",
|
||||||
"LabelTheme": "Téma",
|
"LabelTheme": "Téma",
|
||||||
"LabelThemeDark": "Sötét",
|
"LabelThemeDark": "Sötét",
|
||||||
"LabelThemeLight": "Világos",
|
"LabelThemeLight": "Világos",
|
||||||
"LabelTimeBase": "Időalap",
|
"LabelTimeBase": "Időalap",
|
||||||
|
"LabelTimeDurationXHours": "{0} óra",
|
||||||
|
"LabelTimeDurationXMinutes": "{0} perc",
|
||||||
|
"LabelTimeDurationXSeconds": "{0} másodperc",
|
||||||
|
"LabelTimeInMinutes": "Idő percben",
|
||||||
|
"LabelTimeLeft": "{0} maradt hátra",
|
||||||
"LabelTimeListened": "Hallgatott idő",
|
"LabelTimeListened": "Hallgatott idő",
|
||||||
"LabelTimeListenedToday": "Ma hallgatott idő",
|
"LabelTimeListenedToday": "Ma hallgatott idő",
|
||||||
"LabelTimeRemaining": "{0} maradt",
|
"LabelTimeRemaining": "{0} maradt",
|
||||||
@ -578,6 +638,7 @@
|
|||||||
"LabelTitle": "Cím",
|
"LabelTitle": "Cím",
|
||||||
"LabelToolsEmbedMetadata": "Metaadatok beágyazása",
|
"LabelToolsEmbedMetadata": "Metaadatok beágyazása",
|
||||||
"LabelToolsEmbedMetadataDescription": "Metaadatok beágyazása az audiofájlokba, beleértve a borítóképet és a fejezeteket.",
|
"LabelToolsEmbedMetadataDescription": "Metaadatok beágyazása az audiofájlokba, beleértve a borítóképet és a fejezeteket.",
|
||||||
|
"LabelToolsM4bEncoder": "M4B kódoló",
|
||||||
"LabelToolsMakeM4b": "M4B Hangoskönyv fájl készítése",
|
"LabelToolsMakeM4b": "M4B Hangoskönyv fájl készítése",
|
||||||
"LabelToolsMakeM4bDescription": ".M4B hangoskönyv fájl generálása beágyazott metaadatokkal, borítóképpel és fejezetekkel.",
|
"LabelToolsMakeM4bDescription": ".M4B hangoskönyv fájl generálása beágyazott metaadatokkal, borítóképpel és fejezetekkel.",
|
||||||
"LabelToolsSplitM4b": "M4B felosztása MP3-ra",
|
"LabelToolsSplitM4b": "M4B felosztása MP3-ra",
|
||||||
@ -590,29 +651,41 @@
|
|||||||
"LabelTracksMultiTrack": "Többsávos",
|
"LabelTracksMultiTrack": "Többsávos",
|
||||||
"LabelTracksNone": "Nincsenek sávok",
|
"LabelTracksNone": "Nincsenek sávok",
|
||||||
"LabelTracksSingleTrack": "Egysávos",
|
"LabelTracksSingleTrack": "Egysávos",
|
||||||
|
"LabelTrailer": "Előzetes",
|
||||||
"LabelType": "Típus",
|
"LabelType": "Típus",
|
||||||
"LabelUnabridged": "Nem tömörített",
|
"LabelUnabridged": "Nem tömörített",
|
||||||
"LabelUndo": "Visszavonás",
|
"LabelUndo": "Visszavonás",
|
||||||
"LabelUnknown": "Ismeretlen",
|
"LabelUnknown": "Ismeretlen",
|
||||||
|
"LabelUnknownPublishDate": "Ismeretlen megjelenési dátum",
|
||||||
"LabelUpdateCover": "Borító frissítése",
|
"LabelUpdateCover": "Borító frissítése",
|
||||||
"LabelUpdateCoverHelp": "Lehetővé teszi a meglévő borítók felülírását a kiválasztott könyveknél, amikor találatot talál",
|
"LabelUpdateCoverHelp": "Lehetővé teszi a meglévő borítók felülírását a kiválasztott könyveknél, amikor találatot talál",
|
||||||
"LabelUpdateDetails": "Részletek frissítése",
|
"LabelUpdateDetails": "Részletek frissítése",
|
||||||
"LabelUpdateDetailsHelp": "Lehetővé teszi a meglévő részletek felülírását a kiválasztott könyveknél, amikor találatot talál",
|
"LabelUpdateDetailsHelp": "Lehetővé teszi a meglévő részletek felülírását a kiválasztott könyveknél, amikor találatot talál",
|
||||||
"LabelUpdatedAt": "Frissítve",
|
"LabelUpdatedAt": "Frissítve",
|
||||||
"LabelUploaderDragAndDrop": "Fájlok vagy mappák húzása és elengedése",
|
"LabelUploaderDragAndDrop": "Fájlok vagy mappák húzása és elengedése",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Fájlok húzása és elengedése",
|
||||||
"LabelUploaderDropFiles": "Fájlok elengedése",
|
"LabelUploaderDropFiles": "Fájlok elengedése",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Cím, szerző és sorozat automatikus lekérése",
|
"LabelUploaderItemFetchMetadataHelp": "Cím, szerző és sorozat automatikus lekérése",
|
||||||
|
"LabelUseAdvancedOptions": "Haladó beállítások használata",
|
||||||
"LabelUseChapterTrack": "Fejezetsáv használata",
|
"LabelUseChapterTrack": "Fejezetsáv használata",
|
||||||
"LabelUseFullTrack": "Teljes sáv használata",
|
"LabelUseFullTrack": "Teljes sáv használata",
|
||||||
|
"LabelUseZeroForUnlimited": "Használja a 0-t a korlátlan értékhez",
|
||||||
"LabelUser": "Felhasználó",
|
"LabelUser": "Felhasználó",
|
||||||
"LabelUsername": "Felhasználónév",
|
"LabelUsername": "Felhasználónév",
|
||||||
"LabelValue": "Érték",
|
"LabelValue": "Érték",
|
||||||
"LabelVersion": "Verzió",
|
"LabelVersion": "Verzió",
|
||||||
"LabelViewBookmarks": "Könyvjelzők megtekintése",
|
"LabelViewBookmarks": "Könyvjelzők megtekintése",
|
||||||
"LabelViewChapters": "Fejezetek megtekintése",
|
"LabelViewChapters": "Fejezetek megtekintése",
|
||||||
|
"LabelViewPlayerSettings": "A lejátszó beállításainak megtekintése",
|
||||||
"LabelViewQueue": "Lejátszó sor megtekintése",
|
"LabelViewQueue": "Lejátszó sor megtekintése",
|
||||||
"LabelVolume": "Hangerő",
|
"LabelVolume": "Hangerő",
|
||||||
|
"LabelWebRedirectURLsDescription": "Engedélyezze ezeket az URL-címeket az OAuth-szolgáltatóban, hogy a bejelentkezés után vissza lehessen irányítani a webes alkalmazáshoz:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "Almappa átirányító URL-ek számára",
|
||||||
"LabelWeekdaysToRun": "Futás napjai",
|
"LabelWeekdaysToRun": "Futás napjai",
|
||||||
|
"LabelXBooks": "{0} könyv",
|
||||||
|
"LabelXItems": "{0} elem",
|
||||||
|
"LabelYearReviewHide": "Évértékelő elrejtése",
|
||||||
|
"LabelYearReviewShow": "Évértékelés megtekintése",
|
||||||
"LabelYourAudiobookDuration": "Hangoskönyv időtartama",
|
"LabelYourAudiobookDuration": "Hangoskönyv időtartama",
|
||||||
"LabelYourBookmarks": "Könyvjelzőid",
|
"LabelYourBookmarks": "Könyvjelzőid",
|
||||||
"LabelYourPlaylists": "Lejátszási listáid",
|
"LabelYourPlaylists": "Lejátszási listáid",
|
||||||
@ -620,10 +693,14 @@
|
|||||||
"MessageAddToPlayerQueue": "Hozzáadás a lejátszó sorhoz",
|
"MessageAddToPlayerQueue": "Hozzáadás a lejátszó sorhoz",
|
||||||
"MessageAppriseDescription": "Ennek a funkció használatához futtatnia kell egy <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> példányt vagy egy olyan API-t, amely kezeli ezeket a kéréseket. <br />Az Apprise API URL-nek a teljes URL útvonalat kell tartalmaznia az értesítés elküldéséhez, például, ha az API példánya a <code>http://192.168.1.1:8337</code> címen szolgáltatva, akkor <code>http://192.168.1.1:8337/notify</code> értéket kell megadnia.",
|
"MessageAppriseDescription": "Ennek a funkció használatához futtatnia kell egy <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> példányt vagy egy olyan API-t, amely kezeli ezeket a kéréseket. <br />Az Apprise API URL-nek a teljes URL útvonalat kell tartalmaznia az értesítés elküldéséhez, például, ha az API példánya a <code>http://192.168.1.1:8337</code> címen szolgáltatva, akkor <code>http://192.168.1.1:8337/notify</code> értéket kell megadnia.",
|
||||||
"MessageBackupsDescription": "A biztonsági másolatok tartalmazzák a felhasználókat, a felhasználói haladást, a könyvtári elem részleteit, a szerver beállításait és a képeket, amelyek a <code>/metadata/items</code> és <code>/metadata/authors</code> mappákban vannak tárolva. A biztonsági másolatok <strong>nem</strong> tartalmazzák a könyvtári mappákban tárolt fájlokat.",
|
"MessageBackupsDescription": "A biztonsági másolatok tartalmazzák a felhasználókat, a felhasználói haladást, a könyvtári elem részleteit, a szerver beállításait és a képeket, amelyek a <code>/metadata/items</code> és <code>/metadata/authors</code> mappákban vannak tárolva. A biztonsági másolatok <strong>nem</strong> tartalmazzák a könyvtári mappákban tárolt fájlokat.",
|
||||||
|
"MessageBackupsLocationEditNote": "Megjegyzés: A biztonsági mentés helyének frissítése nem mozgatja vagy módosítja a meglévő biztonsági mentéseket",
|
||||||
|
"MessageBackupsLocationNoEditNote": "Megjegyzés: A biztonsági mentés helye egy környezeti változóval van beállítva, és itt nem módosítható.",
|
||||||
|
"MessageBackupsLocationPathEmpty": "A biztonsági mentés helyének elérési útvonala nem lehet üres",
|
||||||
"MessageBatchQuickMatchDescription": "A Gyors egyeztetés megpróbálja hozzáadni a hiányzó borítókat és metaadatokat a kiválasztott elemekhez. Engedélyezze az alábbi opciókat, hogy a Gyors egyeztetés felülírhassa a meglévő borítókat és/vagy metaadatokat.",
|
"MessageBatchQuickMatchDescription": "A Gyors egyeztetés megpróbálja hozzáadni a hiányzó borítókat és metaadatokat a kiválasztott elemekhez. Engedélyezze az alábbi opciókat, hogy a Gyors egyeztetés felülírhassa a meglévő borítókat és/vagy metaadatokat.",
|
||||||
"MessageBookshelfNoCollections": "Még nem készített gyűjteményeket",
|
"MessageBookshelfNoCollections": "Még nem készített gyűjteményeket",
|
||||||
"MessageBookshelfNoRSSFeeds": "Nincsenek nyitott RSS hírcsatornák",
|
"MessageBookshelfNoRSSFeeds": "Nincsenek nyitott RSS hírcsatornák",
|
||||||
"MessageBookshelfNoResultsForFilter": "Nincs eredmény a \"{0}: {1}\" szűrőre",
|
"MessageBookshelfNoResultsForFilter": "Nincs eredmény a \"{0}: {1}\" szűrőre",
|
||||||
|
"MessageBookshelfNoResultsForQuery": "Nincs eredmény a lekérdezéshez",
|
||||||
"MessageBookshelfNoSeries": "Nincsenek sorozatai",
|
"MessageBookshelfNoSeries": "Nincsenek sorozatai",
|
||||||
"MessageChapterEndIsAfter": "A fejezet vége a hangoskönyv végét követi",
|
"MessageChapterEndIsAfter": "A fejezet vége a hangoskönyv végét követi",
|
||||||
"MessageChapterErrorFirstNotZero": "Az első fejezetnek 0:00-kor kell kezdődnie",
|
"MessageChapterErrorFirstNotZero": "Az első fejezetnek 0:00-kor kell kezdődnie",
|
||||||
@ -633,17 +710,27 @@
|
|||||||
"MessageCheckingCron": "Cron ellenőrzése...",
|
"MessageCheckingCron": "Cron ellenőrzése...",
|
||||||
"MessageConfirmCloseFeed": "Biztosan be szeretné zárni ezt a hírcsatornát?",
|
"MessageConfirmCloseFeed": "Biztosan be szeretné zárni ezt a hírcsatornát?",
|
||||||
"MessageConfirmDeleteBackup": "Biztosan törölni szeretné a(z) {0} biztonsági másolatot?",
|
"MessageConfirmDeleteBackup": "Biztosan törölni szeretné a(z) {0} biztonsági másolatot?",
|
||||||
|
"MessageConfirmDeleteDevice": "Biztos, hogy törölni szeretné a „{0}” e-olvasó eszközt?",
|
||||||
"MessageConfirmDeleteFile": "Ez törölni fogja a fájlt a fájlrendszerből. Biztos benne?",
|
"MessageConfirmDeleteFile": "Ez törölni fogja a fájlt a fájlrendszerből. Biztos benne?",
|
||||||
"MessageConfirmDeleteLibrary": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" könyvtárat?",
|
"MessageConfirmDeleteLibrary": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" könyvtárat?",
|
||||||
"MessageConfirmDeleteLibraryItem": "Ez eltávolítja a könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?",
|
"MessageConfirmDeleteLibraryItem": "Ez eltávolítja a könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?",
|
||||||
"MessageConfirmDeleteLibraryItems": "Ez eltávolítja a(z) {0} könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?",
|
"MessageConfirmDeleteLibraryItems": "Ez eltávolítja a(z) {0} könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?",
|
||||||
|
"MessageConfirmDeleteMetadataProvider": "Biztos, hogy törölni szeretné a „{0}” egyéni metaadat-szolgáltatót?",
|
||||||
|
"MessageConfirmDeleteNotification": "Biztos, hogy törölni szeretné ezt az értesítést?",
|
||||||
"MessageConfirmDeleteSession": "Biztosan törölni szeretné ezt a munkamenetet?",
|
"MessageConfirmDeleteSession": "Biztosan törölni szeretné ezt a munkamenetet?",
|
||||||
|
"MessageConfirmEmbedMetadataInAudioFiles": "Biztos, hogy metaadatokat szeretne beágyazni {0} hangfájlba?",
|
||||||
"MessageConfirmForceReScan": "Biztosan kényszeríteni szeretné az újraszkennelést?",
|
"MessageConfirmForceReScan": "Biztosan kényszeríteni szeretné az újraszkennelést?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "Biztosan meg szeretné jelölni az összes epizódot befejezettnek?",
|
"MessageConfirmMarkAllEpisodesFinished": "Biztosan meg szeretné jelölni az összes epizódot befejezettnek?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "Biztosan meg szeretné jelölni az összes epizódot nem befejezettnek?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "Biztosan meg szeretné jelölni az összes epizódot nem befejezettnek?",
|
||||||
|
"MessageConfirmMarkItemFinished": "Biztos, hogy a „{0}”-t befejezettnek akarja jelölni?",
|
||||||
|
"MessageConfirmMarkItemNotFinished": "Biztos, hogy a „{0}”-t befejezetlennek akarja jelölni?",
|
||||||
"MessageConfirmMarkSeriesFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét befejezettnek?",
|
"MessageConfirmMarkSeriesFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét befejezettnek?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét nem befejezettnek?",
|
"MessageConfirmMarkSeriesNotFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét nem befejezettnek?",
|
||||||
|
"MessageConfirmNotificationTestTrigger": "Ez az értesítés indítható tesztadatokkal?",
|
||||||
|
"MessageConfirmPurgeCache": "A gyorsítótár kiürítése törli a teljes könyvtárat a <code>/metadata/cache</code> helyről. <br /><br />Biztosan eltávolítja a gyorsítótár könyvtárát?",
|
||||||
|
"MessageConfirmPurgeItemsCache": "Az elemek gyorsítótárának kiürítése törli a teljes könyvtárat a <code>/metadata/cache/items</code> helyről.<br />Biztos benne?",
|
||||||
"MessageConfirmQuickEmbed": "Figyelem! A Gyors beágyazás nem készít biztonsági másolatot az audiofájlokról. Győződjön meg arról, hogy van biztonsági másolata az audiofájlokról. <br><br>Szeretné folytatni?",
|
"MessageConfirmQuickEmbed": "Figyelem! A Gyors beágyazás nem készít biztonsági másolatot az audiofájlokról. Győződjön meg arról, hogy van biztonsági másolata az audiofájlokról. <br><br>Szeretné folytatni?",
|
||||||
|
"MessageConfirmQuickMatchEpisodes": "Az epizódok gyors megfeleltetése felülírja a részleteket, ha egyezést talál. Csak a nem egyező epizódok frissülnek. Biztos benne?",
|
||||||
"MessageConfirmReScanLibraryItems": "Biztosan újra szeretné szkennelni a(z) {0} elemet?",
|
"MessageConfirmReScanLibraryItems": "Biztosan újra szeretné szkennelni a(z) {0} elemet?",
|
||||||
"MessageConfirmRemoveAllChapters": "Biztosan eltávolítja az összes fejezetet?",
|
"MessageConfirmRemoveAllChapters": "Biztosan eltávolítja az összes fejezetet?",
|
||||||
"MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?",
|
"MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?",
|
||||||
@ -651,6 +738,7 @@
|
|||||||
"MessageConfirmRemoveEpisode": "Biztosan eltávolítja a(z) \"{0}\" epizódot?",
|
"MessageConfirmRemoveEpisode": "Biztosan eltávolítja a(z) \"{0}\" epizódot?",
|
||||||
"MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?",
|
"MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?",
|
||||||
"MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?",
|
"MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?",
|
||||||
|
"MessageConfirmRemoveMetadataFiles": "Biztos, hogy az összes metaadatot el akarja távolítani {0} fájl van könyvtár mappáiban?",
|
||||||
"MessageConfirmRemoveNarrator": "Biztosan eltávolítja a(z) \"{0}\" előadót?",
|
"MessageConfirmRemoveNarrator": "Biztosan eltávolítja a(z) \"{0}\" előadót?",
|
||||||
"MessageConfirmRemovePlaylist": "Biztosan eltávolítja a(z) \"{0}\" lejátszási listáját?",
|
"MessageConfirmRemovePlaylist": "Biztosan eltávolítja a(z) \"{0}\" lejátszási listáját?",
|
||||||
"MessageConfirmRenameGenre": "Biztosan át szeretné nevezni a(z) \"{0}\" műfajt \"{1}\"-re az összes elemnél?",
|
"MessageConfirmRenameGenre": "Biztosan át szeretné nevezni a(z) \"{0}\" műfajt \"{1}\"-re az összes elemnél?",
|
||||||
@ -659,11 +747,15 @@
|
|||||||
"MessageConfirmRenameTag": "Biztosan át szeretné nevezni a(z) \"{0}\" címkét \"{1}\"-re az összes elemnél?",
|
"MessageConfirmRenameTag": "Biztosan át szeretné nevezni a(z) \"{0}\" címkét \"{1}\"-re az összes elemnél?",
|
||||||
"MessageConfirmRenameTagMergeNote": "Megjegyzés: Ez a címke már létezik, így össze lesznek vonva.",
|
"MessageConfirmRenameTagMergeNote": "Megjegyzés: Ez a címke már létezik, így össze lesznek vonva.",
|
||||||
"MessageConfirmRenameTagWarning": "Figyelem! Egy hasonló, de eltérő nagybetűkkel rendelkező címke már létezik \"{0}\".",
|
"MessageConfirmRenameTagWarning": "Figyelem! Egy hasonló, de eltérő nagybetűkkel rendelkező címke már létezik \"{0}\".",
|
||||||
|
"MessageConfirmResetProgress": "Biztos, hogy vissza akarja állítani a haladási folyamatát?",
|
||||||
"MessageConfirmSendEbookToDevice": "Biztosan el szeretné küldeni a(z) {0} e-könyvet a(z) \"{1}\" eszközre?",
|
"MessageConfirmSendEbookToDevice": "Biztosan el szeretné küldeni a(z) {0} e-könyvet a(z) \"{1}\" eszközre?",
|
||||||
|
"MessageConfirmUnlinkOpenId": "Biztos, hogy el akarja távolítani ezt a felhasználót az OpenID-ból?",
|
||||||
"MessageDownloadingEpisode": "Epizód letöltése",
|
"MessageDownloadingEpisode": "Epizód letöltése",
|
||||||
"MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe",
|
"MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe",
|
||||||
|
"MessageEmbedFailed": "A beágyazás sikertelen!",
|
||||||
"MessageEmbedFinished": "Beágyazás befejeződött!",
|
"MessageEmbedFinished": "Beágyazás befejeződött!",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} epizód letöltésre vár",
|
"MessageEpisodesQueuedForDownload": "{0} epizód letöltésre vár",
|
||||||
|
"MessageEreaderDevices": "Az e-könyvek kézbesítésének biztosítása érdekében a fenti e-mail címet az alább felsorolt minden egyes eszközhöz, mint érvényes feladót kell hozzáadnia.",
|
||||||
"MessageFeedURLWillBe": "A hírcsatorna URL-je {0} lesz",
|
"MessageFeedURLWillBe": "A hírcsatorna URL-je {0} lesz",
|
||||||
"MessageFetching": "Lekérdezés...",
|
"MessageFetching": "Lekérdezés...",
|
||||||
"MessageForceReScanDescription": "minden fájlt újra szkennel, mint egy friss szkennelés. Az audiofájlok ID3 címkéi, OPF fájlok és szövegfájlok újként lesznek szkennelve.",
|
"MessageForceReScanDescription": "minden fájlt újra szkennel, mint egy friss szkennelés. Az audiofájlok ID3 címkéi, OPF fájlok és szövegfájlok újként lesznek szkennelve.",
|
||||||
@ -671,10 +763,11 @@
|
|||||||
"MessageInsertChapterBelow": "Fejezet beszúrása alulra",
|
"MessageInsertChapterBelow": "Fejezet beszúrása alulra",
|
||||||
"MessageItemsSelected": "{0} kiválasztott elem",
|
"MessageItemsSelected": "{0} kiválasztott elem",
|
||||||
"MessageItemsUpdated": "{0} frissített elem",
|
"MessageItemsUpdated": "{0} frissített elem",
|
||||||
"MessageJoinUsOn": "Csatlakozzon hozzánk",
|
"MessageJoinUsOn": "Csatlakozzon hozzánk a",
|
||||||
"MessageListeningSessionsInTheLastYear": "{0} hallgatási munkamenet az elmúlt évben",
|
"MessageListeningSessionsInTheLastYear": "{0} hallgatási munkamenet az elmúlt évben",
|
||||||
"MessageLoading": "Betöltés...",
|
"MessageLoading": "Betöltés...",
|
||||||
"MessageLoadingFolders": "Mappák betöltése...",
|
"MessageLoadingFolders": "Mappák betöltése...",
|
||||||
|
"MessageLogsDescription": "A naplók a <code>/metadata/logs</code> mappában JSON-fájlokként tárolódnak. Az összeomlási naplók a <code>/metadata/logs/crash_logs.txt</code> fájlban tárolódnak.",
|
||||||
"MessageM4BFailed": "M4B sikertelen!",
|
"MessageM4BFailed": "M4B sikertelen!",
|
||||||
"MessageM4BFinished": "M4B befejeződött!",
|
"MessageM4BFinished": "M4B befejeződött!",
|
||||||
"MessageMapChapterTitles": "Fejezetcímek hozzárendelése a meglévő hangoskönyv fejezeteihez anélkül, hogy az időbélyegeket módosítaná",
|
"MessageMapChapterTitles": "Fejezetcímek hozzárendelése a meglévő hangoskönyv fejezeteihez anélkül, hogy az időbélyegeket módosítaná",
|
||||||
@ -691,6 +784,7 @@
|
|||||||
"MessageNoCollections": "Nincsenek gyűjtemények",
|
"MessageNoCollections": "Nincsenek gyűjtemények",
|
||||||
"MessageNoCoversFound": "Nem találhatóak borítók",
|
"MessageNoCoversFound": "Nem találhatóak borítók",
|
||||||
"MessageNoDescription": "Nincs leírás",
|
"MessageNoDescription": "Nincs leírás",
|
||||||
|
"MessageNoDevices": "Nincs eszköz",
|
||||||
"MessageNoDownloadsInProgress": "Jelenleg nincsenek folyamatban lévő letöltések",
|
"MessageNoDownloadsInProgress": "Jelenleg nincsenek folyamatban lévő letöltések",
|
||||||
"MessageNoDownloadsQueued": "Nincsenek várakozó letöltések",
|
"MessageNoDownloadsQueued": "Nincsenek várakozó letöltések",
|
||||||
"MessageNoEpisodeMatchesFound": "Nincs találat az epizódokra",
|
"MessageNoEpisodeMatchesFound": "Nincs találat az epizódokra",
|
||||||
@ -704,6 +798,7 @@
|
|||||||
"MessageNoLogs": "Nincsenek naplók",
|
"MessageNoLogs": "Nincsenek naplók",
|
||||||
"MessageNoMediaProgress": "Nincs előrehaladás a médialejátszásban",
|
"MessageNoMediaProgress": "Nincs előrehaladás a médialejátszásban",
|
||||||
"MessageNoNotifications": "Nincsenek értesítések",
|
"MessageNoNotifications": "Nincsenek értesítések",
|
||||||
|
"MessageNoPodcastFeed": "Érvénytelen podcast: Nincs forrás",
|
||||||
"MessageNoPodcastsFound": "Nem találhatóak podcastok",
|
"MessageNoPodcastsFound": "Nem találhatóak podcastok",
|
||||||
"MessageNoResults": "Nincsenek eredmények",
|
"MessageNoResults": "Nincsenek eredmények",
|
||||||
"MessageNoSearchResultsFor": "Nincs keresési eredmény erre: \"{0}\"",
|
"MessageNoSearchResultsFor": "Nincs keresési eredmény erre: \"{0}\"",
|
||||||
@ -713,11 +808,16 @@
|
|||||||
"MessageNoUpdatesWereNecessary": "Nem volt szükség frissítésekre",
|
"MessageNoUpdatesWereNecessary": "Nem volt szükség frissítésekre",
|
||||||
"MessageNoUserPlaylists": "Nincsenek felhasználói lejátszási listák",
|
"MessageNoUserPlaylists": "Nincsenek felhasználói lejátszási listák",
|
||||||
"MessageNotYetImplemented": "Még nem implementált",
|
"MessageNotYetImplemented": "Még nem implementált",
|
||||||
|
"MessageOpmlPreviewNote": "Megjegyzés: Ez egy előnézeti kép az elemzett OPML fájlról. A podcast tényleges címe az RSS hírcsatornából származik.",
|
||||||
"MessageOr": "vagy",
|
"MessageOr": "vagy",
|
||||||
"MessagePauseChapter": "Fejezet lejátszásának szüneteltetése",
|
"MessagePauseChapter": "Fejezet lejátszásának szüneteltetése",
|
||||||
"MessagePlayChapter": "Fejezet elejének meghallgatása",
|
"MessagePlayChapter": "Fejezet elejének meghallgatása",
|
||||||
"MessagePlaylistCreateFromCollection": "Lejátszási lista létrehozása gyűjteményből",
|
"MessagePlaylistCreateFromCollection": "Lejátszási lista létrehozása gyűjteményből",
|
||||||
|
"MessagePleaseWait": "Kérem várjon...",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "A podcastnak nincs RSS hírcsatorna URL-je az egyeztetéshez",
|
"MessagePodcastHasNoRSSFeedForMatching": "A podcastnak nincs RSS hírcsatorna URL-je az egyeztetéshez",
|
||||||
|
"MessagePodcastSearchField": "Adja meg a keresési kifejezést vagy az RSS hírcsatorna URL-címét",
|
||||||
|
"MessageQuickEmbedInProgress": "Gyors beágyazás folyamatban",
|
||||||
|
"MessageQuickMatchAllEpisodes": "Minden epizód gyors egyeztetése",
|
||||||
"MessageQuickMatchDescription": "Üres elem részletek és borító feltöltése az első találati eredménnyel a(z) '{0}'-ból. Nem írja felül a részleteket, kivéve, ha a 'Preferált egyeztetett metaadatok' szerverbeállítás engedélyezve van.",
|
"MessageQuickMatchDescription": "Üres elem részletek és borító feltöltése az első találati eredménnyel a(z) '{0}'-ból. Nem írja felül a részleteket, kivéve, ha a 'Preferált egyeztetett metaadatok' szerverbeállítás engedélyezve van.",
|
||||||
"MessageRemoveChapter": "Fejezet eltávolítása",
|
"MessageRemoveChapter": "Fejezet eltávolítása",
|
||||||
"MessageRemoveEpisodes": "Epizód(ok) eltávolítása: {0}",
|
"MessageRemoveEpisodes": "Epizód(ok) eltávolítása: {0}",
|
||||||
@ -725,14 +825,49 @@
|
|||||||
"MessageRemoveUserWarning": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" felhasználót?",
|
"MessageRemoveUserWarning": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" felhasználót?",
|
||||||
"MessageReportBugsAndContribute": "Hibák jelentése, funkciók kérése és hozzájárulás itt",
|
"MessageReportBugsAndContribute": "Hibák jelentése, funkciók kérése és hozzájárulás itt",
|
||||||
"MessageResetChaptersConfirm": "Biztosan alaphelyzetbe szeretné állítani a fejezeteket és visszavonni a módosításokat?",
|
"MessageResetChaptersConfirm": "Biztosan alaphelyzetbe szeretné állítani a fejezeteket és visszavonni a módosításokat?",
|
||||||
"MessageRestoreBackupConfirm": "Biztosan vissza szeretné állítani a biztonsági másolatot, amely ekkor készült",
|
"MessageRestoreBackupConfirm": "Biztosan vissza szeretné állítani a biztonsági másolatot, amely ekkor készült:",
|
||||||
"MessageRestoreBackupWarning": "A biztonsági mentés visszaállítása felülírja az egész adatbázist, amely a /config mappában található, valamint a borítóképeket a /metadata/items és /metadata/authors mappákban.<br /><br />A biztonsági mentések nem módosítják a könyvtár mappáiban található fájlokat. Ha engedélyezte a szerverbeállításokat a borítóképek és a metaadatok könyvtármappákban való tárolására, akkor ezek nem kerülnek biztonsági mentésre vagy felülírásra.<br /><br />A szerver használó összes kliens automatikusan frissül.",
|
"MessageRestoreBackupWarning": "A biztonsági mentés visszaállítása felülírja az egész adatbázist, amely a /config mappában található, valamint a borítóképeket a /metadata/items és /metadata/authors mappákban.<br /><br />A biztonsági mentések nem módosítják a könyvtár mappáiban található fájlokat. Ha engedélyezte a szerverbeállításokat a borítóképek és a metaadatok könyvtármappákban való tárolására, akkor ezek nem kerülnek biztonsági mentésre vagy felülírásra.<br /><br />A szerver használó összes kliens automatikusan frissül.",
|
||||||
"MessageSearchResultsFor": "Keresési eredmények",
|
"MessageSearchResultsFor": "Keresési eredmények",
|
||||||
"MessageSelected": "{0} kiválasztva",
|
"MessageSelected": "{0} kiválasztva",
|
||||||
"MessageServerCouldNotBeReached": "A szervert nem lehet elérni",
|
"MessageServerCouldNotBeReached": "A szervert nem lehet elérni",
|
||||||
"MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként",
|
"MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként",
|
||||||
|
"MessageShareExpirationWillBe": "A lejárat: <strong>{0}</strong>",
|
||||||
|
"MessageShareExpiresIn": "{0} múlva jár le",
|
||||||
"MessageStartPlaybackAtTime": "\"{0}\" lejátszásának kezdése {1} -tól?",
|
"MessageStartPlaybackAtTime": "\"{0}\" lejátszásának kezdése {1} -tól?",
|
||||||
"MessageThinking": "Gondolkodás...",
|
"MessageTaskAudioFileNotWritable": "A/Az „{0}” hangfájl nem írható",
|
||||||
|
"MessageTaskCanceledByUser": "Felhasználó törölte a feladatot",
|
||||||
|
"MessageTaskDownloadingEpisodeDescription": "„{0}” epizód letöltése",
|
||||||
|
"MessageTaskEmbeddingMetadata": "Metaadatok beágyazása",
|
||||||
|
"MessageTaskEmbeddingMetadataDescription": "Metaadatok beágyazása a „{0}” hangoskönyvbe",
|
||||||
|
"MessageTaskEncodingM4b": "Kódolás M4B-ban",
|
||||||
|
"MessageTaskEncodingM4bDescription": "„{0}” hangoskönyv kódolása egyetlen m4b fájlba",
|
||||||
|
"MessageTaskFailed": "Sikertelen",
|
||||||
|
"MessageTaskFailedToBackupAudioFile": "Nem sikerült a „{0}” hangfájl mentése",
|
||||||
|
"MessageTaskFailedToCreateCacheDirectory": "Nem sikerült létrehozni a gyorsítótár könyvtárat",
|
||||||
|
"MessageTaskFailedToEmbedMetadataInFile": "Nem sikerült beágyazni a metaadatokat a „{0}” fájlba",
|
||||||
|
"MessageTaskFailedToMergeAudioFiles": "A hangfájlok egyesítése nem sikerült",
|
||||||
|
"MessageTaskFailedToMoveM4bFile": "Nem sikerült m4b fájlt áthelyezni",
|
||||||
|
"MessageTaskFailedToWriteMetadataFile": "Metaadatfájl írása sikertelen",
|
||||||
|
"MessageTaskMatchingBooksInLibrary": "Könyvek egyeztetése a \"{0}\" könyvtárban",
|
||||||
|
"MessageTaskNoFilesToScan": "Nincs beolvasandó fájl",
|
||||||
|
"MessageTaskOpmlImport": "OPML import",
|
||||||
|
"MessageTaskOpmlImportDescription": "Podcastok létrehozása {0} RSS hírcsatornából",
|
||||||
|
"MessageTaskOpmlImportFeedDescription": "RSS feed „{0}” importálása",
|
||||||
|
"MessageTaskOpmlImportFeedFailed": "Nem sikerült letölteni a podcast feedet",
|
||||||
|
"MessageTaskOpmlImportFeedPodcastDescription": "„{0}” podcast létrehozása",
|
||||||
|
"MessageTaskOpmlImportFeedPodcastExists": "Podcast már létezik az elérési útvonalon",
|
||||||
|
"MessageTaskOpmlImportFeedPodcastFailed": "Nem sikerült podcastot létrehozni",
|
||||||
|
"MessageTaskOpmlImportFinished": "{0} podcast hozzáadva",
|
||||||
|
"MessageTaskOpmlParseFailed": "Az OPML fájl elemzése nem sikerült",
|
||||||
|
"MessageTaskOpmlParseFastFail": "Érvénytelen OPML fájl: <opml> tag nem található VAGY nem találtak <outline> taget",
|
||||||
|
"MessageTaskScanItemsAdded": "{0} hozzáadva",
|
||||||
|
"MessageTaskScanItemsMissing": "{0} hiányzik",
|
||||||
|
"MessageTaskScanItemsUpdated": "{0} frissítve",
|
||||||
|
"MessageTaskScanNoChangesNeeded": "Nincs szükség változtatásra",
|
||||||
|
"MessageTaskScanningFileChanges": "Fájlváltozások keresése a „{0}” fájlban",
|
||||||
|
"MessageTaskScanningLibrary": "„{0}” könyvtár beolvasása",
|
||||||
|
"MessageTaskTargetDirectoryNotWritable": "A célkönyvtár nem írható",
|
||||||
|
"MessageThinking": "Gondolkodom...",
|
||||||
"MessageUploaderItemFailed": "A feltöltés sikertelen",
|
"MessageUploaderItemFailed": "A feltöltés sikertelen",
|
||||||
"MessageUploaderItemSuccess": "Sikeresen feltöltve!",
|
"MessageUploaderItemSuccess": "Sikeresen feltöltve!",
|
||||||
"MessageUploading": "Feltöltés...",
|
"MessageUploading": "Feltöltés...",
|
||||||
@ -744,45 +879,101 @@
|
|||||||
"NoteChangeRootPassword": "A Root felhasználó az egyetlen felhasználó, akinek lehet üres jelszava",
|
"NoteChangeRootPassword": "A Root felhasználó az egyetlen felhasználó, akinek lehet üres jelszava",
|
||||||
"NoteChapterEditorTimes": "Megjegyzés: Az első fejezet kezdőidejének 0:00 kell lennie, és az utolsó fejezet kezdőideje nem haladhatja meg a hangoskönyv időtartamát.",
|
"NoteChapterEditorTimes": "Megjegyzés: Az első fejezet kezdőidejének 0:00 kell lennie, és az utolsó fejezet kezdőideje nem haladhatja meg a hangoskönyv időtartamát.",
|
||||||
"NoteFolderPicker": "Megjegyzés: azok a mappák, amelyek már hozzá vannak rendelve, nem jelennek meg",
|
"NoteFolderPicker": "Megjegyzés: azok a mappák, amelyek már hozzá vannak rendelve, nem jelennek meg",
|
||||||
"NoteRSSFeedPodcastAppsHttps": "Figyelem: A legtöbb podcast alkalmazás megköveteli, hogy az RSS feed URL HTTPS-t használjon",
|
"NoteRSSFeedPodcastAppsHttps": "Figyelem: A legtöbb podcast alkalmazás megköveteli, hogy az RSS hírcsatorna URL-jában HTTPS-t használjon",
|
||||||
"NoteRSSFeedPodcastAppsPubDate": "Figyelem: Az egy vagy több epizódnak nincs Közzétételi dátuma. Néhány podcast alkalmazás ezt megköveteli.",
|
"NoteRSSFeedPodcastAppsPubDate": "Figyelem: Az egy vagy több epizódnak nincs Közzétételi dátuma. Néhány podcast alkalmazás ezt megköveteli.",
|
||||||
"NoteUploaderFoldersWithMediaFiles": "A médiafájlokat tartalmazó mappák külön könyvtári tételekként lesznek kezelve.",
|
"NoteUploaderFoldersWithMediaFiles": "A médiafájlokat tartalmazó mappák külön könyvtári tételekként lesznek kezelve.",
|
||||||
"NoteUploaderOnlyAudioFiles": "Ha csak hangfájlokat tölt fel, akkor minden egyes hangfájl külön hangoskönyvként lesz kezelve.",
|
"NoteUploaderOnlyAudioFiles": "Ha csak hangfájlokat tölt fel, akkor minden egyes hangfájl külön hangoskönyvként lesz kezelve.",
|
||||||
"NoteUploaderUnsupportedFiles": "A nem támogatott fájlok figyelmen kívül hagyásra kerülnek. Mappa kiválasztása vagy elengedésekor az elem mappáján kívüli egyéb fájlok figyelmen kívül lesznek hagyva.",
|
"NoteUploaderUnsupportedFiles": "A nem támogatott fájlok figyelmen kívül hagyásra kerülnek. Mappa kiválasztása vagy elengedésekor az elem mappáján kívüli egyéb fájlok figyelmen kívül lesznek hagyva.",
|
||||||
|
"NotificationOnBackupCompletedDescription": "A biztonsági mentés befejezésekor aktiválódik",
|
||||||
|
"NotificationOnBackupFailedDescription": "A biztonsági mentés sikertelensége esetén aktiválódik",
|
||||||
|
"NotificationOnEpisodeDownloadedDescription": "Egy podcast epizód automatikus letöltésekor aktiválódik",
|
||||||
|
"NotificationOnTestDescription": "Esemény az értesítési rendszer teszteléséhez",
|
||||||
"PlaceholderNewCollection": "Új gyűjtemény neve",
|
"PlaceholderNewCollection": "Új gyűjtemény neve",
|
||||||
"PlaceholderNewFolderPath": "Új mappa útvonala",
|
"PlaceholderNewFolderPath": "Új mappa útvonala",
|
||||||
"PlaceholderNewPlaylist": "Új lejátszási lista neve",
|
"PlaceholderNewPlaylist": "Új lejátszási lista neve",
|
||||||
"PlaceholderSearch": "Keresés..",
|
"PlaceholderSearch": "Keresés..",
|
||||||
"PlaceholderSearchEpisode": "Epizód keresése..",
|
"PlaceholderSearchEpisode": "Epizód keresése..",
|
||||||
|
"StatsAuthorsAdded": "szerző hozzáadva",
|
||||||
|
"StatsBooksAdded": "könyv hozzáadva",
|
||||||
|
"StatsBooksAdditional": "Néhány kiegészítés…",
|
||||||
|
"StatsBooksFinished": "könyv befejezve",
|
||||||
|
"StatsBooksFinishedThisYear": "Néhány idén befejezett könyv…",
|
||||||
|
"StatsBooksListenedTo": "hallgatott könyv",
|
||||||
|
"StatsCollectionGrewTo": "Könyvgyűjtemény nőtt…",
|
||||||
|
"StatsSessions": "munkamenet",
|
||||||
|
"StatsSpentListening": "hallgatással töltött idő",
|
||||||
|
"StatsTopAuthor": "TOP SZERZŐ",
|
||||||
|
"StatsTopAuthors": "TOP SZERZŐ",
|
||||||
|
"StatsTopGenre": "TOP MŰFAJ",
|
||||||
|
"StatsTopGenres": "TOP MŰFAJ",
|
||||||
|
"StatsTopMonth": "TOP HÓNAP",
|
||||||
|
"StatsTopNarrator": "TOP ELŐADÓ",
|
||||||
|
"StatsTopNarrators": "TOP ELŐADÓ",
|
||||||
|
"StatsTotalDuration": "A teljes időtartam…",
|
||||||
|
"StatsYearInReview": "ÉVÉRTÉKELÉS",
|
||||||
"ToastAccountUpdateSuccess": "Fiók frissítve",
|
"ToastAccountUpdateSuccess": "Fiók frissítve",
|
||||||
|
"ToastAppriseUrlRequired": "Meg kell adnia egy Apprise URL-címet",
|
||||||
|
"ToastAsinRequired": "ASIN kötelező",
|
||||||
"ToastAuthorImageRemoveSuccess": "Szerző képe eltávolítva",
|
"ToastAuthorImageRemoveSuccess": "Szerző képe eltávolítva",
|
||||||
|
"ToastAuthorNotFound": "A szerző „{0}” nem található",
|
||||||
|
"ToastAuthorRemoveSuccess": "Szerző eltávolítva",
|
||||||
|
"ToastAuthorSearchNotFound": "Szerző nem található",
|
||||||
"ToastAuthorUpdateMerged": "Szerző összevonva",
|
"ToastAuthorUpdateMerged": "Szerző összevonva",
|
||||||
"ToastAuthorUpdateSuccess": "Szerző frissítve",
|
"ToastAuthorUpdateSuccess": "Szerző frissítve",
|
||||||
"ToastAuthorUpdateSuccessNoImageFound": "Szerző frissítve (nem található kép)",
|
"ToastAuthorUpdateSuccessNoImageFound": "Szerző frissítve (nem található kép)",
|
||||||
|
"ToastBackupAppliedSuccess": "Biztonsági mentés alkalmazva",
|
||||||
"ToastBackupCreateFailed": "A biztonsági mentés létrehozása sikertelen",
|
"ToastBackupCreateFailed": "A biztonsági mentés létrehozása sikertelen",
|
||||||
"ToastBackupCreateSuccess": "Biztonsági mentés létrehozva",
|
"ToastBackupCreateSuccess": "Biztonsági mentés létrehozva",
|
||||||
"ToastBackupDeleteFailed": "A biztonsági mentés törlése sikertelen",
|
"ToastBackupDeleteFailed": "A biztonsági mentés törlése sikertelen",
|
||||||
"ToastBackupDeleteSuccess": "Biztonsági mentés törölve",
|
"ToastBackupDeleteSuccess": "Biztonsági mentés törölve",
|
||||||
|
"ToastBackupInvalidMaxKeep": "A megőrzendő biztonsági másolatok száma érvénytelen",
|
||||||
|
"ToastBackupInvalidMaxSize": "Érvénytelen maximális mentésméret",
|
||||||
"ToastBackupRestoreFailed": "A biztonsági mentés visszaállítása sikertelen",
|
"ToastBackupRestoreFailed": "A biztonsági mentés visszaállítása sikertelen",
|
||||||
"ToastBackupUploadFailed": "A biztonsági mentés feltöltése sikertelen",
|
"ToastBackupUploadFailed": "A biztonsági mentés feltöltése sikertelen",
|
||||||
"ToastBackupUploadSuccess": "Biztonsági mentés feltöltve",
|
"ToastBackupUploadSuccess": "Biztonsági mentés feltöltve",
|
||||||
|
"ToastBatchDeleteFailed": "A tömeges törlés nem sikerült",
|
||||||
|
"ToastBatchDeleteSuccess": "Sikeres tömeges törlés",
|
||||||
"ToastBatchUpdateFailed": "Kötegelt frissítés sikertelen",
|
"ToastBatchUpdateFailed": "Kötegelt frissítés sikertelen",
|
||||||
"ToastBatchUpdateSuccess": "Kötegelt frissítés sikeres",
|
"ToastBatchUpdateSuccess": "Kötegelt frissítés sikeres",
|
||||||
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
|
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
|
||||||
"ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva",
|
"ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva",
|
||||||
"ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva",
|
"ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva",
|
||||||
"ToastBookmarkUpdateSuccess": "Könyvjelző frissítve",
|
"ToastBookmarkUpdateSuccess": "Könyvjelző frissítve",
|
||||||
|
"ToastCachePurgeFailed": "A gyorsítótár törlése sikertelen",
|
||||||
|
"ToastCachePurgeSuccess": "A gyorsítótár sikeresen törölve",
|
||||||
"ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
|
"ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
|
||||||
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
|
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
|
||||||
|
"ToastChaptersRemoved": "Fejezetek eltávolítva",
|
||||||
|
"ToastChaptersUpdated": "Fejezetek frissítve",
|
||||||
"ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből",
|
"ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből",
|
||||||
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
|
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
|
||||||
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
|
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
|
||||||
|
"ToastCoverUpdateFailed": "A borító frissítése nem sikerült",
|
||||||
|
"ToastDeleteFileFailed": "Nem sikerült törölni a fájlt",
|
||||||
|
"ToastDeleteFileSuccess": "Fájl törölve",
|
||||||
|
"ToastDeviceAddFailed": "Nem sikerült eszközt hozzáadni",
|
||||||
|
"ToastDeviceNameAlreadyExists": "Ilyen nevű olvasóeszköz már létezik",
|
||||||
|
"ToastDeviceTestEmailFailed": "Teszt email küldése sikertelen",
|
||||||
|
"ToastDeviceTestEmailSuccess": "Teszt email elküldve",
|
||||||
|
"ToastEmailSettingsUpdateSuccess": "Email beállítások frissítve",
|
||||||
|
"ToastEncodeCancelSucces": "Kódolás törölve",
|
||||||
|
"ToastEpisodeDownloadQueueClearFailed": "Nem sikerült törölni a várólistát",
|
||||||
|
"ToastEpisodeUpdateSuccess": "{0} epizód frissítve",
|
||||||
|
"ToastFailedToLoadData": "Sikertelen adatbetöltés",
|
||||||
|
"ToastFailedToMatch": "Nem sikerült egyezőséget találni",
|
||||||
|
"ToastFailedToShare": "Nem sikerült megosztani",
|
||||||
|
"ToastFailedToUpdate": "Nem sikerült frissíteni",
|
||||||
|
"ToastInvalidImageUrl": "Érvénytelen a kép URL címe",
|
||||||
|
"ToastInvalidUrl": "Érvénytelen URL",
|
||||||
"ToastItemCoverUpdateSuccess": "Elem borítója frissítve",
|
"ToastItemCoverUpdateSuccess": "Elem borítója frissítve",
|
||||||
|
"ToastItemDeletedFailed": "Nem sikerült törölni az elemet",
|
||||||
|
"ToastItemDeletedSuccess": "Elem törölve",
|
||||||
"ToastItemDetailsUpdateSuccess": "Elem részletei frissítve",
|
"ToastItemDetailsUpdateSuccess": "Elem részletei frissítve",
|
||||||
"ToastItemMarkedAsFinishedFailed": "Megjelölés Befejezettként sikertelen",
|
"ToastItemMarkedAsFinishedFailed": "Megjelölés Befejezettként sikertelen",
|
||||||
"ToastItemMarkedAsFinishedSuccess": "Elem megjelölve Befejezettként",
|
"ToastItemMarkedAsFinishedSuccess": "Elem megjelölve Befejezettként",
|
||||||
"ToastItemMarkedAsNotFinishedFailed": "Az elem befejezetlennek jelölése sikertelen",
|
"ToastItemMarkedAsNotFinishedFailed": "Az elem befejezetlennek jelölése sikertelen",
|
||||||
"ToastItemMarkedAsNotFinishedSuccess": "Elem megjelölve Nem Befejezettként",
|
"ToastItemMarkedAsNotFinishedSuccess": "Elem megjelölve Nem Befejezettként",
|
||||||
|
"ToastItemUpdateSuccess": "Elem frissítve",
|
||||||
"ToastLibraryCreateFailed": "Könyvtár létrehozása sikertelen",
|
"ToastLibraryCreateFailed": "Könyvtár létrehozása sikertelen",
|
||||||
"ToastLibraryCreateSuccess": "\"{0}\" könyvtár létrehozva",
|
"ToastLibraryCreateSuccess": "\"{0}\" könyvtár létrehozva",
|
||||||
"ToastLibraryDeleteFailed": "Könyvtár törlése sikertelen",
|
"ToastLibraryDeleteFailed": "Könyvtár törlése sikertelen",
|
||||||
@ -790,14 +981,34 @@
|
|||||||
"ToastLibraryScanFailedToStart": "A beolvasás elindítása sikertelen",
|
"ToastLibraryScanFailedToStart": "A beolvasás elindítása sikertelen",
|
||||||
"ToastLibraryScanStarted": "Könyvtár beolvasása elindítva",
|
"ToastLibraryScanStarted": "Könyvtár beolvasása elindítva",
|
||||||
"ToastLibraryUpdateSuccess": "\"{0}\" könyvtár frissítve",
|
"ToastLibraryUpdateSuccess": "\"{0}\" könyvtár frissítve",
|
||||||
|
"ToastMatchAllAuthorsFailed": "Nem sikerült az összes szerzőt azonosítani",
|
||||||
|
"ToastMetadataFilesRemovedError": "Hiba a metaadatok eltávolításakor.{0} fájl",
|
||||||
|
"ToastMetadataFilesRemovedNoneFound": "Nincsenek metaadatok.{0} fájl a könyvtárban",
|
||||||
|
"ToastMetadataFilesRemovedNoneRemoved": "Nincsenek metaadatok.{0} fájl eltávolítva",
|
||||||
|
"ToastMetadataFilesRemovedSuccess": "{0} metaadat.{1} fájl eltávolítva",
|
||||||
|
"ToastMustHaveAtLeastOnePath": "Legalább egy elérési útvonalnak kell lennie",
|
||||||
|
"ToastNameEmailRequired": "Név és e-mail cím megadása kötelező",
|
||||||
|
"ToastNameRequired": "A név megadása kötelező",
|
||||||
|
"ToastNewEpisodesFound": "{0} új epizód",
|
||||||
|
"ToastNewUserCreatedFailed": "Nem sikerült a fiókot létrehozni: „{0}”",
|
||||||
|
"ToastNewUserCreatedSuccess": "Új fiók létrehozva",
|
||||||
|
"ToastNewUserLibraryError": "Legalább egy könyvtárat ki kell választani",
|
||||||
|
"ToastNewUserPasswordError": "Kötelező a jelszó, csak a root felhasználónak lehet üres jelszava",
|
||||||
|
"ToastNewUserUsernameError": "Adjon meg egy felhasználónevet",
|
||||||
|
"ToastNoNewEpisodesFound": "Nincs új epizód",
|
||||||
|
"ToastNoUpdatesNecessary": "Nincs szükség frissítésre",
|
||||||
|
"ToastNotificationSettingsUpdateSuccess": "Értesítési beállítások frissítve",
|
||||||
|
"ToastNotificationUpdateSuccess": "Értesítés frissítve",
|
||||||
"ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen",
|
"ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen",
|
||||||
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
|
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
|
||||||
"ToastPlaylistRemoveSuccess": "Lejátszási lista eltávolítva",
|
"ToastPlaylistRemoveSuccess": "Lejátszási lista eltávolítva",
|
||||||
"ToastPlaylistUpdateSuccess": "Lejátszási lista frissítve",
|
"ToastPlaylistUpdateSuccess": "Lejátszási lista frissítve",
|
||||||
"ToastPodcastCreateFailed": "Podcast létrehozása sikertelen",
|
"ToastPodcastCreateFailed": "Podcast létrehozása sikertelen",
|
||||||
"ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva",
|
"ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva",
|
||||||
|
"ToastPodcastNoEpisodesInFeed": "Nincsenek epizódok az RSS hírcsatornában",
|
||||||
|
"ToastPodcastNoRssFeed": "A podcastnak nincs RSS-hírcsatornája",
|
||||||
"ToastRSSFeedCloseFailed": "Az RSS hírcsatorna bezárása sikertelen",
|
"ToastRSSFeedCloseFailed": "Az RSS hírcsatorna bezárása sikertelen",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS feed bezárva",
|
"ToastRSSFeedCloseSuccess": "RSS hírfolyam leállítva",
|
||||||
"ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen",
|
"ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen",
|
||||||
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
|
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
|
||||||
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
|
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
|
||||||
@ -809,6 +1020,9 @@
|
|||||||
"ToastSocketConnected": "Socket csatlakoztatva",
|
"ToastSocketConnected": "Socket csatlakoztatva",
|
||||||
"ToastSocketDisconnected": "Socket lecsatlakoztatva",
|
"ToastSocketDisconnected": "Socket lecsatlakoztatva",
|
||||||
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
|
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
|
||||||
|
"ToastUnknownError": "Ismeretlen hiba",
|
||||||
"ToastUserDeleteFailed": "Felhasználó törlése sikertelen",
|
"ToastUserDeleteFailed": "Felhasználó törlése sikertelen",
|
||||||
"ToastUserDeleteSuccess": "Felhasználó törölve"
|
"ToastUserDeleteSuccess": "Felhasználó törölve",
|
||||||
|
"ToastUserPasswordChangeSuccess": "Jelszó sikeresen megváltoztatva",
|
||||||
|
"ToastUserRootRequireName": "Egy root felhasználónevet kell megadnia"
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
"ButtonAddDevice": "Legg til enhet",
|
"ButtonAddDevice": "Legg til enhet",
|
||||||
"ButtonAddLibrary": "Legg til bibliotek",
|
"ButtonAddLibrary": "Legg til bibliotek",
|
||||||
"ButtonAddPodcasts": "Legg til podcast",
|
"ButtonAddPodcasts": "Legg til podcast",
|
||||||
|
"ButtonAddUser": "Legg til bruker",
|
||||||
"ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek",
|
"ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek",
|
||||||
"ButtonApply": "Bruk",
|
"ButtonApply": "Bruk",
|
||||||
"ButtonApplyChapters": "Bruk kapittel",
|
"ButtonApplyChapters": "Bruk kapittel",
|
||||||
@ -18,6 +19,7 @@
|
|||||||
"ButtonChooseFiles": "Velg filer",
|
"ButtonChooseFiles": "Velg filer",
|
||||||
"ButtonClearFilter": "Bytt filter",
|
"ButtonClearFilter": "Bytt filter",
|
||||||
"ButtonCloseFeed": "Lukk Feed",
|
"ButtonCloseFeed": "Lukk Feed",
|
||||||
|
"ButtonCloseSession": "Lukk åpen økt",
|
||||||
"ButtonCollections": "Samlinger",
|
"ButtonCollections": "Samlinger",
|
||||||
"ButtonConfigureScanner": "Konfigurer skanner",
|
"ButtonConfigureScanner": "Konfigurer skanner",
|
||||||
"ButtonCreate": "Opprett",
|
"ButtonCreate": "Opprett",
|
||||||
|
@ -88,6 +88,8 @@
|
|||||||
"ButtonSaveTracklist": "Сохранить список треков",
|
"ButtonSaveTracklist": "Сохранить список треков",
|
||||||
"ButtonScan": "Сканировать",
|
"ButtonScan": "Сканировать",
|
||||||
"ButtonScanLibrary": "Сканировать библиотеку",
|
"ButtonScanLibrary": "Сканировать библиотеку",
|
||||||
|
"ButtonScrollLeft": "Перемотать влево",
|
||||||
|
"ButtonScrollRight": "Перемотать вправо",
|
||||||
"ButtonSearch": "Поиск",
|
"ButtonSearch": "Поиск",
|
||||||
"ButtonSelectFolderPath": "Выберите путь папки",
|
"ButtonSelectFolderPath": "Выберите путь папки",
|
||||||
"ButtonSeries": "Серии",
|
"ButtonSeries": "Серии",
|
||||||
@ -190,6 +192,7 @@
|
|||||||
"HeaderSettingsExperimental": "Экспериментальные функции",
|
"HeaderSettingsExperimental": "Экспериментальные функции",
|
||||||
"HeaderSettingsGeneral": "Основные",
|
"HeaderSettingsGeneral": "Основные",
|
||||||
"HeaderSettingsScanner": "Сканер",
|
"HeaderSettingsScanner": "Сканер",
|
||||||
|
"HeaderSettingsWebClient": "Веб-клиент",
|
||||||
"HeaderSleepTimer": "Таймер сна",
|
"HeaderSleepTimer": "Таймер сна",
|
||||||
"HeaderStatsLargestItems": "Самые большые элементы",
|
"HeaderStatsLargestItems": "Самые большые элементы",
|
||||||
"HeaderStatsLongestItems": "Самые длинные элементы (часов)",
|
"HeaderStatsLongestItems": "Самые длинные элементы (часов)",
|
||||||
@ -542,6 +545,7 @@
|
|||||||
"LabelServerYearReview": "Итоги года всего сервера ({0})",
|
"LabelServerYearReview": "Итоги года всего сервера ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Установить как основную",
|
"LabelSetEbookAsPrimary": "Установить как основную",
|
||||||
"LabelSetEbookAsSupplementary": "Установить как дополнительную",
|
"LabelSetEbookAsSupplementary": "Установить как дополнительную",
|
||||||
|
"LabelSettingsAllowIframe": "Разрешить встраивание в iframe",
|
||||||
"LabelSettingsAudiobooksOnly": "Только аудиокниги",
|
"LabelSettingsAudiobooksOnly": "Только аудиокниги",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Если включить эту настройку, файлы электронных книг будут игнорироваться, за исключением случаев, когда они находятся в папке с аудиокнигами, в этом случае они будут рассматриваться как дополнительные электронные книги",
|
"LabelSettingsAudiobooksOnlyHelp": "Если включить эту настройку, файлы электронных книг будут игнорироваться, за исключением случаев, когда они находятся в папке с аудиокнигами, в этом случае они будут рассматриваться как дополнительные электронные книги",
|
||||||
"LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками",
|
"LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками",
|
||||||
@ -592,6 +596,8 @@
|
|||||||
"LabelSize": "Размер",
|
"LabelSize": "Размер",
|
||||||
"LabelSleepTimer": "Таймер сна",
|
"LabelSleepTimer": "Таймер сна",
|
||||||
"LabelSlug": "Слизень",
|
"LabelSlug": "Слизень",
|
||||||
|
"LabelSortAscending": "По возрастанию",
|
||||||
|
"LabelSortDescending": "По убыванию",
|
||||||
"LabelStart": "Начало",
|
"LabelStart": "Начало",
|
||||||
"LabelStartTime": "Время начала",
|
"LabelStartTime": "Время начала",
|
||||||
"LabelStarted": "Начат",
|
"LabelStarted": "Начат",
|
||||||
@ -679,6 +685,8 @@
|
|||||||
"LabelViewPlayerSettings": "Просмотр настроек плеера",
|
"LabelViewPlayerSettings": "Просмотр настроек плеера",
|
||||||
"LabelViewQueue": "Очередь воспроизведения",
|
"LabelViewQueue": "Очередь воспроизведения",
|
||||||
"LabelVolume": "Громкость",
|
"LabelVolume": "Громкость",
|
||||||
|
"LabelWebRedirectURLsDescription": "Авторизуйте эти URL в провайдере OAuth, чтобы разрешить перенаправление обратно в веб-приложение после входа:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "Вложенная папка для URL-адресов перенаправления",
|
||||||
"LabelWeekdaysToRun": "Дни недели для запуска",
|
"LabelWeekdaysToRun": "Дни недели для запуска",
|
||||||
"LabelXBooks": "{0} книг",
|
"LabelXBooks": "{0} книг",
|
||||||
"LabelXItems": "{0} элементов",
|
"LabelXItems": "{0} элементов",
|
||||||
|
@ -88,6 +88,8 @@
|
|||||||
"ButtonSaveTracklist": "Shrani seznam skladb",
|
"ButtonSaveTracklist": "Shrani seznam skladb",
|
||||||
"ButtonScan": "Pregledovanje",
|
"ButtonScan": "Pregledovanje",
|
||||||
"ButtonScanLibrary": "Preglej knjižnico",
|
"ButtonScanLibrary": "Preglej knjižnico",
|
||||||
|
"ButtonScrollLeft": "Premik levo",
|
||||||
|
"ButtonScrollRight": "Premik desno",
|
||||||
"ButtonSearch": "Poišči",
|
"ButtonSearch": "Poišči",
|
||||||
"ButtonSelectFolderPath": "Izberite pot do mape",
|
"ButtonSelectFolderPath": "Izberite pot do mape",
|
||||||
"ButtonSeries": "Serije",
|
"ButtonSeries": "Serije",
|
||||||
@ -190,6 +192,7 @@
|
|||||||
"HeaderSettingsExperimental": "Eksperimentalne funkcije",
|
"HeaderSettingsExperimental": "Eksperimentalne funkcije",
|
||||||
"HeaderSettingsGeneral": "Splošno",
|
"HeaderSettingsGeneral": "Splošno",
|
||||||
"HeaderSettingsScanner": "Pregledovalnik",
|
"HeaderSettingsScanner": "Pregledovalnik",
|
||||||
|
"HeaderSettingsWebClient": "Spletni odjemalec",
|
||||||
"HeaderSleepTimer": "Časovnik za izklop",
|
"HeaderSleepTimer": "Časovnik za izklop",
|
||||||
"HeaderStatsLargestItems": "Največji elementi",
|
"HeaderStatsLargestItems": "Največji elementi",
|
||||||
"HeaderStatsLongestItems": "Najdaljši elementi (ure)",
|
"HeaderStatsLongestItems": "Najdaljši elementi (ure)",
|
||||||
@ -542,6 +545,7 @@
|
|||||||
"LabelServerYearReview": "Pregled leta strežnika ({0})",
|
"LabelServerYearReview": "Pregled leta strežnika ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Nastavi kot primarno",
|
"LabelSetEbookAsPrimary": "Nastavi kot primarno",
|
||||||
"LabelSetEbookAsSupplementary": "Nastavi kot dodatno",
|
"LabelSetEbookAsSupplementary": "Nastavi kot dodatno",
|
||||||
|
"LabelSettingsAllowIframe": "Dovoli vdelavo v iframu",
|
||||||
"LabelSettingsAudiobooksOnly": "Samo zvočne knjige",
|
"LabelSettingsAudiobooksOnly": "Samo zvočne knjige",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Če omogočite to nastavitev, bodo datoteke eknjig prezrte, razen če so znotraj mape zvočnih knjig, v tem primeru bodo nastavljene kot dodatne e-knjige",
|
"LabelSettingsAudiobooksOnlyHelp": "Če omogočite to nastavitev, bodo datoteke eknjig prezrte, razen če so znotraj mape zvočnih knjig, v tem primeru bodo nastavljene kot dodatne e-knjige",
|
||||||
"LabelSettingsBookshelfViewHelp": "Skeuomorfna oblika z lesenimi policami",
|
"LabelSettingsBookshelfViewHelp": "Skeuomorfna oblika z lesenimi policami",
|
||||||
@ -592,6 +596,8 @@
|
|||||||
"LabelSize": "Velikost",
|
"LabelSize": "Velikost",
|
||||||
"LabelSleepTimer": "Časovnik za spanje",
|
"LabelSleepTimer": "Časovnik za spanje",
|
||||||
"LabelSlug": "Slug",
|
"LabelSlug": "Slug",
|
||||||
|
"LabelSortAscending": "Naraščajoče",
|
||||||
|
"LabelSortDescending": "Padajoče",
|
||||||
"LabelStart": "Začetek",
|
"LabelStart": "Začetek",
|
||||||
"LabelStartTime": "Čas začetka",
|
"LabelStartTime": "Čas začetka",
|
||||||
"LabelStarted": "Začeto",
|
"LabelStarted": "Začeto",
|
||||||
|
@ -88,6 +88,8 @@
|
|||||||
"ButtonSaveTracklist": "Зберегти порядок",
|
"ButtonSaveTracklist": "Зберегти порядок",
|
||||||
"ButtonScan": "Сканувати",
|
"ButtonScan": "Сканувати",
|
||||||
"ButtonScanLibrary": "Сканувати бібліотеку",
|
"ButtonScanLibrary": "Сканувати бібліотеку",
|
||||||
|
"ButtonScrollLeft": "Прокрутити ліворуч",
|
||||||
|
"ButtonScrollRight": "Прокрутити праворуч",
|
||||||
"ButtonSearch": "Пошук",
|
"ButtonSearch": "Пошук",
|
||||||
"ButtonSelectFolderPath": "Обрати шлях до теки",
|
"ButtonSelectFolderPath": "Обрати шлях до теки",
|
||||||
"ButtonSeries": "Серії",
|
"ButtonSeries": "Серії",
|
||||||
@ -190,6 +192,7 @@
|
|||||||
"HeaderSettingsExperimental": "Експериментальні функції",
|
"HeaderSettingsExperimental": "Експериментальні функції",
|
||||||
"HeaderSettingsGeneral": "Основне",
|
"HeaderSettingsGeneral": "Основне",
|
||||||
"HeaderSettingsScanner": "Сканер",
|
"HeaderSettingsScanner": "Сканер",
|
||||||
|
"HeaderSettingsWebClient": "Вебклієнт",
|
||||||
"HeaderSleepTimer": "Таймер вимкнення",
|
"HeaderSleepTimer": "Таймер вимкнення",
|
||||||
"HeaderStatsLargestItems": "Найбільші елементи",
|
"HeaderStatsLargestItems": "Найбільші елементи",
|
||||||
"HeaderStatsLongestItems": "Найдовші елементи (год)",
|
"HeaderStatsLongestItems": "Найдовші елементи (год)",
|
||||||
@ -542,6 +545,7 @@
|
|||||||
"LabelServerYearReview": "Підсумки року сервера ({0})",
|
"LabelServerYearReview": "Підсумки року сервера ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Зробити основною",
|
"LabelSetEbookAsPrimary": "Зробити основною",
|
||||||
"LabelSetEbookAsSupplementary": "Зробити додатковою",
|
"LabelSetEbookAsSupplementary": "Зробити додатковою",
|
||||||
|
"LabelSettingsAllowIframe": "Дозволити вбудовування у iframe",
|
||||||
"LabelSettingsAudiobooksOnly": "Лише аудіокниги",
|
"LabelSettingsAudiobooksOnly": "Лише аудіокниги",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Увімкніть цей параметр, щоб ігнорувати файли електронних книг, якщо вони не знаходяться у теці аудіокниги, тоді вони будуть встановлені як додаткові електронні книги",
|
"LabelSettingsAudiobooksOnlyHelp": "Увімкніть цей параметр, щоб ігнорувати файли електронних книг, якщо вони не знаходяться у теці аудіокниги, тоді вони будуть встановлені як додаткові електронні книги",
|
||||||
"LabelSettingsBookshelfViewHelp": "Імітує вигляд дерев'яних полиць",
|
"LabelSettingsBookshelfViewHelp": "Імітує вигляд дерев'яних полиць",
|
||||||
@ -592,6 +596,8 @@
|
|||||||
"LabelSize": "Розмір",
|
"LabelSize": "Розмір",
|
||||||
"LabelSleepTimer": "Таймер вимкнення",
|
"LabelSleepTimer": "Таймер вимкнення",
|
||||||
"LabelSlug": "Назва",
|
"LabelSlug": "Назва",
|
||||||
|
"LabelSortAscending": "По зростанню",
|
||||||
|
"LabelSortDescending": "По спаданню",
|
||||||
"LabelStart": "Початок",
|
"LabelStart": "Початок",
|
||||||
"LabelStartTime": "Час початку",
|
"LabelStartTime": "Час початку",
|
||||||
"LabelStarted": "Почато",
|
"LabelStarted": "Почато",
|
||||||
@ -881,7 +887,7 @@
|
|||||||
"MessageXLibraryIsEmpty": "Бібліотека {0} порожня!",
|
"MessageXLibraryIsEmpty": "Бібліотека {0} порожня!",
|
||||||
"MessageYourAudiobookDurationIsLonger": "Тривалість вашої аудіокниги довша за віднайдену",
|
"MessageYourAudiobookDurationIsLonger": "Тривалість вашої аудіокниги довша за віднайдену",
|
||||||
"MessageYourAudiobookDurationIsShorter": "Тривалість вашої аудіокниги коротша за віднайдену",
|
"MessageYourAudiobookDurationIsShorter": "Тривалість вашої аудіокниги коротша за віднайдену",
|
||||||
"NoteChangeRootPassword": "Кореневий користувач — єдиний, хто може мати порожній пароль",
|
"NoteChangeRootPassword": "Тільки користувач root — єдиний, хто може мати порожній пароль",
|
||||||
"NoteChapterEditorTimes": "Примітка: Перша глава мусить починатися з 0:00, а час початку останньої глави не може бути більшим за зазначену тривалість аудіокниги.",
|
"NoteChapterEditorTimes": "Примітка: Перша глава мусить починатися з 0:00, а час початку останньої глави не може бути більшим за зазначену тривалість аудіокниги.",
|
||||||
"NoteFolderPicker": "Примітка: вже обрані теки не буде показано",
|
"NoteFolderPicker": "Примітка: вже обрані теки не буде показано",
|
||||||
"NoteRSSFeedPodcastAppsHttps": "Попередження: Більшість додатків подкастів вимагатимуть використання протоколу HTTPS від RSS-каналу",
|
"NoteRSSFeedPodcastAppsHttps": "Попередження: Більшість додатків подкастів вимагатимуть використання протоколу HTTPS від RSS-каналу",
|
||||||
|
@ -444,21 +444,6 @@ class Database {
|
|||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFeed(oldFeed) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
await this.models.feed.fullCreateFromOld(oldFeed)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFeed(oldFeed) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
return this.models.feed.fullUpdateFromOld(oldFeed)
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeFeed(feedId) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
await this.models.feed.removeById(feedId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async createBulkBookAuthors(bookAuthors) {
|
async createBulkBookAuthors(bookAuthors) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.bookAuthor.bulkCreate(bookAuthors)
|
await this.models.bookAuthor.bulkCreate(bookAuthors)
|
||||||
|
@ -71,7 +71,6 @@ class Server {
|
|||||||
this.playbackSessionManager = new PlaybackSessionManager()
|
this.playbackSessionManager = new PlaybackSessionManager()
|
||||||
this.podcastManager = new PodcastManager()
|
this.podcastManager = new PodcastManager()
|
||||||
this.audioMetadataManager = new AudioMetadataMangaer()
|
this.audioMetadataManager = new AudioMetadataMangaer()
|
||||||
this.rssFeedManager = new RssFeedManager()
|
|
||||||
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
|
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
|
||||||
this.apiCacheManager = new ApiCacheManager()
|
this.apiCacheManager = new ApiCacheManager()
|
||||||
this.binaryManager = new BinaryManager()
|
this.binaryManager = new BinaryManager()
|
||||||
@ -137,7 +136,7 @@ class Server {
|
|||||||
|
|
||||||
await ShareManager.init()
|
await ShareManager.init()
|
||||||
await this.backupManager.init()
|
await this.backupManager.init()
|
||||||
await this.rssFeedManager.init()
|
await RssFeedManager.init()
|
||||||
|
|
||||||
const libraries = await Database.libraryModel.getAllWithFolders()
|
const libraries = await Database.libraryModel.getAllWithFolders()
|
||||||
await this.cronManager.init(libraries)
|
await this.cronManager.init(libraries)
|
||||||
@ -291,14 +290,14 @@ class Server {
|
|||||||
// RSS Feed temp route
|
// RSS Feed temp route
|
||||||
router.get('/feed/:slug', (req, res) => {
|
router.get('/feed/:slug', (req, res) => {
|
||||||
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
|
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
|
||||||
this.rssFeedManager.getFeed(req, res)
|
RssFeedManager.getFeed(req, res)
|
||||||
})
|
})
|
||||||
router.get('/feed/:slug/cover*', (req, res) => {
|
router.get('/feed/:slug/cover*', (req, res) => {
|
||||||
this.rssFeedManager.getFeedCover(req, res)
|
RssFeedManager.getFeedCover(req, res)
|
||||||
})
|
})
|
||||||
router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
|
router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
|
||||||
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
|
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
|
||||||
this.rssFeedManager.getFeedItem(req, res)
|
RssFeedManager.getFeedItem(req, res)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
|
@ -4,6 +4,7 @@ const Logger = require('../Logger')
|
|||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
|
||||||
|
const RssFeedManager = require('../managers/RssFeedManager')
|
||||||
const Collection = require('../objects/Collection')
|
const Collection = require('../objects/Collection')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -115,6 +116,7 @@ class CollectionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If books array is passed in then update order in collection
|
// If books array is passed in then update order in collection
|
||||||
|
let collectionBooksUpdated = false
|
||||||
if (req.body.books?.length) {
|
if (req.body.books?.length) {
|
||||||
const collectionBooks = await req.collection.getCollectionBooks({
|
const collectionBooks = await req.collection.getCollectionBooks({
|
||||||
include: {
|
include: {
|
||||||
@ -133,9 +135,15 @@ class CollectionController {
|
|||||||
await collectionBooks[i].update({
|
await collectionBooks[i].update({
|
||||||
order: i + 1
|
order: i + 1
|
||||||
})
|
})
|
||||||
wasUpdated = true
|
collectionBooksUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (collectionBooksUpdated) {
|
||||||
|
req.collection.changed('updatedAt', true)
|
||||||
|
await req.collection.save()
|
||||||
|
wasUpdated = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||||
@ -148,6 +156,8 @@ class CollectionController {
|
|||||||
/**
|
/**
|
||||||
* DELETE: /api/collections/:id
|
* DELETE: /api/collections/:id
|
||||||
*
|
*
|
||||||
|
* @this {import('../routers/ApiRouter')}
|
||||||
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
@ -155,7 +165,7 @@ class CollectionController {
|
|||||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||||
|
|
||||||
// Close rss feed - remove from db and emit socket event
|
// Close rss feed - remove from db and emit socket event
|
||||||
await this.rssFeedManager.closeFeedForEntityId(req.collection.id)
|
await RssFeedManager.closeFeedForEntityId(req.collection.id)
|
||||||
|
|
||||||
await req.collection.destroy()
|
await req.collection.destroy()
|
||||||
|
|
||||||
|
@ -18,6 +18,8 @@ const LibraryScanner = require('../scanner/LibraryScanner')
|
|||||||
const Scanner = require('../scanner/Scanner')
|
const Scanner = require('../scanner/Scanner')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const Watcher = require('../Watcher')
|
const Watcher = require('../Watcher')
|
||||||
|
const RssFeedManager = require('../managers/RssFeedManager')
|
||||||
|
|
||||||
const libraryFilters = require('../utils/queries/libraryFilters')
|
const libraryFilters = require('../utils/queries/libraryFilters')
|
||||||
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
||||||
const authorFilters = require('../utils/queries/authorFilters')
|
const authorFilters = require('../utils/queries/authorFilters')
|
||||||
@ -759,8 +761,8 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (include.includes('rssfeed')) {
|
if (include.includes('rssfeed')) {
|
||||||
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
|
const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||||
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
|
seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(seriesJson)
|
res.json(seriesJson)
|
||||||
|
@ -13,6 +13,8 @@ const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUti
|
|||||||
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
||||||
const AudioFileScanner = require('../scanner/AudioFileScanner')
|
const AudioFileScanner = require('../scanner/AudioFileScanner')
|
||||||
const Scanner = require('../scanner/Scanner')
|
const Scanner = require('../scanner/Scanner')
|
||||||
|
|
||||||
|
const RssFeedManager = require('../managers/RssFeedManager')
|
||||||
const CacheManager = require('../managers/CacheManager')
|
const CacheManager = require('../managers/CacheManager')
|
||||||
const CoverManager = require('../managers/CoverManager')
|
const CoverManager = require('../managers/CoverManager')
|
||||||
const ShareManager = require('../managers/ShareManager')
|
const ShareManager = require('../managers/ShareManager')
|
||||||
@ -48,8 +50,8 @@ class LibraryItemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (includeEntities.includes('rssfeed')) {
|
if (includeEntities.includes('rssfeed')) {
|
||||||
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id)
|
const feedData = await RssFeedManager.findFeedForEntityId(item.id)
|
||||||
item.rssFeed = feedData?.toJSONMinified() || null
|
item.rssFeed = feedData?.toOldJSONMinified() || null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {
|
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
const { Request, Response, NextFunction } = require('express')
|
const { Request, Response, NextFunction } = require('express')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
|
||||||
|
const RssFeedManager = require('../managers/RssFeedManager')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef RequestUserObject
|
* @typedef RequestUserObject
|
||||||
@ -22,10 +23,10 @@ class RSSFeedController {
|
|||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async getAll(req, res) {
|
async getAll(req, res) {
|
||||||
const feeds = await this.rssFeedManager.getFeeds()
|
const feeds = await RssFeedManager.getFeeds()
|
||||||
res.json({
|
res.json({
|
||||||
feeds: feeds.map((f) => f.toJSON()),
|
feeds: feeds.map((f) => f.toOldJSON()),
|
||||||
minified: feeds.map((f) => f.toJSONMinified())
|
minified: feeds.map((f) => f.toOldJSONMinified())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,38 +39,43 @@ class RSSFeedController {
|
|||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async openRSSFeedForItem(req, res) {
|
async openRSSFeedForItem(req, res) {
|
||||||
const options = req.body || {}
|
const reqBody = req.body || {}
|
||||||
|
|
||||||
const item = await Database.libraryItemModel.getOldById(req.params.itemId)
|
const itemExpanded = await Database.libraryItemModel.getExpandedById(req.params.itemId)
|
||||||
if (!item) return res.sendStatus(404)
|
if (!itemExpanded) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check user can access this library item
|
// Check user can access this library item
|
||||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
if (!req.user.checkCanAccessLibraryItem(itemExpanded)) {
|
||||||
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${item.media.metadata.title}" that they don\'t have access to`)
|
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${itemExpanded.media.title}" that they don\'t have access to`)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check request body options exist
|
// Check request body options exist
|
||||||
if (!options.serverAddress || !options.slug) {
|
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
|
||||||
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
|
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
|
||||||
return res.status(400).send('Invalid request body')
|
return res.status(400).send('Invalid request body')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check item has audio tracks
|
// Check item has audio tracks
|
||||||
if (!item.media.numTracks) {
|
if (!itemExpanded.hasAudioTracks()) {
|
||||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${item.media.metadata.title}" because it has no audio tracks`)
|
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${itemExpanded.media.title}" because it has no audio tracks`)
|
||||||
return res.status(400).send('Item has no audio tracks')
|
return res.status(400).send('Item has no audio tracks')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||||
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
|
||||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
|
||||||
return res.status(400).send('Slug already in use')
|
return res.status(400).send('Slug already in use')
|
||||||
}
|
}
|
||||||
|
|
||||||
const feed = await this.rssFeedManager.openFeedForItem(req.user.id, item, req.body)
|
const feed = await RssFeedManager.openFeedForItem(req.user.id, itemExpanded, reqBody)
|
||||||
|
if (!feed) {
|
||||||
|
Logger.error(`[RSSFeedController] Failed to open RSS feed for item "${itemExpanded.media.title}"`)
|
||||||
|
return res.status(500).send('Failed to open RSS feed')
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
feed: feed.toJSONMinified()
|
feed: feed.toOldJSONMinified()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,35 +88,37 @@ class RSSFeedController {
|
|||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async openRSSFeedForCollection(req, res) {
|
async openRSSFeedForCollection(req, res) {
|
||||||
const options = req.body || {}
|
const reqBody = req.body || {}
|
||||||
|
|
||||||
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
|
|
||||||
if (!collection) return res.sendStatus(404)
|
|
||||||
|
|
||||||
// Check request body options exist
|
// Check request body options exist
|
||||||
if (!options.serverAddress || !options.slug) {
|
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
|
||||||
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
|
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
|
||||||
return res.status(400).send('Invalid request body')
|
return res.status(400).send('Invalid request body')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||||
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
|
||||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
|
||||||
return res.status(400).send('Slug already in use')
|
return res.status(400).send('Slug already in use')
|
||||||
}
|
}
|
||||||
|
|
||||||
const collectionExpanded = await collection.getOldJsonExpanded()
|
const collection = await Database.collectionModel.getExpandedById(req.params.collectionId)
|
||||||
const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length)
|
if (!collection) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check collection has audio tracks
|
// Check collection has audio tracks
|
||||||
if (!collectionItemsWithTracks.length) {
|
if (!collection.books.some((book) => book.includedAudioFiles.length)) {
|
||||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`)
|
Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`)
|
||||||
return res.status(400).send('Collection has no audio tracks')
|
return res.status(400).send('Collection has no audio tracks')
|
||||||
}
|
}
|
||||||
|
|
||||||
const feed = await this.rssFeedManager.openFeedForCollection(req.user.id, collectionExpanded, req.body)
|
const feed = await RssFeedManager.openFeedForCollection(req.user.id, collection, reqBody)
|
||||||
|
if (!feed) {
|
||||||
|
Logger.error(`[RSSFeedController] Failed to open RSS feed for collection "${collection.name}"`)
|
||||||
|
return res.status(500).send('Failed to open RSS feed')
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
feed: feed.toJSONMinified()
|
feed: feed.toOldJSONMinified()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,37 +131,37 @@ class RSSFeedController {
|
|||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async openRSSFeedForSeries(req, res) {
|
async openRSSFeedForSeries(req, res) {
|
||||||
const options = req.body || {}
|
const reqBody = req.body || {}
|
||||||
|
|
||||||
const series = await Database.seriesModel.findByPk(req.params.seriesId)
|
|
||||||
if (!series) return res.sendStatus(404)
|
|
||||||
|
|
||||||
// Check request body options exist
|
// Check request body options exist
|
||||||
if (!options.serverAddress || !options.slug) {
|
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
|
||||||
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
|
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
|
||||||
return res.status(400).send('Invalid request body')
|
return res.status(400).send('Invalid request body')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||||
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
|
||||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
|
||||||
return res.status(400).send('Slug already in use')
|
return res.status(400).send('Slug already in use')
|
||||||
}
|
}
|
||||||
|
|
||||||
const seriesJson = series.toOldJSON()
|
const series = await Database.seriesModel.getExpandedById(req.params.seriesId)
|
||||||
|
if (!series) return res.sendStatus(404)
|
||||||
// Get books in series that have audio tracks
|
|
||||||
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
|
|
||||||
|
|
||||||
// Check series has audio tracks
|
// Check series has audio tracks
|
||||||
if (!seriesJson.books.length) {
|
if (!series.books.some((book) => book.includedAudioFiles.length)) {
|
||||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${seriesJson.name}" because it has no audio tracks`)
|
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${series.name}" because it has no audio tracks`)
|
||||||
return res.status(400).send('Series has no audio tracks')
|
return res.status(400).send('Series has no audio tracks')
|
||||||
}
|
}
|
||||||
|
|
||||||
const feed = await this.rssFeedManager.openFeedForSeries(req.user.id, seriesJson, req.body)
|
const feed = await RssFeedManager.openFeedForSeries(req.user.id, series, req.body)
|
||||||
|
if (!feed) {
|
||||||
|
Logger.error(`[RSSFeedController] Failed to open RSS feed for series "${series.name}"`)
|
||||||
|
return res.status(500).send('Failed to open RSS feed')
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
feed: feed.toJSONMinified()
|
feed: feed.toOldJSONMinified()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,8 +173,16 @@ class RSSFeedController {
|
|||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
closeRSSFeed(req, res) {
|
async closeRSSFeed(req, res) {
|
||||||
this.rssFeedManager.closeRssFeed(req, res)
|
const feed = await Database.feedModel.findByPk(req.params.id)
|
||||||
|
if (!feed) {
|
||||||
|
Logger.error(`[RSSFeedController] Cannot close RSS feed because feed "${req.params.id}" does not exist`)
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
await RssFeedManager.handleCloseFeed(feed)
|
||||||
|
|
||||||
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -2,6 +2,9 @@ const { Request, Response, NextFunction } = require('express')
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
|
||||||
|
const RssFeedManager = require('../managers/RssFeedManager')
|
||||||
|
|
||||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -51,8 +54,8 @@ class SeriesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (include.includes('rssfeed')) {
|
if (include.includes('rssfeed')) {
|
||||||
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
|
const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||||
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
|
seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(seriesJson)
|
res.json(seriesJson)
|
||||||
|
@ -25,7 +25,9 @@ const LibraryItem = require('../objects/LibraryItem')
|
|||||||
|
|
||||||
class PodcastManager {
|
class PodcastManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
/** @type {PodcastEpisodeDownload[]} */
|
||||||
this.downloadQueue = []
|
this.downloadQueue = []
|
||||||
|
/** @type {PodcastEpisodeDownload} */
|
||||||
this.currentDownload = null
|
this.currentDownload = null
|
||||||
|
|
||||||
this.failedCheckMap = {}
|
this.failedCheckMap = {}
|
||||||
@ -63,6 +65,11 @@ class PodcastManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {PodcastEpisodeDownload} podcastEpisodeDownload
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
|
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
|
||||||
if (this.currentDownload) {
|
if (this.currentDownload) {
|
||||||
this.downloadQueue.push(podcastEpisodeDownload)
|
this.downloadQueue.push(podcastEpisodeDownload)
|
||||||
@ -106,7 +113,7 @@ class PodcastManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let success = false
|
let success = false
|
||||||
if (this.currentDownload.urlFileExtension === 'mp3') {
|
if (this.currentDownload.isMp3) {
|
||||||
// Download episode and tag it
|
// Download episode and tag it
|
||||||
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
|
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
|
||||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||||
|
@ -6,76 +6,139 @@ const SocketAuthority = require('../SocketAuthority')
|
|||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const Feed = require('../objects/Feed')
|
|
||||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
|
||||||
|
|
||||||
class RssFeedManager {
|
class RssFeedManager {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
async validateFeedEntity(feedObj) {
|
|
||||||
if (feedObj.entityType === 'collection') {
|
|
||||||
const collection = await Database.collectionModel.getOldById(feedObj.entityId)
|
|
||||||
if (!collection) {
|
|
||||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} else if (feedObj.entityType === 'libraryItem') {
|
|
||||||
const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
|
|
||||||
if (!libraryItemExists) {
|
|
||||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} else if (feedObj.entityType === 'series') {
|
|
||||||
const series = await Database.seriesModel.findByPk(feedObj.entityId)
|
|
||||||
if (!series) {
|
|
||||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Invalid entityType "${feedObj.entityType}"`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate all feeds and remove invalid
|
* Remove invalid feeds (invalid if the entity does not exist)
|
||||||
*/
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
const feeds = await Database.feedModel.getOldFeeds()
|
const feeds = await Database.feedModel.findAll({
|
||||||
for (const feed of feeds) {
|
attributes: ['id', 'entityId', 'entityType', 'title'],
|
||||||
// Remove invalid feeds
|
include: [
|
||||||
if (!(await this.validateFeedEntity(feed))) {
|
{
|
||||||
await Database.removeFeed(feed.id)
|
model: Database.libraryItemModel,
|
||||||
|
attributes: ['id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.collectionModel,
|
||||||
|
attributes: ['id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.seriesModel,
|
||||||
|
attributes: ['id']
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const feedIdsToRemove = []
|
||||||
|
for (const feed of feeds) {
|
||||||
|
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)
|
* Find open feed for an entity (e.g. collection id, playlist id, library item id)
|
||||||
* @param {string} entityId
|
* @param {string} entityId
|
||||||
* @returns {Promise<objects.Feed>} oldFeed
|
* @returns {Promise<import('../models/Feed')>}
|
||||||
*/
|
*/
|
||||||
findFeedForEntityId(entityId) {
|
findFeedForEntityId(entityId) {
|
||||||
return Database.feedModel.findOneOld({ entityId })
|
return Database.feedModel.findOne({
|
||||||
|
where: {
|
||||||
|
entityId
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find open feed for a slug
|
*
|
||||||
* @param {string} slug
|
* @param {string} slug
|
||||||
* @returns {Promise<objects.Feed>} oldFeed
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
findFeedBySlug(slug) {
|
checkExistsBySlug(slug) {
|
||||||
return Database.feedModel.findOneOld({ slug })
|
return Database.feedModel
|
||||||
|
.count({
|
||||||
|
where: {
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((count) => count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find open feed for a slug
|
* Feed requires update if the entity (or child entities) has been updated since the feed was last updated
|
||||||
* @param {string} slug
|
*
|
||||||
* @returns {Promise<objects.Feed>} oldFeed
|
* @param {import('../models/Feed')} feed
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
findFeed(id) {
|
async checkFeedRequiresUpdate(feed) {
|
||||||
return Database.feedModel.findByPkOld(id)
|
if (feed.entityType === 'libraryItem') {
|
||||||
|
feed.entity = await feed.getEntity({
|
||||||
|
attributes: ['id', 'updatedAt', 'mediaId', 'mediaType']
|
||||||
|
})
|
||||||
|
|
||||||
|
let newEntityUpdatedAt = feed.entity.updatedAt
|
||||||
|
|
||||||
|
if (feed.entity.mediaType === 'podcast') {
|
||||||
|
const mostRecentPodcastEpisode = await Database.podcastEpisodeModel.findOne({
|
||||||
|
where: {
|
||||||
|
podcastId: feed.entity.mediaId
|
||||||
|
},
|
||||||
|
attributes: ['id', 'updatedAt'],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
})
|
||||||
|
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
|
||||||
|
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newEntityUpdatedAt > feed.entityUpdatedAt
|
||||||
|
} else if (feed.entityType === 'collection' || feed.entityType === 'series') {
|
||||||
|
feed.entity = await feed.getEntity({
|
||||||
|
attributes: ['id', 'updatedAt'],
|
||||||
|
include: {
|
||||||
|
model: Database.bookModel,
|
||||||
|
attributes: ['id'],
|
||||||
|
through: {
|
||||||
|
attributes: []
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
model: Database.libraryItemModel,
|
||||||
|
attributes: ['id', 'updatedAt']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let newEntityUpdatedAt = feed.entity.updatedAt
|
||||||
|
|
||||||
|
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
|
||||||
|
if (book.libraryItem.updatedAt > mostRecent) {
|
||||||
|
return book.libraryItem.updatedAt
|
||||||
|
}
|
||||||
|
return mostRecent
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
|
||||||
|
newEntityUpdatedAt = mostRecentItemUpdatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
return newEntityUpdatedAt > feed.entityUpdatedAt
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid feed entity type')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -85,88 +148,23 @@ class RssFeedManager {
|
|||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async getFeed(req, res) {
|
async getFeed(req, res) {
|
||||||
const feed = await this.findFeedBySlug(req.params.slug)
|
let feed = await Database.feedModel.findOne({
|
||||||
|
where: {
|
||||||
|
slug: req.params.slug
|
||||||
|
}
|
||||||
|
})
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||||
res.sendStatus(404)
|
res.sendStatus(404)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if feed needs to be updated
|
const feedRequiresUpdate = await this.checkFeedRequiresUpdate(feed)
|
||||||
if (feed.entityType === 'libraryItem') {
|
if (feedRequiresUpdate) {
|
||||||
const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId)
|
Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
|
||||||
|
feed = await feed.updateFeedForEntity()
|
||||||
let mostRecentlyUpdatedAt = libraryItem.updatedAt
|
} else {
|
||||||
if (libraryItem.isPodcast) {
|
feed.feedEpisodes = await feed.getFeedEpisodes()
|
||||||
libraryItem.media.episodes.forEach((episode) => {
|
|
||||||
if (episode.updatedAt > mostRecentlyUpdatedAt) mostRecentlyUpdatedAt = episode.updatedAt
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) {
|
|
||||||
Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`)
|
|
||||||
|
|
||||||
feed.updateFromItem(libraryItem)
|
|
||||||
await Database.updateFeed(feed)
|
|
||||||
}
|
|
||||||
} else if (feed.entityType === 'collection') {
|
|
||||||
const collection = await Database.collectionModel.findByPk(feed.entityId, {
|
|
||||||
include: Database.collectionBookModel
|
|
||||||
})
|
|
||||||
if (collection) {
|
|
||||||
const collectionExpanded = await collection.getOldJsonExpanded()
|
|
||||||
|
|
||||||
// Find most recently updated item in collection
|
|
||||||
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
|
|
||||||
// Check for most recently updated book
|
|
||||||
collectionExpanded.books.forEach((libraryItem) => {
|
|
||||||
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
|
|
||||||
mostRecentlyUpdatedAt = libraryItem.updatedAt
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// Check for most recently added collection book
|
|
||||||
collection.collectionBooks.forEach((collectionBook) => {
|
|
||||||
if (collectionBook.createdAt.valueOf() > mostRecentlyUpdatedAt) {
|
|
||||||
mostRecentlyUpdatedAt = collectionBook.createdAt.valueOf()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const hasBooksRemoved = collection.collectionBooks.length < feed.episodes.length
|
|
||||||
|
|
||||||
if (!feed.entityUpdatedAt || hasBooksRemoved || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
|
|
||||||
Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`)
|
|
||||||
|
|
||||||
feed.updateFromCollection(collectionExpanded)
|
|
||||||
await Database.updateFeed(feed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (feed.entityType === 'series') {
|
|
||||||
const series = await Database.seriesModel.findByPk(feed.entityId)
|
|
||||||
if (series) {
|
|
||||||
const seriesJson = series.toOldJSON()
|
|
||||||
|
|
||||||
// Get books in series that have audio tracks
|
|
||||||
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
|
|
||||||
|
|
||||||
// Find most recently updated item in series
|
|
||||||
let mostRecentlyUpdatedAt = seriesJson.updatedAt
|
|
||||||
let totalTracks = 0 // Used to detect series items removed
|
|
||||||
seriesJson.books.forEach((libraryItem) => {
|
|
||||||
totalTracks += libraryItem.media.tracks.length
|
|
||||||
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
|
|
||||||
mostRecentlyUpdatedAt = libraryItem.updatedAt
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (totalTracks !== feed.episodes.length) {
|
|
||||||
mostRecentlyUpdatedAt = Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
|
|
||||||
Logger.debug(`[RssFeedManager] Updating RSS feed for series "${seriesJson.name}"`)
|
|
||||||
|
|
||||||
feed.updateFromSeries(seriesJson)
|
|
||||||
await Database.updateFeed(feed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const xml = feed.buildXml(req.originalHostPrefix)
|
const xml = feed.buildXml(req.originalHostPrefix)
|
||||||
@ -181,7 +179,17 @@ class RssFeedManager {
|
|||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async getFeedItem(req, res) {
|
async getFeedItem(req, res) {
|
||||||
const feed = await this.findFeedBySlug(req.params.slug)
|
const feed = await Database.feedModel.findOne({
|
||||||
|
where: {
|
||||||
|
slug: req.params.slug
|
||||||
|
},
|
||||||
|
attributes: ['id', 'slug'],
|
||||||
|
include: {
|
||||||
|
model: Database.feedEpisodeModel,
|
||||||
|
attributes: ['id', 'filePath']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||||
res.sendStatus(404)
|
res.sendStatus(404)
|
||||||
@ -203,7 +211,12 @@ class RssFeedManager {
|
|||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async getFeedCover(req, res) {
|
async getFeedCover(req, res) {
|
||||||
const feed = await this.findFeedBySlug(req.params.slug)
|
const feed = await Database.feedModel.findOne({
|
||||||
|
where: {
|
||||||
|
slug: req.params.slug
|
||||||
|
},
|
||||||
|
attributes: ['coverPath']
|
||||||
|
})
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||||
res.sendStatus(404)
|
res.sendStatus(404)
|
||||||
@ -223,100 +236,143 @@ class RssFeedManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} userId
|
|
||||||
* @param {*} libraryItem
|
|
||||||
* @param {*} options
|
* @param {*} options
|
||||||
* @returns
|
* @returns {import('../models/Feed').FeedOptions}
|
||||||
|
*/
|
||||||
|
getFeedOptionsFromReqOptions(options) {
|
||||||
|
const metadataDetails = options.metadataDetails || {}
|
||||||
|
|
||||||
|
if (metadataDetails.preventIndexing !== false) {
|
||||||
|
metadataDetails.preventIndexing = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
preventIndexing: metadataDetails.preventIndexing,
|
||||||
|
ownerName: metadataDetails.ownerName && typeof metadataDetails.ownerName === 'string' ? metadataDetails.ownerName : null,
|
||||||
|
ownerEmail: metadataDetails.ownerEmail && typeof metadataDetails.ownerEmail === 'string' ? metadataDetails.ownerEmail : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
|
* @param {*} options
|
||||||
|
* @returns {Promise<import('../models/Feed').FeedExpanded>}
|
||||||
*/
|
*/
|
||||||
async openFeedForItem(userId, libraryItem, options) {
|
async openFeedForItem(userId, libraryItem, options) {
|
||||||
const serverAddress = options.serverAddress
|
const serverAddress = options.serverAddress
|
||||||
const slug = options.slug
|
const slug = options.slug
|
||||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
const feedOptions = this.getFeedOptionsFromReqOptions(options)
|
||||||
const ownerName = options.metadataDetails?.ownerName
|
|
||||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
|
||||||
|
|
||||||
const feed = new Feed()
|
Logger.info(`[RssFeedManager] Creating RSS feed for item ${libraryItem.id} "${libraryItem.media.title}"`)
|
||||||
feed.setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
|
const feedExpanded = await Database.feedModel.createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
|
||||||
|
if (feedExpanded) {
|
||||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
|
||||||
await Database.createFeed(feed)
|
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
|
||||||
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
|
}
|
||||||
return feed
|
return feedExpanded
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
* @param {*} collectionExpanded
|
* @param {import('../models/Collection')} collectionExpanded
|
||||||
* @param {*} options
|
* @param {*} options
|
||||||
* @returns
|
* @returns {Promise<import('../models/Feed').FeedExpanded>}
|
||||||
*/
|
*/
|
||||||
async openFeedForCollection(userId, collectionExpanded, options) {
|
async openFeedForCollection(userId, collectionExpanded, options) {
|
||||||
const serverAddress = options.serverAddress
|
const serverAddress = options.serverAddress
|
||||||
const slug = options.slug
|
const slug = options.slug
|
||||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
const feedOptions = this.getFeedOptionsFromReqOptions(options)
|
||||||
const ownerName = options.metadataDetails?.ownerName
|
|
||||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
|
||||||
|
|
||||||
const feed = new Feed()
|
Logger.info(`[RssFeedManager] Creating RSS feed for collection "${collectionExpanded.name}"`)
|
||||||
feed.setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
|
const feedExpanded = await Database.feedModel.createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
|
||||||
|
if (feedExpanded) {
|
||||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
|
||||||
await Database.createFeed(feed)
|
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
|
||||||
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
|
}
|
||||||
return feed
|
return feedExpanded
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
* @param {*} seriesExpanded
|
* @param {import('../models/Series')} seriesExpanded
|
||||||
* @param {*} options
|
* @param {*} options
|
||||||
* @returns
|
* @returns {Promise<import('../models/Feed').FeedExpanded>}
|
||||||
*/
|
*/
|
||||||
async openFeedForSeries(userId, seriesExpanded, options) {
|
async openFeedForSeries(userId, seriesExpanded, options) {
|
||||||
const serverAddress = options.serverAddress
|
const serverAddress = options.serverAddress
|
||||||
const slug = options.slug
|
const slug = options.slug
|
||||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
const feedOptions = this.getFeedOptionsFromReqOptions(options)
|
||||||
const ownerName = options.metadataDetails?.ownerName
|
|
||||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
|
||||||
|
|
||||||
const feed = new Feed()
|
Logger.info(`[RssFeedManager] Creating RSS feed for series "${seriesExpanded.name}"`)
|
||||||
feed.setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
|
const feedExpanded = await Database.feedModel.createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
|
||||||
|
if (feedExpanded) {
|
||||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
|
||||||
await Database.createFeed(feed)
|
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
|
||||||
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
|
}
|
||||||
return feed
|
return feedExpanded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close Feed and emit Socket event
|
||||||
|
*
|
||||||
|
* @param {import('../models/Feed')} feed
|
||||||
|
* @returns {Promise<boolean>} - true if feed was closed
|
||||||
|
*/
|
||||||
async handleCloseFeed(feed) {
|
async handleCloseFeed(feed) {
|
||||||
if (!feed) return
|
if (!feed) return false
|
||||||
await Database.removeFeed(feed.id)
|
const wasRemoved = await Database.feedModel.removeById(feed.id)
|
||||||
SocketAuthority.emitter('rss_feed_closed', feed.toJSONMinified())
|
SocketAuthority.emitter('rss_feed_closed', feed.toOldJSONMinified())
|
||||||
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedUrl}"`)
|
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedURL}"`)
|
||||||
}
|
return wasRemoved
|
||||||
|
|
||||||
async closeRssFeed(req, res) {
|
|
||||||
const feed = await this.findFeed(req.params.id)
|
|
||||||
if (!feed) {
|
|
||||||
Logger.error(`[RssFeedManager] RSS feed not found with id "${req.params.id}"`)
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
await this.handleCloseFeed(feed)
|
|
||||||
res.sendStatus(200)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} entityId
|
||||||
|
* @returns {Promise<boolean>} - true if feed was closed
|
||||||
|
*/
|
||||||
async closeFeedForEntityId(entityId) {
|
async closeFeedForEntityId(entityId) {
|
||||||
const feed = await this.findFeedForEntityId(entityId)
|
const feed = await Database.feedModel.findOne({
|
||||||
if (!feed) return
|
where: {
|
||||||
|
entityId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!feed) {
|
||||||
|
Logger.warn(`[RssFeedManager] closeFeedForEntityId: Feed not found for entity id ${entityId}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
return this.handleCloseFeed(feed)
|
return this.handleCloseFeed(feed)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFeeds() {
|
/**
|
||||||
const feeds = await Database.models.feed.getOldFeeds()
|
*
|
||||||
Logger.info(`[RssFeedManager] Fetched all feeds`)
|
* @param {string[]} entityIds
|
||||||
return feeds
|
*/
|
||||||
|
async closeFeedsForEntityIds(entityIds) {
|
||||||
|
const feeds = await Database.feedModel.findAll({
|
||||||
|
where: {
|
||||||
|
entityId: entityIds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for (const feed of feeds) {
|
||||||
|
await this.handleCloseFeed(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns {Promise<import('../models/Feed').FeedExpanded[]>}
|
||||||
|
*/
|
||||||
|
getFeeds() {
|
||||||
|
return Database.feedModel.findAll({
|
||||||
|
include: {
|
||||||
|
model: Database.feedEpisodeModel
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = RssFeedManager
|
module.exports = new RssFeedManager()
|
||||||
|
@ -29,6 +29,12 @@ const Logger = require('../Logger')
|
|||||||
* @property {SeriesExpanded[]} series
|
* @property {SeriesExpanded[]} series
|
||||||
*
|
*
|
||||||
* @typedef {Book & BookExpandedProperties} BookExpanded
|
* @typedef {Book & BookExpandedProperties} BookExpanded
|
||||||
|
*
|
||||||
|
* Collections use BookExpandedWithLibraryItem
|
||||||
|
* @typedef BookExpandedWithLibraryItemProperties
|
||||||
|
* @property {import('./LibraryItem')} libraryItem
|
||||||
|
*
|
||||||
|
* @typedef {BookExpanded & BookExpandedWithLibraryItemProperties} BookExpandedWithLibraryItem
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -106,6 +112,9 @@ class Book extends Model {
|
|||||||
this.updatedAt
|
this.updatedAt
|
||||||
/** @type {Date} */
|
/** @type {Date} */
|
||||||
this.createdAt
|
this.createdAt
|
||||||
|
|
||||||
|
/** @type {import('./Author')[]} - optional if expanded */
|
||||||
|
this.authors
|
||||||
}
|
}
|
||||||
|
|
||||||
static getOldBook(libraryItemExpanded) {
|
static getOldBook(libraryItemExpanded) {
|
||||||
@ -320,6 +329,32 @@ class Book extends Model {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comma separated array of author names
|
||||||
|
* Requires authors to be loaded
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
get authorName() {
|
||||||
|
if (this.authors === undefined) {
|
||||||
|
Logger.error(`[Book] authorName: Cannot get authorName because authors are not loaded`)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return this.authors.map((au) => au.name).join(', ')
|
||||||
|
}
|
||||||
|
get includedAudioFiles() {
|
||||||
|
return this.audioFiles.filter((af) => !af.exclude)
|
||||||
|
}
|
||||||
|
get trackList() {
|
||||||
|
let startOffset = 0
|
||||||
|
return this.includedAudioFiles.map((af) => {
|
||||||
|
const track = structuredClone(af)
|
||||||
|
track.startOffset = startOffset
|
||||||
|
startOffset += track.duration
|
||||||
|
return track
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Book
|
module.exports = Book
|
||||||
|
@ -18,6 +18,11 @@ class Collection extends Model {
|
|||||||
this.updatedAt
|
this.updatedAt
|
||||||
/** @type {Date} */
|
/** @type {Date} */
|
||||||
this.createdAt
|
this.createdAt
|
||||||
|
|
||||||
|
// Expanded properties
|
||||||
|
|
||||||
|
/** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */
|
||||||
|
this.books
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -107,7 +112,7 @@ class Collection extends Model {
|
|||||||
|
|
||||||
// Map feed if found
|
// Map feed if found
|
||||||
if (c.feeds?.length) {
|
if (c.feeds?.length) {
|
||||||
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0])
|
collectionExpanded.rssFeed = c.feeds[0].toOldJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
return collectionExpanded
|
return collectionExpanded
|
||||||
@ -115,6 +120,39 @@ class Collection extends Model {
|
|||||||
.filter((c) => c)
|
.filter((c) => c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} collectionId
|
||||||
|
* @returns {Promise<Collection>}
|
||||||
|
*/
|
||||||
|
static async getExpandedById(collectionId) {
|
||||||
|
return this.findByPk(collectionId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.book,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.libraryItem
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.author,
|
||||||
|
through: {
|
||||||
|
attributes: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.series,
|
||||||
|
through: {
|
||||||
|
attributes: ['sequence']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get old collection from Collection
|
* Get old collection from Collection
|
||||||
* @param {Collection} collectionExpanded
|
* @param {Collection} collectionExpanded
|
||||||
@ -219,6 +257,34 @@ class Collection extends Model {
|
|||||||
Collection.belongsTo(library)
|
Collection.belongsTo(library)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all books in collection expanded with library item
|
||||||
|
*
|
||||||
|
* @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}
|
||||||
|
*/
|
||||||
|
getBooksExpandedWithLibraryItem() {
|
||||||
|
return this.getBooks({
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.libraryItem
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.author,
|
||||||
|
through: {
|
||||||
|
attributes: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.series,
|
||||||
|
through: {
|
||||||
|
attributes: ['sequence']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [Sequelize.literal('`collectionBook.order` ASC')]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get old collection toJSONExpanded, items filtered for user permissions
|
* Get old collection toJSONExpanded, items filtered for user permissions
|
||||||
*
|
*
|
||||||
@ -282,7 +348,7 @@ class Collection extends Model {
|
|||||||
if (include?.includes('rssfeed')) {
|
if (include?.includes('rssfeed')) {
|
||||||
const feeds = await this.getFeeds()
|
const feeds = await this.getFeeds()
|
||||||
if (feeds?.length) {
|
if (feeds?.length) {
|
||||||
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
|
collectionExpanded.rssFeed = feeds[0].toOldJSON()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,22 @@
|
|||||||
|
const Path = require('path')
|
||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
const oldFeed = require('../objects/Feed')
|
const Logger = require('../Logger')
|
||||||
const areEquivalent = require('../utils/areEquivalent')
|
|
||||||
|
const RSS = require('../libs/rss')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef FeedOptions
|
||||||
|
* @property {boolean} preventIndexing
|
||||||
|
* @property {string} ownerName
|
||||||
|
* @property {string} ownerEmail
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef FeedExpandedProperties
|
||||||
|
* @property {import('./FeedEpisode')} feedEpisodes
|
||||||
|
*
|
||||||
|
* @typedef {Feed & FeedExpandedProperties} FeedExpanded
|
||||||
|
*/
|
||||||
|
|
||||||
class Feed extends Model {
|
class Feed extends Model {
|
||||||
constructor(values, options) {
|
constructor(values, options) {
|
||||||
@ -50,210 +66,288 @@ class Feed extends Model {
|
|||||||
this.createdAt
|
this.createdAt
|
||||||
/** @type {Date} */
|
/** @type {Date} */
|
||||||
this.updatedAt
|
this.updatedAt
|
||||||
}
|
|
||||||
|
|
||||||
static async getOldFeeds() {
|
// Expanded properties
|
||||||
const feeds = await this.findAll({
|
|
||||||
include: {
|
/** @type {import('./FeedEpisode')[]} - only set if expanded */
|
||||||
model: this.sequelize.models.feedEpisode
|
this.feedEpisodes
|
||||||
}
|
|
||||||
})
|
|
||||||
return feeds.map((f) => this.getOldFeed(f))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get old feed from Feed and optionally Feed with FeedEpisodes
|
* @param {string} feedId
|
||||||
* @param {Feed} feedExpanded
|
* @returns {Promise<boolean>} - true if feed was removed
|
||||||
* @returns {oldFeed}
|
|
||||||
*/
|
*/
|
||||||
static getOldFeed(feedExpanded) {
|
static async removeById(feedId) {
|
||||||
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
|
return (
|
||||||
return new oldFeed({
|
(await this.destroy({
|
||||||
id: feedExpanded.id,
|
|
||||||
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: {
|
where: {
|
||||||
id: feedId
|
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() {
|
static getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions = null) {
|
||||||
const feeds = await this.findAll({
|
const media = libraryItem.media
|
||||||
attributes: ['entityId'],
|
|
||||||
where: {
|
let entityUpdatedAt = libraryItem.updatedAt
|
||||||
entityType: 'libraryItem'
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
return feeds.map((f) => f.entityId).filter((f) => f) || []
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find feed where and return oldFeed
|
*
|
||||||
* @param {Object} where sequelize where object
|
* @param {string} userId
|
||||||
* @returns {Promise<oldFeed>} oldFeed
|
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {string} serverAddress
|
||||||
|
* @param {FeedOptions} feedOptions
|
||||||
|
*
|
||||||
|
* @returns {Promise<FeedExpanded>}
|
||||||
*/
|
*/
|
||||||
static async findOneOld(where) {
|
static async createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) {
|
||||||
if (!where) return null
|
const feedObj = this.getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
|
||||||
const feedExpanded = await this.findOne({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
model: this.sequelize.models.feedEpisode
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (!feedExpanded) return null
|
|
||||||
return this.getOldFeed(feedExpanded)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/** @type {typeof import('./FeedEpisode')} */
|
||||||
* Find feed and return oldFeed
|
const feedEpisodeModel = this.sequelize.models.feedEpisode
|
||||||
* @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 transaction = await this.sequelize.transaction()
|
||||||
const feedObj = this.getFromOld(oldFeed)
|
try {
|
||||||
const newFeed = await this.create(feedObj)
|
const feed = await this.create(feedObj, { transaction })
|
||||||
|
|
||||||
if (oldFeed.episodes?.length) {
|
if (libraryItem.mediaType === 'podcast') {
|
||||||
for (const oldFeedEpisode of oldFeed.episodes) {
|
feed.feedEpisodes = await feedEpisodeModel.createFromPodcastEpisodes(libraryItem, feed, slug, transaction)
|
||||||
const feedEpisode = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
|
|
||||||
feedEpisode.feedId = newFeed.id
|
|
||||||
await this.sequelize.models.feedEpisode.create(feedEpisode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async fullUpdateFromOld(oldFeed) {
|
|
||||||
const oldFeedEpisodes = oldFeed.episodes || []
|
|
||||||
const feedObj = this.getFromOld(oldFeed)
|
|
||||||
|
|
||||||
const existingFeed = await this.findByPk(feedObj.id, {
|
|
||||||
include: this.sequelize.models.feedEpisode
|
|
||||||
})
|
|
||||||
if (!existingFeed) return false
|
|
||||||
|
|
||||||
let hasUpdates = false
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
} else {
|
} else {
|
||||||
let episodeHasUpdates = false
|
feed.feedEpisodes = await feedEpisodeModel.createFromAudiobookTracks(libraryItem, feed, slug, transaction)
|
||||||
const oldFeedEpisodeCleaned = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
|
|
||||||
for (const key in oldFeedEpisodeCleaned) {
|
|
||||||
if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) {
|
|
||||||
episodeHasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (episodeHasUpdates) {
|
|
||||||
await feedEpisode.update(oldFeedEpisodeCleaned)
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await transaction.commit()
|
||||||
|
|
||||||
|
return feed
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[Feed] Error creating feed for library item ${libraryItem.id}`, error)
|
||||||
|
await transaction.rollback()
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new feed episodes
|
/**
|
||||||
for (const episode of oldFeedEpisodes) {
|
*
|
||||||
if (!existingFeed.feedEpisodes.some((fe) => fe.id === episode.id)) {
|
* @param {string} userId
|
||||||
await this.sequelize.models.feedEpisode.createFromOld(feedObj.id, episode)
|
* @param {import('./Collection')} collectionExpanded
|
||||||
hasUpdates = true
|
* @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'
|
||||||
}
|
}
|
||||||
|
|
||||||
let feedHasUpdates = false
|
const feedObj = {
|
||||||
for (const key in feedObj) {
|
slug,
|
||||||
let existingValue = existingFeed[key]
|
entityType: 'collection',
|
||||||
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
entityId: collectionExpanded.id,
|
||||||
|
entityUpdatedAt,
|
||||||
if (!areEquivalent(existingValue, feedObj[key])) {
|
serverAddress,
|
||||||
feedHasUpdates = true
|
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 (feedHasUpdates) {
|
if (feedOptions) {
|
||||||
await existingFeed.update(feedObj)
|
feedObj.preventIndexing = feedOptions.preventIndexing
|
||||||
hasUpdates = true
|
feedObj.ownerName = feedOptions.ownerName
|
||||||
|
feedObj.ownerEmail = feedOptions.ownerEmail
|
||||||
}
|
}
|
||||||
|
|
||||||
return hasUpdates
|
|
||||||
}
|
|
||||||
|
|
||||||
static getFromOld(oldFeed) {
|
|
||||||
const oldFeedMeta = oldFeed.meta || {}
|
|
||||||
return {
|
return {
|
||||||
id: oldFeed.id,
|
feedObj,
|
||||||
slug: oldFeed.slug,
|
booksWithTracks
|
||||||
entityType: oldFeed.entityType,
|
|
||||||
entityId: oldFeed.entityId,
|
|
||||||
entityUpdatedAt: oldFeed.entityUpdatedAt,
|
|
||||||
serverAddress: oldFeed.serverAddress,
|
|
||||||
feedURL: oldFeed.feedUrl,
|
|
||||||
coverPath: oldFeed.coverPath || null,
|
|
||||||
imageURL: oldFeedMeta.imageUrl,
|
|
||||||
siteURL: oldFeedMeta.link,
|
|
||||||
title: oldFeedMeta.title,
|
|
||||||
description: oldFeedMeta.description,
|
|
||||||
author: oldFeedMeta.author,
|
|
||||||
podcastType: oldFeedMeta.type || null,
|
|
||||||
language: oldFeedMeta.language || null,
|
|
||||||
ownerName: oldFeedMeta.ownerName || null,
|
|
||||||
ownerEmail: oldFeedMeta.ownerEmail || null,
|
|
||||||
explicit: !!oldFeedMeta.explicit,
|
|
||||||
preventIndexing: !!oldFeedMeta.preventIndexing,
|
|
||||||
userId: oldFeed.userId
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getEntity(options) {
|
/**
|
||||||
if (!this.entityType) return Promise.resolve(null)
|
*
|
||||||
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
|
* @param {string} userId
|
||||||
return this[mixinMethodName](options)
|
* @param {import('./Collection')} collectionExpanded
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {string} serverAddress
|
||||||
|
* @param {FeedOptions} feedOptions
|
||||||
|
*
|
||||||
|
* @returns {Promise<FeedExpanded>}
|
||||||
|
*/
|
||||||
|
static async createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions) {
|
||||||
|
const { feedObj, booksWithTracks } = this.getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
|
||||||
|
|
||||||
|
/** @type {typeof import('./FeedEpisode')} */
|
||||||
|
const feedEpisodeModel = this.sequelize.models.feedEpisode
|
||||||
|
|
||||||
|
const transaction = await this.sequelize.transaction()
|
||||||
|
try {
|
||||||
|
const feed = await this.create(feedObj, { transaction })
|
||||||
|
feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)
|
||||||
|
|
||||||
|
await transaction.commit()
|
||||||
|
|
||||||
|
return feed
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[Feed] Error creating feed for collection ${collectionExpanded.id}`, error)
|
||||||
|
await transaction.rollback()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {import('./Series')} seriesExpanded
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {string} serverAddress
|
||||||
|
* @param {FeedOptions} [feedOptions=null]
|
||||||
|
*
|
||||||
|
* @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }}
|
||||||
|
*/
|
||||||
|
static getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions = null) {
|
||||||
|
const booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length)
|
||||||
|
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
|
||||||
|
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
|
||||||
|
}, seriesExpanded.updatedAt)
|
||||||
|
|
||||||
|
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
|
||||||
|
|
||||||
|
const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => {
|
||||||
|
const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name)
|
||||||
|
return authorNames.concat(bookAuthorsToAdd)
|
||||||
|
}, [])
|
||||||
|
let author = allBookAuthorNames.slice(0, 3).join(', ')
|
||||||
|
if (allBookAuthorNames.length > 3) {
|
||||||
|
author += ' & more'
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedObj = {
|
||||||
|
slug,
|
||||||
|
entityType: 'series',
|
||||||
|
entityId: seriesExpanded.id,
|
||||||
|
entityUpdatedAt,
|
||||||
|
serverAddress,
|
||||||
|
feedURL: `/feed/${slug}`,
|
||||||
|
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
|
||||||
|
siteURL: `/library/${booksWithTracks[0].libraryItem.libraryId}/series/${seriesExpanded.id}`,
|
||||||
|
title: seriesExpanded.name,
|
||||||
|
description: seriesExpanded.description || '',
|
||||||
|
author,
|
||||||
|
podcastType: 'serial',
|
||||||
|
explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit
|
||||||
|
coverPath: firstBookWithCover?.coverPath || null,
|
||||||
|
userId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feedOptions) {
|
||||||
|
feedObj.preventIndexing = feedOptions.preventIndexing
|
||||||
|
feedObj.ownerName = feedOptions.ownerName
|
||||||
|
feedObj.ownerEmail = feedOptions.ownerEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
feedObj,
|
||||||
|
booksWithTracks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {import('./Series')} seriesExpanded
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {string} serverAddress
|
||||||
|
* @param {FeedOptions} feedOptions
|
||||||
|
*
|
||||||
|
* @returns {Promise<FeedExpanded>}
|
||||||
|
*/
|
||||||
|
static async createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions) {
|
||||||
|
const { feedObj, booksWithTracks } = this.getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
|
||||||
|
|
||||||
|
/** @type {typeof import('./FeedEpisode')} */
|
||||||
|
const feedEpisodeModel = this.sequelize.models.feedEpisode
|
||||||
|
|
||||||
|
const transaction = await this.sequelize.transaction()
|
||||||
|
try {
|
||||||
|
const feed = await this.create(feedObj, { transaction })
|
||||||
|
feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)
|
||||||
|
|
||||||
|
await transaction.commit()
|
||||||
|
|
||||||
|
return feed
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[Feed] Error creating feed for series ${seriesExpanded.id}`, error)
|
||||||
|
await transaction.rollback()
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -369,6 +463,192 @@ class Feed extends Model {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns {Promise<FeedExpanded>}
|
||||||
|
*/
|
||||||
|
async updateFeedForEntity() {
|
||||||
|
/** @type {typeof import('./FeedEpisode')} */
|
||||||
|
const feedEpisodeModel = this.sequelize.models.feedEpisode
|
||||||
|
|
||||||
|
let feedObj = null
|
||||||
|
let feedEpisodeCreateFunc = null
|
||||||
|
let feedEpisodeCreateFuncEntity = null
|
||||||
|
|
||||||
|
if (this.entityType === 'libraryItem') {
|
||||||
|
/** @type {typeof import('./LibraryItem')} */
|
||||||
|
const libraryItemModel = this.sequelize.models.libraryItem
|
||||||
|
|
||||||
|
const itemExpanded = await libraryItemModel.getExpandedById(this.entityId)
|
||||||
|
feedObj = Feed.getFeedObjForLibraryItem(this.userId, itemExpanded, this.slug, this.serverAddress)
|
||||||
|
|
||||||
|
feedEpisodeCreateFuncEntity = itemExpanded
|
||||||
|
if (itemExpanded.mediaType === 'podcast') {
|
||||||
|
feedEpisodeCreateFunc = feedEpisodeModel.createFromPodcastEpisodes.bind(feedEpisodeModel)
|
||||||
|
} else {
|
||||||
|
feedEpisodeCreateFunc = feedEpisodeModel.createFromAudiobookTracks.bind(feedEpisodeModel)
|
||||||
|
}
|
||||||
|
} else if (this.entityType === 'collection') {
|
||||||
|
/** @type {typeof import('./Collection')} */
|
||||||
|
const collectionModel = this.sequelize.models.collection
|
||||||
|
|
||||||
|
const collectionExpanded = await collectionModel.getExpandedById(this.entityId)
|
||||||
|
const feedObjData = Feed.getFeedObjForCollection(this.userId, collectionExpanded, this.slug, this.serverAddress)
|
||||||
|
feedObj = feedObjData.feedObj
|
||||||
|
feedEpisodeCreateFuncEntity = feedObjData.booksWithTracks
|
||||||
|
feedEpisodeCreateFunc = feedEpisodeModel.createFromBooks.bind(feedEpisodeModel)
|
||||||
|
} else if (this.entityType === 'series') {
|
||||||
|
/** @type {typeof import('./Series')} */
|
||||||
|
const seriesModel = this.sequelize.models.series
|
||||||
|
|
||||||
|
const seriesExpanded = await seriesModel.getExpandedById(this.entityId)
|
||||||
|
const feedObjData = Feed.getFeedObjForSeries(this.userId, seriesExpanded, this.slug, this.serverAddress)
|
||||||
|
feedObj = feedObjData.feedObj
|
||||||
|
feedEpisodeCreateFuncEntity = feedObjData.booksWithTracks
|
||||||
|
feedEpisodeCreateFunc = feedEpisodeModel.createFromBooks.bind(feedEpisodeModel)
|
||||||
|
} else {
|
||||||
|
Logger.error(`[Feed] Invalid entity type ${this.entityType} for feed ${this.id}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = await this.sequelize.transaction()
|
||||||
|
try {
|
||||||
|
const updatedFeed = await this.update(feedObj, { transaction })
|
||||||
|
|
||||||
|
// Remove existing feed episodes
|
||||||
|
await feedEpisodeModel.destroy({
|
||||||
|
where: {
|
||||||
|
feedId: this.id
|
||||||
|
},
|
||||||
|
transaction
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create new feed episodes
|
||||||
|
updatedFeed.feedEpisodes = await feedEpisodeCreateFunc(feedEpisodeCreateFuncEntity, updatedFeed, this.slug, transaction)
|
||||||
|
|
||||||
|
await transaction.commit()
|
||||||
|
|
||||||
|
return updatedFeed
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[Feed] Error updating feed ${this.entityId}`, error)
|
||||||
|
await transaction.rollback()
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getEntity(options) {
|
||||||
|
if (!this.entityType) return Promise.resolve(null)
|
||||||
|
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
|
||||||
|
return this[mixinMethodName](options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} hostPrefix
|
||||||
|
*/
|
||||||
|
buildXml(hostPrefix) {
|
||||||
|
const blockTags = [{ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }]
|
||||||
|
const rssData = {
|
||||||
|
title: this.title,
|
||||||
|
description: this.description || '',
|
||||||
|
generator: 'Audiobookshelf',
|
||||||
|
feed_url: `${hostPrefix}${this.feedURL}`,
|
||||||
|
site_url: `${hostPrefix}${this.siteURL}`,
|
||||||
|
image_url: `${hostPrefix}${this.imageURL}`,
|
||||||
|
custom_namespaces: {
|
||||||
|
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
|
||||||
|
psc: 'http://podlove.org/simple-chapters',
|
||||||
|
podcast: 'https://podcastindex.org/namespace/1.0',
|
||||||
|
googleplay: 'http://www.google.com/schemas/play-podcasts/1.0'
|
||||||
|
},
|
||||||
|
custom_elements: [
|
||||||
|
{ language: this.language || 'en' },
|
||||||
|
{ author: this.author || 'advplyr' },
|
||||||
|
{ 'itunes:author': this.author || 'advplyr' },
|
||||||
|
{ 'itunes:summary': this.description || '' },
|
||||||
|
{ 'itunes:type': this.podcastType },
|
||||||
|
{
|
||||||
|
'itunes:image': {
|
||||||
|
_attr: {
|
||||||
|
href: `${hostPrefix}${this.imageURL}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'itunes:owner': [{ 'itunes:name': this.ownerName || this.author || '' }, { 'itunes:email': this.ownerEmail || '' }]
|
||||||
|
},
|
||||||
|
{ 'itunes:explicit': !!this.explicit },
|
||||||
|
...(this.preventIndexing ? blockTags : [])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const rssfeed = new RSS(rssData)
|
||||||
|
this.feedEpisodes.forEach((ep) => {
|
||||||
|
rssfeed.item(ep.getRSSData(hostPrefix))
|
||||||
|
})
|
||||||
|
return rssfeed.xml()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getEpisodePath(id) {
|
||||||
|
const episode = this.feedEpisodes.find((ep) => ep.id === id)
|
||||||
|
if (!episode) return null
|
||||||
|
return episode.filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
toOldJSON() {
|
||||||
|
const episodes = this.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
slug: this.slug,
|
||||||
|
userId: this.userId,
|
||||||
|
entityType: this.entityType,
|
||||||
|
entityId: this.entityId,
|
||||||
|
entityUpdatedAt: this.entityUpdatedAt?.valueOf() || null,
|
||||||
|
coverPath: this.coverPath || null,
|
||||||
|
meta: {
|
||||||
|
title: this.title,
|
||||||
|
description: this.description,
|
||||||
|
author: this.author,
|
||||||
|
imageUrl: this.imageURL,
|
||||||
|
feedUrl: this.feedURL,
|
||||||
|
link: this.siteURL,
|
||||||
|
explicit: this.explicit,
|
||||||
|
type: this.podcastType,
|
||||||
|
language: this.language,
|
||||||
|
preventIndexing: this.preventIndexing,
|
||||||
|
ownerName: this.ownerName,
|
||||||
|
ownerEmail: this.ownerEmail
|
||||||
|
},
|
||||||
|
serverAddress: this.serverAddress,
|
||||||
|
feedUrl: this.feedURL,
|
||||||
|
episodes: episodes || [],
|
||||||
|
createdAt: this.createdAt.valueOf(),
|
||||||
|
updatedAt: this.updatedAt.valueOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toOldJSONMinified() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
entityType: this.entityType,
|
||||||
|
entityId: this.entityId,
|
||||||
|
feedUrl: this.feedURL,
|
||||||
|
meta: {
|
||||||
|
title: this.title,
|
||||||
|
description: this.description,
|
||||||
|
preventIndexing: this.preventIndexing,
|
||||||
|
ownerName: this.ownerName,
|
||||||
|
ownerEmail: this.ownerEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Feed
|
module.exports = Feed
|
||||||
|
@ -1,4 +1,9 @@
|
|||||||
|
const Path = require('path')
|
||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
|
const uuidv4 = require('uuid').v4
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
const date = require('../libs/dateAndTime')
|
||||||
|
const { secondsToTimestamp } = require('../utils')
|
||||||
|
|
||||||
class FeedEpisode extends Model {
|
class FeedEpisode extends Model {
|
||||||
constructor(values, options) {
|
constructor(values, options) {
|
||||||
@ -9,6 +14,8 @@ class FeedEpisode extends Model {
|
|||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.title
|
this.title
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
|
this.author
|
||||||
|
/** @type {string} */
|
||||||
this.description
|
this.description
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.siteURL
|
this.siteURL
|
||||||
@ -40,60 +47,167 @@ class FeedEpisode extends Model {
|
|||||||
this.updatedAt
|
this.updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
getOldEpisode() {
|
/**
|
||||||
const enclosure = {
|
*
|
||||||
url: this.enclosureURL,
|
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
|
||||||
size: this.enclosureSize,
|
* @param {import('./Feed')} feed
|
||||||
type: this.enclosureType
|
* @param {string} slug
|
||||||
}
|
* @param {import('./PodcastEpisode')} episode
|
||||||
|
*/
|
||||||
|
static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) {
|
||||||
|
const episodeId = uuidv4()
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: episodeId,
|
||||||
title: this.title,
|
title: episode.title,
|
||||||
description: this.description,
|
author: feed.author,
|
||||||
enclosure,
|
description: episode.description,
|
||||||
pubDate: this.pubDate,
|
siteURL: feed.siteURL,
|
||||||
link: this.siteURL,
|
enclosureURL: `/feed/${slug}/item/${episodeId}/media${Path.extname(episode.audioFile.metadata.filename)}`,
|
||||||
author: this.author,
|
enclosureType: episode.audioFile.mimeType,
|
||||||
explicit: this.explicit,
|
enclosureSize: episode.audioFile.metadata.size,
|
||||||
duration: this.duration,
|
pubDate: episode.pubDate,
|
||||||
season: this.season,
|
season: episode.season,
|
||||||
episode: this.episode,
|
episode: episode.episode,
|
||||||
episodeType: this.episodeType,
|
episodeType: episode.episodeType,
|
||||||
fullPath: this.filePath
|
duration: episode.audioFile.duration,
|
||||||
|
filePath: episode.audioFile.metadata.path,
|
||||||
|
explicit: libraryItemExpanded.media.explicit,
|
||||||
|
feedId: feed.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create feed episode from old model
|
|
||||||
*
|
*
|
||||||
* @param {string} feedId
|
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
|
||||||
* @param {Object} oldFeedEpisode
|
* @param {import('./Feed')} feed
|
||||||
* @returns {Promise<FeedEpisode>}
|
* @param {string} slug
|
||||||
|
* @param {import('sequelize').Transaction} transaction
|
||||||
|
* @returns {Promise<FeedEpisode[]>}
|
||||||
*/
|
*/
|
||||||
static createFromOld(feedId, oldFeedEpisode) {
|
static async createFromPodcastEpisodes(libraryItemExpanded, feed, slug, transaction) {
|
||||||
const newEpisode = this.getFromOld(oldFeedEpisode)
|
const feedEpisodeObjs = []
|
||||||
newEpisode.feedId = feedId
|
|
||||||
return this.create(newEpisode)
|
// Sort podcastEpisodes by pubDate. episodic is newest to oldest. serial is oldest to newest.
|
||||||
|
if (feed.podcastType === 'episodic') {
|
||||||
|
libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
|
||||||
|
} else {
|
||||||
|
libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate))
|
||||||
}
|
}
|
||||||
|
|
||||||
static getFromOld(oldFeedEpisode) {
|
for (const episode of libraryItemExpanded.media.podcastEpisodes) {
|
||||||
return {
|
feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode))
|
||||||
id: oldFeedEpisode.id,
|
|
||||||
title: oldFeedEpisode.title,
|
|
||||||
author: oldFeedEpisode.author,
|
|
||||||
description: oldFeedEpisode.description,
|
|
||||||
siteURL: oldFeedEpisode.link,
|
|
||||||
enclosureURL: oldFeedEpisode.enclosure?.url || null,
|
|
||||||
enclosureType: oldFeedEpisode.enclosure?.type || null,
|
|
||||||
enclosureSize: oldFeedEpisode.enclosure?.size || null,
|
|
||||||
pubDate: oldFeedEpisode.pubDate,
|
|
||||||
season: oldFeedEpisode.season || null,
|
|
||||||
episode: oldFeedEpisode.episode || null,
|
|
||||||
episodeType: oldFeedEpisode.episodeType || null,
|
|
||||||
duration: oldFeedEpisode.duration,
|
|
||||||
filePath: oldFeedEpisode.fullPath,
|
|
||||||
explicit: !!oldFeedEpisode.explicit
|
|
||||||
}
|
}
|
||||||
|
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
|
||||||
|
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names
|
||||||
|
*
|
||||||
|
* @param {import('./Book')} book
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
static checkUseChapterTitlesForEpisodes(book) {
|
||||||
|
const tracks = book.trackList || []
|
||||||
|
const chapters = book.chapters || []
|
||||||
|
if (tracks.length !== chapters.length) return false
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('./Book')} book
|
||||||
|
* @param {Date} pubDateStart
|
||||||
|
* @param {import('./Feed')} feed
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {import('./Book').AudioFileObject} audioTrack
|
||||||
|
* @param {boolean} useChapterTitles
|
||||||
|
*/
|
||||||
|
static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles) {
|
||||||
|
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
|
||||||
|
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order
|
||||||
|
let episodeId = uuidv4()
|
||||||
|
|
||||||
|
// e.g. Track 1 will have a pub date before Track 2
|
||||||
|
const audiobookPubDate = date.format(new Date(pubDateStart.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
||||||
|
|
||||||
|
const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}`
|
||||||
|
|
||||||
|
let title = audioTrack.title
|
||||||
|
if (book.trackList.length == 1) {
|
||||||
|
// If audiobook is a single file, use book title instead of chapter/file title
|
||||||
|
title = book.title
|
||||||
|
} else {
|
||||||
|
if (useChapterTitles) {
|
||||||
|
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
|
||||||
|
const matchingChapter = book.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1)
|
||||||
|
if (matchingChapter?.title) title = matchingChapter.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: episodeId,
|
||||||
|
title,
|
||||||
|
author: feed.author,
|
||||||
|
description: book.description || '',
|
||||||
|
siteURL: feed.siteURL,
|
||||||
|
enclosureURL: contentUrl,
|
||||||
|
enclosureType: audioTrack.mimeType,
|
||||||
|
enclosureSize: audioTrack.metadata.size,
|
||||||
|
pubDate: audiobookPubDate,
|
||||||
|
duration: audioTrack.duration,
|
||||||
|
filePath: audioTrack.metadata.path,
|
||||||
|
explicit: book.explicit,
|
||||||
|
feedId: feed.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
|
||||||
|
* @param {import('./Feed')} feed
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {import('sequelize').Transaction} transaction
|
||||||
|
* @returns {Promise<FeedEpisode[]>}
|
||||||
|
*/
|
||||||
|
static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) {
|
||||||
|
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded.media)
|
||||||
|
|
||||||
|
const feedEpisodeObjs = []
|
||||||
|
for (const track of libraryItemExpanded.media.trackList) {
|
||||||
|
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles))
|
||||||
|
}
|
||||||
|
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
|
||||||
|
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('./Book')[]} books
|
||||||
|
* @param {import('./Feed')} feed
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {import('sequelize').Transaction} transaction
|
||||||
|
* @returns {Promise<FeedEpisode[]>}
|
||||||
|
*/
|
||||||
|
static async createFromBooks(books, feed, slug, transaction) {
|
||||||
|
const earliestLibraryItemCreatedAt = books.reduce((earliest, book) => {
|
||||||
|
return book.libraryItem.createdAt < earliest.libraryItem.createdAt ? book : earliest
|
||||||
|
}).libraryItem.createdAt
|
||||||
|
|
||||||
|
const feedEpisodeObjs = []
|
||||||
|
for (const book of books) {
|
||||||
|
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book)
|
||||||
|
for (const track of book.trackList) {
|
||||||
|
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
|
||||||
|
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -136,6 +250,60 @@ class FeedEpisode extends Model {
|
|||||||
})
|
})
|
||||||
FeedEpisode.belongsTo(feed)
|
FeedEpisode.belongsTo(feed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOldEpisode() {
|
||||||
|
const enclosure = {
|
||||||
|
url: this.enclosureURL,
|
||||||
|
size: this.enclosureSize,
|
||||||
|
type: this.enclosureType
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
title: this.title,
|
||||||
|
description: this.description,
|
||||||
|
enclosure,
|
||||||
|
pubDate: this.pubDate,
|
||||||
|
link: this.siteURL,
|
||||||
|
author: this.author,
|
||||||
|
explicit: this.explicit,
|
||||||
|
duration: this.duration,
|
||||||
|
season: this.season,
|
||||||
|
episode: this.episode,
|
||||||
|
episodeType: this.episodeType,
|
||||||
|
fullPath: this.filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} hostPrefix
|
||||||
|
*/
|
||||||
|
getRSSData(hostPrefix) {
|
||||||
|
return {
|
||||||
|
title: this.title,
|
||||||
|
description: this.description || '',
|
||||||
|
url: `${hostPrefix}${this.siteURL}`,
|
||||||
|
guid: `${hostPrefix}${this.enclosureURL}`,
|
||||||
|
author: this.author,
|
||||||
|
date: this.pubDate,
|
||||||
|
enclosure: {
|
||||||
|
url: `${hostPrefix}${this.enclosureURL}`,
|
||||||
|
type: this.enclosureType,
|
||||||
|
size: this.enclosureSize
|
||||||
|
},
|
||||||
|
custom_elements: [
|
||||||
|
{ 'itunes:author': this.author },
|
||||||
|
{ 'itunes:duration': secondsToTimestamp(this.duration) },
|
||||||
|
{ 'itunes:summary': this.description || '' },
|
||||||
|
{
|
||||||
|
'itunes:explicit': !!this.explicit
|
||||||
|
},
|
||||||
|
{ 'itunes:episodeType': this.episodeType },
|
||||||
|
{ 'itunes:season': this.season },
|
||||||
|
{ 'itunes:episode': this.episode }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = FeedEpisode
|
module.exports = FeedEpisode
|
||||||
|
@ -73,6 +73,9 @@ class LibraryItem extends Model {
|
|||||||
this.createdAt
|
this.createdAt
|
||||||
/** @type {Date} */
|
/** @type {Date} */
|
||||||
this.updatedAt
|
this.updatedAt
|
||||||
|
|
||||||
|
/** @type {Book.BookExpanded|Podcast.PodcastExpanded} - only set when expanded */
|
||||||
|
this.media
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -565,7 +568,7 @@ class LibraryItem extends Model {
|
|||||||
oldLibraryItem.media.metadata.series = li.series
|
oldLibraryItem.media.metadata.series = li.series
|
||||||
}
|
}
|
||||||
if (li.rssFeed) {
|
if (li.rssFeed) {
|
||||||
oldLibraryItem.rssFeed = this.sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
|
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||||
}
|
}
|
||||||
if (li.media.numEpisodes) {
|
if (li.media.numEpisodes) {
|
||||||
oldLibraryItem.media.numEpisodes = li.media.numEpisodes
|
oldLibraryItem.media.numEpisodes = li.media.numEpisodes
|
||||||
@ -1124,6 +1127,24 @@ class LibraryItem extends Model {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if book or podcast library item has audio tracks
|
||||||
|
* Requires expanded library item
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
hasAudioTracks() {
|
||||||
|
if (!this.media) {
|
||||||
|
Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this.mediaType === 'book') {
|
||||||
|
return this.media.audioFiles?.length > 0
|
||||||
|
} else {
|
||||||
|
return this.media.podcastEpisodes?.length > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = LibraryItem
|
module.exports = LibraryItem
|
||||||
|
@ -84,13 +84,6 @@ class Playlist extends Model {
|
|||||||
|
|
||||||
const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems)
|
const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems)
|
||||||
|
|
||||||
if (include?.includes('rssfeed')) {
|
|
||||||
const feeds = await this.getFeeds()
|
|
||||||
if (feeds?.length) {
|
|
||||||
playlistExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return playlistExpanded
|
return playlistExpanded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
const { DataTypes, Model, where, fn, col } = require('sequelize')
|
const { DataTypes, Model, where, fn, col, literal } = require('sequelize')
|
||||||
|
|
||||||
const { getTitlePrefixAtEnd } = require('../utils/index')
|
const { getTitlePrefixAtEnd } = require('../utils/index')
|
||||||
|
|
||||||
@ -20,6 +20,11 @@ class Series extends Model {
|
|||||||
this.createdAt
|
this.createdAt
|
||||||
/** @type {Date} */
|
/** @type {Date} */
|
||||||
this.updatedAt
|
this.updatedAt
|
||||||
|
|
||||||
|
// Expanded properties
|
||||||
|
|
||||||
|
/** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */
|
||||||
|
this.books
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -49,6 +54,18 @@ class Series extends Model {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} seriesId
|
||||||
|
* @returns {Promise<Series>}
|
||||||
|
*/
|
||||||
|
static async getExpandedById(seriesId) {
|
||||||
|
const series = await this.findByPk(seriesId)
|
||||||
|
if (!series) return null
|
||||||
|
series.books = await series.getBooksExpandedWithLibraryItem()
|
||||||
|
return series
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize model
|
* Initialize model
|
||||||
* @param {import('../Database').sequelize} sequelize
|
* @param {import('../Database').sequelize} sequelize
|
||||||
@ -103,6 +120,35 @@ class Series extends Model {
|
|||||||
Series.belongsTo(library)
|
Series.belongsTo(library)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all books in collection expanded with library item
|
||||||
|
*
|
||||||
|
* @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}
|
||||||
|
*/
|
||||||
|
getBooksExpandedWithLibraryItem() {
|
||||||
|
return this.getBooks({
|
||||||
|
joinTableAttributes: ['sequence'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.libraryItem
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.author,
|
||||||
|
through: {
|
||||||
|
attributes: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.series,
|
||||||
|
through: {
|
||||||
|
attributes: ['sequence']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [[literal('CAST(`bookSeries.sequence` AS FLOAT) ASC NULLS LAST')]]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
toOldJSON() {
|
toOldJSON() {
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
|
@ -1,418 +0,0 @@
|
|||||||
const Path = require('path')
|
|
||||||
const uuidv4 = require('uuid').v4
|
|
||||||
const FeedMeta = require('./FeedMeta')
|
|
||||||
const FeedEpisode = require('./FeedEpisode')
|
|
||||||
|
|
||||||
const date = require('../libs/dateAndTime')
|
|
||||||
const RSS = require('../libs/rss')
|
|
||||||
const { createNewSortInstance } = require('../libs/fastSort')
|
|
||||||
const naturalSort = createNewSortInstance({
|
|
||||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
|
||||||
})
|
|
||||||
|
|
||||||
class Feed {
|
|
||||||
constructor(feed) {
|
|
||||||
this.id = null
|
|
||||||
this.slug = null
|
|
||||||
this.userId = null
|
|
||||||
this.entityType = null
|
|
||||||
this.entityId = null
|
|
||||||
this.entityUpdatedAt = null
|
|
||||||
|
|
||||||
this.coverPath = null
|
|
||||||
this.serverAddress = null
|
|
||||||
this.feedUrl = null
|
|
||||||
|
|
||||||
this.meta = null
|
|
||||||
this.episodes = null
|
|
||||||
|
|
||||||
this.createdAt = null
|
|
||||||
this.updatedAt = null
|
|
||||||
|
|
||||||
if (feed) {
|
|
||||||
this.construct(feed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
construct(feed) {
|
|
||||||
this.id = feed.id
|
|
||||||
this.slug = feed.slug
|
|
||||||
this.userId = feed.userId
|
|
||||||
this.entityType = feed.entityType
|
|
||||||
this.entityId = feed.entityId
|
|
||||||
this.entityUpdatedAt = feed.entityUpdatedAt
|
|
||||||
this.coverPath = feed.coverPath
|
|
||||||
this.serverAddress = feed.serverAddress
|
|
||||||
this.feedUrl = feed.feedUrl
|
|
||||||
this.meta = new FeedMeta(feed.meta)
|
|
||||||
this.episodes = feed.episodes.map((ep) => new FeedEpisode(ep))
|
|
||||||
this.createdAt = feed.createdAt
|
|
||||||
this.updatedAt = feed.updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
slug: this.slug,
|
|
||||||
userId: this.userId,
|
|
||||||
entityType: this.entityType,
|
|
||||||
entityId: this.entityId,
|
|
||||||
coverPath: this.coverPath,
|
|
||||||
serverAddress: this.serverAddress,
|
|
||||||
feedUrl: this.feedUrl,
|
|
||||||
meta: this.meta.toJSON(),
|
|
||||||
episodes: this.episodes.map((ep) => ep.toJSON()),
|
|
||||||
createdAt: this.createdAt,
|
|
||||||
updatedAt: this.updatedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONMinified() {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
entityType: this.entityType,
|
|
||||||
entityId: this.entityId,
|
|
||||||
feedUrl: this.feedUrl,
|
|
||||||
meta: this.meta.toJSONMinified()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getEpisodePath(id) {
|
|
||||||
var episode = this.episodes.find((ep) => ep.id === id)
|
|
||||||
if (!episode) return null
|
|
||||||
return episode.fullPath
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names
|
|
||||||
*
|
|
||||||
* @param {import('../objects/LibraryItem')} libraryItem
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
checkUseChapterTitlesForEpisodes(libraryItem) {
|
|
||||||
const tracks = libraryItem.media.tracks
|
|
||||||
const chapters = libraryItem.media.chapters
|
|
||||||
if (tracks.length !== chapters.length) return false
|
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
|
||||||
if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
|
|
||||||
const media = libraryItem.media
|
|
||||||
const mediaMetadata = media.metadata
|
|
||||||
const isPodcast = libraryItem.mediaType === 'podcast'
|
|
||||||
|
|
||||||
const feedUrl = `/feed/${slug}`
|
|
||||||
const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName
|
|
||||||
|
|
||||||
this.id = uuidv4()
|
|
||||||
this.slug = slug
|
|
||||||
this.userId = userId
|
|
||||||
this.entityType = 'libraryItem'
|
|
||||||
this.entityId = libraryItem.id
|
|
||||||
this.entityUpdatedAt = libraryItem.updatedAt
|
|
||||||
this.coverPath = media.coverPath || null
|
|
||||||
this.serverAddress = serverAddress
|
|
||||||
this.feedUrl = feedUrl
|
|
||||||
|
|
||||||
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
|
|
||||||
|
|
||||||
this.meta = new FeedMeta()
|
|
||||||
this.meta.title = mediaMetadata.title
|
|
||||||
this.meta.description = mediaMetadata.description
|
|
||||||
this.meta.author = author
|
|
||||||
this.meta.imageUrl = media.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png`
|
|
||||||
this.meta.feedUrl = feedUrl
|
|
||||||
this.meta.link = `/item/${libraryItem.id}`
|
|
||||||
this.meta.explicit = !!mediaMetadata.explicit
|
|
||||||
this.meta.type = mediaMetadata.type
|
|
||||||
this.meta.language = mediaMetadata.language
|
|
||||||
this.meta.preventIndexing = preventIndexing
|
|
||||||
this.meta.ownerName = ownerName
|
|
||||||
this.meta.ownerEmail = ownerEmail
|
|
||||||
|
|
||||||
this.episodes = []
|
|
||||||
if (isPodcast) {
|
|
||||||
// PODCAST EPISODES
|
|
||||||
media.episodes.forEach((episode) => {
|
|
||||||
if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt
|
|
||||||
|
|
||||||
const feedEpisode = new FeedEpisode()
|
|
||||||
feedEpisode.setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, this.meta)
|
|
||||||
this.episodes.push(feedEpisode)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// AUDIOBOOK EPISODES
|
|
||||||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItem)
|
|
||||||
media.tracks.forEach((audioTrack) => {
|
|
||||||
const feedEpisode = new FeedEpisode()
|
|
||||||
feedEpisode.setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, this.meta, useChapterTitles)
|
|
||||||
this.episodes.push(feedEpisode)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.createdAt = Date.now()
|
|
||||||
this.updatedAt = Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFromItem(libraryItem) {
|
|
||||||
const media = libraryItem.media
|
|
||||||
const mediaMetadata = media.metadata
|
|
||||||
const isPodcast = libraryItem.mediaType === 'podcast'
|
|
||||||
const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName
|
|
||||||
|
|
||||||
this.entityUpdatedAt = libraryItem.updatedAt
|
|
||||||
this.coverPath = media.coverPath || null
|
|
||||||
|
|
||||||
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
|
|
||||||
|
|
||||||
this.meta.title = mediaMetadata.title
|
|
||||||
this.meta.description = mediaMetadata.description
|
|
||||||
this.meta.author = author
|
|
||||||
this.meta.imageUrl = media.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png`
|
|
||||||
this.meta.explicit = !!mediaMetadata.explicit
|
|
||||||
this.meta.type = mediaMetadata.type
|
|
||||||
this.meta.language = mediaMetadata.language
|
|
||||||
|
|
||||||
this.episodes = []
|
|
||||||
if (isPodcast) {
|
|
||||||
// PODCAST EPISODES
|
|
||||||
media.episodes.forEach((episode) => {
|
|
||||||
if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt
|
|
||||||
|
|
||||||
const feedEpisode = new FeedEpisode()
|
|
||||||
feedEpisode.setFromPodcastEpisode(libraryItem, this.serverAddress, this.slug, episode, this.meta)
|
|
||||||
this.episodes.push(feedEpisode)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// AUDIOBOOK EPISODES
|
|
||||||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItem)
|
|
||||||
media.tracks.forEach((audioTrack) => {
|
|
||||||
const feedEpisode = new FeedEpisode()
|
|
||||||
feedEpisode.setFromAudiobookTrack(libraryItem, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles)
|
|
||||||
this.episodes.push(feedEpisode)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updatedAt = Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
|
|
||||||
const feedUrl = `/feed/${slug}`
|
|
||||||
|
|
||||||
const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
|
|
||||||
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
|
|
||||||
|
|
||||||
this.id = uuidv4()
|
|
||||||
this.slug = slug
|
|
||||||
this.userId = userId
|
|
||||||
this.entityType = 'collection'
|
|
||||||
this.entityId = collectionExpanded.id
|
|
||||||
this.entityUpdatedAt = collectionExpanded.lastUpdate // This will be set to the most recently updated library item
|
|
||||||
this.coverPath = firstItemWithCover?.media.coverPath || null
|
|
||||||
this.serverAddress = serverAddress
|
|
||||||
this.feedUrl = feedUrl
|
|
||||||
|
|
||||||
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
|
|
||||||
|
|
||||||
this.meta = new FeedMeta()
|
|
||||||
this.meta.title = collectionExpanded.name
|
|
||||||
this.meta.description = collectionExpanded.description || ''
|
|
||||||
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
|
|
||||||
this.meta.imageUrl = this.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png`
|
|
||||||
this.meta.feedUrl = feedUrl
|
|
||||||
this.meta.link = `/collection/${collectionExpanded.id}`
|
|
||||||
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
|
|
||||||
this.meta.preventIndexing = preventIndexing
|
|
||||||
this.meta.ownerName = ownerName
|
|
||||||
this.meta.ownerEmail = ownerEmail
|
|
||||||
|
|
||||||
this.episodes = []
|
|
||||||
|
|
||||||
// Used for calculating pubdate
|
|
||||||
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
|
|
||||||
|
|
||||||
itemsWithTracks.forEach((item, index) => {
|
|
||||||
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
|
|
||||||
|
|
||||||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
|
|
||||||
item.media.tracks.forEach((audioTrack) => {
|
|
||||||
const feedEpisode = new FeedEpisode()
|
|
||||||
|
|
||||||
// Offset pubdate to ensure correct order
|
|
||||||
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
|
|
||||||
trackTimeOffset += index * 1000 // Offset item
|
|
||||||
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
|
||||||
feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
|
|
||||||
this.episodes.push(feedEpisode)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
this.createdAt = Date.now()
|
|
||||||
this.updatedAt = Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFromCollection(collectionExpanded) {
|
|
||||||
const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
|
|
||||||
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
|
|
||||||
|
|
||||||
this.entityUpdatedAt = collectionExpanded.lastUpdate
|
|
||||||
this.coverPath = firstItemWithCover?.media.coverPath || null
|
|
||||||
|
|
||||||
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
|
|
||||||
|
|
||||||
this.meta.title = collectionExpanded.name
|
|
||||||
this.meta.description = collectionExpanded.description || ''
|
|
||||||
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
|
|
||||||
this.meta.imageUrl = this.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png`
|
|
||||||
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
|
|
||||||
|
|
||||||
this.episodes = []
|
|
||||||
|
|
||||||
// Used for calculating pubdate
|
|
||||||
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
|
|
||||||
|
|
||||||
itemsWithTracks.forEach((item, index) => {
|
|
||||||
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
|
|
||||||
|
|
||||||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
|
|
||||||
item.media.tracks.forEach((audioTrack) => {
|
|
||||||
const feedEpisode = new FeedEpisode()
|
|
||||||
|
|
||||||
// Offset pubdate to ensure correct order
|
|
||||||
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
|
|
||||||
trackTimeOffset += index * 1000 // Offset item
|
|
||||||
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
|
||||||
feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
|
|
||||||
this.episodes.push(feedEpisode)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
this.updatedAt = Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
|
|
||||||
const feedUrl = `/feed/${slug}`
|
|
||||||
|
|
||||||
let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
|
|
||||||
// Sort series items by series sequence
|
|
||||||
itemsWithTracks = naturalSort(itemsWithTracks).asc((li) => li.media.metadata.getSeriesSequence(seriesExpanded.id))
|
|
||||||
|
|
||||||
const libraryId = itemsWithTracks[0].libraryId
|
|
||||||
const firstItemWithCover = itemsWithTracks.find((li) => li.media.coverPath)
|
|
||||||
|
|
||||||
this.id = uuidv4()
|
|
||||||
this.slug = slug
|
|
||||||
this.userId = userId
|
|
||||||
this.entityType = 'series'
|
|
||||||
this.entityId = seriesExpanded.id
|
|
||||||
this.entityUpdatedAt = seriesExpanded.updatedAt // This will be set to the most recently updated library item
|
|
||||||
this.coverPath = firstItemWithCover?.media.coverPath || null
|
|
||||||
this.serverAddress = serverAddress
|
|
||||||
this.feedUrl = feedUrl
|
|
||||||
|
|
||||||
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
|
|
||||||
|
|
||||||
this.meta = new FeedMeta()
|
|
||||||
this.meta.title = seriesExpanded.name
|
|
||||||
this.meta.description = seriesExpanded.description || ''
|
|
||||||
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
|
|
||||||
this.meta.imageUrl = this.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png`
|
|
||||||
this.meta.feedUrl = feedUrl
|
|
||||||
this.meta.link = `/library/${libraryId}/series/${seriesExpanded.id}`
|
|
||||||
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
|
|
||||||
this.meta.preventIndexing = preventIndexing
|
|
||||||
this.meta.ownerName = ownerName
|
|
||||||
this.meta.ownerEmail = ownerEmail
|
|
||||||
|
|
||||||
this.episodes = []
|
|
||||||
|
|
||||||
// Used for calculating pubdate
|
|
||||||
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
|
|
||||||
|
|
||||||
itemsWithTracks.forEach((item, index) => {
|
|
||||||
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
|
|
||||||
|
|
||||||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
|
|
||||||
item.media.tracks.forEach((audioTrack) => {
|
|
||||||
const feedEpisode = new FeedEpisode()
|
|
||||||
|
|
||||||
// Offset pubdate to ensure correct order
|
|
||||||
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
|
|
||||||
trackTimeOffset += index * 1000 // Offset item
|
|
||||||
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
|
||||||
feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
|
|
||||||
this.episodes.push(feedEpisode)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
this.createdAt = Date.now()
|
|
||||||
this.updatedAt = Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFromSeries(seriesExpanded) {
|
|
||||||
let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
|
|
||||||
// Sort series items by series sequence
|
|
||||||
itemsWithTracks = naturalSort(itemsWithTracks).asc((li) => li.media.metadata.getSeriesSequence(seriesExpanded.id))
|
|
||||||
|
|
||||||
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
|
|
||||||
|
|
||||||
this.entityUpdatedAt = seriesExpanded.updatedAt
|
|
||||||
this.coverPath = firstItemWithCover?.media.coverPath || null
|
|
||||||
|
|
||||||
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
|
|
||||||
|
|
||||||
this.meta.title = seriesExpanded.name
|
|
||||||
this.meta.description = seriesExpanded.description || ''
|
|
||||||
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
|
|
||||||
this.meta.imageUrl = this.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png`
|
|
||||||
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
|
|
||||||
|
|
||||||
this.episodes = []
|
|
||||||
|
|
||||||
// Used for calculating pubdate
|
|
||||||
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
|
|
||||||
|
|
||||||
itemsWithTracks.forEach((item, index) => {
|
|
||||||
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
|
|
||||||
|
|
||||||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
|
|
||||||
item.media.tracks.forEach((audioTrack) => {
|
|
||||||
const feedEpisode = new FeedEpisode()
|
|
||||||
|
|
||||||
// Offset pubdate to ensure correct order
|
|
||||||
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
|
|
||||||
trackTimeOffset += index * 1000 // Offset item
|
|
||||||
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
|
||||||
feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
|
|
||||||
this.episodes.push(feedEpisode)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
this.updatedAt = Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
buildXml(originalHostPrefix) {
|
|
||||||
var rssfeed = new RSS(this.meta.getRSSData(originalHostPrefix))
|
|
||||||
this.episodes.forEach((ep) => {
|
|
||||||
rssfeed.item(ep.getRSSData(originalHostPrefix))
|
|
||||||
})
|
|
||||||
return rssfeed.xml()
|
|
||||||
}
|
|
||||||
|
|
||||||
getAuthorsStringFromLibraryItems(libraryItems) {
|
|
||||||
let itemAuthors = []
|
|
||||||
libraryItems.forEach((item) => itemAuthors.push(...item.media.metadata.authors.map((au) => au.name)))
|
|
||||||
itemAuthors = [...new Set(itemAuthors)] // Filter out dupes
|
|
||||||
let author = itemAuthors.slice(0, 3).join(', ')
|
|
||||||
if (itemAuthors.length > 3) {
|
|
||||||
author += ' & more'
|
|
||||||
}
|
|
||||||
return author
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = Feed
|
|
@ -1,181 +0,0 @@
|
|||||||
const Path = require('path')
|
|
||||||
const uuidv4 = require('uuid').v4
|
|
||||||
const date = require('../libs/dateAndTime')
|
|
||||||
const { secondsToTimestamp } = require('../utils/index')
|
|
||||||
|
|
||||||
class FeedEpisode {
|
|
||||||
constructor(episode) {
|
|
||||||
this.id = null
|
|
||||||
|
|
||||||
this.title = null
|
|
||||||
this.description = null
|
|
||||||
this.enclosure = null
|
|
||||||
this.pubDate = null
|
|
||||||
this.link = null
|
|
||||||
this.author = null
|
|
||||||
this.explicit = null
|
|
||||||
this.duration = null
|
|
||||||
this.season = null
|
|
||||||
this.episode = null
|
|
||||||
this.episodeType = null
|
|
||||||
|
|
||||||
this.libraryItemId = null
|
|
||||||
this.episodeId = null
|
|
||||||
this.trackIndex = null
|
|
||||||
this.fullPath = null
|
|
||||||
|
|
||||||
if (episode) {
|
|
||||||
this.construct(episode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
construct(episode) {
|
|
||||||
this.id = episode.id
|
|
||||||
this.title = episode.title
|
|
||||||
this.description = episode.description
|
|
||||||
this.enclosure = episode.enclosure ? { ...episode.enclosure } : null
|
|
||||||
this.pubDate = episode.pubDate
|
|
||||||
this.link = episode.link
|
|
||||||
this.author = episode.author
|
|
||||||
this.explicit = episode.explicit
|
|
||||||
this.duration = episode.duration
|
|
||||||
this.season = episode.season
|
|
||||||
this.episode = episode.episode
|
|
||||||
this.episodeType = episode.episodeType
|
|
||||||
this.libraryItemId = episode.libraryItemId
|
|
||||||
this.episodeId = episode.episodeId || null
|
|
||||||
this.trackIndex = episode.trackIndex || 0
|
|
||||||
this.fullPath = episode.fullPath
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
title: this.title,
|
|
||||||
description: this.description,
|
|
||||||
enclosure: this.enclosure ? { ...this.enclosure } : null,
|
|
||||||
pubDate: this.pubDate,
|
|
||||||
link: this.link,
|
|
||||||
author: this.author,
|
|
||||||
explicit: this.explicit,
|
|
||||||
duration: this.duration,
|
|
||||||
season: this.season,
|
|
||||||
episode: this.episode,
|
|
||||||
episodeType: this.episodeType,
|
|
||||||
libraryItemId: this.libraryItemId,
|
|
||||||
episodeId: this.episodeId,
|
|
||||||
trackIndex: this.trackIndex,
|
|
||||||
fullPath: this.fullPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, meta) {
|
|
||||||
const contentFileExtension = Path.extname(episode.audioFile.metadata.filename)
|
|
||||||
const contentUrl = `/feed/${slug}/item/${episode.id}/media${contentFileExtension}`
|
|
||||||
const media = libraryItem.media
|
|
||||||
const mediaMetadata = media.metadata
|
|
||||||
|
|
||||||
this.id = episode.id
|
|
||||||
this.title = episode.title
|
|
||||||
this.description = episode.description || ''
|
|
||||||
this.enclosure = {
|
|
||||||
url: `${contentUrl}`,
|
|
||||||
type: episode.audioTrack.mimeType,
|
|
||||||
size: episode.size
|
|
||||||
}
|
|
||||||
this.pubDate = episode.pubDate
|
|
||||||
this.link = meta.link
|
|
||||||
this.author = meta.author
|
|
||||||
this.explicit = mediaMetadata.explicit
|
|
||||||
this.duration = episode.duration
|
|
||||||
this.season = episode.season
|
|
||||||
this.episode = episode.episode
|
|
||||||
this.episodeType = episode.episodeType
|
|
||||||
this.libraryItemId = libraryItem.id
|
|
||||||
this.episodeId = episode.id
|
|
||||||
this.trackIndex = 0
|
|
||||||
this.fullPath = episode.audioFile.metadata.path
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {import('../objects/LibraryItem')} libraryItem
|
|
||||||
* @param {string} serverAddress
|
|
||||||
* @param {string} slug
|
|
||||||
* @param {import('../objects/files/AudioTrack')} audioTrack
|
|
||||||
* @param {Object} meta
|
|
||||||
* @param {boolean} useChapterTitles
|
|
||||||
* @param {string} [pubDateOverride] Used for series & collections to ensure correct episode order
|
|
||||||
*/
|
|
||||||
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, useChapterTitles, pubDateOverride = null) {
|
|
||||||
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
|
|
||||||
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order
|
|
||||||
let episodeId = uuidv4()
|
|
||||||
|
|
||||||
// e.g. Track 1 will have a pub date before Track 2
|
|
||||||
const audiobookPubDate = pubDateOverride || date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
|
||||||
|
|
||||||
const contentFileExtension = Path.extname(audioTrack.metadata.filename)
|
|
||||||
const contentUrl = `/feed/${slug}/item/${episodeId}/media${contentFileExtension}`
|
|
||||||
const media = libraryItem.media
|
|
||||||
const mediaMetadata = media.metadata
|
|
||||||
|
|
||||||
let title = audioTrack.title
|
|
||||||
if (libraryItem.media.tracks.length == 1) {
|
|
||||||
// If audiobook is a single file, use book title instead of chapter/file title
|
|
||||||
title = libraryItem.media.metadata.title
|
|
||||||
} else {
|
|
||||||
if (useChapterTitles) {
|
|
||||||
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
|
|
||||||
const matchingChapter = libraryItem.media.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1)
|
|
||||||
if (matchingChapter?.title) title = matchingChapter.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.id = episodeId
|
|
||||||
this.title = title
|
|
||||||
this.description = mediaMetadata.description || ''
|
|
||||||
this.enclosure = {
|
|
||||||
url: `${contentUrl}`,
|
|
||||||
type: audioTrack.mimeType,
|
|
||||||
size: audioTrack.metadata.size
|
|
||||||
}
|
|
||||||
this.pubDate = audiobookPubDate
|
|
||||||
this.link = meta.link
|
|
||||||
this.author = meta.author
|
|
||||||
this.explicit = mediaMetadata.explicit
|
|
||||||
this.duration = audioTrack.duration
|
|
||||||
this.libraryItemId = libraryItem.id
|
|
||||||
this.episodeId = null
|
|
||||||
this.trackIndex = audioTrack.index
|
|
||||||
this.fullPath = audioTrack.metadata.path
|
|
||||||
}
|
|
||||||
|
|
||||||
getRSSData(hostPrefix) {
|
|
||||||
return {
|
|
||||||
title: this.title,
|
|
||||||
description: this.description || '',
|
|
||||||
url: `${hostPrefix}${this.link}`,
|
|
||||||
guid: `${hostPrefix}${this.enclosure.url}`,
|
|
||||||
author: this.author,
|
|
||||||
date: this.pubDate,
|
|
||||||
enclosure: {
|
|
||||||
url: `${hostPrefix}${this.enclosure.url}`,
|
|
||||||
type: this.enclosure.type,
|
|
||||||
size: this.enclosure.size
|
|
||||||
},
|
|
||||||
custom_elements: [
|
|
||||||
{ 'itunes:author': this.author },
|
|
||||||
{ 'itunes:duration': secondsToTimestamp(this.duration) },
|
|
||||||
{ 'itunes:summary': this.description || '' },
|
|
||||||
{
|
|
||||||
'itunes:explicit': !!this.explicit
|
|
||||||
},
|
|
||||||
{ 'itunes:episodeType': this.episodeType },
|
|
||||||
{ 'itunes:season': this.season },
|
|
||||||
{ 'itunes:episode': this.episode }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = FeedEpisode
|
|
@ -1,100 +0,0 @@
|
|||||||
class FeedMeta {
|
|
||||||
constructor(meta) {
|
|
||||||
this.title = null
|
|
||||||
this.description = null
|
|
||||||
this.author = null
|
|
||||||
this.imageUrl = null
|
|
||||||
this.feedUrl = null
|
|
||||||
this.link = null
|
|
||||||
this.explicit = null
|
|
||||||
this.type = null
|
|
||||||
this.language = null
|
|
||||||
this.preventIndexing = null
|
|
||||||
this.ownerName = null
|
|
||||||
this.ownerEmail = null
|
|
||||||
|
|
||||||
if (meta) {
|
|
||||||
this.construct(meta)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
construct(meta) {
|
|
||||||
this.title = meta.title
|
|
||||||
this.description = meta.description
|
|
||||||
this.author = meta.author
|
|
||||||
this.imageUrl = meta.imageUrl
|
|
||||||
this.feedUrl = meta.feedUrl
|
|
||||||
this.link = meta.link
|
|
||||||
this.explicit = meta.explicit
|
|
||||||
this.type = meta.type
|
|
||||||
this.language = meta.language
|
|
||||||
this.preventIndexing = meta.preventIndexing
|
|
||||||
this.ownerName = meta.ownerName
|
|
||||||
this.ownerEmail = meta.ownerEmail
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
title: this.title,
|
|
||||||
description: this.description,
|
|
||||||
author: this.author,
|
|
||||||
imageUrl: this.imageUrl,
|
|
||||||
feedUrl: this.feedUrl,
|
|
||||||
link: this.link,
|
|
||||||
explicit: this.explicit,
|
|
||||||
type: this.type,
|
|
||||||
language: this.language,
|
|
||||||
preventIndexing: this.preventIndexing,
|
|
||||||
ownerName: this.ownerName,
|
|
||||||
ownerEmail: this.ownerEmail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSONMinified() {
|
|
||||||
return {
|
|
||||||
title: this.title,
|
|
||||||
description: this.description,
|
|
||||||
preventIndexing: this.preventIndexing,
|
|
||||||
ownerName: this.ownerName,
|
|
||||||
ownerEmail: this.ownerEmail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getRSSData(hostPrefix) {
|
|
||||||
const blockTags = [{ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }]
|
|
||||||
return {
|
|
||||||
title: this.title,
|
|
||||||
description: this.description || '',
|
|
||||||
generator: 'Audiobookshelf',
|
|
||||||
feed_url: `${hostPrefix}${this.feedUrl}`,
|
|
||||||
site_url: `${hostPrefix}${this.link}`,
|
|
||||||
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.type },
|
|
||||||
{
|
|
||||||
'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 : [])
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = FeedMeta
|
|
@ -53,6 +53,20 @@ class PodcastEpisodeDownload {
|
|||||||
if (globals.SupportedAudioTypes.includes(extname)) return extname
|
if (globals.SupportedAudioTypes.includes(extname)) return extname
|
||||||
return 'mp3'
|
return 'mp3'
|
||||||
}
|
}
|
||||||
|
get enclosureType() {
|
||||||
|
const enclosureType = this.podcastEpisode?.enclosure?.type
|
||||||
|
return typeof enclosureType === 'string' ? enclosureType : null
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* RSS feed may have an episode with file extension of mp3 but the specified enclosure type is not mpeg.
|
||||||
|
* @see https://github.com/advplyr/audiobookshelf/issues/3711
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
get isMp3() {
|
||||||
|
if (this.enclosureType && !this.enclosureType.includes('mpeg')) return false
|
||||||
|
return this.fileExtension === 'mp3'
|
||||||
|
}
|
||||||
|
|
||||||
get targetFilename() {
|
get targetFilename() {
|
||||||
const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : ''
|
const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : ''
|
||||||
|
@ -10,6 +10,7 @@ const fs = require('../libs/fsExtra')
|
|||||||
const date = require('../libs/dateAndTime')
|
const date = require('../libs/dateAndTime')
|
||||||
|
|
||||||
const CacheManager = require('../managers/CacheManager')
|
const CacheManager = require('../managers/CacheManager')
|
||||||
|
const RssFeedManager = require('../managers/RssFeedManager')
|
||||||
|
|
||||||
const LibraryController = require('../controllers/LibraryController')
|
const LibraryController = require('../controllers/LibraryController')
|
||||||
const UserController = require('../controllers/UserController')
|
const UserController = require('../controllers/UserController')
|
||||||
@ -49,8 +50,6 @@ class ApiRouter {
|
|||||||
this.podcastManager = Server.podcastManager
|
this.podcastManager = Server.podcastManager
|
||||||
/** @type {import('../managers/AudioMetadataManager')} */
|
/** @type {import('../managers/AudioMetadataManager')} */
|
||||||
this.audioMetadataManager = Server.audioMetadataManager
|
this.audioMetadataManager = Server.audioMetadataManager
|
||||||
/** @type {import('../managers/RssFeedManager')} */
|
|
||||||
this.rssFeedManager = Server.rssFeedManager
|
|
||||||
/** @type {import('../managers/CronManager')} */
|
/** @type {import('../managers/CronManager')} */
|
||||||
this.cronManager = Server.cronManager
|
this.cronManager = Server.cronManager
|
||||||
/** @type {import('../managers/EmailManager')} */
|
/** @type {import('../managers/EmailManager')} */
|
||||||
@ -394,7 +393,7 @@ class ApiRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Close rss feed - remove from db and emit socket event
|
// Close rss feed - remove from db and emit socket event
|
||||||
await this.rssFeedManager.closeFeedForEntityId(libraryItemId)
|
await RssFeedManager.closeFeedForEntityId(libraryItemId)
|
||||||
|
|
||||||
// purge cover cache
|
// purge cover cache
|
||||||
await CacheManager.purgeCoverCache(libraryItemId)
|
await CacheManager.purgeCoverCache(libraryItemId)
|
||||||
@ -493,7 +492,7 @@ class ApiRouter {
|
|||||||
* @param {import('../models/Series')} series
|
* @param {import('../models/Series')} series
|
||||||
*/
|
*/
|
||||||
async removeEmptySeries(series) {
|
async removeEmptySeries(series) {
|
||||||
await this.rssFeedManager.closeFeedForEntityId(series.id)
|
await RssFeedManager.closeFeedForEntityId(series.id)
|
||||||
Logger.info(`[ApiRouter] Series "${series.name}" is now empty. Removing series`)
|
Logger.info(`[ApiRouter] Series "${series.name}" is now empty. Removing series`)
|
||||||
|
|
||||||
// Remove series from library filter data
|
// Remove series from library filter data
|
||||||
|
@ -6,21 +6,24 @@ const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index')
|
|||||||
const parseNameString = require('../utils/parsers/parseNameString')
|
const parseNameString = require('../utils/parsers/parseNameString')
|
||||||
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
|
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
|
||||||
const globals = require('../utils/globals')
|
const globals = require('../utils/globals')
|
||||||
|
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
||||||
|
|
||||||
const AudioFileScanner = require('./AudioFileScanner')
|
const AudioFileScanner = require('./AudioFileScanner')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
|
||||||
const AudioFile = require('../objects/files/AudioFile')
|
|
||||||
const CoverManager = require('../managers/CoverManager')
|
|
||||||
const LibraryFile = require('../objects/files/LibraryFile')
|
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
const fsExtra = require('../libs/fsExtra')
|
|
||||||
const BookFinder = require('../finders/BookFinder')
|
const BookFinder = require('../finders/BookFinder')
|
||||||
|
const fsExtra = require('../libs/fsExtra')
|
||||||
|
const EBookFile = require('../objects/files/EBookFile')
|
||||||
|
const AudioFile = require('../objects/files/AudioFile')
|
||||||
|
const LibraryFile = require('../objects/files/LibraryFile')
|
||||||
|
|
||||||
|
const RssFeedManager = require('../managers/RssFeedManager')
|
||||||
|
const CoverManager = require('../managers/CoverManager')
|
||||||
|
|
||||||
const LibraryScan = require('./LibraryScan')
|
const LibraryScan = require('./LibraryScan')
|
||||||
const OpfFileScanner = require('./OpfFileScanner')
|
const OpfFileScanner = require('./OpfFileScanner')
|
||||||
const NfoFileScanner = require('./NfoFileScanner')
|
const NfoFileScanner = require('./NfoFileScanner')
|
||||||
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
|
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
|
||||||
const EBookFile = require('../objects/files/EBookFile')
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadata for books pulled from files
|
* Metadata for books pulled from files
|
||||||
@ -941,6 +944,9 @@ class BookScanner {
|
|||||||
id: bookSeriesToRemove
|
id: bookSeriesToRemove
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// Close any open feeds for series
|
||||||
|
await RssFeedManager.closeFeedsForEntityIds(bookSeriesToRemove)
|
||||||
|
|
||||||
bookSeriesToRemove.forEach((seriesId) => {
|
bookSeriesToRemove.forEach((seriesId) => {
|
||||||
Database.removeSeriesFromFilterData(libraryId, seriesId)
|
Database.removeSeriesFromFilterData(libraryId, seriesId)
|
||||||
SocketAuthority.emitter('series_removed', { id: seriesId, libraryId })
|
SocketAuthority.emitter('series_removed', { id: seriesId, libraryId })
|
||||||
|
@ -59,8 +59,8 @@ function extractPodcastMetadata(channel) {
|
|||||||
|
|
||||||
if (channel['description']) {
|
if (channel['description']) {
|
||||||
const rawDescription = extractFirstArrayItem(channel, 'description') || ''
|
const rawDescription = extractFirstArrayItem(channel, 'description') || ''
|
||||||
metadata.description = htmlSanitizer.sanitize(rawDescription)
|
metadata.description = htmlSanitizer.sanitize(rawDescription.trim())
|
||||||
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
|
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link', 'itunes:type']
|
const arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link', 'itunes:type']
|
||||||
@ -103,8 +103,8 @@ function extractEpisodeData(item) {
|
|||||||
// Supposed to be the plaintext description but not always followed
|
// Supposed to be the plaintext description but not always followed
|
||||||
if (item['description']) {
|
if (item['description']) {
|
||||||
const rawDescription = extractFirstArrayItem(item, 'description') || ''
|
const rawDescription = extractFirstArrayItem(item, 'description') || ''
|
||||||
if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription)
|
if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription.trim())
|
||||||
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
|
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item['pubDate']) {
|
if (item['pubDate']) {
|
||||||
|
@ -54,7 +54,7 @@ module.exports = {
|
|||||||
items: libraryItems.map((li) => {
|
items: libraryItems.map((li) => {
|
||||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||||
if (li.rssFeed) {
|
if (li.rssFeed) {
|
||||||
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
|
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||||
}
|
}
|
||||||
if (li.mediaItemShare) {
|
if (li.mediaItemShare) {
|
||||||
oldLibraryItem.mediaItemShare = li.mediaItemShare
|
oldLibraryItem.mediaItemShare = li.mediaItemShare
|
||||||
@ -91,7 +91,7 @@ module.exports = {
|
|||||||
libraryItems: libraryItems.map((li) => {
|
libraryItems: libraryItems.map((li) => {
|
||||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||||
if (li.rssFeed) {
|
if (li.rssFeed) {
|
||||||
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
|
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||||
}
|
}
|
||||||
if (li.size && !oldLibraryItem.media.size) {
|
if (li.size && !oldLibraryItem.media.size) {
|
||||||
oldLibraryItem.media.size = li.size
|
oldLibraryItem.media.size = li.size
|
||||||
@ -109,7 +109,7 @@ module.exports = {
|
|||||||
libraryItems: libraryItems.map((li) => {
|
libraryItems: libraryItems.map((li) => {
|
||||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||||
if (li.rssFeed) {
|
if (li.rssFeed) {
|
||||||
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
|
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||||
}
|
}
|
||||||
if (li.size && !oldLibraryItem.media.size) {
|
if (li.size && !oldLibraryItem.media.size) {
|
||||||
oldLibraryItem.media.size = li.size
|
oldLibraryItem.media.size = li.size
|
||||||
@ -138,7 +138,7 @@ module.exports = {
|
|||||||
libraryItems: libraryItems.map((li) => {
|
libraryItems: libraryItems.map((li) => {
|
||||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||||
if (li.rssFeed) {
|
if (li.rssFeed) {
|
||||||
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
|
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||||
}
|
}
|
||||||
if (li.series) {
|
if (li.series) {
|
||||||
oldLibraryItem.media.metadata.series = li.series
|
oldLibraryItem.media.metadata.series = li.series
|
||||||
@ -168,7 +168,7 @@ module.exports = {
|
|||||||
items: libraryItems.map((li) => {
|
items: libraryItems.map((li) => {
|
||||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||||
if (li.rssFeed) {
|
if (li.rssFeed) {
|
||||||
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
|
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||||
}
|
}
|
||||||
if (li.mediaItemShare) {
|
if (li.mediaItemShare) {
|
||||||
oldLibraryItem.mediaItemShare = li.mediaItemShare
|
oldLibraryItem.mediaItemShare = li.mediaItemShare
|
||||||
@ -279,7 +279,7 @@ module.exports = {
|
|||||||
const oldSeries = s.toOldJSON()
|
const oldSeries = s.toOldJSON()
|
||||||
|
|
||||||
if (s.feeds?.length) {
|
if (s.feeds?.length) {
|
||||||
oldSeries.rssFeed = Database.feedModel.getOldFeed(s.feeds[0]).toJSONMinified()
|
oldSeries.rssFeed = s.feeds[0].toOldJSONMinified()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Sort books by sequence in query
|
// TODO: Sort books by sequence in query
|
||||||
@ -375,7 +375,7 @@ module.exports = {
|
|||||||
libraryItems: libraryItems.map((li) => {
|
libraryItems: libraryItems.map((li) => {
|
||||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
|
||||||
if (li.rssFeed) {
|
if (li.rssFeed) {
|
||||||
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
|
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||||
}
|
}
|
||||||
if (li.mediaItemShare) {
|
if (li.mediaItemShare) {
|
||||||
oldLibraryItem.mediaItemShare = li.mediaItemShare
|
oldLibraryItem.mediaItemShare = li.mediaItemShare
|
||||||
|
@ -615,8 +615,8 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItem.feeds?.length) {
|
if (bookExpanded.libraryItem.feeds?.length) {
|
||||||
libraryItem.rssFeed = libraryItem.feeds[0]
|
libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeMediaItemShare) {
|
if (includeMediaItemShare) {
|
||||||
@ -766,8 +766,8 @@ module.exports = {
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
sequence: s.bookSeries[bookIndex].sequence
|
sequence: s.bookSeries[bookIndex].sequence
|
||||||
}
|
}
|
||||||
if (libraryItem.feeds?.length) {
|
if (s.bookSeries[bookIndex].book.libraryItem.feeds?.length) {
|
||||||
libraryItem.rssFeed = libraryItem.feeds[0]
|
libraryItem.rssFeed = s.bookSeries[bookIndex].book.libraryItem.feeds[0]
|
||||||
}
|
}
|
||||||
libraryItem.media = book
|
libraryItem.media = book
|
||||||
return libraryItem
|
return libraryItem
|
||||||
@ -900,8 +900,8 @@ module.exports = {
|
|||||||
delete book.libraryItem
|
delete book.libraryItem
|
||||||
libraryItem.media = book
|
libraryItem.media = book
|
||||||
|
|
||||||
if (libraryItem.feeds?.length) {
|
if (bookExpanded.libraryItem.feeds?.length) {
|
||||||
libraryItem.rssFeed = libraryItem.feeds[0]
|
libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
return libraryItem
|
return libraryItem
|
||||||
|
@ -180,8 +180,8 @@ module.exports = {
|
|||||||
|
|
||||||
delete podcast.libraryItem
|
delete podcast.libraryItem
|
||||||
|
|
||||||
if (libraryItem.feeds?.length) {
|
if (podcastExpanded.libraryItem.feeds?.length) {
|
||||||
libraryItem.rssFeed = libraryItem.feeds[0]
|
libraryItem.rssFeed = podcastExpanded.libraryItem.feeds[0]
|
||||||
}
|
}
|
||||||
if (podcast.numEpisodesIncomplete) {
|
if (podcast.numEpisodesIncomplete) {
|
||||||
libraryItem.numEpisodesIncomplete = podcast.numEpisodesIncomplete
|
libraryItem.numEpisodesIncomplete = podcast.numEpisodesIncomplete
|
||||||
|
@ -182,7 +182,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (s.feeds?.length) {
|
if (s.feeds?.length) {
|
||||||
oldSeries.rssFeed = Database.feedModel.getOldFeed(s.feeds[0]).toJSONMinified()
|
oldSeries.rssFeed = s.feeds[0].toOldJSONMinified()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Sort books by sequence in query
|
// TODO: Sort books by sequence in query
|
||||||
|
@ -6,7 +6,6 @@ const Database = require('../../../server/Database')
|
|||||||
const ApiRouter = require('../../../server/routers/ApiRouter')
|
const ApiRouter = require('../../../server/routers/ApiRouter')
|
||||||
const LibraryItemController = require('../../../server/controllers/LibraryItemController')
|
const LibraryItemController = require('../../../server/controllers/LibraryItemController')
|
||||||
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
||||||
const RssFeedManager = require('../../../server/managers/RssFeedManager')
|
|
||||||
const Logger = require('../../../server/Logger')
|
const Logger = require('../../../server/Logger')
|
||||||
|
|
||||||
describe('LibraryItemController', () => {
|
describe('LibraryItemController', () => {
|
||||||
@ -20,8 +19,7 @@ describe('LibraryItemController', () => {
|
|||||||
await Database.buildModels()
|
await Database.buildModels()
|
||||||
|
|
||||||
apiRouter = new ApiRouter({
|
apiRouter = new ApiRouter({
|
||||||
apiCacheManager: new ApiCacheManager(),
|
apiCacheManager: new ApiCacheManager()
|
||||||
rssFeedManager: new RssFeedManager()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
sinon.stub(Logger, 'info')
|
sinon.stub(Logger, 'info')
|
||||||
|
Loading…
x
Reference in New Issue
Block a user