mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-08 03:32:43 +02:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b39268ccb0 | |||
| de8a9304d2 | |||
| f8fbd3ac8c | |||
| 369c05936b | |||
| 837a180dc1 | |||
| 302b651e7b | |||
| 4c68ad46f4 | |||
| e50bd93958 | |||
| d576625cb7 | |||
| ca2327aba3 | |||
| 9bd1f9e3d5 | |||
| c4610e6102 | |||
| 329bbea043 | |||
| e616b53877 | |||
| eab86f90a8 | |||
| f97389cb2b | |||
| c5c3aab130 | |||
| 4610e58337 | |||
| 190a1000d9 | |||
| 455b96d1ab | |||
| 8aaf62f243 | |||
| e6d754113e | |||
| 5f72e30e63 | |||
| 57906540fe | |||
| 726adbb3bf | |||
| f7b7b85673 | |||
| 5646466aa3 | |||
| b38ce41731 | |||
| a8ab8badd5 | |||
| 5eca43082e | |||
| 6fa11934be | |||
| ff7edc32a1 | |||
| 9b8e059efe | |||
| 7486d6345d | |||
| 835490a9fc | |||
| 3b4a5b8785 | |||
| 9a1c773b7a | |||
| 890b0b949e | |||
| b19e360bbb | |||
| 1ff7952074 | |||
| 259d93d882 | |||
| 14f60a593b | |||
| 7334580c8c | |||
| f467c44543 | |||
| 867354e59d | |||
| 67952cc577 | |||
| 079a15541c | |||
| 658ac04268 | |||
| cbee6d8f5e | |||
| 68413ae2f6 | |||
| 252a233282 | |||
| c35185fff7 | |||
| 9774b2cfa5 | |||
| 344890fb45 | |||
| 5fa0897ad7 | |||
| 95c80a5b18 | |||
| 0f1b64b883 | |||
| 615ed26f0f | |||
| 84803cef82 | |||
| 605bd73c11 | |||
| cc89db059b | |||
| a03146e09c | |||
| 33aa4f1952 | |||
| c03f18b90a | |||
| 0dedb09a07 | |||
| 2b5484243b | |||
| c496db7c95 | |||
| ea4d5ff665 | |||
| 468a547864 | |||
| cd9999d192 | |||
| 31e302ea59 | |||
| 1ff1ba66fd | |||
| a5457d7e22 | |||
| ddcbfd4500 | |||
| 293e530297 | |||
| 7278ad4ee7 | |||
| 0449fb5ef9 | |||
| d2c28fc69c | |||
| 60ba0163af | |||
| 02ca926d88 | |||
| 4b52f31d58 | |||
| 9917f2d358 | |||
| 8c3ba67583 | |||
| 6d8720b404 | |||
| 843dd0b1b2 | |||
| 70f466d03c | |||
| ef82e8b0d0 | |||
| c643d4cec8 | |||
| 718d8b5999 | |||
| 2ba0f9157d | |||
| 53fdb5273c | |||
| fabdfd5517 | |||
| 950993f652 | |||
| 5a968b002a | |||
| 3acd29fab3 | |||
| 315b21db00 | |||
| f9aaeb3a34 | |||
| d19bb909b3 |
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
||||||
</nuxt-link>
|
</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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -53,7 +53,6 @@
|
|||||||
@showBookmarks="showBookmarks"
|
@showBookmarks="showBookmarks"
|
||||||
@showSleepTimer="showSleepTimerModal = true"
|
@showSleepTimer="showSleepTimerModal = true"
|
||||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||||
@showPlayerSettings="showPlayerSettingsModal = true"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||||
@@ -61,8 +60,6 @@
|
|||||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||||
|
|
||||||
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
|
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
|
||||||
|
|
||||||
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -81,7 +78,6 @@ export default {
|
|||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
showSleepTimerModal: false,
|
showSleepTimerModal: false,
|
||||||
showPlayerQueueItemsModal: false,
|
showPlayerQueueItemsModal: false,
|
||||||
showPlayerSettingsModal: false,
|
|
||||||
sleepTimerSet: false,
|
sleepTimerSet: false,
|
||||||
sleepTimerRemaining: 0,
|
sleepTimerRemaining: 0,
|
||||||
sleepTimerType: null,
|
sleepTimerType: null,
|
||||||
|
|||||||
@@ -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-show="libraryItem" ref="cover" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
<img cy-id="coverImage" v-show="libraryItem" :alt="`${displayTitle}, ${$strings.LabelCover}`" ref="cover" aria-hidden="true" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||||
|
|
||||||
<!-- Placeholder Cover Title & Author -->
|
<!-- Placeholder Cover Title & Author -->
|
||||||
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
|
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
|
||||||
<div>
|
<div>
|
||||||
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
<p cy-id="placeholderTitleText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
|
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
|
||||||
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
<p cy-id="placeholderAuthorText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f">
|
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f">
|
||||||
@@ -93,11 +93,11 @@
|
|||||||
|
|
||||||
<!-- rss feed icon -->
|
<!-- rss feed icon -->
|
||||||
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
|
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
|
||||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
|
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- media item shared icon -->
|
<!-- media item shared icon -->
|
||||||
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
|
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
|
||||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">public</span>
|
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">public</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series sequence -->
|
<!-- Series sequence -->
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
<!-- Podcast Num Episodes -->
|
||||||
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
|
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodes }}</p>
|
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfEpisodes">{{ numEpisodes }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
<!-- Podcast Num Episodes -->
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
|
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
|
||||||
<div :style="{ fontSize: 0.9 + 'em' }">
|
<div :style="{ fontSize: 0.9 + 'em' }">
|
||||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||||
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
<p cy-id="title" ref="displayTitle" aria-hidden="true" class="truncate">{{ displayTitle }}</p>
|
||||||
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || ' ' }}</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)'
|
||||||
|
|||||||
@@ -59,12 +59,19 @@ export default {
|
|||||||
setJumpBackwardAmount(val) {
|
setJumpBackwardAmount(val) {
|
||||||
this.jumpBackwardAmount = val
|
this.jumpBackwardAmount = val
|
||||||
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
|
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mounted() {
|
settingsUpdated() {
|
||||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
||||||
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||||
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.settingsUpdated()
|
||||||
|
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -18,6 +18,23 @@
|
|||||||
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||||
<div v-if="description" dir="auto" class="default-style" v-html="description" />
|
<div v-if="description" dir="auto" class="default-style" v-html="description" />
|
||||||
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white/5 my-4" />
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-grow">
|
||||||
|
<p class="font-semibold text-xs mb-1">{{ $strings.LabelFilename }}</p>
|
||||||
|
<p class="mb-2 text-xs">
|
||||||
|
{{ audioFileFilename }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<p class="font-semibold text-xs mb-1">{{ $strings.LabelSize }}</p>
|
||||||
|
<p class="mb-2 text-xs">
|
||||||
|
{{ audioFileSize }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
</template>
|
</template>
|
||||||
@@ -54,7 +71,7 @@ export default {
|
|||||||
return this.episode.description || ''
|
return this.episode.description || ''
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
return this.libraryItem?.media || {}
|
||||||
},
|
},
|
||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
@@ -65,6 +82,14 @@ export default {
|
|||||||
podcastAuthor() {
|
podcastAuthor() {
|
||||||
return this.mediaMetadata.author
|
return this.mediaMetadata.author
|
||||||
},
|
},
|
||||||
|
audioFileFilename() {
|
||||||
|
return this.episode.audioFile?.metadata?.filename || ''
|
||||||
|
},
|
||||||
|
audioFileSize() {
|
||||||
|
const size = this.episode.audioFile?.metadata?.size || 0
|
||||||
|
|
||||||
|
return this.$bytesPretty(size)
|
||||||
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
|
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
|
||||||
|
|
||||||
<div class="w-full relative">
|
<div class="w-full relative">
|
||||||
<ui-text-input v-model="currentFeed.feedUrl" readonly />
|
<ui-text-input :value="feedUrl" readonly />
|
||||||
|
|
||||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span>
|
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="currentFeed.meta" class="mt-5">
|
<div v-if="currentFeed.meta" class="mt-5">
|
||||||
@@ -111,8 +111,11 @@ export default {
|
|||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
|
feedUrl() {
|
||||||
|
return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : ''
|
||||||
|
},
|
||||||
demoFeedUrl() {
|
demoFeedUrl() {
|
||||||
return `${window.origin}/feed/${this.newFeedSlug}`
|
return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}`
|
||||||
},
|
},
|
||||||
isHttp() {
|
isHttp() {
|
||||||
return window.origin.startsWith('http://')
|
return window.origin.startsWith('http://')
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
|
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
|
||||||
|
|
||||||
<div class="w-full relative">
|
<div class="w-full relative">
|
||||||
<ui-text-input v-model="feed.feedUrl" readonly />
|
<ui-text-input :value="feedUrl" readonly />
|
||||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span>
|
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="feed.meta" class="mt-5">
|
<div v-if="feed.meta" class="mt-5">
|
||||||
@@ -70,6 +70,9 @@ export default {
|
|||||||
},
|
},
|
||||||
_feed() {
|
_feed() {
|
||||||
return this.feed || {}
|
return this.feed || {}
|
||||||
|
},
|
||||||
|
feedUrl() {
|
||||||
|
return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
|
<ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
|
||||||
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerSettings')">
|
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="showPlayerSettings">
|
||||||
<span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
|
<span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
|
||||||
</button>
|
</button>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -64,6 +64,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
|
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
|
||||||
|
|
||||||
|
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -96,6 +98,7 @@ export default {
|
|||||||
audioEl: null,
|
audioEl: null,
|
||||||
seekLoading: false,
|
seekLoading: false,
|
||||||
showChaptersModal: false,
|
showChaptersModal: false,
|
||||||
|
showPlayerSettingsModal: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
duration: 0
|
duration: 0
|
||||||
}
|
}
|
||||||
@@ -315,6 +318,9 @@ export default {
|
|||||||
if (!this.chapters.length) return
|
if (!this.chapters.length) return
|
||||||
this.showChaptersModal = !this.showChaptersModal
|
this.showChaptersModal = !this.showChaptersModal
|
||||||
},
|
},
|
||||||
|
showPlayerSettings() {
|
||||||
|
this.showPlayerSettingsModal = !this.showPlayerSettingsModal
|
||||||
|
},
|
||||||
init() {
|
init() {
|
||||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export default {
|
|||||||
this.users = res.users.sort((a, b) => {
|
this.users = res.users.sort((a, b) => {
|
||||||
return a.createdAt - b.createdAt
|
return a.createdAt - b.createdAt
|
||||||
})
|
})
|
||||||
|
this.$emit('numUsers', this.users.length)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
|
|||||||
@@ -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,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>
|
||||||
<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>
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.17.2",
|
"version": "2.17.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.17.2",
|
"version": "2.17.5",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.17.2",
|
"version": "2.17.5",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|||||||
@@ -64,6 +64,20 @@
|
|||||||
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
|
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
|
||||||
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
|
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
|
||||||
|
|
||||||
|
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
||||||
|
<div class="w-44">
|
||||||
|
<ui-dropdown v-model="newAuthSettings.authOpenIDSubfolderForRedirectURLs" small :items="subfolderOptions" :label="$strings.LabelWebRedirectURLsSubfolder" :disabled="savingSettings" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 sm:mt-5">
|
||||||
|
<p class="sm:pl-4 text-sm text-gray-300">{{ $strings.LabelWebRedirectURLsDescription }}</p>
|
||||||
|
<p class="sm:pl-4 text-sm text-gray-300 mb-2">
|
||||||
|
<code>{{ webCallbackURL }}</code>
|
||||||
|
<br />
|
||||||
|
<code>{{ mobileAppCallbackURL }}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
|
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
|
||||||
|
|
||||||
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
||||||
@@ -164,6 +178,27 @@ export default {
|
|||||||
value: 'username'
|
value: 'username'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
subfolderOptions() {
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
text: 'None',
|
||||||
|
value: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if (this.$config.routerBasePath) {
|
||||||
|
options.push({
|
||||||
|
text: this.$config.routerBasePath,
|
||||||
|
value: this.$config.routerBasePath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
},
|
||||||
|
webCallbackURL() {
|
||||||
|
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback`
|
||||||
|
},
|
||||||
|
mobileAppCallbackURL() {
|
||||||
|
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -325,7 +360,8 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.newAuthSettings = {
|
this.newAuthSettings = {
|
||||||
...this.authSettings
|
...this.authSettings,
|
||||||
|
authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs
|
||||||
}
|
}
|
||||||
this.enableLocalAuth = this.authMethods.includes('local')
|
this.enableLocalAuth = this.authMethods.includes('local')
|
||||||
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
||||||
|
|||||||
@@ -42,11 +42,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2 mb-2">
|
|
||||||
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
|
||||||
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
|
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,6 +89,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<h2 class="font-semibold">{{ $strings.HeaderSettingsWebClient }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||||
|
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="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)" />
|
||||||
|
<p class="pl-4" id="settings-allow-iframe">{{ $strings.LabelSettingsAllowIframe }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -324,21 +333,21 @@ export default {
|
|||||||
},
|
},
|
||||||
updateServerSettings(payload) {
|
updateServerSettings(payload) {
|
||||||
this.updatingServerSettings = true
|
this.updatingServerSettings = true
|
||||||
this.$store
|
this.$store.dispatch('updateServerSettings', payload).then((response) => {
|
||||||
.dispatch('updateServerSettings', payload)
|
|
||||||
.then(() => {
|
|
||||||
this.updatingServerSettings = false
|
this.updatingServerSettings = false
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
console.error('Failed to update server settins', response.error)
|
||||||
|
this.$toast.error(response.error)
|
||||||
|
this.initServerSettings()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.language) {
|
if (payload.language) {
|
||||||
// Updating language after save allows for re-rendering
|
// Updating language after save allows for re-rendering
|
||||||
this.$setLanguageCode(payload.language)
|
this.$setLanguageCode(payload.language)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to update server settings', error)
|
|
||||||
this.updatingServerSettings = false
|
|
||||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
initServerSettings() {
|
initServerSettings() {
|
||||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default {
|
|||||||
},
|
},
|
||||||
coverUrl(feed) {
|
coverUrl(feed) {
|
||||||
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
|
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
|
||||||
return `${feed.feedUrl}/cover`
|
return `${this.$config.routerBasePath}${feed.feedUrl}/cover`
|
||||||
},
|
},
|
||||||
async loadFeeds() {
|
async loadFeeds() {
|
||||||
const data = await this.$axios.$get(`/api/feeds`).catch((err) => {
|
const data = await this.$axios.$get(`/api/feeds`).catch((err) => {
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<app-settings-content :header-text="$strings.HeaderUsers">
|
<app-settings-content :header-text="$strings.HeaderUsers">
|
||||||
<template #header-items>
|
<template #header-items>
|
||||||
|
<div v-if="numUsers" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
|
||||||
|
<span>{{ numUsers }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
|
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
@@ -13,7 +17,7 @@
|
|||||||
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
|
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<tables-users-table class="pt-2" @edit="setShowUserModal" />
|
<tables-users-table class="pt-2" @edit="setShowUserModal" @numUsers="(count) => (numUsers = count)" />
|
||||||
</app-settings-content>
|
</app-settings-content>
|
||||||
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
||||||
</div>
|
</div>
|
||||||
@@ -29,7 +33,8 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selectedAccount: null,
|
selectedAccount: null,
|
||||||
showAccountModal: false
|
showAccountModal: false,
|
||||||
|
numUsers: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -126,12 +126,14 @@ export default {
|
|||||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||||
const currentTime = this.localAudioPlayer.getCurrentTime()
|
const currentTime = this.localAudioPlayer.getCurrentTime()
|
||||||
const duration = this.localAudioPlayer.getDuration()
|
const duration = this.localAudioPlayer.getDuration()
|
||||||
this.seek(Math.min(currentTime + 10, duration))
|
const jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') || 10
|
||||||
|
this.seek(Math.min(currentTime + jumpForwardAmount, duration))
|
||||||
},
|
},
|
||||||
jumpBackward() {
|
jumpBackward() {
|
||||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||||
const currentTime = this.localAudioPlayer.getCurrentTime()
|
const currentTime = this.localAudioPlayer.getCurrentTime()
|
||||||
this.seek(Math.max(currentTime - 10, 0))
|
const jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') || 10
|
||||||
|
this.seek(Math.max(currentTime - jumpBackwardAmount, 0))
|
||||||
},
|
},
|
||||||
setVolume(volume) {
|
setVolume(volume) {
|
||||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||||
@@ -248,6 +250,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.$store.dispatch('user/loadUserSettings')
|
||||||
|
|
||||||
this.resize()
|
this.resize()
|
||||||
window.addEventListener('resize', this.resize)
|
window.addEventListener('resize', this.resize)
|
||||||
window.addEventListener('keydown', this.keyDown)
|
window.addEventListener('keydown', this.keyDown)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const defaultCode = 'en-us'
|
|||||||
const languageCodeMap = {
|
const languageCodeMap = {
|
||||||
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
||||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||||
|
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
||||||
cs: { label: 'Čeština', dateFnsLocale: 'cs' },
|
cs: { label: 'Čeština', dateFnsLocale: 'cs' },
|
||||||
da: { label: 'Dansk', dateFnsLocale: 'da' },
|
da: { label: 'Dansk', dateFnsLocale: 'da' },
|
||||||
de: { label: 'Deutsch', dateFnsLocale: 'de' },
|
de: { label: 'Deutsch', dateFnsLocale: 'de' },
|
||||||
|
|||||||
@@ -72,16 +72,17 @@ export const actions = {
|
|||||||
return this.$axios
|
return this.$axios
|
||||||
.$patch('/api/settings', updatePayload)
|
.$patch('/api/settings', updatePayload)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result.success) {
|
if (result.serverSettings) {
|
||||||
commit('setServerSettings', result.serverSettings)
|
commit('setServerSettings', result.serverSettings)
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
return result
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update server settings', error)
|
console.error('Failed to update server settings', error)
|
||||||
return false
|
const errorMsg = error.response?.data || 'Unknown error'
|
||||||
|
return {
|
||||||
|
error: errorMsg
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
checkForUpdate({ commit }) {
|
checkForUpdate({ commit }) {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
"ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন",
|
"ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন",
|
||||||
"ButtonQueueAddItem": "সারিতে যোগ করুন",
|
"ButtonQueueAddItem": "সারিতে যোগ করুন",
|
||||||
"ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন",
|
"ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন",
|
||||||
|
"ButtonQuickEmbed": "দ্রুত এম্বেড করুন",
|
||||||
"ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন",
|
"ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন",
|
||||||
"ButtonQuickMatch": "দ্রুত ম্যাচ",
|
"ButtonQuickMatch": "দ্রুত ম্যাচ",
|
||||||
"ButtonReScan": "পুনরায় স্ক্যান",
|
"ButtonReScan": "পুনরায় স্ক্যান",
|
||||||
@@ -162,6 +163,7 @@
|
|||||||
"HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন",
|
"HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন",
|
||||||
"HeaderNotifications": "বিজ্ঞপ্তি",
|
"HeaderNotifications": "বিজ্ঞপ্তি",
|
||||||
"HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ",
|
"HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ",
|
||||||
|
"HeaderOpenListeningSessions": "শোনার সেশন খুলুন",
|
||||||
"HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন",
|
"HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন",
|
||||||
"HeaderOtherFiles": "অন্যান্য ফাইল",
|
"HeaderOtherFiles": "অন্যান্য ফাইল",
|
||||||
"HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ",
|
"HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ",
|
||||||
@@ -179,6 +181,7 @@
|
|||||||
"HeaderRemoveEpisodes": "{0}টি পর্ব সরান",
|
"HeaderRemoveEpisodes": "{0}টি পর্ব সরান",
|
||||||
"HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি",
|
"HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি",
|
||||||
"HeaderSchedule": "সময়সূচী",
|
"HeaderSchedule": "সময়সূচী",
|
||||||
|
"HeaderScheduleEpisodeDownloads": "স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন",
|
||||||
"HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী",
|
"HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী",
|
||||||
"HeaderSession": "সেশন",
|
"HeaderSession": "সেশন",
|
||||||
"HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন",
|
"HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন",
|
||||||
@@ -224,7 +227,11 @@
|
|||||||
"LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী",
|
"LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী",
|
||||||
"LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী",
|
"LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী",
|
||||||
"LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে",
|
"LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে",
|
||||||
|
"LabelApiToken": "API টোকেন",
|
||||||
"LabelAppend": "সংযোজন",
|
"LabelAppend": "সংযোজন",
|
||||||
|
"LabelAudioBitrate": "অডিও বিটরেট (যেমন- 128k)",
|
||||||
|
"LabelAudioChannels": "অডিও চ্যানেল (১ বা ২)",
|
||||||
|
"LabelAudioCodec": "অডিও কোডেক",
|
||||||
"LabelAuthor": "লেখক",
|
"LabelAuthor": "লেখক",
|
||||||
"LabelAuthorFirstLast": "লেখক (প্রথম শেষ)",
|
"LabelAuthorFirstLast": "লেখক (প্রথম শেষ)",
|
||||||
"LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)",
|
"LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)",
|
||||||
@@ -237,6 +244,7 @@
|
|||||||
"LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন",
|
"LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন",
|
||||||
"LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন",
|
"LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন",
|
||||||
"LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান",
|
"LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান",
|
||||||
|
"LabelBackupAudioFiles": "অডিও ফাইলগুলো ব্যাকআপ",
|
||||||
"LabelBackupLocation": "ব্যাকআপ অবস্থান",
|
"LabelBackupLocation": "ব্যাকআপ অবস্থান",
|
||||||
"LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন",
|
"LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন",
|
||||||
"LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত",
|
"LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত",
|
||||||
@@ -245,15 +253,18 @@
|
|||||||
"LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন",
|
"LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন",
|
||||||
"LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।",
|
"LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।",
|
||||||
"LabelBitrate": "বিটরেট",
|
"LabelBitrate": "বিটরেট",
|
||||||
|
"LabelBonus": "উপরিলাভ",
|
||||||
"LabelBooks": "বইগুলো",
|
"LabelBooks": "বইগুলো",
|
||||||
"LabelButtonText": "ঘর পাঠ্য",
|
"LabelButtonText": "ঘর পাঠ্য",
|
||||||
"LabelByAuthor": "দ্বারা {0}",
|
"LabelByAuthor": "দ্বারা {0}",
|
||||||
"LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন",
|
"LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন",
|
||||||
"LabelChannels": "চ্যানেল",
|
"LabelChannels": "চ্যানেল",
|
||||||
|
"LabelChapterCount": "{0} অধ্যায়",
|
||||||
"LabelChapterTitle": "অধ্যায়ের শিরোনাম",
|
"LabelChapterTitle": "অধ্যায়ের শিরোনাম",
|
||||||
"LabelChapters": "অধ্যায়",
|
"LabelChapters": "অধ্যায়",
|
||||||
"LabelChaptersFound": "অধ্যায় পাওয়া গেছে",
|
"LabelChaptersFound": "অধ্যায় পাওয়া গেছে",
|
||||||
"LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন",
|
"LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন",
|
||||||
|
"LabelClickToUseCurrentValue": "বর্তমান মান ব্যবহার করতে ক্লিক করুন",
|
||||||
"LabelClosePlayer": "প্লেয়ার বন্ধ করুন",
|
"LabelClosePlayer": "প্লেয়ার বন্ধ করুন",
|
||||||
"LabelCodec": "কোডেক",
|
"LabelCodec": "কোডেক",
|
||||||
"LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন",
|
"LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন",
|
||||||
@@ -303,12 +314,25 @@
|
|||||||
"LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা",
|
"LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা",
|
||||||
"LabelEmbeddedCover": "এম্বেডেড কভার",
|
"LabelEmbeddedCover": "এম্বেডেড কভার",
|
||||||
"LabelEnable": "সক্ষম করুন",
|
"LabelEnable": "সক্ষম করুন",
|
||||||
|
"LabelEncodingBackupLocation": "আপনার আসল অডিও ফাইলগুলোর একটি ব্যাকআপ এখানে সংরক্ষণ করা হবে:",
|
||||||
|
"LabelEncodingChaptersNotEmbedded": "মাল্টি-ট্র্যাক অডিওবুকগুলোতে অধ্যায় এম্বেড করা হয় না।",
|
||||||
|
"LabelEncodingClearItemCache": "পর্যায়ক্রমে আইটেম ক্যাশে পরিষ্কার করতে ভুলবেন না।",
|
||||||
|
"LabelEncodingFinishedM4B": "সমাপ্ত হওয়া M4B-গুলো আপনার অডিওবুক ফোল্ডারে এখানে রাখা হবে:",
|
||||||
|
"LabelEncodingInfoEmbedded": "আপনার অডিওবুক ফোল্ডারের ভিতরে অডিও ট্র্যাকগুলোতে মেটাডেটা এমবেড করা হবে।",
|
||||||
|
"LabelEncodingStartedNavigation": "একবার টাস্ক শুরু হলে আপনি এই পৃষ্ঠা থেকে অন্যত্র যেতে পারেন।",
|
||||||
|
"LabelEncodingTimeWarning": "এনকোডিং ৩০ মিনিট পর্যন্ত সময় নিতে পারে।",
|
||||||
|
"LabelEncodingWarningAdvancedSettings": "সতর্কতা: এই সেটিংস আপডেট করবেন না, যদি না আপনি ffmpeg এনকোডিং বিকল্পগুলোর সাথে পরিচিত হন।",
|
||||||
|
"LabelEncodingWatcherDisabled": "আপনার যদি পর্যবেক্ষক অক্ষম থাকে তবে আপনাকে পরে এই অডিওবুকটি পুনরায় স্ক্যান করতে হবে।",
|
||||||
"LabelEnd": "সমাপ্ত",
|
"LabelEnd": "সমাপ্ত",
|
||||||
"LabelEndOfChapter": "অধ্যায়ের সমাপ্তি",
|
"LabelEndOfChapter": "অধ্যায়ের সমাপ্তি",
|
||||||
"LabelEpisode": "পর্ব",
|
"LabelEpisode": "পর্ব",
|
||||||
|
"LabelEpisodeNotLinkedToRssFeed": "পর্বটি আরএসএস ফিডের সাথে সংযুক্ত করা হয়নি",
|
||||||
|
"LabelEpisodeNumber": "পর্ব #{0}",
|
||||||
"LabelEpisodeTitle": "পর্বের শিরোনাম",
|
"LabelEpisodeTitle": "পর্বের শিরোনাম",
|
||||||
"LabelEpisodeType": "পর্বের ধরন",
|
"LabelEpisodeType": "পর্বের ধরন",
|
||||||
|
"LabelEpisodeUrlFromRssFeed": "আরএসএস ফিড থেকে পর্ব URL",
|
||||||
"LabelEpisodes": "পর্বগুলো",
|
"LabelEpisodes": "পর্বগুলো",
|
||||||
|
"LabelEpisodic": "প্রাসঙ্গিক",
|
||||||
"LabelExample": "উদাহরণ",
|
"LabelExample": "উদাহরণ",
|
||||||
"LabelExpandSeries": "সিরিজ প্রসারিত করুন",
|
"LabelExpandSeries": "সিরিজ প্রসারিত করুন",
|
||||||
"LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন",
|
"LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন",
|
||||||
@@ -336,6 +360,7 @@
|
|||||||
"LabelFontScale": "ফন্ট স্কেল",
|
"LabelFontScale": "ফন্ট স্কেল",
|
||||||
"LabelFontStrikethrough": "অবচ্ছেদন রেখা",
|
"LabelFontStrikethrough": "অবচ্ছেদন রেখা",
|
||||||
"LabelFormat": "ফরম্যাট",
|
"LabelFormat": "ফরম্যাট",
|
||||||
|
"LabelFull": "পূর্ণ",
|
||||||
"LabelGenre": "ঘরানা",
|
"LabelGenre": "ঘরানা",
|
||||||
"LabelGenres": "ঘরানাগুলো",
|
"LabelGenres": "ঘরানাগুলো",
|
||||||
"LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন",
|
"LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন",
|
||||||
@@ -391,6 +416,10 @@
|
|||||||
"LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার",
|
"LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার",
|
||||||
"LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন",
|
"LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন",
|
||||||
"LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে",
|
"LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে",
|
||||||
|
"LabelMaxEpisodesToDownload": "সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।",
|
||||||
|
"LabelMaxEpisodesToDownloadPerCheck": "প্রতি কিস্তিতে সর্বাধিক # টি নতুন পর্ব ডাউনলোড করা হবে",
|
||||||
|
"LabelMaxEpisodesToKeep": "সর্বোচ্চ # টি পর্ব রাখা হবে",
|
||||||
|
"LabelMaxEpisodesToKeepHelp": "০ কোন সর্বোচ্চ সীমা সেট করে না। একটি নতুন পর্ব স্বয়ংক্রিয়-ডাউনলোড হওয়ার পরে আপনার যদি X-এর বেশি পর্ব থাকে তবে এটি সবচেয়ে পুরানো পর্বটি মুছে ফেলবে। এটি প্রতি নতুন ডাউনলোডের জন্য শুধুমাত্র ১ টি পর্ব মুছে ফেলবে।",
|
||||||
"LabelMediaPlayer": "মিডিয়া প্লেয়ার",
|
"LabelMediaPlayer": "মিডিয়া প্লেয়ার",
|
||||||
"LabelMediaType": "মিডিয়ার ধরন",
|
"LabelMediaType": "মিডিয়ার ধরন",
|
||||||
"LabelMetaTag": "মেটা ট্যাগ",
|
"LabelMetaTag": "মেটা ট্যাগ",
|
||||||
@@ -436,12 +465,14 @@
|
|||||||
"LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।",
|
"LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।",
|
||||||
"LabelOpenRSSFeed": "আরএসএস ফিড খুলুন",
|
"LabelOpenRSSFeed": "আরএসএস ফিড খুলুন",
|
||||||
"LabelOverwrite": "পুনঃলিখিত",
|
"LabelOverwrite": "পুনঃলিখিত",
|
||||||
|
"LabelPaginationPageXOfY": "{1} টির মধ্যে {0} পৃষ্ঠা",
|
||||||
"LabelPassword": "পাসওয়ার্ড",
|
"LabelPassword": "পাসওয়ার্ড",
|
||||||
"LabelPath": "পথ",
|
"LabelPath": "পথ",
|
||||||
"LabelPermanent": "স্থায়ী",
|
"LabelPermanent": "স্থায়ী",
|
||||||
"LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে",
|
"LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে",
|
||||||
"LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে",
|
"LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে",
|
||||||
"LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে",
|
"LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে",
|
||||||
|
"LabelPermissionsCreateEreader": "ইরিডার তৈরি করতে পারেন",
|
||||||
"LabelPermissionsDelete": "মুছে দিতে পারবে",
|
"LabelPermissionsDelete": "মুছে দিতে পারবে",
|
||||||
"LabelPermissionsDownload": "ডাউনলোড করতে পারবে",
|
"LabelPermissionsDownload": "ডাউনলোড করতে পারবে",
|
||||||
"LabelPermissionsUpdate": "আপডেট করতে পারবে",
|
"LabelPermissionsUpdate": "আপডেট করতে পারবে",
|
||||||
@@ -465,6 +496,8 @@
|
|||||||
"LabelPubDate": "প্রকাশের তারিখ",
|
"LabelPubDate": "প্রকাশের তারিখ",
|
||||||
"LabelPublishYear": "প্রকাশের বছর",
|
"LabelPublishYear": "প্রকাশের বছর",
|
||||||
"LabelPublishedDate": "প্রকাশিত {0}",
|
"LabelPublishedDate": "প্রকাশিত {0}",
|
||||||
|
"LabelPublishedDecade": "প্রকাশনার দশক",
|
||||||
|
"LabelPublishedDecades": "প্রকাশনার দশকগুলো",
|
||||||
"LabelPublisher": "প্রকাশক",
|
"LabelPublisher": "প্রকাশক",
|
||||||
"LabelPublishers": "প্রকাশকরা",
|
"LabelPublishers": "প্রকাশকরা",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
|
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
|
||||||
@@ -484,21 +517,28 @@
|
|||||||
"LabelRedo": "পুনরায় করুন",
|
"LabelRedo": "পুনরায় করুন",
|
||||||
"LabelRegion": "অঞ্চল",
|
"LabelRegion": "অঞ্চল",
|
||||||
"LabelReleaseDate": "উন্মোচনের তারিখ",
|
"LabelReleaseDate": "উন্মোচনের তারিখ",
|
||||||
|
"LabelRemoveAllMetadataAbs": "সমস্ত metadata.abs ফাইল সরান",
|
||||||
|
"LabelRemoveAllMetadataJson": "সমস্ত metadata.json ফাইল সরান",
|
||||||
"LabelRemoveCover": "কভার সরান",
|
"LabelRemoveCover": "কভার সরান",
|
||||||
|
"LabelRemoveMetadataFile": "লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান",
|
||||||
|
"LabelRemoveMetadataFileHelp": "আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।",
|
||||||
"LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি",
|
"LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি",
|
||||||
"LabelSearchTerm": "অনুসন্ধান শব্দ",
|
"LabelSearchTerm": "অনুসন্ধান শব্দ",
|
||||||
"LabelSearchTitle": "অনুসন্ধান শিরোনাম",
|
"LabelSearchTitle": "অনুসন্ধান শিরোনাম",
|
||||||
"LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN",
|
"LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN",
|
||||||
"LabelSeason": "সেশন",
|
"LabelSeason": "সেশন",
|
||||||
|
"LabelSeasonNumber": "মরসুম #{0}",
|
||||||
"LabelSelectAll": "সব নির্বাচন করুন",
|
"LabelSelectAll": "সব নির্বাচন করুন",
|
||||||
"LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন",
|
"LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন",
|
||||||
"LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন",
|
"LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন",
|
||||||
"LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন",
|
"LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন",
|
||||||
"LabelSendEbookToDevice": "ই-বই পাঠান...",
|
"LabelSendEbookToDevice": "ই-বই পাঠান...",
|
||||||
"LabelSequence": "ক্রম",
|
"LabelSequence": "ক্রম",
|
||||||
|
"LabelSerial": "ধারাবাহিক",
|
||||||
"LabelSeries": "সিরিজ",
|
"LabelSeries": "সিরিজ",
|
||||||
"LabelSeriesName": "সিরিজের নাম",
|
"LabelSeriesName": "সিরিজের নাম",
|
||||||
"LabelSeriesProgress": "সিরিজের অগ্রগতি",
|
"LabelSeriesProgress": "সিরিজের অগ্রগতি",
|
||||||
|
"LabelServerLogLevel": "সার্ভার লগ লেভেল",
|
||||||
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
|
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
|
||||||
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
|
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
|
||||||
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
|
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
|
||||||
@@ -523,6 +563,9 @@
|
|||||||
"LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।",
|
"LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।",
|
||||||
"LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন",
|
"LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন",
|
||||||
"LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন",
|
"LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন",
|
||||||
|
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "শতকরা সম্পূর্ণ এর চেয়ে বেশি",
|
||||||
|
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "বাকি সময় (সেকেন্ড) এর চেয়ে কম",
|
||||||
|
"LabelSettingsLibraryMarkAsFinishedWhen": "মিডিয়া আইটেমকে সমাপ্ত হিসাবে চিহ্নিত করুন যখন",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।",
|
||||||
"LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন",
|
"LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন",
|
||||||
@@ -587,6 +630,7 @@
|
|||||||
"LabelTimeDurationXMinutes": "{0} মিনিট",
|
"LabelTimeDurationXMinutes": "{0} মিনিট",
|
||||||
"LabelTimeDurationXSeconds": "{0} সেকেন্ড",
|
"LabelTimeDurationXSeconds": "{0} সেকেন্ড",
|
||||||
"LabelTimeInMinutes": "মিনিটে সময়",
|
"LabelTimeInMinutes": "মিনিটে সময়",
|
||||||
|
"LabelTimeLeft": "{0} বাকি",
|
||||||
"LabelTimeListened": "সময় শোনা হয়েছে",
|
"LabelTimeListened": "সময় শোনা হয়েছে",
|
||||||
"LabelTimeListenedToday": "আজ শোনার সময়",
|
"LabelTimeListenedToday": "আজ শোনার সময়",
|
||||||
"LabelTimeRemaining": "{0}টি অবশিষ্ট",
|
"LabelTimeRemaining": "{0}টি অবশিষ্ট",
|
||||||
@@ -594,6 +638,7 @@
|
|||||||
"LabelTitle": "শিরোনাম",
|
"LabelTitle": "শিরোনাম",
|
||||||
"LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন",
|
"LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন",
|
||||||
"LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।",
|
"LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।",
|
||||||
|
"LabelToolsM4bEncoder": "M4B এনকোডার",
|
||||||
"LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন",
|
"LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন",
|
||||||
"LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।",
|
"LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।",
|
||||||
"LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন",
|
"LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন",
|
||||||
@@ -606,6 +651,7 @@
|
|||||||
"LabelTracksMultiTrack": "মাল্টি-ট্র্যাক",
|
"LabelTracksMultiTrack": "মাল্টি-ট্র্যাক",
|
||||||
"LabelTracksNone": "কোন ট্র্যাক নেই",
|
"LabelTracksNone": "কোন ট্র্যাক নেই",
|
||||||
"LabelTracksSingleTrack": "একক-ট্র্যাক",
|
"LabelTracksSingleTrack": "একক-ট্র্যাক",
|
||||||
|
"LabelTrailer": "আনুগমিক",
|
||||||
"LabelType": "টাইপ",
|
"LabelType": "টাইপ",
|
||||||
"LabelUnabridged": "অসংলগ্ন",
|
"LabelUnabridged": "অসংলগ্ন",
|
||||||
"LabelUndo": "পূর্বাবস্থা",
|
"LabelUndo": "পূর্বাবস্থা",
|
||||||
@@ -617,10 +663,13 @@
|
|||||||
"LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন",
|
"LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন",
|
||||||
"LabelUpdatedAt": "আপডেট করা হয়েছে",
|
"LabelUpdatedAt": "আপডেট করা হয়েছে",
|
||||||
"LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন",
|
"LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "ফাইল টেনে আনুন",
|
||||||
"LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন",
|
"LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন",
|
"LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন",
|
||||||
|
"LabelUseAdvancedOptions": "উন্নত বিকল্প ব্যবহার করুন",
|
||||||
"LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন",
|
"LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন",
|
||||||
"LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন",
|
"LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন",
|
||||||
|
"LabelUseZeroForUnlimited": "অসীমের জন্য 0 ব্যবহার করুন",
|
||||||
"LabelUser": "ব্যবহারকারী",
|
"LabelUser": "ব্যবহারকারী",
|
||||||
"LabelUsername": "ব্যবহারকারীর নাম",
|
"LabelUsername": "ব্যবহারকারীর নাম",
|
||||||
"LabelValue": "মান",
|
"LabelValue": "মান",
|
||||||
@@ -667,6 +716,7 @@
|
|||||||
"MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?",
|
"MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?",
|
||||||
"MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?",
|
"MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?",
|
||||||
"MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?",
|
"MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?",
|
||||||
|
"MessageConfirmEmbedMetadataInAudioFiles": "আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?",
|
||||||
"MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?",
|
"MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
|
"MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
|
||||||
@@ -678,6 +728,7 @@
|
|||||||
"MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?",
|
"MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?",
|
||||||
"MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?",
|
"MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?",
|
||||||
"MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?",
|
"MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?",
|
||||||
|
"MessageConfirmQuickMatchEpisodes": "একটি মিল পাওয়া গেলে দ্রুত ম্যাচিং পর্বগুলি বিস্তারিত ওভাররাইট করবে। শুধুমাত্র অতুলনীয় পর্ব আপডেট করা হবে। আপনি কি নিশ্চিত?",
|
||||||
"MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?",
|
"MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?",
|
||||||
"MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?",
|
"MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?",
|
||||||
"MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?",
|
"MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?",
|
||||||
@@ -685,6 +736,7 @@
|
|||||||
"MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?",
|
"MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?",
|
||||||
"MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?",
|
"MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?",
|
||||||
"MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?",
|
"MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?",
|
||||||
|
"MessageConfirmRemoveMetadataFiles": "আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?",
|
||||||
"MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?",
|
"MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?",
|
||||||
"MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?",
|
"MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?",
|
||||||
"MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?",
|
"MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?",
|
||||||
@@ -700,6 +752,7 @@
|
|||||||
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
|
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
|
||||||
"MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!",
|
"MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!",
|
||||||
"MessageEmbedFinished": "এম্বেড করা শেষ!",
|
"MessageEmbedFinished": "এম্বেড করা শেষ!",
|
||||||
|
"MessageEmbedQueue": "মেটাডেটা এম্বেডের জন্য সারিবদ্ধ ({0} সারিতে)",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ",
|
"MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ",
|
||||||
"MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।",
|
"MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।",
|
||||||
"MessageFeedURLWillBe": "ফিড URL হবে {0}",
|
"MessageFeedURLWillBe": "ফিড URL হবে {0}",
|
||||||
@@ -744,6 +797,7 @@
|
|||||||
"MessageNoLogs": "কোনও লগ নেই",
|
"MessageNoLogs": "কোনও লগ নেই",
|
||||||
"MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই",
|
"MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই",
|
||||||
"MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই",
|
"MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই",
|
||||||
|
"MessageNoPodcastFeed": "অবৈধ পডকাস্ট: কোনো ফিড নেই",
|
||||||
"MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি",
|
"MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি",
|
||||||
"MessageNoResults": "কোন ফলাফল নেই",
|
"MessageNoResults": "কোন ফলাফল নেই",
|
||||||
"MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই",
|
"MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই",
|
||||||
@@ -760,6 +814,10 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন",
|
"MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন",
|
||||||
"MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।",
|
"MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই",
|
"MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই",
|
||||||
|
"MessagePodcastSearchField": "অনুসন্ধান শব্দ বা RSS ফিড URL লিখুন",
|
||||||
|
"MessageQuickEmbedInProgress": "দ্রুত এম্বেড করা হচ্ছে",
|
||||||
|
"MessageQuickEmbedQueue": "দ্রুত এম্বেড করার জন্য সারিবদ্ধ ({0} সারিতে)",
|
||||||
|
"MessageQuickMatchAllEpisodes": "দ্রুত ম্যাচ সব পর্ব",
|
||||||
"MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।",
|
"MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।",
|
||||||
"MessageRemoveChapter": "অধ্যায় সরান",
|
"MessageRemoveChapter": "অধ্যায় সরান",
|
||||||
"MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান",
|
"MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান",
|
||||||
@@ -802,6 +860,9 @@
|
|||||||
"MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান",
|
"MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান",
|
||||||
"MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ",
|
"MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ",
|
||||||
"MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে",
|
"MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে",
|
||||||
|
"MessageTaskOpmlParseFailed": "OPML ফাইল পার্স করতে ব্যর্থ হয়েছে",
|
||||||
|
"MessageTaskOpmlParseFastFail": "অবৈধ OPML ফাইল <opml> ট্যাগ পাওয়া যায়নি বা একটি <outline> ট্যাগ পাওয়া যায়নি",
|
||||||
|
"MessageTaskOpmlParseNoneFound": "OPML ফাইলে কোনো ফিড পাওয়া যায়নি",
|
||||||
"MessageTaskScanItemsAdded": "{0}টি করা হয়েছে",
|
"MessageTaskScanItemsAdded": "{0}টি করা হয়েছে",
|
||||||
"MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত",
|
"MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত",
|
||||||
"MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে",
|
"MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে",
|
||||||
@@ -826,6 +887,10 @@
|
|||||||
"NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।",
|
"NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।",
|
||||||
"NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।",
|
"NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।",
|
||||||
"NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।",
|
"NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।",
|
||||||
|
"NotificationOnBackupCompletedDescription": "ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে",
|
||||||
|
"NotificationOnBackupFailedDescription": "ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে",
|
||||||
|
"NotificationOnEpisodeDownloadedDescription": "একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে",
|
||||||
|
"NotificationOnTestDescription": "বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট",
|
||||||
"PlaceholderNewCollection": "নতুন সংগ্রহের নাম",
|
"PlaceholderNewCollection": "নতুন সংগ্রহের নাম",
|
||||||
"PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ",
|
"PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ",
|
||||||
"PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম",
|
"PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম",
|
||||||
@@ -851,6 +916,7 @@
|
|||||||
"StatsYearInReview": "বাৎসরিক পর্যালোচনা",
|
"StatsYearInReview": "বাৎসরিক পর্যালোচনা",
|
||||||
"ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে",
|
"ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে",
|
||||||
"ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে",
|
"ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে",
|
||||||
|
"ToastAsinRequired": "ASIN প্রয়োজন",
|
||||||
"ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে",
|
"ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে",
|
||||||
"ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি",
|
"ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি",
|
||||||
"ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে",
|
"ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে",
|
||||||
@@ -870,6 +936,8 @@
|
|||||||
"ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে",
|
"ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে",
|
||||||
"ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে",
|
"ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে",
|
||||||
"ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে",
|
"ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে",
|
||||||
|
"ToastBatchQuickMatchFailed": "ব্যাচ কুইক ম্যাচ ব্যর্থ!",
|
||||||
|
"ToastBatchQuickMatchStarted": "{0}টি বইয়ের ব্যাচ কুইক ম্যাচ শুরু হয়েছে!",
|
||||||
"ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে",
|
"ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে",
|
||||||
"ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য",
|
"ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য",
|
||||||
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
|
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
|
||||||
@@ -881,6 +949,7 @@
|
|||||||
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
|
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
|
||||||
"ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে",
|
"ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে",
|
||||||
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
|
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
|
||||||
|
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
|
||||||
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
|
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
|
||||||
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
|
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
|
||||||
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
|
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
|
||||||
@@ -898,11 +967,14 @@
|
|||||||
"ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে",
|
"ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে",
|
||||||
"ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে",
|
"ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে",
|
||||||
"ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে",
|
"ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে",
|
||||||
|
"ToastEpisodeUpdateSuccess": "{0}টি পর্ব আপডেট করা হয়েছে",
|
||||||
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
|
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
|
||||||
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
|
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
|
||||||
|
"ToastFailedToMatch": "মেলাতে ব্যর্থ হয়েছে",
|
||||||
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
|
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
|
||||||
"ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে",
|
"ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে",
|
||||||
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
|
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
|
||||||
|
"ToastInvalidMaxEpisodesToDownload": "ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব",
|
||||||
"ToastInvalidUrl": "অকার্যকর ইউআরএল",
|
"ToastInvalidUrl": "অকার্যকর ইউআরএল",
|
||||||
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
|
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
|
||||||
"ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ",
|
"ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ",
|
||||||
@@ -920,14 +992,22 @@
|
|||||||
"ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ",
|
"ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ",
|
||||||
"ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে",
|
"ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে",
|
||||||
"ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে",
|
"ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে",
|
||||||
|
"ToastMatchAllAuthorsFailed": "সমস্ত লেখকের সাথে মিলতে ব্যর্থ হয়েছে",
|
||||||
|
"ToastMetadataFilesRemovedError": "মেটাডেটা সরানোর সময় ত্রুটি {0} ফাইল",
|
||||||
|
"ToastMetadataFilesRemovedNoneFound": "কোনো মেটাডেটা নেই।লাইব্রেরিতে {0} ফাইল পাওয়া গেছে",
|
||||||
|
"ToastMetadataFilesRemovedNoneRemoved": "কোনো মেটাডেটা নেই।{0} ফাইল সরানো হয়েছে",
|
||||||
|
"ToastMetadataFilesRemovedSuccess": "{0} মেটাডেটা৷{1} ফাইল সরানো হয়েছে",
|
||||||
|
"ToastMustHaveAtLeastOnePath": "অন্তত একটি পথ থাকতে হবে",
|
||||||
"ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক",
|
"ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক",
|
||||||
"ToastNameRequired": "নাম আবশ্যক",
|
"ToastNameRequired": "নাম আবশ্যক",
|
||||||
|
"ToastNewEpisodesFound": "{0}টি নতুন পর্ব পাওয়া গেছে",
|
||||||
"ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"",
|
"ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"",
|
||||||
"ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে",
|
"ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে",
|
||||||
"ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে",
|
"ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে",
|
||||||
"ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে",
|
"ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে",
|
||||||
"ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে",
|
"ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে",
|
||||||
"ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন",
|
"ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন",
|
||||||
|
"ToastNoNewEpisodesFound": "কোন নতুন পর্ব পাওয়া যায়নি",
|
||||||
"ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই",
|
"ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই",
|
||||||
"ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ",
|
"ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ",
|
||||||
"ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ",
|
"ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ",
|
||||||
@@ -946,6 +1026,7 @@
|
|||||||
"ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে",
|
"ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে",
|
||||||
"ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি",
|
"ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি",
|
||||||
"ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই",
|
"ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই",
|
||||||
|
"ToastProgressIsNotBeingSynced": "অগ্রগতি সিঙ্ক হচ্ছে না, প্লেব্যাক পুনরায় চালু করুন",
|
||||||
"ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে",
|
"ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে",
|
||||||
"ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে",
|
"ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে",
|
||||||
"ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক",
|
"ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক",
|
||||||
@@ -972,6 +1053,7 @@
|
|||||||
"ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে",
|
"ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে",
|
||||||
"ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ",
|
"ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ",
|
||||||
"ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে",
|
"ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে",
|
||||||
|
"ToastSleepTimerDone": "স্লিপ টাইমার হয়ে গেছে... zZzzZz",
|
||||||
"ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে",
|
"ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে",
|
||||||
"ToastSlugRequired": "স্লাগ আবশ্যক",
|
"ToastSlugRequired": "স্লাগ আবশ্যক",
|
||||||
"ToastSocketConnected": "সকেট সংযুক্ত",
|
"ToastSocketConnected": "সকেট সংযুক্ত",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -584,7 +584,7 @@
|
|||||||
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
|
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
|
||||||
"LabelSettingsTimeFormat": "Zeitformat",
|
"LabelSettingsTimeFormat": "Zeitformat",
|
||||||
"LabelShare": "Freigeben",
|
"LabelShare": "Freigeben",
|
||||||
"LabelShareOpen": "Freigabe",
|
"LabelShareOpen": "Freigeben",
|
||||||
"LabelShareURL": "Freigabe URL",
|
"LabelShareURL": "Freigabe URL",
|
||||||
"LabelShowAll": "Alles anzeigen",
|
"LabelShowAll": "Alles anzeigen",
|
||||||
"LabelShowSeconds": "Zeige Sekunden",
|
"LabelShowSeconds": "Zeige Sekunden",
|
||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird",
|
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird",
|
||||||
"LabelUpdatedAt": "Aktualisiert am",
|
"LabelUpdatedAt": "Aktualisiert am",
|
||||||
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
|
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Dateien per Drag & Drop hierher ziehen",
|
||||||
"LabelUploaderDropFiles": "Dateien löschen",
|
"LabelUploaderDropFiles": "Dateien löschen",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie",
|
"LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie",
|
||||||
"LabelUseAdvancedOptions": "Nutze Erweiterte Optionen",
|
"LabelUseAdvancedOptions": "Nutze Erweiterte Optionen",
|
||||||
@@ -678,6 +679,8 @@
|
|||||||
"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:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "Unterordner für Weiterleitung-URLs",
|
||||||
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
|
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
|
||||||
"LabelXBooks": "{0} Bücher",
|
"LabelXBooks": "{0} Bücher",
|
||||||
"LabelXItems": "{0} Medien",
|
"LabelXItems": "{0} Medien",
|
||||||
@@ -727,7 +730,7 @@
|
|||||||
"MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis <code>/metadata/cache</code> löschen. <br /><br />Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?",
|
"MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis <code>/metadata/cache</code> löschen. <br /><br />Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?",
|
||||||
"MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter <code>/metadata/cache/items</code> gelöscht.<br />Bist du dir sicher?",
|
"MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter <code>/metadata/cache/items</code> gelöscht.<br />Bist du dir sicher?",
|
||||||
"MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt. <br><br>Möchtest du fortfahren?",
|
"MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt. <br><br>Möchtest du fortfahren?",
|
||||||
"MessageConfirmQuickMatchEpisodes": "Schnelles Zuordnen von Episoden überschreibt die Details, wenn eine Übereinstimmung gefunden wird. Nur nicht zugeordnete Episoden werden aktualisiert. Bist du sicher?",
|
"MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details, wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?",
|
||||||
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
|
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
|
||||||
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
|
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
|
||||||
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
|
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
|
||||||
@@ -832,7 +835,7 @@
|
|||||||
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
|
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
|
||||||
"MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab",
|
"MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab",
|
||||||
"MessageShareExpiresIn": "Läuft in {0} ab",
|
"MessageShareExpiresIn": "Läuft in {0} ab",
|
||||||
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein.",
|
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein",
|
||||||
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
|
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
|
||||||
"MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt",
|
"MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt",
|
||||||
"MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen",
|
"MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen",
|
||||||
@@ -1040,7 +1043,7 @@
|
|||||||
"ToastRenameFailed": "Umbenennen fehlgeschlagen",
|
"ToastRenameFailed": "Umbenennen fehlgeschlagen",
|
||||||
"ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}",
|
"ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}",
|
||||||
"ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt",
|
"ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt",
|
||||||
"ToastRescanUpToDate": "Erneut scannen erledigt, Artikel wahr auf dem neusten Stand",
|
"ToastRescanUpToDate": "Erneut scannen erledigt, Artikel war auf dem neusten Stand",
|
||||||
"ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert",
|
"ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert",
|
||||||
"ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek",
|
"ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek",
|
||||||
"ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus",
|
"ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus",
|
||||||
|
|||||||
@@ -88,6 +88,8 @@
|
|||||||
"ButtonSaveTracklist": "Save Tracklist",
|
"ButtonSaveTracklist": "Save Tracklist",
|
||||||
"ButtonScan": "Scan",
|
"ButtonScan": "Scan",
|
||||||
"ButtonScanLibrary": "Scan Library",
|
"ButtonScanLibrary": "Scan Library",
|
||||||
|
"ButtonScrollLeft": "Scroll Left",
|
||||||
|
"ButtonScrollRight": "Scroll Right",
|
||||||
"ButtonSearch": "Search",
|
"ButtonSearch": "Search",
|
||||||
"ButtonSelectFolderPath": "Select Folder Path",
|
"ButtonSelectFolderPath": "Select Folder Path",
|
||||||
"ButtonSeries": "Series",
|
"ButtonSeries": "Series",
|
||||||
@@ -190,6 +192,7 @@
|
|||||||
"HeaderSettingsExperimental": "Experimental Features",
|
"HeaderSettingsExperimental": "Experimental Features",
|
||||||
"HeaderSettingsGeneral": "General",
|
"HeaderSettingsGeneral": "General",
|
||||||
"HeaderSettingsScanner": "Scanner",
|
"HeaderSettingsScanner": "Scanner",
|
||||||
|
"HeaderSettingsWebClient": "Web Client",
|
||||||
"HeaderSleepTimer": "Sleep Timer",
|
"HeaderSleepTimer": "Sleep Timer",
|
||||||
"HeaderStatsLargestItems": "Largest Items",
|
"HeaderStatsLargestItems": "Largest Items",
|
||||||
"HeaderStatsLongestItems": "Longest Items (hrs)",
|
"HeaderStatsLongestItems": "Longest Items (hrs)",
|
||||||
@@ -542,6 +545,7 @@
|
|||||||
"LabelServerYearReview": "Server Year in Review ({0})",
|
"LabelServerYearReview": "Server Year in Review ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Set as primary",
|
"LabelSetEbookAsPrimary": "Set as primary",
|
||||||
"LabelSetEbookAsSupplementary": "Set as supplementary",
|
"LabelSetEbookAsSupplementary": "Set as supplementary",
|
||||||
|
"LabelSettingsAllowIframe": "Allow embedding in an iframe",
|
||||||
"LabelSettingsAudiobooksOnly": "Audiobooks only",
|
"LabelSettingsAudiobooksOnly": "Audiobooks only",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
|
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
|
||||||
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
|
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
|
||||||
@@ -592,6 +596,8 @@
|
|||||||
"LabelSize": "Size",
|
"LabelSize": "Size",
|
||||||
"LabelSleepTimer": "Sleep timer",
|
"LabelSleepTimer": "Sleep timer",
|
||||||
"LabelSlug": "Slug",
|
"LabelSlug": "Slug",
|
||||||
|
"LabelSortAscending": "Ascending",
|
||||||
|
"LabelSortDescending": "Descending",
|
||||||
"LabelStart": "Start",
|
"LabelStart": "Start",
|
||||||
"LabelStartTime": "Start Time",
|
"LabelStartTime": "Start Time",
|
||||||
"LabelStarted": "Started",
|
"LabelStarted": "Started",
|
||||||
@@ -679,6 +685,8 @@
|
|||||||
"LabelViewPlayerSettings": "View player settings",
|
"LabelViewPlayerSettings": "View player settings",
|
||||||
"LabelViewQueue": "View player queue",
|
"LabelViewQueue": "View player queue",
|
||||||
"LabelVolume": "Volume",
|
"LabelVolume": "Volume",
|
||||||
|
"LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs",
|
||||||
"LabelWeekdaysToRun": "Weekdays to run",
|
"LabelWeekdaysToRun": "Weekdays to run",
|
||||||
"LabelXBooks": "{0} books",
|
"LabelXBooks": "{0} books",
|
||||||
"LabelXItems": "{0} items",
|
"LabelXItems": "{0} items",
|
||||||
|
|||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
|
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
|
||||||
"LabelUpdatedAt": "Actualizado En",
|
"LabelUpdatedAt": "Actualizado En",
|
||||||
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
|
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Arrastrar y soltar archivos",
|
||||||
"LabelUploaderDropFiles": "Suelte los Archivos",
|
"LabelUploaderDropFiles": "Suelte los Archivos",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente",
|
"LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente",
|
||||||
"LabelUseAdvancedOptions": "Usar opciones avanzadas",
|
"LabelUseAdvancedOptions": "Usar opciones avanzadas",
|
||||||
@@ -678,6 +679,8 @@
|
|||||||
"LabelViewPlayerSettings": "Ver los ajustes del reproductor",
|
"LabelViewPlayerSettings": "Ver los ajustes del reproductor",
|
||||||
"LabelViewQueue": "Ver Fila del Reproductor",
|
"LabelViewQueue": "Ver Fila del Reproductor",
|
||||||
"LabelVolume": "Volumen",
|
"LabelVolume": "Volumen",
|
||||||
|
"LabelWebRedirectURLsDescription": "Autorice estas URL en su proveedor OAuth para permitir la redirección a la aplicación web después de iniciar sesión:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "Subcarpeta para URL de redireccionamiento",
|
||||||
"LabelWeekdaysToRun": "Correr en Días de la Semana",
|
"LabelWeekdaysToRun": "Correr en Días de la Semana",
|
||||||
"LabelXBooks": "{0} libros",
|
"LabelXBooks": "{0} libros",
|
||||||
"LabelXItems": "{0} elementos",
|
"LabelXItems": "{0} elementos",
|
||||||
|
|||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée",
|
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée",
|
||||||
"LabelUpdatedAt": "Mis à jour à",
|
"LabelUpdatedAt": "Mis à jour à",
|
||||||
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
|
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Glisser & déposer des fichiers",
|
||||||
"LabelUploaderDropFiles": "Déposer des fichiers",
|
"LabelUploaderDropFiles": "Déposer des fichiers",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, l’auteur et la série",
|
"LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, l’auteur et la série",
|
||||||
"LabelUseAdvancedOptions": "Utiliser les options avancées",
|
"LabelUseAdvancedOptions": "Utiliser les options avancées",
|
||||||
@@ -869,10 +870,10 @@
|
|||||||
"MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »",
|
"MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »",
|
||||||
"MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »",
|
"MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »",
|
||||||
"MessageTaskTargetDirectoryNotWritable": "Le répertoire cible n’est pas accessible en écriture",
|
"MessageTaskTargetDirectoryNotWritable": "Le répertoire cible n’est pas accessible en écriture",
|
||||||
"MessageThinking": "Je cherche…",
|
"MessageThinking": "À la recherche de…",
|
||||||
"MessageUploaderItemFailed": "Échec du téléversement",
|
"MessageUploaderItemFailed": "Échec du téléversement",
|
||||||
"MessageUploaderItemSuccess": "Téléversement effectué !",
|
"MessageUploaderItemSuccess": "Téléversement effectué !",
|
||||||
"MessageUploading": "Téléversement…",
|
"MessageUploading": "Téléchargement…",
|
||||||
"MessageValidCronExpression": "Expression cron valide",
|
"MessageValidCronExpression": "Expression cron valide",
|
||||||
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
|
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
|
||||||
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !",
|
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !",
|
||||||
|
|||||||
@@ -271,7 +271,7 @@
|
|||||||
"LabelCollapseSubSeries": "Podserijale prikaži sažeto",
|
"LabelCollapseSubSeries": "Podserijale prikaži sažeto",
|
||||||
"LabelCollection": "Zbirka",
|
"LabelCollection": "Zbirka",
|
||||||
"LabelCollections": "Zbirke",
|
"LabelCollections": "Zbirke",
|
||||||
"LabelComplete": "Dovršeno",
|
"LabelComplete": "Potpuno",
|
||||||
"LabelConfirmPassword": "Potvrda zaporke",
|
"LabelConfirmPassword": "Potvrda zaporke",
|
||||||
"LabelContinueListening": "Nastavi slušati",
|
"LabelContinueListening": "Nastavi slušati",
|
||||||
"LabelContinueReading": "Nastavi čitati",
|
"LabelContinueReading": "Nastavi čitati",
|
||||||
@@ -532,7 +532,7 @@
|
|||||||
"LabelSelectAllEpisodes": "Označi sve nastavke",
|
"LabelSelectAllEpisodes": "Označi sve nastavke",
|
||||||
"LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka",
|
"LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka",
|
||||||
"LabelSelectUsers": "Označi korisnike",
|
"LabelSelectUsers": "Označi korisnike",
|
||||||
"LabelSendEbookToDevice": "Pošalji e-knjigu",
|
"LabelSendEbookToDevice": "Pošalji e-knjigu …",
|
||||||
"LabelSequence": "Slijed",
|
"LabelSequence": "Slijed",
|
||||||
"LabelSerial": "Serijal",
|
"LabelSerial": "Serijal",
|
||||||
"LabelSeries": "Serijal",
|
"LabelSeries": "Serijal",
|
||||||
@@ -567,7 +567,7 @@
|
|||||||
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)",
|
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)",
|
||||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada",
|
"LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako uključite ovu opciju, serijal će vam se nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako se ova opcija uključi serijal će nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.",
|
||||||
"LabelSettingsParseSubtitles": "Raščlani podnaslove",
|
"LabelSettingsParseSubtitles": "Raščlani podnaslove",
|
||||||
"LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.<br>Podnaslov mora biti odvojen s \" - \"<br>npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"",
|
"LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.<br>Podnaslov mora biti odvojen s \" - \"<br>npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"",
|
||||||
"LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki",
|
"LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki",
|
||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju",
|
"LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju",
|
||||||
"LabelUpdatedAt": "Ažurirano",
|
"LabelUpdatedAt": "Ažurirano",
|
||||||
"LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape",
|
"LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Pritisni i prevuci datoteke",
|
||||||
"LabelUploaderDropFiles": "Ispusti datoteke",
|
"LabelUploaderDropFiles": "Ispusti datoteke",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
|
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
|
||||||
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama",
|
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama",
|
||||||
@@ -678,6 +679,8 @@
|
|||||||
"LabelViewPlayerSettings": "Pogledaj postavke reproduktora",
|
"LabelViewPlayerSettings": "Pogledaj postavke reproduktora",
|
||||||
"LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora",
|
"LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora",
|
||||||
"LabelVolume": "Glasnoća",
|
"LabelVolume": "Glasnoća",
|
||||||
|
"LabelWebRedirectURLsDescription": "Autoriziraj ove URL-ove u svom pružatelju OAuth ovjere kako bi omogućio preusmjeravanje natrag na web-aplikaciju nakon prijave:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "Podmapa za URL-ove preusmjeravanja",
|
||||||
"LabelWeekdaysToRun": "Dani u tjednu za pokretanje",
|
"LabelWeekdaysToRun": "Dani u tjednu za pokretanje",
|
||||||
"LabelXBooks": "{0} knjiga",
|
"LabelXBooks": "{0} knjiga",
|
||||||
"LabelXItems": "{0} stavki",
|
"LabelXItems": "{0} stavki",
|
||||||
|
|||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
|
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
|
||||||
"LabelUpdatedAt": "Обновлено в",
|
"LabelUpdatedAt": "Обновлено в",
|
||||||
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
|
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Перетаскивание файлов",
|
||||||
"LabelUploaderDropFiles": "Перетащите файлы",
|
"LabelUploaderDropFiles": "Перетащите файлы",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии",
|
"LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии",
|
||||||
"LabelUseAdvancedOptions": "Используйте расширенные опции",
|
"LabelUseAdvancedOptions": "Используйте расширенные опции",
|
||||||
|
|||||||
@@ -184,7 +184,7 @@
|
|||||||
"HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod",
|
"HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod",
|
||||||
"HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice",
|
"HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice",
|
||||||
"HeaderSession": "Seja",
|
"HeaderSession": "Seja",
|
||||||
"HeaderSetBackupSchedule": "Nastavite urnik varnostnega kopiranja",
|
"HeaderSetBackupSchedule": "Nastavi urnik varnostnega kopiranja",
|
||||||
"HeaderSettings": "Nastavitve",
|
"HeaderSettings": "Nastavitve",
|
||||||
"HeaderSettingsDisplay": "Zaslon",
|
"HeaderSettingsDisplay": "Zaslon",
|
||||||
"HeaderSettingsExperimental": "Eksperimentalne funkcije",
|
"HeaderSettingsExperimental": "Eksperimentalne funkcije",
|
||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje",
|
"LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje",
|
||||||
"LabelUpdatedAt": "Posodobljeno ob",
|
"LabelUpdatedAt": "Posodobljeno ob",
|
||||||
"LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape",
|
"LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Povleci in spusti datoteke",
|
||||||
"LabelUploaderDropFiles": "Spusti datoteke",
|
"LabelUploaderDropFiles": "Spusti datoteke",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo",
|
"LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo",
|
||||||
"LabelUseAdvancedOptions": "Uporabi napredne možnosti",
|
"LabelUseAdvancedOptions": "Uporabi napredne možnosti",
|
||||||
@@ -678,6 +679,8 @@
|
|||||||
"LabelViewPlayerSettings": "Ogled nastavitev predvajalnika",
|
"LabelViewPlayerSettings": "Ogled nastavitev predvajalnika",
|
||||||
"LabelViewQueue": "Ogled čakalno vrsto predvajalnika",
|
"LabelViewQueue": "Ogled čakalno vrsto predvajalnika",
|
||||||
"LabelVolume": "Glasnost",
|
"LabelVolume": "Glasnost",
|
||||||
|
"LabelWebRedirectURLsDescription": "Avtorizirajte URL-je pri svojem ponudniku OAuth ter s tem omogočite preusmeritev nazaj v spletno aplikacijo po prijavi:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "Podmapa za URL-je preusmeritve",
|
||||||
"LabelWeekdaysToRun": "Delovni dnevi predvajanja",
|
"LabelWeekdaysToRun": "Delovni dnevi predvajanja",
|
||||||
"LabelXBooks": "{0} knjig",
|
"LabelXBooks": "{0} knjig",
|
||||||
"LabelXItems": "{0} elementov",
|
"LabelXItems": "{0} elementov",
|
||||||
@@ -829,7 +832,7 @@
|
|||||||
"MessageSearchResultsFor": "Rezultati iskanja za",
|
"MessageSearchResultsFor": "Rezultati iskanja za",
|
||||||
"MessageSelected": "{0} izbrano",
|
"MessageSelected": "{0} izbrano",
|
||||||
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
|
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
|
||||||
"MessageSetChaptersFromTracksDescription": "Nastavite poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke",
|
"MessageSetChaptersFromTracksDescription": "Nastavi poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke",
|
||||||
"MessageShareExpirationWillBe": "Potečeno bo <strong>{0}</strong>",
|
"MessageShareExpirationWillBe": "Potečeno bo <strong>{0}</strong>",
|
||||||
"MessageShareExpiresIn": "Poteče čez {0}",
|
"MessageShareExpiresIn": "Poteče čez {0}",
|
||||||
"MessageShareURLWillBe": "URL za skupno rabo bo <strong>{0}</strong>",
|
"MessageShareURLWillBe": "URL za skupno rabo bo <strong>{0}</strong>",
|
||||||
|
|||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення",
|
"LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення",
|
||||||
"LabelUpdatedAt": "Оновлення",
|
"LabelUpdatedAt": "Оновлення",
|
||||||
"LabelUploaderDragAndDrop": "Перетягніть файли або теки",
|
"LabelUploaderDragAndDrop": "Перетягніть файли або теки",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Перетягніть і скиньте файли",
|
||||||
"LabelUploaderDropFiles": "Перетягніть файли",
|
"LabelUploaderDropFiles": "Перетягніть файли",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію",
|
"LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію",
|
||||||
"LabelUseAdvancedOptions": "Використовувати розширені налаштування",
|
"LabelUseAdvancedOptions": "Використовувати розширені налаштування",
|
||||||
@@ -678,6 +679,8 @@
|
|||||||
"LabelViewPlayerSettings": "Переглянути налаштування програвача",
|
"LabelViewPlayerSettings": "Переглянути налаштування програвача",
|
||||||
"LabelViewQueue": "Переглянути чергу відтворення",
|
"LabelViewQueue": "Переглянути чергу відтворення",
|
||||||
"LabelVolume": "Гучність",
|
"LabelVolume": "Гучність",
|
||||||
|
"LabelWebRedirectURLsDescription": "Авторизуйте ці URL у вашому OAuth постачальнику, щоб дозволити редирекцію назад до веб-додатку після входу:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "Підпапка для Redirect URL",
|
||||||
"LabelWeekdaysToRun": "Виконувати у дні",
|
"LabelWeekdaysToRun": "Виконувати у дні",
|
||||||
"LabelXBooks": "{0} книг",
|
"LabelXBooks": "{0} книг",
|
||||||
"LabelXItems": "{0} елементів",
|
"LabelXItems": "{0} елементів",
|
||||||
|
|||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
|
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
|
||||||
"LabelUpdatedAt": "更新时间",
|
"LabelUpdatedAt": "更新时间",
|
||||||
"LabelUploaderDragAndDrop": "拖放文件或文件夹",
|
"LabelUploaderDragAndDrop": "拖放文件或文件夹",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "拖放文件",
|
||||||
"LabelUploaderDropFiles": "删除文件",
|
"LabelUploaderDropFiles": "删除文件",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列",
|
"LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列",
|
||||||
"LabelUseAdvancedOptions": "使用高级选项",
|
"LabelUseAdvancedOptions": "使用高级选项",
|
||||||
@@ -678,6 +679,8 @@
|
|||||||
"LabelViewPlayerSettings": "查看播放器设置",
|
"LabelViewPlayerSettings": "查看播放器设置",
|
||||||
"LabelViewQueue": "查看播放列表",
|
"LabelViewQueue": "查看播放列表",
|
||||||
"LabelVolume": "音量",
|
"LabelVolume": "音量",
|
||||||
|
"LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹",
|
||||||
"LabelWeekdaysToRun": "工作日运行",
|
"LabelWeekdaysToRun": "工作日运行",
|
||||||
"LabelXBooks": "{0} 本书",
|
"LabelXBooks": "{0} 本书",
|
||||||
"LabelXItems": "{0} 项目",
|
"LabelXItems": "{0} 项目",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ if (isDev) {
|
|||||||
if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath
|
if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath
|
||||||
if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath
|
if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath
|
||||||
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
|
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
|
||||||
|
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
||||||
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
||||||
process.env.SOURCE = 'local'
|
process.env.SOURCE = 'local'
|
||||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
|
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.17.2",
|
"version": "2.17.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.17.2",
|
"version": "2.17.5",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.17.2",
|
"version": "2.17.5",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/
|
|||||||
|
|
||||||
Join us on [Discord](https://discord.gg/HQgCbd6E75)
|
Join us on [Discord](https://discord.gg/HQgCbd6E75)
|
||||||
|
|
||||||
|
### Demo
|
||||||
|
|
||||||
|
Check out the web client demo: https://audiobooks.dev/ (thanks for hosting [@Vito0912](https://github.com/Vito0912)!)
|
||||||
|
|
||||||
|
Username/password: `demo`/`demo` (user account)
|
||||||
|
|
||||||
|
|
||||||
### Android App (beta)
|
### Android App (beta)
|
||||||
|
|
||||||
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||||
|
|||||||
+4
-4
@@ -131,7 +131,7 @@ class Auth {
|
|||||||
{
|
{
|
||||||
client: openIdClient,
|
client: openIdClient,
|
||||||
params: {
|
params: {
|
||||||
redirect_uri: '/auth/openid/callback',
|
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
|
||||||
scope: 'openid profile email'
|
scope: 'openid profile email'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -480,9 +480,9 @@ class Auth {
|
|||||||
// for the request to mobile-redirect and as such the session is not shared
|
// for the request to mobile-redirect and as such the session is not shared
|
||||||
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
|
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
|
||||||
|
|
||||||
redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString()
|
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
||||||
} else {
|
} else {
|
||||||
redirectUri = new URL('/auth/openid/callback', hostUrl).toString()
|
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
|
||||||
|
|
||||||
if (req.query.state) {
|
if (req.query.state) {
|
||||||
Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
|
Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
|
||||||
@@ -733,7 +733,7 @@ class Auth {
|
|||||||
const host = req.get('host')
|
const host = req.get('host')
|
||||||
// TODO: ABS does currently not support subfolders for installation
|
// TODO: ABS does currently not support subfolders for installation
|
||||||
// If we want to support it we need to include a config for the serverurl
|
// If we want to support it we need to include a config for the serverurl
|
||||||
postLogoutRedirectUri = `${protocol}://${host}/login`
|
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
|
||||||
}
|
}
|
||||||
// else for openid-mobile we keep postLogoutRedirectUri on null
|
// else for openid-mobile we keep postLogoutRedirectUri on null
|
||||||
// nice would be to redirect to the app here, but for example Authentik does not implement
|
// nice would be to redirect to the app here, but for example Authentik does not implement
|
||||||
|
|||||||
@@ -406,11 +406,6 @@ class Database {
|
|||||||
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
|
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
|
||||||
}
|
}
|
||||||
|
|
||||||
removeLibrary(libraryId) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
return this.models.library.removeById(libraryId)
|
|
||||||
}
|
|
||||||
|
|
||||||
createBulkCollectionBooks(collectionBooks) {
|
createBulkCollectionBooks(collectionBooks) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
return this.models.collectionBook.bulkCreate(collectionBooks)
|
return this.models.collectionBook.bulkCreate(collectionBooks)
|
||||||
@@ -449,21 +444,6 @@ class Database {
|
|||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFeed(oldFeed) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
await this.models.feed.fullCreateFromOld(oldFeed)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFeed(oldFeed) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
return this.models.feed.fullUpdateFromOld(oldFeed)
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeFeed(feedId) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
await this.models.feed.removeById(feedId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async createBulkBookAuthors(bookAuthors) {
|
async createBulkBookAuthors(bookAuthors) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.bookAuthor.bulkCreate(bookAuthors)
|
await this.models.bookAuthor.bulkCreate(bookAuthors)
|
||||||
|
|||||||
+21
-22
@@ -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()
|
||||||
@@ -84,7 +83,6 @@ class Server {
|
|||||||
Logger.logManager = new LogManager()
|
Logger.logManager = new LogManager()
|
||||||
|
|
||||||
this.server = null
|
this.server = null
|
||||||
this.io = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,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)
|
||||||
@@ -194,6 +192,12 @@ class Server {
|
|||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (!global.ServerSettings.allowIframe) {
|
||||||
|
// Prevent clickjacking by disallowing iframes
|
||||||
|
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @temporary
|
* @temporary
|
||||||
* This is necessary for the ebook & cover API endpoint in the mobile apps
|
* This is necessary for the ebook & cover API endpoint in the mobile apps
|
||||||
@@ -205,7 +209,6 @@ class Server {
|
|||||||
* Running in development allows cors to allow testing the mobile apps in the browser
|
* Running in development allows cors to allow testing the mobile apps in the browser
|
||||||
* or env variable ALLOW_CORS = '1'
|
* or env variable ALLOW_CORS = '1'
|
||||||
*/
|
*/
|
||||||
app.use((req, res, next) => {
|
|
||||||
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
|
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
|
||||||
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
|
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
|
||||||
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
|
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
|
||||||
@@ -246,14 +249,17 @@ class Server {
|
|||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
// if RouterBasePath is set, modify all requests to include the base path
|
// if RouterBasePath is set, modify all requests to include the base path
|
||||||
if (global.RouterBasePath) {
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (!req.url.startsWith(global.RouterBasePath)) {
|
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
||||||
|
const host = req.get('host')
|
||||||
|
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||||
|
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
|
||||||
|
req.originalHostPrefix = `${protocol}://${host}${prefix}`
|
||||||
|
if (!urlStartsWithRouterBasePath) {
|
||||||
req.url = `${global.RouterBasePath}${req.url}`
|
req.url = `${global.RouterBasePath}${req.url}`
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
}
|
|
||||||
app.use(global.RouterBasePath, router)
|
app.use(global.RouterBasePath, router)
|
||||||
app.disable('x-powered-by')
|
app.disable('x-powered-by')
|
||||||
|
|
||||||
@@ -284,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
|
||||||
@@ -438,18 +444,11 @@ class Server {
|
|||||||
async stop() {
|
async stop() {
|
||||||
Logger.info('=== Stopping Server ===')
|
Logger.info('=== Stopping Server ===')
|
||||||
Watcher.close()
|
Watcher.close()
|
||||||
Logger.info('Watcher Closed')
|
Logger.info('[Server] Watcher Closed')
|
||||||
|
await SocketAuthority.close()
|
||||||
return new Promise((resolve) => {
|
Logger.info('[Server] Closing HTTP Server')
|
||||||
SocketAuthority.close((err) => {
|
await new Promise((resolve) => this.server.close(resolve))
|
||||||
if (err) {
|
Logger.info('[Server] HTTP Server Closed')
|
||||||
Logger.error('Failed to close server', err)
|
|
||||||
} else {
|
|
||||||
Logger.info('Server successfully closed')
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = Server
|
module.exports = Server
|
||||||
|
|||||||
+34
-12
@@ -14,7 +14,7 @@ const Auth = require('./Auth')
|
|||||||
class SocketAuthority {
|
class SocketAuthority {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.Server = null
|
this.Server = null
|
||||||
this.io = null
|
this.socketIoServers = []
|
||||||
|
|
||||||
/** @type {Object.<string, SocketClient>} */
|
/** @type {Object.<string, SocketClient>} */
|
||||||
this.clients = {}
|
this.clients = {}
|
||||||
@@ -89,25 +89,46 @@ class SocketAuthority {
|
|||||||
*
|
*
|
||||||
* @param {Function} callback
|
* @param {Function} callback
|
||||||
*/
|
*/
|
||||||
close(callback) {
|
async close() {
|
||||||
Logger.info('[SocketAuthority] Shutting down')
|
Logger.info('[SocketAuthority] closing...')
|
||||||
// This will close all open socket connections, and also close the underlying http server
|
const closePromises = this.socketIoServers.map((io) => {
|
||||||
if (this.io) this.io.close(callback)
|
return new Promise((resolve) => {
|
||||||
else callback()
|
Logger.info(`[SocketAuthority] Closing Socket.IO server: ${io.path}`)
|
||||||
|
io.close(() => {
|
||||||
|
Logger.info(`[SocketAuthority] Socket.IO server closed: ${io.path}`)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await Promise.all(closePromises)
|
||||||
|
Logger.info('[SocketAuthority] closed')
|
||||||
|
this.socketIoServers = []
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize(Server) {
|
initialize(Server) {
|
||||||
this.Server = Server
|
this.Server = Server
|
||||||
|
|
||||||
this.io = new SocketIO.Server(this.Server.server, {
|
const socketIoOptions = {
|
||||||
cors: {
|
cors: {
|
||||||
origin: '*',
|
origin: '*',
|
||||||
methods: ['GET', 'POST']
|
methods: ['GET', 'POST']
|
||||||
},
|
}
|
||||||
path: `${global.RouterBasePath}/socket.io`
|
}
|
||||||
})
|
|
||||||
|
|
||||||
this.io.on('connection', (socket) => {
|
const ioServer = new SocketIO.Server(Server.server, socketIoOptions)
|
||||||
|
ioServer.path = '/socket.io'
|
||||||
|
this.socketIoServers.push(ioServer)
|
||||||
|
|
||||||
|
if (global.RouterBasePath) {
|
||||||
|
// open a separate socket.io server for the router base path, keeping the original server open for legacy clients
|
||||||
|
const ioBasePath = `${global.RouterBasePath}/socket.io`
|
||||||
|
const ioBasePathServer = new SocketIO.Server(Server.server, { ...socketIoOptions, path: ioBasePath })
|
||||||
|
ioBasePathServer.path = ioBasePath
|
||||||
|
this.socketIoServers.push(ioBasePathServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socketIoServers.forEach((io) => {
|
||||||
|
io.on('connection', (socket) => {
|
||||||
this.clients[socket.id] = {
|
this.clients[socket.id] = {
|
||||||
id: socket.id,
|
id: socket.id,
|
||||||
socket,
|
socket,
|
||||||
@@ -115,7 +136,7 @@ class SocketAuthority {
|
|||||||
}
|
}
|
||||||
socket.sheepClient = this.clients[socket.id]
|
socket.sheepClient = this.clients[socket.id]
|
||||||
|
|
||||||
Logger.info('[SocketAuthority] Socket Connected', socket.id)
|
Logger.info(`[SocketAuthority] Socket Connected to ${io.path}`, socket.id)
|
||||||
|
|
||||||
// Required for associating a User with a socket
|
// Required for associating a User with a socket
|
||||||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||||
@@ -167,6 +188,7 @@ class SocketAuthority {
|
|||||||
socket.emit('pong')
|
socket.emit('pong')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -400,19 +402,48 @@ class LibraryController {
|
|||||||
model: Database.podcastEpisodeModel,
|
model: Database.podcastEpisodeModel,
|
||||||
attributes: ['id']
|
attributes: ['id']
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.bookModel,
|
||||||
|
attributes: ['id'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.bookAuthorModel,
|
||||||
|
attributes: ['authorId']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.bookSeriesModel,
|
||||||
|
attributes: ['seriesId']
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`)
|
Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`)
|
||||||
|
const seriesIds = []
|
||||||
|
const authorIds = []
|
||||||
for (const libraryItem of libraryItemsInFolder) {
|
for (const libraryItem of libraryItemsInFolder) {
|
||||||
let mediaItemIds = []
|
let mediaItemIds = []
|
||||||
if (req.library.isPodcast) {
|
if (req.library.isPodcast) {
|
||||||
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
|
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
|
||||||
} else {
|
} else {
|
||||||
mediaItemIds.push(libraryItem.mediaId)
|
mediaItemIds.push(libraryItem.mediaId)
|
||||||
|
if (libraryItem.media.bookAuthors.length) {
|
||||||
|
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
|
||||||
|
}
|
||||||
|
if (libraryItem.media.bookSeries.length) {
|
||||||
|
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
|
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
|
||||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorIds.length) {
|
||||||
|
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||||
|
}
|
||||||
|
if (seriesIds.length) {
|
||||||
|
await this.checkRemoveEmptySeries(seriesIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove folder
|
// Remove folder
|
||||||
@@ -501,11 +532,24 @@ class LibraryController {
|
|||||||
mediaItemIds.push(libraryItem.mediaId)
|
mediaItemIds.push(libraryItem.mediaId)
|
||||||
}
|
}
|
||||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
|
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
|
||||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set PlaybackSessions libraryId to null
|
||||||
|
const [sessionsUpdated] = await Database.playbackSessionModel.update(
|
||||||
|
{
|
||||||
|
libraryId: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
libraryId: req.library.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Logger.info(`[LibraryController] Updated ${sessionsUpdated} playback sessions to remove library id`)
|
||||||
|
|
||||||
const libraryJson = req.library.toOldJSON()
|
const libraryJson = req.library.toOldJSON()
|
||||||
await Database.removeLibrary(req.library.id)
|
await req.library.destroy()
|
||||||
|
|
||||||
// Re-order libraries
|
// Re-order libraries
|
||||||
await Database.libraryModel.resetDisplayOrder()
|
await Database.libraryModel.resetDisplayOrder()
|
||||||
@@ -567,6 +611,8 @@ class LibraryController {
|
|||||||
* DELETE: /api/libraries/:id/issues
|
* DELETE: /api/libraries/:id/issues
|
||||||
* Remove all library items missing or invalid
|
* Remove all library items missing or invalid
|
||||||
*
|
*
|
||||||
|
* @this {import('../routers/ApiRouter')}
|
||||||
|
*
|
||||||
* @param {LibraryControllerRequest} req
|
* @param {LibraryControllerRequest} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
@@ -592,6 +638,20 @@ class LibraryController {
|
|||||||
model: Database.podcastEpisodeModel,
|
model: Database.podcastEpisodeModel,
|
||||||
attributes: ['id']
|
attributes: ['id']
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.bookModel,
|
||||||
|
attributes: ['id'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.bookAuthorModel,
|
||||||
|
attributes: ['authorId']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.bookSeriesModel,
|
||||||
|
attributes: ['seriesId']
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -602,15 +662,30 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
|
Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
|
||||||
|
const authorIds = []
|
||||||
|
const seriesIds = []
|
||||||
for (const libraryItem of libraryItemsWithIssues) {
|
for (const libraryItem of libraryItemsWithIssues) {
|
||||||
let mediaItemIds = []
|
let mediaItemIds = []
|
||||||
if (req.library.isPodcast) {
|
if (req.library.isPodcast) {
|
||||||
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
|
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
|
||||||
} else {
|
} else {
|
||||||
mediaItemIds.push(libraryItem.mediaId)
|
mediaItemIds.push(libraryItem.mediaId)
|
||||||
|
if (libraryItem.media.bookAuthors.length) {
|
||||||
|
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
|
||||||
|
}
|
||||||
|
if (libraryItem.media.bookSeries.length) {
|
||||||
|
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
|
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
|
||||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorIds.length) {
|
||||||
|
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||||
|
}
|
||||||
|
if (seriesIds.length) {
|
||||||
|
await this.checkRemoveEmptySeries(seriesIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set numIssues to 0 for library filter data
|
// Set numIssues to 0 for library filter data
|
||||||
@@ -686,8 +761,8 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (include.includes('rssfeed')) {
|
if (include.includes('rssfeed')) {
|
||||||
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
|
const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||||
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
|
seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(seriesJson)
|
res.json(seriesJson)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUti
|
|||||||
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
||||||
const AudioFileScanner = require('../scanner/AudioFileScanner')
|
const AudioFileScanner = require('../scanner/AudioFileScanner')
|
||||||
const Scanner = require('../scanner/Scanner')
|
const Scanner = require('../scanner/Scanner')
|
||||||
|
|
||||||
|
const RssFeedManager = require('../managers/RssFeedManager')
|
||||||
const CacheManager = require('../managers/CacheManager')
|
const CacheManager = require('../managers/CacheManager')
|
||||||
const CoverManager = require('../managers/CoverManager')
|
const CoverManager = require('../managers/CoverManager')
|
||||||
const ShareManager = require('../managers/ShareManager')
|
const ShareManager = require('../managers/ShareManager')
|
||||||
@@ -48,8 +50,8 @@ class LibraryItemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (includeEntities.includes('rssfeed')) {
|
if (includeEntities.includes('rssfeed')) {
|
||||||
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id)
|
const feedData = await RssFeedManager.findFeedForEntityId(item.id)
|
||||||
item.rssFeed = feedData?.toJSONMinified() || null
|
item.rssFeed = feedData?.toOldJSONMinified() || null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {
|
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {
|
||||||
@@ -96,6 +98,8 @@ class LibraryItemController {
|
|||||||
* Optional query params:
|
* Optional query params:
|
||||||
* ?hard=1
|
* ?hard=1
|
||||||
*
|
*
|
||||||
|
* @this {import('../routers/ApiRouter')}
|
||||||
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
@@ -103,14 +107,36 @@ class LibraryItemController {
|
|||||||
const hardDelete = req.query.hard == 1 // Delete from file system
|
const hardDelete = req.query.hard == 1 // Delete from file system
|
||||||
const libraryItemPath = req.libraryItem.path
|
const libraryItemPath = req.libraryItem.path
|
||||||
|
|
||||||
const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id]
|
const mediaItemIds = []
|
||||||
await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds)
|
const authorIds = []
|
||||||
|
const seriesIds = []
|
||||||
|
if (req.libraryItem.isPodcast) {
|
||||||
|
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id))
|
||||||
|
} else {
|
||||||
|
mediaItemIds.push(req.libraryItem.media.id)
|
||||||
|
if (req.libraryItem.media.metadata.authors?.length) {
|
||||||
|
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id))
|
||||||
|
}
|
||||||
|
if (req.libraryItem.media.metadata.series?.length) {
|
||||||
|
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
|
||||||
if (hardDelete) {
|
if (hardDelete) {
|
||||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||||
await fs.remove(libraryItemPath).catch((error) => {
|
await fs.remove(libraryItemPath).catch((error) => {
|
||||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authorIds.length) {
|
||||||
|
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||||
|
}
|
||||||
|
if (seriesIds.length) {
|
||||||
|
await this.checkRemoveEmptySeries(seriesIds)
|
||||||
|
}
|
||||||
|
|
||||||
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
|
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
@@ -212,15 +238,6 @@ class LibraryItemController {
|
|||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
libraryItem.updatedAt = Date.now()
|
libraryItem.updatedAt = Date.now()
|
||||||
|
|
||||||
if (seriesRemoved.length) {
|
|
||||||
// Check remove empty series
|
|
||||||
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
|
||||||
await this.checkRemoveEmptySeries(
|
|
||||||
libraryItem.media.id,
|
|
||||||
seriesRemoved.map((se) => se.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPodcastAutoDownloadUpdated) {
|
if (isPodcastAutoDownloadUpdated) {
|
||||||
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
||||||
}
|
}
|
||||||
@@ -232,10 +249,12 @@ class LibraryItemController {
|
|||||||
if (authorsRemoved.length) {
|
if (authorsRemoved.length) {
|
||||||
// Check remove empty authors
|
// Check remove empty authors
|
||||||
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
|
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
|
||||||
await this.checkRemoveAuthorsWithNoBooks(
|
await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
|
||||||
libraryItem.libraryId,
|
}
|
||||||
authorsRemoved.map((au) => au.id)
|
if (seriesRemoved.length) {
|
||||||
)
|
// Check remove empty series
|
||||||
|
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
|
||||||
|
await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res.json({
|
res.json({
|
||||||
@@ -450,6 +469,8 @@ class LibraryItemController {
|
|||||||
* Optional query params:
|
* Optional query params:
|
||||||
* ?hard=1
|
* ?hard=1
|
||||||
*
|
*
|
||||||
|
* @this {import('../routers/ApiRouter')}
|
||||||
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
@@ -477,14 +498,33 @@ class LibraryItemController {
|
|||||||
for (const libraryItem of itemsToDelete) {
|
for (const libraryItem of itemsToDelete) {
|
||||||
const libraryItemPath = libraryItem.path
|
const libraryItemPath = libraryItem.path
|
||||||
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
|
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
|
||||||
const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id]
|
const mediaItemIds = []
|
||||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
const seriesIds = []
|
||||||
|
const authorIds = []
|
||||||
|
if (libraryItem.isPodcast) {
|
||||||
|
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id))
|
||||||
|
} else {
|
||||||
|
mediaItemIds.push(libraryItem.media.id)
|
||||||
|
if (libraryItem.media.metadata.series?.length) {
|
||||||
|
seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id))
|
||||||
|
}
|
||||||
|
if (libraryItem.media.metadata.authors?.length) {
|
||||||
|
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||||
if (hardDelete) {
|
if (hardDelete) {
|
||||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||||
await fs.remove(libraryItemPath).catch((error) => {
|
await fs.remove(libraryItemPath).catch((error) => {
|
||||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (seriesIds.length) {
|
||||||
|
await this.checkRemoveEmptySeries(seriesIds)
|
||||||
|
}
|
||||||
|
if (authorIds.length) {
|
||||||
|
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Database.resetLibraryIssuesFilterData(libraryId)
|
await Database.resetLibraryIssuesFilterData(libraryId)
|
||||||
@@ -494,48 +534,74 @@ class LibraryItemController {
|
|||||||
/**
|
/**
|
||||||
* POST: /api/items/batch/update
|
* POST: /api/items/batch/update
|
||||||
*
|
*
|
||||||
|
* @this {import('../routers/ApiRouter')}
|
||||||
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async batchUpdate(req, res) {
|
async batchUpdate(req, res) {
|
||||||
const updatePayloads = req.body
|
const updatePayloads = req.body
|
||||||
if (!updatePayloads?.length) {
|
if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
|
||||||
return res.sendStatus(500)
|
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that each update payload has a unique library item id
|
||||||
|
const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))]
|
||||||
|
if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) {
|
||||||
|
Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all library items to update
|
||||||
|
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||||
|
id: libraryItemIds
|
||||||
|
})
|
||||||
|
if (updatePayloads.length !== libraryItems.length) {
|
||||||
|
Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`)
|
||||||
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
let itemsUpdated = 0
|
let itemsUpdated = 0
|
||||||
|
|
||||||
|
const seriesIdsRemoved = []
|
||||||
|
const authorIdsRemoved = []
|
||||||
|
|
||||||
for (const updatePayload of updatePayloads) {
|
for (const updatePayload of updatePayloads) {
|
||||||
const mediaPayload = updatePayload.mediaPayload
|
const mediaPayload = updatePayload.mediaPayload
|
||||||
const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
|
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
|
||||||
if (!libraryItem) return null
|
|
||||||
|
|
||||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
||||||
|
|
||||||
let seriesRemoved = []
|
if (libraryItem.isBook) {
|
||||||
if (libraryItem.isBook && mediaPayload.metadata?.series) {
|
if (Array.isArray(mediaPayload.metadata?.series)) {
|
||||||
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id)
|
const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
|
||||||
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||||
|
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
|
||||||
|
}
|
||||||
|
if (Array.isArray(mediaPayload.metadata?.authors)) {
|
||||||
|
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
|
||||||
|
const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
|
||||||
|
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItem.media.update(mediaPayload)) {
|
if (libraryItem.media.update(mediaPayload)) {
|
||||||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||||
|
|
||||||
if (seriesRemoved.length) {
|
|
||||||
// Check remove empty series
|
|
||||||
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
|
||||||
await this.checkRemoveEmptySeries(
|
|
||||||
libraryItem.media.id,
|
|
||||||
seriesRemoved.map((se) => se.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await Database.updateLibraryItem(libraryItem)
|
await Database.updateLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
itemsUpdated++
|
itemsUpdated++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (seriesIdsRemoved.length) {
|
||||||
|
await this.checkRemoveEmptySeries(seriesIdsRemoved)
|
||||||
|
}
|
||||||
|
if (authorIdsRemoved.length) {
|
||||||
|
await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
updates: itemsUpdated
|
updates: itemsUpdated
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ class MiscController {
|
|||||||
if (!isObject(settingsUpdate)) {
|
if (!isObject(settingsUpdate)) {
|
||||||
return res.status(400).send('Invalid settings update object')
|
return res.status(400).send('Invalid settings update object')
|
||||||
}
|
}
|
||||||
|
if (settingsUpdate.allowIframe == false && process.env.ALLOW_IFRAME === '1') {
|
||||||
|
Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
|
||||||
|
return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
|
||||||
|
}
|
||||||
|
|
||||||
const madeUpdates = Database.serverSettings.update(settingsUpdate)
|
const madeUpdates = Database.serverSettings.update(settingsUpdate)
|
||||||
if (madeUpdates) {
|
if (madeUpdates) {
|
||||||
@@ -137,7 +141,6 @@ class MiscController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
|
||||||
serverSettings: Database.serverSettings.toJSONForBrowser()
|
serverSettings: Database.serverSettings.toJSONForBrowser()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -679,9 +682,9 @@ class MiscController {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let updatedValue = settingsUpdate[key]
|
let updatedValue = settingsUpdate[key]
|
||||||
if (updatedValue === '') updatedValue = null
|
if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
|
||||||
let currentValue = currentAuthenticationSettings[key]
|
let currentValue = currentAuthenticationSettings[key]
|
||||||
if (currentValue === '') currentValue = null
|
if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null
|
||||||
|
|
||||||
if (updatedValue !== currentValue) {
|
if (updatedValue !== currentValue) {
|
||||||
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
|
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -368,6 +368,19 @@ class UserController {
|
|||||||
await playlist.destroy()
|
await playlist.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set PlaybackSessions userId to null
|
||||||
|
const [sessionsUpdated] = await Database.playbackSessionModel.update(
|
||||||
|
{
|
||||||
|
userId: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
userId: user.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Logger.info(`[UserController] Updated ${sessionsUpdated} playback sessions to remove user id`)
|
||||||
|
|
||||||
const userJson = user.toOldJSONForBrowser()
|
const userJson = user.toOldJSONForBrowser()
|
||||||
await user.destroy()
|
await user.destroy()
|
||||||
SocketAuthority.adminEmitter('user_removed', userJson)
|
SocketAuthority.adminEmitter('user_removed', userJson)
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class CacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async purgeEntityCache(entityId, cachePath) {
|
async purgeEntityCache(entityId, cachePath) {
|
||||||
|
if (!entityId || !cachePath) return []
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
(await fs.readdir(cachePath)).reduce((promises, file) => {
|
(await fs.readdir(cachePath)).reduce((promises, file) => {
|
||||||
if (file.startsWith(entityId)) {
|
if (file.startsWith(entityId)) {
|
||||||
|
|||||||
@@ -93,8 +93,11 @@ class CoverManager {
|
|||||||
const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`)
|
const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`)
|
||||||
|
|
||||||
// Move cover from temp upload dir to destination
|
// Move cover from temp upload dir to destination
|
||||||
const success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
|
const success = await coverFile
|
||||||
Logger.error('[CoverManager] Failed to move cover file', path, error)
|
.mv(coverFullPath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
|
Logger.error('[CoverManager] Failed to move cover file', coverFullPath, error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -124,7 +127,9 @@ class CoverManager {
|
|||||||
var temppath = Path.posix.join(coverDirPath, 'cover')
|
var temppath = Path.posix.join(coverDirPath, 'cover')
|
||||||
|
|
||||||
let errorMsg = ''
|
let errorMsg = ''
|
||||||
let success = await downloadImageFile(url, temppath).then(() => true).catch((err) => {
|
let success = await downloadImageFile(url, temppath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch((err) => {
|
||||||
errorMsg = err.message || 'Unknown error'
|
errorMsg = err.message || 'Unknown error'
|
||||||
Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
|
Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
|
||||||
return false
|
return false
|
||||||
@@ -180,7 +185,7 @@ class CoverManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cover path does not exist
|
// Cover path does not exist
|
||||||
if (!await fs.pathExists(coverPath)) {
|
if (!(await fs.pathExists(coverPath))) {
|
||||||
Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
|
Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
|
||||||
return {
|
return {
|
||||||
error: 'Cover path does not exist'
|
error: 'Cover path does not exist'
|
||||||
@@ -188,7 +193,7 @@ class CoverManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cover path is not a file
|
// Cover path is not a file
|
||||||
if (!await checkPathIsFile(coverPath)) {
|
if (!(await checkPathIsFile(coverPath))) {
|
||||||
Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`)
|
Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`)
|
||||||
return {
|
return {
|
||||||
error: 'Cover path is not a file'
|
error: 'Cover path is not a file'
|
||||||
@@ -211,7 +216,10 @@ class CoverManager {
|
|||||||
var newCoverPath = Path.posix.join(coverDirPath, coverFilename)
|
var newCoverPath = Path.posix.join(coverDirPath, coverFilename)
|
||||||
Logger.debug(`[CoverManager] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`)
|
Logger.debug(`[CoverManager] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`)
|
||||||
|
|
||||||
var copySuccess = await fs.copy(coverPath, newCoverPath, { overwrite: true }).then(() => true).catch((error) => {
|
var copySuccess = await fs
|
||||||
|
.copy(coverPath, newCoverPath, { overwrite: true })
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
Logger.error(`[CoverManager] validate cover path failed to copy cover`, error)
|
Logger.error(`[CoverManager] validate cover path failed to copy cover`, error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -243,7 +251,7 @@ class CoverManager {
|
|||||||
* @returns {Promise<string>} returns cover path
|
* @returns {Promise<string>} returns cover path
|
||||||
*/
|
*/
|
||||||
async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) {
|
async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) {
|
||||||
let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt)
|
let audioFileWithCover = audioFiles.find((af) => af.embeddedCoverArt)
|
||||||
if (!audioFileWithCover) return null
|
if (!audioFileWithCover) return null
|
||||||
|
|
||||||
let coverDirPath = null
|
let coverDirPath = null
|
||||||
@@ -328,7 +336,9 @@ class CoverManager {
|
|||||||
await fs.ensureDir(coverDirPath)
|
await fs.ensureDir(coverDirPath)
|
||||||
|
|
||||||
const temppath = Path.posix.join(coverDirPath, 'cover')
|
const temppath = Path.posix.join(coverDirPath, 'cover')
|
||||||
const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => {
|
const success = await downloadImageFile(url, temppath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch((err) => {
|
||||||
Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
|
Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+257
-182
@@ -1,3 +1,4 @@
|
|||||||
|
const { Request, Response } = require('express')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
|
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
@@ -5,170 +6,190 @@ const SocketAuthority = require('../SocketAuthority')
|
|||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const Feed = require('../objects/Feed')
|
|
||||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
|
||||||
|
|
||||||
class RssFeedManager {
|
class RssFeedManager {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
async validateFeedEntity(feedObj) {
|
|
||||||
if (feedObj.entityType === 'collection') {
|
|
||||||
const collection = await Database.collectionModel.getOldById(feedObj.entityId)
|
|
||||||
if (!collection) {
|
|
||||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} else if (feedObj.entityType === 'libraryItem') {
|
|
||||||
const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
|
|
||||||
if (!libraryItemExists) {
|
|
||||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} else if (feedObj.entityType === 'series') {
|
|
||||||
const series = await Database.seriesModel.findByPk(feedObj.entityId)
|
|
||||||
if (!series) {
|
|
||||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Invalid entityType "${feedObj.entityType}"`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate all feeds and remove invalid
|
* Remove invalid feeds (invalid if the entity does not exist)
|
||||||
*/
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
const feeds = await Database.feedModel.getOldFeeds()
|
const feeds = await Database.feedModel.findAll({
|
||||||
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /feed/:slug
|
||||||
|
*
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
async getFeed(req, res) {
|
async getFeed(req, res) {
|
||||||
const feed = await this.findFeedBySlug(req.params.slug)
|
let feed = await Database.feedModel.findOne({
|
||||||
|
where: {
|
||||||
|
slug: req.params.slug
|
||||||
|
}
|
||||||
|
})
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||||
res.sendStatus(404)
|
res.sendStatus(404)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if feed needs to be updated
|
const feedRequiresUpdate = await this.checkFeedRequiresUpdate(feed)
|
||||||
if (feed.entityType === 'libraryItem') {
|
if (feedRequiresUpdate) {
|
||||||
const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId)
|
Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
|
||||||
|
feed = await feed.updateFeedForEntity()
|
||||||
let mostRecentlyUpdatedAt = libraryItem.updatedAt
|
} else {
|
||||||
if (libraryItem.isPodcast) {
|
feed.feedEpisodes = await feed.getFeedEpisodes()
|
||||||
libraryItem.media.episodes.forEach((episode) => {
|
|
||||||
if (episode.updatedAt > mostRecentlyUpdatedAt) mostRecentlyUpdatedAt = episode.updatedAt
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) {
|
const xml = feed.buildXml(req.originalHostPrefix)
|
||||||
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()
|
|
||||||
res.set('Content-Type', 'text/xml')
|
res.set('Content-Type', 'text/xml')
|
||||||
res.send(xml)
|
res.send(xml)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /feed/:slug/item/:episodeId/*
|
||||||
|
*
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
async getFeedItem(req, res) {
|
async getFeedItem(req, res) {
|
||||||
const feed = await this.findFeedBySlug(req.params.slug)
|
const feed = await Database.feedModel.findOne({
|
||||||
|
where: {
|
||||||
|
slug: req.params.slug
|
||||||
|
},
|
||||||
|
attributes: ['id', 'slug'],
|
||||||
|
include: {
|
||||||
|
model: Database.feedEpisodeModel,
|
||||||
|
attributes: ['id', 'filePath']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||||
res.sendStatus(404)
|
res.sendStatus(404)
|
||||||
@@ -183,8 +204,19 @@ class RssFeedManager {
|
|||||||
res.sendFile(episodePath)
|
res.sendFile(episodePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /feed/:slug/cover*
|
||||||
|
*
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
async getFeedCover(req, res) {
|
async getFeedCover(req, res) {
|
||||||
const feed = await this.findFeedBySlug(req.params.slug)
|
const feed = await Database.feedModel.findOne({
|
||||||
|
where: {
|
||||||
|
slug: req.params.slug
|
||||||
|
},
|
||||||
|
attributes: ['coverPath']
|
||||||
|
})
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||||
res.sendStatus(404)
|
res.sendStatus(404)
|
||||||
@@ -204,100 +236,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = RssFeedManager
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns {Promise<import('../models/Feed').FeedExpanded[]>}
|
||||||
|
*/
|
||||||
|
getFeeds() {
|
||||||
|
return Database.feedModel.findAll({
|
||||||
|
include: {
|
||||||
|
model: Database.feedEpisodeModel
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = new RssFeedManager()
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.
|
Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.
|
||||||
|
|
||||||
| Server Version | Migration Script Name | Description |
|
| Server Version | Migration Script Name | Description |
|
||||||
| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ |
|
| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
|
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
|
||||||
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
|
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
|
||||||
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
|
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
|
||||||
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
|
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
|
||||||
|
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
|
||||||
|
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
|
||||||
|
| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables |
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* @typedef MigrationContext
|
||||||
|
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||||
|
* @property {import('../Logger')} logger - a Logger object.
|
||||||
|
*
|
||||||
|
* @typedef MigrationOptions
|
||||||
|
* @property {MigrationContext} context - an object containing the migration context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This upward migration script changes foreign key constraints for the
|
||||||
|
* libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, and mediaProgresses tables.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function up({ context: { queryInterface, logger } }) {
|
||||||
|
// Upwards migration script
|
||||||
|
logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints')
|
||||||
|
|
||||||
|
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
|
||||||
|
|
||||||
|
// Disable foreign key constraints for the next sequence of operations
|
||||||
|
await execQuery(`PRAGMA foreign_keys = OFF;`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execQuery(`BEGIN TRANSACTION;`)
|
||||||
|
|
||||||
|
logger.info('[2.17.3 migration] Updating libraryItems constraints')
|
||||||
|
const libraryItemsConstraints = [
|
||||||
|
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
|
||||||
|
{ field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
|
||||||
|
]
|
||||||
|
if (await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints)) {
|
||||||
|
logger.info('[2.17.3 migration] Finished updating libraryItems constraints')
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] No changes needed for libraryItems constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[2.17.3 migration] Updating feeds constraints')
|
||||||
|
const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
|
||||||
|
if (await changeConstraints(queryInterface, 'feeds', feedsConstraints)) {
|
||||||
|
logger.info('[2.17.3 migration] Finished updating feeds constraints')
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] No changes needed for feeds constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await queryInterface.tableExists('mediaItemShares')) {
|
||||||
|
logger.info('[2.17.3 migration] Updating mediaItemShares constraints')
|
||||||
|
const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
|
||||||
|
if (await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints)) {
|
||||||
|
logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints')
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] No changes needed for mediaItemShares constraints')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[2.17.3 migration] Updating playbackSessions constraints')
|
||||||
|
const playbackSessionsConstraints = [
|
||||||
|
{ field: 'deviceId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
|
||||||
|
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
|
||||||
|
{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
|
||||||
|
]
|
||||||
|
if (await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints)) {
|
||||||
|
logger.info('[2.17.3 migration] Finished updating playbackSessions constraints')
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] No changes needed for playbackSessions constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[2.17.3 migration] Updating playlistMediaItems constraints')
|
||||||
|
const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
|
||||||
|
if (await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints)) {
|
||||||
|
logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints')
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] No changes needed for playlistMediaItems constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[2.17.3 migration] Updating mediaProgresses constraints')
|
||||||
|
const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
|
||||||
|
if (await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints)) {
|
||||||
|
logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints')
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] No changes needed for mediaProgresses constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
await execQuery(`COMMIT;`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[2.17.3 migration] Migration failed - rolling back. Error:`, error)
|
||||||
|
await execQuery(`ROLLBACK;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await execQuery(`PRAGMA foreign_keys = ON;`)
|
||||||
|
|
||||||
|
// Completed migration
|
||||||
|
logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This downward migration script is a no-op.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function down({ context: { queryInterface, logger } }) {
|
||||||
|
// Downward migration script
|
||||||
|
logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-fk-constraints')
|
||||||
|
|
||||||
|
// This migration is a no-op
|
||||||
|
logger.info('[2.17.3 migration] No action required for downgrade')
|
||||||
|
|
||||||
|
// Completed migration
|
||||||
|
logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-fk-constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef ConstraintUpdateObj
|
||||||
|
* @property {string} field - The field to update
|
||||||
|
* @property {string} onDelete - The onDelete constraint
|
||||||
|
* @property {string} onUpdate - The onUpdate constraint
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef SequelizeFKObj
|
||||||
|
* @property {{ model: string, key: string }} references
|
||||||
|
* @property {string} onDelete
|
||||||
|
* @property {string} onUpdate
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} fk - The foreign key object from PRAGMA foreign_key_list
|
||||||
|
* @returns {SequelizeFKObj} - The foreign key object formatted for Sequelize
|
||||||
|
*/
|
||||||
|
const formatFKsPragmaToSequelizeFK = (fk) => {
|
||||||
|
return {
|
||||||
|
references: {
|
||||||
|
model: fk.table,
|
||||||
|
key: fk.to
|
||||||
|
},
|
||||||
|
onDelete: fk['on_delete'],
|
||||||
|
onUpdate: fk['on_update']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('sequelize').QueryInterface} queryInterface
|
||||||
|
* @param {string} tableName
|
||||||
|
* @param {ConstraintUpdateObj[]} constraints
|
||||||
|
* @returns {Promise<Record<string, SequelizeFKObj>|null>}
|
||||||
|
*/
|
||||||
|
async function getUpdatedForeignKeys(queryInterface, tableName, constraints) {
|
||||||
|
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
|
||||||
|
const quotedTableName = queryInterface.quoteIdentifier(tableName)
|
||||||
|
|
||||||
|
const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`)
|
||||||
|
|
||||||
|
let hasUpdates = false
|
||||||
|
const foreignKeysByColName = foreignKeys.reduce((prev, curr) => {
|
||||||
|
const fk = formatFKsPragmaToSequelizeFK(curr)
|
||||||
|
|
||||||
|
const constraint = constraints.find((c) => c.field === curr.from)
|
||||||
|
if (constraint && (constraint.onDelete !== fk.onDelete || constraint.onUpdate !== fk.onUpdate)) {
|
||||||
|
fk.onDelete = constraint.onDelete
|
||||||
|
fk.onUpdate = constraint.onUpdate
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...prev, [curr.from]: fk }
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
return hasUpdates ? foreignKeysByColName : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends the Sequelize describeTable function to include the updated foreign key constraints
|
||||||
|
*
|
||||||
|
* @param {import('sequelize').QueryInterface} queryInterface
|
||||||
|
* @param {String} tableName
|
||||||
|
* @param {Record<string, SequelizeFKObj>} updatedForeignKeys
|
||||||
|
*/
|
||||||
|
async function describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) {
|
||||||
|
const tableDescription = await queryInterface.describeTable(tableName)
|
||||||
|
|
||||||
|
const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => {
|
||||||
|
let extendedAttributes = attributes
|
||||||
|
|
||||||
|
if (updatedForeignKeys[col]) {
|
||||||
|
extendedAttributes = {
|
||||||
|
...extendedAttributes,
|
||||||
|
...updatedForeignKeys[col]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...prev, [col]: extendedAttributes }
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
return tableDescriptionWithFks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://www.sqlite.org/lang_altertable.html#otheralter
|
||||||
|
* @see https://sequelize.org/docs/v6/other-topics/query-interface/#changing-and-removing-columns-in-sqlite
|
||||||
|
*
|
||||||
|
* @param {import('sequelize').QueryInterface} queryInterface
|
||||||
|
* @param {string} tableName
|
||||||
|
* @param {ConstraintUpdateObj[]} constraints
|
||||||
|
* @returns {Promise<boolean>} - Return false if no changes are needed, true otherwise
|
||||||
|
*/
|
||||||
|
async function changeConstraints(queryInterface, tableName, constraints) {
|
||||||
|
const updatedForeignKeys = await getUpdatedForeignKeys(queryInterface, tableName, constraints)
|
||||||
|
if (!updatedForeignKeys) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
|
||||||
|
const quotedTableName = queryInterface.quoteIdentifier(tableName)
|
||||||
|
|
||||||
|
const backupTableName = `${tableName}_${Math.round(Math.random() * 100)}_backup`
|
||||||
|
const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, updatedForeignKeys)
|
||||||
|
|
||||||
|
const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks)
|
||||||
|
|
||||||
|
// Create the backup table
|
||||||
|
await queryInterface.createTable(backupTableName, attributes)
|
||||||
|
|
||||||
|
const attributeNames = Object.keys(attributes)
|
||||||
|
.map((attr) => queryInterface.quoteIdentifier(attr))
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
// Copy all data from the target table to the backup table
|
||||||
|
await execQuery(`INSERT INTO ${quotedBackupTableName} SELECT ${attributeNames} FROM ${quotedTableName};`)
|
||||||
|
|
||||||
|
// Drop the old (original) table
|
||||||
|
await queryInterface.dropTable(tableName)
|
||||||
|
|
||||||
|
// Rename the backup table to the original table's name
|
||||||
|
await queryInterface.renameTable(backupTableName, tableName)
|
||||||
|
|
||||||
|
// Validate that all foreign key constraints are correct
|
||||||
|
const result = await execQuery(`PRAGMA foreign_key_check(${quotedTableName});`, {
|
||||||
|
type: queryInterface.sequelize.Sequelize.QueryTypes.SELECT
|
||||||
|
})
|
||||||
|
|
||||||
|
// There are foreign key violations, exit
|
||||||
|
if (result.length) {
|
||||||
|
return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down }
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* @typedef MigrationContext
|
||||||
|
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||||
|
* @property {import('../Logger')} logger - a Logger object.
|
||||||
|
*
|
||||||
|
* @typedef MigrationOptions
|
||||||
|
* @property {MigrationContext} context - an object containing the migration context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This upward migration adds an subfolder setting for OIDC redirect URIs.
|
||||||
|
* It updates existing OIDC setups to set this option to None (empty subfolder), so they continue to work as before.
|
||||||
|
* IF OIDC is not enabled, no action is taken (i.e. the subfolder is left undefined),
|
||||||
|
* so that future OIDC setups will use the default subfolder.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function up({ context: { queryInterface, logger } }) {
|
||||||
|
// Upwards migration script
|
||||||
|
logger.info('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')
|
||||||
|
|
||||||
|
const serverSettings = await getServerSettings(queryInterface, logger)
|
||||||
|
if (serverSettings.authActiveAuthMethods?.includes('openid')) {
|
||||||
|
logger.info('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')
|
||||||
|
serverSettings.authOpenIDSubfolderForRedirectURLs = ''
|
||||||
|
await updateServerSettings(queryInterface, logger, serverSettings)
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.4 migration] OIDC is not enabled, no action required')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This downward migration script removes the subfolder setting for OIDC redirect URIs.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function down({ context: { queryInterface, logger } }) {
|
||||||
|
// Downward migration script
|
||||||
|
logger.info('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
|
||||||
|
|
||||||
|
// Remove the OIDC subfolder option from the server settings
|
||||||
|
const serverSettings = await getServerSettings(queryInterface, logger)
|
||||||
|
if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) {
|
||||||
|
logger.info('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')
|
||||||
|
delete serverSettings.authOpenIDSubfolderForRedirectURLs
|
||||||
|
await updateServerSettings(queryInterface, logger, serverSettings)
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getServerSettings(queryInterface, logger) {
|
||||||
|
const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";')
|
||||||
|
if (!result[0].length) {
|
||||||
|
logger.error('[2.17.4 migration] Server settings not found')
|
||||||
|
throw new Error('Server settings not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverSettings = null
|
||||||
|
try {
|
||||||
|
serverSettings = JSON.parse(result[0][0].value)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[2.17.4 migration] Error parsing server settings:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateServerSettings(queryInterface, logger, serverSettings) {
|
||||||
|
await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = "server-settings";', {
|
||||||
|
replacements: {
|
||||||
|
value: JSON.stringify(serverSettings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down }
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* @typedef MigrationContext
|
||||||
|
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||||
|
* @property {import('../Logger')} logger - a Logger object.
|
||||||
|
*
|
||||||
|
* @typedef MigrationOptions
|
||||||
|
* @property {MigrationContext} context - an object containing the migration context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const migrationVersion = '2.17.5'
|
||||||
|
const migrationName = `${migrationVersion}-remove-host-from-feed-urls`
|
||||||
|
const loggerPrefix = `[${migrationVersion} migration]`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This upward migration removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function up({ context: { queryInterface, logger } }) {
|
||||||
|
// Upwards migration script
|
||||||
|
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} Removing serverAddress from Feeds table URLs`)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE Feeds
|
||||||
|
SET feedUrl = REPLACE(feedUrl, COALESCE(serverAddress, ''), ''),
|
||||||
|
imageUrl = REPLACE(imageUrl, COALESCE(serverAddress, ''), ''),
|
||||||
|
siteUrl = REPLACE(siteUrl, COALESCE(serverAddress, ''), '');
|
||||||
|
`)
|
||||||
|
logger.info(`${loggerPrefix} Removed serverAddress from Feeds table URLs`)
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} Removing serverAddress from FeedEpisodes table URLs`)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE FeedEpisodes
|
||||||
|
SET siteUrl = REPLACE(siteUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''),
|
||||||
|
enclosureUrl = REPLACE(enclosureUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), '');
|
||||||
|
`)
|
||||||
|
logger.info(`${loggerPrefix} Removed serverAddress from FeedEpisodes table URLs`)
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This downward migration script adds the host (serverAddress) back to URL columns in the feeds and feedEpisodes tables.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function down({ context: { queryInterface, logger } }) {
|
||||||
|
// Downward migration script
|
||||||
|
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} Adding serverAddress back to Feeds table URLs`)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE Feeds
|
||||||
|
SET feedUrl = COALESCE(serverAddress, '') || feedUrl,
|
||||||
|
imageUrl = COALESCE(serverAddress, '') || imageUrl,
|
||||||
|
siteUrl = COALESCE(serverAddress, '') || siteUrl;
|
||||||
|
`)
|
||||||
|
logger.info(`${loggerPrefix} Added serverAddress back to Feeds table URLs`)
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} Adding serverAddress back to FeedEpisodes table URLs`)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE FeedEpisodes
|
||||||
|
SET siteUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.siteUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId),
|
||||||
|
enclosureUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.enclosureUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId);
|
||||||
|
`)
|
||||||
|
logger.info(`${loggerPrefix} Added serverAddress back to FeedEpisodes table URLs`)
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down }
|
||||||
@@ -29,6 +29,12 @@ const Logger = require('../Logger')
|
|||||||
* @property {SeriesExpanded[]} series
|
* @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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+451
-171
@@ -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
|
||||||
|
|||||||
+212
-44
@@ -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
|
||||||
|
|||||||
@@ -107,19 +107,6 @@ class Library extends Model {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy library by id
|
|
||||||
* @param {string} libraryId
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
static removeById(libraryId) {
|
|
||||||
return this.destroy({
|
|
||||||
where: {
|
|
||||||
id: libraryId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all library ids
|
* Get all library ids
|
||||||
* @returns {Promise<string[]>} array of library ids
|
* @returns {Promise<string[]>} array of library ids
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-1
@@ -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,
|
||||||
|
|||||||
@@ -611,7 +611,7 @@ class User extends Model {
|
|||||||
*/
|
*/
|
||||||
getOldMediaProgress(libraryItemId, episodeId = null) {
|
getOldMediaProgress(libraryItemId, episodeId = null) {
|
||||||
const mediaProgress = this.mediaProgresses?.find((mp) => {
|
const mediaProgress = this.mediaProgresses?.find((mp) => {
|
||||||
if (episodeId && mp.mediaItemId === episodeId) return true
|
if (episodeId && mp.mediaItemId !== episodeId) return false
|
||||||
return mp.extraData?.libraryItemId === libraryItemId
|
return mp.extraData?.libraryItemId === libraryItemId
|
||||||
})
|
})
|
||||||
return mediaProgress?.getOldMediaProgress() || null
|
return mediaProgress?.getOldMediaProgress() || null
|
||||||
|
|||||||
@@ -1,427 +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
|
|
||||||
|
|
||||||
// Cached xml
|
|
||||||
this.xml = 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 = `${serverAddress}/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 ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
|
|
||||||
this.meta.feedUrl = feedUrl
|
|
||||||
this.meta.link = `${serverAddress}/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 ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/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()
|
|
||||||
this.xml = null
|
|
||||||
}
|
|
||||||
|
|
||||||
setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
|
|
||||||
const feedUrl = `${serverAddress}/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 ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
|
|
||||||
this.meta.feedUrl = feedUrl
|
|
||||||
this.meta.link = `${serverAddress}/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 ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/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()
|
|
||||||
this.xml = null
|
|
||||||
}
|
|
||||||
|
|
||||||
setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
|
|
||||||
const feedUrl = `${serverAddress}/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 ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
|
|
||||||
this.meta.feedUrl = feedUrl
|
|
||||||
this.meta.link = `${serverAddress}/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 ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/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()
|
|
||||||
this.xml = null
|
|
||||||
}
|
|
||||||
|
|
||||||
buildXml() {
|
|
||||||
if (this.xml) return this.xml
|
|
||||||
|
|
||||||
var rssfeed = new RSS(this.meta.getRSSData())
|
|
||||||
this.episodes.forEach((ep) => {
|
|
||||||
rssfeed.item(ep.getRSSData())
|
|
||||||
})
|
|
||||||
this.xml = rssfeed.xml()
|
|
||||||
return this.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,177 +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: `${serverAddress}${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: `${serverAddress}${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() {
|
|
||||||
return {
|
|
||||||
title: this.title,
|
|
||||||
description: this.description || '',
|
|
||||||
url: this.link,
|
|
||||||
guid: this.enclosure.url,
|
|
||||||
author: this.author,
|
|
||||||
date: this.pubDate,
|
|
||||||
enclosure: this.enclosure,
|
|
||||||
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,106 +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() {
|
|
||||||
const blockTags = [
|
|
||||||
{ 'itunes:block': 'yes' },
|
|
||||||
{ 'googleplay:block': 'yes' }
|
|
||||||
]
|
|
||||||
return {
|
|
||||||
title: this.title,
|
|
||||||
description: this.description || '',
|
|
||||||
generator: 'Audiobookshelf',
|
|
||||||
feed_url: this.feedUrl,
|
|
||||||
site_url: this.link,
|
|
||||||
image_url: 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: this.imageUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'itunes:owner': [
|
|
||||||
{ 'itunes:name': this.ownerName || this.author || '' },
|
|
||||||
{ 'itunes:email': this.ownerEmail || '' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{ 'itunes:explicit': !!this.explicit },
|
|
||||||
...(this.preventIndexing ? blockTags : [])
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = FeedMeta
|
|
||||||
@@ -262,7 +262,7 @@ class LibraryItem {
|
|||||||
* @returns {Promise<LibraryFile>} null if not saved
|
* @returns {Promise<LibraryFile>} null if not saved
|
||||||
*/
|
*/
|
||||||
async saveMetadata() {
|
async saveMetadata() {
|
||||||
if (this.isSavingMetadata) return null
|
if (this.isSavingMetadata || !global.MetadataPath) return null
|
||||||
|
|
||||||
this.isSavingMetadata = true
|
this.isSavingMetadata = true
|
||||||
|
|
||||||
|
|||||||
@@ -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})` : ''
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class ServerSettings {
|
|||||||
// Security/Rate limits
|
// Security/Rate limits
|
||||||
this.rateLimitLoginRequests = 10
|
this.rateLimitLoginRequests = 10
|
||||||
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
|
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
|
||||||
|
this.allowIframe = false
|
||||||
|
|
||||||
// Backups
|
// Backups
|
||||||
this.backupPath = Path.join(global.MetadataPath, 'backups')
|
this.backupPath = Path.join(global.MetadataPath, 'backups')
|
||||||
@@ -78,6 +79,7 @@ class ServerSettings {
|
|||||||
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
|
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
|
||||||
this.authOpenIDGroupClaim = ''
|
this.authOpenIDGroupClaim = ''
|
||||||
this.authOpenIDAdvancedPermsClaim = ''
|
this.authOpenIDAdvancedPermsClaim = ''
|
||||||
|
this.authOpenIDSubfolderForRedirectURLs = undefined
|
||||||
|
|
||||||
if (settings) {
|
if (settings) {
|
||||||
this.construct(settings)
|
this.construct(settings)
|
||||||
@@ -98,6 +100,7 @@ class ServerSettings {
|
|||||||
|
|
||||||
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
||||||
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
|
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
|
||||||
|
this.allowIframe = !!settings.allowIframe
|
||||||
|
|
||||||
this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups')
|
this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups')
|
||||||
this.backupSchedule = settings.backupSchedule || false
|
this.backupSchedule = settings.backupSchedule || false
|
||||||
@@ -139,6 +142,7 @@ class ServerSettings {
|
|||||||
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
|
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
|
||||||
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
|
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
|
||||||
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
|
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
|
||||||
|
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
|
||||||
|
|
||||||
if (!Array.isArray(this.authActiveAuthMethods)) {
|
if (!Array.isArray(this.authActiveAuthMethods)) {
|
||||||
this.authActiveAuthMethods = ['local']
|
this.authActiveAuthMethods = ['local']
|
||||||
@@ -188,6 +192,11 @@ class ServerSettings {
|
|||||||
Logger.info(`[ServerSettings] Using backup path from environment variable ${process.env.BACKUP_PATH}`)
|
Logger.info(`[ServerSettings] Using backup path from environment variable ${process.env.BACKUP_PATH}`)
|
||||||
this.backupPath = process.env.BACKUP_PATH
|
this.backupPath = process.env.BACKUP_PATH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.ALLOW_IFRAME === '1' && !this.allowIframe) {
|
||||||
|
Logger.info(`[ServerSettings] Using allowIframe from environment variable`)
|
||||||
|
this.allowIframe = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
@@ -205,6 +214,7 @@ class ServerSettings {
|
|||||||
metadataFileFormat: this.metadataFileFormat,
|
metadataFileFormat: this.metadataFileFormat,
|
||||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||||
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
||||||
|
allowIframe: this.allowIframe,
|
||||||
backupPath: this.backupPath,
|
backupPath: this.backupPath,
|
||||||
backupSchedule: this.backupSchedule,
|
backupSchedule: this.backupSchedule,
|
||||||
backupsToKeep: this.backupsToKeep,
|
backupsToKeep: this.backupsToKeep,
|
||||||
@@ -240,7 +250,8 @@ class ServerSettings {
|
|||||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
||||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
||||||
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
||||||
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim // Do not return to client
|
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
|
||||||
|
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +297,7 @@ class ServerSettings {
|
|||||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
||||||
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
||||||
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
|
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
|
||||||
|
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
|
||||||
|
|
||||||
authOpenIDSamplePermissions: User.getSampleAbsPermissions()
|
authOpenIDSamplePermissions: User.getSampleAbsPermissions()
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-50
@@ -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')} */
|
||||||
@@ -348,11 +347,10 @@ class ApiRouter {
|
|||||||
//
|
//
|
||||||
/**
|
/**
|
||||||
* Remove library item and associated entities
|
* Remove library item and associated entities
|
||||||
* @param {string} mediaType
|
|
||||||
* @param {string} libraryItemId
|
* @param {string} libraryItemId
|
||||||
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
|
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
|
||||||
*/
|
*/
|
||||||
async handleDeleteLibraryItem(mediaType, libraryItemId, mediaItemIds) {
|
async handleDeleteLibraryItem(libraryItemId, mediaItemIds) {
|
||||||
const numProgressRemoved = await Database.mediaProgressModel.destroy({
|
const numProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||||
where: {
|
where: {
|
||||||
mediaItemId: mediaItemIds
|
mediaItemId: mediaItemIds
|
||||||
@@ -362,29 +360,6 @@ class ApiRouter {
|
|||||||
Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`)
|
Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Remove open sessions for library item
|
|
||||||
|
|
||||||
// Remove series if empty
|
|
||||||
if (mediaType === 'book') {
|
|
||||||
// TODO: update filter data
|
|
||||||
const bookSeries = await Database.bookSeriesModel.findAll({
|
|
||||||
where: {
|
|
||||||
bookId: mediaItemIds[0]
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
model: Database.seriesModel,
|
|
||||||
include: {
|
|
||||||
model: Database.bookModel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
for (const bs of bookSeries) {
|
|
||||||
if (bs.series.books.length === 1) {
|
|
||||||
await this.removeEmptySeries(bs.series)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove item from playlists
|
// remove item from playlists
|
||||||
const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds)
|
const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds)
|
||||||
for (const playlist of playlistsWithItem) {
|
for (const playlist of playlistsWithItem) {
|
||||||
@@ -418,16 +393,19 @@ 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)
|
||||||
|
|
||||||
|
// Remove metadata file if in /metadata/items dir
|
||||||
|
if (global.MetadataPath) {
|
||||||
const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId)
|
const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId)
|
||||||
if (await fs.pathExists(itemMetadataPath)) {
|
if (await fs.pathExists(itemMetadataPath)) {
|
||||||
Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`)
|
Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`)
|
||||||
await fs.remove(itemMetadataPath)
|
await fs.remove(itemMetadataPath)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Database.libraryItemModel.removeById(libraryItemId)
|
await Database.libraryItemModel.removeById(libraryItemId)
|
||||||
|
|
||||||
@@ -437,32 +415,27 @@ class ApiRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used when a series is removed from a book
|
* After deleting book(s), remove empty series
|
||||||
* Series is removed if it only has 1 book
|
|
||||||
*
|
*
|
||||||
* @param {string} bookId
|
|
||||||
* @param {string[]} seriesIds
|
* @param {string[]} seriesIds
|
||||||
*/
|
*/
|
||||||
async checkRemoveEmptySeries(bookId, seriesIds) {
|
async checkRemoveEmptySeries(seriesIds) {
|
||||||
if (!seriesIds?.length) return
|
if (!seriesIds?.length) return
|
||||||
|
|
||||||
const bookSeries = await Database.bookSeriesModel.findAll({
|
const series = await Database.seriesModel.findAll({
|
||||||
where: {
|
where: {
|
||||||
bookId,
|
id: seriesIds
|
||||||
seriesId: seriesIds
|
|
||||||
},
|
},
|
||||||
include: [
|
attributes: ['id', 'name', 'libraryId'],
|
||||||
{
|
|
||||||
model: Database.seriesModel,
|
|
||||||
include: {
|
include: {
|
||||||
model: Database.bookModel
|
model: Database.bookModel,
|
||||||
|
attributes: ['id']
|
||||||
}
|
}
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
for (const bs of bookSeries) {
|
|
||||||
if (bs.series.books.length === 1) {
|
for (const s of series) {
|
||||||
await this.removeEmptySeries(bs.series)
|
if (!s.books.length) {
|
||||||
|
await this.removeEmptySeries(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,11 +444,10 @@ class ApiRouter {
|
|||||||
* Remove authors with no books and unset asin, description and imagePath
|
* Remove authors with no books and unset asin, description and imagePath
|
||||||
* Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged)
|
* Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged)
|
||||||
*
|
*
|
||||||
* @param {string} libraryId
|
|
||||||
* @param {string[]} authorIds
|
* @param {string[]} authorIds
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async checkRemoveAuthorsWithNoBooks(libraryId, authorIds) {
|
async checkRemoveAuthorsWithNoBooks(authorIds) {
|
||||||
if (!authorIds?.length) return
|
if (!authorIds?.length) return
|
||||||
|
|
||||||
const bookAuthorsToRemove = (
|
const bookAuthorsToRemove = (
|
||||||
@@ -495,10 +467,10 @@ class ApiRouter {
|
|||||||
},
|
},
|
||||||
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)
|
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)
|
||||||
],
|
],
|
||||||
attributes: ['id', 'name'],
|
attributes: ['id', 'name', 'libraryId'],
|
||||||
raw: true
|
raw: true
|
||||||
})
|
})
|
||||||
).map((au) => ({ id: au.id, name: au.name }))
|
).map((au) => ({ id: au.id, name: au.name, libraryId: au.libraryId }))
|
||||||
|
|
||||||
if (bookAuthorsToRemove.length) {
|
if (bookAuthorsToRemove.length) {
|
||||||
await Database.authorModel.destroy({
|
await Database.authorModel.destroy({
|
||||||
@@ -506,7 +478,7 @@ class ApiRouter {
|
|||||||
id: bookAuthorsToRemove.map((au) => au.id)
|
id: bookAuthorsToRemove.map((au) => au.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
bookAuthorsToRemove.forEach(({ id, name }) => {
|
bookAuthorsToRemove.forEach(({ id, name, libraryId }) => {
|
||||||
Database.removeAuthorFromFilterData(libraryId, id)
|
Database.removeAuthorFromFilterData(libraryId, id)
|
||||||
// TODO: Clients were expecting full author in payload but its unnecessary
|
// TODO: Clients were expecting full author in payload but its unnecessary
|
||||||
SocketAuthority.emitter('author_removed', { id, libraryId })
|
SocketAuthority.emitter('author_removed', { id, libraryId })
|
||||||
@@ -520,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
|
||||||
|
|||||||
@@ -133,8 +133,8 @@ class AudioFileScanner {
|
|||||||
|
|
||||||
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
|
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
|
||||||
const pathdir = Path.dirname(path).split('/').pop()
|
const pathdir = Path.dirname(path).split('/').pop()
|
||||||
if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) {
|
if (pathdir && /^(cd|dis[ck])\s*\d{1,3}$/i.test(pathdir)) {
|
||||||
const discFromFolder = Number(pathdir.replace(/cd/i, ''))
|
const discFromFolder = Number(pathdir.replace(/^(cd|dis[ck])\s*/i, ''))
|
||||||
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
|
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -424,8 +424,8 @@ class LibraryScanner {
|
|||||||
}
|
}
|
||||||
const folder = library.libraryFolders[0]
|
const folder = library.libraryFolders[0]
|
||||||
|
|
||||||
const relFilePaths = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUpdate.relPath)
|
const filePathItems = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUtils.getFilePathItemFromFileUpdate(fileUpdate))
|
||||||
const fileUpdateGroup = scanUtils.groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths)
|
const fileUpdateGroup = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, filePathItems, !!library.settings?.audiobooksOnly)
|
||||||
|
|
||||||
if (!Object.keys(fileUpdateGroup).length) {
|
if (!Object.keys(fileUpdateGroup).length) {
|
||||||
Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`)
|
Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`)
|
||||||
|
|||||||
@@ -131,11 +131,21 @@ async function readTextFile(path) {
|
|||||||
}
|
}
|
||||||
module.exports.readTextFile = readTextFile
|
module.exports.readTextFile = readTextFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef FilePathItem
|
||||||
|
* @property {string} name - file name e.g. "audiofile.m4b"
|
||||||
|
* @property {string} path - fullpath excluding folder e.g. "Author/Book/audiofile.m4b"
|
||||||
|
* @property {string} reldirpath - path excluding file name e.g. "Author/Book"
|
||||||
|
* @property {string} fullpath - full path e.g. "/audiobooks/Author/Book/audiofile.m4b"
|
||||||
|
* @property {string} extension - file extension e.g. ".m4b"
|
||||||
|
* @property {number} deep - depth of file in directory (0 is file in folder root)
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get array of files inside dir
|
* Get array of files inside dir
|
||||||
* @param {string} path
|
* @param {string} path
|
||||||
* @param {string} [relPathToReplace]
|
* @param {string} [relPathToReplace]
|
||||||
* @returns {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]}
|
* @returns {FilePathItem[]}
|
||||||
*/
|
*/
|
||||||
async function recurseFiles(path, relPathToReplace = null) {
|
async function recurseFiles(path, relPathToReplace = null) {
|
||||||
path = filePathToPOSIX(path)
|
path = filePathToPOSIX(path)
|
||||||
@@ -213,7 +223,6 @@ async function recurseFiles(path, relPathToReplace = null) {
|
|||||||
return {
|
return {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
path: item.fullname.replace(relPathToReplace, ''),
|
path: item.fullname.replace(relPathToReplace, ''),
|
||||||
dirpath: item.path,
|
|
||||||
reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''),
|
reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''),
|
||||||
fullpath: item.fullname,
|
fullpath: item.fullname,
|
||||||
extension: item.extension,
|
extension: item.extension,
|
||||||
@@ -228,6 +237,26 @@ async function recurseFiles(path, relPathToReplace = null) {
|
|||||||
}
|
}
|
||||||
module.exports.recurseFiles = recurseFiles
|
module.exports.recurseFiles = recurseFiles
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../Watcher').PendingFileUpdate} fileUpdate
|
||||||
|
* @returns {FilePathItem}
|
||||||
|
*/
|
||||||
|
module.exports.getFilePathItemFromFileUpdate = (fileUpdate) => {
|
||||||
|
let relPath = fileUpdate.relPath
|
||||||
|
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||||
|
|
||||||
|
const dirname = Path.dirname(relPath)
|
||||||
|
return {
|
||||||
|
name: Path.basename(relPath),
|
||||||
|
path: relPath,
|
||||||
|
reldirpath: dirname === '.' ? '' : dirname,
|
||||||
|
fullpath: fileUpdate.path,
|
||||||
|
extension: Path.extname(relPath),
|
||||||
|
deep: relPath.split('/').length - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download file from web to local file system
|
* Download file from web to local file system
|
||||||
* Uses SSRF filter to prevent internal URLs
|
* Uses SSRF filter to prevent internal URLs
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ function parseTags(format, verbose) {
|
|||||||
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
|
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
|
||||||
file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'),
|
file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'),
|
||||||
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'),
|
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'),
|
||||||
file_tag_grouping: tryGrabTags(format, 'grouping'),
|
file_tag_grouping: tryGrabTags(format, 'grouping', 'grp1'),
|
||||||
file_tag_isbn: tryGrabTags(format, 'isbn'), // custom
|
file_tag_isbn: tryGrabTags(format, 'isbn'), // custom
|
||||||
file_tag_language: tryGrabTags(format, 'language', 'lang'),
|
file_tag_language: tryGrabTags(format, 'language', 'lang'),
|
||||||
file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom
|
file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ module.exports = {
|
|||||||
|
|
||||||
for (const book of booksAdded) {
|
for (const book of booksAdded) {
|
||||||
// Grab first 25 that have a cover
|
// Grab first 25 that have a cover
|
||||||
if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(book.coverPath)) {
|
if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && (await fsExtra.pathExists(book.coverPath))) {
|
||||||
booksWithCovers.push(book.libraryItem.id)
|
booksWithCovers.push(book.libraryItem.id)
|
||||||
}
|
}
|
||||||
if (book.duration && !isNaN(book.duration)) {
|
if (book.duration && !isNaN(book.duration)) {
|
||||||
@@ -95,45 +95,54 @@ module.exports = {
|
|||||||
const listeningSessions = await this.getListeningSessionsForYear(year)
|
const listeningSessions = await this.getListeningSessionsForYear(year)
|
||||||
let totalListeningTime = 0
|
let totalListeningTime = 0
|
||||||
for (const ls of listeningSessions) {
|
for (const ls of listeningSessions) {
|
||||||
totalListeningTime += (ls.timeListening || 0)
|
totalListeningTime += ls.timeListening || 0
|
||||||
|
|
||||||
const authors = ls.mediaMetadata.authors || []
|
const authors = ls.mediaMetadata?.authors || []
|
||||||
authors.forEach((au) => {
|
authors.forEach((au) => {
|
||||||
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
|
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
|
||||||
authorListeningMap[au.name] += (ls.timeListening || 0)
|
authorListeningMap[au.name] += ls.timeListening || 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const narrators = ls.mediaMetadata.narrators || []
|
const narrators = ls.mediaMetadata?.narrators || []
|
||||||
narrators.forEach((narrator) => {
|
narrators.forEach((narrator) => {
|
||||||
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
|
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
|
||||||
narratorListeningMap[narrator] += (ls.timeListening || 0)
|
narratorListeningMap[narrator] += ls.timeListening || 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filter out bad genres like "audiobook" and "audio book"
|
// Filter out bad genres like "audiobook" and "audio book"
|
||||||
const genres = (ls.mediaMetadata.genres || []).filter(g => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
|
const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
|
||||||
genres.forEach((genre) => {
|
genres.forEach((genre) => {
|
||||||
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
|
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
|
||||||
genreListeningMap[genre] += (ls.timeListening || 0)
|
genreListeningMap[genre] += ls.timeListening || 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let topAuthors = null
|
let topAuthors = null
|
||||||
topAuthors = Object.keys(authorListeningMap).map(authorName => ({
|
topAuthors = Object.keys(authorListeningMap)
|
||||||
|
.map((authorName) => ({
|
||||||
name: authorName,
|
name: authorName,
|
||||||
time: Math.round(authorListeningMap[authorName])
|
time: Math.round(authorListeningMap[authorName])
|
||||||
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
}))
|
||||||
|
.sort((a, b) => b.time - a.time)
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
let topNarrators = null
|
let topNarrators = null
|
||||||
topNarrators = Object.keys(narratorListeningMap).map(narratorName => ({
|
topNarrators = Object.keys(narratorListeningMap)
|
||||||
|
.map((narratorName) => ({
|
||||||
name: narratorName,
|
name: narratorName,
|
||||||
time: Math.round(narratorListeningMap[narratorName])
|
time: Math.round(narratorListeningMap[narratorName])
|
||||||
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
}))
|
||||||
|
.sort((a, b) => b.time - a.time)
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
let topGenres = null
|
let topGenres = null
|
||||||
topGenres = Object.keys(genreListeningMap).map(genre => ({
|
topGenres = Object.keys(genreListeningMap)
|
||||||
|
.map((genre) => ({
|
||||||
genre,
|
genre,
|
||||||
time: Math.round(genreListeningMap[genre])
|
time: Math.round(genreListeningMap[genre])
|
||||||
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
}))
|
||||||
|
.sort((a, b) => b.time - a.time)
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
// Stats for total books, size and duration for everything added this year or earlier
|
// Stats for total books, size and duration for everything added this year or earlier
|
||||||
const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, {
|
const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user