mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-01 12:09:43 -05:00
Compare commits
121 Commits
v2.17.0
...
feed_migra
| 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 | ||
|
|
f850db23fe | ||
|
|
5f81010f6a | ||
|
|
daf2493f50 | ||
|
|
57222f3611 | ||
|
|
62b185979e | ||
|
|
ebcc85acc4 | ||
|
|
33a7ba4acd | ||
|
|
1d4e6993fc | ||
|
|
784b761629 | ||
|
|
268fb2ce9a | ||
|
|
fc5f35b388 | ||
|
|
ff026a06bb | ||
|
|
b148a57c98 | ||
|
|
ee6e2d2983 | ||
|
|
ea3a6fd75e | ||
|
|
22f85d3af9 | ||
|
|
75f4c2ee99 | ||
|
|
dd3467efa2 | ||
|
|
4adb15c11b | ||
|
|
a5e38d1473 | ||
|
|
778256ca16 | ||
|
|
d5fbc1d455 | ||
|
|
0d54b57151 |
33
.github/pull_request_template.md
vendored
Normal file
33
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
<!--
|
||||
For Work In Progress Pull Requests, please use the Draft PR feature,
|
||||
see https://github.blog/2019-02-14-introducing-draft-pull-requests/ for further details.
|
||||
|
||||
If you do not follow this template, the PR may be closed without review.
|
||||
|
||||
Please ensure all checks pass.
|
||||
If you are a new contributor, the workflows will need to be manually approved before they run.
|
||||
-->
|
||||
|
||||
## Brief summary
|
||||
|
||||
<!-- Please provide a brief summary of what your PR attempts to achieve. -->
|
||||
|
||||
## Which issue is fixed?
|
||||
|
||||
<!-- Which issue number does this PR fix? Ex: "Fixes #1234" -->
|
||||
|
||||
## In-depth Description
|
||||
|
||||
<!--
|
||||
Describe your solution in more depth.
|
||||
How does it work? Why is this the best solution?
|
||||
Does it solve a problem that affects multiple users or is this an edge case for your setup?
|
||||
-->
|
||||
|
||||
## How have you tested this?
|
||||
|
||||
<!-- Please describe in detail with reproducible steps how you tested your changes. -->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- If your PR includes any changes to the web client, please include screenshots or a short video from before and after your changes. -->
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full h-16 bg-primary relative">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
|
||||
<div id="appbar" role="toolbar" aria-label="Appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
|
||||
<div class="flex h-full items-center">
|
||||
<nuxt-link to="/">
|
||||
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
|
||||
<template v-for="(shelf, index) in supportedShelves">
|
||||
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||
<p class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
<h2 class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</h2>
|
||||
</widgets-item-slider>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -37,18 +37,18 @@
|
||||
<div class="relative">
|
||||
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
||||
<p :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
<h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
|
||||
</div>
|
||||
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
|
||||
<button v-show="canScrollLeft && !isScrolling" :aria-label="$strings.ButtonScrollLeft" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
|
||||
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
|
||||
</div>
|
||||
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
|
||||
</button>
|
||||
<button v-show="canScrollRight && !isScrolling" :aria-label="$strings.ButtonScrollRight" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
|
||||
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
<div id="toolbar" role="toolbar" aria-label="Library Toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
<!-- Series books page -->
|
||||
<template v-if="selectedSeries">
|
||||
<p class="pl-2 text-base md:text-lg">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
|
||||
<div role="toolbar" aria-orientation="vertical" aria-label="Config Sidebar">
|
||||
<div role="navigation" aria-label="Config Navigation" class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
|
||||
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
|
||||
<span class="material-symbols text-2xl">arrow_back</span>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
@showBookmarks="showBookmarks"
|
||||
@showSleepTimer="showSleepTimerModal = true"
|
||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||
@showPlayerSettings="showPlayerSettingsModal = true"
|
||||
/>
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||
@@ -61,8 +60,6 @@
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||
|
||||
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
|
||||
|
||||
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -81,7 +78,6 @@ export default {
|
||||
currentTime: 0,
|
||||
showSleepTimerModal: false,
|
||||
showPlayerQueueItemsModal: false,
|
||||
showPlayerSettingsModal: false,
|
||||
sleepTimerSet: false,
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimerType: null,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||
<div role="toolbar" aria-orientation="vertical" aria-label="Library Sidebar" class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
||||
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||
|
||||
<div id="siderail-buttons-container" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
|
||||
<div id="siderail-buttons-container" role="navigation" aria-label="Library Navigation" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
||||
<article class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
||||
<nuxt-link :to="`/author/${author?.id}`">
|
||||
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<article ref="card" :id="`book-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
|
||||
<!-- When cover image does not fill -->
|
||||
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
@@ -14,21 +14,21 @@
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
|
||||
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" aria-hidden="true" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Cover Image -->
|
||||
<img cy-id="coverImage" v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
<img cy-id="coverImage" v-show="libraryItem" :alt="`${displayTitle}, ${$strings.LabelCover}`" ref="cover" aria-hidden="true" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
|
||||
<!-- Placeholder Cover Title & Author -->
|
||||
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
|
||||
<div>
|
||||
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
||||
<p cy-id="placeholderTitleText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
|
||||
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
||||
<p cy-id="placeholderAuthorText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f">
|
||||
@@ -93,11 +93,11 @@
|
||||
|
||||
<!-- rss feed icon -->
|
||||
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
|
||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
|
||||
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
|
||||
</div>
|
||||
<!-- media item shared icon -->
|
||||
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
|
||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">public</span>
|
||||
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">public</span>
|
||||
</div>
|
||||
|
||||
<!-- Series sequence -->
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodes }}</p>
|
||||
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfEpisodes">{{ numEpisodes }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
@@ -128,7 +128,7 @@
|
||||
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
|
||||
<div :style="{ fontSize: 0.9 + 'em' }">
|
||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
||||
<p cy-id="title" ref="displayTitle" aria-hidden="true" class="truncate">{{ displayTitle }}</p>
|
||||
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@
|
||||
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="card" :id="`collection-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div ref="card" :id="`collection-card-${index}`" role="button" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div ref="card" :id="`playlist-card-${index}`" role="button" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div cy-id="card" ref="card" :id="`series-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<article cy-id="card" ref="card" :id="`series-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||
@@ -7,12 +7,12 @@
|
||||
</div>
|
||||
|
||||
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ books.length }}</p>
|
||||
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfBooks">{{ books.length }}</p>
|
||||
</div>
|
||||
|
||||
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||
|
||||
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
|
||||
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" aria-hidden="true" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
|
||||
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
|
||||
<div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
||||
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
<p cy-id="standardBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center">
|
||||
<p cy-id="detailBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
<p cy-id="detailBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<div class="w-full relative sm:w-80">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<form role="search" @submit.prevent="submitSearch">
|
||||
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
</form>
|
||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||
<button :aria-hidden="!search" class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||
<span v-if="!search" class="material-symbols" style="font-size: 1.2rem"></span>
|
||||
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu" @mousedown.stop.prevent>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
</span>
|
||||
<div class="relative h-7">
|
||||
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||
<button v-else :aria-label="$strings.ButtonClearFilter" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||
<span class="material-symbols" style="font-size: 1.1rem">close</span>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
|
||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<ul v-show="!sublist" class="h-full w-full" role="menu">
|
||||
<template v-for="item in selectItems">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" :aria-haspopup="item.sublist ? '' : 'menu'" @click="clickedOption(item)">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
|
||||
</div>
|
||||
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-symbols text-2xl">arrow_right</span>
|
||||
<span class="material-symbols text-2xl" :aria-label="$strings.LabelMore">arrow_right</span>
|
||||
</div>
|
||||
<!-- selected checkmark icon -->
|
||||
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||
@@ -31,8 +33,8 @@
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
|
||||
<ul v-show="sublist" class="h-full w-full" role="menu">
|
||||
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="menuitem" @click="sublist = null">
|
||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-symbols text-2xl">arrow_left</span>
|
||||
</div>
|
||||
@@ -40,13 +42,13 @@
|
||||
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="menuitem">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<template v-for="item in sublistItems">
|
||||
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)">
|
||||
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedSublistOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
<span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
|
||||
<template v-for="item in selectItems">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
<span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
<span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
<span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -121,6 +121,8 @@ export default {
|
||||
|
||||
var img = document.createElement('img')
|
||||
img.src = src
|
||||
img.alt = `${this.name}, ${this.$strings.LabelCover}`
|
||||
img.ariaHidden = true
|
||||
img.className = 'absolute top-0 left-0 w-full h-full'
|
||||
img.style.objectFit = showCoverBg ? 'contain' : 'cover'
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
||||
<div ref="wrapper" role="dialog" aria-modal="true" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
|
||||
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
|
||||
<span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||
</button>
|
||||
<slot name="outer" />
|
||||
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||
<div ref="content" tabindex="0" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white outline-none" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||
<slot />
|
||||
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
@@ -126,6 +126,9 @@ export default {
|
||||
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
this.$store.commit('setOpenModal', this.name)
|
||||
|
||||
// Set focus to the modal content
|
||||
this.content.focus()
|
||||
},
|
||||
setHide() {
|
||||
if (this.content) this.content.style.transform = 'scale(0)'
|
||||
|
||||
@@ -59,12 +59,19 @@ export default {
|
||||
setJumpBackwardAmount(val) {
|
||||
this.jumpBackwardAmount = val
|
||||
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
|
||||
},
|
||||
settingsUpdated() {
|
||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
||||
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
||||
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
||||
this.settingsUpdated()
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">Changelog</p>
|
||||
<h1 class="text-3xl text-white truncate">Changelog</h1>
|
||||
</div>
|
||||
</template>
|
||||
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
|
||||
@@ -13,7 +13,7 @@
|
||||
</p>
|
||||
<div class="custom-text" v-html="getChangelog(release)" />
|
||||
</div>
|
||||
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" class="border-b border-black-300 my-8" />
|
||||
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" :key="`${release.name}-divider`" class="border-b border-black-300 my-8" />
|
||||
</template>
|
||||
</div>
|
||||
</modals-modal>
|
||||
|
||||
@@ -18,6 +18,23 @@
|
||||
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||
<div v-if="description" dir="auto" class="default-style" v-html="description" />
|
||||
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
||||
|
||||
<div class="w-full h-px bg-white/5 my-4" />
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-grow">
|
||||
<p class="font-semibold text-xs mb-1">{{ $strings.LabelFilename }}</p>
|
||||
<p class="mb-2 text-xs">
|
||||
{{ audioFileFilename }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<p class="font-semibold text-xs mb-1">{{ $strings.LabelSize }}</p>
|
||||
<p class="mb-2 text-xs">
|
||||
{{ audioFileSize }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -54,7 +71,7 @@ export default {
|
||||
return this.episode.description || ''
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
@@ -65,6 +82,14 @@ export default {
|
||||
podcastAuthor() {
|
||||
return this.mediaMetadata.author
|
||||
},
|
||||
audioFileFilename() {
|
||||
return this.episode.audioFile?.metadata?.filename || ''
|
||||
},
|
||||
audioFileSize() {
|
||||
const size = this.episode.audioFile?.metadata?.size || 0
|
||||
|
||||
return this.$bytesPretty(size)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
|
||||
|
||||
<div class="w-full relative">
|
||||
<ui-text-input v-model="currentFeed.feedUrl" readonly />
|
||||
<ui-text-input :value="feedUrl" readonly />
|
||||
|
||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span>
|
||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
|
||||
</div>
|
||||
|
||||
<div v-if="currentFeed.meta" class="mt-5">
|
||||
@@ -111,8 +111,11 @@ export default {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
feedUrl() {
|
||||
return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : ''
|
||||
},
|
||||
demoFeedUrl() {
|
||||
return `${window.origin}/feed/${this.newFeedSlug}`
|
||||
return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}`
|
||||
},
|
||||
isHttp() {
|
||||
return window.origin.startsWith('http://')
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
|
||||
|
||||
<div class="w-full relative">
|
||||
<ui-text-input v-model="feed.feedUrl" readonly />
|
||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span>
|
||||
<ui-text-input :value="feedUrl" readonly />
|
||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
|
||||
</div>
|
||||
|
||||
<div v-if="feed.meta" class="mt-5">
|
||||
@@ -70,6 +70,9 @@ export default {
|
||||
},
|
||||
_feed() {
|
||||
return this.feed || {}
|
||||
},
|
||||
feedUrl() {
|
||||
return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
|
||||
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerSettings')">
|
||||
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="showPlayerSettings">
|
||||
<span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
@@ -64,6 +64,8 @@
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
|
||||
|
||||
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -96,6 +98,7 @@ export default {
|
||||
audioEl: null,
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
showPlayerSettingsModal: false,
|
||||
currentTime: 0,
|
||||
duration: 0
|
||||
}
|
||||
@@ -315,6 +318,9 @@ export default {
|
||||
if (!this.chapters.length) return
|
||||
this.showChaptersModal = !this.showChaptersModal
|
||||
},
|
||||
showPlayerSettings() {
|
||||
this.showPlayerSettingsModal = !this.showPlayerSettingsModal
|
||||
},
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ export default {
|
||||
this.users = res.users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
this.$emit('numUsers', this.users.length)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p> -->
|
||||
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
|
||||
<form @submit.prevent="submit" class="flex flex-grow">
|
||||
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
|
||||
@@ -515,6 +514,10 @@ export default {
|
||||
}
|
||||
},
|
||||
filterSortChanged() {
|
||||
// Save filterKey and sortKey to local storage
|
||||
localStorage.setItem('podcastEpisodesFilter', this.filterKey)
|
||||
localStorage.setItem('podcastEpisodesSortBy', this.sortKey + (this.sortDesc ? '-desc' : ''))
|
||||
|
||||
this.init()
|
||||
},
|
||||
refresh() {
|
||||
@@ -537,6 +540,11 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.filterKey = localStorage.getItem('podcastEpisodesFilter') || 'incomplete'
|
||||
const sortBy = localStorage.getItem('podcastEpisodesSortBy') || 'publishedAt-desc'
|
||||
this.sortKey = sortBy.split('-')[0]
|
||||
this.sortDesc = sortBy.split('-')[1] === 'desc'
|
||||
|
||||
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
|
||||
this.initListeners()
|
||||
this.init()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
||||
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
|
||||
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" :aria-label="$strings.LabelMore" aria-haspopup="menu" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-symbols text-2xl" :class="iconClass"></span>
|
||||
</button>
|
||||
<div v-else class="h-full w-full flex items-center justify-center">
|
||||
@@ -10,12 +10,12 @@
|
||||
</slot>
|
||||
|
||||
<transition name="menu">
|
||||
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
|
||||
<div v-show="showMenu" ref="menuWrapper" role="menu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<template v-if="item.subitems">
|
||||
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
|
||||
<button :key="index" role="menuitem" aria-haspopup="menu" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default w-full" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
|
||||
<p>{{ item.text }}</p>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
v-if="mouseoverItemIndex === index"
|
||||
:key="`subitems-${index}`"
|
||||
@@ -25,14 +25,14 @@
|
||||
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
|
||||
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
|
||||
>
|
||||
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)">
|
||||
<button v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(subitem.action, subitem.data)">
|
||||
<p>{{ subitem.text }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
|
||||
<button v-else :key="index" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(item.action)">
|
||||
<p class="text-left">{{ item.text }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
||||
<button :aria-label="ariaLabel" class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
@@ -28,7 +28,8 @@ export default {
|
||||
size: {
|
||||
type: Number,
|
||||
default: 9
|
||||
}
|
||||
},
|
||||
ariaLabel: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
|
||||
aria-haspopup="listbox"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="showMenu"
|
||||
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
|
||||
@click.stop.prevent="clickShowMenu"
|
||||
@@ -16,9 +16,9 @@
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="menu">
|
||||
<template v-for="library in librariesFiltered">
|
||||
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
|
||||
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="menuitem" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
|
||||
<div class="flex items-center px-2">
|
||||
<ui-library-icon :icon="library.icon" class="mr-1.5" />
|
||||
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
|
||||
<button :aria-label="isRead ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
|
||||
<div class="w-5 h-5 text-white relative">
|
||||
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
||||
<div aria-hidden="true" class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
||||
<span class="material-symbols" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button"></span>
|
||||
<p class="px-2 font-mono" style="font-size: 1rem">{{ bookCoverWidth }}</p>
|
||||
<span class="material-symbols" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button"></span>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<div class="flex items-center py-3e">
|
||||
<slot />
|
||||
<div class="flex-grow" />
|
||||
<button cy-id="leftScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||
<button cy-id="leftScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollLeft" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_left</span>
|
||||
</button>
|
||||
<button cy-id="rightScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||
<button cy-id="rightScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollRight" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -71,8 +71,6 @@ export default {
|
||||
this.showSeriesForm = true
|
||||
},
|
||||
submitSeriesForm() {
|
||||
console.log('submit series form', this.value, this.selectedSeries)
|
||||
|
||||
if (!this.selectedSeries.name) {
|
||||
this.$toast.error('Must enter a series')
|
||||
return
|
||||
|
||||
@@ -28,10 +28,8 @@ export default {
|
||||
var validOtherFiles = []
|
||||
var ignoredFiles = []
|
||||
files.forEach((file) => {
|
||||
// var filetype = this.checkFileType(file.name)
|
||||
if (!file.filetype) ignoredFiles.push(file)
|
||||
else {
|
||||
// file.filetype = filetype
|
||||
if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
|
||||
else validOtherFiles.push(file)
|
||||
}
|
||||
@@ -165,7 +163,7 @@ export default {
|
||||
|
||||
var firstBookPath = Path.dirname(firstBookFile.filepath)
|
||||
|
||||
var dirs = firstBookPath.split('/').filter(d => !!d && d !== '.')
|
||||
var dirs = firstBookPath.split('/').filter((d) => !!d && d !== '.')
|
||||
if (dirs.length) {
|
||||
audiobook.title = dirs.pop()
|
||||
if (dirs.length > 1) {
|
||||
@@ -189,7 +187,7 @@ export default {
|
||||
var firstAudioFile = podcast.itemFiles[0]
|
||||
if (!firstAudioFile.filepath) return podcast // No path
|
||||
var firstPath = Path.dirname(firstAudioFile.filepath)
|
||||
var dirs = firstPath.split('/').filter(d => !!d && d !== '.')
|
||||
var dirs = firstPath.split('/').filter((d) => !!d && d !== '.')
|
||||
if (dirs.length) {
|
||||
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
|
||||
} else {
|
||||
@@ -212,13 +210,15 @@ export default {
|
||||
}
|
||||
var ignoredFiles = itemData.ignoredFiles
|
||||
var index = 1
|
||||
var items = itemData.items.filter((ab) => {
|
||||
if (!ab.itemFiles.length) {
|
||||
if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles)
|
||||
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
|
||||
}
|
||||
return ab.itemFiles.length
|
||||
}).map(ab => this.cleanItem(ab, mediaType, index++))
|
||||
var items = itemData.items
|
||||
.filter((ab) => {
|
||||
if (!ab.itemFiles.length) {
|
||||
if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles)
|
||||
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
|
||||
}
|
||||
return ab.itemFiles.length
|
||||
})
|
||||
.map((ab) => this.cleanItem(ab, mediaType, index++))
|
||||
return {
|
||||
items,
|
||||
ignoredFiles
|
||||
@@ -259,7 +259,7 @@ export default {
|
||||
|
||||
otherFiles.forEach((file) => {
|
||||
var dir = Path.dirname(file.filepath)
|
||||
var findItem = Object.values(itemMap).find(b => dir.startsWith(b.path))
|
||||
var findItem = Object.values(itemMap).find((b) => dir.startsWith(b.path))
|
||||
if (findItem) {
|
||||
findItem.otherFiles.push(file)
|
||||
} else {
|
||||
@@ -270,18 +270,18 @@ export default {
|
||||
var items = []
|
||||
var index = 1
|
||||
// If book media type and all files are audio files then treat each one as an audiobook
|
||||
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) {
|
||||
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some((f) => f.filetype !== 'audio')) {
|
||||
items = itemMap[''].itemFiles.map((audioFile) => {
|
||||
return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++)
|
||||
})
|
||||
} else {
|
||||
items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
|
||||
items = Object.values(itemMap).map((i) => this.cleanItem(i, mediaType, index++))
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
ignoredFiles: ignoredFiles
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.5",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.5",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -64,6 +64,20 @@
|
||||
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
|
||||
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
|
||||
|
||||
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
||||
<div class="w-44">
|
||||
<ui-dropdown v-model="newAuthSettings.authOpenIDSubfolderForRedirectURLs" small :items="subfolderOptions" :label="$strings.LabelWebRedirectURLsSubfolder" :disabled="savingSettings" />
|
||||
</div>
|
||||
<div class="mt-2 sm:mt-5">
|
||||
<p class="sm:pl-4 text-sm text-gray-300">{{ $strings.LabelWebRedirectURLsDescription }}</p>
|
||||
<p class="sm:pl-4 text-sm text-gray-300 mb-2">
|
||||
<code>{{ webCallbackURL }}</code>
|
||||
<br />
|
||||
<code>{{ mobileAppCallbackURL }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
|
||||
|
||||
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
||||
@@ -164,6 +178,27 @@ export default {
|
||||
value: 'username'
|
||||
}
|
||||
]
|
||||
},
|
||||
subfolderOptions() {
|
||||
const options = [
|
||||
{
|
||||
text: 'None',
|
||||
value: ''
|
||||
}
|
||||
]
|
||||
if (this.$config.routerBasePath) {
|
||||
options.push({
|
||||
text: this.$config.routerBasePath,
|
||||
value: this.$config.routerBasePath
|
||||
})
|
||||
}
|
||||
return options
|
||||
},
|
||||
webCallbackURL() {
|
||||
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback`
|
||||
},
|
||||
mobileAppCallbackURL() {
|
||||
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -325,7 +360,8 @@ export default {
|
||||
},
|
||||
init() {
|
||||
this.newAuthSettings = {
|
||||
...this.authSettings
|
||||
...this.authSettings,
|
||||
authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs
|
||||
}
|
||||
this.enableLocalAuth = this.authMethods.includes('local')
|
||||
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
||||
|
||||
@@ -42,11 +42,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2 mb-2">
|
||||
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
|
||||
</div>
|
||||
@@ -94,6 +89,20 @@
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsWebClient }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch 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 class="flex-1">
|
||||
@@ -324,21 +333,21 @@ export default {
|
||||
},
|
||||
updateServerSettings(payload) {
|
||||
this.updatingServerSettings = true
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then(() => {
|
||||
this.updatingServerSettings = false
|
||||
this.$store.dispatch('updateServerSettings', payload).then((response) => {
|
||||
this.updatingServerSettings = false
|
||||
|
||||
if (payload.language) {
|
||||
// Updating language after save allows for re-rendering
|
||||
this.$setLanguageCode(payload.language)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
if (response.error) {
|
||||
console.error('Failed to update server settins', response.error)
|
||||
this.$toast.error(response.error)
|
||||
this.initServerSettings()
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.language) {
|
||||
// Updating language after save allows for re-rendering
|
||||
this.$setLanguageCode(payload.language)
|
||||
}
|
||||
})
|
||||
},
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
|
||||
@@ -126,7 +126,7 @@ export default {
|
||||
},
|
||||
coverUrl(feed) {
|
||||
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
|
||||
return `${feed.feedUrl}/cover`
|
||||
return `${this.$config.routerBasePath}${feed.feedUrl}/cover`
|
||||
},
|
||||
async loadFeeds() {
|
||||
const data = await this.$axios.$get(`/api/feeds`).catch((err) => {
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderUsers">
|
||||
<template #header-items>
|
||||
<div v-if="numUsers" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
|
||||
<span>{{ numUsers }}</span>
|
||||
</div>
|
||||
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
@@ -13,7 +17,7 @@
|
||||
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
|
||||
</template>
|
||||
|
||||
<tables-users-table class="pt-2" @edit="setShowUserModal" />
|
||||
<tables-users-table class="pt-2" @edit="setShowUserModal" @numUsers="(count) => (numUsers = count)" />
|
||||
</app-settings-content>
|
||||
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
||||
</div>
|
||||
@@ -29,7 +33,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
selectedAccount: null,
|
||||
showAccountModal: false
|
||||
showAccountModal: false,
|
||||
numUsers: 0
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<!-- Item Cover Overlay -->
|
||||
<div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none">
|
||||
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem">
|
||||
<button class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" :aria-label="$strings.ButtonPlay" @click.stop.prevent="playItem">
|
||||
<span class="material-symbols fill text-4xl">play_arrow</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" @click="showEditCover">edit</span>
|
||||
<button class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" :aria-label="$strings.ButtonEdit" @click="showEditCover">edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@
|
||||
</ui-btn>
|
||||
|
||||
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
||||
<span v-show="!isStreaming" class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
|
||||
<span class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
|
||||
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
|
||||
</ui-btn>
|
||||
|
||||
@@ -96,12 +96,12 @@
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
||||
<span class="material-symbols text-2xl -ml-2 pr-2 text-white">auto_stories</span>
|
||||
<span class="material-symbols text-2xl -ml-2 pr-2 text-white" aria-hidden="true">auto_stories</span>
|
||||
{{ $strings.ButtonRead }}
|
||||
</ui-btn>
|
||||
|
||||
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
|
||||
<ui-icon-btn icon="" outlined class="mx-0.5" @click="editClick" />
|
||||
<ui-icon-btn icon="" outlined class="mx-0.5" :aria-label="$strings.LabelEdit" @click="editClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
|
||||
@@ -110,12 +110,12 @@
|
||||
|
||||
<!-- Only admin or root user can download new episodes -->
|
||||
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
|
||||
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||
<ui-icon-btn icon="search" class="mx-0.5" :aria-label="$strings.LabelFindEpisodes" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
|
||||
<template #default="{ showMenu, clickShowMenu, disabled }">
|
||||
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.LabelMore" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-symbols text-2xl"></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -126,12 +126,14 @@ export default {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
const currentTime = this.localAudioPlayer.getCurrentTime()
|
||||
const duration = this.localAudioPlayer.getDuration()
|
||||
this.seek(Math.min(currentTime + 10, duration))
|
||||
const jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') || 10
|
||||
this.seek(Math.min(currentTime + jumpForwardAmount, duration))
|
||||
},
|
||||
jumpBackward() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
const currentTime = this.localAudioPlayer.getCurrentTime()
|
||||
this.seek(Math.max(currentTime - 10, 0))
|
||||
const jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') || 10
|
||||
this.seek(Math.max(currentTime - jumpBackwardAmount, 0))
|
||||
},
|
||||
setVolume(volume) {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
@@ -248,6 +250,8 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('user/loadUserSettings')
|
||||
|
||||
this.resize()
|
||||
window.addEventListener('resize', this.resize)
|
||||
window.addEventListener('keydown', this.keyDown)
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-0 sm:p-6 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="page p-1 sm:p-6 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="w-full max-w-6xl mx-auto">
|
||||
<!-- Library & folder picker -->
|
||||
<div class="flex my-6 -mx-2">
|
||||
<div class="w-1/5 px-2">
|
||||
<div class="flex flex-wrap my-6 md:-mx-2">
|
||||
<div class="w-full md:w-1/5 px-2">
|
||||
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" :label="$strings.LabelLibrary" :disabled="!!items.length" @input="libraryChanged" />
|
||||
</div>
|
||||
<div class="w-3/5 px-2">
|
||||
<div class="w-full md:w-3/5 px-2">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId || !!items.length" :label="$strings.LabelFolder" />
|
||||
</div>
|
||||
<div class="w-1/5 px-2">
|
||||
<div class="w-full md:w-1/5 px-2">
|
||||
<ui-text-input-with-label :value="selectedLibraryMediaType" readonly :label="$strings.LabelMediaType" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6">
|
||||
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6 px-2 md:px-0">
|
||||
<label class="flex cursor-pointer pt-4">
|
||||
<ui-toggle-switch v-model="fetchMetadata.enabled" class="inline-flex" />
|
||||
<span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span>
|
||||
@@ -33,13 +33,13 @@
|
||||
</widgets-alert>
|
||||
|
||||
<!-- Picker display -->
|
||||
<div v-if="!items.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
|
||||
<p class="text-2xl text-center">{{ isDragging ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop }}</p>
|
||||
<div v-if="!items.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-4 md:px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
|
||||
<p class="text-2xl text-center">{{ isDragging ? $strings.LabelUploaderDropFiles : isIOS ? $strings.LabelUploaderDragAndDropFilesOnly : $strings.LabelUploaderDragAndDrop }}</p>
|
||||
<p class="text-center text-sm my-5">{{ $strings.MessageOr }}</p>
|
||||
<div class="w-full max-w-xl mx-auto">
|
||||
<div class="flex">
|
||||
<ui-btn class="w-full mx-1" @click="openFilePicker">{{ $strings.ButtonChooseFiles }}</ui-btn>
|
||||
<ui-btn class="w-full mx-1" @click="openFolderPicker">{{ $strings.ButtonChooseAFolder }}</ui-btn>
|
||||
<ui-btn v-if="!isIOS" class="w-full mx-1" @click="openFolderPicker">{{ $strings.ButtonChooseAFolder }} </ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-8 text-center">
|
||||
@@ -48,7 +48,7 @@
|
||||
</p>
|
||||
|
||||
<p class="text-sm text-white text-opacity-70">
|
||||
{{ $strings.NoteUploaderFoldersWithMediaFiles }} <span v-if="selectedLibraryMediaType === 'book'">{{ $strings.NoteUploaderOnlyAudioFiles }}</span>
|
||||
<span v-if="!isIOS">{{ $strings.NoteUploaderFoldersWithMediaFiles }}</span> <span v-if="selectedLibraryMediaType === 'book'">{{ $strings.NoteUploaderOnlyAudioFiles }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,8 +84,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<input ref="fileInput" type="file" multiple :accept="isIOS ? '' : inputAccept" class="hidden" @change="inputChanged" />
|
||||
<input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" v-if="!isIOS" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -127,6 +127,10 @@ export default {
|
||||
})
|
||||
return extensions
|
||||
},
|
||||
isIOS() {
|
||||
const ua = window.navigator.userAgent
|
||||
return /iPad|iPhone|iPod/.test(ua) && !window.MSStream
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ const defaultCode = 'en-us'
|
||||
const languageCodeMap = {
|
||||
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
||||
cs: { label: 'Čeština', dateFnsLocale: 'cs' },
|
||||
da: { label: 'Dansk', dateFnsLocale: 'da' },
|
||||
de: { label: 'Deutsch', dateFnsLocale: 'de' },
|
||||
|
||||
@@ -72,16 +72,17 @@ export const actions = {
|
||||
return this.$axios
|
||||
.$patch('/api/settings', updatePayload)
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
if (result.serverSettings) {
|
||||
commit('setServerSettings', result.serverSettings)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
return false
|
||||
const errorMsg = error.response?.data || 'Unknown error'
|
||||
return {
|
||||
error: errorMsg
|
||||
}
|
||||
})
|
||||
},
|
||||
checkForUpdate({ commit }) {
|
||||
|
||||
@@ -127,5 +127,30 @@
|
||||
"HeaderCollectionItems": "عناصر المجموعة",
|
||||
"HeaderCover": "الغلاف",
|
||||
"HeaderCurrentDownloads": "التنزيلات الجارية",
|
||||
"HeaderCustomMessageOnLogin": "رسالة مخصصة عند تسجيل الدخول"
|
||||
"HeaderCustomMessageOnLogin": "رسالة مخصصة عند تسجيل الدخول",
|
||||
"HeaderCustomMetadataProviders": "مقدمو البيانات الوصفية المخصصة",
|
||||
"HeaderDetails": "التفاصيل",
|
||||
"HeaderDownloadQueue": "تنزيل قائمة الانتظار",
|
||||
"HeaderEbookFiles": "ملفات الكتب الإلكترونية",
|
||||
"HeaderEmail": "البريد الإلكتروني",
|
||||
"HeaderEmailSettings": "إعدادات البريد الإلكتروني",
|
||||
"HeaderEpisodes": "الحلقات",
|
||||
"HeaderEreaderDevices": "أجهزة قراءة الكتب الإلكترونية",
|
||||
"HeaderEreaderSettings": "إعدادات القارئ الإلكتروني",
|
||||
"HeaderFiles": "ملفات",
|
||||
"HeaderFindChapters": "البحث عن الفصول",
|
||||
"HeaderIgnoredFiles": "الملفات المتجاهلة",
|
||||
"HeaderItemFiles": "ملفات العنصر",
|
||||
"HeaderItemMetadataUtils": "بيانات تعريف العنصر",
|
||||
"HeaderLastListeningSession": "آخر جلسة استماع",
|
||||
"HeaderLatestEpisodes": "أحدث الحلقات",
|
||||
"HeaderLibraries": "المكتبات",
|
||||
"HeaderLibraryFiles": "ملفات المكتبة",
|
||||
"HeaderLibraryStats": "إحصائيات المكتبة",
|
||||
"HeaderListeningSessions": "جلسات الاستماع",
|
||||
"HeaderListeningStats": "جلسات الاستماع",
|
||||
"HeaderLogin": "تسجيل الدخول",
|
||||
"HeaderLogs": "السجلات",
|
||||
"HeaderManageGenres": "إدارة الانواع",
|
||||
"HeaderManageTags": "إدارة العلامات"
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন",
|
||||
"ButtonQueueAddItem": "সারিতে যোগ করুন",
|
||||
"ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন",
|
||||
"ButtonQuickEmbed": "দ্রুত এম্বেড করুন",
|
||||
"ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন",
|
||||
"ButtonQuickMatch": "দ্রুত ম্যাচ",
|
||||
"ButtonReScan": "পুনরায় স্ক্যান",
|
||||
@@ -162,6 +163,7 @@
|
||||
"HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন",
|
||||
"HeaderNotifications": "বিজ্ঞপ্তি",
|
||||
"HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ",
|
||||
"HeaderOpenListeningSessions": "শোনার সেশন খুলুন",
|
||||
"HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন",
|
||||
"HeaderOtherFiles": "অন্যান্য ফাইল",
|
||||
"HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ",
|
||||
@@ -179,6 +181,7 @@
|
||||
"HeaderRemoveEpisodes": "{0}টি পর্ব সরান",
|
||||
"HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি",
|
||||
"HeaderSchedule": "সময়সূচী",
|
||||
"HeaderScheduleEpisodeDownloads": "স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন",
|
||||
"HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী",
|
||||
"HeaderSession": "সেশন",
|
||||
"HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন",
|
||||
@@ -224,7 +227,11 @@
|
||||
"LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী",
|
||||
"LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী",
|
||||
"LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে",
|
||||
"LabelApiToken": "API টোকেন",
|
||||
"LabelAppend": "সংযোজন",
|
||||
"LabelAudioBitrate": "অডিও বিটরেট (যেমন- 128k)",
|
||||
"LabelAudioChannels": "অডিও চ্যানেল (১ বা ২)",
|
||||
"LabelAudioCodec": "অডিও কোডেক",
|
||||
"LabelAuthor": "লেখক",
|
||||
"LabelAuthorFirstLast": "লেখক (প্রথম শেষ)",
|
||||
"LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)",
|
||||
@@ -237,6 +244,7 @@
|
||||
"LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন",
|
||||
"LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন",
|
||||
"LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান",
|
||||
"LabelBackupAudioFiles": "অডিও ফাইলগুলো ব্যাকআপ",
|
||||
"LabelBackupLocation": "ব্যাকআপ অবস্থান",
|
||||
"LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত",
|
||||
@@ -245,15 +253,18 @@
|
||||
"LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন",
|
||||
"LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।",
|
||||
"LabelBitrate": "বিটরেট",
|
||||
"LabelBonus": "উপরিলাভ",
|
||||
"LabelBooks": "বইগুলো",
|
||||
"LabelButtonText": "ঘর পাঠ্য",
|
||||
"LabelByAuthor": "দ্বারা {0}",
|
||||
"LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন",
|
||||
"LabelChannels": "চ্যানেল",
|
||||
"LabelChapterCount": "{0} অধ্যায়",
|
||||
"LabelChapterTitle": "অধ্যায়ের শিরোনাম",
|
||||
"LabelChapters": "অধ্যায়",
|
||||
"LabelChaptersFound": "অধ্যায় পাওয়া গেছে",
|
||||
"LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন",
|
||||
"LabelClickToUseCurrentValue": "বর্তমান মান ব্যবহার করতে ক্লিক করুন",
|
||||
"LabelClosePlayer": "প্লেয়ার বন্ধ করুন",
|
||||
"LabelCodec": "কোডেক",
|
||||
"LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন",
|
||||
@@ -303,12 +314,25 @@
|
||||
"LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা",
|
||||
"LabelEmbeddedCover": "এম্বেডেড কভার",
|
||||
"LabelEnable": "সক্ষম করুন",
|
||||
"LabelEncodingBackupLocation": "আপনার আসল অডিও ফাইলগুলোর একটি ব্যাকআপ এখানে সংরক্ষণ করা হবে:",
|
||||
"LabelEncodingChaptersNotEmbedded": "মাল্টি-ট্র্যাক অডিওবুকগুলোতে অধ্যায় এম্বেড করা হয় না।",
|
||||
"LabelEncodingClearItemCache": "পর্যায়ক্রমে আইটেম ক্যাশে পরিষ্কার করতে ভুলবেন না।",
|
||||
"LabelEncodingFinishedM4B": "সমাপ্ত হওয়া M4B-গুলো আপনার অডিওবুক ফোল্ডারে এখানে রাখা হবে:",
|
||||
"LabelEncodingInfoEmbedded": "আপনার অডিওবুক ফোল্ডারের ভিতরে অডিও ট্র্যাকগুলোতে মেটাডেটা এমবেড করা হবে।",
|
||||
"LabelEncodingStartedNavigation": "একবার টাস্ক শুরু হলে আপনি এই পৃষ্ঠা থেকে অন্যত্র যেতে পারেন।",
|
||||
"LabelEncodingTimeWarning": "এনকোডিং ৩০ মিনিট পর্যন্ত সময় নিতে পারে।",
|
||||
"LabelEncodingWarningAdvancedSettings": "সতর্কতা: এই সেটিংস আপডেট করবেন না, যদি না আপনি ffmpeg এনকোডিং বিকল্পগুলোর সাথে পরিচিত হন।",
|
||||
"LabelEncodingWatcherDisabled": "আপনার যদি পর্যবেক্ষক অক্ষম থাকে তবে আপনাকে পরে এই অডিওবুকটি পুনরায় স্ক্যান করতে হবে।",
|
||||
"LabelEnd": "সমাপ্ত",
|
||||
"LabelEndOfChapter": "অধ্যায়ের সমাপ্তি",
|
||||
"LabelEpisode": "পর্ব",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "পর্বটি আরএসএস ফিডের সাথে সংযুক্ত করা হয়নি",
|
||||
"LabelEpisodeNumber": "পর্ব #{0}",
|
||||
"LabelEpisodeTitle": "পর্বের শিরোনাম",
|
||||
"LabelEpisodeType": "পর্বের ধরন",
|
||||
"LabelEpisodeUrlFromRssFeed": "আরএসএস ফিড থেকে পর্ব URL",
|
||||
"LabelEpisodes": "পর্বগুলো",
|
||||
"LabelEpisodic": "প্রাসঙ্গিক",
|
||||
"LabelExample": "উদাহরণ",
|
||||
"LabelExpandSeries": "সিরিজ প্রসারিত করুন",
|
||||
"LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন",
|
||||
@@ -336,6 +360,7 @@
|
||||
"LabelFontScale": "ফন্ট স্কেল",
|
||||
"LabelFontStrikethrough": "অবচ্ছেদন রেখা",
|
||||
"LabelFormat": "ফরম্যাট",
|
||||
"LabelFull": "পূর্ণ",
|
||||
"LabelGenre": "ঘরানা",
|
||||
"LabelGenres": "ঘরানাগুলো",
|
||||
"LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন",
|
||||
@@ -391,6 +416,10 @@
|
||||
"LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার",
|
||||
"LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন",
|
||||
"LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে",
|
||||
"LabelMaxEpisodesToDownload": "সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "প্রতি কিস্তিতে সর্বাধিক # টি নতুন পর্ব ডাউনলোড করা হবে",
|
||||
"LabelMaxEpisodesToKeep": "সর্বোচ্চ # টি পর্ব রাখা হবে",
|
||||
"LabelMaxEpisodesToKeepHelp": "০ কোন সর্বোচ্চ সীমা সেট করে না। একটি নতুন পর্ব স্বয়ংক্রিয়-ডাউনলোড হওয়ার পরে আপনার যদি X-এর বেশি পর্ব থাকে তবে এটি সবচেয়ে পুরানো পর্বটি মুছে ফেলবে। এটি প্রতি নতুন ডাউনলোডের জন্য শুধুমাত্র ১ টি পর্ব মুছে ফেলবে।",
|
||||
"LabelMediaPlayer": "মিডিয়া প্লেয়ার",
|
||||
"LabelMediaType": "মিডিয়ার ধরন",
|
||||
"LabelMetaTag": "মেটা ট্যাগ",
|
||||
@@ -436,12 +465,14 @@
|
||||
"LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।",
|
||||
"LabelOpenRSSFeed": "আরএসএস ফিড খুলুন",
|
||||
"LabelOverwrite": "পুনঃলিখিত",
|
||||
"LabelPaginationPageXOfY": "{1} টির মধ্যে {0} পৃষ্ঠা",
|
||||
"LabelPassword": "পাসওয়ার্ড",
|
||||
"LabelPath": "পথ",
|
||||
"LabelPermanent": "স্থায়ী",
|
||||
"LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে",
|
||||
"LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে",
|
||||
"LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে",
|
||||
"LabelPermissionsCreateEreader": "ইরিডার তৈরি করতে পারেন",
|
||||
"LabelPermissionsDelete": "মুছে দিতে পারবে",
|
||||
"LabelPermissionsDownload": "ডাউনলোড করতে পারবে",
|
||||
"LabelPermissionsUpdate": "আপডেট করতে পারবে",
|
||||
@@ -465,6 +496,8 @@
|
||||
"LabelPubDate": "প্রকাশের তারিখ",
|
||||
"LabelPublishYear": "প্রকাশের বছর",
|
||||
"LabelPublishedDate": "প্রকাশিত {0}",
|
||||
"LabelPublishedDecade": "প্রকাশনার দশক",
|
||||
"LabelPublishedDecades": "প্রকাশনার দশকগুলো",
|
||||
"LabelPublisher": "প্রকাশক",
|
||||
"LabelPublishers": "প্রকাশকরা",
|
||||
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
|
||||
@@ -484,21 +517,28 @@
|
||||
"LabelRedo": "পুনরায় করুন",
|
||||
"LabelRegion": "অঞ্চল",
|
||||
"LabelReleaseDate": "উন্মোচনের তারিখ",
|
||||
"LabelRemoveAllMetadataAbs": "সমস্ত metadata.abs ফাইল সরান",
|
||||
"LabelRemoveAllMetadataJson": "সমস্ত metadata.json ফাইল সরান",
|
||||
"LabelRemoveCover": "কভার সরান",
|
||||
"LabelRemoveMetadataFile": "লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান",
|
||||
"LabelRemoveMetadataFileHelp": "আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।",
|
||||
"LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি",
|
||||
"LabelSearchTerm": "অনুসন্ধান শব্দ",
|
||||
"LabelSearchTitle": "অনুসন্ধান শিরোনাম",
|
||||
"LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN",
|
||||
"LabelSeason": "সেশন",
|
||||
"LabelSeasonNumber": "মরসুম #{0}",
|
||||
"LabelSelectAll": "সব নির্বাচন করুন",
|
||||
"LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন",
|
||||
"LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন",
|
||||
"LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন",
|
||||
"LabelSendEbookToDevice": "ই-বই পাঠান...",
|
||||
"LabelSequence": "ক্রম",
|
||||
"LabelSerial": "ধারাবাহিক",
|
||||
"LabelSeries": "সিরিজ",
|
||||
"LabelSeriesName": "সিরিজের নাম",
|
||||
"LabelSeriesProgress": "সিরিজের অগ্রগতি",
|
||||
"LabelServerLogLevel": "সার্ভার লগ লেভেল",
|
||||
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
|
||||
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
|
||||
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
|
||||
@@ -523,6 +563,9 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।",
|
||||
"LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন",
|
||||
"LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন",
|
||||
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "শতকরা সম্পূর্ণ এর চেয়ে বেশি",
|
||||
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "বাকি সময় (সেকেন্ড) এর চেয়ে কম",
|
||||
"LabelSettingsLibraryMarkAsFinishedWhen": "মিডিয়া আইটেমকে সমাপ্ত হিসাবে চিহ্নিত করুন যখন",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।",
|
||||
"LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন",
|
||||
@@ -587,6 +630,7 @@
|
||||
"LabelTimeDurationXMinutes": "{0} মিনিট",
|
||||
"LabelTimeDurationXSeconds": "{0} সেকেন্ড",
|
||||
"LabelTimeInMinutes": "মিনিটে সময়",
|
||||
"LabelTimeLeft": "{0} বাকি",
|
||||
"LabelTimeListened": "সময় শোনা হয়েছে",
|
||||
"LabelTimeListenedToday": "আজ শোনার সময়",
|
||||
"LabelTimeRemaining": "{0}টি অবশিষ্ট",
|
||||
@@ -594,6 +638,7 @@
|
||||
"LabelTitle": "শিরোনাম",
|
||||
"LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন",
|
||||
"LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।",
|
||||
"LabelToolsM4bEncoder": "M4B এনকোডার",
|
||||
"LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন",
|
||||
"LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।",
|
||||
"LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন",
|
||||
@@ -606,6 +651,7 @@
|
||||
"LabelTracksMultiTrack": "মাল্টি-ট্র্যাক",
|
||||
"LabelTracksNone": "কোন ট্র্যাক নেই",
|
||||
"LabelTracksSingleTrack": "একক-ট্র্যাক",
|
||||
"LabelTrailer": "আনুগমিক",
|
||||
"LabelType": "টাইপ",
|
||||
"LabelUnabridged": "অসংলগ্ন",
|
||||
"LabelUndo": "পূর্বাবস্থা",
|
||||
@@ -617,10 +663,13 @@
|
||||
"LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন",
|
||||
"LabelUpdatedAt": "আপডেট করা হয়েছে",
|
||||
"LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন",
|
||||
"LabelUploaderDragAndDropFilesOnly": "ফাইল টেনে আনুন",
|
||||
"LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন",
|
||||
"LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন",
|
||||
"LabelUseAdvancedOptions": "উন্নত বিকল্প ব্যবহার করুন",
|
||||
"LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন",
|
||||
"LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন",
|
||||
"LabelUseZeroForUnlimited": "অসীমের জন্য 0 ব্যবহার করুন",
|
||||
"LabelUser": "ব্যবহারকারী",
|
||||
"LabelUsername": "ব্যবহারকারীর নাম",
|
||||
"LabelValue": "মান",
|
||||
@@ -667,6 +716,7 @@
|
||||
"MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?",
|
||||
"MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?",
|
||||
"MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
|
||||
@@ -678,6 +728,7 @@
|
||||
"MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?",
|
||||
"MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?",
|
||||
"MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?",
|
||||
"MessageConfirmQuickMatchEpisodes": "একটি মিল পাওয়া গেলে দ্রুত ম্যাচিং পর্বগুলি বিস্তারিত ওভাররাইট করবে। শুধুমাত্র অতুলনীয় পর্ব আপডেট করা হবে। আপনি কি নিশ্চিত?",
|
||||
"MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?",
|
||||
"MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?",
|
||||
"MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?",
|
||||
@@ -685,6 +736,7 @@
|
||||
"MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?",
|
||||
"MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?",
|
||||
"MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?",
|
||||
"MessageConfirmRemoveMetadataFiles": "আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?",
|
||||
"MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?",
|
||||
@@ -700,6 +752,7 @@
|
||||
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
|
||||
"MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!",
|
||||
"MessageEmbedFinished": "এম্বেড করা শেষ!",
|
||||
"MessageEmbedQueue": "মেটাডেটা এম্বেডের জন্য সারিবদ্ধ ({0} সারিতে)",
|
||||
"MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ",
|
||||
"MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।",
|
||||
"MessageFeedURLWillBe": "ফিড URL হবে {0}",
|
||||
@@ -744,6 +797,7 @@
|
||||
"MessageNoLogs": "কোনও লগ নেই",
|
||||
"MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই",
|
||||
"MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই",
|
||||
"MessageNoPodcastFeed": "অবৈধ পডকাস্ট: কোনো ফিড নেই",
|
||||
"MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি",
|
||||
"MessageNoResults": "কোন ফলাফল নেই",
|
||||
"MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই",
|
||||
@@ -760,6 +814,10 @@
|
||||
"MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন",
|
||||
"MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই",
|
||||
"MessagePodcastSearchField": "অনুসন্ধান শব্দ বা RSS ফিড URL লিখুন",
|
||||
"MessageQuickEmbedInProgress": "দ্রুত এম্বেড করা হচ্ছে",
|
||||
"MessageQuickEmbedQueue": "দ্রুত এম্বেড করার জন্য সারিবদ্ধ ({0} সারিতে)",
|
||||
"MessageQuickMatchAllEpisodes": "দ্রুত ম্যাচ সব পর্ব",
|
||||
"MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।",
|
||||
"MessageRemoveChapter": "অধ্যায় সরান",
|
||||
"MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান",
|
||||
@@ -802,6 +860,9 @@
|
||||
"MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান",
|
||||
"MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ",
|
||||
"MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে",
|
||||
"MessageTaskOpmlParseFailed": "OPML ফাইল পার্স করতে ব্যর্থ হয়েছে",
|
||||
"MessageTaskOpmlParseFastFail": "অবৈধ OPML ফাইল <opml> ট্যাগ পাওয়া যায়নি বা একটি <outline> ট্যাগ পাওয়া যায়নি",
|
||||
"MessageTaskOpmlParseNoneFound": "OPML ফাইলে কোনো ফিড পাওয়া যায়নি",
|
||||
"MessageTaskScanItemsAdded": "{0}টি করা হয়েছে",
|
||||
"MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত",
|
||||
"MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে",
|
||||
@@ -826,6 +887,10 @@
|
||||
"NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।",
|
||||
"NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।",
|
||||
"NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।",
|
||||
"NotificationOnBackupCompletedDescription": "ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে",
|
||||
"NotificationOnBackupFailedDescription": "ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে",
|
||||
"NotificationOnEpisodeDownloadedDescription": "একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে",
|
||||
"NotificationOnTestDescription": "বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট",
|
||||
"PlaceholderNewCollection": "নতুন সংগ্রহের নাম",
|
||||
"PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ",
|
||||
"PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম",
|
||||
@@ -851,6 +916,7 @@
|
||||
"StatsYearInReview": "বাৎসরিক পর্যালোচনা",
|
||||
"ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে",
|
||||
"ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে",
|
||||
"ToastAsinRequired": "ASIN প্রয়োজন",
|
||||
"ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে",
|
||||
"ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি",
|
||||
"ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে",
|
||||
@@ -870,6 +936,8 @@
|
||||
"ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে",
|
||||
"ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে",
|
||||
"ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে",
|
||||
"ToastBatchQuickMatchFailed": "ব্যাচ কুইক ম্যাচ ব্যর্থ!",
|
||||
"ToastBatchQuickMatchStarted": "{0}টি বইয়ের ব্যাচ কুইক ম্যাচ শুরু হয়েছে!",
|
||||
"ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে",
|
||||
"ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য",
|
||||
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
|
||||
@@ -881,6 +949,7 @@
|
||||
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
|
||||
"ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে",
|
||||
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
|
||||
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
|
||||
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
|
||||
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
|
||||
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
|
||||
@@ -898,11 +967,14 @@
|
||||
"ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে",
|
||||
"ToastEpisodeUpdateSuccess": "{0}টি পর্ব আপডেট করা হয়েছে",
|
||||
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
|
||||
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
|
||||
"ToastFailedToMatch": "মেলাতে ব্যর্থ হয়েছে",
|
||||
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
|
||||
"ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে",
|
||||
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
|
||||
"ToastInvalidMaxEpisodesToDownload": "ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব",
|
||||
"ToastInvalidUrl": "অকার্যকর ইউআরএল",
|
||||
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
|
||||
"ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ",
|
||||
@@ -920,14 +992,22 @@
|
||||
"ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ",
|
||||
"ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে",
|
||||
"ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে",
|
||||
"ToastMatchAllAuthorsFailed": "সমস্ত লেখকের সাথে মিলতে ব্যর্থ হয়েছে",
|
||||
"ToastMetadataFilesRemovedError": "মেটাডেটা সরানোর সময় ত্রুটি {0} ফাইল",
|
||||
"ToastMetadataFilesRemovedNoneFound": "কোনো মেটাডেটা নেই।লাইব্রেরিতে {0} ফাইল পাওয়া গেছে",
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "কোনো মেটাডেটা নেই।{0} ফাইল সরানো হয়েছে",
|
||||
"ToastMetadataFilesRemovedSuccess": "{0} মেটাডেটা৷{1} ফাইল সরানো হয়েছে",
|
||||
"ToastMustHaveAtLeastOnePath": "অন্তত একটি পথ থাকতে হবে",
|
||||
"ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক",
|
||||
"ToastNameRequired": "নাম আবশ্যক",
|
||||
"ToastNewEpisodesFound": "{0}টি নতুন পর্ব পাওয়া গেছে",
|
||||
"ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"",
|
||||
"ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে",
|
||||
"ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে",
|
||||
"ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে",
|
||||
"ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে",
|
||||
"ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন",
|
||||
"ToastNoNewEpisodesFound": "কোন নতুন পর্ব পাওয়া যায়নি",
|
||||
"ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই",
|
||||
"ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ",
|
||||
"ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ",
|
||||
@@ -946,6 +1026,7 @@
|
||||
"ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে",
|
||||
"ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি",
|
||||
"ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই",
|
||||
"ToastProgressIsNotBeingSynced": "অগ্রগতি সিঙ্ক হচ্ছে না, প্লেব্যাক পুনরায় চালু করুন",
|
||||
"ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে",
|
||||
"ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে",
|
||||
"ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক",
|
||||
@@ -972,6 +1053,7 @@
|
||||
"ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে",
|
||||
"ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ",
|
||||
"ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে",
|
||||
"ToastSleepTimerDone": "স্লিপ টাইমার হয়ে গেছে... zZzzZz",
|
||||
"ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে",
|
||||
"ToastSlugRequired": "স্লাগ আবশ্যক",
|
||||
"ToastSocketConnected": "সকেট সংযুক্ত",
|
||||
|
||||
1027
client/strings/ca.json
Normal file
1027
client/strings/ca.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,8 +71,8 @@
|
||||
"ButtonQuickMatch": "Schnellabgleich",
|
||||
"ButtonReScan": "Neu scannen",
|
||||
"ButtonRead": "Lesen",
|
||||
"ButtonReadLess": "Weniger anzeigen",
|
||||
"ButtonReadMore": "Mehr anzeigen",
|
||||
"ButtonReadLess": "weniger Anzeigen",
|
||||
"ButtonReadMore": "Mehr Anzeigen",
|
||||
"ButtonRefresh": "Neu Laden",
|
||||
"ButtonRemove": "Entfernen",
|
||||
"ButtonRemoveAll": "Alles entfernen",
|
||||
@@ -220,7 +220,7 @@
|
||||
"LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
|
||||
"LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
|
||||
"LabelAddedAt": "Hinzugefügt am",
|
||||
"LabelAddedDate": "Hinzugefügt {0}",
|
||||
"LabelAddedDate": "{0} Hinzugefügt",
|
||||
"LabelAdminUsersOnly": "Nur Admin Benutzer",
|
||||
"LabelAll": "Alle",
|
||||
"LabelAllUsers": "Alle Benutzer",
|
||||
@@ -534,6 +534,7 @@
|
||||
"LabelSelectUsers": "Benutzer auswählen",
|
||||
"LabelSendEbookToDevice": "E-Buch senden an …",
|
||||
"LabelSequence": "Reihenfolge",
|
||||
"LabelSerial": "fortlaufend",
|
||||
"LabelSeries": "Serien",
|
||||
"LabelSeriesName": "Serienname",
|
||||
"LabelSeriesProgress": "Serienfortschritt",
|
||||
@@ -583,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",
|
||||
"LabelSettingsTimeFormat": "Zeitformat",
|
||||
"LabelShare": "Freigeben",
|
||||
"LabelShareOpen": "Freigabe",
|
||||
"LabelShareOpen": "Freigeben",
|
||||
"LabelShareURL": "Freigabe URL",
|
||||
"LabelShowAll": "Alles anzeigen",
|
||||
"LabelShowSeconds": "Zeige Sekunden",
|
||||
@@ -662,6 +663,7 @@
|
||||
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird",
|
||||
"LabelUpdatedAt": "Aktualisiert am",
|
||||
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Dateien per Drag & Drop hierher ziehen",
|
||||
"LabelUploaderDropFiles": "Dateien löschen",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie",
|
||||
"LabelUseAdvancedOptions": "Nutze Erweiterte Optionen",
|
||||
@@ -677,11 +679,13 @@
|
||||
"LabelViewPlayerSettings": "Zeige player Einstellungen",
|
||||
"LabelViewQueue": "Player-Warteschlange anzeigen",
|
||||
"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",
|
||||
"LabelXBooks": "{0} Bücher",
|
||||
"LabelXItems": "{0} Medien",
|
||||
"LabelYearReviewHide": "Verstecke Jahr in Übersicht",
|
||||
"LabelYearReviewShow": "Zeige Jahr in Übersicht",
|
||||
"LabelYearReviewHide": "Jahresrückblick verbergen",
|
||||
"LabelYearReviewShow": "Jahresrückblick anzeigen",
|
||||
"LabelYourAudiobookDuration": "Laufzeit deines Mediums",
|
||||
"LabelYourBookmarks": "Lesezeichen",
|
||||
"LabelYourPlaylists": "Eigene Wiedergabelisten",
|
||||
@@ -726,7 +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?",
|
||||
"MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter <code>/metadata/cache/items</code> gelöscht.<br />Bist du dir sicher?",
|
||||
"MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt. <br><br>Möchtest du fortfahren?",
|
||||
"MessageConfirmQuickMatchEpisodes": "Schnelles Zuordnen von Episoden überschreibt die Details, wenn eine Übereinstimmung gefunden wird. Nur nicht zugeordnete Episoden werden aktualisiert. Bist du sicher?",
|
||||
"MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details, wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?",
|
||||
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
|
||||
@@ -831,7 +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",
|
||||
"MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab",
|
||||
"MessageShareExpiresIn": "Läuft in {0} ab",
|
||||
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein.",
|
||||
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein",
|
||||
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
|
||||
"MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt",
|
||||
"MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen",
|
||||
@@ -1039,7 +1043,7 @@
|
||||
"ToastRenameFailed": "Umbenennen fehlgeschlagen",
|
||||
"ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}",
|
||||
"ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt",
|
||||
"ToastRescanUpToDate": "Erneut scannen erledigt, Artikel wahr auf dem neusten Stand",
|
||||
"ToastRescanUpToDate": "Erneut scannen erledigt, Artikel war auf dem neusten Stand",
|
||||
"ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert",
|
||||
"ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek",
|
||||
"ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus",
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "Save Tracklist",
|
||||
"ButtonScan": "Scan",
|
||||
"ButtonScanLibrary": "Scan Library",
|
||||
"ButtonScrollLeft": "Scroll Left",
|
||||
"ButtonScrollRight": "Scroll Right",
|
||||
"ButtonSearch": "Search",
|
||||
"ButtonSelectFolderPath": "Select Folder Path",
|
||||
"ButtonSeries": "Series",
|
||||
@@ -190,6 +192,7 @@
|
||||
"HeaderSettingsExperimental": "Experimental Features",
|
||||
"HeaderSettingsGeneral": "General",
|
||||
"HeaderSettingsScanner": "Scanner",
|
||||
"HeaderSettingsWebClient": "Web Client",
|
||||
"HeaderSleepTimer": "Sleep Timer",
|
||||
"HeaderStatsLargestItems": "Largest Items",
|
||||
"HeaderStatsLongestItems": "Longest Items (hrs)",
|
||||
@@ -542,6 +545,7 @@
|
||||
"LabelServerYearReview": "Server Year in Review ({0})",
|
||||
"LabelSetEbookAsPrimary": "Set as primary",
|
||||
"LabelSetEbookAsSupplementary": "Set as supplementary",
|
||||
"LabelSettingsAllowIframe": "Allow embedding in an iframe",
|
||||
"LabelSettingsAudiobooksOnly": "Audiobooks only",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
|
||||
@@ -592,6 +596,8 @@
|
||||
"LabelSize": "Size",
|
||||
"LabelSleepTimer": "Sleep timer",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelSortAscending": "Ascending",
|
||||
"LabelSortDescending": "Descending",
|
||||
"LabelStart": "Start",
|
||||
"LabelStartTime": "Start Time",
|
||||
"LabelStarted": "Started",
|
||||
@@ -663,6 +669,7 @@
|
||||
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
|
||||
"LabelUpdatedAt": "Updated At",
|
||||
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Drag & drop files",
|
||||
"LabelUploaderDropFiles": "Drop files",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseAdvancedOptions": "Use Advanced Options",
|
||||
@@ -678,6 +685,8 @@
|
||||
"LabelViewPlayerSettings": "View player settings",
|
||||
"LabelViewQueue": "View player queue",
|
||||
"LabelVolume": "Volume",
|
||||
"LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:",
|
||||
"LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs",
|
||||
"LabelWeekdaysToRun": "Weekdays to run",
|
||||
"LabelXBooks": "{0} books",
|
||||
"LabelXItems": "{0} items",
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
|
||||
"LabelUpdatedAt": "Actualizado En",
|
||||
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Arrastrar y soltar archivos",
|
||||
"LabelUploaderDropFiles": "Suelte los Archivos",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente",
|
||||
"LabelUseAdvancedOptions": "Usar opciones avanzadas",
|
||||
@@ -678,6 +679,8 @@
|
||||
"LabelViewPlayerSettings": "Ver los ajustes del reproductor",
|
||||
"LabelViewQueue": "Ver Fila del Reproductor",
|
||||
"LabelVolume": "Volumen",
|
||||
"LabelWebRedirectURLsDescription": "Autorice estas URL en su proveedor OAuth para permitir la redirección a la aplicación web después de iniciar sesión:",
|
||||
"LabelWebRedirectURLsSubfolder": "Subcarpeta para URL de redireccionamiento",
|
||||
"LabelWeekdaysToRun": "Correr en Días de la Semana",
|
||||
"LabelXBooks": "{0} libros",
|
||||
"LabelXItems": "{0} elementos",
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée",
|
||||
"LabelUpdatedAt": "Mis à jour à",
|
||||
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Glisser & déposer des fichiers",
|
||||
"LabelUploaderDropFiles": "Déposer des fichiers",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, l’auteur et la série",
|
||||
"LabelUseAdvancedOptions": "Utiliser les options avancées",
|
||||
@@ -869,10 +870,10 @@
|
||||
"MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »",
|
||||
"MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »",
|
||||
"MessageTaskTargetDirectoryNotWritable": "Le répertoire cible n’est pas accessible en écriture",
|
||||
"MessageThinking": "Je cherche…",
|
||||
"MessageThinking": "À la recherche de…",
|
||||
"MessageUploaderItemFailed": "Échec du téléversement",
|
||||
"MessageUploaderItemSuccess": "Téléversement effectué !",
|
||||
"MessageUploading": "Téléversement…",
|
||||
"MessageUploading": "Téléchargement…",
|
||||
"MessageValidCronExpression": "Expression cron valide",
|
||||
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
|
||||
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"ButtonChooseAFolder": "בחר תיקייה",
|
||||
"ButtonChooseFiles": "בחר קבצים",
|
||||
"ButtonClearFilter": "נקה סינון",
|
||||
"ButtonCloseFeed": "סגור פיד",
|
||||
"ButtonCloseFeed": "סגור ערוץ",
|
||||
"ButtonCloseSession": "סגור סשן פתוח",
|
||||
"ButtonCollections": "אוספים",
|
||||
"ButtonConfigureScanner": "הגדר סורק",
|
||||
"ButtonCreate": "צור",
|
||||
@@ -28,6 +29,7 @@
|
||||
"ButtonEdit": "ערוך",
|
||||
"ButtonEditChapters": "ערוך פרקים",
|
||||
"ButtonEditPodcast": "ערוך פודקאסט",
|
||||
"ButtonEnable": "הפעל",
|
||||
"ButtonForceReScan": "סרוק מחדש בכוח",
|
||||
"ButtonFullPath": "נתיב מלא",
|
||||
"ButtonHide": "הסתר",
|
||||
@@ -46,19 +48,24 @@
|
||||
"ButtonNevermind": "לא משנה",
|
||||
"ButtonNext": "הבא",
|
||||
"ButtonNextChapter": "פרק הבא",
|
||||
"ButtonNextItemInQueue": "פריט הבא בתור",
|
||||
"ButtonOk": "אישור",
|
||||
"ButtonOpenFeed": "פתח פיד",
|
||||
"ButtonOpenManager": "פתח מנהל",
|
||||
"ButtonPause": "השהה",
|
||||
"ButtonPlay": "נגן",
|
||||
"ButtonPlayAll": "נגן הכל",
|
||||
"ButtonPlaying": "מנגן",
|
||||
"ButtonPlaylists": "רשימות השמעה",
|
||||
"ButtonPrevious": "קודם",
|
||||
"ButtonPreviousChapter": "פרק קודם",
|
||||
"ButtonProbeAudioFile": "בדוק קובץ אודיו",
|
||||
"ButtonPurgeAllCache": "נקה את כל המטמון",
|
||||
"ButtonPurgeItemsCache": "נקה את מטמון הפריטים",
|
||||
"ButtonQueueAddItem": "הוסף לתור",
|
||||
"ButtonQueueRemoveItem": "הסר מהתור",
|
||||
"ButtonQuickEmbed": "הטמעה מהירה",
|
||||
"ButtonQuickEmbedMetadata": "הטמעת מטא נתונים מהירה",
|
||||
"ButtonQuickMatch": "התאמה מהירה",
|
||||
"ButtonReScan": "סרוק מחדש",
|
||||
"ButtonRead": "קרא",
|
||||
@@ -88,8 +95,10 @@
|
||||
"ButtonShow": "הצג",
|
||||
"ButtonStartM4BEncode": "התחל קידוד M4B",
|
||||
"ButtonStartMetadataEmbed": "התחל הטמעת מטא-נתונים",
|
||||
"ButtonStats": "סטטיסטיקות",
|
||||
"ButtonSubmit": "שלח",
|
||||
"ButtonTest": "בדיקה",
|
||||
"ButtonUnlinkOpenId": "נתק OpenID",
|
||||
"ButtonUpload": "העלה",
|
||||
"ButtonUploadBackup": "העלה גיבוי",
|
||||
"ButtonUploadCover": "העלה כריכה",
|
||||
@@ -102,6 +111,7 @@
|
||||
"ErrorUploadFetchMetadataNoResults": "לא ניתן לשלוף מטא-נתונים - נסה לעדכן כותרת ו/או יוצר",
|
||||
"ErrorUploadLacksTitle": "חובה לתת כותרת",
|
||||
"HeaderAccount": "חשבון",
|
||||
"HeaderAddCustomMetadataProvider": "הוסף ספק מטא-נתונים מותאם אישית",
|
||||
"HeaderAdvanced": "מתקדם",
|
||||
"HeaderAppriseNotificationSettings": "הגדרות התראות של Apprise",
|
||||
"HeaderAudioTracks": "רצועות קול",
|
||||
@@ -147,13 +157,17 @@
|
||||
"HeaderMetadataToEmbed": "מטא-נתונים להטמעה",
|
||||
"HeaderNewAccount": "חשבון חדש",
|
||||
"HeaderNewLibrary": "ספרייה חדשה",
|
||||
"HeaderNotificationCreate": "צור התראה",
|
||||
"HeaderNotificationUpdate": "עדכון התראה",
|
||||
"HeaderNotifications": "התראות",
|
||||
"HeaderOpenIDConnectAuthentication": "אימות OpenID Connect",
|
||||
"HeaderOpenListeningSessions": "פתח הפעלות האזנה",
|
||||
"HeaderOpenRSSFeed": "פתח ערוץ RSS",
|
||||
"HeaderOtherFiles": "קבצים אחרים",
|
||||
"HeaderPasswordAuthentication": "אימות סיסמה",
|
||||
"HeaderPermissions": "הרשאות",
|
||||
"HeaderPlayerQueue": "תור ניגון",
|
||||
"HeaderPlayerSettings": "הגדרות נגן",
|
||||
"HeaderPlaylist": "רשימת השמעה",
|
||||
"HeaderPlaylistItems": "פריטי רשימת השמעה",
|
||||
"HeaderPodcastsToAdd": "פודקאסטים להוספה",
|
||||
@@ -165,6 +179,7 @@
|
||||
"HeaderRemoveEpisodes": "הסר {0} פרקים",
|
||||
"HeaderSavedMediaProgress": "התקדמות מדיה שמורה",
|
||||
"HeaderSchedule": "תיזמון",
|
||||
"HeaderScheduleEpisodeDownloads": "תזמן הורדת פרקים אוטומטית",
|
||||
"HeaderScheduleLibraryScans": "קבע סריקות ספרייה אוטומטיות",
|
||||
"HeaderSession": "הפעלה",
|
||||
"HeaderSetBackupSchedule": "קבע לוח זמנים לגיבוי",
|
||||
@@ -190,6 +205,9 @@
|
||||
"HeaderYearReview": "שנת {0} בסקירה",
|
||||
"HeaderYourStats": "הסטטיסטיקות שלך",
|
||||
"LabelAbridged": "מקוצר",
|
||||
"LabelAbridgedChecked": "מקוצר (מסומן)",
|
||||
"LabelAbridgedUnchecked": "בלתי מקוצר (לא מסומן)",
|
||||
"LabelAccessibleBy": "נגיש על ידי",
|
||||
"LabelAccountType": "סוג חשבון",
|
||||
"LabelAccountTypeAdmin": "מנהל",
|
||||
"LabelAccountTypeGuest": "אורח",
|
||||
@@ -200,13 +218,18 @@
|
||||
"LabelAddToPlaylist": "הוסף לרשימת השמעה",
|
||||
"LabelAddToPlaylistBatch": "הוסף {0} פריטים לרשימת השמעה",
|
||||
"LabelAddedAt": "נוסף בתאריך",
|
||||
"LabelAddedDate": "נוסף ב-{0}",
|
||||
"LabelAdminUsersOnly": "רק מנהלים",
|
||||
"LabelAll": "הכל",
|
||||
"LabelAllUsers": "כל המשתמשים",
|
||||
"LabelAllUsersExcludingGuests": "כל המשתמשים, ללא אורחים",
|
||||
"LabelAllUsersIncludingGuests": "כל המשתמשים כולל אורחים",
|
||||
"LabelAlreadyInYourLibrary": "כבר קיים בספרייה שלך",
|
||||
"LabelApiToken": "טוקן API",
|
||||
"LabelAppend": "הוסף לסוף",
|
||||
"LabelAudioBitrate": "קצב סיביות (לדוגמא 128k)",
|
||||
"LabelAudioChannels": "ערוצי קול (1 או 2)",
|
||||
"LabelAudioCodec": "קידוד קול",
|
||||
"LabelAuthor": "יוצר",
|
||||
"LabelAuthorFirstLast": "יוצר (שם פרטי שם משפחה)",
|
||||
"LabelAuthorLastFirst": "יוצר (שם משפחה, שם פרטי)",
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
"LabelCollapseSubSeries": "Podserijale prikaži sažeto",
|
||||
"LabelCollection": "Zbirka",
|
||||
"LabelCollections": "Zbirke",
|
||||
"LabelComplete": "Dovršeno",
|
||||
"LabelComplete": "Potpuno",
|
||||
"LabelConfirmPassword": "Potvrda zaporke",
|
||||
"LabelContinueListening": "Nastavi slušati",
|
||||
"LabelContinueReading": "Nastavi čitati",
|
||||
@@ -532,7 +532,7 @@
|
||||
"LabelSelectAllEpisodes": "Označi sve nastavke",
|
||||
"LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka",
|
||||
"LabelSelectUsers": "Označi korisnike",
|
||||
"LabelSendEbookToDevice": "Pošalji e-knjigu",
|
||||
"LabelSendEbookToDevice": "Pošalji e-knjigu …",
|
||||
"LabelSequence": "Slijed",
|
||||
"LabelSerial": "Serijal",
|
||||
"LabelSeries": "Serijal",
|
||||
@@ -567,7 +567,7 @@
|
||||
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)",
|
||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako uključite ovu opciju, serijal će vam se nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako se ova opcija uključi serijal će nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.",
|
||||
"LabelSettingsParseSubtitles": "Raščlani podnaslove",
|
||||
"LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.<br>Podnaslov mora biti odvojen s \" - \"<br>npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki",
|
||||
@@ -663,6 +663,7 @@
|
||||
"LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju",
|
||||
"LabelUpdatedAt": "Ažurirano",
|
||||
"LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Pritisni i prevuci datoteke",
|
||||
"LabelUploaderDropFiles": "Ispusti datoteke",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
|
||||
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama",
|
||||
@@ -678,6 +679,8 @@
|
||||
"LabelViewPlayerSettings": "Pogledaj postavke reproduktora",
|
||||
"LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora",
|
||||
"LabelVolume": "Glasnoća",
|
||||
"LabelWebRedirectURLsDescription": "Autoriziraj ove URL-ove u svom pružatelju OAuth ovjere kako bi omogućio preusmjeravanje natrag na web-aplikaciju nakon prijave:",
|
||||
"LabelWebRedirectURLsSubfolder": "Podmapa za URL-ove preusmjeravanja",
|
||||
"LabelWeekdaysToRun": "Dani u tjednu za pokretanje",
|
||||
"LabelXBooks": "{0} knjiga",
|
||||
"LabelXItems": "{0} stavki",
|
||||
|
||||
@@ -66,13 +66,13 @@
|
||||
"ButtonPurgeItemsCache": "Elimina la Cache selezionata",
|
||||
"ButtonQueueAddItem": "Aggiungi alla Coda",
|
||||
"ButtonQueueRemoveItem": "Rimuovi dalla Coda",
|
||||
"ButtonQuickEmbed": "Quick Embed",
|
||||
"ButtonQuickEmbed": "Incorporazione Rapida",
|
||||
"ButtonQuickEmbedMetadata": "Incorporamento rapido Metadati",
|
||||
"ButtonQuickMatch": "Controlla Metadata Auto",
|
||||
"ButtonReScan": "Ri-scansiona",
|
||||
"ButtonRead": "Leggi",
|
||||
"ButtonReadLess": "Leggi di Meno",
|
||||
"ButtonReadMore": "Leggi di Più",
|
||||
"ButtonReadLess": "Riduci",
|
||||
"ButtonReadMore": "Espandi",
|
||||
"ButtonRefresh": "Aggiorna",
|
||||
"ButtonRemove": "Rimuovi",
|
||||
"ButtonRemoveAll": "Rimuovi Tutto",
|
||||
@@ -220,7 +220,7 @@
|
||||
"LabelAddToPlaylist": "Aggiungi alla playlist",
|
||||
"LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
|
||||
"LabelAddedAt": "Aggiunto il",
|
||||
"LabelAddedDate": "{0} aggiunti",
|
||||
"LabelAddedDate": "Aggiunti {0}",
|
||||
"LabelAdminUsersOnly": "Solo utenti Amministratori",
|
||||
"LabelAll": "Tutti",
|
||||
"LabelAllUsers": "Tutti gli Utenti",
|
||||
@@ -495,7 +495,7 @@
|
||||
"LabelProviderAuthorizationValue": "Authorization Header Value",
|
||||
"LabelPubDate": "Data di pubblicazione",
|
||||
"LabelPublishYear": "Anno di pubblicazione",
|
||||
"LabelPublishedDate": "{0} pubblicati",
|
||||
"LabelPublishedDate": "Pubblicati {0}",
|
||||
"LabelPublishedDecade": "Decennio di pubblicazione",
|
||||
"LabelPublishedDecades": "Decenni di pubblicazione",
|
||||
"LabelPublisher": "Editore",
|
||||
@@ -682,7 +682,7 @@
|
||||
"LabelXBooks": "{0} libri",
|
||||
"LabelXItems": "{0} oggetti",
|
||||
"LabelYearReviewHide": "Nascondi Anno in rassegna",
|
||||
"LabelYearReviewShow": "Vedi Anno in rassegna",
|
||||
"LabelYearReviewShow": "Mostra Anno in rassegna",
|
||||
"LabelYourAudiobookDuration": "La durata dell'audiolibro",
|
||||
"LabelYourBookmarks": "I tuoi preferiti",
|
||||
"LabelYourPlaylists": "le tue Playlist",
|
||||
@@ -779,7 +779,7 @@
|
||||
"MessageNoBackups": "Nessun Backup",
|
||||
"MessageNoBookmarks": "Nessun preferito",
|
||||
"MessageNoChapters": "Nessun capitolo",
|
||||
"MessageNoCollections": "Nessuna Raccolta",
|
||||
"MessageNoCollections": "Nessuna Collezione",
|
||||
"MessageNoCoversFound": "Nessuna Cover Trovata",
|
||||
"MessageNoDescription": "Nessuna descrizione",
|
||||
"MessageNoDevices": "nessun dispositivo",
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
|
||||
"LabelUpdatedAt": "Обновлено в",
|
||||
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Перетаскивание файлов",
|
||||
"LabelUploaderDropFiles": "Перетащите файлы",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии",
|
||||
"LabelUseAdvancedOptions": "Используйте расширенные опции",
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod",
|
||||
"HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice",
|
||||
"HeaderSession": "Seja",
|
||||
"HeaderSetBackupSchedule": "Nastavite urnik varnostnega kopiranja",
|
||||
"HeaderSetBackupSchedule": "Nastavi urnik varnostnega kopiranja",
|
||||
"HeaderSettings": "Nastavitve",
|
||||
"HeaderSettingsDisplay": "Zaslon",
|
||||
"HeaderSettingsExperimental": "Eksperimentalne funkcije",
|
||||
@@ -495,7 +495,7 @@
|
||||
"LabelProviderAuthorizationValue": "Vrednost glave avtorizacije",
|
||||
"LabelPubDate": "Datum objave",
|
||||
"LabelPublishYear": "Leto izdaje",
|
||||
"LabelPublishedDate": "Izdano {0}",
|
||||
"LabelPublishedDate": "Objavljeno {0}",
|
||||
"LabelPublishedDecade": "Desetletje izdaje",
|
||||
"LabelPublishedDecades": "Desetletja izdaje",
|
||||
"LabelPublisher": "Izdajatelj",
|
||||
@@ -663,6 +663,7 @@
|
||||
"LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje",
|
||||
"LabelUpdatedAt": "Posodobljeno ob",
|
||||
"LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Povleci in spusti datoteke",
|
||||
"LabelUploaderDropFiles": "Spusti datoteke",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo",
|
||||
"LabelUseAdvancedOptions": "Uporabi napredne možnosti",
|
||||
@@ -678,11 +679,13 @@
|
||||
"LabelViewPlayerSettings": "Ogled nastavitev predvajalnika",
|
||||
"LabelViewQueue": "Ogled čakalno vrsto predvajalnika",
|
||||
"LabelVolume": "Glasnost",
|
||||
"LabelWebRedirectURLsDescription": "Avtorizirajte URL-je pri svojem ponudniku OAuth ter s tem omogočite preusmeritev nazaj v spletno aplikacijo po prijavi:",
|
||||
"LabelWebRedirectURLsSubfolder": "Podmapa za URL-je preusmeritve",
|
||||
"LabelWeekdaysToRun": "Delovni dnevi predvajanja",
|
||||
"LabelXBooks": "{0} knjig",
|
||||
"LabelXItems": "{0} elementov",
|
||||
"LabelYearReviewHide": "Skrij pregled leta",
|
||||
"LabelYearReviewShow": "Poglej pregled leta",
|
||||
"LabelYearReviewShow": "Poglej si pregled leta",
|
||||
"LabelYourAudiobookDuration": "Trajanje tvojih zvočnih knjig",
|
||||
"LabelYourBookmarks": "Tvoji zaznamki",
|
||||
"LabelYourPlaylists": "Tvoje seznami predvajanj",
|
||||
@@ -829,7 +832,7 @@
|
||||
"MessageSearchResultsFor": "Rezultati iskanja za",
|
||||
"MessageSelected": "{0} izbrano",
|
||||
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
|
||||
"MessageSetChaptersFromTracksDescription": "Nastavite poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke",
|
||||
"MessageSetChaptersFromTracksDescription": "Nastavi poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke",
|
||||
"MessageShareExpirationWillBe": "Potečeno bo <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "Poteče čez {0}",
|
||||
"MessageShareURLWillBe": "URL za skupno rabo bo <strong>{0}</strong>",
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
"LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення",
|
||||
"LabelUpdatedAt": "Оновлення",
|
||||
"LabelUploaderDragAndDrop": "Перетягніть файли або теки",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Перетягніть і скиньте файли",
|
||||
"LabelUploaderDropFiles": "Перетягніть файли",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію",
|
||||
"LabelUseAdvancedOptions": "Використовувати розширені налаштування",
|
||||
@@ -678,6 +679,8 @@
|
||||
"LabelViewPlayerSettings": "Переглянути налаштування програвача",
|
||||
"LabelViewQueue": "Переглянути чергу відтворення",
|
||||
"LabelVolume": "Гучність",
|
||||
"LabelWebRedirectURLsDescription": "Авторизуйте ці URL у вашому OAuth постачальнику, щоб дозволити редирекцію назад до веб-додатку після входу:",
|
||||
"LabelWebRedirectURLsSubfolder": "Підпапка для Redirect URL",
|
||||
"LabelWeekdaysToRun": "Виконувати у дні",
|
||||
"LabelXBooks": "{0} книг",
|
||||
"LabelXItems": "{0} елементів",
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
|
||||
"LabelUpdatedAt": "更新时间",
|
||||
"LabelUploaderDragAndDrop": "拖放文件或文件夹",
|
||||
"LabelUploaderDragAndDropFilesOnly": "拖放文件",
|
||||
"LabelUploaderDropFiles": "删除文件",
|
||||
"LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列",
|
||||
"LabelUseAdvancedOptions": "使用高级选项",
|
||||
@@ -678,6 +679,8 @@
|
||||
"LabelViewPlayerSettings": "查看播放器设置",
|
||||
"LabelViewQueue": "查看播放列表",
|
||||
"LabelVolume": "音量",
|
||||
"LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:",
|
||||
"LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹",
|
||||
"LabelWeekdaysToRun": "工作日运行",
|
||||
"LabelXBooks": "{0} 本书",
|
||||
"LabelXItems": "{0} 项目",
|
||||
|
||||
1
index.js
1
index.js
@@ -11,6 +11,7 @@ if (isDev) {
|
||||
if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath
|
||||
if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath
|
||||
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
|
||||
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
||||
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
||||
process.env.SOURCE = 'local'
|
||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.5",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.5",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -41,6 +41,13 @@ Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/
|
||||
|
||||
Join us on [Discord](https://discord.gg/HQgCbd6E75)
|
||||
|
||||
### Demo
|
||||
|
||||
Check out the web client demo: https://audiobooks.dev/ (thanks for hosting [@Vito0912](https://github.com/Vito0912)!)
|
||||
|
||||
Username/password: `demo`/`demo` (user account)
|
||||
|
||||
|
||||
### Android App (beta)
|
||||
|
||||
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||
|
||||
@@ -131,7 +131,7 @@ class Auth {
|
||||
{
|
||||
client: openIdClient,
|
||||
params: {
|
||||
redirect_uri: '/auth/openid/callback',
|
||||
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
|
||||
scope: 'openid profile email'
|
||||
}
|
||||
},
|
||||
@@ -480,9 +480,9 @@ class Auth {
|
||||
// for the request to mobile-redirect and as such the session is not shared
|
||||
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
|
||||
|
||||
redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString()
|
||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
||||
} else {
|
||||
redirectUri = new URL('/auth/openid/callback', hostUrl).toString()
|
||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
|
||||
|
||||
if (req.query.state) {
|
||||
Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
|
||||
@@ -733,7 +733,7 @@ class Auth {
|
||||
const host = req.get('host')
|
||||
// TODO: ABS does currently not support subfolders for installation
|
||||
// If we want to support it we need to include a config for the serverurl
|
||||
postLogoutRedirectUri = `${protocol}://${host}/login`
|
||||
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
|
||||
}
|
||||
// else for openid-mobile we keep postLogoutRedirectUri on null
|
||||
// nice would be to redirect to the app here, but for example Authentik does not implement
|
||||
|
||||
@@ -406,11 +406,6 @@ class Database {
|
||||
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
|
||||
}
|
||||
|
||||
removeLibrary(libraryId) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.library.removeById(libraryId)
|
||||
}
|
||||
|
||||
createBulkCollectionBooks(collectionBooks) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.collectionBook.bulkCreate(collectionBooks)
|
||||
@@ -449,21 +444,6 @@ class Database {
|
||||
return updated
|
||||
}
|
||||
|
||||
async createFeed(oldFeed) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.feed.fullCreateFromOld(oldFeed)
|
||||
}
|
||||
|
||||
updateFeed(oldFeed) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.feed.fullUpdateFromOld(oldFeed)
|
||||
}
|
||||
|
||||
async removeFeed(feedId) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.feed.removeById(feedId)
|
||||
}
|
||||
|
||||
async createBulkBookAuthors(bookAuthors) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.bookAuthor.bulkCreate(bookAuthors)
|
||||
|
||||
@@ -71,7 +71,6 @@ class Server {
|
||||
this.playbackSessionManager = new PlaybackSessionManager()
|
||||
this.podcastManager = new PodcastManager()
|
||||
this.audioMetadataManager = new AudioMetadataMangaer()
|
||||
this.rssFeedManager = new RssFeedManager()
|
||||
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
|
||||
this.apiCacheManager = new ApiCacheManager()
|
||||
this.binaryManager = new BinaryManager()
|
||||
@@ -84,7 +83,6 @@ class Server {
|
||||
Logger.logManager = new LogManager()
|
||||
|
||||
this.server = null
|
||||
this.io = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +136,7 @@ class Server {
|
||||
|
||||
await ShareManager.init()
|
||||
await this.backupManager.init()
|
||||
await this.rssFeedManager.init()
|
||||
await RssFeedManager.init()
|
||||
|
||||
const libraries = await Database.libraryModel.getAllWithFolders()
|
||||
await this.cronManager.init(libraries)
|
||||
@@ -194,18 +192,23 @@ class Server {
|
||||
|
||||
const app = express()
|
||||
|
||||
/**
|
||||
* @temporary
|
||||
* This is necessary for the ebook & cover API endpoint in the mobile apps
|
||||
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
|
||||
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
|
||||
* The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors
|
||||
* @see https://ionicframework.com/docs/troubleshooting/cors
|
||||
*
|
||||
* Running in development allows cors to allow testing the mobile apps in the browser
|
||||
* or env variable ALLOW_CORS = '1'
|
||||
*/
|
||||
app.use((req, res, next) => {
|
||||
if (!global.ServerSettings.allowIframe) {
|
||||
// Prevent clickjacking by disallowing iframes
|
||||
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
|
||||
}
|
||||
|
||||
/**
|
||||
* @temporary
|
||||
* This is necessary for the ebook & cover API endpoint in the mobile apps
|
||||
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
|
||||
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
|
||||
* The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors
|
||||
* @see https://ionicframework.com/docs/troubleshooting/cors
|
||||
*
|
||||
* Running in development allows cors to allow testing the mobile apps in the browser
|
||||
* or env variable ALLOW_CORS = '1'
|
||||
*/
|
||||
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
|
||||
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
|
||||
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
|
||||
@@ -246,14 +249,17 @@ class Server {
|
||||
|
||||
const router = express.Router()
|
||||
// if RouterBasePath is set, modify all requests to include the base path
|
||||
if (global.RouterBasePath) {
|
||||
app.use((req, res, next) => {
|
||||
if (!req.url.startsWith(global.RouterBasePath)) {
|
||||
req.url = `${global.RouterBasePath}${req.url}`
|
||||
}
|
||||
next()
|
||||
})
|
||||
}
|
||||
app.use((req, res, next) => {
|
||||
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
||||
const host = req.get('host')
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
|
||||
req.originalHostPrefix = `${protocol}://${host}${prefix}`
|
||||
if (!urlStartsWithRouterBasePath) {
|
||||
req.url = `${global.RouterBasePath}${req.url}`
|
||||
}
|
||||
next()
|
||||
})
|
||||
app.use(global.RouterBasePath, router)
|
||||
app.disable('x-powered-by')
|
||||
|
||||
@@ -284,14 +290,14 @@ class Server {
|
||||
// RSS Feed temp route
|
||||
router.get('/feed/:slug', (req, res) => {
|
||||
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
|
||||
this.rssFeedManager.getFeed(req, res)
|
||||
RssFeedManager.getFeed(req, res)
|
||||
})
|
||||
router.get('/feed/:slug/cover*', (req, res) => {
|
||||
this.rssFeedManager.getFeedCover(req, res)
|
||||
RssFeedManager.getFeedCover(req, res)
|
||||
})
|
||||
router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
|
||||
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
|
||||
this.rssFeedManager.getFeedItem(req, res)
|
||||
RssFeedManager.getFeedItem(req, res)
|
||||
})
|
||||
|
||||
// Auth routes
|
||||
@@ -438,18 +444,11 @@ class Server {
|
||||
async stop() {
|
||||
Logger.info('=== Stopping Server ===')
|
||||
Watcher.close()
|
||||
Logger.info('Watcher Closed')
|
||||
|
||||
return new Promise((resolve) => {
|
||||
SocketAuthority.close((err) => {
|
||||
if (err) {
|
||||
Logger.error('Failed to close server', err)
|
||||
} else {
|
||||
Logger.info('Server successfully closed')
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
Logger.info('[Server] Watcher Closed')
|
||||
await SocketAuthority.close()
|
||||
Logger.info('[Server] Closing HTTP Server')
|
||||
await new Promise((resolve) => this.server.close(resolve))
|
||||
Logger.info('[Server] HTTP Server Closed')
|
||||
}
|
||||
}
|
||||
module.exports = Server
|
||||
|
||||
@@ -14,7 +14,7 @@ const Auth = require('./Auth')
|
||||
class SocketAuthority {
|
||||
constructor() {
|
||||
this.Server = null
|
||||
this.io = null
|
||||
this.socketIoServers = []
|
||||
|
||||
/** @type {Object.<string, SocketClient>} */
|
||||
this.clients = {}
|
||||
@@ -89,82 +89,104 @@ class SocketAuthority {
|
||||
*
|
||||
* @param {Function} callback
|
||||
*/
|
||||
close(callback) {
|
||||
Logger.info('[SocketAuthority] Shutting down')
|
||||
// This will close all open socket connections, and also close the underlying http server
|
||||
if (this.io) this.io.close(callback)
|
||||
else callback()
|
||||
async close() {
|
||||
Logger.info('[SocketAuthority] closing...')
|
||||
const closePromises = this.socketIoServers.map((io) => {
|
||||
return new Promise((resolve) => {
|
||||
Logger.info(`[SocketAuthority] Closing Socket.IO server: ${io.path}`)
|
||||
io.close(() => {
|
||||
Logger.info(`[SocketAuthority] Socket.IO server closed: ${io.path}`)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
await Promise.all(closePromises)
|
||||
Logger.info('[SocketAuthority] closed')
|
||||
this.socketIoServers = []
|
||||
}
|
||||
|
||||
initialize(Server) {
|
||||
this.Server = Server
|
||||
|
||||
this.io = new SocketIO.Server(this.Server.server, {
|
||||
const socketIoOptions = {
|
||||
cors: {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST']
|
||||
},
|
||||
path: `${global.RouterBasePath}/socket.io`
|
||||
})
|
||||
|
||||
this.io.on('connection', (socket) => {
|
||||
this.clients[socket.id] = {
|
||||
id: socket.id,
|
||||
socket,
|
||||
connected_at: Date.now()
|
||||
}
|
||||
socket.sheepClient = this.clients[socket.id]
|
||||
}
|
||||
|
||||
Logger.info('[SocketAuthority] Socket Connected', socket.id)
|
||||
const ioServer = new SocketIO.Server(Server.server, socketIoOptions)
|
||||
ioServer.path = '/socket.io'
|
||||
this.socketIoServers.push(ioServer)
|
||||
|
||||
// Required for associating a User with a socket
|
||||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||
if (global.RouterBasePath) {
|
||||
// open a separate socket.io server for the router base path, keeping the original server open for legacy clients
|
||||
const ioBasePath = `${global.RouterBasePath}/socket.io`
|
||||
const ioBasePathServer = new SocketIO.Server(Server.server, { ...socketIoOptions, path: ioBasePath })
|
||||
ioBasePathServer.path = ioBasePath
|
||||
this.socketIoServers.push(ioBasePathServer)
|
||||
}
|
||||
|
||||
// Scanning
|
||||
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
|
||||
|
||||
// Logs
|
||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
||||
|
||||
// Sent automatically from socket.io clients
|
||||
socket.on('disconnect', (reason) => {
|
||||
Logger.removeSocketListener(socket.id)
|
||||
|
||||
const _client = this.clients[socket.id]
|
||||
if (!_client) {
|
||||
Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
|
||||
} else if (!_client.user) {
|
||||
Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
|
||||
delete this.clients[socket.id]
|
||||
} else {
|
||||
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
|
||||
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
|
||||
|
||||
const disconnectTime = Date.now() - _client.connected_at
|
||||
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
|
||||
delete this.clients[socket.id]
|
||||
this.socketIoServers.forEach((io) => {
|
||||
io.on('connection', (socket) => {
|
||||
this.clients[socket.id] = {
|
||||
id: socket.id,
|
||||
socket,
|
||||
connected_at: Date.now()
|
||||
}
|
||||
})
|
||||
socket.sheepClient = this.clients[socket.id]
|
||||
|
||||
//
|
||||
// Events for testing
|
||||
//
|
||||
socket.on('message_all_users', (payload) => {
|
||||
// admin user can send a message to all authenticated users
|
||||
// displays on the web app as a toast
|
||||
const client = this.clients[socket.id] || {}
|
||||
if (client.user?.isAdminOrUp) {
|
||||
this.emitter('admin_message', payload.message || '')
|
||||
} else {
|
||||
Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
|
||||
}
|
||||
})
|
||||
socket.on('ping', () => {
|
||||
const client = this.clients[socket.id] || {}
|
||||
const user = client.user || {}
|
||||
Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)
|
||||
socket.emit('pong')
|
||||
Logger.info(`[SocketAuthority] Socket Connected to ${io.path}`, socket.id)
|
||||
|
||||
// Required for associating a User with a socket
|
||||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||
|
||||
// Scanning
|
||||
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
|
||||
|
||||
// Logs
|
||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
||||
|
||||
// Sent automatically from socket.io clients
|
||||
socket.on('disconnect', (reason) => {
|
||||
Logger.removeSocketListener(socket.id)
|
||||
|
||||
const _client = this.clients[socket.id]
|
||||
if (!_client) {
|
||||
Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
|
||||
} else if (!_client.user) {
|
||||
Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
|
||||
delete this.clients[socket.id]
|
||||
} else {
|
||||
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
|
||||
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
|
||||
|
||||
const disconnectTime = Date.now() - _client.connected_at
|
||||
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
|
||||
delete this.clients[socket.id]
|
||||
}
|
||||
})
|
||||
|
||||
//
|
||||
// Events for testing
|
||||
//
|
||||
socket.on('message_all_users', (payload) => {
|
||||
// admin user can send a message to all authenticated users
|
||||
// displays on the web app as a toast
|
||||
const client = this.clients[socket.id] || {}
|
||||
if (client.user?.isAdminOrUp) {
|
||||
this.emitter('admin_message', payload.message || '')
|
||||
} else {
|
||||
Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
|
||||
}
|
||||
})
|
||||
socket.on('ping', () => {
|
||||
const client = this.clients[socket.id] || {}
|
||||
const user = client.user || {}
|
||||
Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)
|
||||
socket.emit('pong')
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const Collection = require('../objects/Collection')
|
||||
|
||||
/**
|
||||
@@ -115,6 +116,7 @@ class CollectionController {
|
||||
}
|
||||
|
||||
// If books array is passed in then update order in collection
|
||||
let collectionBooksUpdated = false
|
||||
if (req.body.books?.length) {
|
||||
const collectionBooks = await req.collection.getCollectionBooks({
|
||||
include: {
|
||||
@@ -133,9 +135,15 @@ class CollectionController {
|
||||
await collectionBooks[i].update({
|
||||
order: i + 1
|
||||
})
|
||||
wasUpdated = true
|
||||
collectionBooksUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (collectionBooksUpdated) {
|
||||
req.collection.changed('updatedAt', true)
|
||||
await req.collection.save()
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
@@ -148,6 +156,8 @@ class CollectionController {
|
||||
/**
|
||||
* DELETE: /api/collections/:id
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
@@ -155,7 +165,7 @@ class CollectionController {
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
|
||||
// Close rss feed - remove from db and emit socket event
|
||||
await this.rssFeedManager.closeFeedForEntityId(req.collection.id)
|
||||
await RssFeedManager.closeFeedForEntityId(req.collection.id)
|
||||
|
||||
await req.collection.destroy()
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ const LibraryScanner = require('../scanner/LibraryScanner')
|
||||
const Scanner = require('../scanner/Scanner')
|
||||
const Database = require('../Database')
|
||||
const Watcher = require('../Watcher')
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
|
||||
const libraryFilters = require('../utils/queries/libraryFilters')
|
||||
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
||||
const authorFilters = require('../utils/queries/authorFilters')
|
||||
@@ -400,19 +402,48 @@ class LibraryController {
|
||||
model: Database.podcastEpisodeModel,
|
||||
attributes: ['id']
|
||||
}
|
||||
},
|
||||
{
|
||||
model: Database.bookModel,
|
||||
attributes: ['id'],
|
||||
include: [
|
||||
{
|
||||
model: Database.bookAuthorModel,
|
||||
attributes: ['authorId']
|
||||
},
|
||||
{
|
||||
model: Database.bookSeriesModel,
|
||||
attributes: ['seriesId']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`)
|
||||
const seriesIds = []
|
||||
const authorIds = []
|
||||
for (const libraryItem of libraryItemsInFolder) {
|
||||
let mediaItemIds = []
|
||||
if (req.library.isPodcast) {
|
||||
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
|
||||
} else {
|
||||
mediaItemIds.push(libraryItem.mediaId)
|
||||
if (libraryItem.media.bookAuthors.length) {
|
||||
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
|
||||
}
|
||||
if (libraryItem.media.bookSeries.length) {
|
||||
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
|
||||
}
|
||||
}
|
||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
|
||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||
}
|
||||
|
||||
if (authorIds.length) {
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||
}
|
||||
if (seriesIds.length) {
|
||||
await this.checkRemoveEmptySeries(seriesIds)
|
||||
}
|
||||
|
||||
// Remove folder
|
||||
@@ -501,11 +532,24 @@ class LibraryController {
|
||||
mediaItemIds.push(libraryItem.mediaId)
|
||||
}
|
||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
|
||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||
}
|
||||
|
||||
// Set PlaybackSessions libraryId to null
|
||||
const [sessionsUpdated] = await Database.playbackSessionModel.update(
|
||||
{
|
||||
libraryId: null
|
||||
},
|
||||
{
|
||||
where: {
|
||||
libraryId: req.library.id
|
||||
}
|
||||
}
|
||||
)
|
||||
Logger.info(`[LibraryController] Updated ${sessionsUpdated} playback sessions to remove library id`)
|
||||
|
||||
const libraryJson = req.library.toOldJSON()
|
||||
await Database.removeLibrary(req.library.id)
|
||||
await req.library.destroy()
|
||||
|
||||
// Re-order libraries
|
||||
await Database.libraryModel.resetDisplayOrder()
|
||||
@@ -567,6 +611,8 @@ class LibraryController {
|
||||
* DELETE: /api/libraries/:id/issues
|
||||
* Remove all library items missing or invalid
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {LibraryControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
@@ -592,6 +638,20 @@ class LibraryController {
|
||||
model: Database.podcastEpisodeModel,
|
||||
attributes: ['id']
|
||||
}
|
||||
},
|
||||
{
|
||||
model: Database.bookModel,
|
||||
attributes: ['id'],
|
||||
include: [
|
||||
{
|
||||
model: Database.bookAuthorModel,
|
||||
attributes: ['authorId']
|
||||
},
|
||||
{
|
||||
model: Database.bookSeriesModel,
|
||||
attributes: ['seriesId']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -602,15 +662,30 @@ class LibraryController {
|
||||
}
|
||||
|
||||
Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
|
||||
const authorIds = []
|
||||
const seriesIds = []
|
||||
for (const libraryItem of libraryItemsWithIssues) {
|
||||
let mediaItemIds = []
|
||||
if (req.library.isPodcast) {
|
||||
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
|
||||
} else {
|
||||
mediaItemIds.push(libraryItem.mediaId)
|
||||
if (libraryItem.media.bookAuthors.length) {
|
||||
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
|
||||
}
|
||||
if (libraryItem.media.bookSeries.length) {
|
||||
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
|
||||
}
|
||||
}
|
||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
|
||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||
}
|
||||
|
||||
if (authorIds.length) {
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||
}
|
||||
if (seriesIds.length) {
|
||||
await this.checkRemoveEmptySeries(seriesIds)
|
||||
}
|
||||
|
||||
// Set numIssues to 0 for library filter data
|
||||
@@ -686,8 +761,8 @@ class LibraryController {
|
||||
}
|
||||
|
||||
if (include.includes('rssfeed')) {
|
||||
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
|
||||
const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||
seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
|
||||
}
|
||||
|
||||
res.json(seriesJson)
|
||||
|
||||
@@ -13,6 +13,8 @@ const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUti
|
||||
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
||||
const AudioFileScanner = require('../scanner/AudioFileScanner')
|
||||
const Scanner = require('../scanner/Scanner')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
const ShareManager = require('../managers/ShareManager')
|
||||
@@ -48,8 +50,8 @@ class LibraryItemController {
|
||||
}
|
||||
|
||||
if (includeEntities.includes('rssfeed')) {
|
||||
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id)
|
||||
item.rssFeed = feedData?.toJSONMinified() || null
|
||||
const feedData = await RssFeedManager.findFeedForEntityId(item.id)
|
||||
item.rssFeed = feedData?.toOldJSONMinified() || null
|
||||
}
|
||||
|
||||
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {
|
||||
@@ -96,6 +98,8 @@ class LibraryItemController {
|
||||
* Optional query params:
|
||||
* ?hard=1
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
@@ -103,14 +107,36 @@ class LibraryItemController {
|
||||
const hardDelete = req.query.hard == 1 // Delete from file system
|
||||
const libraryItemPath = req.libraryItem.path
|
||||
|
||||
const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id]
|
||||
await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds)
|
||||
const mediaItemIds = []
|
||||
const authorIds = []
|
||||
const seriesIds = []
|
||||
if (req.libraryItem.isPodcast) {
|
||||
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id))
|
||||
} else {
|
||||
mediaItemIds.push(req.libraryItem.media.id)
|
||||
if (req.libraryItem.media.metadata.authors?.length) {
|
||||
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id))
|
||||
}
|
||||
if (req.libraryItem.media.metadata.series?.length) {
|
||||
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id))
|
||||
}
|
||||
}
|
||||
|
||||
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
|
||||
if (hardDelete) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||
})
|
||||
}
|
||||
|
||||
if (authorIds.length) {
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||
}
|
||||
if (seriesIds.length) {
|
||||
await this.checkRemoveEmptySeries(seriesIds)
|
||||
}
|
||||
|
||||
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
@@ -212,15 +238,6 @@ class LibraryItemController {
|
||||
if (hasUpdates) {
|
||||
libraryItem.updatedAt = Date.now()
|
||||
|
||||
if (seriesRemoved.length) {
|
||||
// Check remove empty series
|
||||
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
||||
await this.checkRemoveEmptySeries(
|
||||
libraryItem.media.id,
|
||||
seriesRemoved.map((se) => se.id)
|
||||
)
|
||||
}
|
||||
|
||||
if (isPodcastAutoDownloadUpdated) {
|
||||
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
||||
}
|
||||
@@ -232,10 +249,12 @@ class LibraryItemController {
|
||||
if (authorsRemoved.length) {
|
||||
// Check remove empty authors
|
||||
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
|
||||
await this.checkRemoveAuthorsWithNoBooks(
|
||||
libraryItem.libraryId,
|
||||
authorsRemoved.map((au) => au.id)
|
||||
)
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
|
||||
}
|
||||
if (seriesRemoved.length) {
|
||||
// Check remove empty series
|
||||
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
|
||||
await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id))
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
@@ -450,6 +469,8 @@ class LibraryItemController {
|
||||
* Optional query params:
|
||||
* ?hard=1
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
@@ -477,14 +498,33 @@ class LibraryItemController {
|
||||
for (const libraryItem of itemsToDelete) {
|
||||
const libraryItemPath = libraryItem.path
|
||||
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
|
||||
const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id]
|
||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
||||
const mediaItemIds = []
|
||||
const seriesIds = []
|
||||
const authorIds = []
|
||||
if (libraryItem.isPodcast) {
|
||||
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id))
|
||||
} else {
|
||||
mediaItemIds.push(libraryItem.media.id)
|
||||
if (libraryItem.media.metadata.series?.length) {
|
||||
seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id))
|
||||
}
|
||||
if (libraryItem.media.metadata.authors?.length) {
|
||||
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id))
|
||||
}
|
||||
}
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||
if (hardDelete) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||
})
|
||||
}
|
||||
if (seriesIds.length) {
|
||||
await this.checkRemoveEmptySeries(seriesIds)
|
||||
}
|
||||
if (authorIds.length) {
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||
}
|
||||
}
|
||||
|
||||
await Database.resetLibraryIssuesFilterData(libraryId)
|
||||
@@ -494,48 +534,74 @@ class LibraryItemController {
|
||||
/**
|
||||
* POST: /api/items/batch/update
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async batchUpdate(req, res) {
|
||||
const updatePayloads = req.body
|
||||
if (!updatePayloads?.length) {
|
||||
return res.sendStatus(500)
|
||||
if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
|
||||
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
// Ensure that each update payload has a unique library item id
|
||||
const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))]
|
||||
if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) {
|
||||
Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
// Get all library items to update
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
id: libraryItemIds
|
||||
})
|
||||
if (updatePayloads.length !== libraryItems.length) {
|
||||
Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
let itemsUpdated = 0
|
||||
|
||||
const seriesIdsRemoved = []
|
||||
const authorIdsRemoved = []
|
||||
|
||||
for (const updatePayload of updatePayloads) {
|
||||
const mediaPayload = updatePayload.mediaPayload
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
|
||||
if (!libraryItem) return null
|
||||
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
|
||||
|
||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
||||
|
||||
let seriesRemoved = []
|
||||
if (libraryItem.isBook && mediaPayload.metadata?.series) {
|
||||
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id)
|
||||
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||
if (libraryItem.isBook) {
|
||||
if (Array.isArray(mediaPayload.metadata?.series)) {
|
||||
const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
|
||||
const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
|
||||
}
|
||||
if (Array.isArray(mediaPayload.metadata?.authors)) {
|
||||
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
|
||||
const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
|
||||
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id))
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItem.media.update(mediaPayload)) {
|
||||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||
|
||||
if (seriesRemoved.length) {
|
||||
// Check remove empty series
|
||||
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
||||
await this.checkRemoveEmptySeries(
|
||||
libraryItem.media.id,
|
||||
seriesRemoved.map((se) => se.id)
|
||||
)
|
||||
}
|
||||
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
itemsUpdated++
|
||||
}
|
||||
}
|
||||
|
||||
if (seriesIdsRemoved.length) {
|
||||
await this.checkRemoveEmptySeries(seriesIdsRemoved)
|
||||
}
|
||||
if (authorIdsRemoved.length) {
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
updates: itemsUpdated
|
||||
|
||||
@@ -126,6 +126,10 @@ class MiscController {
|
||||
if (!isObject(settingsUpdate)) {
|
||||
return res.status(400).send('Invalid settings update object')
|
||||
}
|
||||
if (settingsUpdate.allowIframe == false && process.env.ALLOW_IFRAME === '1') {
|
||||
Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
|
||||
return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
|
||||
}
|
||||
|
||||
const madeUpdates = Database.serverSettings.update(settingsUpdate)
|
||||
if (madeUpdates) {
|
||||
@@ -137,7 +141,6 @@ class MiscController {
|
||||
}
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
serverSettings: Database.serverSettings.toJSONForBrowser()
|
||||
})
|
||||
}
|
||||
@@ -679,9 +682,9 @@ class MiscController {
|
||||
continue
|
||||
}
|
||||
let updatedValue = settingsUpdate[key]
|
||||
if (updatedValue === '') updatedValue = null
|
||||
if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
|
||||
let currentValue = currentAuthenticationSettings[key]
|
||||
if (currentValue === '') currentValue = null
|
||||
if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null
|
||||
|
||||
if (updatedValue !== currentValue) {
|
||||
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const { Request, Response, NextFunction } = require('express')
|
||||
const Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
@@ -22,10 +23,10 @@ class RSSFeedController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getAll(req, res) {
|
||||
const feeds = await this.rssFeedManager.getFeeds()
|
||||
const feeds = await RssFeedManager.getFeeds()
|
||||
res.json({
|
||||
feeds: feeds.map((f) => f.toJSON()),
|
||||
minified: feeds.map((f) => f.toJSONMinified())
|
||||
feeds: feeds.map((f) => f.toOldJSON()),
|
||||
minified: feeds.map((f) => f.toOldJSONMinified())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,38 +39,43 @@ class RSSFeedController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async openRSSFeedForItem(req, res) {
|
||||
const options = req.body || {}
|
||||
const reqBody = req.body || {}
|
||||
|
||||
const item = await Database.libraryItemModel.getOldById(req.params.itemId)
|
||||
if (!item) return res.sendStatus(404)
|
||||
const itemExpanded = await Database.libraryItemModel.getExpandedById(req.params.itemId)
|
||||
if (!itemExpanded) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${item.media.metadata.title}" that they don\'t have access to`)
|
||||
if (!req.user.checkCanAccessLibraryItem(itemExpanded)) {
|
||||
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${itemExpanded.media.title}" that they don\'t have access to`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// Check request body options exist
|
||||
if (!options.serverAddress || !options.slug) {
|
||||
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
|
||||
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Check item has audio tracks
|
||||
if (!item.media.numTracks) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${item.media.metadata.title}" because it has no audio tracks`)
|
||||
if (!itemExpanded.hasAudioTracks()) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${itemExpanded.media.title}" because it has no audio tracks`)
|
||||
return res.status(400).send('Item has no audio tracks')
|
||||
}
|
||||
|
||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
|
||||
return res.status(400).send('Slug already in use')
|
||||
}
|
||||
|
||||
const feed = await this.rssFeedManager.openFeedForItem(req.user.id, item, req.body)
|
||||
const feed = await RssFeedManager.openFeedForItem(req.user.id, itemExpanded, reqBody)
|
||||
if (!feed) {
|
||||
Logger.error(`[RSSFeedController] Failed to open RSS feed for item "${itemExpanded.media.title}"`)
|
||||
return res.status(500).send('Failed to open RSS feed')
|
||||
}
|
||||
|
||||
res.json({
|
||||
feed: feed.toJSONMinified()
|
||||
feed: feed.toOldJSONMinified()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,35 +88,37 @@ class RSSFeedController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async openRSSFeedForCollection(req, res) {
|
||||
const options = req.body || {}
|
||||
|
||||
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
|
||||
if (!collection) return res.sendStatus(404)
|
||||
const reqBody = req.body || {}
|
||||
|
||||
// Check request body options exist
|
||||
if (!options.serverAddress || !options.slug) {
|
||||
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
|
||||
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
|
||||
return res.status(400).send('Slug already in use')
|
||||
}
|
||||
|
||||
const collectionExpanded = await collection.getOldJsonExpanded()
|
||||
const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length)
|
||||
const collection = await Database.collectionModel.getExpandedById(req.params.collectionId)
|
||||
if (!collection) return res.sendStatus(404)
|
||||
|
||||
// Check collection has audio tracks
|
||||
if (!collectionItemsWithTracks.length) {
|
||||
if (!collection.books.some((book) => book.includedAudioFiles.length)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`)
|
||||
return res.status(400).send('Collection has no audio tracks')
|
||||
}
|
||||
|
||||
const feed = await this.rssFeedManager.openFeedForCollection(req.user.id, collectionExpanded, req.body)
|
||||
const feed = await RssFeedManager.openFeedForCollection(req.user.id, collection, reqBody)
|
||||
if (!feed) {
|
||||
Logger.error(`[RSSFeedController] Failed to open RSS feed for collection "${collection.name}"`)
|
||||
return res.status(500).send('Failed to open RSS feed')
|
||||
}
|
||||
|
||||
res.json({
|
||||
feed: feed.toJSONMinified()
|
||||
feed: feed.toOldJSONMinified()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,37 +131,37 @@ class RSSFeedController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async openRSSFeedForSeries(req, res) {
|
||||
const options = req.body || {}
|
||||
|
||||
const series = await Database.seriesModel.findByPk(req.params.seriesId)
|
||||
if (!series) return res.sendStatus(404)
|
||||
const reqBody = req.body || {}
|
||||
|
||||
// Check request body options exist
|
||||
if (!options.serverAddress || !options.slug) {
|
||||
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
|
||||
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
|
||||
return res.status(400).send('Slug already in use')
|
||||
}
|
||||
|
||||
const seriesJson = series.toOldJSON()
|
||||
|
||||
// Get books in series that have audio tracks
|
||||
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
|
||||
const series = await Database.seriesModel.getExpandedById(req.params.seriesId)
|
||||
if (!series) return res.sendStatus(404)
|
||||
|
||||
// Check series has audio tracks
|
||||
if (!seriesJson.books.length) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${seriesJson.name}" because it has no audio tracks`)
|
||||
if (!series.books.some((book) => book.includedAudioFiles.length)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${series.name}" because it has no audio tracks`)
|
||||
return res.status(400).send('Series has no audio tracks')
|
||||
}
|
||||
|
||||
const feed = await this.rssFeedManager.openFeedForSeries(req.user.id, seriesJson, req.body)
|
||||
const feed = await RssFeedManager.openFeedForSeries(req.user.id, series, req.body)
|
||||
if (!feed) {
|
||||
Logger.error(`[RSSFeedController] Failed to open RSS feed for series "${series.name}"`)
|
||||
return res.status(500).send('Failed to open RSS feed')
|
||||
}
|
||||
|
||||
res.json({
|
||||
feed: feed.toJSONMinified()
|
||||
feed: feed.toOldJSONMinified()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,8 +173,16 @@ class RSSFeedController {
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
closeRSSFeed(req, res) {
|
||||
this.rssFeedManager.closeRssFeed(req, res)
|
||||
async closeRSSFeed(req, res) {
|
||||
const feed = await Database.feedModel.findByPk(req.params.id)
|
||||
if (!feed) {
|
||||
Logger.error(`[RSSFeedController] Cannot close RSS feed because feed "${req.params.id}" does not exist`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
await RssFeedManager.handleCloseFeed(feed)
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,9 @@ const { Request, Response, NextFunction } = require('express')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
/**
|
||||
@@ -51,8 +54,8 @@ class SeriesController {
|
||||
}
|
||||
|
||||
if (include.includes('rssfeed')) {
|
||||
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
|
||||
const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||
seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
|
||||
}
|
||||
|
||||
res.json(seriesJson)
|
||||
|
||||
@@ -368,6 +368,19 @@ class UserController {
|
||||
await playlist.destroy()
|
||||
}
|
||||
|
||||
// Set PlaybackSessions userId to null
|
||||
const [sessionsUpdated] = await Database.playbackSessionModel.update(
|
||||
{
|
||||
userId: null
|
||||
},
|
||||
{
|
||||
where: {
|
||||
userId: user.id
|
||||
}
|
||||
}
|
||||
)
|
||||
Logger.info(`[UserController] Updated ${sessionsUpdated} playback sessions to remove user id`)
|
||||
|
||||
const userJson = user.toOldJSONForBrowser()
|
||||
await user.destroy()
|
||||
SocketAuthority.adminEmitter('user_removed', userJson)
|
||||
|
||||
@@ -86,6 +86,7 @@ class CacheManager {
|
||||
}
|
||||
|
||||
async purgeEntityCache(entityId, cachePath) {
|
||||
if (!entityId || !cachePath) return []
|
||||
return Promise.all(
|
||||
(await fs.readdir(cachePath)).reduce((promises, file) => {
|
||||
if (file.startsWith(entityId)) {
|
||||
|
||||
@@ -12,7 +12,7 @@ const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
|
||||
class CoverManager {
|
||||
constructor() { }
|
||||
constructor() {}
|
||||
|
||||
getCoverDirectory(libraryItem) {
|
||||
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) {
|
||||
@@ -93,10 +93,13 @@ class CoverManager {
|
||||
const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`)
|
||||
|
||||
// Move cover from temp upload dir to destination
|
||||
const success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
|
||||
Logger.error('[CoverManager] Failed to move cover file', path, error)
|
||||
return false
|
||||
})
|
||||
const success = await coverFile
|
||||
.mv(coverFullPath)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
Logger.error('[CoverManager] Failed to move cover file', coverFullPath, error)
|
||||
return false
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
@@ -124,11 +127,13 @@ class CoverManager {
|
||||
var temppath = Path.posix.join(coverDirPath, 'cover')
|
||||
|
||||
let errorMsg = ''
|
||||
let success = await downloadImageFile(url, temppath).then(() => true).catch((err) => {
|
||||
errorMsg = err.message || 'Unknown error'
|
||||
Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
|
||||
return false
|
||||
})
|
||||
let success = await downloadImageFile(url, temppath)
|
||||
.then(() => true)
|
||||
.catch((err) => {
|
||||
errorMsg = err.message || 'Unknown error'
|
||||
Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
|
||||
return false
|
||||
})
|
||||
if (!success) {
|
||||
return {
|
||||
error: 'Failed to download image from url: ' + errorMsg
|
||||
@@ -180,7 +185,7 @@ class CoverManager {
|
||||
}
|
||||
|
||||
// Cover path does not exist
|
||||
if (!await fs.pathExists(coverPath)) {
|
||||
if (!(await fs.pathExists(coverPath))) {
|
||||
Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
|
||||
return {
|
||||
error: 'Cover path does not exist'
|
||||
@@ -188,7 +193,7 @@ class CoverManager {
|
||||
}
|
||||
|
||||
// Cover path is not a file
|
||||
if (!await checkPathIsFile(coverPath)) {
|
||||
if (!(await checkPathIsFile(coverPath))) {
|
||||
Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`)
|
||||
return {
|
||||
error: 'Cover path is not a file'
|
||||
@@ -211,10 +216,13 @@ class CoverManager {
|
||||
var newCoverPath = Path.posix.join(coverDirPath, coverFilename)
|
||||
Logger.debug(`[CoverManager] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`)
|
||||
|
||||
var copySuccess = await fs.copy(coverPath, newCoverPath, { overwrite: true }).then(() => true).catch((error) => {
|
||||
Logger.error(`[CoverManager] validate cover path failed to copy cover`, error)
|
||||
return false
|
||||
})
|
||||
var copySuccess = await fs
|
||||
.copy(coverPath, newCoverPath, { overwrite: true })
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
Logger.error(`[CoverManager] validate cover path failed to copy cover`, error)
|
||||
return false
|
||||
})
|
||||
if (!copySuccess) {
|
||||
return {
|
||||
error: 'Failed to copy cover to dir'
|
||||
@@ -236,14 +244,14 @@ class CoverManager {
|
||||
|
||||
/**
|
||||
* Extract cover art from audio file and save for library item
|
||||
*
|
||||
* @param {import('../models/Book').AudioFileObject[]} audioFiles
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null for isFile library items
|
||||
*
|
||||
* @param {import('../models/Book').AudioFileObject[]} audioFiles
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null for isFile library items
|
||||
* @returns {Promise<string>} returns cover path
|
||||
*/
|
||||
async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) {
|
||||
let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt)
|
||||
let audioFileWithCover = audioFiles.find((af) => af.embeddedCoverArt)
|
||||
if (!audioFileWithCover) return null
|
||||
|
||||
let coverDirPath = null
|
||||
@@ -273,10 +281,10 @@ class CoverManager {
|
||||
|
||||
/**
|
||||
* Extract cover art from ebook and save for library item
|
||||
*
|
||||
* @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null for isFile library items
|
||||
*
|
||||
* @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null for isFile library items
|
||||
* @returns {Promise<string>} returns cover path
|
||||
*/
|
||||
async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) {
|
||||
@@ -310,9 +318,9 @@ class CoverManager {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} libraryItemId
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast
|
||||
* @returns {Promise<{error:string}|{cover:string}>}
|
||||
*/
|
||||
@@ -328,10 +336,12 @@ class CoverManager {
|
||||
await fs.ensureDir(coverDirPath)
|
||||
|
||||
const temppath = Path.posix.join(coverDirPath, 'cover')
|
||||
const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => {
|
||||
Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
|
||||
return false
|
||||
})
|
||||
const success = await downloadImageFile(url, temppath)
|
||||
.then(() => true)
|
||||
.catch((err) => {
|
||||
Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
|
||||
return false
|
||||
})
|
||||
if (!success) {
|
||||
return {
|
||||
error: 'Failed to download image from url'
|
||||
@@ -361,4 +371,4 @@ class CoverManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = new CoverManager()
|
||||
module.exports = new CoverManager()
|
||||
|
||||
@@ -25,7 +25,9 @@ const LibraryItem = require('../objects/LibraryItem')
|
||||
|
||||
class PodcastManager {
|
||||
constructor() {
|
||||
/** @type {PodcastEpisodeDownload[]} */
|
||||
this.downloadQueue = []
|
||||
/** @type {PodcastEpisodeDownload} */
|
||||
this.currentDownload = null
|
||||
|
||||
this.failedCheckMap = {}
|
||||
@@ -63,6 +65,11 @@ class PodcastManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PodcastEpisodeDownload} podcastEpisodeDownload
|
||||
* @returns
|
||||
*/
|
||||
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
|
||||
if (this.currentDownload) {
|
||||
this.downloadQueue.push(podcastEpisodeDownload)
|
||||
@@ -106,7 +113,7 @@ class PodcastManager {
|
||||
}
|
||||
|
||||
let success = false
|
||||
if (this.currentDownload.urlFileExtension === 'mp3') {
|
||||
if (this.currentDownload.isMp3) {
|
||||
// Download episode and tag it
|
||||
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
|
||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { Request, Response } = require('express')
|
||||
const Path = require('path')
|
||||
|
||||
const Logger = require('../Logger')
|
||||
@@ -5,170 +6,190 @@ const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Feed = require('../objects/Feed')
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
class RssFeedManager {
|
||||
constructor() {}
|
||||
|
||||
async validateFeedEntity(feedObj) {
|
||||
if (feedObj.entityType === 'collection') {
|
||||
const collection = await Database.collectionModel.getOldById(feedObj.entityId)
|
||||
if (!collection) {
|
||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
|
||||
return false
|
||||
}
|
||||
} else if (feedObj.entityType === 'libraryItem') {
|
||||
const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
|
||||
if (!libraryItemExists) {
|
||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
|
||||
return false
|
||||
}
|
||||
} else if (feedObj.entityType === 'series') {
|
||||
const series = await Database.seriesModel.findByPk(feedObj.entityId)
|
||||
if (!series) {
|
||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Invalid entityType "${feedObj.entityType}"`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all feeds and remove invalid
|
||||
* Remove invalid feeds (invalid if the entity does not exist)
|
||||
*/
|
||||
async init() {
|
||||
const feeds = await Database.feedModel.getOldFeeds()
|
||||
const feeds = await Database.feedModel.findAll({
|
||||
attributes: ['id', 'entityId', 'entityType', 'title'],
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel,
|
||||
attributes: ['id']
|
||||
},
|
||||
{
|
||||
model: Database.collectionModel,
|
||||
attributes: ['id']
|
||||
},
|
||||
{
|
||||
model: Database.seriesModel,
|
||||
attributes: ['id']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const feedIdsToRemove = []
|
||||
for (const feed of feeds) {
|
||||
// Remove invalid feeds
|
||||
if (!(await this.validateFeedEntity(feed))) {
|
||||
await Database.removeFeed(feed.id)
|
||||
if (!feed.entity) {
|
||||
Logger.error(`[RssFeedManager] Removing feed "${feed.title}". Entity not found`)
|
||||
feedIdsToRemove.push(feed.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (feedIdsToRemove.length) {
|
||||
Logger.info(`[RssFeedManager] Removing ${feedIdsToRemove.length} invalid feeds`)
|
||||
await Database.feedModel.destroy({
|
||||
where: {
|
||||
id: feedIdsToRemove
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find open feed for an entity (e.g. collection id, playlist id, library item id)
|
||||
* @param {string} entityId
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
* @returns {Promise<import('../models/Feed')>}
|
||||
*/
|
||||
findFeedForEntityId(entityId) {
|
||||
return Database.feedModel.findOneOld({ entityId })
|
||||
return Database.feedModel.findOne({
|
||||
where: {
|
||||
entityId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Find open feed for a slug
|
||||
*
|
||||
* @param {string} slug
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
findFeedBySlug(slug) {
|
||||
return Database.feedModel.findOneOld({ slug })
|
||||
checkExistsBySlug(slug) {
|
||||
return Database.feedModel
|
||||
.count({
|
||||
where: {
|
||||
slug
|
||||
}
|
||||
})
|
||||
.then((count) => count > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find open feed for a slug
|
||||
* @param {string} slug
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
* Feed requires update if the entity (or child entities) has been updated since the feed was last updated
|
||||
*
|
||||
* @param {import('../models/Feed')} feed
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
findFeed(id) {
|
||||
return Database.feedModel.findByPkOld(id)
|
||||
async checkFeedRequiresUpdate(feed) {
|
||||
if (feed.entityType === 'libraryItem') {
|
||||
feed.entity = await feed.getEntity({
|
||||
attributes: ['id', 'updatedAt', 'mediaId', 'mediaType']
|
||||
})
|
||||
|
||||
let newEntityUpdatedAt = feed.entity.updatedAt
|
||||
|
||||
if (feed.entity.mediaType === 'podcast') {
|
||||
const mostRecentPodcastEpisode = await Database.podcastEpisodeModel.findOne({
|
||||
where: {
|
||||
podcastId: feed.entity.mediaId
|
||||
},
|
||||
attributes: ['id', 'updatedAt'],
|
||||
order: [['createdAt', 'DESC']]
|
||||
})
|
||||
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
|
||||
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
return newEntityUpdatedAt > feed.entityUpdatedAt
|
||||
} else if (feed.entityType === 'collection' || feed.entityType === 'series') {
|
||||
feed.entity = await feed.getEntity({
|
||||
attributes: ['id', 'updatedAt'],
|
||||
include: {
|
||||
model: Database.bookModel,
|
||||
attributes: ['id'],
|
||||
through: {
|
||||
attributes: []
|
||||
},
|
||||
include: {
|
||||
model: Database.libraryItemModel,
|
||||
attributes: ['id', 'updatedAt']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let newEntityUpdatedAt = feed.entity.updatedAt
|
||||
|
||||
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
|
||||
if (book.libraryItem.updatedAt > mostRecent) {
|
||||
return book.libraryItem.updatedAt
|
||||
}
|
||||
return mostRecent
|
||||
}, 0)
|
||||
|
||||
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
|
||||
newEntityUpdatedAt = mostRecentItemUpdatedAt
|
||||
}
|
||||
|
||||
return newEntityUpdatedAt > feed.entityUpdatedAt
|
||||
} else {
|
||||
throw new Error('Invalid feed entity type')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /feed/:slug
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getFeed(req, res) {
|
||||
const feed = await this.findFeedBySlug(req.params.slug)
|
||||
let feed = await Database.feedModel.findOne({
|
||||
where: {
|
||||
slug: req.params.slug
|
||||
}
|
||||
})
|
||||
if (!feed) {
|
||||
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||
res.sendStatus(404)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if feed needs to be updated
|
||||
if (feed.entityType === 'libraryItem') {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId)
|
||||
|
||||
let mostRecentlyUpdatedAt = libraryItem.updatedAt
|
||||
if (libraryItem.isPodcast) {
|
||||
libraryItem.media.episodes.forEach((episode) => {
|
||||
if (episode.updatedAt > mostRecentlyUpdatedAt) mostRecentlyUpdatedAt = episode.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) {
|
||||
Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`)
|
||||
|
||||
feed.updateFromItem(libraryItem)
|
||||
await Database.updateFeed(feed)
|
||||
}
|
||||
} else if (feed.entityType === 'collection') {
|
||||
const collection = await Database.collectionModel.findByPk(feed.entityId, {
|
||||
include: Database.collectionBookModel
|
||||
})
|
||||
if (collection) {
|
||||
const collectionExpanded = await collection.getOldJsonExpanded()
|
||||
|
||||
// Find most recently updated item in collection
|
||||
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
|
||||
// Check for most recently updated book
|
||||
collectionExpanded.books.forEach((libraryItem) => {
|
||||
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
|
||||
mostRecentlyUpdatedAt = libraryItem.updatedAt
|
||||
}
|
||||
})
|
||||
// Check for most recently added collection book
|
||||
collection.collectionBooks.forEach((collectionBook) => {
|
||||
if (collectionBook.createdAt.valueOf() > mostRecentlyUpdatedAt) {
|
||||
mostRecentlyUpdatedAt = collectionBook.createdAt.valueOf()
|
||||
}
|
||||
})
|
||||
const hasBooksRemoved = collection.collectionBooks.length < feed.episodes.length
|
||||
|
||||
if (!feed.entityUpdatedAt || hasBooksRemoved || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
|
||||
Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`)
|
||||
|
||||
feed.updateFromCollection(collectionExpanded)
|
||||
await Database.updateFeed(feed)
|
||||
}
|
||||
}
|
||||
} else if (feed.entityType === 'series') {
|
||||
const series = await Database.seriesModel.findByPk(feed.entityId)
|
||||
if (series) {
|
||||
const seriesJson = series.toOldJSON()
|
||||
|
||||
// Get books in series that have audio tracks
|
||||
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
|
||||
|
||||
// Find most recently updated item in series
|
||||
let mostRecentlyUpdatedAt = seriesJson.updatedAt
|
||||
let totalTracks = 0 // Used to detect series items removed
|
||||
seriesJson.books.forEach((libraryItem) => {
|
||||
totalTracks += libraryItem.media.tracks.length
|
||||
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
|
||||
mostRecentlyUpdatedAt = libraryItem.updatedAt
|
||||
}
|
||||
})
|
||||
if (totalTracks !== feed.episodes.length) {
|
||||
mostRecentlyUpdatedAt = Date.now()
|
||||
}
|
||||
|
||||
if (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
|
||||
Logger.debug(`[RssFeedManager] Updating RSS feed for series "${seriesJson.name}"`)
|
||||
|
||||
feed.updateFromSeries(seriesJson)
|
||||
await Database.updateFeed(feed)
|
||||
}
|
||||
}
|
||||
const feedRequiresUpdate = await this.checkFeedRequiresUpdate(feed)
|
||||
if (feedRequiresUpdate) {
|
||||
Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
|
||||
feed = await feed.updateFeedForEntity()
|
||||
} else {
|
||||
feed.feedEpisodes = await feed.getFeedEpisodes()
|
||||
}
|
||||
|
||||
const xml = feed.buildXml()
|
||||
const xml = feed.buildXml(req.originalHostPrefix)
|
||||
res.set('Content-Type', 'text/xml')
|
||||
res.send(xml)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /feed/:slug/item/:episodeId/*
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getFeedItem(req, res) {
|
||||
const feed = await this.findFeedBySlug(req.params.slug)
|
||||
const feed = await Database.feedModel.findOne({
|
||||
where: {
|
||||
slug: req.params.slug
|
||||
},
|
||||
attributes: ['id', 'slug'],
|
||||
include: {
|
||||
model: Database.feedEpisodeModel,
|
||||
attributes: ['id', 'filePath']
|
||||
}
|
||||
})
|
||||
|
||||
if (!feed) {
|
||||
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||
res.sendStatus(404)
|
||||
@@ -183,8 +204,19 @@ class RssFeedManager {
|
||||
res.sendFile(episodePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /feed/:slug/cover*
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getFeedCover(req, res) {
|
||||
const feed = await this.findFeedBySlug(req.params.slug)
|
||||
const feed = await Database.feedModel.findOne({
|
||||
where: {
|
||||
slug: req.params.slug
|
||||
},
|
||||
attributes: ['coverPath']
|
||||
})
|
||||
if (!feed) {
|
||||
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||
res.sendStatus(404)
|
||||
@@ -204,100 +236,143 @@ class RssFeedManager {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {*} libraryItem
|
||||
* @param {*} options
|
||||
* @returns
|
||||
* @returns {import('../models/Feed').FeedOptions}
|
||||
*/
|
||||
getFeedOptionsFromReqOptions(options) {
|
||||
const metadataDetails = options.metadataDetails || {}
|
||||
|
||||
if (metadataDetails.preventIndexing !== false) {
|
||||
metadataDetails.preventIndexing = true
|
||||
}
|
||||
|
||||
return {
|
||||
preventIndexing: metadataDetails.preventIndexing,
|
||||
ownerName: metadataDetails.ownerName && typeof metadataDetails.ownerName === 'string' ? metadataDetails.ownerName : null,
|
||||
ownerEmail: metadataDetails.ownerEmail && typeof metadataDetails.ownerEmail === 'string' ? metadataDetails.ownerEmail : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {*} options
|
||||
* @returns {Promise<import('../models/Feed').FeedExpanded>}
|
||||
*/
|
||||
async openFeedForItem(userId, libraryItem, options) {
|
||||
const serverAddress = options.serverAddress
|
||||
const slug = options.slug
|
||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
||||
const ownerName = options.metadataDetails?.ownerName
|
||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
||||
const feedOptions = this.getFeedOptionsFromReqOptions(options)
|
||||
|
||||
const feed = new Feed()
|
||||
feed.setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
|
||||
|
||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
||||
await Database.createFeed(feed)
|
||||
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
|
||||
return feed
|
||||
Logger.info(`[RssFeedManager] Creating RSS feed for item ${libraryItem.id} "${libraryItem.media.title}"`)
|
||||
const feedExpanded = await Database.feedModel.createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
|
||||
if (feedExpanded) {
|
||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
|
||||
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
|
||||
}
|
||||
return feedExpanded
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {*} collectionExpanded
|
||||
* @param {import('../models/Collection')} collectionExpanded
|
||||
* @param {*} options
|
||||
* @returns
|
||||
* @returns {Promise<import('../models/Feed').FeedExpanded>}
|
||||
*/
|
||||
async openFeedForCollection(userId, collectionExpanded, options) {
|
||||
const serverAddress = options.serverAddress
|
||||
const slug = options.slug
|
||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
||||
const ownerName = options.metadataDetails?.ownerName
|
||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
||||
const feedOptions = this.getFeedOptionsFromReqOptions(options)
|
||||
|
||||
const feed = new Feed()
|
||||
feed.setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
|
||||
|
||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
||||
await Database.createFeed(feed)
|
||||
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
|
||||
return feed
|
||||
Logger.info(`[RssFeedManager] Creating RSS feed for collection "${collectionExpanded.name}"`)
|
||||
const feedExpanded = await Database.feedModel.createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
|
||||
if (feedExpanded) {
|
||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
|
||||
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
|
||||
}
|
||||
return feedExpanded
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {*} seriesExpanded
|
||||
* @param {import('../models/Series')} seriesExpanded
|
||||
* @param {*} options
|
||||
* @returns
|
||||
* @returns {Promise<import('../models/Feed').FeedExpanded>}
|
||||
*/
|
||||
async openFeedForSeries(userId, seriesExpanded, options) {
|
||||
const serverAddress = options.serverAddress
|
||||
const slug = options.slug
|
||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
||||
const ownerName = options.metadataDetails?.ownerName
|
||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
||||
const feedOptions = this.getFeedOptionsFromReqOptions(options)
|
||||
|
||||
const feed = new Feed()
|
||||
feed.setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
|
||||
|
||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
||||
await Database.createFeed(feed)
|
||||
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
|
||||
return feed
|
||||
}
|
||||
|
||||
async handleCloseFeed(feed) {
|
||||
if (!feed) return
|
||||
await Database.removeFeed(feed.id)
|
||||
SocketAuthority.emitter('rss_feed_closed', feed.toJSONMinified())
|
||||
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedUrl}"`)
|
||||
}
|
||||
|
||||
async closeRssFeed(req, res) {
|
||||
const feed = await this.findFeed(req.params.id)
|
||||
if (!feed) {
|
||||
Logger.error(`[RssFeedManager] RSS feed not found with id "${req.params.id}"`)
|
||||
return res.sendStatus(404)
|
||||
Logger.info(`[RssFeedManager] Creating RSS feed for series "${seriesExpanded.name}"`)
|
||||
const feedExpanded = await Database.feedModel.createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
|
||||
if (feedExpanded) {
|
||||
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
|
||||
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
|
||||
}
|
||||
await this.handleCloseFeed(feed)
|
||||
res.sendStatus(200)
|
||||
return feedExpanded
|
||||
}
|
||||
|
||||
/**
|
||||
* Close Feed and emit Socket event
|
||||
*
|
||||
* @param {import('../models/Feed')} feed
|
||||
* @returns {Promise<boolean>} - true if feed was closed
|
||||
*/
|
||||
async handleCloseFeed(feed) {
|
||||
if (!feed) return false
|
||||
const wasRemoved = await Database.feedModel.removeById(feed.id)
|
||||
SocketAuthority.emitter('rss_feed_closed', feed.toOldJSONMinified())
|
||||
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedURL}"`)
|
||||
return wasRemoved
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} entityId
|
||||
* @returns {Promise<boolean>} - true if feed was closed
|
||||
*/
|
||||
async closeFeedForEntityId(entityId) {
|
||||
const feed = await this.findFeedForEntityId(entityId)
|
||||
if (!feed) return
|
||||
const feed = await Database.feedModel.findOne({
|
||||
where: {
|
||||
entityId
|
||||
}
|
||||
})
|
||||
if (!feed) {
|
||||
Logger.warn(`[RssFeedManager] closeFeedForEntityId: Feed not found for entity id ${entityId}`)
|
||||
return false
|
||||
}
|
||||
return this.handleCloseFeed(feed)
|
||||
}
|
||||
|
||||
async getFeeds() {
|
||||
const feeds = await Database.models.feed.getOldFeeds()
|
||||
Logger.info(`[RssFeedManager] Fetched all feeds`)
|
||||
return feeds
|
||||
/**
|
||||
*
|
||||
* @param {string[]} entityIds
|
||||
*/
|
||||
async closeFeedsForEntityIds(entityIds) {
|
||||
const feeds = await Database.feedModel.findAll({
|
||||
where: {
|
||||
entityId: entityIds
|
||||
}
|
||||
})
|
||||
for (const feed of feeds) {
|
||||
await this.handleCloseFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<import('../models/Feed').FeedExpanded[]>}
|
||||
*/
|
||||
getFeeds() {
|
||||
return Database.feedModel.findAll({
|
||||
include: {
|
||||
model: Database.feedEpisodeModel
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = RssFeedManager
|
||||
module.exports = new RssFeedManager()
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.
|
||||
|
||||
| Server Version | Migration Script Name | Description |
|
||||
| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
|
||||
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
|
||||
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
|
||||
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
|
||||
| Server Version | Migration Script Name | Description |
|
||||
| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
|
||||
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
|
||||
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
|
||||
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
|
||||
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
|
||||
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
|
||||
| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables |
|
||||
|
||||
@@ -27,10 +27,14 @@ async function up({ context: { queryInterface, logger } }) {
|
||||
type: 'UUID'
|
||||
})
|
||||
|
||||
logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID')
|
||||
await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', {
|
||||
type: 'UUID'
|
||||
})
|
||||
if (await queryInterface.tableExists('mediaItemShares')) {
|
||||
logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID')
|
||||
await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', {
|
||||
type: 'UUID'
|
||||
})
|
||||
} else {
|
||||
logger.info('[2.17.0 migration] mediaItemShares table does not exist, skipping column change')
|
||||
}
|
||||
|
||||
logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUID')
|
||||
await queryInterface.changeColumn('playbackSessions', 'mediaItemId', {
|
||||
|
||||
259
server/migrations/v2.17.3-fk-constraints.js
Normal file
259
server/migrations/v2.17.3-fk-constraints.js
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This upward migration script changes foreign key constraints for the
|
||||
* libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, and mediaProgresses tables.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
// Upwards migration script
|
||||
logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints')
|
||||
|
||||
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
|
||||
|
||||
// Disable foreign key constraints for the next sequence of operations
|
||||
await execQuery(`PRAGMA foreign_keys = OFF;`)
|
||||
|
||||
try {
|
||||
await execQuery(`BEGIN TRANSACTION;`)
|
||||
|
||||
logger.info('[2.17.3 migration] Updating libraryItems constraints')
|
||||
const libraryItemsConstraints = [
|
||||
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
|
||||
{ field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
|
||||
]
|
||||
if (await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints)) {
|
||||
logger.info('[2.17.3 migration] Finished updating libraryItems constraints')
|
||||
} else {
|
||||
logger.info('[2.17.3 migration] No changes needed for libraryItems constraints')
|
||||
}
|
||||
|
||||
logger.info('[2.17.3 migration] Updating feeds constraints')
|
||||
const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
|
||||
if (await changeConstraints(queryInterface, 'feeds', feedsConstraints)) {
|
||||
logger.info('[2.17.3 migration] Finished updating feeds constraints')
|
||||
} else {
|
||||
logger.info('[2.17.3 migration] No changes needed for feeds constraints')
|
||||
}
|
||||
|
||||
if (await queryInterface.tableExists('mediaItemShares')) {
|
||||
logger.info('[2.17.3 migration] Updating mediaItemShares constraints')
|
||||
const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
|
||||
if (await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints)) {
|
||||
logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints')
|
||||
} else {
|
||||
logger.info('[2.17.3 migration] No changes needed for mediaItemShares constraints')
|
||||
}
|
||||
} else {
|
||||
logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change')
|
||||
}
|
||||
|
||||
logger.info('[2.17.3 migration] Updating playbackSessions constraints')
|
||||
const playbackSessionsConstraints = [
|
||||
{ field: 'deviceId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
|
||||
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
|
||||
{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
|
||||
]
|
||||
if (await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints)) {
|
||||
logger.info('[2.17.3 migration] Finished updating playbackSessions constraints')
|
||||
} else {
|
||||
logger.info('[2.17.3 migration] No changes needed for playbackSessions constraints')
|
||||
}
|
||||
|
||||
logger.info('[2.17.3 migration] Updating playlistMediaItems constraints')
|
||||
const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
|
||||
if (await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints)) {
|
||||
logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints')
|
||||
} else {
|
||||
logger.info('[2.17.3 migration] No changes needed for playlistMediaItems constraints')
|
||||
}
|
||||
|
||||
logger.info('[2.17.3 migration] Updating mediaProgresses constraints')
|
||||
const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
|
||||
if (await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints)) {
|
||||
logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints')
|
||||
} else {
|
||||
logger.info('[2.17.3 migration] No changes needed for mediaProgresses constraints')
|
||||
}
|
||||
|
||||
await execQuery(`COMMIT;`)
|
||||
} catch (error) {
|
||||
logger.error(`[2.17.3 migration] Migration failed - rolling back. Error:`, error)
|
||||
await execQuery(`ROLLBACK;`)
|
||||
}
|
||||
|
||||
await execQuery(`PRAGMA foreign_keys = ON;`)
|
||||
|
||||
// Completed migration
|
||||
logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints')
|
||||
}
|
||||
|
||||
/**
|
||||
* This downward migration script is a no-op.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
// Downward migration script
|
||||
logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-fk-constraints')
|
||||
|
||||
// This migration is a no-op
|
||||
logger.info('[2.17.3 migration] No action required for downgrade')
|
||||
|
||||
// Completed migration
|
||||
logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-fk-constraints')
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef ConstraintUpdateObj
|
||||
* @property {string} field - The field to update
|
||||
* @property {string} onDelete - The onDelete constraint
|
||||
* @property {string} onUpdate - The onUpdate constraint
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef SequelizeFKObj
|
||||
* @property {{ model: string, key: string }} references
|
||||
* @property {string} onDelete
|
||||
* @property {string} onUpdate
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Object} fk - The foreign key object from PRAGMA foreign_key_list
|
||||
* @returns {SequelizeFKObj} - The foreign key object formatted for Sequelize
|
||||
*/
|
||||
const formatFKsPragmaToSequelizeFK = (fk) => {
|
||||
return {
|
||||
references: {
|
||||
model: fk.table,
|
||||
key: fk.to
|
||||
},
|
||||
onDelete: fk['on_delete'],
|
||||
onUpdate: fk['on_update']
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface
|
||||
* @param {string} tableName
|
||||
* @param {ConstraintUpdateObj[]} constraints
|
||||
* @returns {Promise<Record<string, SequelizeFKObj>|null>}
|
||||
*/
|
||||
async function getUpdatedForeignKeys(queryInterface, tableName, constraints) {
|
||||
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
|
||||
const quotedTableName = queryInterface.quoteIdentifier(tableName)
|
||||
|
||||
const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`)
|
||||
|
||||
let hasUpdates = false
|
||||
const foreignKeysByColName = foreignKeys.reduce((prev, curr) => {
|
||||
const fk = formatFKsPragmaToSequelizeFK(curr)
|
||||
|
||||
const constraint = constraints.find((c) => c.field === curr.from)
|
||||
if (constraint && (constraint.onDelete !== fk.onDelete || constraint.onUpdate !== fk.onUpdate)) {
|
||||
fk.onDelete = constraint.onDelete
|
||||
fk.onUpdate = constraint.onUpdate
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
return { ...prev, [curr.from]: fk }
|
||||
}, {})
|
||||
|
||||
return hasUpdates ? foreignKeysByColName : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the Sequelize describeTable function to include the updated foreign key constraints
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface
|
||||
* @param {String} tableName
|
||||
* @param {Record<string, SequelizeFKObj>} updatedForeignKeys
|
||||
*/
|
||||
async function describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) {
|
||||
const tableDescription = await queryInterface.describeTable(tableName)
|
||||
|
||||
const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => {
|
||||
let extendedAttributes = attributes
|
||||
|
||||
if (updatedForeignKeys[col]) {
|
||||
extendedAttributes = {
|
||||
...extendedAttributes,
|
||||
...updatedForeignKeys[col]
|
||||
}
|
||||
}
|
||||
return { ...prev, [col]: extendedAttributes }
|
||||
}, {})
|
||||
|
||||
return tableDescriptionWithFks
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://www.sqlite.org/lang_altertable.html#otheralter
|
||||
* @see https://sequelize.org/docs/v6/other-topics/query-interface/#changing-and-removing-columns-in-sqlite
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface
|
||||
* @param {string} tableName
|
||||
* @param {ConstraintUpdateObj[]} constraints
|
||||
* @returns {Promise<boolean>} - Return false if no changes are needed, true otherwise
|
||||
*/
|
||||
async function changeConstraints(queryInterface, tableName, constraints) {
|
||||
const updatedForeignKeys = await getUpdatedForeignKeys(queryInterface, tableName, constraints)
|
||||
if (!updatedForeignKeys) {
|
||||
return false
|
||||
}
|
||||
|
||||
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
|
||||
const quotedTableName = queryInterface.quoteIdentifier(tableName)
|
||||
|
||||
const backupTableName = `${tableName}_${Math.round(Math.random() * 100)}_backup`
|
||||
const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName)
|
||||
|
||||
try {
|
||||
const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, updatedForeignKeys)
|
||||
|
||||
const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks)
|
||||
|
||||
// Create the backup table
|
||||
await queryInterface.createTable(backupTableName, attributes)
|
||||
|
||||
const attributeNames = Object.keys(attributes)
|
||||
.map((attr) => queryInterface.quoteIdentifier(attr))
|
||||
.join(', ')
|
||||
|
||||
// Copy all data from the target table to the backup table
|
||||
await execQuery(`INSERT INTO ${quotedBackupTableName} SELECT ${attributeNames} FROM ${quotedTableName};`)
|
||||
|
||||
// Drop the old (original) table
|
||||
await queryInterface.dropTable(tableName)
|
||||
|
||||
// Rename the backup table to the original table's name
|
||||
await queryInterface.renameTable(backupTableName, tableName)
|
||||
|
||||
// Validate that all foreign key constraints are correct
|
||||
const result = await execQuery(`PRAGMA foreign_key_check(${quotedTableName});`, {
|
||||
type: queryInterface.sequelize.Sequelize.QueryTypes.SELECT
|
||||
})
|
||||
|
||||
// There are foreign key violations, exit
|
||||
if (result.length) {
|
||||
return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This upward migration adds an subfolder setting for OIDC redirect URIs.
|
||||
* It updates existing OIDC setups to set this option to None (empty subfolder), so they continue to work as before.
|
||||
* IF OIDC is not enabled, no action is taken (i.e. the subfolder is left undefined),
|
||||
* so that future OIDC setups will use the default subfolder.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
// Upwards migration script
|
||||
logger.info('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')
|
||||
|
||||
const serverSettings = await getServerSettings(queryInterface, logger)
|
||||
if (serverSettings.authActiveAuthMethods?.includes('openid')) {
|
||||
logger.info('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')
|
||||
serverSettings.authOpenIDSubfolderForRedirectURLs = ''
|
||||
await updateServerSettings(queryInterface, logger, serverSettings)
|
||||
} else {
|
||||
logger.info('[2.17.4 migration] OIDC is not enabled, no action required')
|
||||
}
|
||||
|
||||
logger.info('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')
|
||||
}
|
||||
|
||||
/**
|
||||
* This downward migration script removes the subfolder setting for OIDC redirect URIs.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
// Downward migration script
|
||||
logger.info('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
|
||||
|
||||
// Remove the OIDC subfolder option from the server settings
|
||||
const serverSettings = await getServerSettings(queryInterface, logger)
|
||||
if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) {
|
||||
logger.info('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')
|
||||
delete serverSettings.authOpenIDSubfolderForRedirectURLs
|
||||
await updateServerSettings(queryInterface, logger, serverSettings)
|
||||
} else {
|
||||
logger.info('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')
|
||||
}
|
||||
|
||||
logger.info('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
|
||||
}
|
||||
|
||||
async function getServerSettings(queryInterface, logger) {
|
||||
const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";')
|
||||
if (!result[0].length) {
|
||||
logger.error('[2.17.4 migration] Server settings not found')
|
||||
throw new Error('Server settings not found')
|
||||
}
|
||||
|
||||
let serverSettings = null
|
||||
try {
|
||||
serverSettings = JSON.parse(result[0][0].value)
|
||||
} catch (error) {
|
||||
logger.error('[2.17.4 migration] Error parsing server settings:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
return serverSettings
|
||||
}
|
||||
|
||||
async function updateServerSettings(queryInterface, logger, serverSettings) {
|
||||
await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = "server-settings";', {
|
||||
replacements: {
|
||||
value: JSON.stringify(serverSettings)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
74
server/migrations/v2.17.5-remove-host-from-feed-urls.js
Normal file
74
server/migrations/v2.17.5-remove-host-from-feed-urls.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.17.5'
|
||||
const migrationName = `${migrationVersion}-remove-host-from-feed-urls`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This upward migration removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
// Upwards migration script
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
logger.info(`${loggerPrefix} Removing serverAddress from Feeds table URLs`)
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE Feeds
|
||||
SET feedUrl = REPLACE(feedUrl, COALESCE(serverAddress, ''), ''),
|
||||
imageUrl = REPLACE(imageUrl, COALESCE(serverAddress, ''), ''),
|
||||
siteUrl = REPLACE(siteUrl, COALESCE(serverAddress, ''), '');
|
||||
`)
|
||||
logger.info(`${loggerPrefix} Removed serverAddress from Feeds table URLs`)
|
||||
|
||||
logger.info(`${loggerPrefix} Removing serverAddress from FeedEpisodes table URLs`)
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE FeedEpisodes
|
||||
SET siteUrl = REPLACE(siteUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''),
|
||||
enclosureUrl = REPLACE(enclosureUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), '');
|
||||
`)
|
||||
logger.info(`${loggerPrefix} Removed serverAddress from FeedEpisodes table URLs`)
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This downward migration script adds the host (serverAddress) back to URL columns in the feeds and feedEpisodes tables.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
// Downward migration script
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
logger.info(`${loggerPrefix} Adding serverAddress back to Feeds table URLs`)
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE Feeds
|
||||
SET feedUrl = COALESCE(serverAddress, '') || feedUrl,
|
||||
imageUrl = COALESCE(serverAddress, '') || imageUrl,
|
||||
siteUrl = COALESCE(serverAddress, '') || siteUrl;
|
||||
`)
|
||||
logger.info(`${loggerPrefix} Added serverAddress back to Feeds table URLs`)
|
||||
|
||||
logger.info(`${loggerPrefix} Adding serverAddress back to FeedEpisodes table URLs`)
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE FeedEpisodes
|
||||
SET siteUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.siteUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId),
|
||||
enclosureUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.enclosureUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId);
|
||||
`)
|
||||
logger.info(`${loggerPrefix} Added serverAddress back to FeedEpisodes table URLs`)
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
@@ -29,6 +29,12 @@ const Logger = require('../Logger')
|
||||
* @property {SeriesExpanded[]} series
|
||||
*
|
||||
* @typedef {Book & BookExpandedProperties} BookExpanded
|
||||
*
|
||||
* Collections use BookExpandedWithLibraryItem
|
||||
* @typedef BookExpandedWithLibraryItemProperties
|
||||
* @property {import('./LibraryItem')} libraryItem
|
||||
*
|
||||
* @typedef {BookExpanded & BookExpandedWithLibraryItemProperties} BookExpandedWithLibraryItem
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -106,6 +112,9 @@ class Book extends Model {
|
||||
this.updatedAt
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
|
||||
/** @type {import('./Author')[]} - optional if expanded */
|
||||
this.authors
|
||||
}
|
||||
|
||||
static getOldBook(libraryItemExpanded) {
|
||||
@@ -320,6 +329,32 @@ class Book extends Model {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Comma separated array of author names
|
||||
* Requires authors to be loaded
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
get authorName() {
|
||||
if (this.authors === undefined) {
|
||||
Logger.error(`[Book] authorName: Cannot get authorName because authors are not loaded`)
|
||||
return ''
|
||||
}
|
||||
return this.authors.map((au) => au.name).join(', ')
|
||||
}
|
||||
get includedAudioFiles() {
|
||||
return this.audioFiles.filter((af) => !af.exclude)
|
||||
}
|
||||
get trackList() {
|
||||
let startOffset = 0
|
||||
return this.includedAudioFiles.map((af) => {
|
||||
const track = structuredClone(af)
|
||||
track.startOffset = startOffset
|
||||
startOffset += track.duration
|
||||
return track
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Book
|
||||
|
||||
@@ -18,6 +18,11 @@ class Collection extends Model {
|
||||
this.updatedAt
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */
|
||||
this.books
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,7 +112,7 @@ class Collection extends Model {
|
||||
|
||||
// Map feed if found
|
||||
if (c.feeds?.length) {
|
||||
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0])
|
||||
collectionExpanded.rssFeed = c.feeds[0].toOldJSON()
|
||||
}
|
||||
|
||||
return collectionExpanded
|
||||
@@ -115,6 +120,39 @@ class Collection extends Model {
|
||||
.filter((c) => c)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} collectionId
|
||||
* @returns {Promise<Collection>}
|
||||
*/
|
||||
static async getExpandedById(collectionId) {
|
||||
return this.findByPk(collectionId, {
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.book,
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.author,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.series,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old collection from Collection
|
||||
* @param {Collection} collectionExpanded
|
||||
@@ -219,6 +257,34 @@ class Collection extends Model {
|
||||
Collection.belongsTo(library)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all books in collection expanded with library item
|
||||
*
|
||||
* @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}
|
||||
*/
|
||||
getBooksExpandedWithLibraryItem() {
|
||||
return this.getBooks({
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.author,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.series,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
}
|
||||
],
|
||||
order: [Sequelize.literal('`collectionBook.order` ASC')]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old collection toJSONExpanded, items filtered for user permissions
|
||||
*
|
||||
@@ -282,7 +348,7 @@ class Collection extends Model {
|
||||
if (include?.includes('rssfeed')) {
|
||||
const feeds = await this.getFeeds()
|
||||
if (feeds?.length) {
|
||||
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
|
||||
collectionExpanded.rssFeed = feeds[0].toOldJSON()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
const Path = require('path')
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const oldFeed = require('../objects/Feed')
|
||||
const areEquivalent = require('../utils/areEquivalent')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const RSS = require('../libs/rss')
|
||||
|
||||
/**
|
||||
* @typedef FeedOptions
|
||||
* @property {boolean} preventIndexing
|
||||
* @property {string} ownerName
|
||||
* @property {string} ownerEmail
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef FeedExpandedProperties
|
||||
* @property {import('./FeedEpisode')} feedEpisodes
|
||||
*
|
||||
* @typedef {Feed & FeedExpandedProperties} FeedExpanded
|
||||
*/
|
||||
|
||||
class Feed extends Model {
|
||||
constructor(values, options) {
|
||||
@@ -50,210 +66,288 @@ class Feed extends Model {
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
static async getOldFeeds() {
|
||||
const feeds = await this.findAll({
|
||||
include: {
|
||||
model: this.sequelize.models.feedEpisode
|
||||
}
|
||||
})
|
||||
return feeds.map((f) => this.getOldFeed(f))
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./FeedEpisode')[]} - only set if expanded */
|
||||
this.feedEpisodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old feed from Feed and optionally Feed with FeedEpisodes
|
||||
* @param {Feed} feedExpanded
|
||||
* @returns {oldFeed}
|
||||
* @param {string} feedId
|
||||
* @returns {Promise<boolean>} - true if feed was removed
|
||||
*/
|
||||
static getOldFeed(feedExpanded) {
|
||||
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
|
||||
return new oldFeed({
|
||||
id: feedExpanded.id,
|
||||
slug: feedExpanded.slug,
|
||||
userId: feedExpanded.userId,
|
||||
entityType: feedExpanded.entityType,
|
||||
entityId: feedExpanded.entityId,
|
||||
entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null,
|
||||
coverPath: feedExpanded.coverPath || null,
|
||||
meta: {
|
||||
title: feedExpanded.title,
|
||||
description: feedExpanded.description,
|
||||
author: feedExpanded.author,
|
||||
imageUrl: feedExpanded.imageURL,
|
||||
feedUrl: feedExpanded.feedURL,
|
||||
link: feedExpanded.siteURL,
|
||||
explicit: feedExpanded.explicit,
|
||||
type: feedExpanded.podcastType,
|
||||
language: feedExpanded.language,
|
||||
preventIndexing: feedExpanded.preventIndexing,
|
||||
ownerName: feedExpanded.ownerName,
|
||||
ownerEmail: feedExpanded.ownerEmail
|
||||
},
|
||||
serverAddress: feedExpanded.serverAddress,
|
||||
feedUrl: feedExpanded.feedURL,
|
||||
episodes: episodes || [],
|
||||
createdAt: feedExpanded.createdAt.valueOf(),
|
||||
updatedAt: feedExpanded.updatedAt.valueOf()
|
||||
})
|
||||
}
|
||||
|
||||
static removeById(feedId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: feedId
|
||||
}
|
||||
})
|
||||
static async removeById(feedId) {
|
||||
return (
|
||||
(await this.destroy({
|
||||
where: {
|
||||
id: feedId
|
||||
}
|
||||
})) > 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all library item ids that have an open feed (used in library filter)
|
||||
* @returns {Promise<string[]>} array of library item ids
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
|
||||
* @param {string} slug
|
||||
* @param {string} serverAddress
|
||||
* @param {FeedOptions} [feedOptions=null]
|
||||
*
|
||||
* @returns {Feed}
|
||||
*/
|
||||
static async findAllLibraryItemIds() {
|
||||
const feeds = await this.findAll({
|
||||
attributes: ['entityId'],
|
||||
where: {
|
||||
entityType: 'libraryItem'
|
||||
}
|
||||
})
|
||||
return feeds.map((f) => f.entityId).filter((f) => f) || []
|
||||
}
|
||||
static getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions = null) {
|
||||
const media = libraryItem.media
|
||||
|
||||
/**
|
||||
* Find feed where and return oldFeed
|
||||
* @param {Object} where sequelize where object
|
||||
* @returns {Promise<oldFeed>} oldFeed
|
||||
*/
|
||||
static async findOneOld(where) {
|
||||
if (!where) return null
|
||||
const feedExpanded = await this.findOne({
|
||||
where,
|
||||
include: {
|
||||
model: this.sequelize.models.feedEpisode
|
||||
}
|
||||
})
|
||||
if (!feedExpanded) return null
|
||||
return this.getOldFeed(feedExpanded)
|
||||
}
|
||||
let entityUpdatedAt = libraryItem.updatedAt
|
||||
|
||||
/**
|
||||
* Find feed and return oldFeed
|
||||
* @param {string} id
|
||||
* @returns {Promise<oldFeed>} oldFeed
|
||||
*/
|
||||
static async findByPkOld(id) {
|
||||
if (!id) return null
|
||||
const feedExpanded = await this.findByPk(id, {
|
||||
include: {
|
||||
model: this.sequelize.models.feedEpisode
|
||||
}
|
||||
})
|
||||
if (!feedExpanded) return null
|
||||
return this.getOldFeed(feedExpanded)
|
||||
}
|
||||
|
||||
static async fullCreateFromOld(oldFeed) {
|
||||
const feedObj = this.getFromOld(oldFeed)
|
||||
const newFeed = await this.create(feedObj)
|
||||
|
||||
if (oldFeed.episodes?.length) {
|
||||
for (const oldFeedEpisode of oldFeed.episodes) {
|
||||
const feedEpisode = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
|
||||
feedEpisode.feedId = newFeed.id
|
||||
await this.sequelize.models.feedEpisode.create(feedEpisode)
|
||||
}
|
||||
// Podcast feeds should use the most recent episode updatedAt if more recent
|
||||
if (libraryItem.mediaType === 'podcast') {
|
||||
entityUpdatedAt = libraryItem.media.podcastEpisodes.reduce((mostRecent, episode) => {
|
||||
return episode.updatedAt > mostRecent ? episode.updatedAt : mostRecent
|
||||
}, entityUpdatedAt)
|
||||
}
|
||||
|
||||
const feedObj = {
|
||||
slug,
|
||||
entityType: 'libraryItem',
|
||||
entityId: libraryItem.id,
|
||||
entityUpdatedAt,
|
||||
serverAddress,
|
||||
feedURL: `/feed/${slug}`,
|
||||
imageURL: media.coverPath ? `/feed/${slug}/cover${Path.extname(media.coverPath)}` : `/Logo.png`,
|
||||
siteURL: `/item/${libraryItem.id}`,
|
||||
title: media.title,
|
||||
description: media.description,
|
||||
author: libraryItem.mediaType === 'podcast' ? media.author : media.authorName,
|
||||
podcastType: libraryItem.mediaType === 'podcast' ? media.podcastType : 'serial',
|
||||
language: media.language,
|
||||
explicit: media.explicit,
|
||||
coverPath: media.coverPath,
|
||||
userId
|
||||
}
|
||||
|
||||
if (feedOptions) {
|
||||
feedObj.preventIndexing = feedOptions.preventIndexing
|
||||
feedObj.ownerName = feedOptions.ownerName
|
||||
feedObj.ownerEmail = feedOptions.ownerEmail
|
||||
}
|
||||
|
||||
return feedObj
|
||||
}
|
||||
|
||||
static async fullUpdateFromOld(oldFeed) {
|
||||
const oldFeedEpisodes = oldFeed.episodes || []
|
||||
const feedObj = this.getFromOld(oldFeed)
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
|
||||
* @param {string} slug
|
||||
* @param {string} serverAddress
|
||||
* @param {FeedOptions} feedOptions
|
||||
*
|
||||
* @returns {Promise<FeedExpanded>}
|
||||
*/
|
||||
static async createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) {
|
||||
const feedObj = this.getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
|
||||
|
||||
const existingFeed = await this.findByPk(feedObj.id, {
|
||||
include: this.sequelize.models.feedEpisode
|
||||
})
|
||||
if (!existingFeed) return false
|
||||
/** @type {typeof import('./FeedEpisode')} */
|
||||
const feedEpisodeModel = this.sequelize.models.feedEpisode
|
||||
|
||||
let hasUpdates = false
|
||||
const transaction = await this.sequelize.transaction()
|
||||
try {
|
||||
const feed = await this.create(feedObj, { transaction })
|
||||
|
||||
// Remove and update existing feed episodes
|
||||
for (const feedEpisode of existingFeed.feedEpisodes) {
|
||||
const oldFeedEpisode = oldFeedEpisodes.find((ep) => ep.id === feedEpisode.id)
|
||||
// Episode removed
|
||||
if (!oldFeedEpisode) {
|
||||
feedEpisode.destroy()
|
||||
if (libraryItem.mediaType === 'podcast') {
|
||||
feed.feedEpisodes = await feedEpisodeModel.createFromPodcastEpisodes(libraryItem, feed, slug, transaction)
|
||||
} else {
|
||||
let episodeHasUpdates = false
|
||||
const oldFeedEpisodeCleaned = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
|
||||
for (const key in oldFeedEpisodeCleaned) {
|
||||
if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) {
|
||||
episodeHasUpdates = true
|
||||
}
|
||||
}
|
||||
if (episodeHasUpdates) {
|
||||
await feedEpisode.update(oldFeedEpisodeCleaned)
|
||||
hasUpdates = true
|
||||
}
|
||||
feed.feedEpisodes = await feedEpisodeModel.createFromAudiobookTracks(libraryItem, feed, slug, transaction)
|
||||
}
|
||||
|
||||
await transaction.commit()
|
||||
|
||||
return feed
|
||||
} catch (error) {
|
||||
Logger.error(`[Feed] Error creating feed for library item ${libraryItem.id}`, error)
|
||||
await transaction.rollback()
|
||||
return null
|
||||
}
|
||||
|
||||
// Add new feed episodes
|
||||
for (const episode of oldFeedEpisodes) {
|
||||
if (!existingFeed.feedEpisodes.some((fe) => fe.id === episode.id)) {
|
||||
await this.sequelize.models.feedEpisode.createFromOld(feedObj.id, episode)
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
let feedHasUpdates = false
|
||||
for (const key in feedObj) {
|
||||
let existingValue = existingFeed[key]
|
||||
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
||||
|
||||
if (!areEquivalent(existingValue, feedObj[key])) {
|
||||
feedHasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (feedHasUpdates) {
|
||||
await existingFeed.update(feedObj)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
static getFromOld(oldFeed) {
|
||||
const oldFeedMeta = oldFeed.meta || {}
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {import('./Collection')} collectionExpanded
|
||||
* @param {string} slug
|
||||
* @param {string} serverAddress
|
||||
* @param {FeedOptions} [feedOptions=null]
|
||||
*
|
||||
* @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }}
|
||||
*/
|
||||
static getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions = null) {
|
||||
const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length)
|
||||
|
||||
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
|
||||
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
|
||||
}, collectionExpanded.updatedAt)
|
||||
|
||||
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
|
||||
|
||||
const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => {
|
||||
const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name)
|
||||
return authorNames.concat(bookAuthorsToAdd)
|
||||
}, [])
|
||||
let author = allBookAuthorNames.slice(0, 3).join(', ')
|
||||
if (allBookAuthorNames.length > 3) {
|
||||
author += ' & more'
|
||||
}
|
||||
|
||||
const feedObj = {
|
||||
slug,
|
||||
entityType: 'collection',
|
||||
entityId: collectionExpanded.id,
|
||||
entityUpdatedAt,
|
||||
serverAddress,
|
||||
feedURL: `/feed/${slug}`,
|
||||
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
|
||||
siteURL: `/collection/${collectionExpanded.id}`,
|
||||
title: collectionExpanded.name,
|
||||
description: collectionExpanded.description || '',
|
||||
author,
|
||||
podcastType: 'serial',
|
||||
explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit
|
||||
coverPath: firstBookWithCover?.coverPath || null,
|
||||
userId
|
||||
}
|
||||
|
||||
if (feedOptions) {
|
||||
feedObj.preventIndexing = feedOptions.preventIndexing
|
||||
feedObj.ownerName = feedOptions.ownerName
|
||||
feedObj.ownerEmail = feedOptions.ownerEmail
|
||||
}
|
||||
|
||||
return {
|
||||
id: oldFeed.id,
|
||||
slug: oldFeed.slug,
|
||||
entityType: oldFeed.entityType,
|
||||
entityId: oldFeed.entityId,
|
||||
entityUpdatedAt: oldFeed.entityUpdatedAt,
|
||||
serverAddress: oldFeed.serverAddress,
|
||||
feedURL: oldFeed.feedUrl,
|
||||
coverPath: oldFeed.coverPath || null,
|
||||
imageURL: oldFeedMeta.imageUrl,
|
||||
siteURL: oldFeedMeta.link,
|
||||
title: oldFeedMeta.title,
|
||||
description: oldFeedMeta.description,
|
||||
author: oldFeedMeta.author,
|
||||
podcastType: oldFeedMeta.type || null,
|
||||
language: oldFeedMeta.language || null,
|
||||
ownerName: oldFeedMeta.ownerName || null,
|
||||
ownerEmail: oldFeedMeta.ownerEmail || null,
|
||||
explicit: !!oldFeedMeta.explicit,
|
||||
preventIndexing: !!oldFeedMeta.preventIndexing,
|
||||
userId: oldFeed.userId
|
||||
feedObj,
|
||||
booksWithTracks
|
||||
}
|
||||
}
|
||||
|
||||
getEntity(options) {
|
||||
if (!this.entityType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
|
||||
return this[mixinMethodName](options)
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {import('./Collection')} collectionExpanded
|
||||
* @param {string} slug
|
||||
* @param {string} serverAddress
|
||||
* @param {FeedOptions} feedOptions
|
||||
*
|
||||
* @returns {Promise<FeedExpanded>}
|
||||
*/
|
||||
static async createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions) {
|
||||
const { feedObj, booksWithTracks } = this.getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
|
||||
|
||||
/** @type {typeof import('./FeedEpisode')} */
|
||||
const feedEpisodeModel = this.sequelize.models.feedEpisode
|
||||
|
||||
const transaction = await this.sequelize.transaction()
|
||||
try {
|
||||
const feed = await this.create(feedObj, { transaction })
|
||||
feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)
|
||||
|
||||
await transaction.commit()
|
||||
|
||||
return feed
|
||||
} catch (error) {
|
||||
Logger.error(`[Feed] Error creating feed for collection ${collectionExpanded.id}`, error)
|
||||
await transaction.rollback()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {import('./Series')} seriesExpanded
|
||||
* @param {string} slug
|
||||
* @param {string} serverAddress
|
||||
* @param {FeedOptions} [feedOptions=null]
|
||||
*
|
||||
* @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }}
|
||||
*/
|
||||
static getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions = null) {
|
||||
const booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length)
|
||||
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
|
||||
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
|
||||
}, seriesExpanded.updatedAt)
|
||||
|
||||
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
|
||||
|
||||
const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => {
|
||||
const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name)
|
||||
return authorNames.concat(bookAuthorsToAdd)
|
||||
}, [])
|
||||
let author = allBookAuthorNames.slice(0, 3).join(', ')
|
||||
if (allBookAuthorNames.length > 3) {
|
||||
author += ' & more'
|
||||
}
|
||||
|
||||
const feedObj = {
|
||||
slug,
|
||||
entityType: 'series',
|
||||
entityId: seriesExpanded.id,
|
||||
entityUpdatedAt,
|
||||
serverAddress,
|
||||
feedURL: `/feed/${slug}`,
|
||||
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
|
||||
siteURL: `/library/${booksWithTracks[0].libraryItem.libraryId}/series/${seriesExpanded.id}`,
|
||||
title: seriesExpanded.name,
|
||||
description: seriesExpanded.description || '',
|
||||
author,
|
||||
podcastType: 'serial',
|
||||
explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit
|
||||
coverPath: firstBookWithCover?.coverPath || null,
|
||||
userId
|
||||
}
|
||||
|
||||
if (feedOptions) {
|
||||
feedObj.preventIndexing = feedOptions.preventIndexing
|
||||
feedObj.ownerName = feedOptions.ownerName
|
||||
feedObj.ownerEmail = feedOptions.ownerEmail
|
||||
}
|
||||
|
||||
return {
|
||||
feedObj,
|
||||
booksWithTracks
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {import('./Series')} seriesExpanded
|
||||
* @param {string} slug
|
||||
* @param {string} serverAddress
|
||||
* @param {FeedOptions} feedOptions
|
||||
*
|
||||
* @returns {Promise<FeedExpanded>}
|
||||
*/
|
||||
static async createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions) {
|
||||
const { feedObj, booksWithTracks } = this.getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
|
||||
|
||||
/** @type {typeof import('./FeedEpisode')} */
|
||||
const feedEpisodeModel = this.sequelize.models.feedEpisode
|
||||
|
||||
const transaction = await this.sequelize.transaction()
|
||||
try {
|
||||
const feed = await this.create(feedObj, { transaction })
|
||||
feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)
|
||||
|
||||
await transaction.commit()
|
||||
|
||||
return feed
|
||||
} catch (error) {
|
||||
Logger.error(`[Feed] Error creating feed for series ${seriesExpanded.id}`, error)
|
||||
await transaction.rollback()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
const Path = require('path')
|
||||
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 {
|
||||
constructor(values, options) {
|
||||
@@ -9,6 +14,8 @@ class FeedEpisode extends Model {
|
||||
/** @type {string} */
|
||||
this.title
|
||||
/** @type {string} */
|
||||
this.author
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {string} */
|
||||
this.siteURL
|
||||
@@ -40,60 +47,167 @@ class FeedEpisode extends Model {
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
getOldEpisode() {
|
||||
const enclosure = {
|
||||
url: this.enclosureURL,
|
||||
size: this.enclosureSize,
|
||||
type: this.enclosureType
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
|
||||
* @param {import('./Feed')} feed
|
||||
* @param {string} slug
|
||||
* @param {import('./PodcastEpisode')} episode
|
||||
*/
|
||||
static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) {
|
||||
const episodeId = uuidv4()
|
||||
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
|
||||
id: episodeId,
|
||||
title: episode.title,
|
||||
author: feed.author,
|
||||
description: episode.description,
|
||||
siteURL: feed.siteURL,
|
||||
enclosureURL: `/feed/${slug}/item/${episodeId}/media${Path.extname(episode.audioFile.metadata.filename)}`,
|
||||
enclosureType: episode.audioFile.mimeType,
|
||||
enclosureSize: episode.audioFile.metadata.size,
|
||||
pubDate: episode.pubDate,
|
||||
season: episode.season,
|
||||
episode: episode.episode,
|
||||
episodeType: episode.episodeType,
|
||||
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 {Object} oldFeedEpisode
|
||||
* @returns {Promise<FeedEpisode>}
|
||||
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
|
||||
* @param {import('./Feed')} feed
|
||||
* @param {string} slug
|
||||
* @param {import('sequelize').Transaction} transaction
|
||||
* @returns {Promise<FeedEpisode[]>}
|
||||
*/
|
||||
static createFromOld(feedId, oldFeedEpisode) {
|
||||
const newEpisode = this.getFromOld(oldFeedEpisode)
|
||||
newEpisode.feedId = feedId
|
||||
return this.create(newEpisode)
|
||||
static async createFromPodcastEpisodes(libraryItemExpanded, feed, slug, transaction) {
|
||||
const feedEpisodeObjs = []
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
for (const episode of libraryItemExpanded.media.podcastEpisodes) {
|
||||
feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode))
|
||||
}
|
||||
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
|
||||
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
||||
}
|
||||
|
||||
static getFromOld(oldFeedEpisode) {
|
||||
return {
|
||||
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
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
* @returns {Promise<string[]>} array of library ids
|
||||
|
||||
@@ -73,6 +73,9 @@ class LibraryItem extends Model {
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
|
||||
/** @type {Book.BookExpanded|Podcast.PodcastExpanded} - only set when expanded */
|
||||
this.media
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,7 +482,7 @@ class LibraryItem extends Model {
|
||||
{
|
||||
model: this.sequelize.models.series,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
attributes: ['id', 'sequence']
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -565,7 +568,7 @@ class LibraryItem extends Model {
|
||||
oldLibraryItem.media.metadata.series = li.series
|
||||
}
|
||||
if (li.rssFeed) {
|
||||
oldLibraryItem.rssFeed = this.sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
|
||||
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
|
||||
}
|
||||
if (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
|
||||
|
||||
@@ -84,13 +84,6 @@ class Playlist extends Model {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -20,6 +20,11 @@ class Series extends Model {
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
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
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
@@ -103,6 +120,35 @@ class Series extends Model {
|
||||
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() {
|
||||
return {
|
||||
id: this.id,
|
||||
|
||||
@@ -611,7 +611,7 @@ class User extends Model {
|
||||
*/
|
||||
getOldMediaProgress(libraryItemId, episodeId = null) {
|
||||
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 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
|
||||
*/
|
||||
async saveMetadata() {
|
||||
if (this.isSavingMetadata) return null
|
||||
if (this.isSavingMetadata || !global.MetadataPath) return null
|
||||
|
||||
this.isSavingMetadata = true
|
||||
|
||||
|
||||
@@ -53,6 +53,20 @@ class PodcastEpisodeDownload {
|
||||
if (globals.SupportedAudioTypes.includes(extname)) return extname
|
||||
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() {
|
||||
const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : ''
|
||||
|
||||
@@ -29,7 +29,12 @@ class BookMetadata {
|
||||
this.subtitle = metadata.subtitle
|
||||
this.authors = metadata.authors?.map ? metadata.authors.map((a) => ({ ...a })) : []
|
||||
this.narrators = metadata.narrators ? [...metadata.narrators].filter((n) => n) : []
|
||||
this.series = metadata.series?.map ? metadata.series.map((s) => ({ ...s })) : []
|
||||
this.series = metadata.series?.map
|
||||
? metadata.series.map((s) => ({
|
||||
...s,
|
||||
name: s.name || 'No Title'
|
||||
}))
|
||||
: []
|
||||
this.genres = metadata.genres ? [...metadata.genres] : []
|
||||
this.publishedYear = metadata.publishedYear || null
|
||||
this.publishedDate = metadata.publishedDate || null
|
||||
|
||||
@@ -24,6 +24,7 @@ class ServerSettings {
|
||||
// Security/Rate limits
|
||||
this.rateLimitLoginRequests = 10
|
||||
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
|
||||
this.allowIframe = false
|
||||
|
||||
// Backups
|
||||
this.backupPath = Path.join(global.MetadataPath, 'backups')
|
||||
@@ -78,6 +79,7 @@ class ServerSettings {
|
||||
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
|
||||
this.authOpenIDGroupClaim = ''
|
||||
this.authOpenIDAdvancedPermsClaim = ''
|
||||
this.authOpenIDSubfolderForRedirectURLs = undefined
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
@@ -98,6 +100,7 @@ class ServerSettings {
|
||||
|
||||
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
||||
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.backupSchedule = settings.backupSchedule || false
|
||||
@@ -139,6 +142,7 @@ class ServerSettings {
|
||||
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
|
||||
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
|
||||
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
|
||||
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
|
||||
|
||||
if (!Array.isArray(this.authActiveAuthMethods)) {
|
||||
this.authActiveAuthMethods = ['local']
|
||||
@@ -188,6 +192,11 @@ class ServerSettings {
|
||||
Logger.info(`[ServerSettings] Using backup path from environment variable ${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() {
|
||||
@@ -205,6 +214,7 @@ class ServerSettings {
|
||||
metadataFileFormat: this.metadataFileFormat,
|
||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
||||
allowIframe: this.allowIframe,
|
||||
backupPath: this.backupPath,
|
||||
backupSchedule: this.backupSchedule,
|
||||
backupsToKeep: this.backupsToKeep,
|
||||
@@ -240,7 +250,8 @@ class ServerSettings {
|
||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // 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
|
||||
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
||||
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
|
||||
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
|
||||
|
||||
authOpenIDSamplePermissions: User.getSampleAbsPermissions()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user