mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-02-19 07:48:12 -05:00
Compare commits
127 Commits
plugin-imp
...
bookmark-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32b886a0c3 | ||
|
|
2463c62bbf | ||
|
|
6057930507 | ||
|
|
9bbb23b853 | ||
|
|
e865241258 | ||
|
|
1a67f57551 | ||
|
|
9b5bdc1fdb | ||
|
|
acda776e3e | ||
|
|
8c4a9280ab | ||
|
|
1812282946 | ||
|
|
64e9ac9d8f | ||
|
|
0da9a04d8e | ||
|
|
11178f58bd | ||
|
|
08b2d07f65 | ||
|
|
3c210170b2 | ||
|
|
03d35421b4 | ||
|
|
a176ba53e0 | ||
|
|
e34dff8f30 | ||
|
|
0881ab4bfb | ||
|
|
20c32efd62 | ||
|
|
e2b8127a5b | ||
|
|
90f32cefca | ||
|
|
ab2e661e22 | ||
|
|
a073aedca2 | ||
|
|
b440a22ec9 | ||
|
|
ec695e5f48 | ||
|
|
69ad0bf113 | ||
|
|
88f464398a | ||
|
|
6fce501389 | ||
|
|
559fab0d90 | ||
|
|
69c428802b | ||
|
|
6da631fa4f | ||
|
|
f83b081791 | ||
|
|
a6ce5fdd98 | ||
|
|
0a2e725bd3 | ||
|
|
c07c4a3341 | ||
|
|
422773e745 | ||
|
|
7a298aa6f5 | ||
|
|
41daf557aa | ||
|
|
de5bc63d88 | ||
|
|
5e2282ef76 | ||
|
|
c819afc53b | ||
|
|
37221a0446 | ||
|
|
0f20ed101e | ||
|
|
b0dbccd283 | ||
|
|
7001adb4dd | ||
|
|
02ecf7ccfe | ||
|
|
05ff5f1956 | ||
|
|
1649fb40db | ||
|
|
052e0059ff | ||
|
|
5edd799b3e | ||
|
|
1632d8edee | ||
|
|
e6181196a7 | ||
|
|
bea9d6aff4 | ||
|
|
d410b13c9b | ||
|
|
8286aad7a4 | ||
|
|
ed5960825b | ||
|
|
7fd8178dde | ||
|
|
db17a5c88b | ||
|
|
2ec84edb5e | ||
|
|
0eed38b771 | ||
|
|
977bdbf0bb | ||
|
|
a1ec10bd0d | ||
|
|
57d742b862 | ||
|
|
108eaba022 | ||
|
|
ac159bea72 | ||
|
|
d5ce7b4939 | ||
|
|
e64302f1d4 | ||
|
|
fdbca4feb6 | ||
|
|
f366dfa909 | ||
|
|
5d1a17ffa8 | ||
|
|
0ed4ea9138 | ||
|
|
1e9470b840 | ||
|
|
726a9eaea5 | ||
|
|
6d52f88a96 | ||
|
|
7fae25a726 | ||
|
|
d8823c8b1c | ||
|
|
43d8d9b286 | ||
|
|
4a398f6113 | ||
|
|
69d1744496 | ||
|
|
0357dc90d4 | ||
|
|
6cd874dffc | ||
|
|
6467a92de6 | ||
|
|
63466ec48b | ||
|
|
de7296eaab | ||
|
|
c251f1899d | ||
|
|
f70f21455f | ||
|
|
a6fd0c95b2 | ||
|
|
d205c6f734 | ||
|
|
5e8678f1cc | ||
|
|
12c6f2e9a5 | ||
|
|
5cd14108f9 | ||
|
|
eb853d9f09 | ||
|
|
4787e7fdb5 | ||
|
|
dd0ebdf2d8 | ||
|
|
fe2ba083be | ||
|
|
de8b0abc3a | ||
|
|
08bbe1ba02 | ||
|
|
87bac1e33b | ||
|
|
e9eeab6fb5 | ||
|
|
235d05eff3 | ||
|
|
f9f8c6d751 | ||
|
|
e175a9c533 | ||
|
|
f9130a138e | ||
|
|
ed17dd9b51 | ||
|
|
0d8d0a650b | ||
|
|
eb505a0be7 | ||
|
|
f3918a47e1 | ||
|
|
c8a05920dd | ||
|
|
e7f7d1a573 | ||
|
|
5201625d38 | ||
|
|
8c4d0c503b | ||
|
|
d3bda898d4 | ||
|
|
86809dcc62 | ||
|
|
9fa00a1904 | ||
|
|
46247ecf78 | ||
|
|
0444829a9f | ||
|
|
754c121168 | ||
|
|
1c2ee09f18 | ||
|
|
ee310d967e | ||
|
|
25b7f005c6 | ||
|
|
777c59458d | ||
|
|
9785bc02ea | ||
|
|
6780ef9b37 | ||
|
|
88a0e75576 | ||
|
|
476933a144 | ||
|
|
d7830f4bfc |
@@ -46,5 +46,10 @@ RUN apk del make python3 g++
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
ENV PORT=80
|
||||
ENV CONFIG_PATH="/config"
|
||||
ENV METADATA_PATH="/metadata"
|
||||
ENV SOURCE="docker"
|
||||
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -112,14 +112,6 @@ export default {
|
||||
}
|
||||
]
|
||||
|
||||
if (this.$store.state.pluginsEnabled) {
|
||||
configRoutes.push({
|
||||
id: 'config-plugins',
|
||||
title: 'Plugins',
|
||||
path: '/config/plugins'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.currentLibraryId) {
|
||||
configRoutes.push({
|
||||
id: 'library-stats',
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||
/>
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :playback-rate="currentPlaybackRate" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||
|
||||
<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" />
|
||||
|
||||
@@ -374,19 +374,27 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
|
||||
if ('mediaSession' in navigator) {
|
||||
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
|
||||
const artwork = [
|
||||
{
|
||||
src: coverImageSrc
|
||||
}
|
||||
]
|
||||
const chapterInfo = []
|
||||
if (this.chapters.length) {
|
||||
this.chapters.forEach((chapter) => {
|
||||
chapterInfo.push({
|
||||
title: chapter.title,
|
||||
startTime: chapter.start
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: this.title,
|
||||
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
|
||||
album: this.mediaMetadata.seriesName || '',
|
||||
artwork
|
||||
artwork: [
|
||||
{
|
||||
src: this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
|
||||
}
|
||||
]
|
||||
})
|
||||
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<article class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
||||
<div 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>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<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 ref="card" :id="`book-card-${index}`" tabindex="0" :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">
|
||||
@@ -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" aria-hidden="true" class="truncate">{{ displayTitle }}</p>
|
||||
<p cy-id="title" ref="displayTitle" 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>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<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="card" ref="card" :id="`series-card-${index}`" tabindex="0" :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">
|
||||
@@ -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" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
<p cy-id="standardBottomDisplayTitle" class="truncate" :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" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
<p cy-id="detailBottomDisplayTitle" class="truncate" :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>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -5,24 +5,26 @@
|
||||
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div v-if="show" class="w-full h-full">
|
||||
<div v-if="show" class="w-full rounded-lg bg-bg box-shadow-md relative" style="max-height: 80vh">
|
||||
<div v-if="bookmarks.length" class="h-full max-h-[calc(80vh-60px)] w-full relative overflow-y-auto overflow-x-hidden">
|
||||
<template v-for="bookmark in bookmarks">
|
||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" />
|
||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" :playback-rate="playbackRate" @click="clickBookmark" @delete="deleteBookmark" />
|
||||
</template>
|
||||
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
|
||||
</div>
|
||||
<div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" />
|
||||
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
|
||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
||||
</div>
|
||||
<div v-else class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="canCreateBookmark && !hideCreate" class="w-full border-t border-white/10">
|
||||
<form @submit.prevent="submitCreateBookmark">
|
||||
<div class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
||||
<div class="w-16 max-w-16 text-center">
|
||||
<p class="text-sm font-mono text-gray-400">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
{{ this.$secondsToTimestamp(currentTime / playbackRate) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full h-10" />
|
||||
</div>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">add</span></ui-btn>
|
||||
</div>
|
||||
@@ -45,6 +47,7 @@ export default {
|
||||
default: 0
|
||||
},
|
||||
libraryItemId: String,
|
||||
playbackRate: Number,
|
||||
hideCreate: Boolean
|
||||
},
|
||||
data() {
|
||||
@@ -57,6 +60,7 @@ export default {
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.selectedBookmark = null
|
||||
this.showBookmarkTitleInput = false
|
||||
this.newBookmarkTitle = ''
|
||||
}
|
||||
@@ -72,7 +76,7 @@ export default {
|
||||
}
|
||||
},
|
||||
canCreateBookmark() {
|
||||
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
|
||||
return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
@@ -102,19 +106,6 @@ export default {
|
||||
clickBookmark(bm) {
|
||||
this.$emit('select', bm)
|
||||
},
|
||||
submitUpdateBookmark(updatedBookmark) {
|
||||
var bookmark = { ...updatedBookmark }
|
||||
this.$axios
|
||||
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastBookmarkUpdateSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
console.error(error)
|
||||
})
|
||||
this.show = false
|
||||
},
|
||||
submitCreateBookmark() {
|
||||
if (!this.newBookmarkTitle) {
|
||||
this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="flex items-center px-4 py-4 justify-start relative bg-primary hover:bg-opacity-25" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-primary/10" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="w-16 max-w-16 text-center">
|
||||
<p class="text-sm font-mono text-gray-400">
|
||||
{{ this.$secondsToTimestamp(bookmark.time) }}
|
||||
{{ this.$secondsToTimestamp(bookmark.time / playbackRate) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-grow overflow-hidden px-2">
|
||||
@@ -10,7 +10,7 @@
|
||||
<form @submit.prevent="submitUpdate">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-grow pr-2">
|
||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full h-10" />
|
||||
</div>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">forward</span></ui-btn>
|
||||
<div class="pl-2 flex items-center">
|
||||
@@ -35,7 +35,8 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
highlight: Boolean
|
||||
highlight: Boolean,
|
||||
playbackRate: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -83,11 +84,19 @@ export default {
|
||||
if (this.newBookmarkTitle === this.bookmark.title) {
|
||||
return this.cancelEditing()
|
||||
}
|
||||
var bookmark = { ...this.bookmark }
|
||||
const bookmark = { ...this.bookmark }
|
||||
bookmark.title = this.newBookmarkTitle
|
||||
this.$emit('update', bookmark)
|
||||
|
||||
this.$axios
|
||||
.$patch(`/api/me/item/${bookmark.libraryItemId}/bookmark`, bookmark)
|
||||
.then(() => {
|
||||
this.isEditing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
console.error(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -138,7 +138,6 @@ export default {
|
||||
.$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds })
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Books removed from collection`, updatedCollection)
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -152,7 +151,6 @@ export default {
|
||||
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Book removed from collection`, updatedCollection)
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -167,12 +165,11 @@ export default {
|
||||
this.processing = true
|
||||
|
||||
if (this.showBatchCollectionModal) {
|
||||
// BATCH Remove books
|
||||
// BATCH Add books
|
||||
this.$axios
|
||||
.$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Books added to collection`, updatedCollection)
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -187,7 +184,6 @@ export default {
|
||||
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Book added to collection`, updatedCollection)
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -214,7 +210,6 @@ export default {
|
||||
.$post('/api/collections', newCollection)
|
||||
.then((data) => {
|
||||
console.log('New Collection Created', data)
|
||||
this.$toast.success(`Collection "${data.name}" created`)
|
||||
this.processing = false
|
||||
this.newCollectionName = ''
|
||||
})
|
||||
|
||||
@@ -113,6 +113,10 @@ export default {
|
||||
return false
|
||||
})
|
||||
console.log('updateResult', updateResult)
|
||||
} else if (!lastEpisodeCheck) {
|
||||
this.$toast.error(this.$strings.ToastDateTimeInvalidOrIncomplete)
|
||||
this.checkingNewEpisodes = false
|
||||
return false
|
||||
}
|
||||
|
||||
this.$axios
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
||||
</div>
|
||||
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoScan" v-model="cronExpression" @input="updatedCron" />
|
||||
<div v-else>
|
||||
<p class="text-yellow-400 text-base">{{ $strings.MessageScheduleLibraryScanNote }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -130,7 +130,6 @@ export default {
|
||||
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
|
||||
.then((updatedPlaylist) => {
|
||||
console.log(`Items removed from playlist`, updatedPlaylist)
|
||||
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -148,7 +147,6 @@ export default {
|
||||
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
|
||||
.then((updatedPlaylist) => {
|
||||
console.log(`Items added to playlist`, updatedPlaylist)
|
||||
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -174,7 +172,6 @@ export default {
|
||||
.$post('/api/playlists', newPlaylist)
|
||||
.then((data) => {
|
||||
console.log('New playlist created', data)
|
||||
this.$toast.success(this.$strings.ToastPlaylistCreateSuccess + ': ' + data.name)
|
||||
this.processing = false
|
||||
this.newPlaylistName = ''
|
||||
})
|
||||
|
||||
@@ -170,6 +170,12 @@ export default {
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
libraryItemUpdated(libraryItem) {
|
||||
const episode = libraryItem.media.episodes.find((e) => e.id === this.selectedEpisodeId)
|
||||
if (episode) {
|
||||
this.episodeItem = episode
|
||||
}
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
|
||||
this.goNextEpisode()
|
||||
@@ -178,9 +184,15 @@ export default {
|
||||
}
|
||||
},
|
||||
registerListeners() {
|
||||
if (this.libraryItem) {
|
||||
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
}
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
},
|
||||
unregisterListeners() {
|
||||
if (this.libraryItem) {
|
||||
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
}
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<ui-dropdown v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" :items="episodeTypes" small />
|
||||
</div>
|
||||
<div class="w-2/5 p-1">
|
||||
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" :label="$strings.LabelPubDate" />
|
||||
<ui-text-input-with-label v-model="pubDateInput" ref="pubdate" type="datetime-local" :label="$strings.LabelPubDate" @input="updatePubDate" />
|
||||
</div>
|
||||
<div class="w-full p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.title" :label="$strings.LabelTitle" />
|
||||
@@ -145,11 +145,18 @@ export default {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check pubdate is valid if it is being updated. Cannot be set to null in the web client
|
||||
if (this.newEpisode.pubDate === null && this.$refs.pubdate?.$refs?.input?.isInvalidDate) {
|
||||
this.$toast.error(this.$strings.ToastDateTimeInvalidOrIncomplete)
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedDetails = this.getUpdatePayload()
|
||||
if (!Object.keys(updatedDetails).length) {
|
||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||
return false
|
||||
}
|
||||
|
||||
return this.updateDetails(updatedDetails)
|
||||
},
|
||||
async updateDetails(updatedDetails) {
|
||||
@@ -163,13 +170,10 @@ export default {
|
||||
|
||||
this.isProcessing = false
|
||||
if (updateResult) {
|
||||
if (updateResult) {
|
||||
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="absolute -top-10 lg:top-0 right-0 lg:right-2 flex items-center h-full">
|
||||
<controls-playback-speed-control v-model="playbackRate" @input="setPlaybackRate" @change="playbackRateChanged" class="mx-2 block" />
|
||||
|
||||
<ui-tooltip direction="left" :text="$strings.LabelVolume">
|
||||
<ui-tooltip direction="bottom" :text="$strings.LabelVolume">
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden sm:block" />
|
||||
</ui-tooltip>
|
||||
|
||||
|
||||
@@ -7,14 +7,6 @@
|
||||
|
||||
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
|
||||
|
||||
<div v-if="formFields.length" class="mb-6 space-y-2">
|
||||
<template v-for="field in formFields">
|
||||
<ui-select-input v-if="field.type === 'select'" :key="field.name" v-model="formData[field.name]" :label="field.label" :items="field.options" class="px-1" />
|
||||
<ui-textarea-with-label v-else-if="field.type === 'textarea'" :key="field.name" v-model="formData[field.name]" :label="field.label" class="px-1" />
|
||||
<ui-text-input-with-label v-else-if="field.type === 'text'" :key="field.name" v-model="formData[field.name]" :label="field.label" class="px-1" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
@@ -33,8 +25,7 @@ export default {
|
||||
return {
|
||||
el: null,
|
||||
content: null,
|
||||
checkboxValue: false,
|
||||
formData: {}
|
||||
checkboxValue: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -70,9 +61,6 @@ export default {
|
||||
persistent() {
|
||||
return !!this.confirmPromptOptions.persistent
|
||||
},
|
||||
formFields() {
|
||||
return this.confirmPromptOptions.formFields || []
|
||||
},
|
||||
checkboxLabel() {
|
||||
return this.confirmPromptOptions.checkboxLabel
|
||||
},
|
||||
@@ -112,31 +100,11 @@ export default {
|
||||
this.show = false
|
||||
},
|
||||
confirm() {
|
||||
if (this.callback) {
|
||||
if (this.formFields.length) {
|
||||
const formFieldData = {
|
||||
...this.formData
|
||||
}
|
||||
|
||||
this.callback(true, formFieldData)
|
||||
} else {
|
||||
this.callback(true, this.checkboxValue)
|
||||
}
|
||||
}
|
||||
if (this.callback) this.callback(true, this.checkboxValue)
|
||||
this.show = false
|
||||
},
|
||||
setShow() {
|
||||
this.checkboxValue = this.checkboxDefaultValue
|
||||
|
||||
if (this.formFields.length) {
|
||||
this.formFields.forEach((field) => {
|
||||
let defaultValue = ''
|
||||
if (field.type === 'boolean') defaultValue = false
|
||||
if (field.type === 'select') defaultValue = field.options[0].value
|
||||
this.$set(this.formData, field.name, defaultValue)
|
||||
})
|
||||
}
|
||||
|
||||
this.$eventBus.$emit('showing-prompt', true)
|
||||
document.body.appendChild(this.el)
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div id="heatmap" class="w-full">
|
||||
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
|
||||
<p class="mb-2 px-1 text-sm text-gray-200">{{ $getString('MessageListeningSessionsInTheLastYear', [Object.values(daysListening).length]) }}</p>
|
||||
<p class="mb-2 px-1 text-sm text-gray-200">{{ $getString('MessageDaysListenedInTheLastYear', [daysListenedInTheLastYear]) }}</p>
|
||||
<div class="border border-white border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
|
||||
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
|
||||
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
|
||||
@@ -37,6 +37,7 @@ export default {
|
||||
innerHeight: 13 * 7,
|
||||
blockWidth: 13,
|
||||
data: [],
|
||||
daysListenedInTheLastYear: 0,
|
||||
monthLabels: [],
|
||||
tooltipEl: null,
|
||||
tooltipTextEl: null,
|
||||
@@ -62,9 +63,6 @@ export default {
|
||||
dayOfWeekToday() {
|
||||
return new Date().getDay()
|
||||
},
|
||||
firstWeekStart() {
|
||||
return this.$addDaysToToday(-this.daysToShow)
|
||||
},
|
||||
dayLabels() {
|
||||
return [
|
||||
{
|
||||
@@ -193,46 +191,59 @@ export default {
|
||||
buildData() {
|
||||
this.data = []
|
||||
|
||||
var maxValue = 0
|
||||
var minValue = 0
|
||||
Object.values(this.daysListening).forEach((val) => {
|
||||
if (val > maxValue) maxValue = val
|
||||
if (!minValue || val < minValue) minValue = val
|
||||
})
|
||||
let maxValue = 0
|
||||
let minValue = 0
|
||||
|
||||
const dates = []
|
||||
|
||||
const numDaysInTheLastYear = 52 * 7 + this.dayOfWeekToday
|
||||
const firstDay = this.$addDaysToToday(-numDaysInTheLastYear)
|
||||
for (let i = 0; i < numDaysInTheLastYear + 1; i++) {
|
||||
const date = i === 0 ? firstDay : this.$addDaysToDate(firstDay, i)
|
||||
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
|
||||
|
||||
if (this.daysListening[dateString] > 0) {
|
||||
this.daysListenedInTheLastYear++
|
||||
}
|
||||
|
||||
const visibleDayIndex = i - (numDaysInTheLastYear - this.daysToShow)
|
||||
if (visibleDayIndex < 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const dateObj = {
|
||||
col: Math.floor(visibleDayIndex / 7),
|
||||
row: visibleDayIndex % 7,
|
||||
date,
|
||||
dateString,
|
||||
datePretty: this.$formatJsDate(date, 'MMM d, yyyy'),
|
||||
monthString: this.$formatJsDate(date, 'MMM'),
|
||||
dayOfMonth: Number(dateString.split('-').pop()),
|
||||
yearString: dateString.split('-').shift(),
|
||||
value: this.daysListening[dateString] || 0
|
||||
}
|
||||
dates.push(dateObj)
|
||||
|
||||
if (dateObj.value > 0) {
|
||||
if (dateObj.value > maxValue) maxValue = dateObj.value
|
||||
if (!minValue || dateObj.value < minValue) minValue = dateObj.value
|
||||
}
|
||||
}
|
||||
const range = maxValue - minValue + 0.01
|
||||
|
||||
for (let i = 0; i < this.daysToShow + 1; i++) {
|
||||
const col = Math.floor(i / 7)
|
||||
const row = i % 7
|
||||
|
||||
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
|
||||
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
|
||||
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
|
||||
const monthString = this.$formatJsDate(date, 'MMM')
|
||||
const value = this.daysListening[dateString] || 0
|
||||
const x = col * 13
|
||||
const y = row * 13
|
||||
|
||||
var bgColor = this.bgColors[0]
|
||||
var outlineColor = this.outlineColors[0]
|
||||
if (value) {
|
||||
for (const dateObj of dates) {
|
||||
let bgColor = this.bgColors[0]
|
||||
let outlineColor = this.outlineColors[0]
|
||||
if (dateObj.value) {
|
||||
outlineColor = this.outlineColors[1]
|
||||
var percentOfAvg = (value - minValue) / range
|
||||
var bgIndex = Math.floor(percentOfAvg * 4) + 1
|
||||
const percentOfAvg = (dateObj.value - minValue) / range
|
||||
const bgIndex = Math.floor(percentOfAvg * 4) + 1
|
||||
bgColor = this.bgColors[bgIndex] || 'red'
|
||||
}
|
||||
|
||||
this.data.push({
|
||||
date,
|
||||
dateString,
|
||||
datePretty,
|
||||
monthString,
|
||||
dayOfMonth: Number(dateString.split('-').pop()),
|
||||
yearString: dateString.split('-').shift(),
|
||||
value,
|
||||
col,
|
||||
row,
|
||||
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||
...dateObj,
|
||||
style: `transform:translate(${dateObj.col * 13}px,${dateObj.row * 13}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -260,6 +271,7 @@ export default {
|
||||
const heatmapEl = document.getElementById('heatmap')
|
||||
this.contentWidth = heatmapEl.clientWidth
|
||||
this.maxInnerWidth = this.contentWidth - 52
|
||||
this.daysListenedInTheLastYear = 0
|
||||
this.buildData()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -218,7 +218,6 @@ export default {
|
||||
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
|
||||
} else {
|
||||
console.log(`Item removed from playlist`, updatedPlaylist)
|
||||
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -96,7 +96,7 @@ export default {
|
||||
return this.episode?.title || ''
|
||||
},
|
||||
episodeSubtitle() {
|
||||
return this.episode?.subtitle || ''
|
||||
return this.episode?.subtitle || this.episode?.description || ''
|
||||
},
|
||||
episodeType() {
|
||||
return this.episode?.episodeType || ''
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
|
||||
</form>
|
||||
</div>
|
||||
<div class="relative min-h-[176px]">
|
||||
<div class="relative min-h-44">
|
||||
<template v-for="episode in totalEpisodes">
|
||||
<div :key="episode" :id="`episode-${episode - 1}`" class="w-full h-44 px-2 py-3 overflow-hidden relative border-b border-white/10">
|
||||
<!-- episode is mounted here -->
|
||||
@@ -39,7 +39,7 @@
|
||||
<div v-if="isSearching" class="w-full h-full absolute inset-0 flex justify-center py-12" :class="{ 'bg-black/50': totalEpisodes }">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
<div v-else-if="!totalEpisodes" class="h-44 flex items-center justify-center">
|
||||
<div v-else-if="!totalEpisodes" id="no-episodes" class="h-44 flex items-center justify-center">
|
||||
<p class="text-lg">{{ $strings.MessageNoEpisodes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +80,8 @@ export default {
|
||||
episodeComponentRefs: {},
|
||||
windowHeight: 0,
|
||||
episodesTableOffsetTop: 0,
|
||||
episodeRowHeight: 176
|
||||
episodeRowHeight: 44 * 4, // h-44,
|
||||
currScrollTop: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -484,9 +485,8 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
scroll(evt) {
|
||||
if (!evt?.target?.scrollTop) return
|
||||
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
|
||||
handleScroll() {
|
||||
const scrollTop = this.currScrollTop
|
||||
let firstEpisodeIndex = Math.floor(scrollTop / this.episodeRowHeight)
|
||||
let lastEpisodeIndex = Math.ceil((scrollTop + this.windowHeight) / this.episodeRowHeight)
|
||||
lastEpisodeIndex = Math.min(this.totalEpisodes - 1, lastEpisodeIndex)
|
||||
@@ -501,6 +501,12 @@ export default {
|
||||
})
|
||||
this.mountEpisodes(firstEpisodeIndex, lastEpisodeIndex + 1)
|
||||
},
|
||||
scroll(evt) {
|
||||
if (!evt?.target?.scrollTop) return
|
||||
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
|
||||
this.currScrollTop = scrollTop
|
||||
this.handleScroll()
|
||||
},
|
||||
initListeners() {
|
||||
const itemPageWrapper = document.getElementById('item-page-wrapper')
|
||||
if (itemPageWrapper) {
|
||||
@@ -532,11 +538,24 @@ export default {
|
||||
this.episodesTableOffsetTop = (lazyEpisodesTableEl?.offsetTop || 0) + 64
|
||||
|
||||
this.windowHeight = window.innerHeight
|
||||
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.mountEpisodes(0, Math.min(this.episodesPerPage, this.totalEpisodes))
|
||||
this.recalcEpisodeRowHeight()
|
||||
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
|
||||
// Maybe update currScrollTop if items were removed
|
||||
const itemPageWrapper = document.getElementById('item-page-wrapper')
|
||||
const { scrollHeight, clientHeight } = itemPageWrapper
|
||||
const maxScrollTop = scrollHeight - clientHeight
|
||||
this.currScrollTop = Math.min(this.currScrollTop, maxScrollTop)
|
||||
this.handleScroll()
|
||||
})
|
||||
},
|
||||
recalcEpisodeRowHeight() {
|
||||
const episodeRowEl = document.getElementById('episode-0') || document.getElementById('no-episodes')
|
||||
if (episodeRowEl) {
|
||||
const height = getComputedStyle(episodeRowEl).height
|
||||
this.episodeRowHeight = parseInt(height) || this.episodeRowHeight
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
</div>
|
||||
</template>
|
||||
<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)">
|
||||
<span v-if="item.icon" class="material-symbols text-base mr-1">{{ item.icon }}</span>
|
||||
<p class="text-left">{{ item.text }}</p>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative">
|
||||
<input
|
||||
:id="inputId"
|
||||
:name="inputName"
|
||||
ref="input"
|
||||
v-model="inputValue"
|
||||
:type="actualType"
|
||||
:step="step"
|
||||
:min="min"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
dir="auto"
|
||||
class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full"
|
||||
:class="classList"
|
||||
@keyup="keyup"
|
||||
@change="change"
|
||||
@focus="focused"
|
||||
@blur="blurred"
|
||||
/>
|
||||
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="rounded bg-primary text-gray-200 focus:bg-bg focus:outline-none border h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||
</div>
|
||||
@@ -65,7 +47,8 @@ export default {
|
||||
showPassword: false,
|
||||
isHovering: false,
|
||||
isFocused: false,
|
||||
hasCopied: false
|
||||
hasCopied: false,
|
||||
isInvalidDate: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -84,6 +67,10 @@ export default {
|
||||
if (this.noSpinner) _list.push('no-spinner')
|
||||
if (this.textCenter) _list.push('text-center')
|
||||
if (this.customInputClass) _list.push(this.customInputClass)
|
||||
|
||||
if (this.isInvalidDate) _list.push('border-error')
|
||||
else _list.push('focus:border-gray-300 border-gray-600')
|
||||
|
||||
return _list.join(' ')
|
||||
},
|
||||
actualType() {
|
||||
@@ -118,6 +105,14 @@ export default {
|
||||
},
|
||||
keyup(e) {
|
||||
this.$emit('keyup', e)
|
||||
|
||||
if (this.type === 'datetime-local') {
|
||||
if (e.target.validity?.badInput) {
|
||||
this.isInvalidDate = true
|
||||
} else {
|
||||
this.isInvalidDate = false
|
||||
}
|
||||
}
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<slot>
|
||||
<label :for="identifier" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }"
|
||||
>{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label
|
||||
>
|
||||
<label :for="identifier" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||
{{ label }}
|
||||
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</label>
|
||||
</slot>
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
||||
</div>
|
||||
@@ -57,4 +58,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -249,11 +249,33 @@ export default {
|
||||
}
|
||||
}
|
||||
return target
|
||||
},
|
||||
enableBreakParagraphOnReturn() {
|
||||
// Trix works with divs by default, we want paragraphs instead
|
||||
Trix.config.blockAttributes.default.tagName = 'p'
|
||||
// Enable break paragraph on Enter (Shift + Enter will still create a line break)
|
||||
Trix.config.blockAttributes.default.breakOnReturn = true
|
||||
|
||||
// Hack to fix buggy paragraph breaks
|
||||
// Copied from https://github.com/basecamp/trix/issues/680#issuecomment-735742942
|
||||
Trix.Block.prototype.breaksOnReturn = function () {
|
||||
const attr = this.getLastAttribute()
|
||||
const config = Trix.getBlockConfig(attr ? attr : 'default')
|
||||
return config ? config.breakOnReturn : false
|
||||
}
|
||||
Trix.LineBreakInsertion.prototype.shouldInsertBlockBreak = function () {
|
||||
if (this.block.hasAttributes() && this.block.isListItem() && !this.block.isEmpty()) {
|
||||
return this.startLocation.offset > 0
|
||||
} else {
|
||||
return !this.shouldBreakFormattedBlock() ? this.breaksOnReturn : false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
/** Override editor configuration */
|
||||
this.overrideConfig(this.config)
|
||||
this.enableBreakParagraphOnReturn()
|
||||
/** Check if editor read-only mode is required */
|
||||
this.decorateDisabledEditor(this.disabledEditor)
|
||||
this.$nextTick(() => {
|
||||
@@ -283,4 +305,4 @@ export default {
|
||||
.trix_container .trix-content {
|
||||
background-color: white;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
188
client/cypress/tests/utils/ElapsedPrettyExtended.cy.js
Normal file
188
client/cypress/tests/utils/ElapsedPrettyExtended.cy.js
Normal file
@@ -0,0 +1,188 @@
|
||||
import Vue from 'vue'
|
||||
import '@/plugins/utils'
|
||||
|
||||
// This is the actual function that is being tested
|
||||
const elapsedPrettyExtended = Vue.prototype.$elapsedPrettyExtended
|
||||
|
||||
// Helper function to convert days, hours, minutes, seconds to total seconds
|
||||
function DHMStoSeconds(days, hours, minutes, seconds) {
|
||||
return seconds + minutes * 60 + hours * 3600 + days * 86400
|
||||
}
|
||||
|
||||
describe('$elapsedPrettyExtended', () => {
|
||||
describe('function is on the Vue Prototype', () => {
|
||||
it('exists as a function on Vue.prototype', () => {
|
||||
expect(Vue.prototype.$elapsedPrettyExtended).to.exist
|
||||
expect(Vue.prototype.$elapsedPrettyExtended).to.be.a('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('param default values', () => {
|
||||
const testSeconds = DHMStoSeconds(0, 25, 1, 5) // 25h 1m 5s = 90065 seconds
|
||||
|
||||
it('uses useDays=true showSeconds=true by default', () => {
|
||||
expect(elapsedPrettyExtended(testSeconds)).to.equal('1d 1h 1m 5s')
|
||||
})
|
||||
|
||||
it('only useDays=false overrides useDays but keeps showSeconds=true', () => {
|
||||
expect(elapsedPrettyExtended(testSeconds, false)).to.equal('25h 1m 5s')
|
||||
})
|
||||
|
||||
it('explicit useDays=false showSeconds=false overrides both', () => {
|
||||
expect(elapsedPrettyExtended(testSeconds, false, false)).to.equal('25h 1m')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDays=false showSeconds=true', () => {
|
||||
const useDaysFalse = false
|
||||
const showSecondsTrue = true
|
||||
const testCases = [
|
||||
[[0, 0, 0, 0], '', '0s -> ""'],
|
||||
[[0, 1, 0, 1], '1h 1s', '1h 1s -> 1h 1s'],
|
||||
[[0, 25, 0, 1], '25h 1s', '25h 1s -> 25h 1s']
|
||||
]
|
||||
|
||||
testCases.forEach(([dhms, expected, description]) => {
|
||||
it(description, () => {
|
||||
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysFalse, showSecondsTrue)).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDays=true showSeconds=true', () => {
|
||||
const useDaysTrue = true
|
||||
const showSecondsTrue = true
|
||||
const testCases = [
|
||||
[[0, 0, 0, 0], '', '0s -> ""'],
|
||||
[[0, 1, 0, 1], '1h 1s', '1h 1s -> 1h 1s'],
|
||||
[[0, 25, 0, 1], '1d 1h 1s', '25h 1s -> 1d 1h 1s']
|
||||
]
|
||||
|
||||
testCases.forEach(([dhms, expected, description]) => {
|
||||
it(description, () => {
|
||||
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsTrue)).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDays=true showSeconds=false', () => {
|
||||
const useDaysTrue = true
|
||||
const showSecondsFalse = false
|
||||
const testCases = [
|
||||
[[0, 0, 0, 0], '', '0s -> ""'],
|
||||
[[0, 1, 0, 0], '1h', '1h -> 1h'],
|
||||
[[0, 1, 0, 1], '1h', '1h 1s -> 1h'],
|
||||
[[0, 1, 1, 0], '1h 1m', '1h 1m -> 1h 1m'],
|
||||
[[0, 25, 0, 0], '1d 1h', '25h -> 1d 1h'],
|
||||
[[0, 25, 0, 1], '1d 1h', '25h 1s -> 1d 1h'],
|
||||
[[2, 0, 0, 0], '2d', '2d -> 2d']
|
||||
]
|
||||
|
||||
testCases.forEach(([dhms, expected, description]) => {
|
||||
it(description, () => {
|
||||
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsFalse)).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rounding useDays=true showSeconds=true', () => {
|
||||
const useDaysTrue = true
|
||||
const showSecondsTrue = true
|
||||
const testCases = [
|
||||
// Seconds rounding
|
||||
[[0, 0, 0, 1], '1s', '1s -> 1s'],
|
||||
[[0, 0, 0, 29.9], '30s', '29.9s -> 30s'],
|
||||
[[0, 0, 0, 30], '30s', '30s -> 30s'],
|
||||
[[0, 0, 0, 30.1], '30s', '30.1s -> 30s'],
|
||||
[[0, 0, 0, 59.4], '59s', '59.4s -> 59s'],
|
||||
[[0, 0, 0, 59.5], '1m', '59.5s -> 1m'],
|
||||
|
||||
// Minutes rounding
|
||||
[[0, 0, 59, 29], '59m 29s', '59m 29s -> 59m 29s'],
|
||||
[[0, 0, 59, 30], '59m 30s', '59m 30s -> 59m 30s'],
|
||||
[[0, 0, 59, 59.5], '1h', '59m 59.5s -> 1h'],
|
||||
|
||||
// Hours rounding
|
||||
[[0, 23, 59, 29], '23h 59m 29s', '23h 59m 29s -> 23h 59m 29s'],
|
||||
[[0, 23, 59, 30], '23h 59m 30s', '23h 59m 30s -> 23h 59m 30s'],
|
||||
[[0, 23, 59, 59.5], '1d', '23h 59m 59.5s -> 1d'],
|
||||
|
||||
// The actual bug case
|
||||
[[44, 23, 59, 30], '44d 23h 59m 30s', '44d 23h 59m 30s -> 44d 23h 59m 30s']
|
||||
]
|
||||
|
||||
testCases.forEach(([dhms, expected, description]) => {
|
||||
it(description, () => {
|
||||
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsTrue)).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rounding useDays=true showSeconds=false', () => {
|
||||
const useDaysTrue = true
|
||||
const showSecondsFalse = false
|
||||
const testCases = [
|
||||
// Seconds rounding - these cases changed behavior from original
|
||||
[[0, 0, 0, 1], '', '1s -> ""'],
|
||||
[[0, 0, 0, 29.9], '', '29.9s -> ""'],
|
||||
[[0, 0, 0, 30], '', '30s -> ""'],
|
||||
[[0, 0, 0, 30.1], '', '30.1s -> ""'],
|
||||
[[0, 0, 0, 59.4], '', '59.4s -> ""'],
|
||||
[[0, 0, 0, 59.5], '1m', '59.5s -> 1m'],
|
||||
// This is unexpected behavior, but it's consistent with the original behavior
|
||||
// We preserved the test case, to document the current behavior
|
||||
// - with showSeconds=false,
|
||||
// one might expect: 1m 29.5s --round(1.4901m)-> 1m
|
||||
// actual implementation: 1h 29.5s --roundSeconds-> 1h 30s --roundMinutes-> 2m
|
||||
// So because of the separate rounding of seconds, and then minutes, it returns 2m
|
||||
[[0, 0, 1, 29.5], '2m', '1m 29.5s -> 2m'],
|
||||
|
||||
// Minutes carry - actual bug fixes below
|
||||
[[0, 0, 59, 29], '59m', '59m 29s -> 59m'],
|
||||
[[0, 0, 59, 30], '1h', '59m 30s -> 1h'], // This was an actual bug, used to return 60m
|
||||
[[0, 0, 59, 59.5], '1h', '59m 59.5s -> 1h'],
|
||||
|
||||
// Hours carry
|
||||
[[0, 23, 59, 29], '23h 59m', '23h 59m 29s -> 23h 59m'],
|
||||
[[0, 23, 59, 30], '1d', '23h 59m 30s -> 1d'], // This was an actual bug, used to return 23h 60m
|
||||
[[0, 23, 59, 59.5], '1d', '23h 59m 59.5s -> 1d'],
|
||||
|
||||
// The actual bug case
|
||||
[[44, 23, 59, 30], '45d', '44d 23h 59m 30s -> 45d'] // This was an actual bug, used to return 44d 23h 60m
|
||||
]
|
||||
|
||||
testCases.forEach(([dhms, expected, description]) => {
|
||||
it(description, () => {
|
||||
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsFalse)).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty values', () => {
|
||||
const paramCombos = [
|
||||
// useDays, showSeconds, description
|
||||
[true, true, 'with days and seconds'],
|
||||
[true, false, 'with days, no seconds'],
|
||||
[false, true, 'no days, with seconds'],
|
||||
[false, false, 'no days, no seconds']
|
||||
]
|
||||
|
||||
const emptyInputs = [
|
||||
// input, description
|
||||
[null, 'null input'],
|
||||
[undefined, 'undefined input'],
|
||||
[0, 'zero'],
|
||||
[0.49, 'rounds to zero'] // Just under rounding threshold
|
||||
]
|
||||
|
||||
paramCombos.forEach(([useDays, showSeconds, paramDesc]) => {
|
||||
describe(paramDesc, () => {
|
||||
emptyInputs.forEach(([input, desc]) => {
|
||||
it(desc, () => {
|
||||
expect(elapsedPrettyExtended(input, useDays, showSeconds)).to.equal('')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.6",
|
||||
"version": "2.17.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.6",
|
||||
"version": "2.17.7",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.6",
|
||||
"version": "2.17.7",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="`Plugin: ${pluginManifest.name}`">
|
||||
<template #header-prefix>
|
||||
<nuxt-link to="/config/plugins" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center mr-2">
|
||||
<span class="material-symbols text-2xl">arrow_back</span>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
<template #header-items>
|
||||
<ui-tooltip v-if="pluginManifest.documentationUrl" :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a :href="pluginManifest.documentationUrl" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<a v-if="repositoryUrl" :href="repositoryUrl" target="_blank" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-4 py-1 text-sm inline-flex items-center space-x-2"><span>Source</span><span class="material-symbols text-base">open_in_new</span> </a>
|
||||
</template>
|
||||
|
||||
<div class="py-4">
|
||||
<p v-if="configDescription" class="mb-4">{{ configDescription }}</p>
|
||||
|
||||
<form v-if="configFormFields.length" @submit.prevent="handleFormSubmit">
|
||||
<template v-for="field in configFormFields">
|
||||
<div :key="field.name" class="flex items-center mb-4">
|
||||
<label :for="field.name" class="w-1/3 text-gray-200">{{ field.label }}</label>
|
||||
<div class="w-2/3">
|
||||
<input :id="field.name" :type="field.type" :placeholder="field.placeholder" class="w-full bg-bg border border-white border-opacity-20 rounded-md p-2 text-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<ui-btn class="bg-primary bg-opacity-70 text-white rounded-md p-2" :loading="processing" type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, redirect, params, app }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
const pluginConfigData = await app.$axios.$get(`/api/plugins/${params.id}/config`).catch((error) => {
|
||||
console.error('Failed to get plugin config', error)
|
||||
return null
|
||||
})
|
||||
if (!pluginConfigData) {
|
||||
redirect('/config/plugins')
|
||||
}
|
||||
const pluginManifest = store.state.plugins.find((plugin) => plugin.id === params.id)
|
||||
if (!pluginManifest) {
|
||||
redirect('/config/plugins')
|
||||
}
|
||||
return {
|
||||
pluginManifest,
|
||||
pluginConfig: pluginConfigData.config
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
pluginManifestConfig() {
|
||||
return this.pluginManifest.config
|
||||
},
|
||||
pluginLocalization() {
|
||||
return this.pluginManifest.localization || {}
|
||||
},
|
||||
localizedStrings() {
|
||||
const localeKey = this.$languageCodes.current
|
||||
if (!localeKey) return {}
|
||||
return this.pluginLocalization[localeKey] || {}
|
||||
},
|
||||
configDescription() {
|
||||
if (this.pluginManifestConfig.descriptionKey && this.localizedStrings[this.pluginManifestConfig.descriptionKey]) {
|
||||
return this.localizedStrings[this.pluginManifestConfig.descriptionKey]
|
||||
}
|
||||
|
||||
return this.pluginManifestConfig.description
|
||||
},
|
||||
configFormFields() {
|
||||
return this.pluginManifestConfig.formFields || []
|
||||
},
|
||||
repositoryUrl() {
|
||||
return this.pluginManifest.repositoryUrl
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getFormData() {
|
||||
const formData = {}
|
||||
this.configFormFields.forEach((field) => {
|
||||
if (field.type === 'checkbox') {
|
||||
formData[field.name] = document.getElementById(field.name).checked
|
||||
} else {
|
||||
formData[field.name] = document.getElementById(field.name).value
|
||||
}
|
||||
})
|
||||
return formData
|
||||
},
|
||||
handleFormSubmit() {
|
||||
const formData = this.getFormData()
|
||||
console.log('Form data', formData)
|
||||
|
||||
const payload = {
|
||||
config: formData
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/plugins/${this.pluginManifest.id}/config`, payload)
|
||||
.then(() => {
|
||||
console.log('Plugin configuration saved')
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response?.data || 'Error saving plugin configuration'
|
||||
console.error('Failed to save config:', error)
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
initializeForm() {
|
||||
if (!this.pluginConfig) return
|
||||
this.configFormFields.forEach((field) => {
|
||||
if (this.pluginConfig[field.name] === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const value = this.pluginConfig[field.name]
|
||||
if (field.type === 'checkbox') {
|
||||
document.getElementById(field.name).checked = value
|
||||
} else {
|
||||
document.getElementById(field.name).value = value
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('Plugin manifest', this.pluginManifest, 'config', this.pluginConfig)
|
||||
this.initializeForm()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,48 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="'Plugins'">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
<div class="py-4">
|
||||
<p v-if="!plugins.length" class="text-gray-300">No plugins installed</p>
|
||||
|
||||
<template v-for="plugin in plugins">
|
||||
<nuxt-link :key="plugin.id" :to="`/config/plugins/${plugin.id}`" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
|
||||
<div class="flex items-center space-x-4">
|
||||
<p class="text-lg">{{ plugin.name }}</p>
|
||||
<p class="text-sm text-gray-300">{{ plugin.description }}</p>
|
||||
<div class="flex-grow" />
|
||||
<span class="material-symbols">arrow_forward</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
plugins() {
|
||||
return this.$store.state.plugins
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -141,7 +141,7 @@
|
||||
</div>
|
||||
|
||||
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :playback-rate="1" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -364,9 +364,6 @@ export default {
|
||||
showCollectionsButton() {
|
||||
return this.isBook && this.userCanUpdate
|
||||
},
|
||||
pluginExtensions() {
|
||||
return this.$store.getters['getPluginExtensions']('item.detail.actions')
|
||||
},
|
||||
contextMenuItems() {
|
||||
const items = []
|
||||
|
||||
@@ -432,18 +429,6 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
if (this.pluginExtensions.length) {
|
||||
this.pluginExtensions.forEach((plugin) => {
|
||||
plugin.extensions.forEach((pext) => {
|
||||
items.push({
|
||||
text: pext.label,
|
||||
action: `plugin-${plugin.id}-action-${pext.name}`,
|
||||
icon: 'extension'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
},
|
||||
@@ -778,54 +763,7 @@ export default {
|
||||
} else if (action === 'share') {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setShareModal', this.mediaItemShare)
|
||||
} else if (action.startsWith('plugin-')) {
|
||||
const actionStrSplit = action.replace('plugin-', '').split('-action-')
|
||||
const pluginId = actionStrSplit[0]
|
||||
const pluginAction = actionStrSplit[1]
|
||||
this.onPluginAction(pluginId, pluginAction)
|
||||
}
|
||||
},
|
||||
onPluginAction(pluginId, pluginAction) {
|
||||
const plugin = this.pluginExtensions.find((p) => p.id === pluginId)
|
||||
const extension = plugin.extensions.find((ext) => ext.name === pluginAction)
|
||||
|
||||
if (extension.prompt) {
|
||||
const payload = {
|
||||
message: extension.prompt.message,
|
||||
formFields: extension.prompt.formFields || [],
|
||||
yesButtonText: this.$strings.ButtonSubmit,
|
||||
callback: (confirmed, promptData) => {
|
||||
if (confirmed) {
|
||||
this.sendPluginAction(pluginId, pluginAction, promptData)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
} else {
|
||||
this.sendPluginAction(pluginId, pluginAction)
|
||||
}
|
||||
},
|
||||
sendPluginAction(pluginId, pluginAction, promptData = null) {
|
||||
this.$axios
|
||||
.$post(`/api/plugins/${pluginId}/action`, {
|
||||
pluginAction,
|
||||
target: 'item.detail.actions',
|
||||
data: {
|
||||
entityId: this.libraryItemId,
|
||||
entityType: 'libraryItem',
|
||||
userId: this.$store.state.user.user.id,
|
||||
promptData
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
console.log('Plugin action response', data)
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response?.data || 'Plugin action failed'
|
||||
console.error('Plugin action failed:', error)
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -166,14 +166,10 @@ export default {
|
||||
|
||||
location.reload()
|
||||
},
|
||||
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices, plugins }) {
|
||||
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) {
|
||||
this.$store.commit('setServerSettings', serverSettings)
|
||||
this.$store.commit('setSource', Source)
|
||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||
if (plugins !== undefined) {
|
||||
this.$store.commit('setPlugins', plugins)
|
||||
}
|
||||
|
||||
this.$setServerLanguageCode(serverSettings.language)
|
||||
|
||||
if (serverSettings.chromecastEnabled) {
|
||||
|
||||
@@ -110,6 +110,84 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mediaSessionPlay() {
|
||||
console.log('Media session play')
|
||||
this.play()
|
||||
},
|
||||
mediaSessionPause() {
|
||||
console.log('Media session pause')
|
||||
this.pause()
|
||||
},
|
||||
mediaSessionStop() {
|
||||
console.log('Media session stop')
|
||||
this.pause()
|
||||
},
|
||||
mediaSessionSeekBackward() {
|
||||
console.log('Media session seek backward')
|
||||
this.jumpBackward()
|
||||
},
|
||||
mediaSessionSeekForward() {
|
||||
console.log('Media session seek forward')
|
||||
this.jumpForward()
|
||||
},
|
||||
mediaSessionSeekTo(e) {
|
||||
console.log('Media session seek to', e)
|
||||
if (e.seekTime !== null && !isNaN(e.seekTime)) {
|
||||
this.seek(e.seekTime)
|
||||
}
|
||||
},
|
||||
mediaSessionPreviousTrack() {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.prevChapter()
|
||||
}
|
||||
},
|
||||
mediaSessionNextTrack() {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.nextChapter()
|
||||
}
|
||||
},
|
||||
updateMediaSessionPlaybackState() {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
|
||||
}
|
||||
},
|
||||
setMediaSession() {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
|
||||
if ('mediaSession' in navigator) {
|
||||
const chapterInfo = []
|
||||
if (this.chapters.length > 0) {
|
||||
this.chapters.forEach((chapter) => {
|
||||
chapterInfo.push({
|
||||
title: chapter.title,
|
||||
startTime: chapter.start
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: this.mediaItemShare.playbackSession.displayTitle || 'No title',
|
||||
artist: this.mediaItemShare.playbackSession.displayAuthor || 'Unknown',
|
||||
artwork: [
|
||||
{
|
||||
src: this.coverUrl
|
||||
}
|
||||
],
|
||||
chapterInfo
|
||||
})
|
||||
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
|
||||
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
|
||||
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
|
||||
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
|
||||
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
|
||||
} else {
|
||||
console.warn('Media session not available')
|
||||
}
|
||||
},
|
||||
async coverImageLoaded(e) {
|
||||
if (!this.playbackSession.coverPath) return
|
||||
const fac = new FastAverageColor()
|
||||
@@ -126,8 +204,19 @@ export default {
|
||||
})
|
||||
},
|
||||
playPause() {
|
||||
if (this.isPlaying) {
|
||||
this.pause()
|
||||
} else {
|
||||
this.play()
|
||||
}
|
||||
},
|
||||
play() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
this.localAudioPlayer.playPause()
|
||||
this.localAudioPlayer.play()
|
||||
},
|
||||
pause() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
this.localAudioPlayer.pause()
|
||||
},
|
||||
jumpForward() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
@@ -213,6 +302,7 @@ export default {
|
||||
} else {
|
||||
this.stopPlayInterval()
|
||||
}
|
||||
this.updateMediaSessionPlaybackState()
|
||||
},
|
||||
playerTimeUpdate(time) {
|
||||
this.setCurrentTime(time)
|
||||
@@ -276,6 +366,8 @@ export default {
|
||||
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
|
||||
this.localAudioPlayer.on('error', this.playerError.bind(this))
|
||||
this.localAudioPlayer.on('finished', this.playerFinished.bind(this))
|
||||
|
||||
this.setMediaSession()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
|
||||
@@ -69,17 +69,22 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true, showSeconds = t
|
||||
let hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
|
||||
// Handle rollovers before days calculation
|
||||
if (minutes && seconds && !showSeconds) {
|
||||
if (seconds >= 30) minutes++
|
||||
if (minutes >= 60) {
|
||||
hours++ // Increment hours if minutes roll over
|
||||
minutes -= 60 // adjust minutes
|
||||
}
|
||||
}
|
||||
|
||||
// Now calculate days with the final hours value
|
||||
let days = 0
|
||||
if (useDays || Math.floor(hours / 24) >= 100) {
|
||||
days = Math.floor(hours / 24)
|
||||
hours -= days * 24
|
||||
}
|
||||
|
||||
// If not showing seconds then round minutes up
|
||||
if (minutes && seconds && !showSeconds) {
|
||||
if (seconds >= 30) minutes++
|
||||
}
|
||||
|
||||
const strs = []
|
||||
if (days) strs.push(`${days}d`)
|
||||
if (hours) strs.push(`${hours}h`)
|
||||
|
||||
@@ -28,9 +28,7 @@ export const state = () => ({
|
||||
openModal: null,
|
||||
innerModalOpen: false,
|
||||
lastBookshelfScrollData: {},
|
||||
routerBasePath: '/',
|
||||
plugins: [],
|
||||
pluginsEnabled: false
|
||||
routerBasePath: '/'
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@@ -63,20 +61,6 @@ export const getters = {
|
||||
getHomeBookshelfView: (state) => {
|
||||
if (!state.serverSettings || isNaN(state.serverSettings.homeBookshelfView)) return Constants.BookshelfView.STANDARD
|
||||
return state.serverSettings.homeBookshelfView
|
||||
},
|
||||
getPluginExtensions: (state) => (target) => {
|
||||
if (!state.pluginsEnabled) return []
|
||||
return state.plugins
|
||||
.map((pext) => {
|
||||
const extensionsMatchingTarget = pext.extensions?.filter((ext) => ext.target === target) || []
|
||||
if (!extensionsMatchingTarget.length) return null
|
||||
return {
|
||||
id: pext.id,
|
||||
name: pext.name,
|
||||
extensions: extensionsMatchingTarget
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,9 +239,5 @@ export const mutations = {
|
||||
},
|
||||
setInnerModalOpen(state, val) {
|
||||
state.innerModalOpen = val
|
||||
},
|
||||
setPlugins(state, val) {
|
||||
state.plugins = val
|
||||
state.pluginsEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,117 @@
|
||||
{}
|
||||
{
|
||||
"ButtonAdd": "Дадаць",
|
||||
"ButtonAddChapters": "Дадаць раздзелы",
|
||||
"ButtonAddDevice": "Дадаць прыладу",
|
||||
"ButtonAddLibrary": "Дадаць бібліятэку",
|
||||
"ButtonAddPodcasts": "Дадаць падкасты",
|
||||
"ButtonAddUser": "Дадаць карыстальніка",
|
||||
"ButtonAddYourFirstLibrary": "Дадайце сваю першую бібліятэку",
|
||||
"ButtonApply": "Ужыць",
|
||||
"ButtonApplyChapters": "Ужыць раздзелы",
|
||||
"ButtonAuthors": "Аўтары",
|
||||
"ButtonBack": "Назад",
|
||||
"ButtonBrowseForFolder": "Знайсці тэчку",
|
||||
"ButtonCancel": "Адмяніць",
|
||||
"ButtonCancelEncode": "Адмяніць кадзіраванне",
|
||||
"ButtonChangeRootPassword": "Зменіце Root пароль",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Праверыць і спампаваць новыя эпізоды",
|
||||
"ButtonChooseAFolder": "Выбраць тэчку",
|
||||
"ButtonChooseFiles": "Выбраць файлы",
|
||||
"ButtonClearFilter": "Ачысціць фільтр",
|
||||
"ButtonCloseFeed": "Закрыць стужку",
|
||||
"ButtonCloseSession": "Закрыць адкрыты сеанс",
|
||||
"ButtonCollections": "Калекцыі",
|
||||
"ButtonConfigureScanner": "Наладзіць сканер",
|
||||
"ButtonCreate": "Ствараць",
|
||||
"ButtonCreateBackup": "Стварыць рэзервовую копію",
|
||||
"ButtonDelete": "Выдаліць",
|
||||
"ButtonDownloadQueue": "Чарга",
|
||||
"ButtonEdit": "Рэдагаваць",
|
||||
"ButtonEditChapters": "Рэдагаваць раздзелы",
|
||||
"ButtonEditPodcast": "Рэдагаваць падкаст",
|
||||
"ButtonEnable": "Уключыць",
|
||||
"ButtonFireAndFail": "Агонь і няўдача",
|
||||
"ButtonFireOnTest": "Тэст на вогнеўстойлівасць",
|
||||
"ButtonForceReScan": "Прымусовае паўторнае сканаванне",
|
||||
"ButtonFullPath": "Поўны шлях",
|
||||
"ButtonHide": "Схаваць",
|
||||
"ButtonIssues": "Праблемы",
|
||||
"ButtonJumpBackward": "Перайсці назад",
|
||||
"ButtonJumpForward": "Перайсці наперад",
|
||||
"ButtonLibrary": "Бібліятэка",
|
||||
"ButtonLogout": "Выйсці",
|
||||
"ButtonLookup": "",
|
||||
"ButtonMapChapterTitles": "Супаставіць назвы раздзелаў",
|
||||
"ButtonMatchAllAuthors": "Супадзенне ўсіх аўтараў",
|
||||
"ButtonNevermind": "Няважна",
|
||||
"ButtonNext": "Далей",
|
||||
"ButtonNextChapter": "Наступны раздзел",
|
||||
"ButtonNextItemInQueue": "Наступны элемент у чарзе",
|
||||
"ButtonOk": "Добра",
|
||||
"ButtonOpenFeed": "Адкрыць стужку",
|
||||
"ButtonOpenManager": "Адкрыць менеджар",
|
||||
"ButtonPause": "Паўза",
|
||||
"ButtonPlay": "Прайграць",
|
||||
"ButtonPlayAll": "Прайграць усё",
|
||||
"ButtonPlaying": "Прайграваецца",
|
||||
"ButtonPlaylists": "Плэйлісты",
|
||||
"ButtonPrevious": "Папярэдні",
|
||||
"ButtonPreviousChapter": "Папярэдні раздзел",
|
||||
"ButtonProbeAudioFile": "Праверыць аўдыяфайл",
|
||||
"ButtonPurgeAllCache": "Ачысціць увесь кэш",
|
||||
"ButtonPurgeItemsCache": "Ачысціць кэш элементаў",
|
||||
"ButtonQueueAddItem": "Дадаць у чаргу",
|
||||
"ButtonQueueRemoveItem": "Выдаліць з чаргі",
|
||||
"ButtonQuickEmbed": "Хуткае ўбудаванне",
|
||||
"ButtonQuickEmbedMetadata": "Хуткае ўбудаванне метаданых",
|
||||
"ButtonQuickMatch": "Хуткі пошук",
|
||||
"ButtonReScan": "Паўторнае сканаванне",
|
||||
"ButtonRead": "Чытаць",
|
||||
"ButtonRefresh": "Абнавіць",
|
||||
"ButtonRemove": "Выдаліць",
|
||||
"ButtonRemoveAll": "Выдаліць усе",
|
||||
"ButtonRemoveAllLibraryItems": "Выдаліць усе элементы бібліятэкі",
|
||||
"ButtonReset": "Скінуць",
|
||||
"ButtonResetToDefault": "Скінуць па змаўчанні",
|
||||
"ButtonRestore": "Аднавіць",
|
||||
"ButtonSave": "Захаваць",
|
||||
"ButtonSaveAndClose": "Захаваць і зачыніць",
|
||||
"ButtonSaveTracklist": "Захаваць спіс трэкаў",
|
||||
"ButtonScan": "Сканаваць",
|
||||
"ButtonScanLibrary": "Сканіраваць бібліятэку",
|
||||
"ButtonScrollLeft": "Пракруціць улева",
|
||||
"ButtonScrollRight": "Пракруціць направа",
|
||||
"ButtonSearch": "Пошук",
|
||||
"ButtonSelectFolderPath": "Выбраць шлях да тэчкі",
|
||||
"ButtonSeries": "Серыі",
|
||||
"ButtonSetChaptersFromTracks": "Усталяваць раздзелы з трэкаў",
|
||||
"ButtonShare": "Падзяліцца",
|
||||
"ButtonStartM4BEncode": "Пачаць кадзіраванне ў M4B",
|
||||
"ButtonStartMetadataEmbed": "Пачаць убудаванне метаданых",
|
||||
"ButtonStats": "Статыстыка",
|
||||
"ButtonSubmit": "Адправіць",
|
||||
"ButtonTest": "Тэст",
|
||||
"ButtonUnlinkOpenId": "Адвязаць OpenID",
|
||||
"ButtonUpload": "Загрузіць",
|
||||
"ButtonUploadBackup": "Загрузіць рэзервовую копію",
|
||||
"ButtonUploadCover": "Загрузіць вокладку",
|
||||
"ButtonUploadOPMLFile": "Загрузіць OPML файл",
|
||||
"ButtonUserDelete": "Выдаліць карыстальніка {0}",
|
||||
"ButtonUserEdit": "Рэдагаваць карыстальніка {0}",
|
||||
"ButtonViewAll": "Прагледзець усе",
|
||||
"ButtonYes": "Так",
|
||||
"HeaderAccount": "Уліковы запіс",
|
||||
"HeaderAddCustomMetadataProvider": "Дадаць карыстальніцкага пастаўшчыка метаданных",
|
||||
"HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise",
|
||||
"HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг",
|
||||
"HeaderAuthentication": "Аўтэнтыфікацыя",
|
||||
"HeaderBackups": "Рэзервовыя копіі",
|
||||
"HeaderChangePassword": "Змяніць пароль",
|
||||
"HeaderChapters": "Раздзелы",
|
||||
"HeaderChooseAFolder": "Выбраць тэчку",
|
||||
"HeaderCollection": "Калекцыя",
|
||||
"HeaderCollectionItems": "Элементы калекцыі",
|
||||
"HeaderCover": "Вокладка",
|
||||
"HeaderCurrentDownloads": "Бягучыя загрузкі",
|
||||
"HeaderCustomMessageOnLogin": "Карыстальніцкае паведамленне пры ўваходзе"
|
||||
}
|
||||
|
||||
@@ -629,7 +629,6 @@
|
||||
"MessageItemsSelected": "{0} избрани",
|
||||
"MessageItemsUpdated": "{0} елемента обновени",
|
||||
"MessageJoinUsOn": "Присъединете се към нас",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} слушателски сесии през последната година",
|
||||
"MessageLoading": "Зареждане...",
|
||||
"MessageLoadingFolders": "Зареждане на Папки...",
|
||||
"MessageM4BFailed": "M4B Провалено!",
|
||||
@@ -726,10 +725,8 @@
|
||||
"ToastBookmarkCreateFailed": "Неуспешно създаване на отметка",
|
||||
"ToastBookmarkCreateSuccess": "Отметката е създадена",
|
||||
"ToastBookmarkRemoveSuccess": "Отметката е премахната",
|
||||
"ToastBookmarkUpdateSuccess": "Отметката е обновена",
|
||||
"ToastChaptersHaveErrors": "Главите имат грешки",
|
||||
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
||||
"ToastCollectionItemsRemoveSuccess": "Елемент(и) премахнати от колекция",
|
||||
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
||||
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
|
||||
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "ট্র্যাকলিস্ট সংরক্ষণ করুন",
|
||||
"ButtonScan": "স্ক্যান",
|
||||
"ButtonScanLibrary": "স্ক্যান লাইব্রেরি",
|
||||
"ButtonScrollLeft": "বাম দিকে স্ক্রল করুন",
|
||||
"ButtonScrollRight": "ডানদিকে স্ক্রল করুন",
|
||||
"ButtonSearch": "অনুসন্ধান",
|
||||
"ButtonSelectFolderPath": "ফোল্ডারের পথ নির্বাচন করুন",
|
||||
"ButtonSeries": "সিরিজ",
|
||||
@@ -190,6 +192,7 @@
|
||||
"HeaderSettingsExperimental": "পরীক্ষামূলক ফিচার",
|
||||
"HeaderSettingsGeneral": "সাধারণ",
|
||||
"HeaderSettingsScanner": "স্ক্যানার",
|
||||
"HeaderSettingsWebClient": "ওয়েব ক্লায়েন্ট",
|
||||
"HeaderSleepTimer": "স্লিপ টাইমার",
|
||||
"HeaderStatsLargestItems": "সবচেয়ে বড় আইটেম",
|
||||
"HeaderStatsLongestItems": "দীর্ঘতম আইটেম (ঘন্টা)",
|
||||
@@ -297,6 +300,7 @@
|
||||
"LabelDiscover": "আবিষ্কার",
|
||||
"LabelDownload": "ডাউনলোড করুন",
|
||||
"LabelDownloadNEpisodes": "{0}টি পর্ব ডাউনলোড করুন",
|
||||
"LabelDownloadable": "ডাউনলোডযোগ্য",
|
||||
"LabelDuration": "সময়কাল",
|
||||
"LabelDurationComparisonExactMatch": "(সঠিক মিল)",
|
||||
"LabelDurationComparisonLonger": "({0} দীর্ঘ)",
|
||||
@@ -542,6 +546,7 @@
|
||||
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
|
||||
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
|
||||
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
|
||||
"LabelSettingsAllowIframe": "আইফ্রেমে এম্বেড করার অনুমতি দিন",
|
||||
"LabelSettingsAudiobooksOnly": "শুধুমাত্র অডিও বই",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "এই সেটিংটি সক্ষম করা ই-বই ফাইলগুলিকে উপেক্ষা করবে যদি না সেগুলি একটি অডিওবই ফোল্ডারের মধ্যে থাকে যে ক্ষেত্রে সেগুলিকে সম্পূরক ই-বই হিসাবে সেট করা হবে",
|
||||
"LabelSettingsBookshelfViewHelp": "কাঠের তাক সহ স্কুমরফিক ডিজাইন",
|
||||
@@ -584,6 +589,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "ডিফল্টরূপে মেটাডেটা ফাইলগুলি /মেটাডাটা/আইটেমগুলি -এ সংরক্ষণ করা হয়, এই সেটিংটি সক্ষম করলে মেটাডেটা ফাইলগুলি আপনার লাইব্রেরি আইটেম ফোল্ডারে সংরক্ষণ করা হবে",
|
||||
"LabelSettingsTimeFormat": "সময় বিন্যাস",
|
||||
"LabelShare": "শেয়ার করুন",
|
||||
"LabelShareDownloadableHelp": "শেয়ার লিঙ্ক সহ ব্যবহারকারীদের লাইব্রেরি আইটেমের একটি জিপ ফাইল ডাউনলোড করার অনুমতি দিন।",
|
||||
"LabelShareOpen": "শেয়ার খোলা",
|
||||
"LabelShareURL": "শেয়ার ইউআরএল",
|
||||
"LabelShowAll": "সব দেখান",
|
||||
@@ -592,6 +598,8 @@
|
||||
"LabelSize": "আকার",
|
||||
"LabelSleepTimer": "স্লিপ টাইমার",
|
||||
"LabelSlug": "স্লাগ",
|
||||
"LabelSortAscending": "আরোহী",
|
||||
"LabelSortDescending": "অবরোহী",
|
||||
"LabelStart": "শুরু",
|
||||
"LabelStartTime": "শুরুর সময়",
|
||||
"LabelStarted": "শুরু হয়েছে",
|
||||
@@ -679,6 +687,8 @@
|
||||
"LabelViewPlayerSettings": "প্লেয়ার সেটিংস দেখুন",
|
||||
"LabelViewQueue": "প্লেয়ার সারি দেখুন",
|
||||
"LabelVolume": "ভলিউম",
|
||||
"LabelWebRedirectURLsDescription": "লগইন করার পরে ওয়েব অ্যাপে পুনঃনির্দেশের অনুমতি দেওয়ার জন্য আপনার OAuth প্রদানকারীতে এই URLগুলোকে অনুমোদন করুন:",
|
||||
"LabelWebRedirectURLsSubfolder": "রিডাইরেক্ট URL এর জন্য সাবফোল্ডার",
|
||||
"LabelWeekdaysToRun": "চলতে হবে সপ্তাহের দিন",
|
||||
"LabelXBooks": "{0}টি বই",
|
||||
"LabelXItems": "{0}টি আইটেম",
|
||||
@@ -763,7 +773,6 @@
|
||||
"MessageItemsSelected": "{0}টি আইটেম নির্বাচিত",
|
||||
"MessageItemsUpdated": "{0}টি আইটেম আপডেট করা হয়েছে",
|
||||
"MessageJoinUsOn": "আমাদের সাথে যোগ দিন",
|
||||
"MessageListeningSessionsInTheLastYear": "গত বছরে {0}টি শোনার সেশন",
|
||||
"MessageLoading": "লোড হচ্ছে.।",
|
||||
"MessageLoadingFolders": "ফোল্ডার লোড হচ্ছে...",
|
||||
"MessageLogsDescription": "লগগুলি JSON ফাইল হিসাবে <code>/metadata/logs</code>-এ সংরক্ষণ করা হয়। ক্র্যাশ লগগুলি <code>/metadata/logs/crash_logs.txt</code>-এ সংরক্ষণ করা হয়।",
|
||||
@@ -943,7 +952,6 @@
|
||||
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
|
||||
"ToastBookmarkCreateSuccess": "বুকমার্ক যোগ করা হয়েছে",
|
||||
"ToastBookmarkRemoveSuccess": "বুকমার্ক সরানো হয়েছে",
|
||||
"ToastBookmarkUpdateSuccess": "বুকমার্ক আপডেট করা হয়েছে",
|
||||
"ToastCachePurgeFailed": "ক্যাশে পরিষ্কার করতে ব্যর্থ হয়েছে",
|
||||
"ToastCachePurgeSuccess": "ক্যাশে সফলভাবে পরিষ্কার করা হয়েছে",
|
||||
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
|
||||
@@ -951,8 +959,6 @@
|
||||
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
|
||||
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
|
||||
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
|
||||
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
|
||||
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
|
||||
"ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে",
|
||||
"ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে",
|
||||
"ToastCoverUpdateFailed": "কভার আপডেট ব্যর্থ হয়েছে",
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
"ButtonSaveTracklist": "Desa Pistes",
|
||||
"ButtonScan": "Escaneja",
|
||||
"ButtonScanLibrary": "Escaneja Biblioteca",
|
||||
"ButtonScrollLeft": "Mou a l'esquerra",
|
||||
"ButtonScrollRight": "Mou a la dreta",
|
||||
"ButtonSearch": "Cerca",
|
||||
"ButtonSelectFolderPath": "Selecciona Ruta de Carpeta",
|
||||
"ButtonSeries": "Sèries",
|
||||
@@ -896,7 +898,6 @@
|
||||
"ToastBookmarkCreateFailed": "Error en crear marcador",
|
||||
"ToastBookmarkCreateSuccess": "Marcador afegit",
|
||||
"ToastBookmarkRemoveSuccess": "Marcador eliminat",
|
||||
"ToastBookmarkUpdateSuccess": "Marcador actualitzat",
|
||||
"ToastCachePurgeFailed": "Error en purgar la memòria cau",
|
||||
"ToastCachePurgeSuccess": "Memòria cau purgada amb èxit",
|
||||
"ToastChaptersHaveErrors": "Els capítols tenen errors",
|
||||
@@ -904,8 +905,6 @@
|
||||
"ToastChaptersRemoved": "Capítols eliminats",
|
||||
"ToastChaptersUpdated": "Capítols actualitzats",
|
||||
"ToastCollectionItemsAddFailed": "Error en afegir elements a la col·lecció",
|
||||
"ToastCollectionItemsAddSuccess": "Elements afegits a la col·lecció",
|
||||
"ToastCollectionItemsRemoveSuccess": "Elements eliminats de la col·lecció",
|
||||
"ToastCollectionRemoveSuccess": "Col·lecció eliminada",
|
||||
"ToastCollectionUpdateSuccess": "Col·lecció actualitzada",
|
||||
"ToastCoverUpdateFailed": "Error en actualitzar la portada",
|
||||
|
||||
@@ -572,7 +572,7 @@
|
||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Označit položku médií jako dokončenou, když",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Přeskočit předchozí knihy v pokračování série",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polička Pokračovat v sérii na domovské stránce zobrazuje první nezačatou knihu v sériích, které mají alespoň jednu knihu dokončenou a žádnou rozečtenou. Povolením tohoto nastavení budou série pokračovat od poslední dokončené knihy namísto první nezačaté knihy.",
|
||||
"LabelSettingsParseSubtitles": "Analzyovat podtitul",
|
||||
"LabelSettingsParseSubtitles": "Analyzovat podtitul",
|
||||
"LabelSettingsParseSubtitlesHelp": "Rozparsovat podtitul z názvů složek audioknih.<br>Podtiul musí být oddělen znakem \" - \"<br>tj. \"Název knihy - Zde Podtitul\" má podtitul \"Zde podtitul\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "Preferovat spárovaná metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Spárovaná data budou mít při použití funkce Rychlé párování přednost před údaji o položce. Ve výchozím nastavení funkce Rychlé párování pouze doplní chybějící údaje.",
|
||||
@@ -756,6 +756,7 @@
|
||||
"MessageConfirmResetProgress": "Opravdu chcete zahodit svůj pokrok?",
|
||||
"MessageConfirmSendEbookToDevice": "Opravdu chcete odeslat e-knihu {0} {1}\" do zařízení \"{2}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "Opravdu chcete odpojit tohoto uživatele z OpenID?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} poslechových dní v minulém roce",
|
||||
"MessageDownloadingEpisode": "Stahuji epizodu",
|
||||
"MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop",
|
||||
"MessageEmbedFailed": "Vložení selhalo!",
|
||||
@@ -771,7 +772,6 @@
|
||||
"MessageItemsSelected": "{0} vybraných položek",
|
||||
"MessageItemsUpdated": "{0} položky byly aktualizovány",
|
||||
"MessageJoinUsOn": "Přidejte se k nám",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} poslechových relací za poslední rok",
|
||||
"MessageLoading": "Načítá se...",
|
||||
"MessageLoadingFolders": "Načítám složky...",
|
||||
"MessageLogsDescription": "Protokoly se ukládají do souborů JSON v <code>/metadata/logs</code>. Protokoly o pádech jsou uloženy v <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@@ -867,6 +867,8 @@
|
||||
"MessageTaskOpmlImportFeedPodcastExists": "Podcast se stejnou cestou již existuje",
|
||||
"MessageTaskOpmlImportFeedPodcastFailed": "Vytváření podcastu selhalo",
|
||||
"MessageTaskOpmlImportFinished": "Přidáno {0} podcastů",
|
||||
"MessageTaskOpmlParseFailed": "Selhalo parsování OPML souboru",
|
||||
"MessageTaskOpmlParseFastFail": "Neplatný OPML soubor <opml> tag nenalezen NEBO <outline> tag nenalezen",
|
||||
"MessageTaskScanItemsAdded": "{0} přidáno",
|
||||
"MessageTaskScanItemsMissing": "{0} chybí",
|
||||
"MessageTaskScanItemsUpdated": "{0} aktualizováno",
|
||||
@@ -891,6 +893,10 @@
|
||||
"NoteUploaderFoldersWithMediaFiles": "Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.",
|
||||
"NoteUploaderOnlyAudioFiles": "Pokud nahráváte pouze zvukové soubory, bude s každým zvukovým souborem zacházeno jako se samostatnou audioknihou.",
|
||||
"NoteUploaderUnsupportedFiles": "Nepodporované soubory jsou ignorovány. Při výběru nebo přetažení složky jsou ostatní soubory, které nejsou ve složce položek, ignorovány.",
|
||||
"NotificationOnBackupCompletedDescription": "Spuštěno po dokončení zálohování",
|
||||
"NotificationOnBackupFailedDescription": "Spuštěno pokud zálohování selže",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Spuštěno při automatickém stažení epizody podcastu",
|
||||
"NotificationOnTestDescription": "Akce pro otestování upozorňovacího systému",
|
||||
"PlaceholderNewCollection": "Nový název kolekce",
|
||||
"PlaceholderNewFolderPath": "Nová cesta ke složce",
|
||||
"PlaceholderNewPlaylist": "Nový název seznamu přehrávání",
|
||||
@@ -901,6 +907,7 @@
|
||||
"StatsBooksAdditional": "Některé další zahrnují…",
|
||||
"StatsBooksFinished": "dokončené knihy",
|
||||
"StatsBooksFinishedThisYear": "Některé knihy dokončené tento rok…",
|
||||
"StatsBooksListenedTo": "knih poslechnuto",
|
||||
"StatsCollectionGrewTo": "Vaše kolekce knih se rozrostla na…",
|
||||
"StatsSessions": "sezení",
|
||||
"StatsSpentListening": "stráveno posloucháním",
|
||||
@@ -909,10 +916,13 @@
|
||||
"StatsTopGenre": "TOP ŽÁNR",
|
||||
"StatsTopGenres": "TOP ŽÁNRY",
|
||||
"StatsTopMonth": "TOP MĚSÍC",
|
||||
"StatsTopNarrator": "NEJLEPŠÍ VYPRAVĚČ",
|
||||
"StatsTopNarrators": "NEJLEPŠÍ VYPRAVĚČI",
|
||||
"StatsTotalDuration": "S celkovou dobou…",
|
||||
"StatsYearInReview": "ROK V PŘEHLEDU",
|
||||
"ToastAccountUpdateSuccess": "Účet aktualizován",
|
||||
"ToastAppriseUrlRequired": "Je nutné zadat Apprise URL",
|
||||
"ToastAsinRequired": "ASIN vyžadován",
|
||||
"ToastAuthorImageRemoveSuccess": "Obrázek autora odstraněn",
|
||||
"ToastAuthorNotFound": "Author \"{0}\" nenalezen",
|
||||
"ToastAuthorRemoveSuccess": "Autor odstraněn",
|
||||
@@ -937,13 +947,11 @@
|
||||
"ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo",
|
||||
"ToastBookmarkCreateSuccess": "Přidána záložka",
|
||||
"ToastBookmarkRemoveSuccess": "Záložka odstraněna",
|
||||
"ToastBookmarkUpdateSuccess": "Záložka aktualizována",
|
||||
"ToastCachePurgeFailed": "Nepodařilo se vyčistit mezipaměť",
|
||||
"ToastCachePurgeSuccess": "Vyrovnávací paměť úspěšně vyčištěna",
|
||||
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
|
||||
"ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy",
|
||||
"ToastChaptersRemoved": "Kapitoly odstraněny",
|
||||
"ToastCollectionItemsRemoveSuccess": "Položky odstraněny z kolekce",
|
||||
"ToastCollectionRemoveSuccess": "Kolekce odstraněna",
|
||||
"ToastCollectionUpdateSuccess": "Kolekce aktualizována",
|
||||
"ToastCoverUpdateFailed": "Aktualizace obálky selhala",
|
||||
|
||||
@@ -539,7 +539,6 @@
|
||||
"MessageItemsSelected": "{0} elementer valgt",
|
||||
"MessageItemsUpdated": "{0} elementer opdateret",
|
||||
"MessageJoinUsOn": "Deltag i os på",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} lyttesessioner i det sidste år",
|
||||
"MessageLoading": "Indlæser...",
|
||||
"MessageLoadingFolders": "Indlæser mapper...",
|
||||
"MessageM4BFailed": "M4B mislykkedes!",
|
||||
@@ -637,10 +636,8 @@
|
||||
"ToastBookmarkCreateFailed": "Mislykkedes oprettelse af bogmærke",
|
||||
"ToastBookmarkCreateSuccess": "Bogmærke tilføjet",
|
||||
"ToastBookmarkRemoveSuccess": "Bogmærke fjernet",
|
||||
"ToastBookmarkUpdateSuccess": "Bogmærke opdateret",
|
||||
"ToastChaptersHaveErrors": "Kapitler har fejl",
|
||||
"ToastChaptersMustHaveTitles": "Kapitler skal have titler",
|
||||
"ToastCollectionItemsRemoveSuccess": "Element(er) fjernet fra samlingen",
|
||||
"ToastCollectionRemoveSuccess": "Samling fjernet",
|
||||
"ToastCollectionUpdateSuccess": "Samling opdateret",
|
||||
"ToastItemCoverUpdateSuccess": "Varens omslag opdateret",
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"LabelDiscover": "Entdecken",
|
||||
"LabelDownload": "Herunterladen",
|
||||
"LabelDownloadNEpisodes": "Download {0} Episoden",
|
||||
"LabelDownloadable": "Herunterladbar",
|
||||
"LabelDuration": "Laufzeit",
|
||||
"LabelDurationComparisonExactMatch": "(genauer Treffer)",
|
||||
"LabelDurationComparisonLonger": "({0} länger)",
|
||||
@@ -588,6 +589,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",
|
||||
"LabelShareDownloadableHelp": "Erlaubt es einem Nutzer, mit dem Link, die Dateien des Mediums als ZIP herunterzuladen.",
|
||||
"LabelShareOpen": "Freigeben",
|
||||
"LabelShareURL": "Freigabe URL",
|
||||
"LabelShowAll": "Alles anzeigen",
|
||||
@@ -756,6 +758,7 @@
|
||||
"MessageConfirmResetProgress": "Möchtest du Ihren Fortschritt wirklich zurücksetzen?",
|
||||
"MessageConfirmSendEbookToDevice": "{0} E-Buch „{1}“ wird auf das Gerät „{2}“ gesendet! Bist du dir sicher?",
|
||||
"MessageConfirmUnlinkOpenId": "Möchtest du die Verknüpfung dieses Benutzers mit OpenID wirklich löschen?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} Tage in dem letzten Jahr gehört",
|
||||
"MessageDownloadingEpisode": "Episode wird heruntergeladen",
|
||||
"MessageDragFilesIntoTrackOrder": "Verschiebe die Dateien in die richtige Reihenfolge",
|
||||
"MessageEmbedFailed": "Einbetten fehlgeschlagen!",
|
||||
@@ -771,7 +774,6 @@
|
||||
"MessageItemsSelected": "{0} ausgewählte Medien",
|
||||
"MessageItemsUpdated": "{0} Medien aktualisiert",
|
||||
"MessageJoinUsOn": "Besuche uns auf",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr",
|
||||
"MessageLoading": "Wird geladen …",
|
||||
"MessageLoadingFolders": "Lade Ordner...",
|
||||
"MessageLogsDescription": "Die Logs werdern in <code>/metadata/logs</code> als JSON Dateien gespeichert. Crash logs werden in <code>/metadata/logs/crash_logs.txt</code> gespeichert.",
|
||||
@@ -951,7 +953,6 @@
|
||||
"ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
|
||||
"ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt",
|
||||
"ToastBookmarkRemoveSuccess": "Lesezeichen entfernt",
|
||||
"ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
|
||||
"ToastCachePurgeFailed": "Cache leeren fehlgeschlagen",
|
||||
"ToastCachePurgeSuccess": "Cache geleert",
|
||||
"ToastChaptersHaveErrors": "Kapitel sind fehlerhaft",
|
||||
@@ -959,11 +960,10 @@
|
||||
"ToastChaptersRemoved": "Kapitel entfernt",
|
||||
"ToastChaptersUpdated": "Kapitel aktualisiert",
|
||||
"ToastCollectionItemsAddFailed": "Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen",
|
||||
"ToastCollectionItemsAddSuccess": "Element(e) erfolgreich zur Sammlung hinzugefügt",
|
||||
"ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt",
|
||||
"ToastCollectionRemoveSuccess": "Sammlung entfernt",
|
||||
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
|
||||
"ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen",
|
||||
"ToastDateTimeInvalidOrIncomplete": "Datum und Zeit ist ungültig oder unvollständig",
|
||||
"ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden",
|
||||
"ToastDeleteFileSuccess": "Datei gelöscht",
|
||||
"ToastDeviceAddFailed": "Gerät konnte nicht hinzugefügt werden",
|
||||
|
||||
@@ -758,6 +758,7 @@
|
||||
"MessageConfirmResetProgress": "Are you sure you want to reset your progress?",
|
||||
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "Are you sure you want to unlink this user from OpenID?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} days listened in the last year",
|
||||
"MessageDownloadingEpisode": "Downloading episode",
|
||||
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
|
||||
"MessageEmbedFailed": "Embed Failed!",
|
||||
@@ -773,7 +774,6 @@
|
||||
"MessageItemsSelected": "{0} Items Selected",
|
||||
"MessageItemsUpdated": "{0} Items Updated",
|
||||
"MessageJoinUsOn": "Join us on",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
|
||||
"MessageLoading": "Loading...",
|
||||
"MessageLoadingFolders": "Loading folders...",
|
||||
"MessageLogsDescription": "Logs are stored in <code>/metadata/logs</code> as JSON files. Crash logs are stored in <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@@ -837,6 +837,7 @@
|
||||
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
|
||||
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
||||
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
||||
"MessageScheduleLibraryScanNote": "For most users, it is recommended to leave this feature disabled and keep the folder watcher setting enabled. The folder watcher will automatically detect changes in your library folders. The folder watcher doesn't work for every file system (like NFS) so scheduled library scans can be used instead.",
|
||||
"MessageSearchResultsFor": "Search results for",
|
||||
"MessageSelected": "{0} selected",
|
||||
"MessageServerCouldNotBeReached": "Server could not be reached",
|
||||
@@ -953,7 +954,6 @@
|
||||
"ToastBookmarkCreateFailed": "Failed to create bookmark",
|
||||
"ToastBookmarkCreateSuccess": "Bookmark added",
|
||||
"ToastBookmarkRemoveSuccess": "Bookmark removed",
|
||||
"ToastBookmarkUpdateSuccess": "Bookmark updated",
|
||||
"ToastCachePurgeFailed": "Failed to purge cache",
|
||||
"ToastCachePurgeSuccess": "Cache purged successfully",
|
||||
"ToastChaptersHaveErrors": "Chapters have errors",
|
||||
@@ -961,11 +961,10 @@
|
||||
"ToastChaptersRemoved": "Chapters removed",
|
||||
"ToastChaptersUpdated": "Chapters updated",
|
||||
"ToastCollectionItemsAddFailed": "Item(s) added to collection failed",
|
||||
"ToastCollectionItemsAddSuccess": "Item(s) added to collection success",
|
||||
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
|
||||
"ToastCollectionRemoveSuccess": "Collection removed",
|
||||
"ToastCollectionUpdateSuccess": "Collection updated",
|
||||
"ToastCoverUpdateFailed": "Cover update failed",
|
||||
"ToastDateTimeInvalidOrIncomplete": "Date and time is invalid or incomplete",
|
||||
"ToastDeleteFileFailed": "Failed to delete file",
|
||||
"ToastDeleteFileSuccess": "File deleted",
|
||||
"ToastDeviceAddFailed": "Failed to add device",
|
||||
@@ -1018,6 +1017,7 @@
|
||||
"ToastNewUserTagError": "Must select at least one tag",
|
||||
"ToastNewUserUsernameError": "Enter a username",
|
||||
"ToastNoNewEpisodesFound": "No new episodes found",
|
||||
"ToastNoRSSFeed": "Podcast does not have an RSS Feed",
|
||||
"ToastNoUpdatesNecessary": "No updates necessary",
|
||||
"ToastNotificationCreateFailed": "Failed to create notification",
|
||||
"ToastNotificationDeleteFailed": "Failed to delete notification",
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"LabelDiscover": "Descubrir",
|
||||
"LabelDownload": "Descargar",
|
||||
"LabelDownloadNEpisodes": "Descargar {0} episodios",
|
||||
"LabelDownloadable": "Descarregable",
|
||||
"LabelDuration": "Duración",
|
||||
"LabelDurationComparisonExactMatch": "(coincidencia exacta)",
|
||||
"LabelDurationComparisonLonger": "({0} más largo)",
|
||||
@@ -588,6 +589,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca",
|
||||
"LabelSettingsTimeFormat": "Formato de Tiempo",
|
||||
"LabelShare": "Compartir",
|
||||
"LabelShareDownloadableHelp": "Permet als usuaris amb l'enllaç compartit descarregar un arxiu zip amb l'item de la llibreria.",
|
||||
"LabelShareOpen": "abrir un recurso compartido",
|
||||
"LabelShareURL": "Compartir la URL",
|
||||
"LabelShowAll": "Mostrar Todos",
|
||||
@@ -756,6 +758,7 @@
|
||||
"MessageConfirmResetProgress": "¿Estás seguro de que quieres reiniciar tu progreso?",
|
||||
"MessageConfirmSendEbookToDevice": "¿Está seguro de que enviar {0} ebook(s) \"{1}\" al dispositivo \"{2}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "¿Estás seguro de que deseas desvincular este usuario de OpenID?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} dies escoltats en l'últim any",
|
||||
"MessageDownloadingEpisode": "Descargando Capitulo",
|
||||
"MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas",
|
||||
"MessageEmbedFailed": "¡Error al insertar!",
|
||||
@@ -771,7 +774,6 @@
|
||||
"MessageItemsSelected": "{0} Elementos Seleccionados",
|
||||
"MessageItemsUpdated": "{0} Elementos Actualizados",
|
||||
"MessageJoinUsOn": "Únetenos en",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} sesiones de escucha en el último año",
|
||||
"MessageLoading": "Cargando...",
|
||||
"MessageLoadingFolders": "Cargando archivos...",
|
||||
"MessageLogsDescription": "Logs son almacenados en <code>/metadata/logs</code> en archivos bajo formato JSON. Logs de fallos son almacenados en <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@@ -951,7 +953,6 @@
|
||||
"ToastBookmarkCreateFailed": "Error al crear marcador",
|
||||
"ToastBookmarkCreateSuccess": "Marcador Agregado",
|
||||
"ToastBookmarkRemoveSuccess": "Marcador eliminado",
|
||||
"ToastBookmarkUpdateSuccess": "Marcador actualizado",
|
||||
"ToastCachePurgeFailed": "Error al purgar el caché",
|
||||
"ToastCachePurgeSuccess": "Caché purgado de manera exitosa",
|
||||
"ToastChaptersHaveErrors": "Los capítulos tienen errores",
|
||||
@@ -959,8 +960,6 @@
|
||||
"ToastChaptersRemoved": "Capítulos eliminados",
|
||||
"ToastChaptersUpdated": "Capítulos actualizados",
|
||||
"ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)",
|
||||
"ToastCollectionItemsAddSuccess": "Artículo(s) añadido(s) a la colección correctamente",
|
||||
"ToastCollectionItemsRemoveSuccess": "Elementos(s) removidos de la colección",
|
||||
"ToastCollectionRemoveSuccess": "Colección removida",
|
||||
"ToastCollectionUpdateSuccess": "Colección actualizada",
|
||||
"ToastCoverUpdateFailed": "Error al actualizar la cubierta",
|
||||
@@ -1000,7 +999,7 @@
|
||||
"ToastLibraryScanFailedToStart": "Error al iniciar el escaneo",
|
||||
"ToastLibraryScanStarted": "Se inició el escaneo de la biblioteca",
|
||||
"ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada",
|
||||
"ToastMatchAllAuthorsFailed": "No coincide con todos los autores",
|
||||
"ToastMatchAllAuthorsFailed": "No se pudo encontrar a todos los autores",
|
||||
"ToastMetadataFilesRemovedError": "Error al eliminar metadatos de {0} archivo(s)",
|
||||
"ToastMetadataFilesRemovedNoneFound": "No hay metadatos.{0} archivo(s) encontrado(s) en la biblioteca",
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "Sin metadatos.{0} archivo(s) eliminado(s)",
|
||||
|
||||
@@ -611,7 +611,6 @@
|
||||
"MessageItemsSelected": "{0} Valitud üksust",
|
||||
"MessageItemsUpdated": "{0} Üksust on uuendatud",
|
||||
"MessageJoinUsOn": "Liitu meiega",
|
||||
"MessageListeningSessionsInTheLastYear": "Kuulamissessioone viimase aasta jooksul: {0}",
|
||||
"MessageLoading": "Laadimine...",
|
||||
"MessageLoadingFolders": "Kaustade laadimine...",
|
||||
"MessageM4BFailed": "M4B ebaõnnestus!",
|
||||
@@ -710,10 +709,8 @@
|
||||
"ToastBookmarkCreateFailed": "Järjehoidja loomine ebaõnnestus",
|
||||
"ToastBookmarkCreateSuccess": "Järjehoidja lisatud",
|
||||
"ToastBookmarkRemoveSuccess": "Järjehoidja eemaldatud",
|
||||
"ToastBookmarkUpdateSuccess": "Järjehoidja värskendatud",
|
||||
"ToastChaptersHaveErrors": "Peatükkidel on vigu",
|
||||
"ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri",
|
||||
"ToastCollectionItemsRemoveSuccess": "Üksus(ed) eemaldatud kogumist",
|
||||
"ToastCollectionRemoveSuccess": "Kogum eemaldatud",
|
||||
"ToastCollectionUpdateSuccess": "Kogum värskendatud",
|
||||
"ToastItemCoverUpdateSuccess": "Üksuse kaas värskendatud",
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"ButtonPurgeItemsCache": "Tyhjennä kohteiden välimuisti",
|
||||
"ButtonQueueAddItem": "Lisää jonoon",
|
||||
"ButtonQueueRemoveItem": "Poista jonosta",
|
||||
"ButtonQuickEmbed": "Pikaupota",
|
||||
"ButtonQuickMatch": "Pikatäsmää",
|
||||
"ButtonReScan": "Uudelleenskannaa",
|
||||
"ButtonRead": "Lue",
|
||||
@@ -85,6 +86,8 @@
|
||||
"ButtonSaveTracklist": "Tallenna raitalista",
|
||||
"ButtonScan": "Skannaa",
|
||||
"ButtonScanLibrary": "Skannaa kirjasto",
|
||||
"ButtonScrollLeft": "Vieritä vasemmalle",
|
||||
"ButtonScrollRight": "Vieritä oikealle",
|
||||
"ButtonSearch": "Etsi",
|
||||
"ButtonSelectFolderPath": "Valitse kansiopolku",
|
||||
"ButtonSeries": "Sarjat",
|
||||
@@ -148,6 +151,7 @@
|
||||
"HeaderLogs": "Lokit",
|
||||
"HeaderManageGenres": "Hallitse lajityyppejä",
|
||||
"HeaderManageTags": "Hallitse tageja",
|
||||
"HeaderMetadataOrderOfPrecedence": "Metadatan tärkeysjärjestys",
|
||||
"HeaderMetadataToEmbed": "Sisällytettävä metadata",
|
||||
"HeaderNewAccount": "Uusi tili",
|
||||
"HeaderNewLibrary": "Uusi kirjasto",
|
||||
@@ -156,6 +160,7 @@
|
||||
"HeaderNotifications": "Ilmoitukset",
|
||||
"HeaderOpenRSSFeed": "Avaa RSS-syöte",
|
||||
"HeaderOtherFiles": "Muut tiedostot",
|
||||
"HeaderPasswordAuthentication": "Salasanan todentaminen",
|
||||
"HeaderPermissions": "Käyttöoikeudet",
|
||||
"HeaderPlayerQueue": "Soittimen jono",
|
||||
"HeaderPlayerSettings": "Soittimen asetukset",
|
||||
@@ -169,22 +174,28 @@
|
||||
"HeaderRemoveEpisode": "Poista jakso",
|
||||
"HeaderRemoveEpisodes": "Poista {0} jaksoa",
|
||||
"HeaderSchedule": "Ajoita",
|
||||
"HeaderScheduleEpisodeDownloads": "Ajoita automaattiset jaksolataukset",
|
||||
"HeaderScheduleLibraryScans": "Ajoita automaattiset kirjastoskannaukset",
|
||||
"HeaderSession": "Istunto",
|
||||
"HeaderSetBackupSchedule": "Aseta varmuuskopiointiaikataulu",
|
||||
"HeaderSettings": "Asetukset",
|
||||
"HeaderSettingsDisplay": "Näyttö",
|
||||
"HeaderSettingsExperimental": "Kokeelliset ominaisuudet",
|
||||
"HeaderSettingsGeneral": "Yleiset",
|
||||
"HeaderSettingsScanner": "Skannaaja",
|
||||
"HeaderSleepTimer": "Uniajastin",
|
||||
"HeaderStatsMinutesListeningChart": "Kuunteluminuutit (viim. 7 pv)",
|
||||
"HeaderStatsRecentSessions": "Viimeaikaiset istunnot",
|
||||
"HeaderStatsTop10Authors": "Top 10 kirjailijat",
|
||||
"HeaderStatsTop5Genres": "Top 5 lajityypit",
|
||||
"HeaderTableOfContents": "Sisällysluettelo",
|
||||
"HeaderTools": "Työkalut",
|
||||
"HeaderUpdateAccount": "Päivitä tili",
|
||||
"HeaderUpdateAuthor": "Päivitä kirjailija",
|
||||
"HeaderUpdateDetails": "Päivitä yksityiskohdat",
|
||||
"HeaderUpdateLibrary": "Päivitä kirjasto",
|
||||
"HeaderUsers": "Käyttäjät",
|
||||
"HeaderYearReview": "Vuosi {0} tarkasteltuna",
|
||||
"HeaderYourStats": "Tilastosi",
|
||||
"LabelAbridged": "Lyhennetty",
|
||||
"LabelAccountType": "Tilin tyyppi",
|
||||
@@ -204,11 +215,15 @@
|
||||
"LabelAllUsersExcludingGuests": "Kaikki käyttäjät vieraita lukuun ottamatta",
|
||||
"LabelAllUsersIncludingGuests": "Kaikki käyttäjät mukaan lukien vieraat",
|
||||
"LabelAlreadyInYourLibrary": "Jo kirjastossasi",
|
||||
"LabelAudioBitrate": "Äänen bittinopeus (esim. 128k)",
|
||||
"LabelAudioChannels": "Äänikanavat (1 tai 2)",
|
||||
"LabelAudioCodec": "Äänikoodekki",
|
||||
"LabelAuthor": "Tekijä",
|
||||
"LabelAuthorFirstLast": "Tekijä (Etunimi Sukunimi)",
|
||||
"LabelAuthorLastFirst": "Tekijä (Sukunimi, Etunimi)",
|
||||
"LabelAuthors": "Tekijät",
|
||||
"LabelAutoDownloadEpisodes": "Lataa jaksot automaattisesti",
|
||||
"LabelAutoFetchMetadata": "Etsi metadata automaattisesti",
|
||||
"LabelBackToUser": "Takaisin käyttäjään",
|
||||
"LabelBackupLocation": "Varmuuskopiointipaikka",
|
||||
"LabelBackupsEnableAutomaticBackups": "Ota automaattinen varmuuskopiointi käyttöön",
|
||||
@@ -238,24 +253,41 @@
|
||||
"LabelCurrent": "Nykyinen",
|
||||
"LabelDays": "Päivää",
|
||||
"LabelDescription": "Kuvaus",
|
||||
"LabelDeselectAll": "Poista valinta kaikista",
|
||||
"LabelDevice": "Laite",
|
||||
"LabelDeviceInfo": "Laitteen tiedot",
|
||||
"LabelDeviceIsAvailableTo": "Laite on saatavilla...",
|
||||
"LabelDirectory": "Kansio",
|
||||
"LabelDiscover": "Löydä",
|
||||
"LabelDownload": "Lataa",
|
||||
"LabelDownloadNEpisodes": "Lataa {0} jaksoa",
|
||||
"LabelDownloadable": "Ladattavissa",
|
||||
"LabelDuration": "Kesto",
|
||||
"LabelDurationComparisonLonger": "({0} pidempi)",
|
||||
"LabelDurationComparisonShorter": "({0} lyhyempi)",
|
||||
"LabelDurationFound": "Kesto löydetty:",
|
||||
"LabelEbook": "E-kirja",
|
||||
"LabelEbooks": "E-kirjat",
|
||||
"LabelEdit": "Muokkaa",
|
||||
"LabelEmail": "Sähköposti",
|
||||
"LabelEmailSettingsFromAddress": "Osoitteesta",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "SSL-sertifikaatin varmentamisen käytöstä poistaminen saattaa vaarantaa yhteytesti turvallisuusriskeihin, kuten man-in-the-middle hyökkäyksiin. Poista käytöstä vain jos ymmärrät vaaran ja luotat yhdistämääsi sähköpostipalvelimeen.",
|
||||
"LabelEmailSettingsSecure": "Turvallinen",
|
||||
"LabelEmailSettingsTestAddress": "Testiosoite",
|
||||
"LabelEmbeddedCover": "Upotettu kansikuva",
|
||||
"LabelEnable": "Ota käyttöön",
|
||||
"LabelEncodingBackupLocation": "Alkuperäisistä audiotiedostoistasi tallennetaan varmuuskopio osoitteessa:",
|
||||
"LabelEncodingStartedNavigation": "Voit poistua sivulta kun tehtävä on aloitettu.",
|
||||
"LabelEncodingTimeWarning": "Koodaus saattaa kestää 30 minuuttiin asti.",
|
||||
"LabelEncodingWarningAdvancedSettings": "Varoitus: Älä päivitä näitä asetuksia ellet ymmärrä ffmpeg-koodausasetuksia.",
|
||||
"LabelEnd": "Loppu",
|
||||
"LabelEndOfChapter": "Luvun loppu",
|
||||
"LabelEpisode": "Jakso",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "Jakso ei yhdistetty RSS-syötteeseen",
|
||||
"LabelEpisodeNumber": "Jakso #{0}",
|
||||
"LabelEpisodeTitle": "Jakson nimi",
|
||||
"LabelEpisodeType": "Jakson tyyppi",
|
||||
"LabelEpisodeUrlFromRssFeed": "Jakson URL RSS-syötteestä",
|
||||
"LabelEpisodes": "Jaksot",
|
||||
"LabelExample": "Esimerkki",
|
||||
"LabelFeedURL": "Syötteen URL",
|
||||
@@ -287,12 +319,20 @@
|
||||
"LabelLanguageDefaultServer": "Palvelimen oletuskieli",
|
||||
"LabelLanguages": "Kielet",
|
||||
"LabelLastBookAdded": "Viimeisin lisätty kirja",
|
||||
"LabelLastBookUpdated": "Viimeisin päivitetty kirja",
|
||||
"LabelLastUpdate": "Viimeisin päivitys",
|
||||
"LabelLibrariesAccessibleToUser": "Käyttäjälle saatavilla olevat kirjastot",
|
||||
"LabelLibrary": "Kirjasto",
|
||||
"LabelLibraryName": "Kirjaston nimi",
|
||||
"LabelLineSpacing": "Riviväli",
|
||||
"LabelListenAgain": "Kuuntele uudelleen",
|
||||
"LabelLookForNewEpisodesAfterDate": "Etsi uusia jaksoja tämän päivämäärän jälkeen",
|
||||
"LabelMaxEpisodesToDownload": "Jaksojen maksimilatausmäärä. 0 poistaa rajoituksen.",
|
||||
"LabelMediaPlayer": "Mediasoitin",
|
||||
"LabelMediaType": "Mediatyyppi",
|
||||
"LabelMinute": "Minuutti",
|
||||
"LabelMinutes": "Minuutit",
|
||||
"LabelMissingEbook": "Ei e-kirjaa",
|
||||
"LabelMore": "Lisää",
|
||||
"LabelMoreInfo": "Lisätietoja",
|
||||
"LabelName": "Nimi",
|
||||
@@ -302,6 +342,7 @@
|
||||
"LabelNewPassword": "Uusi salasana",
|
||||
"LabelNewestAuthors": "Uusimmat kirjailijat",
|
||||
"LabelNewestEpisodes": "Uusimmat jaksot",
|
||||
"LabelNextBackupDate": "Seuraava varmuuskopiointipäivämäärä",
|
||||
"LabelNotStarted": "Ei aloitettu",
|
||||
"LabelPassword": "Salasana",
|
||||
"LabelPath": "Polku",
|
||||
|
||||
@@ -765,7 +765,6 @@
|
||||
"MessageItemsSelected": "{0} éléments sélectionnés",
|
||||
"MessageItemsUpdated": "{0} éléments mis à jour",
|
||||
"MessageJoinUsOn": "Rejoignez-nous sur",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} sessions d’écoute l’an dernier",
|
||||
"MessageLoading": "Chargement…",
|
||||
"MessageLoadingFolders": "Chargement des dossiers…",
|
||||
"MessageLogsDescription": "Les journaux sont stockés dans <code>/metadata/logs</code> sous forme de fichiers JSON. Les journaux d’incidents sont stockés dans <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@@ -945,7 +944,6 @@
|
||||
"ToastBookmarkCreateFailed": "Échec de la création de signet",
|
||||
"ToastBookmarkCreateSuccess": "Signet ajouté",
|
||||
"ToastBookmarkRemoveSuccess": "Signet supprimé",
|
||||
"ToastBookmarkUpdateSuccess": "Signet mis à jour",
|
||||
"ToastCachePurgeFailed": "Échec de la purge du cache",
|
||||
"ToastCachePurgeSuccess": "Cache purgé avec succès",
|
||||
"ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs",
|
||||
@@ -953,8 +951,6 @@
|
||||
"ToastChaptersRemoved": "Chapitres supprimés",
|
||||
"ToastChaptersUpdated": "Chapitres mis à jour",
|
||||
"ToastCollectionItemsAddFailed": "Échec de l’ajout de(s) élément(s) à la collection",
|
||||
"ToastCollectionItemsAddSuccess": "Ajout de(s) élément(s) à la collection réussi",
|
||||
"ToastCollectionItemsRemoveSuccess": "Élément(s) supprimé(s) de la collection",
|
||||
"ToastCollectionRemoveSuccess": "Collection supprimée",
|
||||
"ToastCollectionUpdateSuccess": "Collection mise à jour",
|
||||
"ToastCoverUpdateFailed": "Échec de la mise à jour de la couverture",
|
||||
|
||||
@@ -642,7 +642,6 @@
|
||||
"MessageItemsSelected": "{0} פריטים נבחרו",
|
||||
"MessageItemsUpdated": "{0} פריטים עודכנו",
|
||||
"MessageJoinUsOn": "הצטרף אלינו ב-",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} מפגשי האזנה בשנה האחרונה",
|
||||
"MessageLoading": "טוען...",
|
||||
"MessageLoadingFolders": "טוען תיקיות...",
|
||||
"MessageM4BFailed": "M4B נכשל!",
|
||||
@@ -741,10 +740,8 @@
|
||||
"ToastBookmarkCreateFailed": "יצירת סימניה נכשלה",
|
||||
"ToastBookmarkCreateSuccess": "הסימניה נוספה בהצלחה",
|
||||
"ToastBookmarkRemoveSuccess": "הסימניה הוסרה בהצלחה",
|
||||
"ToastBookmarkUpdateSuccess": "הסימניה עודכנה בהצלחה",
|
||||
"ToastChaptersHaveErrors": "פרקים מכילים שגיאות",
|
||||
"ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות",
|
||||
"ToastCollectionItemsRemoveSuccess": "הפריט(ים) הוסרו מהאוסף בהצלחה",
|
||||
"ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה",
|
||||
"ToastCollectionUpdateSuccess": "האוסף עודכן בהצלחה",
|
||||
"ToastItemCoverUpdateSuccess": "כריכת הפריט עודכנה בהצלחה",
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"LabelDiscover": "Otkrij",
|
||||
"LabelDownload": "Preuzmi",
|
||||
"LabelDownloadNEpisodes": "Preuzmi {0} nastavak/a",
|
||||
"LabelDownloadable": "Moguće preuzimanje",
|
||||
"LabelDuration": "Trajanje",
|
||||
"LabelDurationComparisonExactMatch": "(točno podudaranje)",
|
||||
"LabelDurationComparisonLonger": "({0} duže)",
|
||||
@@ -588,6 +589,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Meta-podatci se obično spremaju u /metadata/items; ako uključite ovu postavku meta-podatci će se čuvati u mapama knjižničkih stavki",
|
||||
"LabelSettingsTimeFormat": "Format vremena",
|
||||
"LabelShare": "Podijeli",
|
||||
"LabelShareDownloadableHelp": "Korisnicima s poveznicom za dijeljenje omogućuje preuzimanje stavke.",
|
||||
"LabelShareOpen": "Dijeljenje otvoreno",
|
||||
"LabelShareURL": "URL za dijeljenje",
|
||||
"LabelShowAll": "Prikaži sve",
|
||||
@@ -756,6 +758,7 @@
|
||||
"MessageConfirmResetProgress": "Sigurno želite resetirati napredak?",
|
||||
"MessageConfirmSendEbookToDevice": "Sigurno želite poslati {0} e-knjiga/u \"{1}\" na uređaj \"{2}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "Sigurno želite odspojiti ovog korisnika s OpenID-ja?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} dana slušanja u posljednjih godinu dana",
|
||||
"MessageDownloadingEpisode": "Preuzimam nastavak",
|
||||
"MessageDragFilesIntoTrackOrder": "Prevlačenjem datoteka složite pravilan redoslijed",
|
||||
"MessageEmbedFailed": "Ugrađivanje nije uspjelo!",
|
||||
@@ -771,7 +774,6 @@
|
||||
"MessageItemsSelected": "{0} odabranih stavki",
|
||||
"MessageItemsUpdated": "{0} stavki ažurirano",
|
||||
"MessageJoinUsOn": "Pridruži nam se na",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} slušanja u prošloj godini",
|
||||
"MessageLoading": "Učitavam...",
|
||||
"MessageLoadingFolders": "Učitavam mape...",
|
||||
"MessageLogsDescription": "Zapisnici se čuvaju u <code>/metadata/logs</code> u obliku JSON datoteka. Zapisnici pada sustava čuvaju se u datoteci <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@@ -951,7 +953,6 @@
|
||||
"ToastBookmarkCreateFailed": "Izrada knjižne oznake nije uspjela",
|
||||
"ToastBookmarkCreateSuccess": "Knjižna oznaka dodana",
|
||||
"ToastBookmarkRemoveSuccess": "Knjižna oznaka uklonjena",
|
||||
"ToastBookmarkUpdateSuccess": "Knjižna oznaka ažurirana",
|
||||
"ToastCachePurgeFailed": "Čišćenje predmemorije nije uspjelo",
|
||||
"ToastCachePurgeSuccess": "Predmemorija uspješno očišćena",
|
||||
"ToastChaptersHaveErrors": "Poglavlja imaju pogreške",
|
||||
@@ -959,8 +960,6 @@
|
||||
"ToastChaptersRemoved": "Poglavlja uklonjena",
|
||||
"ToastChaptersUpdated": "Poglavlja su ažurirana",
|
||||
"ToastCollectionItemsAddFailed": "Neuspješno dodavanje stavki u zbirku",
|
||||
"ToastCollectionItemsAddSuccess": "Uspješno dodavanje stavki u zbirku",
|
||||
"ToastCollectionItemsRemoveSuccess": "Stavke izbrisane iz zbirke",
|
||||
"ToastCollectionRemoveSuccess": "Zbirka izbrisana",
|
||||
"ToastCollectionUpdateSuccess": "Zbirka ažurirana",
|
||||
"ToastCoverUpdateFailed": "Ažuriranje naslovnice nije uspjelo",
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"ButtonStartM4BEncode": "M4B kódolás indítása",
|
||||
"ButtonStartMetadataEmbed": "Metaadatok beágyazásának indítása",
|
||||
"ButtonStats": "Statisztikák",
|
||||
"ButtonSubmit": "Beküldés",
|
||||
"ButtonSubmit": "Küldés",
|
||||
"ButtonTest": "Teszt",
|
||||
"ButtonUnlinkOpenId": "OpenID szétkapcsolása",
|
||||
"ButtonUpload": "Feltöltés",
|
||||
@@ -143,7 +143,7 @@
|
||||
"HeaderFindChapters": "Fejezetek keresése",
|
||||
"HeaderIgnoredFiles": "Figyelmen kívül hagyott fájlok",
|
||||
"HeaderItemFiles": "Elemfájlok",
|
||||
"HeaderItemMetadataUtils": "Elem metaadat eszközök",
|
||||
"HeaderItemMetadataUtils": "Metaadatok eszközei",
|
||||
"HeaderLastListeningSession": "Utolsó hallgatási munkamenet",
|
||||
"HeaderLatestEpisodes": "Legújabb epizódok",
|
||||
"HeaderLibraries": "Könyvtárak",
|
||||
@@ -165,6 +165,7 @@
|
||||
"HeaderNotificationUpdate": "Értesítés frissítése",
|
||||
"HeaderNotifications": "Értesítések",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect hitelesítés",
|
||||
"HeaderOpenListeningSessions": "Hallgatási menetek megnyitása",
|
||||
"HeaderOpenRSSFeed": "RSS hírcsatorna megnyitása",
|
||||
"HeaderOtherFiles": "Egyéb fájlok",
|
||||
"HeaderPasswordAuthentication": "Jelszó hitelesítés",
|
||||
@@ -194,7 +195,7 @@
|
||||
"HeaderSettingsWebClient": "Webkliens",
|
||||
"HeaderSleepTimer": "Alvásidőzítő",
|
||||
"HeaderStatsLargestItems": "Legnagyobb elemek",
|
||||
"HeaderStatsLongestItems": "Leghosszabb elemek (órákban)",
|
||||
"HeaderStatsLongestItems": "Leghosszabb elemek (órában)",
|
||||
"HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)",
|
||||
"HeaderStatsRecentSessions": "Legutóbbi munkamenetek",
|
||||
"HeaderStatsTop10Authors": "Top 10 szerző",
|
||||
@@ -206,7 +207,7 @@
|
||||
"HeaderUpdateDetails": "Részletek frissítése",
|
||||
"HeaderUpdateLibrary": "Könyvtár frissítése",
|
||||
"HeaderUsers": "Felhasználók",
|
||||
"HeaderYearReview": "{0} év visszatekintése",
|
||||
"HeaderYearReview": "Visszatekintés {0} -ra/re",
|
||||
"HeaderYourStats": "Saját statisztikák",
|
||||
"LabelAbridged": "Tömörített",
|
||||
"LabelAbridgedChecked": "Rövidített (ellenőrizve)",
|
||||
@@ -237,7 +238,7 @@
|
||||
"LabelAuthor": "Szerző",
|
||||
"LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)",
|
||||
"LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)",
|
||||
"LabelAuthors": "Szerzők",
|
||||
"LabelAuthors": "Szerző",
|
||||
"LabelAutoDownloadEpisodes": "Epizódok automatikus letöltése",
|
||||
"LabelAutoFetchMetadata": "Metaadatok automatikus lekérése",
|
||||
"LabelAutoFetchMetadataHelp": "Cím, szerző és sorozat metaadatok automatikus lekérése a feltöltés megkönnyítése érdekében. További metaadatok egyeztetése szükséges lehet a feltöltés után.",
|
||||
@@ -272,7 +273,7 @@
|
||||
"LabelCollapseSeries": "Sorozat összecsukása",
|
||||
"LabelCollapseSubSeries": "Alszéria összecsukása",
|
||||
"LabelCollection": "Gyűjtemény",
|
||||
"LabelCollections": "Gyűjtemények",
|
||||
"LabelCollections": "Gyűjtemény",
|
||||
"LabelComplete": "Kész",
|
||||
"LabelConfirmPassword": "Jelszó megerősítése",
|
||||
"LabelContinueListening": "Hallgatás folytatása",
|
||||
@@ -299,6 +300,7 @@
|
||||
"LabelDiscover": "Felfedezés",
|
||||
"LabelDownload": "Letöltés",
|
||||
"LabelDownloadNEpisodes": "{0} epizód letöltése",
|
||||
"LabelDownloadable": "Letölthető",
|
||||
"LabelDuration": "Időtartam",
|
||||
"LabelDurationComparisonExactMatch": "(pontos egyezés)",
|
||||
"LabelDurationComparisonLonger": "({0}-val hosszabb)",
|
||||
@@ -320,6 +322,7 @@
|
||||
"LabelEncodingChaptersNotEmbedded": "A fejezetek nincsenek beágyazva a többsávos hangoskönyvekbe.",
|
||||
"LabelEncodingClearItemCache": "Győződjön meg róla, hogy rendszeresen tisztítja az elemek gyorsítótárát.",
|
||||
"LabelEncodingFinishedM4B": "A kész M4B a hangoskönyv mappádba kerül:",
|
||||
"LabelEncodingInfoEmbedded": "A metaadatok beépülnek a hangsávokba a hangoskönyv mappáján belül.",
|
||||
"LabelEncodingStartedNavigation": "Ha a feladat elindult, el lehet navigálni erről az oldalról.",
|
||||
"LabelEncodingTimeWarning": "A kódolás akár 30 percet is igénybe vehet.",
|
||||
"LabelEncodingWarningAdvancedSettings": "Figyelmeztetés: Ne frissítse ezeket a beállításokat, hacsak nem ismeri az ffmpeg kódolási beállításait.",
|
||||
@@ -441,7 +444,7 @@
|
||||
"LabelNarrators": "Előadók",
|
||||
"LabelNew": "Új",
|
||||
"LabelNewPassword": "Új jelszó",
|
||||
"LabelNewestAuthors": "Legújabb szerzők",
|
||||
"LabelNewestAuthors": "A legújabb szerzők",
|
||||
"LabelNewestEpisodes": "Legújabb epizódok",
|
||||
"LabelNextBackupDate": "Következő biztonsági másolat dátuma",
|
||||
"LabelNextScheduledRun": "Következő ütemezett futtatás",
|
||||
@@ -478,7 +481,7 @@
|
||||
"LabelPermissionsDownload": "Letölthet",
|
||||
"LabelPermissionsUpdate": "Frissíthet",
|
||||
"LabelPermissionsUpload": "Feltölthet",
|
||||
"LabelPersonalYearReview": "Az évvisszatekintésed ({0})",
|
||||
"LabelPersonalYearReview": "Az éved összefoglalása ({0})",
|
||||
"LabelPhotoPathURL": "Fénykép útvonal/URL",
|
||||
"LabelPlayMethod": "Lejátszási módszer",
|
||||
"LabelPlayerChapterNumberMarker": "{0} a {1} -ből",
|
||||
@@ -535,11 +538,12 @@
|
||||
"LabelSelectUsers": "Felhasználók kiválasztása",
|
||||
"LabelSendEbookToDevice": "E-könyv küldése...",
|
||||
"LabelSequence": "Sorozat",
|
||||
"LabelSerial": "Sorozat",
|
||||
"LabelSeries": "Sorozat",
|
||||
"LabelSeriesName": "Sorozat neve",
|
||||
"LabelSeriesProgress": "Sorozat haladása",
|
||||
"LabelServerLogLevel": "Kiszolgáló naplózási szint",
|
||||
"LabelServerYearReview": "Szerver évvisszatekintés ({0})",
|
||||
"LabelServerYearReview": "Szerver éves visszatekintése ({0})",
|
||||
"LabelSetEbookAsPrimary": "Beállítás elsődlegesként",
|
||||
"LabelSetEbookAsSupplementary": "Beállítás kiegészítőként",
|
||||
"LabelSettingsAllowIframe": "A beágyazás engedélyezése egy iframe-be",
|
||||
@@ -585,7 +589,11 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Alapértelmezés szerint a metaadatfájlok a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a metaadatfájlokat a könyvtári elem mappáiban tárolja",
|
||||
"LabelSettingsTimeFormat": "Időformátum",
|
||||
"LabelShare": "Megosztás",
|
||||
"LabelShareDownloadableHelp": "Lehetővé teszi a megosztási linket birtokló felhasználók számára, hogy letöltsék a könyvtári elem zip-fájlját.",
|
||||
"LabelShareOpen": "Megosztás megnyitása",
|
||||
"LabelShareURL": "URL megosztása",
|
||||
"LabelShowAll": "Mindent mutat",
|
||||
"LabelShowSeconds": "Másodperc megjelenítése",
|
||||
"LabelShowSubtitles": "Felirat megjelenítése",
|
||||
"LabelSize": "Méret",
|
||||
"LabelSleepTimer": "Alvásidőzítő",
|
||||
@@ -596,8 +604,8 @@
|
||||
"LabelStartTime": "Kezdési idő",
|
||||
"LabelStarted": "Elkezdődött",
|
||||
"LabelStartedAt": "Kezdés ideje",
|
||||
"LabelStatsAudioTracks": "Audiósávok",
|
||||
"LabelStatsAuthors": "Szerzők",
|
||||
"LabelStatsAudioTracks": "Audiósáv",
|
||||
"LabelStatsAuthors": "Szerző",
|
||||
"LabelStatsBestDay": "Legjobb nap",
|
||||
"LabelStatsDailyAverage": "Napi átlag",
|
||||
"LabelStatsDays": "Napok",
|
||||
@@ -605,7 +613,7 @@
|
||||
"LabelStatsHours": "Órák",
|
||||
"LabelStatsInARow": "egymás után",
|
||||
"LabelStatsItemsFinished": "Befejezett elem",
|
||||
"LabelStatsItemsInLibrary": "Elemek a könyvtárban",
|
||||
"LabelStatsItemsInLibrary": "Elem a könyvtárban",
|
||||
"LabelStatsMinutes": "perc",
|
||||
"LabelStatsMinutesListening": "Hallgatási perc",
|
||||
"LabelStatsOverallDays": "Összes nap",
|
||||
@@ -684,8 +692,8 @@
|
||||
"LabelWeekdaysToRun": "Futás napjai",
|
||||
"LabelXBooks": "{0} könyv",
|
||||
"LabelXItems": "{0} elem",
|
||||
"LabelYearReviewHide": "Az évvisszatekintés elrejtése",
|
||||
"LabelYearReviewShow": "Évvisszatekintés megtekintése",
|
||||
"LabelYearReviewHide": "Visszatekintés az évre elrejtése",
|
||||
"LabelYearReviewShow": "Visszatekintés az évre megtekintése",
|
||||
"LabelYourAudiobookDuration": "Hangoskönyv időtartama",
|
||||
"LabelYourBookmarks": "Könyvjelzőid",
|
||||
"LabelYourPlaylists": "Lejátszási listáid",
|
||||
@@ -750,10 +758,12 @@
|
||||
"MessageConfirmResetProgress": "Biztos, hogy vissza akarja állítani a haladási folyamatát?",
|
||||
"MessageConfirmSendEbookToDevice": "Biztosan el szeretné küldeni a(z) {0} e-könyvet a(z) \"{1}\" eszközre?",
|
||||
"MessageConfirmUnlinkOpenId": "Biztos, hogy el akarja távolítani ezt a felhasználót az OpenID-ból?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} napot hallgatott az elmúlt évben",
|
||||
"MessageDownloadingEpisode": "Epizód letöltése",
|
||||
"MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe",
|
||||
"MessageEmbedFailed": "A beágyazás sikertelen!",
|
||||
"MessageEmbedFinished": "Beágyazás befejeződött!",
|
||||
"MessageEmbedQueue": "Metaadatok beágyazására várakozik ({0} a sorban)",
|
||||
"MessageEpisodesQueuedForDownload": "{0} epizód letöltésre vár",
|
||||
"MessageEreaderDevices": "Az e-könyvek kézbesítésének biztosítása érdekében a fenti e-mail címet az alább felsorolt minden egyes eszközhöz, mint érvényes feladót kell hozzáadnia.",
|
||||
"MessageFeedURLWillBe": "A hírcsatorna URL-je {0} lesz",
|
||||
@@ -764,7 +774,6 @@
|
||||
"MessageItemsSelected": "{0} kiválasztott elem",
|
||||
"MessageItemsUpdated": "{0} frissített elem",
|
||||
"MessageJoinUsOn": "Csatlakozzon hozzánk a",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} hallgatási munkamenet az elmúlt évben",
|
||||
"MessageLoading": "Betöltés...",
|
||||
"MessageLoadingFolders": "Mappák betöltése...",
|
||||
"MessageLogsDescription": "A naplók a <code>/metadata/logs</code> mappában JSON-fájlokként tárolódnak. Az összeomlási naplók a <code>/metadata/logs/crash_logs.txt</code> fájlban tárolódnak.",
|
||||
@@ -817,6 +826,7 @@
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "A podcastnak nincs RSS hírcsatorna URL-je az egyeztetéshez",
|
||||
"MessagePodcastSearchField": "Adja meg a keresési kifejezést vagy az RSS hírcsatorna URL-címét",
|
||||
"MessageQuickEmbedInProgress": "Gyors beágyazás folyamatban",
|
||||
"MessageQuickEmbedQueue": "Gyors beágyazásra várakozik ({0} a sorban)",
|
||||
"MessageQuickMatchAllEpisodes": "Minden epizód gyors egyeztetése",
|
||||
"MessageQuickMatchDescription": "Üres elem részletek és borító feltöltése az első találati eredménnyel a(z) '{0}'-ból. Nem írja felül a részleteket, kivéve, ha a 'Preferált egyeztetett metaadatok' szerverbeállítás engedélyezve van.",
|
||||
"MessageRemoveChapter": "Fejezet eltávolítása",
|
||||
@@ -833,6 +843,7 @@
|
||||
"MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként",
|
||||
"MessageShareExpirationWillBe": "A lejárat: <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "{0} múlva jár le",
|
||||
"MessageShareURLWillBe": "A megosztási URL <strong>{0}</strong> lesz",
|
||||
"MessageStartPlaybackAtTime": "\"{0}\" lejátszásának kezdése {1} -tól?",
|
||||
"MessageTaskAudioFileNotWritable": "A/Az „{0}” hangfájl nem írható",
|
||||
"MessageTaskCanceledByUser": "Felhasználó törölte a feladatot",
|
||||
@@ -938,14 +949,12 @@
|
||||
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
|
||||
"ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva",
|
||||
"ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva",
|
||||
"ToastBookmarkUpdateSuccess": "Könyvjelző frissítve",
|
||||
"ToastCachePurgeFailed": "A gyorsítótár törlése sikertelen",
|
||||
"ToastCachePurgeSuccess": "A gyorsítótár sikeresen törölve",
|
||||
"ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
|
||||
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
|
||||
"ToastChaptersRemoved": "Fejezetek eltávolítva",
|
||||
"ToastChaptersUpdated": "Fejezetek frissítve",
|
||||
"ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből",
|
||||
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
|
||||
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
|
||||
"ToastCoverUpdateFailed": "A borító frissítése nem sikerült",
|
||||
@@ -956,9 +965,11 @@
|
||||
"ToastDeviceTestEmailFailed": "Teszt email küldése sikertelen",
|
||||
"ToastDeviceTestEmailSuccess": "Teszt email elküldve",
|
||||
"ToastEmailSettingsUpdateSuccess": "Email beállítások frissítve",
|
||||
"ToastEncodeCancelFailed": "A kódolás törlése sikertelen volt",
|
||||
"ToastEncodeCancelSucces": "Kódolás törölve",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "Nem sikerült törölni a várólistát",
|
||||
"ToastEpisodeUpdateSuccess": "{0} epizód frissítve",
|
||||
"ToastErrorCannotShare": "Ezen az eszközön nem lehet natívan megosztani",
|
||||
"ToastFailedToLoadData": "Sikertelen adatbetöltés",
|
||||
"ToastFailedToMatch": "Nem sikerült egyezőséget találni",
|
||||
"ToastFailedToShare": "Nem sikerült megosztani",
|
||||
@@ -994,10 +1005,14 @@
|
||||
"ToastNewUserCreatedSuccess": "Új fiók létrehozva",
|
||||
"ToastNewUserLibraryError": "Legalább egy könyvtárat ki kell választani",
|
||||
"ToastNewUserPasswordError": "Kötelező a jelszó, csak a root felhasználónak lehet üres jelszava",
|
||||
"ToastNewUserTagError": "Legalább egy címkét ki kell választania",
|
||||
"ToastNewUserUsernameError": "Adjon meg egy felhasználónevet",
|
||||
"ToastNoNewEpisodesFound": "Nincs új epizód",
|
||||
"ToastNoUpdatesNecessary": "Nincs szükség frissítésre",
|
||||
"ToastNotificationCreateFailed": "Értesítés létrehozása sikertelen",
|
||||
"ToastNotificationDeleteFailed": "Értesítés törlése sikertelen",
|
||||
"ToastNotificationSettingsUpdateSuccess": "Értesítési beállítások frissítve",
|
||||
"ToastNotificationTestTriggerFailed": "Nem sikerült a tesztértesítést elindítani",
|
||||
"ToastNotificationUpdateSuccess": "Értesítés frissítve",
|
||||
"ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen",
|
||||
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
|
||||
@@ -1007,22 +1022,37 @@
|
||||
"ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva",
|
||||
"ToastPodcastNoEpisodesInFeed": "Nincsenek epizódok az RSS hírcsatornában",
|
||||
"ToastPodcastNoRssFeed": "A podcastnak nincs RSS-hírcsatornája",
|
||||
"ToastProgressIsNotBeingSynced": "Az előrehaladás nem szinkronizálódik, a lejátszás újraindul",
|
||||
"ToastProviderCreatedFailed": "Hiba a szolgáltató hozzáadásakor",
|
||||
"ToastProviderCreatedSuccess": "Új szolgáltató hozzáadva",
|
||||
"ToastProviderNameAndUrlRequired": "Név és Url kötelező",
|
||||
"ToastProviderRemoveSuccess": "Szolgáltató eltávolítva",
|
||||
"ToastRSSFeedCloseFailed": "Az RSS hírcsatorna bezárása sikertelen",
|
||||
"ToastRSSFeedCloseSuccess": "RSS hírfolyam leállítva",
|
||||
"ToastRemoveFailed": "Sikertelen eltávolítás",
|
||||
"ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
|
||||
"ToastRenameFailed": "Sikertelen átnevezés",
|
||||
"ToastSelectAtLeastOneUser": "Válasszon legalább egy felhasználót",
|
||||
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
|
||||
"ToastSendEbookToDeviceSuccess": "E-könyv elküldve az eszközre \"{0}\"",
|
||||
"ToastSeriesUpdateFailed": "Sorozat frissítése sikertelen",
|
||||
"ToastSeriesUpdateSuccess": "Sorozat frissítése sikeres",
|
||||
"ToastServerSettingsUpdateSuccess": "Szerver beállítások frissítve",
|
||||
"ToastSessionCloseFailed": "A munkamenet bezárása sikertelen",
|
||||
"ToastSessionDeleteFailed": "Munkamenet törlése sikertelen",
|
||||
"ToastSessionDeleteSuccess": "Munkamenet törölve",
|
||||
"ToastSleepTimerDone": "Alvásidőzítő kész... zZzzZZz",
|
||||
"ToastSocketConnected": "Socket csatlakoztatva",
|
||||
"ToastSocketDisconnected": "Socket lecsatlakoztatva",
|
||||
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
|
||||
"ToastSortingPrefixesEmptyError": "Legalább 1 rendezési előtaggal kell rendelkeznie",
|
||||
"ToastSortingPrefixesUpdateSuccess": "Rendezési előtagok frissítése ({0} elem)",
|
||||
"ToastTitleRequired": "A cím kötelező",
|
||||
"ToastUnknownError": "Ismeretlen hiba",
|
||||
"ToastUserDeleteFailed": "Felhasználó törlése sikertelen",
|
||||
"ToastUserDeleteSuccess": "Felhasználó törölve",
|
||||
"ToastUserPasswordChangeSuccess": "Jelszó sikeresen megváltoztatva",
|
||||
"ToastUserPasswordMustChange": "Az új jelszó nem egyezik a régi jelszóval",
|
||||
"ToastUserRootRequireName": "Egy root felhasználónevet kell megadnia"
|
||||
}
|
||||
|
||||
@@ -762,7 +762,6 @@
|
||||
"MessageItemsSelected": "{0} oggetti Selezionati",
|
||||
"MessageItemsUpdated": "{0} Oggetti aggiornati",
|
||||
"MessageJoinUsOn": "Unisciti a noi su",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} sessioni di ascolto nell'ultimo anno",
|
||||
"MessageLoading": "Caricamento…",
|
||||
"MessageLoadingFolders": "Caricamento Cartelle...",
|
||||
"MessageLogsDescription": "I log vengono archiviati nel percorso <code>/metadata/logs</code> as JSON files. I registri degli arresti anomali vengono archiviati nel percorso <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@@ -942,7 +941,6 @@
|
||||
"ToastBookmarkCreateFailed": "Creazione segnalibro fallita",
|
||||
"ToastBookmarkCreateSuccess": "Segnalibro creato",
|
||||
"ToastBookmarkRemoveSuccess": "Segnalibro Rimosso",
|
||||
"ToastBookmarkUpdateSuccess": "Segnalibro aggiornato",
|
||||
"ToastCachePurgeFailed": "Impossibile eliminare la cache",
|
||||
"ToastCachePurgeSuccess": "Cache eliminata correttamente",
|
||||
"ToastChaptersHaveErrors": "I capitoli contengono errori",
|
||||
@@ -950,8 +948,6 @@
|
||||
"ToastChaptersRemoved": "Capitoli rimossi",
|
||||
"ToastChaptersUpdated": "Capitoli aggiornati",
|
||||
"ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito",
|
||||
"ToastCollectionItemsAddSuccess": "L'aggiunta dell'elemento(i) alla raccolta è riuscito",
|
||||
"ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta",
|
||||
"ToastCollectionRemoveSuccess": "Collezione rimossa",
|
||||
"ToastCollectionUpdateSuccess": "Raccolta aggiornata",
|
||||
"ToastCoverUpdateFailed": "Aggiornamento cover fallito",
|
||||
|
||||
@@ -563,7 +563,6 @@
|
||||
"MessageItemsSelected": "Pasirinkti {0} elementai (-ų)",
|
||||
"MessageItemsUpdated": "Atnaujinti {0} elementai (-ų)",
|
||||
"MessageJoinUsOn": "Prisijunkite prie mūsų",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} klausymo sesijų per paskutinius metus",
|
||||
"MessageLoading": "Kraunama...",
|
||||
"MessageLoadingFolders": "Kraunami aplankai...",
|
||||
"MessageM4BFailed": "M4B Nepavyko!",
|
||||
@@ -661,13 +660,10 @@
|
||||
"ToastBookmarkCreateFailed": "Žymos sukurti nepavyko",
|
||||
"ToastBookmarkCreateSuccess": "Žyma pridėta",
|
||||
"ToastBookmarkRemoveSuccess": "Žyma pašalinta",
|
||||
"ToastBookmarkUpdateSuccess": "Žyma atnaujinta",
|
||||
"ToastChaptersHaveErrors": "Skyriai turi klaidų",
|
||||
"ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus",
|
||||
"ToastChaptersRemoved": "Skyriai pašalinti",
|
||||
"ToastCollectionItemsAddFailed": "Nepavyko pridėti į kolekciją",
|
||||
"ToastCollectionItemsAddSuccess": "Pridėta į kolekciją",
|
||||
"ToastCollectionItemsRemoveSuccess": "Elementai pašalinti iš kolekcijos",
|
||||
"ToastCollectionRemoveSuccess": "Kolekcija pašalinta",
|
||||
"ToastCollectionUpdateSuccess": "Kolekcija atnaujinta",
|
||||
"ToastCoverUpdateFailed": "Viršelio atnaujinimas nepavyko",
|
||||
|
||||
@@ -758,7 +758,6 @@
|
||||
"MessageItemsSelected": "{0} onderdelen geselecteerd",
|
||||
"MessageItemsUpdated": "{0} onderdelen bijgewerkt",
|
||||
"MessageJoinUsOn": "Doe mee op",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} luistersessies in het laatste jaar",
|
||||
"MessageLoading": "Aan het laden...",
|
||||
"MessageLoadingFolders": "Mappen aan het laden...",
|
||||
"MessageLogsDescription": "Logs worden opgeslagen in <code>/metadata/logs</code> als JSON-bestanden. Crashlogs worden opgeslagen in <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@@ -938,7 +937,6 @@
|
||||
"ToastBookmarkCreateFailed": "Aanmaken boekwijzer mislukt",
|
||||
"ToastBookmarkCreateSuccess": "boekwijzer toegevoegd",
|
||||
"ToastBookmarkRemoveSuccess": "Boekwijzer verwijderd",
|
||||
"ToastBookmarkUpdateSuccess": "Boekwijzer bijgewerkt",
|
||||
"ToastCachePurgeFailed": "Cache wissen is mislukt",
|
||||
"ToastCachePurgeSuccess": "Cache succesvol verwijderd",
|
||||
"ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten",
|
||||
@@ -946,8 +944,6 @@
|
||||
"ToastChaptersRemoved": "Hoofdstukken verwijderd",
|
||||
"ToastChaptersUpdated": "Hoofdstukken bijgewerkt",
|
||||
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
|
||||
"ToastCollectionItemsAddSuccess": "Item(s) toegevoegd aan collectie gelukt",
|
||||
"ToastCollectionItemsRemoveSuccess": "Onderdeel (of onderdelen) verwijderd uit collectie",
|
||||
"ToastCollectionRemoveSuccess": "Collectie verwijderd",
|
||||
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
|
||||
"ToastCoverUpdateFailed": "Cover update mislukt",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"ButtonBack": "Tilbake",
|
||||
"ButtonBrowseForFolder": "Bla gjennom mappe",
|
||||
"ButtonCancel": "Avbryt",
|
||||
"ButtonCancelEncode": "Avbryt Encode",
|
||||
"ButtonCancelEncode": "Avbryt konvertering",
|
||||
"ButtonChangeRootPassword": "Bytt Root-bruker passord",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Sjekk og last ned nye episoder",
|
||||
"ButtonChooseAFolder": "Velg mappe",
|
||||
@@ -97,10 +97,10 @@
|
||||
"ButtonShare": "Del",
|
||||
"ButtonShiftTimes": "Forskyv tider",
|
||||
"ButtonShow": "Vis",
|
||||
"ButtonStartM4BEncode": "Start M4B Koding",
|
||||
"ButtonStartM4BEncode": "Start konvertering til M4B",
|
||||
"ButtonStartMetadataEmbed": "Start Metadata innbaking",
|
||||
"ButtonStats": "Statistikk",
|
||||
"ButtonSubmit": "Send inn",
|
||||
"ButtonSubmit": "Lagre",
|
||||
"ButtonTest": "Test",
|
||||
"ButtonUnlinkOpenId": "Koble fra OpenID",
|
||||
"ButtonUpload": "Last opp",
|
||||
@@ -143,12 +143,12 @@
|
||||
"HeaderFindChapters": "Finn Kapittel",
|
||||
"HeaderIgnoredFiles": "Ignorerte filer",
|
||||
"HeaderItemFiles": "Elementfiler",
|
||||
"HeaderItemMetadataUtils": "Enhet Metadata verktøy",
|
||||
"HeaderItemMetadataUtils": "Element Metadata verktøy",
|
||||
"HeaderLastListeningSession": "Siste lyttesesjon",
|
||||
"HeaderLatestEpisodes": "Siste episoder",
|
||||
"HeaderLibraries": "Biblioteker",
|
||||
"HeaderLibraryFiles": "Bibliotek filer",
|
||||
"HeaderLibraryStats": "Bibliotek statistikk",
|
||||
"HeaderLibraryStats": "Bibliotekstatistikk",
|
||||
"HeaderListeningSessions": "Lyttesesjoner",
|
||||
"HeaderListeningStats": "Lyttestatistikk",
|
||||
"HeaderLogin": "Logg inn",
|
||||
@@ -300,6 +300,7 @@
|
||||
"LabelDiscover": "Oppdag",
|
||||
"LabelDownload": "Last ned",
|
||||
"LabelDownloadNEpisodes": "Last ned {0} episoder",
|
||||
"LabelDownloadable": "Nedlastbar",
|
||||
"LabelDuration": "Varighet",
|
||||
"LabelDurationComparisonExactMatch": "(nøyaktig treff)",
|
||||
"LabelDurationComparisonLonger": "({0} lenger)",
|
||||
@@ -365,11 +366,11 @@
|
||||
"LabelFormat": "Format",
|
||||
"LabelFull": "Full",
|
||||
"LabelGenre": "Sjanger",
|
||||
"LabelGenres": "Sjangers",
|
||||
"LabelGenres": "Sjangre",
|
||||
"LabelHardDeleteFile": "Tving sletting av fil",
|
||||
"LabelHasEbook": "Har e-bok",
|
||||
"LabelHasSupplementaryEbook": "Har komplimentær e-bok",
|
||||
"LabelHideSubtitles": "Skjul undertekster",
|
||||
"LabelHideSubtitles": "Skjul undertitler",
|
||||
"LabelHighestPriority": "Høyeste prioritet",
|
||||
"LabelHost": "Tjener",
|
||||
"LabelHour": "Time",
|
||||
@@ -406,7 +407,7 @@
|
||||
"LabelLess": "Mindre",
|
||||
"LabelLibrariesAccessibleToUser": "Biblioteker tilgjengelig for bruker",
|
||||
"LabelLibrary": "Bibliotek",
|
||||
"LabelLibraryFilterSublistEmpty": "",
|
||||
"LabelLibraryFilterSublistEmpty": "Ingen {0}",
|
||||
"LabelLibraryItem": "Bibliotek enhet",
|
||||
"LabelLibraryName": "Bibliotek navn",
|
||||
"LabelLimit": "Begrensning",
|
||||
@@ -570,7 +571,7 @@
|
||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Marker som ferdig når",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hopp over tidligere bøker i \"Fortsett serien\"",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "\"Fortsett serie\"-siden viser første bok som ikke er påbegynt i serier der en bok er lest og ingen bøker leses nå. Ved å slå på denne innstillingen så vil man fortsette på serien etter siste leste bok, fremfor første bok som ikke er startet på i en serie.",
|
||||
"LabelSettingsParseSubtitles": "Analyser undertekster",
|
||||
"LabelSettingsParseSubtitles": "Analyser undertitler",
|
||||
"LabelSettingsParseSubtitlesHelp": "Hent undertittel fra lydbokens mappenavn.<br>Undertittel må være separert med \" - \"<br>f.eks. \"Boktittel - En lengre tittel\" har undertittel \"En lengre tittel\".",
|
||||
"LabelSettingsPreferMatchedMetadata": "Foretrekk funnet metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Funnet data vil overskrive enhetens detaljene når man bruker Kjapt søk. Som standard vil Kjapt søk kun fylle inn manglende detaljer.",
|
||||
@@ -586,6 +587,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden",
|
||||
"LabelSettingsTimeFormat": "Tid format",
|
||||
"LabelShare": "Dele",
|
||||
"LabelShareDownloadableHelp": "Tillat brukere med en delt link å laste ned en zip-fil av elementet.",
|
||||
"LabelShareOpen": "Åpne deling",
|
||||
"LabelShareURL": "Dele URL",
|
||||
"LabelShowAll": "Vis alle",
|
||||
@@ -615,7 +617,7 @@
|
||||
"LabelStatsOverallDays": "Totale dager",
|
||||
"LabelStatsOverallHours": "Totale timer",
|
||||
"LabelStatsWeekListening": "Uker lyttet",
|
||||
"LabelSubtitle": "undertekster",
|
||||
"LabelSubtitle": "Undertittel",
|
||||
"LabelSupportedFileTypes": "Støttede filtyper",
|
||||
"LabelTag": "Tag",
|
||||
"LabelTags": "Tagger",
|
||||
@@ -640,11 +642,11 @@
|
||||
"LabelTimeRemaining": "{0} gjennstående",
|
||||
"LabelTimeToShift": "Tid å forflytte i sekunder",
|
||||
"LabelTitle": "Tittel",
|
||||
"LabelToolsEmbedMetadata": "Bak inn metadata",
|
||||
"LabelToolsEmbedMetadata": "Bygg inn metadata",
|
||||
"LabelToolsEmbedMetadataDescription": "Bak inn metadata i lydfilen, inkludert omslagsbilde og kapitler.",
|
||||
"LabelToolsM4bEncoder": "M4B enkoder",
|
||||
"LabelToolsMakeM4b": "Lag M4B Lydbokfil",
|
||||
"LabelToolsMakeM4bDescription": "Lager en.M4B lydbokfil med innbakte omslagsbilde og kapitler.",
|
||||
"LabelToolsMakeM4bDescription": "Lager en M4B lydbokfil med innbakt omslagsbilde og kapitler.",
|
||||
"LabelToolsSplitM4b": "Del M4B inn i MP3er",
|
||||
"LabelToolsSplitM4bDescription": "Lager MP3er fra en M4B inndelt i kapitler med innbakt metadata, omslagsbilde og kapitler.",
|
||||
"LabelTotalDuration": "Total lengde",
|
||||
@@ -754,6 +756,7 @@
|
||||
"MessageConfirmResetProgress": "Er du sikkert på at du vil tilbakestille fremgangen?",
|
||||
"MessageConfirmSendEbookToDevice": "Er du sikker på at du vil sende {0} ebok \"{1}\" til enhet \"{2}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "Er du sikker på at du vil koble denne brukeren fra OpenID?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} dager med lytting siste året",
|
||||
"MessageDownloadingEpisode": "Laster ned episode",
|
||||
"MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge",
|
||||
"MessageEmbedFailed": "Innbygging feilet!",
|
||||
@@ -769,9 +772,9 @@
|
||||
"MessageItemsSelected": "{0} Gjenstander valgt",
|
||||
"MessageItemsUpdated": "{0} Gjenstander oppdatert",
|
||||
"MessageJoinUsOn": "Følg oss nå",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} Lyttesesjoner iløpet av siste året",
|
||||
"MessageLoading": "Laster...",
|
||||
"MessageLoadingFolders": "Laster mapper...",
|
||||
"MessageLogsDescription": "Logger lagres i <code>/metadata/logs</code> som JSON-filer. Krasjlogger lagres i <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
"MessageM4BFailed": "M4B mislykkes!",
|
||||
"MessageM4BFinished": "M4B fullført!",
|
||||
"MessageMapChapterTitles": "Bruk kapittel titler fra din eksisterende lydbok kapitler uten å justere tidsstempel",
|
||||
@@ -788,6 +791,7 @@
|
||||
"MessageNoCollections": "Ingen samlinger",
|
||||
"MessageNoCoversFound": "Ingen omslagsbilde funnet",
|
||||
"MessageNoDescription": "Ingen beskrivelse",
|
||||
"MessageNoDevices": "Ingen enheter",
|
||||
"MessageNoDownloadsInProgress": "Ingen aktive nedlastinger",
|
||||
"MessageNoDownloadsQueued": "Ingen nedlastinger i kø",
|
||||
"MessageNoEpisodeMatchesFound": "Ingen lik episode funnet",
|
||||
@@ -801,6 +805,7 @@
|
||||
"MessageNoLogs": "Ingen logger",
|
||||
"MessageNoMediaProgress": "Ingen mediefremgang",
|
||||
"MessageNoNotifications": "Ingen varslinger",
|
||||
"MessageNoPodcastFeed": "Ugyldig podcast: Ingen feed",
|
||||
"MessageNoPodcastsFound": "Ingen podcaster funnet",
|
||||
"MessageNoResults": "Ingen resultat",
|
||||
"MessageNoSearchResultsFor": "Ingen søkeresultat for \"{0}\"",
|
||||
@@ -810,11 +815,17 @@
|
||||
"MessageNoUpdatesWereNecessary": "Ingen oppdatering var nødvendig",
|
||||
"MessageNoUserPlaylists": "Du har ingen spillelister",
|
||||
"MessageNotYetImplemented": "Ikke implementert ennå",
|
||||
"MessageOpmlPreviewNote": "PS: Dette er en forhåndvisning av en OPML-fil. Den faktiske podcast-tittelen hentes direkte fra RSS-feeden.",
|
||||
"MessageOr": "eller",
|
||||
"MessagePauseChapter": "Pause avspilling av kapittel",
|
||||
"MessagePlayChapter": "Lytter på begynnelsen av kapittel",
|
||||
"MessagePlaylistCreateFromCollection": "Lag spilleliste fra samling",
|
||||
"MessagePleaseWait": "Vennligst vent...",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast har ingen RSS feed url til bruk av sammenligning",
|
||||
"MessagePodcastSearchField": "Skriv inn søkeord eller RSS-feed URL",
|
||||
"MessageQuickEmbedInProgress": "Hurtiginnbygging pågår",
|
||||
"MessageQuickEmbedQueue": "Kø for hurtiginnbygging ({0} i kø)",
|
||||
"MessageQuickMatchAllEpisodes": "Kjapp matching av alle episoder",
|
||||
"MessageQuickMatchDescription": "Fyll inn tomme gjenstandsdetaljer og omslagsbilde med første resultat fra '{0}'. Overskriver ikke detaljene utenom 'Foretrekk funnet metadata' tjenerinstilling er aktivert.",
|
||||
"MessageRemoveChapter": "fjerne kapittel",
|
||||
"MessageRemoveEpisodes": "fjerne {0} kapitler",
|
||||
@@ -824,10 +835,29 @@
|
||||
"MessageResetChaptersConfirm": "Er du sikker på at du vil nullstille kapitler og angre endringene du har gjort?",
|
||||
"MessageRestoreBackupConfirm": "Er du sikker på at du vil gjenopprette sikkerhetskopien som var laget",
|
||||
"MessageRestoreBackupWarning": "gjenoppretting av sikkerhetskopi vil overskrive hele databasen under /config og omslagsbilde under /metadata/items og /metadata/authors.<br /><br />Sikkerhetskopier endrer ikke noen filer under dine bibliotekmapper. Hvis du har aktivert tjenerinstillingen for å lagre omslagsbilder og metadata i bibliotekmapper så vil ikke de filene bli tatt sikkerhetskopi eller overskrevet.<br /><br />Alle klientene som bruker din tjener vil bli fornyet automatisk.",
|
||||
"MessageScheduleLibraryScanNote": "For de fleste brukere er det anbefalt å la denne funksjonen være slått av, og la mappeovervåkeren stå på. Mappeovervåkeren oppdager automatisk endringer i biblioteksmappene. Mappeovervåkeren fungerer ikke med alle filsystemer (f.eks. NFS) og da kan planlagt skanning av bibliotekene brukes i steden for.",
|
||||
"MessageSearchResultsFor": "Søk resultat for",
|
||||
"MessageSelected": "{0} valgt",
|
||||
"MessageServerCouldNotBeReached": "Tjener kunne ikke bli nådd",
|
||||
"MessageSetChaptersFromTracksDescription": "Sett kapitler ved å bruke hver lydfil som kapittel og kapitteltittel som lydfilnavnet",
|
||||
"MessageShareExpirationWillBe": "Utløp vil være <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "Utløper om {0}",
|
||||
"MessageShareURLWillBe": "URL for deling blir <strong>{0}</strong>",
|
||||
"MessageStartPlaybackAtTime": "Start avspilling av \"{0}\" ved {1}?",
|
||||
"MessageTaskAudioFileNotWritable": "Lydfilen \"{0}\" kan ikke skrives til",
|
||||
"MessageTaskCanceledByUser": "Oppgave kansellert av bruker",
|
||||
"MessageTaskDownloadingEpisodeDescription": "Laster ned episode \"{0}\"",
|
||||
"MessageTaskEmbeddingMetadata": "Bygger inn metadata",
|
||||
"MessageTaskEmbeddingMetadataDescription": "Bygger inn metadata i lydboken \"{0}\"",
|
||||
"MessageTaskEncodingM4b": "Konverterer til M4B",
|
||||
"MessageTaskEncodingM4bDescription": "Konverterer lydboken \"{0}\" til én M4B-fil",
|
||||
"MessageTaskFailed": "Feilet",
|
||||
"MessageTaskFailedToBackupAudioFile": "Feil ved sikkerhetskopiering av lydfilen \"{0}\"",
|
||||
"MessageTaskFailedToCreateCacheDirectory": "Kunne ikke opprette mappe for mellomlagring (cache)",
|
||||
"MessageTaskFailedToEmbedMetadataInFile": "Kunne ikke bygge inn metadata i filen \"{0}\"",
|
||||
"MessageTaskFailedToMergeAudioFiles": "Kunne ikke slå sammen lydfiler",
|
||||
"MessageTaskFailedToMoveM4bFile": "Kunne ikke flytte M4B-fil",
|
||||
"MessageTaskFailedToWriteMetadataFile": "Kunne ikke lagre metadata-fil",
|
||||
"MessageThinking": "Tenker...",
|
||||
"MessageUploaderItemFailed": "Opplastning mislykkes",
|
||||
"MessageUploaderItemSuccess": "Opplastning fullført!",
|
||||
@@ -874,7 +904,6 @@
|
||||
"ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke",
|
||||
"ToastBookmarkCreateSuccess": "Bokmerke lagt til",
|
||||
"ToastBookmarkRemoveSuccess": "Bokmerke fjernet",
|
||||
"ToastBookmarkUpdateSuccess": "Bokmerke oppdatert",
|
||||
"ToastCachePurgeFailed": "Kunne ikke å slette mellomlager",
|
||||
"ToastCachePurgeSuccess": "Mellomlager slettet",
|
||||
"ToastChaptersHaveErrors": "Kapittel har feil",
|
||||
@@ -882,8 +911,6 @@
|
||||
"ToastChaptersRemoved": "Kapitler fjernet",
|
||||
"ToastChaptersUpdated": "Kapitler oppdatert",
|
||||
"ToastCollectionItemsAddFailed": "Feil med å legge til element(er)",
|
||||
"ToastCollectionItemsAddSuccess": "Element(er) lagt til samlingen",
|
||||
"ToastCollectionItemsRemoveSuccess": "Gjenstand(er) fjernet fra samling",
|
||||
"ToastCollectionRemoveSuccess": "Samling fjernet",
|
||||
"ToastCollectionUpdateSuccess": "samlingupdated",
|
||||
"ToastCoverUpdateFailed": "Oppdatering av bilde feilet",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"ButtonEditChapters": "Edytuj rozdziały",
|
||||
"ButtonEditPodcast": "Edytuj podcast",
|
||||
"ButtonEnable": "Włącz",
|
||||
"ButtonFireAndFail": "Fail start",
|
||||
"ButtonForceReScan": "Wymuś ponowne skanowanie",
|
||||
"ButtonFullPath": "Pełna ścieżka",
|
||||
"ButtonHide": "Ukryj",
|
||||
@@ -657,7 +658,6 @@
|
||||
"MessageInsertChapterBelow": "Wstaw rozdział poniżej",
|
||||
"MessageItemsSelected": "{0} zaznaczone elementy",
|
||||
"MessageJoinUsOn": "Dołącz do nas na",
|
||||
"MessageListeningSessionsInTheLastYear": "Sesje słuchania w ostatnim roku: {0}",
|
||||
"MessageLoading": "Ładowanie...",
|
||||
"MessageLoadingFolders": "Ładowanie folderów...",
|
||||
"MessageLogsDescription": "Logi zapisane są w <code>/metadata/logs</code> jako pliki JSON. Logi awaryjne są zapisane w <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@@ -771,8 +771,6 @@
|
||||
"ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki",
|
||||
"ToastBookmarkCreateSuccess": "Dodano zakładkę",
|
||||
"ToastBookmarkRemoveSuccess": "Zakładka została usunięta",
|
||||
"ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę",
|
||||
"ToastCollectionItemsRemoveSuccess": "Przedmiot(y) zostały usunięte z kolekcji",
|
||||
"ToastCollectionRemoveSuccess": "Kolekcja usunięta",
|
||||
"ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję",
|
||||
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",
|
||||
|
||||
@@ -630,7 +630,6 @@
|
||||
"MessageItemsSelected": "{0} Itens Selecionados",
|
||||
"MessageItemsUpdated": "{0} Itens Atualizados",
|
||||
"MessageJoinUsOn": "Junte-se a nós",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} sessões de escuta no ano anterior",
|
||||
"MessageLoading": "Carregando...",
|
||||
"MessageLoadingFolders": "Carregando pastas...",
|
||||
"MessageLogsDescription": "Os logs estão armazenados em <code>/metadata/logs</code> como arquivos JSON. Logs de crash estão armazenados em <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@@ -730,12 +729,10 @@
|
||||
"ToastBookmarkCreateFailed": "Falha ao criar marcador",
|
||||
"ToastBookmarkCreateSuccess": "Marcador adicionado",
|
||||
"ToastBookmarkRemoveSuccess": "Marcador removido",
|
||||
"ToastBookmarkUpdateSuccess": "Marcador atualizado",
|
||||
"ToastCachePurgeFailed": "Falha ao apagar o cache",
|
||||
"ToastCachePurgeSuccess": "Cache apagado com sucesso",
|
||||
"ToastChaptersHaveErrors": "Capítulos com erro",
|
||||
"ToastChaptersMustHaveTitles": "Capítulos precisam ter títulos",
|
||||
"ToastCollectionItemsRemoveSuccess": "Item(ns) removidos da coleção",
|
||||
"ToastCollectionRemoveSuccess": "Coleção removida",
|
||||
"ToastCollectionUpdateSuccess": "Coleção atualizada",
|
||||
"ToastDeleteFileFailed": "Falha ao apagar arquivo",
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"LabelDiscover": "Не начато",
|
||||
"LabelDownload": "Скачать",
|
||||
"LabelDownloadNEpisodes": "Скачать {0} эпизодов",
|
||||
"LabelDownloadable": "Загружаемый",
|
||||
"LabelDuration": "Длина",
|
||||
"LabelDurationComparisonExactMatch": "(точное совпадение)",
|
||||
"LabelDurationComparisonLonger": "({0} дольше)",
|
||||
@@ -347,7 +348,7 @@
|
||||
"LabelFetchingMetadata": "Извлечение метаданных",
|
||||
"LabelFile": "Файл",
|
||||
"LabelFileBirthtime": "Дата создания",
|
||||
"LabelFileBornDate": "Родился {0}",
|
||||
"LabelFileBornDate": "Создан {0}",
|
||||
"LabelFileModified": "Дата модификации",
|
||||
"LabelFileModifiedDate": "Изменено {0}",
|
||||
"LabelFilename": "Имя файла",
|
||||
@@ -588,6 +589,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента",
|
||||
"LabelSettingsTimeFormat": "Формат времени",
|
||||
"LabelShare": "Поделиться",
|
||||
"LabelShareDownloadableHelp": "Позволяет пользователям с помощью ссылки загрузить zip-файл элемента библиотеки.",
|
||||
"LabelShareOpen": "Общедоступно",
|
||||
"LabelShareURL": "Общедоступный URL",
|
||||
"LabelShowAll": "Показать все",
|
||||
@@ -756,6 +758,7 @@
|
||||
"MessageConfirmResetProgress": "Вы уверены, что хотите сбросить свой прогресс?",
|
||||
"MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "Вы уверены, что хотите отвязать этого пользователя от OpenID?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} дней прослушивания за последний год",
|
||||
"MessageDownloadingEpisode": "Эпизод скачивается",
|
||||
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
|
||||
"MessageEmbedFailed": "Вставка не удалась!",
|
||||
@@ -771,7 +774,6 @@
|
||||
"MessageItemsSelected": "{0} Элементов выделено",
|
||||
"MessageItemsUpdated": "{0} Элементов обновлено",
|
||||
"MessageJoinUsOn": "Присоединяйтесь к нам в",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} сеансов прослушивания в прошлом году",
|
||||
"MessageLoading": "Загрузка...",
|
||||
"MessageLoadingFolders": "Загрузка каталогов...",
|
||||
"MessageLogsDescription": "Журналы хранятся в <code>/metadata/logs</code> в виде JSON-файлов. Журналы сбоев хранятся в <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@@ -835,6 +837,7 @@
|
||||
"MessageResetChaptersConfirm": "Вы уверены, что хотите сбросить главы и отменить внесенные изменения?",
|
||||
"MessageRestoreBackupConfirm": "Вы уверены, что хотите восстановить резервную копию, созданную",
|
||||
"MessageRestoreBackupWarning": "Восстановление резервной копии перезапишет всю базу данных, расположенную в /config, и обложки изображений в /metadata/items и /metadata/authors.<br/><br/>Бэкапы не изменяют файлы в папках библиотеки. Если вы включили параметры сервера для хранения обложек и метаданных в папках библиотеки, то они не резервируются и не перезаписываются.<br/><br/>Все клиенты, использующие ваш сервер, будут автоматически обновлены.",
|
||||
"MessageScheduleLibraryScanNote": "Большинству пользователей рекомендуется отключить эту функцию и включить функцию просмотра папок. Программа просмотра папок автоматически обнаружит изменения в папках вашей библиотеки. Программа просмотра папок работает не для каждой файловой системы (например, NFS), поэтому вместо этого можно использовать запланированные проверки библиотеки.",
|
||||
"MessageSearchResultsFor": "Результаты поиска для",
|
||||
"MessageSelected": "{0} выбрано",
|
||||
"MessageServerCouldNotBeReached": "Не удалось связаться с сервером",
|
||||
@@ -951,7 +954,6 @@
|
||||
"ToastBookmarkCreateFailed": "Не удалось создать закладку",
|
||||
"ToastBookmarkCreateSuccess": "Добавлена закладка",
|
||||
"ToastBookmarkRemoveSuccess": "Закладка удалена",
|
||||
"ToastBookmarkUpdateSuccess": "Закладка обновлена",
|
||||
"ToastCachePurgeFailed": "Не удалось очистить кэш",
|
||||
"ToastCachePurgeSuccess": "Кэш успешно очищен",
|
||||
"ToastChaptersHaveErrors": "Главы имеют ошибки",
|
||||
@@ -959,11 +961,10 @@
|
||||
"ToastChaptersRemoved": "Удалены главы",
|
||||
"ToastChaptersUpdated": "Обновленные главы",
|
||||
"ToastCollectionItemsAddFailed": "Не удалось добавить элемент(ы) в коллекцию",
|
||||
"ToastCollectionItemsAddSuccess": "Элемент(ы) добавлены в коллекцию",
|
||||
"ToastCollectionItemsRemoveSuccess": "Элемент(ы), удалены из коллекции",
|
||||
"ToastCollectionRemoveSuccess": "Коллекция удалена",
|
||||
"ToastCollectionUpdateSuccess": "Коллекция обновлена",
|
||||
"ToastCoverUpdateFailed": "Не удалось обновить обложку",
|
||||
"ToastDateTimeInvalidOrIncomplete": "Дата и время указаны неверно или не до конца",
|
||||
"ToastDeleteFileFailed": "Не удалось удалить файл",
|
||||
"ToastDeleteFileSuccess": "Файл удален",
|
||||
"ToastDeviceAddFailed": "Не удалось добавить устройство",
|
||||
@@ -1016,6 +1017,7 @@
|
||||
"ToastNewUserTagError": "Необходимо выбрать хотя бы один тег",
|
||||
"ToastNewUserUsernameError": "Введите имя пользователя",
|
||||
"ToastNoNewEpisodesFound": "Новых эпизодов не найдено",
|
||||
"ToastNoRSSFeed": "У подкаста нет RSS-канала",
|
||||
"ToastNoUpdatesNecessary": "Обновления не требуются",
|
||||
"ToastNotificationCreateFailed": "Не удалось создать уведомление",
|
||||
"ToastNotificationDeleteFailed": "Не удалось удалить уведомление",
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"LabelDiscover": "Odkrij",
|
||||
"LabelDownload": "Prenos",
|
||||
"LabelDownloadNEpisodes": "Prenesi {0} epizod",
|
||||
"LabelDownloadable": "Možen prenos",
|
||||
"LabelDuration": "Trajanje",
|
||||
"LabelDurationComparisonExactMatch": "(natančno ujemanje)",
|
||||
"LabelDurationComparisonLonger": "({0} dlje)",
|
||||
@@ -588,6 +589,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Datoteke z metapodatki so privzeto shranjene v /metadata/items, če omogočite to nastavitev, boste datoteke z metapodatki shranili v mape elementov vaše knjižnice",
|
||||
"LabelSettingsTimeFormat": "Oblika časa",
|
||||
"LabelShare": "Deli",
|
||||
"LabelShareDownloadableHelp": "Omogoča uporabnikom s povezavo skupne rabe, da prenesejo zip datoteko elementa knjižnice.",
|
||||
"LabelShareOpen": "Deli odprto",
|
||||
"LabelShareURL": "Deli URL",
|
||||
"LabelShowAll": "Prikaži vse",
|
||||
@@ -756,6 +758,7 @@
|
||||
"MessageConfirmResetProgress": "Ali ste prepričani, da želite ponastaviti svoj napredek?",
|
||||
"MessageConfirmSendEbookToDevice": "Ali ste prepričani, da želite poslati {0} e-knjigo \"{1}\" v napravo \"{2}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "Ali ste prepričani, da želite prekiniti povezavo tega uporabnika z OpenID?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} dni poslušanja v zadnjem letu",
|
||||
"MessageDownloadingEpisode": "Prenašam epizodo",
|
||||
"MessageDragFilesIntoTrackOrder": "Povlecite datoteke v pravilen vrstni red posnetkov",
|
||||
"MessageEmbedFailed": "Vdelava ni uspela!",
|
||||
@@ -771,7 +774,6 @@
|
||||
"MessageItemsSelected": "{0} izbranih elementov",
|
||||
"MessageItemsUpdated": "Št. posodobljenih elementov: {0}",
|
||||
"MessageJoinUsOn": "Pridružite se nam",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} sej poslušanja v zadnjem letu",
|
||||
"MessageLoading": "Nalagam...",
|
||||
"MessageLoadingFolders": "Nalagam mape...",
|
||||
"MessageLogsDescription": "Dnevniki so shranjeni v <code>/metadata/logs</code> kot datoteke JSON. Dnevniki zrušitev so shranjeni v <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@@ -835,6 +837,7 @@
|
||||
"MessageResetChaptersConfirm": "Ali ste prepričani, da želite ponastaviti poglavja in razveljaviti spremembe, ki ste jih naredili?",
|
||||
"MessageRestoreBackupConfirm": "Ali ste prepričani, da želite obnoviti varnostno kopijo, ustvarjeno ob",
|
||||
"MessageRestoreBackupWarning": "Obnovitev varnostne kopije bo prepisala celotno zbirko podatkov, ki se nahaja v /config, in zajema slike v /metadata/items in /metadata/authors.<br /><br />Varnostne kopije ne spreminjajo nobenih datotek v mapah vaše knjižnice. Če ste omogočili nastavitve strežnika za shranjevanje naslovnic in metapodatkov v mapah vaše knjižnice, potem ti niso varnostno kopirani ali prepisani.<br /><br />Vsi odjemalci, ki uporabljajo vaš strežnik, bodo samodejno osveženi.",
|
||||
"MessageScheduleLibraryScanNote": "Za večino uporabnikov je priporočljivo, da to funkcijo pustite onemogočeno in ohranite nastavitev pregledovalnika map omogočeno. Pregledovalnik map bo samodejno zaznal spremembe v mapah vaše knjižnice. Pregledovalnik map ne deluje za vse datotečne sisteme (na primer NFS), zato lahko namesto tega uporabite načrtovane preglede knjižnic.",
|
||||
"MessageSearchResultsFor": "Rezultati iskanja za",
|
||||
"MessageSelected": "{0} izbrano",
|
||||
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
|
||||
@@ -951,7 +954,6 @@
|
||||
"ToastBookmarkCreateFailed": "Zaznamka ni bilo mogoče ustvariti",
|
||||
"ToastBookmarkCreateSuccess": "Zaznamek dodan",
|
||||
"ToastBookmarkRemoveSuccess": "Zaznamek odstranjen",
|
||||
"ToastBookmarkUpdateSuccess": "Zaznamek posodobljen",
|
||||
"ToastCachePurgeFailed": "Čiščenje predpomnilnika ni uspelo",
|
||||
"ToastCachePurgeSuccess": "Predpomnilnik je bil uspešno očiščen",
|
||||
"ToastChaptersHaveErrors": "Poglavja imajo napake",
|
||||
@@ -959,11 +961,10 @@
|
||||
"ToastChaptersRemoved": "Poglavja so odstranjena",
|
||||
"ToastChaptersUpdated": "Poglavja so posodobljena",
|
||||
"ToastCollectionItemsAddFailed": "Dodajanje elementov v zbirko ni uspelo",
|
||||
"ToastCollectionItemsAddSuccess": "Dodajanje elementov v zbirko je bilo uspešno",
|
||||
"ToastCollectionItemsRemoveSuccess": "Elementi so bili odstranjeni iz zbirke",
|
||||
"ToastCollectionRemoveSuccess": "Zbirka je bila odstranjena",
|
||||
"ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena",
|
||||
"ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela",
|
||||
"ToastDateTimeInvalidOrIncomplete": "Datum in čas sta neveljavna ali nepopolna",
|
||||
"ToastDeleteFileFailed": "Brisanje datoteke ni uspelo",
|
||||
"ToastDeleteFileSuccess": "Datoteka je bila izbrisana",
|
||||
"ToastDeviceAddFailed": "Naprave ni bilo mogoče dodati",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"ButtonChooseFiles": "Välj filer",
|
||||
"ButtonClearFilter": "Rensa filter",
|
||||
"ButtonCloseFeed": "Stäng flöde",
|
||||
"ButtonCloseSession": "Stäng öppen session",
|
||||
"ButtonCollections": "Samlingar",
|
||||
"ButtonConfigureScanner": "Konfigurera skanner",
|
||||
"ButtonCreate": "Skapa",
|
||||
@@ -28,11 +29,14 @@
|
||||
"ButtonEdit": "Redigera",
|
||||
"ButtonEditChapters": "Redigera kapitel",
|
||||
"ButtonEditPodcast": "Redigera podcast",
|
||||
"ButtonEnable": "Aktivera",
|
||||
"ButtonForceReScan": "Tvinga omstart",
|
||||
"ButtonFullPath": "Fullständig sökväg",
|
||||
"ButtonHide": "Dölj",
|
||||
"ButtonHome": "Hem",
|
||||
"ButtonIssues": "Problem",
|
||||
"ButtonJumpBackward": "Hoppa bakåt",
|
||||
"ButtonJumpForward": "Hoppa framåt",
|
||||
"ButtonLatest": "Senaste",
|
||||
"ButtonLibrary": "Bibliotek",
|
||||
"ButtonLogout": "Logga ut",
|
||||
@@ -44,6 +48,7 @@
|
||||
"ButtonNevermind": "Glöm det",
|
||||
"ButtonNext": "Nästa",
|
||||
"ButtonNextChapter": "Nästa kapitel",
|
||||
"ButtonNextItemInQueue": "Nästa objekt i Kö",
|
||||
"ButtonOk": "Ok",
|
||||
"ButtonOpenFeed": "Öppna flöde",
|
||||
"ButtonOpenManager": "Öppna Manager",
|
||||
@@ -54,6 +59,7 @@
|
||||
"ButtonPlaylists": "Spellistor",
|
||||
"ButtonPrevious": "Föregående",
|
||||
"ButtonPreviousChapter": "Föregående kapitel",
|
||||
"ButtonProbeAudioFile": "Analysera ljudfil",
|
||||
"ButtonPurgeAllCache": "Rensa all cache",
|
||||
"ButtonPurgeItemsCache": "Rensa föremåls-cache",
|
||||
"ButtonQueueAddItem": "Lägg till i kön",
|
||||
@@ -590,7 +596,6 @@
|
||||
"MessageItemsSelected": "{0} Objekt markerade",
|
||||
"MessageItemsUpdated": "{0} Objekt uppdaterade",
|
||||
"MessageJoinUsOn": "Anslut dig till oss på",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} lyssningssessioner det senaste året",
|
||||
"MessageLoading": "Laddar...",
|
||||
"MessageLoadingFolders": "Laddar mappar...",
|
||||
"MessageM4BFailed": "M4B misslyckades!",
|
||||
@@ -702,10 +707,8 @@
|
||||
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
|
||||
"ToastBookmarkCreateSuccess": "Bokmärket har adderats",
|
||||
"ToastBookmarkRemoveSuccess": "Bokmärket har raderats",
|
||||
"ToastBookmarkUpdateSuccess": "Bokmärket har uppdaterats",
|
||||
"ToastChaptersHaveErrors": "Kapitlen har fel",
|
||||
"ToastChaptersMustHaveTitles": "Kapitel måste ha titlar",
|
||||
"ToastCollectionItemsRemoveSuccess": "Objekt borttagna från samlingen",
|
||||
"ToastCollectionRemoveSuccess": "Samlingen har raderats",
|
||||
"ToastCollectionUpdateSuccess": "Samlingen har uppdaterats",
|
||||
"ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat",
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"LabelDiscover": "Огляд",
|
||||
"LabelDownload": "Завантажити",
|
||||
"LabelDownloadNEpisodes": "Завантажити епізодів: {0}",
|
||||
"LabelDownloadable": "Можна завантажити",
|
||||
"LabelDuration": "Тривалість",
|
||||
"LabelDurationComparisonExactMatch": "(повний збіг)",
|
||||
"LabelDurationComparisonLonger": "(на {0} довше)",
|
||||
@@ -588,6 +589,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "За замовчуванням файли метаданих зберігаються у /metadata/items. Цей параметр увімкне збереження метаданих у теці елемента бібліотеки",
|
||||
"LabelSettingsTimeFormat": "Формат часу",
|
||||
"LabelShare": "Поділитися",
|
||||
"LabelShareDownloadableHelp": "Дозволяє користувачам із посиланням для спільного доступу завантажувати zip-файл елемента бібліотеки.",
|
||||
"LabelShareOpen": "Поділитися відкрито",
|
||||
"LabelShareURL": "Поділитися URL",
|
||||
"LabelShowAll": "Показати все",
|
||||
@@ -756,6 +758,7 @@
|
||||
"MessageConfirmResetProgress": "Ви впевнені, що хочете скинути свій прогрес?",
|
||||
"MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "Ви впевнені, що хочете відв'язати цього користувача від OpenID?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} днів, прослуханих за останній рік",
|
||||
"MessageDownloadingEpisode": "Завантаження епізоду",
|
||||
"MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку",
|
||||
"MessageEmbedFailed": "Не вдалося вбудувати!",
|
||||
@@ -771,7 +774,6 @@
|
||||
"MessageItemsSelected": "Обрано елементів: {0}",
|
||||
"MessageItemsUpdated": "Оновлено елементів: {0}",
|
||||
"MessageJoinUsOn": "Приєднуйтесь до",
|
||||
"MessageListeningSessionsInTheLastYear": "Сесій прослуховування минулого року: {0}",
|
||||
"MessageLoading": "Завантаження...",
|
||||
"MessageLoadingFolders": "Завантаження тек...",
|
||||
"MessageLogsDescription": "Журнали зберігаються у <code>/metadata/logs</code> як JSON-файли. Журнали збоїв зберігаються у <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
@@ -835,6 +837,7 @@
|
||||
"MessageResetChaptersConfirm": "Ви дійсно бажаєте скинути глави та скасувати внесені зміни?",
|
||||
"MessageRestoreBackupConfirm": "Ви дійсно бажаєте відновити резервну копію від",
|
||||
"MessageRestoreBackupWarning": "Відновлення резервної копії перезапише всю базу даних, розташовану в /config, і зображення обкладинок в /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють жодних файлів у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються..<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.",
|
||||
"MessageScheduleLibraryScanNote": "Для більшості користувачів рекомендується залишити цю функцію вимкненою та залишити параметр перегляду папок увімкненим. Засіб спостереження за папками автоматично виявить зміни в папках вашої бібліотеки. Засіб спостереження за папками не працює для кожної файлової системи (наприклад, NFS), тому замість нього можна використовувати сканування бібліотек за розкладом.",
|
||||
"MessageSearchResultsFor": "Результати пошуку для",
|
||||
"MessageSelected": "Вибрано: {0}",
|
||||
"MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера",
|
||||
@@ -951,7 +954,6 @@
|
||||
"ToastBookmarkCreateFailed": "Не вдалося створити закладку",
|
||||
"ToastBookmarkCreateSuccess": "Закладку додано",
|
||||
"ToastBookmarkRemoveSuccess": "Закладку видалено",
|
||||
"ToastBookmarkUpdateSuccess": "Закладку оновлено",
|
||||
"ToastCachePurgeFailed": "Не вдалося очистити кеш",
|
||||
"ToastCachePurgeSuccess": "Кеш очищено",
|
||||
"ToastChaptersHaveErrors": "Глави містять помилки",
|
||||
@@ -959,11 +961,10 @@
|
||||
"ToastChaptersRemoved": "Розділи видалені",
|
||||
"ToastChaptersUpdated": "Розділи оновлені",
|
||||
"ToastCollectionItemsAddFailed": "Не вдалося додати елемент(и) до колекції",
|
||||
"ToastCollectionItemsAddSuccess": "Елемент(и) успішно додано до колекції",
|
||||
"ToastCollectionItemsRemoveSuccess": "Елемент(и) видалено з добірки",
|
||||
"ToastCollectionRemoveSuccess": "Добірку видалено",
|
||||
"ToastCollectionUpdateSuccess": "Добірку оновлено",
|
||||
"ToastCoverUpdateFailed": "Не вдалося оновити обкладинку",
|
||||
"ToastDateTimeInvalidOrIncomplete": "Дата й час недійсні або неповні",
|
||||
"ToastDeleteFileFailed": "Не вдалося видалити файл",
|
||||
"ToastDeleteFileSuccess": "Файл видалено",
|
||||
"ToastDeviceAddFailed": "Не вдалося додати пристрій",
|
||||
@@ -1016,6 +1017,7 @@
|
||||
"ToastNewUserTagError": "Потрібно вибрати хоча б один тег",
|
||||
"ToastNewUserUsernameError": "Введіть ім'я користувача",
|
||||
"ToastNoNewEpisodesFound": "Нових епізодів не знайдено",
|
||||
"ToastNoRSSFeed": "Подкаст не має RSS-канал",
|
||||
"ToastNoUpdatesNecessary": "Оновлення не потрібні",
|
||||
"ToastNotificationCreateFailed": "Не вдалося створити сповіщення",
|
||||
"ToastNotificationDeleteFailed": "Не вдалося видалити сповіщення",
|
||||
|
||||
@@ -581,7 +581,6 @@
|
||||
"MessageItemsSelected": "{0} Mục Đã Chọn",
|
||||
"MessageItemsUpdated": "{0} Mục Đã Cập Nhật",
|
||||
"MessageJoinUsOn": "Tham gia cùng chúng tôi trên",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} phiên nghe trong năm qua",
|
||||
"MessageLoading": "Đang tải...",
|
||||
"MessageLoadingFolders": "Đang tải các thư mục...",
|
||||
"MessageM4BFailed": "M4B thất bại!",
|
||||
@@ -680,10 +679,8 @@
|
||||
"ToastBookmarkCreateFailed": "Tạo đánh dấu thất bại",
|
||||
"ToastBookmarkCreateSuccess": "Đã thêm đánh dấu",
|
||||
"ToastBookmarkRemoveSuccess": "Đánh dấu đã được xóa",
|
||||
"ToastBookmarkUpdateSuccess": "Đánh dấu đã được cập nhật",
|
||||
"ToastChaptersHaveErrors": "Các chương có lỗi",
|
||||
"ToastChaptersMustHaveTitles": "Các chương phải có tiêu đề",
|
||||
"ToastCollectionItemsRemoveSuccess": "Mục đã được xóa khỏi bộ sưu tập",
|
||||
"ToastCollectionRemoveSuccess": "Bộ sưu tập đã được xóa",
|
||||
"ToastCollectionUpdateSuccess": "Bộ sưu tập đã được cập nhật",
|
||||
"ToastItemCoverUpdateSuccess": "Ảnh bìa mục đã được cập nhật",
|
||||
|
||||
@@ -771,7 +771,6 @@
|
||||
"MessageItemsSelected": "已选定 {0} 个项目",
|
||||
"MessageItemsUpdated": "已更新 {0} 个项目",
|
||||
"MessageJoinUsOn": "加入我们",
|
||||
"MessageListeningSessionsInTheLastYear": "去年收听 {0} 个会话",
|
||||
"MessageLoading": "正在加载...",
|
||||
"MessageLoadingFolders": "加载文件夹...",
|
||||
"MessageLogsDescription": "日志以 JSON 文件形式存储在 <code>/metadata/logs</code> 目录中. 崩溃日志存储在 <code>/metadata/logs/crash_logs.txt</code> 目录中.",
|
||||
@@ -951,7 +950,6 @@
|
||||
"ToastBookmarkCreateFailed": "创建书签失败",
|
||||
"ToastBookmarkCreateSuccess": "书签已添加",
|
||||
"ToastBookmarkRemoveSuccess": "书签已删除",
|
||||
"ToastBookmarkUpdateSuccess": "书签已更新",
|
||||
"ToastCachePurgeFailed": "清除缓存失败",
|
||||
"ToastCachePurgeSuccess": "缓存清除成功",
|
||||
"ToastChaptersHaveErrors": "章节有错误",
|
||||
@@ -959,8 +957,6 @@
|
||||
"ToastChaptersRemoved": "已删除章节",
|
||||
"ToastChaptersUpdated": "章节已更新",
|
||||
"ToastCollectionItemsAddFailed": "项目添加到收藏夹失败",
|
||||
"ToastCollectionItemsAddSuccess": "项目添加到收藏夹成功",
|
||||
"ToastCollectionItemsRemoveSuccess": "项目从收藏夹移除",
|
||||
"ToastCollectionRemoveSuccess": "收藏夹已删除",
|
||||
"ToastCollectionUpdateSuccess": "收藏夹已更新",
|
||||
"ToastCoverUpdateFailed": "封面更新失败",
|
||||
|
||||
@@ -625,7 +625,6 @@
|
||||
"MessageItemsSelected": "已選定 {0} 個項目",
|
||||
"MessageItemsUpdated": "已更新 {0} 個項目",
|
||||
"MessageJoinUsOn": "加入我們",
|
||||
"MessageListeningSessionsInTheLastYear": "去年收聽 {0} 個會話",
|
||||
"MessageLoading": "讀取...",
|
||||
"MessageLoadingFolders": "讀取資料夾...",
|
||||
"MessageM4BFailed": "M4B 失敗!",
|
||||
@@ -724,10 +723,8 @@
|
||||
"ToastBookmarkCreateFailed": "創建書簽失敗",
|
||||
"ToastBookmarkCreateSuccess": "書籤已新增",
|
||||
"ToastBookmarkRemoveSuccess": "書籤已刪除",
|
||||
"ToastBookmarkUpdateSuccess": "書籤已更新",
|
||||
"ToastChaptersHaveErrors": "章節有錯誤",
|
||||
"ToastChaptersMustHaveTitles": "章節必須有標題",
|
||||
"ToastCollectionItemsRemoveSuccess": "項目從收藏夾移除",
|
||||
"ToastCollectionRemoveSuccess": "收藏夾已刪除",
|
||||
"ToastCollectionUpdateSuccess": "收藏夾已更新",
|
||||
"ToastItemCoverUpdateSuccess": "項目封面已更新",
|
||||
|
||||
34
index.js
34
index.js
@@ -1,3 +1,18 @@
|
||||
const optionDefinitions = [
|
||||
{ name: 'config', alias: 'c', type: String },
|
||||
{ name: 'metadata', alias: 'm', type: String },
|
||||
{ name: 'port', alias: 'p', type: String },
|
||||
{ name: 'host', alias: 'h', type: String },
|
||||
{ name: 'source', alias: 's', type: String },
|
||||
{ name: 'dev', alias: 'd', type: Boolean }
|
||||
]
|
||||
|
||||
const commandLineArgs = require('./server/libs/commandLineArgs')
|
||||
const options = commandLineArgs(optionDefinitions)
|
||||
|
||||
const Path = require('path')
|
||||
process.env.NODE_ENV = options.dev ? 'development' : process.env.NODE_ENV || 'production'
|
||||
|
||||
const server = require('./server/Server')
|
||||
global.appRoot = __dirname
|
||||
|
||||
@@ -13,20 +28,23 @@ if (isDev) {
|
||||
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
|
||||
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
||||
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
||||
if (devEnv.AllowPlugins) process.env.ALLOW_PLUGINS = '1'
|
||||
if (devEnv.DevPluginsPath) process.env.DEV_PLUGINS_PATH = devEnv.DevPluginsPath
|
||||
process.env.SOURCE = 'local'
|
||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 80
|
||||
const HOST = process.env.HOST
|
||||
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
|
||||
const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
|
||||
const SOURCE = process.env.SOURCE || 'docker'
|
||||
const inputConfig = options.config ? Path.resolve(options.config) : null
|
||||
const inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
|
||||
|
||||
const PORT = options.port || process.env.PORT || 3333
|
||||
const HOST = options.host || process.env.HOST
|
||||
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
|
||||
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
|
||||
const SOURCE = options.source || process.env.SOURCE || 'debian'
|
||||
|
||||
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || ''
|
||||
|
||||
console.log('Config', CONFIG_PATH, METADATA_PATH)
|
||||
console.log(`Running in ${process.env.NODE_ENV} mode.`)
|
||||
console.log(`Options: CONFIG_PATH=${CONFIG_PATH}, METADATA_PATH=${METADATA_PATH}, PORT=${PORT}, HOST=${HOST}, SOURCE=${SOURCE}, ROUTER_BASE_PATH=${ROUTER_BASE_PATH}`)
|
||||
|
||||
const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH)
|
||||
Server.start()
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.17.6",
|
||||
"version": "2.17.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.17.6",
|
||||
"version": "2.17.7",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
@@ -30,7 +30,7 @@
|
||||
"xml2js": "^0.5.0"
|
||||
},
|
||||
"bin": {
|
||||
"audiobookshelf": "prod.js"
|
||||
"audiobookshelf": "index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.3.10",
|
||||
|
||||
10
package.json
10
package.json
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.17.6",
|
||||
"version": "2.17.7",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon --watch server index.js",
|
||||
"dev": "nodemon --watch server index.js -- --dev",
|
||||
"start": "node index.js",
|
||||
"client": "cd client && npm ci && npm run generate",
|
||||
"prod": "npm run client && npm ci && node prod.js",
|
||||
"prod": "npm run client && npm ci && node index.js",
|
||||
"build-win": "npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
||||
"build-linux": "build/linuxpackager",
|
||||
"docker": "docker buildx build --platform linux/amd64,linux/arm64 --push . -t advplyr/audiobookshelf",
|
||||
@@ -18,7 +18,7 @@
|
||||
"test": "mocha",
|
||||
"coverage": "nyc mocha"
|
||||
},
|
||||
"bin": "prod.js",
|
||||
"bin": "index.js",
|
||||
"pkg": {
|
||||
"assets": [
|
||||
"client/dist/**/*",
|
||||
@@ -26,7 +26,7 @@
|
||||
"server/migrations/*.js"
|
||||
],
|
||||
"scripts": [
|
||||
"prod.js",
|
||||
"index.js",
|
||||
"server/**/*.js"
|
||||
]
|
||||
},
|
||||
|
||||
4
prod.js
4
prod.js
@@ -16,8 +16,8 @@ const server = require('./server/Server')
|
||||
|
||||
global.appRoot = __dirname
|
||||
|
||||
const inputConfig = options.config ? Path.resolve(options.config) : null
|
||||
const inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
|
||||
var inputConfig = options.config ? Path.resolve(options.config) : null
|
||||
var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
|
||||
|
||||
const PORT = options.port || process.env.PORT || 3333
|
||||
const HOST = options.host || process.env.HOST
|
||||
|
||||
@@ -111,8 +111,8 @@ server {
|
||||
|
||||
location / {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ const Logger = require('./Logger')
|
||||
*/
|
||||
class Auth {
|
||||
constructor() {
|
||||
this.pluginManifests = []
|
||||
|
||||
// Map of openId sessions indexed by oauth2 state-variable
|
||||
this.openIdAuthSession = new Map()
|
||||
this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/]
|
||||
@@ -935,28 +933,11 @@ class Auth {
|
||||
*/
|
||||
async getUserLoginResponsePayload(user) {
|
||||
const libraryIds = await Database.libraryModel.getAllLibraryIds()
|
||||
|
||||
let plugins = undefined
|
||||
if (process.env.ALLOW_PLUGINS === '1') {
|
||||
// TODO: Should be better handled by the PluginManager
|
||||
// restrict plugin extensions that are not allowed for the user type
|
||||
plugins = this.pluginManifests.map((manifest) => {
|
||||
const manifestExtensions = (manifest.extensions || []).filter((ext) => {
|
||||
if (ext.restrictToAccountTypes?.length) {
|
||||
return ext.restrictToAccountTypes.includes(user.type)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return { ...manifest, extensions: manifestExtensions }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
user: user.toOldJSONForBrowser(),
|
||||
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
|
||||
serverSettings: Database.serverSettings.toJSONForBrowser(),
|
||||
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
|
||||
plugins,
|
||||
Source: global.Source
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +152,6 @@ class Database {
|
||||
return this.models.device
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Plugin')} */
|
||||
get pluginModel() {
|
||||
return this.models.plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if db file exists
|
||||
* @returns {boolean}
|
||||
@@ -310,7 +305,6 @@ class Database {
|
||||
require('./models/Setting').init(this.sequelize)
|
||||
require('./models/CustomMetadataProvider').init(this.sequelize)
|
||||
require('./models/MediaItemShare').init(this.sequelize)
|
||||
require('./models/Plugin').init(this.sequelize)
|
||||
|
||||
return this.sequelize.sync({ force, alter: false })
|
||||
}
|
||||
@@ -407,60 +401,6 @@ class Database {
|
||||
return this.models.setting.updateSettingObj(settings.toJSON())
|
||||
}
|
||||
|
||||
updateBulkBooks(oldBooks) {
|
||||
if (!this.sequelize) return false
|
||||
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
|
||||
}
|
||||
|
||||
createBulkCollectionBooks(collectionBooks) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.collectionBook.bulkCreate(collectionBooks)
|
||||
}
|
||||
|
||||
createPlaylistMediaItem(playlistMediaItem) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.playlistMediaItem.create(playlistMediaItem)
|
||||
}
|
||||
|
||||
createBulkPlaylistMediaItems(playlistMediaItems) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
|
||||
}
|
||||
|
||||
async createLibraryItem(oldLibraryItem) {
|
||||
if (!this.sequelize) return false
|
||||
await oldLibraryItem.saveMetadata()
|
||||
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save metadata file and update library item
|
||||
*
|
||||
* @param {import('./objects/LibraryItem')} oldLibraryItem
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async updateLibraryItem(oldLibraryItem) {
|
||||
if (!this.sequelize) return false
|
||||
await oldLibraryItem.saveMetadata()
|
||||
const updated = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
|
||||
// Clear library filter data cache
|
||||
if (updated) {
|
||||
delete this.libraryFilterData[oldLibraryItem.libraryId]
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
async createBulkBookAuthors(bookAuthors) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.bookAuthor.bulkCreate(bookAuthors)
|
||||
}
|
||||
|
||||
async removeBulkBookAuthors(authorId = null, bookId = null) {
|
||||
if (!this.sequelize) return false
|
||||
if (!authorId && !bookId) return
|
||||
await this.models.bookAuthor.removeByIds(authorId, bookId)
|
||||
}
|
||||
|
||||
getPlaybackSessions(where = null) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.playbackSession.getOldPlaybackSessions(where)
|
||||
@@ -686,7 +626,7 @@ class Database {
|
||||
/**
|
||||
* Clean invalid records in database
|
||||
* Series should have atleast one Book
|
||||
* Book and Podcast must have an associated LibraryItem
|
||||
* Book and Podcast must have an associated LibraryItem (and vice versa)
|
||||
* Remove playback sessions that are 3 seconds or less
|
||||
*/
|
||||
async cleanDatabase() {
|
||||
@@ -716,6 +656,49 @@ class Database {
|
||||
await book.destroy()
|
||||
}
|
||||
|
||||
// Remove invalid LibraryItem records
|
||||
const libraryItemsWithNoMedia = await this.libraryItemModel.findAll({
|
||||
include: [
|
||||
{
|
||||
model: this.bookModel,
|
||||
attributes: ['id']
|
||||
},
|
||||
{
|
||||
model: this.podcastModel,
|
||||
attributes: ['id']
|
||||
}
|
||||
],
|
||||
where: {
|
||||
'$book.id$': null,
|
||||
'$podcast.id$': null
|
||||
}
|
||||
})
|
||||
for (const libraryItem of libraryItemsWithNoMedia) {
|
||||
Logger.warn(`Found libraryItem "${libraryItem.id}" with no media - removing it`)
|
||||
await libraryItem.destroy()
|
||||
}
|
||||
|
||||
const playlistMediaItemsWithNoMediaItem = await this.playlistMediaItemModel.findAll({
|
||||
include: [
|
||||
{
|
||||
model: this.bookModel,
|
||||
attributes: ['id']
|
||||
},
|
||||
{
|
||||
model: this.podcastEpisodeModel,
|
||||
attributes: ['id']
|
||||
}
|
||||
],
|
||||
where: {
|
||||
'$book.id$': null,
|
||||
'$podcastEpisode.id$': null
|
||||
}
|
||||
})
|
||||
for (const playlistMediaItem of playlistMediaItemsWithNoMediaItem) {
|
||||
Logger.warn(`Found playlistMediaItem with no book or podcastEpisode - removing it`)
|
||||
await playlistMediaItem.destroy()
|
||||
}
|
||||
|
||||
// Remove empty series
|
||||
const emptySeries = await this.seriesModel.findAll({
|
||||
include: {
|
||||
|
||||
@@ -6,6 +6,7 @@ const util = require('util')
|
||||
const fs = require('./libs/fsExtra')
|
||||
const fileUpload = require('./libs/expressFileupload')
|
||||
const cookieParser = require('cookie-parser')
|
||||
const axios = require('axios')
|
||||
|
||||
const { version } = require('../package.json')
|
||||
|
||||
@@ -28,6 +29,7 @@ const AbMergeManager = require('./managers/AbMergeManager')
|
||||
const CacheManager = require('./managers/CacheManager')
|
||||
const BackupManager = require('./managers/BackupManager')
|
||||
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
|
||||
const PodcastManager = require('./managers/PodcastManager')
|
||||
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||
const RssFeedManager = require('./managers/RssFeedManager')
|
||||
const CronManager = require('./managers/CronManager')
|
||||
@@ -35,7 +37,6 @@ const ApiCacheManager = require('./managers/ApiCacheManager')
|
||||
const BinaryManager = require('./managers/BinaryManager')
|
||||
const ShareManager = require('./managers/ShareManager')
|
||||
const LibraryScanner = require('./scanner/LibraryScanner')
|
||||
const PluginManager = require('./managers/PluginManager')
|
||||
|
||||
//Import the main Passport and Express-Session library
|
||||
const passport = require('passport')
|
||||
@@ -54,7 +55,26 @@ class Server {
|
||||
global.XAccel = process.env.USE_X_ACCEL
|
||||
global.AllowCors = process.env.ALLOW_CORS === '1'
|
||||
|
||||
if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') {
|
||||
if (process.env.EXP_PROXY_SUPPORT === '1') {
|
||||
// https://github.com/advplyr/audiobookshelf/pull/3754
|
||||
Logger.info(`[Server] Experimental Proxy Support Enabled, SSRF Request Filter was Disabled`)
|
||||
global.DisableSsrfRequestFilter = () => true
|
||||
|
||||
axios.defaults.maxRedirects = 0
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if ([301, 302].includes(error.response?.status)) {
|
||||
return axios({
|
||||
...error.config,
|
||||
url: error.response.headers.location
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
} else if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') {
|
||||
Logger.info(`[Server] SSRF Request Filter Disabled`)
|
||||
global.DisableSsrfRequestFilter = () => true
|
||||
} else if (process.env.SSRF_REQUEST_FILTER_WHITELIST?.length) {
|
||||
@@ -65,6 +85,12 @@ class Server {
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.PODCAST_DOWNLOAD_TIMEOUT) {
|
||||
global.PodcastDownloadTimeout = process.env.PODCAST_DOWNLOAD_TIMEOUT
|
||||
} else {
|
||||
global.PodcastDownloadTimeout = 30000
|
||||
}
|
||||
|
||||
if (!fs.pathExistsSync(global.ConfigPath)) {
|
||||
fs.mkdirSync(global.ConfigPath)
|
||||
}
|
||||
@@ -79,8 +105,9 @@ class Server {
|
||||
this.backupManager = new BackupManager()
|
||||
this.abMergeManager = new AbMergeManager()
|
||||
this.playbackSessionManager = new PlaybackSessionManager()
|
||||
this.podcastManager = new PodcastManager()
|
||||
this.audioMetadataManager = new AudioMetadataMangaer()
|
||||
this.cronManager = new CronManager(this.playbackSessionManager)
|
||||
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
|
||||
this.apiCacheManager = new ApiCacheManager()
|
||||
this.binaryManager = new BinaryManager()
|
||||
|
||||
@@ -160,15 +187,6 @@ class Server {
|
||||
LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask)
|
||||
})
|
||||
}
|
||||
|
||||
if (process.env.ALLOW_PLUGINS === '1') {
|
||||
Logger.info(`[Server] Experimental plugin support enabled`)
|
||||
|
||||
// Initialize plugins
|
||||
await PluginManager.init()
|
||||
// TODO: Prevents circular dependency for SocketAuthority
|
||||
this.auth.pluginManifests = PluginManager.pluginManifests
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,16 +44,21 @@ class AuthorController {
|
||||
|
||||
// Used on author landing page to include library items and items grouped in series
|
||||
if (include.includes('items')) {
|
||||
authorJson.libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
|
||||
const libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
|
||||
|
||||
if (include.includes('series')) {
|
||||
const seriesMap = {}
|
||||
// Group items into series
|
||||
authorJson.libraryItems.forEach((li) => {
|
||||
if (li.media.metadata.series) {
|
||||
li.media.metadata.series.forEach((series) => {
|
||||
const itemWithSeries = li.toJSONMinified()
|
||||
itemWithSeries.media.metadata.series = series
|
||||
libraryItems.forEach((li) => {
|
||||
if (li.media.series?.length) {
|
||||
li.media.series.forEach((series) => {
|
||||
const itemWithSeries = li.toOldJSONMinified()
|
||||
itemWithSeries.media.metadata.series = {
|
||||
id: series.id,
|
||||
name: series.name,
|
||||
nameIgnorePrefix: series.nameIgnorePrefix,
|
||||
sequence: series.bookSeries.sequence
|
||||
}
|
||||
|
||||
if (seriesMap[series.id]) {
|
||||
seriesMap[series.id].items.push(itemWithSeries)
|
||||
@@ -76,7 +81,7 @@ class AuthorController {
|
||||
}
|
||||
|
||||
// Minify library items
|
||||
authorJson.libraryItems = authorJson.libraryItems.map((li) => li.toJSONMinified())
|
||||
authorJson.libraryItems = libraryItems.map((li) => li.toOldJSONMinified())
|
||||
}
|
||||
|
||||
return res.json(authorJson)
|
||||
@@ -125,7 +130,7 @@ class AuthorController {
|
||||
const bookAuthorsToCreate = []
|
||||
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
|
||||
|
||||
const oldLibraryItems = []
|
||||
const libraryItems = []
|
||||
allItemsWithAuthor.forEach((libraryItem) => {
|
||||
// Replace old author with merging author for each book
|
||||
libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)
|
||||
@@ -134,23 +139,22 @@ class AuthorController {
|
||||
name: existingAuthor.name
|
||||
})
|
||||
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
oldLibraryItems.push(oldLibraryItem)
|
||||
libraryItems.push(libraryItem)
|
||||
|
||||
bookAuthorsToCreate.push({
|
||||
bookId: libraryItem.media.id,
|
||||
authorId: existingAuthor.id
|
||||
})
|
||||
})
|
||||
if (oldLibraryItems.length) {
|
||||
await Database.removeBulkBookAuthors(req.author.id) // Remove all old BookAuthor
|
||||
await Database.createBulkBookAuthors(bookAuthorsToCreate) // Create all new BookAuthor
|
||||
for (const libraryItem of allItemsWithAuthor) {
|
||||
if (libraryItems.length) {
|
||||
await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
|
||||
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor
|
||||
for (const libraryItem of libraryItems) {
|
||||
await libraryItem.saveMetadataFile()
|
||||
}
|
||||
SocketAuthority.emitter(
|
||||
'items_updated',
|
||||
oldLibraryItems.map((li) => li.toJSONExpanded())
|
||||
libraryItems.map((li) => li.toOldJSONExpanded())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -190,7 +194,7 @@ class AuthorController {
|
||||
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
|
||||
|
||||
numBooksForAuthor = allItemsWithAuthor.length
|
||||
const oldLibraryItems = []
|
||||
const libraryItems = []
|
||||
// Update author name on all books
|
||||
for (const libraryItem of allItemsWithAuthor) {
|
||||
libraryItem.media.authors = libraryItem.media.authors.map((au) => {
|
||||
@@ -199,16 +203,16 @@ class AuthorController {
|
||||
}
|
||||
return au
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
oldLibraryItems.push(oldLibraryItem)
|
||||
|
||||
libraryItems.push(libraryItem)
|
||||
|
||||
await libraryItem.saveMetadataFile()
|
||||
}
|
||||
|
||||
if (oldLibraryItems.length) {
|
||||
if (libraryItems.length) {
|
||||
SocketAuthority.emitter(
|
||||
'items_updated',
|
||||
oldLibraryItems.map((li) => li.toJSONExpanded())
|
||||
libraryItems.map((li) => li.toOldJSONExpanded())
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -238,8 +242,18 @@ class AuthorController {
|
||||
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
}
|
||||
|
||||
// Load library items so that metadata file can be updated
|
||||
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
|
||||
allItemsWithAuthor.forEach((libraryItem) => {
|
||||
libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)
|
||||
})
|
||||
|
||||
await req.author.destroy()
|
||||
|
||||
for (const libraryItem of allItemsWithAuthor) {
|
||||
await libraryItem.saveMetadataFile()
|
||||
}
|
||||
|
||||
SocketAuthority.emitter('author_removed', req.author.toOldJSON())
|
||||
|
||||
// Update filter data
|
||||
|
||||
@@ -5,13 +5,17 @@ const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const Collection = require('../objects/Collection')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*
|
||||
* @typedef RequestEntityObject
|
||||
* @property {import('../models/Collection')} collection
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject} CollectionControllerRequest
|
||||
*/
|
||||
|
||||
class CollectionController {
|
||||
@@ -25,36 +29,71 @@ class CollectionController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async create(req, res) {
|
||||
const newCollection = new Collection()
|
||||
req.body.userId = req.user.id
|
||||
if (!newCollection.setData(req.body)) {
|
||||
const reqBody = req.body || {}
|
||||
|
||||
// Validation
|
||||
if (!reqBody.name || !reqBody.libraryId) {
|
||||
return res.status(400).send('Invalid collection data')
|
||||
}
|
||||
if (reqBody.description && typeof reqBody.description !== 'string') {
|
||||
return res.status(400).send('Invalid collection description')
|
||||
}
|
||||
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid collection data. No books')
|
||||
}
|
||||
|
||||
// Create collection record
|
||||
await Database.collectionModel.createFromOld(newCollection)
|
||||
|
||||
// Get library items in collection
|
||||
const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection)
|
||||
|
||||
// Create collectionBook records
|
||||
let order = 1
|
||||
const collectionBooksToAdd = []
|
||||
for (const libraryItemId of newCollection.books) {
|
||||
const libraryItem = libraryItemsInCollection.find((li) => li.id === libraryItemId)
|
||||
if (libraryItem) {
|
||||
collectionBooksToAdd.push({
|
||||
collectionId: newCollection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
order: order++
|
||||
})
|
||||
// Load library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: libraryItemIds,
|
||||
libraryId: reqBody.libraryId,
|
||||
mediaType: 'book'
|
||||
}
|
||||
}
|
||||
if (collectionBooksToAdd.length) {
|
||||
await Database.createBulkCollectionBooks(collectionBooksToAdd)
|
||||
})
|
||||
if (libraryItems.length !== libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid collection data. Invalid books')
|
||||
}
|
||||
|
||||
const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection)
|
||||
/** @type {import('../models/Collection')} */
|
||||
let newCollection = null
|
||||
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
// Create collection
|
||||
newCollection = await Database.collectionModel.create(
|
||||
{
|
||||
libraryId: reqBody.libraryId,
|
||||
name: reqBody.name,
|
||||
description: reqBody.description || null
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
// Create collectionBooks
|
||||
const collectionBookPayloads = libraryItemIds.map((llid, index) => {
|
||||
const libraryItem = libraryItems.find((li) => li.id === llid)
|
||||
return {
|
||||
collectionId: newCollection.id,
|
||||
bookId: libraryItem.mediaId,
|
||||
order: index + 1
|
||||
}
|
||||
})
|
||||
await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction })
|
||||
|
||||
await transaction.commit()
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
Logger.error('[CollectionController] create:', error)
|
||||
return res.status(500).send('Failed to create collection')
|
||||
}
|
||||
|
||||
// Load books expanded
|
||||
newCollection.books = await newCollection.getBooksExpandedWithLibraryItem()
|
||||
|
||||
// Note: The old collection model stores expanded libraryItems in the books property
|
||||
const jsonExpanded = newCollection.toOldJSONExpanded()
|
||||
SocketAuthority.emitter('collection_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
@@ -75,7 +114,7 @@ class CollectionController {
|
||||
/**
|
||||
* GET: /api/collections/:id
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
@@ -94,7 +133,7 @@ class CollectionController {
|
||||
* PATCH: /api/collections/:id
|
||||
* Update collection
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
@@ -158,7 +197,7 @@ class CollectionController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
@@ -178,11 +217,13 @@ class CollectionController {
|
||||
* Add a single book to a collection
|
||||
* Req.body { id: <library item id> }
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async addBook(req, res) {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.body.id)
|
||||
const libraryItem = await Database.libraryItemModel.findByPk(req.body.id, {
|
||||
attributes: ['libraryId', 'mediaId']
|
||||
})
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Book not found')
|
||||
}
|
||||
@@ -192,14 +233,14 @@ class CollectionController {
|
||||
|
||||
// Check if book is already in collection
|
||||
const collectionBooks = await req.collection.getCollectionBooks()
|
||||
if (collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) {
|
||||
if (collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {
|
||||
return res.status(400).send('Book already in collection')
|
||||
}
|
||||
|
||||
// Create collectionBook record
|
||||
await Database.collectionBookModel.create({
|
||||
collectionId: req.collection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
bookId: libraryItem.mediaId,
|
||||
order: collectionBooks.length + 1
|
||||
})
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
@@ -212,11 +253,13 @@ class CollectionController {
|
||||
* Remove a single book from a collection. Re-order books
|
||||
* TODO: bookId is actually libraryItemId. Clients need updating to use bookId
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeBook(req, res) {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId)
|
||||
const libraryItem = await Database.libraryItemModel.findByPk(req.params.bookId, {
|
||||
attributes: ['mediaId']
|
||||
})
|
||||
if (!libraryItem) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
@@ -227,7 +270,7 @@ class CollectionController {
|
||||
})
|
||||
|
||||
let jsonExpanded = null
|
||||
const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.media.id)
|
||||
const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.mediaId)
|
||||
if (collectionBookToRemove) {
|
||||
// Remove collection book record
|
||||
await collectionBookToRemove.destroy()
|
||||
@@ -235,7 +278,7 @@ class CollectionController {
|
||||
// Update order on collection books
|
||||
let order = 1
|
||||
for (const collectionBook of collectionBooks) {
|
||||
if (collectionBook.bookId === libraryItem.media.id) continue
|
||||
if (collectionBook.bookId === libraryItem.mediaId) continue
|
||||
if (collectionBook.order !== order) {
|
||||
await collectionBook.update({
|
||||
order
|
||||
@@ -257,29 +300,31 @@ class CollectionController {
|
||||
* Add multiple books to collection
|
||||
* Req.body { books: <Array of library item ids> }
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async addBatch(req, res) {
|
||||
// filter out invalid libraryItemIds
|
||||
const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string')
|
||||
if (!bookIdsToAdd.length) {
|
||||
return res.status(500).send('Invalid request body')
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Get library items associated with ids
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: {
|
||||
[Sequelize.Op.in]: bookIdsToAdd
|
||||
}
|
||||
},
|
||||
include: {
|
||||
model: Database.bookModel
|
||||
id: bookIdsToAdd,
|
||||
libraryId: req.collection.libraryId,
|
||||
mediaType: 'book'
|
||||
}
|
||||
})
|
||||
if (!libraryItems.length) {
|
||||
return res.status(400).send('Invalid request body. No valid books')
|
||||
}
|
||||
|
||||
// Get collection books already in collection
|
||||
/** @type {import('../models/CollectionBook')[]} */
|
||||
const collectionBooks = await req.collection.getCollectionBooks()
|
||||
|
||||
let order = collectionBooks.length + 1
|
||||
@@ -288,10 +333,10 @@ class CollectionController {
|
||||
|
||||
// Check and set new collection books to add
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (!collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) {
|
||||
if (!collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {
|
||||
collectionBooksToAdd.push({
|
||||
collectionId: req.collection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
bookId: libraryItem.mediaId,
|
||||
order: order++
|
||||
})
|
||||
hasUpdated = true
|
||||
@@ -302,7 +347,8 @@ class CollectionController {
|
||||
|
||||
let jsonExpanded = null
|
||||
if (hasUpdated) {
|
||||
await Database.createBulkCollectionBooks(collectionBooksToAdd)
|
||||
await Database.collectionBookModel.bulkCreate(collectionBooksToAdd)
|
||||
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
} else {
|
||||
@@ -316,7 +362,7 @@ class CollectionController {
|
||||
* Remove multiple books from collection
|
||||
* Req.body { books: <Array of library item ids> }
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeBatch(req, res) {
|
||||
@@ -329,9 +375,7 @@ class CollectionController {
|
||||
// Get library items associated with ids
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Sequelize.Op.in]: bookIdsToRemove
|
||||
}
|
||||
id: bookIdsToRemove
|
||||
},
|
||||
include: {
|
||||
model: Database.bookModel
|
||||
@@ -339,6 +383,7 @@ class CollectionController {
|
||||
})
|
||||
|
||||
// Get collection books already in collection
|
||||
/** @type {import('../models/CollectionBook')[]} */
|
||||
const collectionBooks = await req.collection.getCollectionBooks({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
|
||||
@@ -106,7 +106,7 @@ class EmailController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(req.body.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Library item not found')
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ const Scanner = require('../scanner/Scanner')
|
||||
const Database = require('../Database')
|
||||
const Watcher = require('../Watcher')
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const PodcastManager = require('../managers/PodcastManager')
|
||||
|
||||
const libraryFilters = require('../utils/queries/libraryFilters')
|
||||
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
||||
@@ -171,21 +170,34 @@ class LibraryController {
|
||||
* GET: /api/libraries
|
||||
* Get all libraries
|
||||
*
|
||||
* ?include=stats to load library stats - used in android auto to filter out libraries with no audio
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findAll(req, res) {
|
||||
const libraries = await Database.libraryModel.getAllWithFolders()
|
||||
let libraries = await Database.libraryModel.getAllWithFolders()
|
||||
|
||||
const librariesAccessible = req.user.permissions?.librariesAccessible || []
|
||||
if (librariesAccessible.length) {
|
||||
return res.json({
|
||||
libraries: libraries.filter((lib) => librariesAccessible.includes(lib.id)).map((lib) => lib.toOldJSON())
|
||||
})
|
||||
libraries = libraries.filter((lib) => librariesAccessible.includes(lib.id))
|
||||
}
|
||||
|
||||
libraries = libraries.map((lib) => lib.toOldJSON())
|
||||
|
||||
const includeArray = (req.query.include || '').split(',')
|
||||
if (includeArray.includes('stats')) {
|
||||
for (const library of libraries) {
|
||||
if (library.mediaType === 'book') {
|
||||
library.stats = await libraryItemsBookFilters.getBookLibraryStats(library.id)
|
||||
} else if (library.mediaType === 'podcast') {
|
||||
library.stats = await libraryItemsPodcastFilters.getPodcastLibraryStats(library.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
libraries: libraries.map((lib) => lib.toOldJSON())
|
||||
libraries
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,7 +232,7 @@ class LibraryController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getEpisodeDownloadQueue(req, res) {
|
||||
const libraryDownloadQueueDetails = PodcastManager.getDownloadQueueDetails(req.library.id)
|
||||
const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id)
|
||||
res.json(libraryDownloadQueueDetails)
|
||||
}
|
||||
|
||||
@@ -1146,14 +1158,14 @@ class LibraryController {
|
||||
await libraryItem.media.update({
|
||||
narrators: libraryItem.media.narrators
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
itemsUpdated.push(oldLibraryItem)
|
||||
|
||||
itemsUpdated.push(libraryItem)
|
||||
}
|
||||
|
||||
if (itemsUpdated.length) {
|
||||
SocketAuthority.emitter(
|
||||
'items_updated',
|
||||
itemsUpdated.map((li) => li.toJSONExpanded())
|
||||
itemsUpdated.map((li) => li.toOldJSONExpanded())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1190,14 +1202,14 @@ class LibraryController {
|
||||
await libraryItem.media.update({
|
||||
narrators: libraryItem.media.narrators
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
itemsUpdated.push(oldLibraryItem)
|
||||
|
||||
itemsUpdated.push(libraryItem)
|
||||
}
|
||||
|
||||
if (itemsUpdated.length) {
|
||||
SocketAuthority.emitter(
|
||||
'items_updated',
|
||||
itemsUpdated.map((li) => li.toJSONExpanded())
|
||||
itemsUpdated.map((li) => li.toOldJSONExpanded())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1289,7 +1301,7 @@ class LibraryController {
|
||||
}
|
||||
})
|
||||
|
||||
const opmlText = PodcastManager.generateOPMLFileText(podcasts)
|
||||
const opmlText = this.podcastManager.generateOPMLFileText(podcasts)
|
||||
res.type('application/xml')
|
||||
res.send(opmlText)
|
||||
}
|
||||
|
||||
@@ -18,13 +18,22 @@ const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
const ShareManager = require('../managers/ShareManager')
|
||||
const PodcastManager = require('../managers/PodcastManager')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*
|
||||
* @typedef RequestEntityObject
|
||||
* @property {import('../models/LibraryItem')} libraryItem
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject} LibraryItemControllerRequest
|
||||
*
|
||||
* @typedef RequestLibraryFileObject
|
||||
* @property {import('../objects/files/LibraryFile')} libraryFile
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
|
||||
*/
|
||||
|
||||
class LibraryItemController {
|
||||
@@ -36,17 +45,17 @@ class LibraryItemController {
|
||||
* ?include=progress,rssfeed,downloads,share
|
||||
* ?expanded=1
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
const includeEntities = (req.query.include || '').split(',')
|
||||
if (req.query.expanded == 1) {
|
||||
var item = req.libraryItem.toJSONExpanded()
|
||||
const item = req.libraryItem.toOldJSONExpanded()
|
||||
|
||||
// Include users media progress
|
||||
if (includeEntities.includes('progress')) {
|
||||
var episodeId = req.query.episode || null
|
||||
const episodeId = req.query.episode || null
|
||||
item.userMediaProgress = req.user.getOldMediaProgress(item.id, episodeId)
|
||||
}
|
||||
|
||||
@@ -60,37 +69,16 @@ class LibraryItemController {
|
||||
}
|
||||
|
||||
if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) {
|
||||
const downloadsInQueue = PodcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||
item.episodeDownloadsQueued = downloadsInQueue.map((d) => d.toJSONForClient())
|
||||
if (PodcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
|
||||
item.episodesDownloading = [PodcastManager.currentDownload.toJSONForClient()]
|
||||
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
|
||||
item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()]
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(item)
|
||||
}
|
||||
res.json(req.libraryItem)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
// Item has cover and update is removing cover so purge it from cache
|
||||
if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
}
|
||||
|
||||
const hasUpdates = libraryItem.update(req.body)
|
||||
if (hasUpdates) {
|
||||
Logger.debug(`[LibraryItemController] Updated now saving`)
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
}
|
||||
res.json(libraryItem.toJSON())
|
||||
res.json(req.libraryItem.toOldJSON())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +89,7 @@ class LibraryItemController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
@@ -112,14 +100,14 @@ class LibraryItemController {
|
||||
const authorIds = []
|
||||
const seriesIds = []
|
||||
if (req.libraryItem.isPodcast) {
|
||||
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id))
|
||||
mediaItemIds.push(...req.libraryItem.media.podcastEpisodes.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.authors?.length) {
|
||||
authorIds.push(...req.libraryItem.media.authors.map((au) => au.id))
|
||||
}
|
||||
if (req.libraryItem.media.metadata.series?.length) {
|
||||
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id))
|
||||
if (req.libraryItem.media.series?.length) {
|
||||
seriesIds.push(...req.libraryItem.media.series.map((se) => se.id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +144,7 @@ class LibraryItemController {
|
||||
* GET: /api/items/:id/download
|
||||
* Download library item. Zip file if multiple files.
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async download(req, res) {
|
||||
@@ -165,7 +153,7 @@ class LibraryItemController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const libraryItemPath = req.libraryItem.path
|
||||
const itemTitle = req.libraryItem.media.metadata.title
|
||||
const itemTitle = req.libraryItem.media.title
|
||||
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
|
||||
|
||||
@@ -195,11 +183,10 @@ class LibraryItemController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateMedia(req, res) {
|
||||
const libraryItem = req.libraryItem
|
||||
const mediaPayload = req.body
|
||||
|
||||
if (mediaPayload.url) {
|
||||
@@ -207,69 +194,79 @@ class LibraryItemController {
|
||||
if (res.writableEnded || res.headersSent) return
|
||||
}
|
||||
|
||||
// Book specific
|
||||
if (libraryItem.isBook) {
|
||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
||||
}
|
||||
|
||||
// Podcast specific
|
||||
let isPodcastAutoDownloadUpdated = false
|
||||
if (libraryItem.isPodcast) {
|
||||
if (mediaPayload.autoDownloadEpisodes !== undefined && libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
|
||||
if (req.libraryItem.isPodcast) {
|
||||
if (mediaPayload.autoDownloadEpisodes !== undefined && req.libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
|
||||
isPodcastAutoDownloadUpdated = true
|
||||
} else if (mediaPayload.autoDownloadSchedule !== undefined && libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
|
||||
} else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
|
||||
isPodcastAutoDownloadUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
// Book specific - Get all series being removed from this item
|
||||
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))
|
||||
}
|
||||
let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
|
||||
|
||||
let authorsRemoved = []
|
||||
if (libraryItem.isBook && mediaPayload.metadata?.authors) {
|
||||
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
|
||||
authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
|
||||
}
|
||||
|
||||
const hasUpdates = libraryItem.media.update(mediaPayload) || mediaPayload.url
|
||||
if (hasUpdates) {
|
||||
libraryItem.updatedAt = Date.now()
|
||||
|
||||
if (isPodcastAutoDownloadUpdated) {
|
||||
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
||||
}
|
||||
|
||||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
|
||||
if (authorsRemoved.length) {
|
||||
// Check remove empty authors
|
||||
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
|
||||
}
|
||||
if (seriesRemoved.length) {
|
||||
if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
|
||||
const seriesUpdateData = await req.libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, req.libraryItem.libraryId)
|
||||
if (seriesUpdateData?.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))
|
||||
await this.checkRemoveEmptySeries(seriesUpdateData.seriesRemoved.map((se) => se.id))
|
||||
}
|
||||
if (seriesUpdateData?.seriesAdded.length) {
|
||||
// Add series to filter data
|
||||
seriesUpdateData.seriesAdded.forEach((se) => {
|
||||
Database.addSeriesToFilterData(req.libraryItem.libraryId, se.name, se.id)
|
||||
})
|
||||
}
|
||||
if (seriesUpdateData?.hasUpdates) {
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {
|
||||
const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)
|
||||
const authorUpdateData = await req.libraryItem.media.updateAuthorsFromRequest(authorNames, req.libraryItem.libraryId)
|
||||
if (authorUpdateData?.authorsRemoved.length) {
|
||||
// Check remove empty authors
|
||||
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorUpdateData.authorsRemoved.map((au) => au.id))
|
||||
hasUpdates = true
|
||||
}
|
||||
if (authorUpdateData?.authorsAdded.length) {
|
||||
// Add authors to filter data
|
||||
authorUpdateData.authorsAdded.forEach((au) => {
|
||||
Database.addAuthorToFilterData(req.libraryItem.libraryId, au.name, au.id)
|
||||
})
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
req.libraryItem.changed('updatedAt', true)
|
||||
await req.libraryItem.save()
|
||||
|
||||
await req.libraryItem.saveMetadataFile()
|
||||
|
||||
if (isPodcastAutoDownloadUpdated) {
|
||||
this.cronManager.checkUpdatePodcastCron(req.libraryItem)
|
||||
}
|
||||
|
||||
Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
}
|
||||
res.json({
|
||||
updated: hasUpdates,
|
||||
libraryItem
|
||||
libraryItem: req.libraryItem.toOldJSON()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/items/:id/cover
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
* @param {boolean} [updateAndReturnJson=true]
|
||||
* @param {boolean} [updateAndReturnJson=true] - Allows the function to be used for both direct API calls and internally
|
||||
*/
|
||||
async uploadCover(req, res, updateAndReturnJson = true) {
|
||||
if (!req.user.canUpload) {
|
||||
@@ -277,15 +274,13 @@ class LibraryItemController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
let libraryItem = req.libraryItem
|
||||
|
||||
let result = null
|
||||
if (req.body?.url) {
|
||||
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
|
||||
result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url)
|
||||
result = await CoverManager.downloadCoverFromUrlNew(req.body.url, req.libraryItem.id, req.libraryItem.isFile ? null : req.libraryItem.path)
|
||||
} else if (req.files?.cover) {
|
||||
Logger.debug(`[LibraryItemController] Handling uploaded cover`)
|
||||
result = await CoverManager.uploadCover(libraryItem, req.files.cover)
|
||||
result = await CoverManager.uploadCover(req.libraryItem, req.files.cover)
|
||||
} else {
|
||||
return res.status(400).send('Invalid request no file or url')
|
||||
}
|
||||
@@ -296,9 +291,16 @@ class LibraryItemController {
|
||||
return res.status(500).send('Unknown error occurred')
|
||||
}
|
||||
|
||||
req.libraryItem.media.coverPath = result.cover
|
||||
req.libraryItem.media.changed('coverPath', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
if (updateAndReturnJson) {
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
// client uses updatedAt timestamp in URL to force refresh cover
|
||||
req.libraryItem.changed('updatedAt', true)
|
||||
await req.libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
res.json({
|
||||
success: true,
|
||||
cover: result.cover
|
||||
@@ -309,22 +311,28 @@ class LibraryItemController {
|
||||
/**
|
||||
* PATCH: /api/items/:id/cover
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateCover(req, res) {
|
||||
const libraryItem = req.libraryItem
|
||||
if (!req.body.cover) {
|
||||
return res.status(400).send('Invalid request no cover path')
|
||||
}
|
||||
|
||||
const validationResult = await CoverManager.validateCoverPath(req.body.cover, libraryItem)
|
||||
const validationResult = await CoverManager.validateCoverPath(req.body.cover, req.libraryItem)
|
||||
if (validationResult.error) {
|
||||
return res.status(500).send(validationResult.error)
|
||||
}
|
||||
if (validationResult.updated) {
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
req.libraryItem.media.coverPath = validationResult.cover
|
||||
req.libraryItem.media.changed('coverPath', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
// client uses updatedAt timestamp in URL to force refresh cover
|
||||
req.libraryItem.changed('updatedAt', true)
|
||||
await req.libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -335,17 +343,22 @@ class LibraryItemController {
|
||||
/**
|
||||
* DELETE: /api/items/:id/cover
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeCover(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
if (req.libraryItem.media.coverPath) {
|
||||
req.libraryItem.media.coverPath = null
|
||||
req.libraryItem.media.changed('coverPath', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
if (libraryItem.media.coverPath) {
|
||||
libraryItem.updateMediaCover('')
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
// client uses updatedAt timestamp in URL to force refresh cover
|
||||
req.libraryItem.changed('updatedAt', true)
|
||||
await req.libraryItem.save()
|
||||
|
||||
await CacheManager.purgeCoverCache(req.libraryItem.id)
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
@@ -354,7 +367,7 @@ class LibraryItemController {
|
||||
/**
|
||||
* GET: /api/items/:id/cover
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getCover(req, res) {
|
||||
@@ -396,11 +409,11 @@ class LibraryItemController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
startPlaybackSession(req, res) {
|
||||
if (!req.libraryItem.media.numTracks) {
|
||||
if (!req.libraryItem.hasAudioTracks) {
|
||||
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
@@ -413,18 +426,18 @@ class LibraryItemController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
startEpisodePlaybackSession(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
if (!libraryItem.media.numTracks) {
|
||||
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
if (!req.libraryItem.isPodcast) {
|
||||
Logger.error(`[LibraryItemController] startEpisodePlaybackSession invalid media type ${req.libraryItem.id}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
var episodeId = req.params.episodeId
|
||||
if (!libraryItem.media.episodes.find((ep) => ep.id === episodeId)) {
|
||||
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||
|
||||
const episodeId = req.params.episodeId
|
||||
if (!req.libraryItem.media.podcastEpisodes.some((ep) => ep.id === episodeId)) {
|
||||
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${req.libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
@@ -434,30 +447,55 @@ class LibraryItemController {
|
||||
/**
|
||||
* PATCH: /api/items/:id/tracks
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateTracks(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
var orderedFileData = req.body.orderedFileData
|
||||
if (!libraryItem.media.updateAudioTracks) {
|
||||
Logger.error(`[LibraryItemController] updateTracks invalid media type ${libraryItem.id}`)
|
||||
return res.sendStatus(500)
|
||||
const orderedFileData = req.body?.orderedFileData
|
||||
|
||||
if (!req.libraryItem.isBook) {
|
||||
Logger.error(`[LibraryItemController] updateTracks invalid media type ${req.libraryItem.id}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
libraryItem.media.updateAudioTracks(orderedFileData)
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
res.json(libraryItem.toJSON())
|
||||
if (!Array.isArray(orderedFileData) || !orderedFileData.length) {
|
||||
Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
// Ensure that each orderedFileData has a valid ino and is in the book audioFiles
|
||||
if (orderedFileData.some((fileData) => !fileData?.ino || !req.libraryItem.media.audioFiles.some((af) => af.ino === fileData.ino))) {
|
||||
Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
let index = 1
|
||||
const updatedAudioFiles = orderedFileData.map((fileData) => {
|
||||
const audioFile = req.libraryItem.media.audioFiles.find((af) => af.ino === fileData.ino)
|
||||
audioFile.manuallyVerified = true
|
||||
audioFile.exclude = !!fileData.exclude
|
||||
if (audioFile.exclude) {
|
||||
audioFile.index = -1
|
||||
} else {
|
||||
audioFile.index = index++
|
||||
}
|
||||
return audioFile
|
||||
})
|
||||
updatedAudioFiles.sort((a, b) => a.index - b.index)
|
||||
|
||||
req.libraryItem.media.audioFiles = updatedAudioFiles
|
||||
req.libraryItem.media.changed('audioFiles', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
res.json(req.libraryItem.toOldJSON())
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/items/:id/match
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async match(req, res) {
|
||||
const libraryItem = req.libraryItem
|
||||
const reqBody = req.body || {}
|
||||
|
||||
const options = {}
|
||||
@@ -474,7 +512,7 @@ class LibraryItemController {
|
||||
options.overrideDetails = !!reqBody.overrideDetails
|
||||
}
|
||||
|
||||
var matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
|
||||
const matchResult = await Scanner.quickMatchLibraryItem(this, req.libraryItem, options)
|
||||
res.json(matchResult)
|
||||
}
|
||||
|
||||
@@ -497,11 +535,11 @@ class LibraryItemController {
|
||||
const hardDelete = req.query.hard == 1 // Delete files from filesystem
|
||||
|
||||
const { libraryItemIds } = req.body
|
||||
if (!libraryItemIds?.length) {
|
||||
if (!libraryItemIds?.length || !Array.isArray(libraryItemIds)) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
const itemsToDelete = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
const itemsToDelete = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: libraryItemIds
|
||||
})
|
||||
|
||||
@@ -512,19 +550,19 @@ class LibraryItemController {
|
||||
const libraryId = itemsToDelete[0].libraryId
|
||||
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}"`)
|
||||
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
|
||||
const mediaItemIds = []
|
||||
const seriesIds = []
|
||||
const authorIds = []
|
||||
if (libraryItem.isPodcast) {
|
||||
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id))
|
||||
mediaItemIds.push(...libraryItem.media.podcastEpisodes.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.series?.length) {
|
||||
seriesIds.push(...libraryItem.media.series.map((se) => se.id))
|
||||
}
|
||||
if (libraryItem.media.metadata.authors?.length) {
|
||||
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id))
|
||||
if (libraryItem.media.authors?.length) {
|
||||
authorIds.push(...libraryItem.media.authors.map((au) => au.id))
|
||||
}
|
||||
}
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||
@@ -569,7 +607,7 @@ class LibraryItemController {
|
||||
}
|
||||
|
||||
// Get all library items to update
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: libraryItemIds
|
||||
})
|
||||
if (updatePayloads.length !== libraryItems.length) {
|
||||
@@ -586,26 +624,46 @@ class LibraryItemController {
|
||||
const mediaPayload = updatePayload.mediaPayload
|
||||
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
|
||||
|
||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
||||
let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
|
||||
|
||||
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 (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
|
||||
const seriesUpdateData = await libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, libraryItem.libraryId)
|
||||
if (seriesUpdateData?.seriesRemoved.length) {
|
||||
seriesIdsRemoved.push(...seriesUpdateData.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 (seriesUpdateData?.seriesAdded.length) {
|
||||
seriesUpdateData.seriesAdded.forEach((se) => {
|
||||
Database.addSeriesToFilterData(libraryItem.libraryId, se.name, se.id)
|
||||
})
|
||||
}
|
||||
if (seriesUpdateData?.hasUpdates) {
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItem.media.update(mediaPayload)) {
|
||||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {
|
||||
const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)
|
||||
const authorUpdateData = await libraryItem.media.updateAuthorsFromRequest(authorNames, libraryItem.libraryId)
|
||||
if (authorUpdateData?.authorsRemoved.length) {
|
||||
authorIdsRemoved.push(...authorUpdateData.authorsRemoved.map((au) => au.id))
|
||||
hasUpdates = true
|
||||
}
|
||||
if (authorUpdateData?.authorsAdded.length) {
|
||||
authorUpdateData.authorsAdded.forEach((au) => {
|
||||
Database.addAuthorToFilterData(libraryItem.libraryId, au.name, au.id)
|
||||
})
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
if (hasUpdates) {
|
||||
libraryItem.changed('updatedAt', true)
|
||||
await libraryItem.save()
|
||||
|
||||
await libraryItem.saveMetadataFile()
|
||||
|
||||
Logger.debug(`[LibraryItemController] Updated library item media "${libraryItem.media.title}"`)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
itemsUpdated++
|
||||
}
|
||||
}
|
||||
@@ -634,11 +692,11 @@ class LibraryItemController {
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(403).send('Invalid payload')
|
||||
}
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: libraryItemIds
|
||||
})
|
||||
res.json({
|
||||
libraryItems: libraryItems.map((li) => li.toJSONExpanded())
|
||||
libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -661,7 +719,7 @@ class LibraryItemController {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: req.body.libraryItemIds
|
||||
})
|
||||
if (!libraryItems?.length) {
|
||||
@@ -742,7 +800,7 @@ class LibraryItemController {
|
||||
/**
|
||||
* POST: /api/items/:id/scan
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async scan(req, res) {
|
||||
@@ -766,7 +824,7 @@ class LibraryItemController {
|
||||
/**
|
||||
* GET: /api/items/:id/metadata-object
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
getMetadataObject(req, res) {
|
||||
@@ -775,7 +833,7 @@ class LibraryItemController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.includedAudioFiles.length) {
|
||||
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
@@ -786,7 +844,7 @@ class LibraryItemController {
|
||||
/**
|
||||
* POST: /api/items/:id/chapters
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateMediaChapters(req, res) {
|
||||
@@ -795,26 +853,53 @@ class LibraryItemController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.hasAudioTracks) {
|
||||
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
if (!req.body.chapters) {
|
||||
if (!Array.isArray(req.body.chapters) || req.body.chapters.some((c) => !c.title || typeof c.title !== 'string' || c.start === undefined || typeof c.start !== 'number' || c.end === undefined || typeof c.end !== 'number')) {
|
||||
Logger.error(`[LibraryItemController] Invalid payload`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const chapters = req.body.chapters || []
|
||||
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
|
||||
if (wasUpdated) {
|
||||
await Database.updateLibraryItem(req.libraryItem)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
|
||||
let hasUpdates = false
|
||||
if (chapters.length !== req.libraryItem.media.chapters.length) {
|
||||
req.libraryItem.media.chapters = chapters.map((c, index) => {
|
||||
return {
|
||||
id: index,
|
||||
title: c.title,
|
||||
start: c.start,
|
||||
end: c.end
|
||||
}
|
||||
})
|
||||
hasUpdates = true
|
||||
} else {
|
||||
for (const [index, chapter] of chapters.entries()) {
|
||||
const currentChapter = req.libraryItem.media.chapters[index]
|
||||
if (currentChapter.title !== chapter.title || currentChapter.start !== chapter.start || currentChapter.end !== chapter.end) {
|
||||
currentChapter.title = chapter.title
|
||||
currentChapter.start = chapter.start
|
||||
currentChapter.end = chapter.end
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
req.libraryItem.media.changed('chapters', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
await req.libraryItem.saveMetadataFile()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
updated: wasUpdated
|
||||
updated: hasUpdates
|
||||
})
|
||||
}
|
||||
|
||||
@@ -822,7 +907,7 @@ class LibraryItemController {
|
||||
* GET: /api/items/:id/ffprobe/:fileid
|
||||
* FFProbe JSON result from audio file
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getFFprobeData(req, res) {
|
||||
@@ -830,25 +915,21 @@ class LibraryItemController {
|
||||
Logger.error(`[LibraryItemController] Non-admin user "${req.user.username}" attempted to get ffprobe data`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (req.libraryFile.fileType !== 'audio') {
|
||||
Logger.error(`[LibraryItemController] Invalid filetype "${req.libraryFile.fileType}" for fileid "${req.params.fileid}". Expected audio file`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const audioFile = req.libraryItem.media.findFileWithInode(req.params.fileid)
|
||||
const audioFile = req.libraryItem.getAudioFileWithIno(req.params.fileid)
|
||||
if (!audioFile) {
|
||||
Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile)
|
||||
const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile.metadata.path)
|
||||
res.json(ffprobeData)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/items/:id/file/:fileid
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequestWithFile} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getLibraryFile(req, res) {
|
||||
@@ -871,7 +952,7 @@ class LibraryItemController {
|
||||
/**
|
||||
* DELETE api/items/:id/file/:fileid
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequestWithFile} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async deleteLibraryFile(req, res) {
|
||||
@@ -882,17 +963,49 @@ class LibraryItemController {
|
||||
await fs.remove(libraryFile.metadata.path).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error)
|
||||
})
|
||||
req.libraryItem.removeLibraryFile(req.params.fileid)
|
||||
|
||||
if (req.libraryItem.media.removeFileWithInode(req.params.fileid)) {
|
||||
// If book has no more media files then mark it as missing
|
||||
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
|
||||
req.libraryItem.setMissing()
|
||||
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((lf) => lf.ino !== req.params.fileid)
|
||||
req.libraryItem.changed('libraryFiles', true)
|
||||
|
||||
if (req.libraryItem.isBook) {
|
||||
if (req.libraryItem.media.audioFiles.some((af) => af.ino === req.params.fileid)) {
|
||||
req.libraryItem.media.audioFiles = req.libraryItem.media.audioFiles.filter((af) => af.ino !== req.params.fileid)
|
||||
req.libraryItem.media.changed('audioFiles', true)
|
||||
} else if (req.libraryItem.media.ebookFile?.ino === req.params.fileid) {
|
||||
req.libraryItem.media.ebookFile = null
|
||||
req.libraryItem.media.changed('ebookFile', true)
|
||||
}
|
||||
if (!req.libraryItem.media.hasMediaFiles) {
|
||||
req.libraryItem.isMissing = true
|
||||
}
|
||||
} else if (req.libraryItem.media.podcastEpisodes.some((ep) => ep.audioFile.ino === req.params.fileid)) {
|
||||
const episodeToRemove = req.libraryItem.media.podcastEpisodes.find((ep) => ep.audioFile.ino === req.params.fileid)
|
||||
// Remove episode from all playlists
|
||||
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id])
|
||||
|
||||
// Remove episode media progress
|
||||
const numProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||
where: {
|
||||
mediaItemId: episodeToRemove.id
|
||||
}
|
||||
})
|
||||
if (numProgressRemoved > 0) {
|
||||
Logger.info(`[LibraryItemController] Removed media progress for episode ${episodeToRemove.id}`)
|
||||
}
|
||||
|
||||
// Remove episode
|
||||
await episodeToRemove.destroy()
|
||||
|
||||
req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.audioFile.ino !== req.params.fileid)
|
||||
}
|
||||
req.libraryItem.updatedAt = Date.now()
|
||||
await Database.updateLibraryItem(req.libraryItem)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
|
||||
if (req.libraryItem.media.changed()) {
|
||||
await req.libraryItem.media.save()
|
||||
}
|
||||
|
||||
await req.libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@@ -900,7 +1013,7 @@ class LibraryItemController {
|
||||
* GET api/items/:id/file/:fileid/download
|
||||
* Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequestWithFile} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async downloadLibraryFile(req, res) {
|
||||
@@ -912,7 +1025,7 @@ class LibraryItemController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" file at "${libraryFile.metadata.path}"`)
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.title}" file at "${libraryFile.metadata.path}"`)
|
||||
|
||||
if (global.XAccel) {
|
||||
const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path)
|
||||
@@ -948,13 +1061,13 @@ class LibraryItemController {
|
||||
* fileid is only required when reading a supplementary ebook
|
||||
* when no fileid is passed in the primary ebook will be returned
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getEBookFile(req, res) {
|
||||
let ebookFile = null
|
||||
if (req.params.fileid) {
|
||||
ebookFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
|
||||
ebookFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid)
|
||||
if (!ebookFile?.isEBookFile) {
|
||||
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
|
||||
return res.status(400).send('Invalid ebook file id')
|
||||
@@ -964,12 +1077,12 @@ class LibraryItemController {
|
||||
}
|
||||
|
||||
if (!ebookFile) {
|
||||
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.metadata.title}"`)
|
||||
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.title}"`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
const ebookFilePath = ebookFile.metadata.path
|
||||
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" ebook at "${ebookFilePath}"`)
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.title}" ebook at "${ebookFilePath}"`)
|
||||
|
||||
if (global.XAccel) {
|
||||
const encodedURI = encodeUriPath(global.XAccel + ebookFilePath)
|
||||
@@ -992,28 +1105,55 @@ class LibraryItemController {
|
||||
* if an ebook file is the primary ebook, then it will be changed to supplementary
|
||||
* if an ebook file is supplementary, then it will be changed to primary
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequestWithFile} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateEbookFileStatus(req, res) {
|
||||
const ebookLibraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
|
||||
if (!ebookLibraryFile?.isEBookFile) {
|
||||
if (!req.libraryItem.isBook) {
|
||||
Logger.error(`[LibraryItemController] Invalid media type for ebook file status update`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (!req.libraryFile?.isEBookFile) {
|
||||
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
|
||||
return res.status(400).send('Invalid ebook file id')
|
||||
}
|
||||
|
||||
const ebookLibraryFile = req.libraryFile
|
||||
let primaryEbookFile = null
|
||||
|
||||
const ebookLibraryFileInos = req.libraryItem
|
||||
.getLibraryFiles()
|
||||
.filter((lf) => lf.isEBookFile)
|
||||
.map((lf) => lf.ino)
|
||||
|
||||
if (ebookLibraryFile.isSupplementary) {
|
||||
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`)
|
||||
req.libraryItem.setPrimaryEbook(ebookLibraryFile)
|
||||
|
||||
primaryEbookFile = ebookLibraryFile.toJSON()
|
||||
delete primaryEbookFile.isSupplementary
|
||||
delete primaryEbookFile.fileType
|
||||
primaryEbookFile.ebookFormat = ebookLibraryFile.metadata.format
|
||||
} else {
|
||||
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`)
|
||||
ebookLibraryFile.isSupplementary = true
|
||||
req.libraryItem.setPrimaryEbook(null)
|
||||
}
|
||||
|
||||
req.libraryItem.updatedAt = Date.now()
|
||||
await Database.updateLibraryItem(req.libraryItem)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
req.libraryItem.media.ebookFile = primaryEbookFile
|
||||
req.libraryItem.media.changed('ebookFile', true)
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.map((lf) => {
|
||||
if (ebookLibraryFileInos.includes(lf.ino)) {
|
||||
lf.isSupplementary = lf.ino !== primaryEbookFile?.ino
|
||||
}
|
||||
return lf
|
||||
})
|
||||
req.libraryItem.changed('libraryFiles', true)
|
||||
|
||||
req.libraryItem.isMissing = !req.libraryItem.media.hasMediaFiles
|
||||
|
||||
await req.libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@@ -1024,7 +1164,7 @@ class LibraryItemController {
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async middleware(req, res, next) {
|
||||
req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
|
||||
req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
|
||||
if (!req.libraryItem?.media) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
@@ -1034,7 +1174,7 @@ class LibraryItemController {
|
||||
|
||||
// For library file routes, get the library file
|
||||
if (req.params.fileid) {
|
||||
req.libraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
|
||||
req.libraryFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid)
|
||||
if (!req.libraryFile) {
|
||||
Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`)
|
||||
return res.sendStatus(404)
|
||||
|
||||
@@ -66,7 +66,7 @@ class MeController {
|
||||
const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId)
|
||||
const episode = await Database.podcastEpisodeModel.findByPk(req.params.episodeId)
|
||||
|
||||
if (!libraryItem || (libraryItem.mediaType === 'podcast' && !episode)) {
|
||||
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
|
||||
Logger.error(`[MeController] Media item not found for library item id "${req.params.libraryItemId}"`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
@@ -296,7 +296,7 @@ class MeController {
|
||||
const mediaProgressesInProgress = req.user.mediaProgresses.filter((mp) => !mp.isFinished && (mp.currentTime > 0 || mp.ebookProgress > 0))
|
||||
|
||||
const libraryItemsIds = [...new Set(mediaProgressesInProgress.map((mp) => mp.extraData?.libraryItemId).filter((id) => id))]
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: libraryItemsIds })
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: libraryItemsIds })
|
||||
|
||||
let itemsInProgress = []
|
||||
|
||||
@@ -304,19 +304,19 @@ class MeController {
|
||||
const oldMediaProgress = mediaProgress.getOldMediaProgress()
|
||||
const libraryItem = libraryItems.find((li) => li.id === oldMediaProgress.libraryItemId)
|
||||
if (libraryItem) {
|
||||
if (oldMediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
|
||||
const episode = libraryItem.media.episodes.find((ep) => ep.id === oldMediaProgress.episodeId)
|
||||
if (oldMediaProgress.episodeId && libraryItem.isPodcast) {
|
||||
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === oldMediaProgress.episodeId)
|
||||
if (episode) {
|
||||
const libraryItemWithEpisode = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
recentEpisode: episode.toJSON(),
|
||||
...libraryItem.toOldJSONMinified(),
|
||||
recentEpisode: episode.toOldJSON(libraryItem.id),
|
||||
progressLastUpdate: oldMediaProgress.lastUpdate
|
||||
}
|
||||
itemsInProgress.push(libraryItemWithEpisode)
|
||||
}
|
||||
} else if (!oldMediaProgress.episodeId) {
|
||||
itemsInProgress.push({
|
||||
...libraryItem.toJSONMinified(),
|
||||
...libraryItem.toOldJSONMinified(),
|
||||
progressLastUpdate: oldMediaProgress.lastUpdate
|
||||
})
|
||||
}
|
||||
|
||||
@@ -342,8 +342,8 @@ class MiscController {
|
||||
tags: libraryItem.media.tags
|
||||
})
|
||||
await libraryItem.saveMetadataFile()
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
}
|
||||
@@ -385,8 +385,8 @@ class MiscController {
|
||||
tags: libraryItem.media.tags
|
||||
})
|
||||
await libraryItem.saveMetadataFile()
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
|
||||
@@ -480,8 +480,8 @@ class MiscController {
|
||||
genres: libraryItem.media.genres
|
||||
})
|
||||
await libraryItem.saveMetadataFile()
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
}
|
||||
@@ -523,8 +523,8 @@ class MiscController {
|
||||
genres: libraryItem.media.genres
|
||||
})
|
||||
await libraryItem.saveMetadataFile()
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,16 @@ const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const Playlist = require('../objects/Playlist')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*
|
||||
* @typedef RequestEntityObject
|
||||
* @property {import('../models/Playlist')} playlist
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject} PlaylistControllerRequest
|
||||
*/
|
||||
|
||||
class PlaylistController {
|
||||
@@ -23,48 +26,103 @@ class PlaylistController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async create(req, res) {
|
||||
const oldPlaylist = new Playlist()
|
||||
req.body.userId = req.user.id
|
||||
const success = oldPlaylist.setData(req.body)
|
||||
if (!success) {
|
||||
return res.status(400).send('Invalid playlist request data')
|
||||
const reqBody = req.body || {}
|
||||
|
||||
// Validation
|
||||
if (!reqBody.name || !reqBody.libraryId) {
|
||||
return res.status(400).send('Invalid playlist data')
|
||||
}
|
||||
if (reqBody.description && typeof reqBody.description !== 'string') {
|
||||
return res.status(400).send('Invalid playlist description')
|
||||
}
|
||||
const items = reqBody.items || []
|
||||
const isPodcast = items.some((i) => i.episodeId)
|
||||
const libraryItemIds = new Set()
|
||||
for (const item of items) {
|
||||
if (!item.libraryItemId || typeof item.libraryItemId !== 'string') {
|
||||
return res.status(400).send('Invalid playlist item')
|
||||
}
|
||||
if (isPodcast && (!item.episodeId || typeof item.episodeId !== 'string')) {
|
||||
return res.status(400).send('Invalid playlist item episodeId')
|
||||
} else if (!isPodcast && item.episodeId) {
|
||||
return res.status(400).send('Invalid playlist item episodeId')
|
||||
}
|
||||
libraryItemIds.add(item.libraryItemId)
|
||||
}
|
||||
|
||||
// Create Playlist record
|
||||
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
|
||||
|
||||
// Lookup all library items in playlist
|
||||
const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId).filter((i) => i)
|
||||
const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
|
||||
// Load library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
id: Array.from(libraryItemIds),
|
||||
libraryId: reqBody.libraryId,
|
||||
mediaType: isPodcast ? 'podcast' : 'book'
|
||||
}
|
||||
})
|
||||
if (libraryItems.length !== libraryItemIds.size) {
|
||||
return res.status(400).send('Invalid playlist data. Invalid items')
|
||||
}
|
||||
|
||||
// Create playlistMediaItem records
|
||||
const mediaItemsToAdd = []
|
||||
let order = 1
|
||||
for (const mediaItemObj of oldPlaylist.items) {
|
||||
const libraryItem = libraryItemsInPlaylist.find((li) => li.id === mediaItemObj.libraryItemId)
|
||||
if (!libraryItem) continue
|
||||
|
||||
mediaItemsToAdd.push({
|
||||
mediaItemId: mediaItemObj.episodeId || libraryItem.mediaId,
|
||||
mediaItemType: mediaItemObj.episodeId ? 'podcastEpisode' : 'book',
|
||||
playlistId: oldPlaylist.id,
|
||||
order: order++
|
||||
// Validate podcast episodes
|
||||
if (isPodcast) {
|
||||
const podcastEpisodeIds = items.map((i) => i.episodeId)
|
||||
const podcastEpisodes = await Database.podcastEpisodeModel.findAll({
|
||||
attributes: ['id'],
|
||||
where: {
|
||||
id: podcastEpisodeIds
|
||||
}
|
||||
})
|
||||
}
|
||||
if (mediaItemsToAdd.length) {
|
||||
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||
if (podcastEpisodes.length !== podcastEpisodeIds.length) {
|
||||
return res.status(400).send('Invalid playlist data. Invalid podcast episodes')
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
// Create playlist
|
||||
const newPlaylist = await Database.playlistModel.create(
|
||||
{
|
||||
libraryId: reqBody.libraryId,
|
||||
userId: req.user.id,
|
||||
name: reqBody.name,
|
||||
description: reqBody.description || null
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
// Create playlistMediaItems
|
||||
const playlistItemPayloads = []
|
||||
for (const [index, item] of items.entries()) {
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
playlistItemPayloads.push({
|
||||
playlistId: newPlaylist.id,
|
||||
mediaItemId: item.episodeId || libraryItem.mediaId,
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: index + 1
|
||||
})
|
||||
}
|
||||
|
||||
await Database.playlistMediaItemModel.bulkCreate(playlistItemPayloads, { transaction })
|
||||
|
||||
await transaction.commit()
|
||||
|
||||
newPlaylist.playlistMediaItems = await newPlaylist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const jsonExpanded = newPlaylist.toOldJSONExpanded()
|
||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
Logger.error('[PlaylistController] create:', error)
|
||||
res.status(500).send('Failed to create playlist')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - Use /api/libraries/:libraryId/playlists
|
||||
* This is not used by Abs web client or mobile apps
|
||||
* TODO: Remove this endpoint or make it the primary
|
||||
*
|
||||
* GET: /api/playlists
|
||||
* Get all playlists for user
|
||||
*
|
||||
@@ -72,68 +130,89 @@ class PlaylistController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findAllForUser(req, res) {
|
||||
const playlistsForUser = await Database.playlistModel.findAll({
|
||||
where: {
|
||||
userId: req.user.id
|
||||
}
|
||||
})
|
||||
const playlists = []
|
||||
for (const playlist of playlistsForUser) {
|
||||
const jsonExpanded = await playlist.getOldJsonExpanded()
|
||||
playlists.push(jsonExpanded)
|
||||
}
|
||||
const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)
|
||||
res.json({
|
||||
playlists
|
||||
playlists: playlistsForUser
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/playlists/:id
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
res.json(jsonExpanded)
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
res.json(req.playlist.toOldJSONExpanded())
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH: /api/playlists/:id
|
||||
* Update playlist
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* Used for updating name and description or reordering items
|
||||
*
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
const updatedPlaylist = req.playlist.set(req.body)
|
||||
let wasUpdated = false
|
||||
const changed = updatedPlaylist.changed()
|
||||
if (changed?.length) {
|
||||
await req.playlist.save()
|
||||
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
|
||||
wasUpdated = true
|
||||
// Validation
|
||||
const reqBody = req.body || {}
|
||||
if (reqBody.libraryId || reqBody.userId) {
|
||||
// Could allow support for this if needed with additional validation
|
||||
return res.status(400).send('Invalid playlist data. Cannot update libraryId or userId')
|
||||
}
|
||||
if (reqBody.name && typeof reqBody.name !== 'string') {
|
||||
return res.status(400).send('Invalid playlist name')
|
||||
}
|
||||
if (reqBody.description && typeof reqBody.description !== 'string') {
|
||||
return res.status(400).send('Invalid playlist description')
|
||||
}
|
||||
if (reqBody.items && (!Array.isArray(reqBody.items) || reqBody.items.some((i) => !i.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string')))) {
|
||||
return res.status(400).send('Invalid playlist items')
|
||||
}
|
||||
|
||||
// If array of items is passed in then update order of playlist media items
|
||||
const libraryItemIds = req.body.items?.map((i) => i.libraryItemId).filter((i) => i) || []
|
||||
if (libraryItemIds.length) {
|
||||
const playlistUpdatePayload = {}
|
||||
if (reqBody.name) playlistUpdatePayload.name = reqBody.name
|
||||
if (reqBody.description) playlistUpdatePayload.description = reqBody.description
|
||||
|
||||
// Update name and description
|
||||
let wasUpdated = false
|
||||
if (Object.keys(playlistUpdatePayload).length) {
|
||||
req.playlist.set(playlistUpdatePayload)
|
||||
const changed = req.playlist.changed()
|
||||
if (changed?.length) {
|
||||
await req.playlist.save()
|
||||
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
// If array of items is set then update order of playlist media items
|
||||
if (reqBody.items?.length) {
|
||||
const libraryItemIds = Array.from(new Set(reqBody.items.map((i) => i.libraryItemId)))
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType'],
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
}
|
||||
})
|
||||
const existingPlaylistMediaItems = await updatedPlaylist.getPlaylistMediaItems({
|
||||
if (libraryItems.length !== libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid playlist items. Items not found')
|
||||
}
|
||||
/** @type {import('../models/PlaylistMediaItem')[]} */
|
||||
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
if (existingPlaylistMediaItems.length !== reqBody.items.length) {
|
||||
return res.status(400).send('Invalid playlist items. Length mismatch')
|
||||
}
|
||||
|
||||
// Set an array of mediaItemId
|
||||
const newMediaItemIdOrder = []
|
||||
for (const item of req.body.items) {
|
||||
for (const item of reqBody.items) {
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
continue
|
||||
}
|
||||
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||
newMediaItemIdOrder.push(mediaItemId)
|
||||
}
|
||||
@@ -146,21 +225,21 @@ class PlaylistController {
|
||||
})
|
||||
|
||||
// Update order on playlistMediaItem records
|
||||
let order = 1
|
||||
for (const playlistMediaItem of existingPlaylistMediaItems) {
|
||||
if (playlistMediaItem.order !== order) {
|
||||
for (const [index, playlistMediaItem] of existingPlaylistMediaItems.entries()) {
|
||||
if (playlistMediaItem.order !== index + 1) {
|
||||
await playlistMediaItem.update({
|
||||
order
|
||||
order: index + 1
|
||||
})
|
||||
wasUpdated = true
|
||||
}
|
||||
order++
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = await updatedPlaylist.getOldJsonExpanded()
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
if (wasUpdated) {
|
||||
SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded)
|
||||
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
@@ -169,11 +248,13 @@ class PlaylistController {
|
||||
* DELETE: /api/playlists/:id
|
||||
* Remove playlist
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||
res.sendStatus(200)
|
||||
@@ -183,43 +264,64 @@ class PlaylistController {
|
||||
* POST: /api/playlists/:id/item
|
||||
* Add item to playlist
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* This is not used by Abs web client or mobile apps. Only the batch endpoints are used.
|
||||
*
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async addItem(req, res) {
|
||||
const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
|
||||
const itemToAdd = req.body
|
||||
const itemToAdd = req.body || {}
|
||||
|
||||
if (!itemToAdd.libraryItemId) {
|
||||
return res.status(400).send('Request body has no libraryItemId')
|
||||
}
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(itemToAdd.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.status(400).send('Library item not found')
|
||||
}
|
||||
if (libraryItem.libraryId !== oldPlaylist.libraryId) {
|
||||
if (libraryItem.libraryId !== req.playlist.libraryId) {
|
||||
return res.status(400).send('Library item in different library')
|
||||
}
|
||||
if (oldPlaylist.containsItem(itemToAdd)) {
|
||||
return res.status(400).send('Item already in playlist')
|
||||
}
|
||||
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
|
||||
return res.status(400).send('Invalid item to add for this library type')
|
||||
}
|
||||
if (itemToAdd.episodeId && !libraryItem.media.checkHasEpisode(itemToAdd.episodeId)) {
|
||||
if (itemToAdd.episodeId && !libraryItem.media.podcastEpisodes.some((pe) => pe.id === itemToAdd.episodeId)) {
|
||||
return res.status(400).send('Episode not found in library item')
|
||||
}
|
||||
|
||||
const playlistMediaItem = {
|
||||
playlistId: oldPlaylist.id,
|
||||
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
|
||||
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: oldPlaylist.items.length + 1
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
if (req.playlist.checkHasMediaItem(itemToAdd.libraryItemId, itemToAdd.episodeId)) {
|
||||
return res.status(400).send('Item already in playlist')
|
||||
}
|
||||
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
const playlistMediaItem = {
|
||||
playlistId: req.playlist.id,
|
||||
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
|
||||
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: req.playlist.playlistMediaItems.length + 1
|
||||
}
|
||||
await Database.playlistMediaItemModel.create(playlistMediaItem)
|
||||
|
||||
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
|
||||
if (itemToAdd.episodeId) {
|
||||
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === itemToAdd.episodeId)
|
||||
jsonExpanded.items.push({
|
||||
episodeId: itemToAdd.episodeId,
|
||||
episode: episode.toOldJSONExpanded(libraryItem.id),
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONMinified()
|
||||
})
|
||||
} else {
|
||||
jsonExpanded.items.push({
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONExpanded()
|
||||
})
|
||||
}
|
||||
|
||||
await Database.createPlaylistMediaItem(playlistMediaItem)
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
@@ -228,43 +330,36 @@ class PlaylistController {
|
||||
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
|
||||
* Remove item from playlist
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeItem(req, res) {
|
||||
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
|
||||
if (!oldLibraryItem) {
|
||||
return res.status(404).send('Library item not found')
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
let playlistMediaItem = null
|
||||
if (req.params.episodeId) {
|
||||
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === req.params.episodeId)
|
||||
} else {
|
||||
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === req.params.libraryItemId)
|
||||
}
|
||||
|
||||
// Get playlist media items
|
||||
const mediaItemId = req.params.episodeId || oldLibraryItem.media.id
|
||||
const playlistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
|
||||
// Check if media item to delete is in playlist
|
||||
const mediaItemToRemove = playlistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
|
||||
if (!mediaItemToRemove) {
|
||||
if (!playlistMediaItem) {
|
||||
return res.status(404).send('Media item not found in playlist')
|
||||
}
|
||||
|
||||
// Remove record
|
||||
await mediaItemToRemove.destroy()
|
||||
await playlistMediaItem.destroy()
|
||||
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
|
||||
|
||||
// Update playlist media items order
|
||||
let order = 1
|
||||
for (const mediaItem of playlistMediaItems) {
|
||||
if (mediaItem.mediaItemId === mediaItemId) continue
|
||||
if (mediaItem.order !== order) {
|
||||
for (const [index, mediaItem] of req.playlist.playlistMediaItems.entries()) {
|
||||
if (mediaItem.order !== index + 1) {
|
||||
await mediaItem.update({
|
||||
order
|
||||
order: index + 1
|
||||
})
|
||||
}
|
||||
order++
|
||||
}
|
||||
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
// Playlist is removed when there are no items
|
||||
if (!jsonExpanded.items.length) {
|
||||
@@ -282,64 +377,68 @@ class PlaylistController {
|
||||
* POST: /api/playlists/:id/batch/add
|
||||
* Batch add playlist items
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async addBatch(req, res) {
|
||||
if (!req.body.items?.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
const itemsToAdd = req.body.items
|
||||
|
||||
const libraryItemIds = itemsToAdd.map((i) => i.libraryItemId).filter((i) => i)
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
}
|
||||
|
||||
// Find all library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
}
|
||||
})
|
||||
const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))
|
||||
|
||||
// Get all existing playlist media items
|
||||
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) })
|
||||
if (libraryItems.length !== libraryItemIds.size) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
}
|
||||
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const mediaItemsToAdd = []
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
// Setup array of playlistMediaItem records to add
|
||||
let order = existingPlaylistMediaItems.length + 1
|
||||
for (const item of itemsToAdd) {
|
||||
let order = req.playlist.playlistMediaItems.length + 1
|
||||
for (const item of req.body.items) {
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Item not found with id ' + item.libraryItemId)
|
||||
|
||||
const mediaItemId = item.episodeId || libraryItem.media.id
|
||||
if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
|
||||
// Already exists in playlist
|
||||
continue
|
||||
} else {
|
||||
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||
if (existingPlaylistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
|
||||
// Already exists in playlist
|
||||
continue
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: req.playlist.id,
|
||||
mediaItemId,
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: order++
|
||||
})
|
||||
|
||||
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
|
||||
if (item.episodeId) {
|
||||
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === item.episodeId)
|
||||
jsonExpanded.items.push({
|
||||
episodeId: item.episodeId,
|
||||
episode: episode.toOldJSONExpanded(libraryItem.id),
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONMinified()
|
||||
})
|
||||
} else {
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: req.playlist.id,
|
||||
mediaItemId,
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: order++
|
||||
jsonExpanded.items.push({
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONExpanded()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let jsonExpanded = null
|
||||
if (mediaItemsToAdd.length) {
|
||||
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||
jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd)
|
||||
|
||||
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
} else {
|
||||
jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
}
|
||||
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
@@ -347,50 +446,40 @@ class PlaylistController {
|
||||
* POST: /api/playlists/:id/batch/remove
|
||||
* Batch remove playlist items
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeBatch(req, res) {
|
||||
if (!req.body.items?.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
}
|
||||
|
||||
const itemsToRemove = req.body.items
|
||||
const libraryItemIds = itemsToRemove.map((i) => i.libraryItemId).filter((i) => i)
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Find all library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
}
|
||||
})
|
||||
|
||||
// Get all existing playlist media items for playlist
|
||||
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
let numMediaItems = existingPlaylistMediaItems.length
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
// Remove playlist media items
|
||||
let hasUpdated = false
|
||||
for (const item of itemsToRemove) {
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
if (!libraryItem) continue
|
||||
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||
const existingMediaItem = existingPlaylistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
|
||||
if (!existingMediaItem) continue
|
||||
await existingMediaItem.destroy()
|
||||
for (const item of req.body.items) {
|
||||
let playlistMediaItem = null
|
||||
if (item.episodeId) {
|
||||
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === item.episodeId)
|
||||
} else {
|
||||
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === item.libraryItemId)
|
||||
}
|
||||
if (!playlistMediaItem) {
|
||||
Logger.warn(`[PlaylistController] Playlist item not found in playlist ${req.playlist.id}`, item)
|
||||
continue
|
||||
}
|
||||
|
||||
await playlistMediaItem.destroy()
|
||||
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
|
||||
|
||||
hasUpdated = true
|
||||
numMediaItems--
|
||||
}
|
||||
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
if (hasUpdated) {
|
||||
// Playlist is removed when there are no items
|
||||
if (!numMediaItems) {
|
||||
if (!req.playlist.playlistMediaItems.length) {
|
||||
Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||
@@ -425,33 +514,41 @@ class PlaylistController {
|
||||
return res.status(400).send('Collection has no books')
|
||||
}
|
||||
|
||||
const oldPlaylist = new Playlist()
|
||||
oldPlaylist.setData({
|
||||
userId: req.user.id,
|
||||
libraryId: collection.libraryId,
|
||||
name: collection.name,
|
||||
description: collection.description || null
|
||||
})
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
const playlist = await Database.playlistModel.create(
|
||||
{
|
||||
userId: req.user.id,
|
||||
libraryId: collection.libraryId,
|
||||
name: collection.name,
|
||||
description: collection.description || null
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
// Create Playlist record
|
||||
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
|
||||
const mediaItemsToAdd = []
|
||||
for (const [index, libraryItem] of collectionExpanded.books.entries()) {
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: playlist.id,
|
||||
mediaItemId: libraryItem.media.id,
|
||||
mediaItemType: 'book',
|
||||
order: index + 1
|
||||
})
|
||||
}
|
||||
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd, { transaction })
|
||||
|
||||
// Create PlaylistMediaItem records
|
||||
const mediaItemsToAdd = []
|
||||
let order = 1
|
||||
for (const libraryItem of collectionExpanded.books) {
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: newPlaylist.id,
|
||||
mediaItemId: libraryItem.media.id,
|
||||
mediaItemType: 'book',
|
||||
order: order++
|
||||
})
|
||||
await transaction.commit()
|
||||
|
||||
playlist.playlistMediaItems = await playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const jsonExpanded = playlist.toOldJSONExpanded()
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
Logger.error('[PlaylistController] createFromCollection:', error)
|
||||
res.status(500).send('Failed to create playlist')
|
||||
}
|
||||
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||
|
||||
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
const { Request, Response, NextFunction } = require('express')
|
||||
const PluginManager = require('../managers/PluginManager')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
class PluginController {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
getConfig(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
res.json({
|
||||
config: req.pluginData.instance.config
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/plugins/:id/action
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async handleAction(req, res) {
|
||||
const actionName = req.body.pluginAction
|
||||
const target = req.body.target
|
||||
const data = req.body.data
|
||||
Logger.info(`[PluginController] Handle plugin "${req.pluginData.manifest.name}" action ${actionName} ${target}`, data)
|
||||
const actionData = await PluginManager.onAction(req.pluginData, actionName, target, data)
|
||||
if (!actionData || actionData.error) {
|
||||
return res.status(400).send(actionData?.error || 'Error performing action')
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/plugins/:id/config
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async handleConfigSave(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (!req.body.config || typeof req.body.config !== 'object') {
|
||||
return res.status(400).send('Invalid config')
|
||||
}
|
||||
|
||||
const config = req.body.config
|
||||
Logger.info(`[PluginController] Handle save config for plugin ${req.pluginData.manifest.name}`, config)
|
||||
const saveData = await PluginManager.onConfigSave(req.pluginData, config)
|
||||
if (!saveData || saveData.error) {
|
||||
return res.status(400).send(saveData?.error || 'Error saving config')
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async middleware(req, res, next) {
|
||||
if (req.params.id) {
|
||||
const pluginData = PluginManager.getPluginDataById(req.params.id)
|
||||
if (!pluginData) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
await pluginData.instance.reload()
|
||||
req.pluginData = pluginData
|
||||
}
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new PluginController()
|
||||
@@ -1,3 +1,4 @@
|
||||
const Path = require('path')
|
||||
const { Request, Response, NextFunction } = require('express')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
@@ -11,15 +12,17 @@ const { validateUrl } = require('../utils/index')
|
||||
|
||||
const Scanner = require('../scanner/Scanner')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
const PodcastManager = require('../managers/PodcastManager')
|
||||
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*
|
||||
* @typedef RequestEntityObject
|
||||
* @property {import('../models/LibraryItem')} libraryItem
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem
|
||||
*/
|
||||
|
||||
class PodcastController {
|
||||
@@ -38,6 +41,9 @@ class PodcastController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const payload = req.body
|
||||
if (!payload.media || !payload.media.metadata) {
|
||||
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required')
|
||||
}
|
||||
|
||||
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
|
||||
if (!library) {
|
||||
@@ -79,48 +85,87 @@ class PodcastController {
|
||||
let relPath = payload.path.replace(folder.fullPath, '')
|
||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||
|
||||
const libraryItemPayload = {
|
||||
path: podcastPath,
|
||||
relPath,
|
||||
folderId: payload.folderId,
|
||||
libraryId: payload.libraryId,
|
||||
ino: libraryItemFolderStats.ino,
|
||||
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
||||
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
||||
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
||||
media: payload.media
|
||||
let newLibraryItem = null
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
const podcast = await Database.podcastModel.createFromRequest(payload.media, transaction)
|
||||
|
||||
newLibraryItem = await Database.libraryItemModel.create(
|
||||
{
|
||||
ino: libraryItemFolderStats.ino,
|
||||
path: podcastPath,
|
||||
relPath,
|
||||
mediaId: podcast.id,
|
||||
mediaType: 'podcast',
|
||||
isFile: false,
|
||||
isMissing: false,
|
||||
isInvalid: false,
|
||||
mtime: libraryItemFolderStats.mtimeMs || 0,
|
||||
ctime: libraryItemFolderStats.ctimeMs || 0,
|
||||
birthtime: libraryItemFolderStats.birthtimeMs || 0,
|
||||
size: 0,
|
||||
libraryFiles: [],
|
||||
extraData: {},
|
||||
libraryId: library.id,
|
||||
libraryFolderId: folder.id
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
await transaction.commit()
|
||||
} catch (error) {
|
||||
Logger.error(`[PodcastController] Failed to create podcast: ${error}`)
|
||||
await transaction.rollback()
|
||||
return res.status(500).send('Failed to create podcast')
|
||||
}
|
||||
|
||||
const libraryItem = new LibraryItem()
|
||||
libraryItem.setData('podcast', libraryItemPayload)
|
||||
newLibraryItem.media = await newLibraryItem.getMediaExpanded()
|
||||
|
||||
// Download and save cover image
|
||||
if (payload.media.metadata.imageUrl) {
|
||||
// TODO: Scan cover image to library files
|
||||
if (typeof payload.media.metadata.imageUrl === 'string' && payload.media.metadata.imageUrl.startsWith('http')) {
|
||||
// Podcast cover will always go into library item folder
|
||||
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
|
||||
if (coverResponse) {
|
||||
if (coverResponse.error) {
|
||||
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
|
||||
} else if (coverResponse.cover) {
|
||||
libraryItem.media.coverPath = coverResponse.cover
|
||||
const coverResponse = await CoverManager.downloadCoverFromUrlNew(payload.media.metadata.imageUrl, newLibraryItem.id, newLibraryItem.path, true)
|
||||
if (coverResponse.error) {
|
||||
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
|
||||
} else if (coverResponse.cover) {
|
||||
const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
|
||||
if (!coverImageFileStats) {
|
||||
Logger.error(`[PodcastController] Failed to get cover image stats for "${coverResponse.cover}"`)
|
||||
} else {
|
||||
// Add libraryFile to libraryItem and coverPath to podcast
|
||||
const newLibraryFile = {
|
||||
ino: coverImageFileStats.ino,
|
||||
fileType: 'image',
|
||||
addedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
metadata: {
|
||||
filename: Path.basename(coverResponse.cover),
|
||||
ext: Path.extname(coverResponse.cover).slice(1),
|
||||
path: coverResponse.cover,
|
||||
relPath: Path.basename(coverResponse.cover),
|
||||
size: coverImageFileStats.size,
|
||||
mtimeMs: coverImageFileStats.mtimeMs || 0,
|
||||
ctimeMs: coverImageFileStats.ctimeMs || 0,
|
||||
birthtimeMs: coverImageFileStats.birthtimeMs || 0
|
||||
}
|
||||
}
|
||||
newLibraryItem.libraryFiles.push(newLibraryFile)
|
||||
newLibraryItem.changed('libraryFiles', true)
|
||||
await newLibraryItem.save()
|
||||
|
||||
newLibraryItem.media.coverPath = coverResponse.cover
|
||||
await newLibraryItem.media.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Database.createLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
|
||||
SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
|
||||
|
||||
res.json(libraryItem.toJSONExpanded())
|
||||
|
||||
if (payload.episodesToDownload?.length) {
|
||||
Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`)
|
||||
PodcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
|
||||
}
|
||||
res.json(newLibraryItem.toOldJSONExpanded())
|
||||
|
||||
// Turn on podcast auto download cron if not already on
|
||||
if (libraryItem.media.autoDownloadEpisodes) {
|
||||
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
||||
if (newLibraryItem.media.autoDownloadEpisodes) {
|
||||
this.cronManager.checkUpdatePodcastCron(newLibraryItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +215,7 @@ class PodcastController {
|
||||
}
|
||||
|
||||
res.json({
|
||||
feeds: PodcastManager.getParsedOPMLFileFeeds(req.body.opmlText)
|
||||
feeds: this.podcastManager.getParsedOPMLFileFeeds(req.body.opmlText)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,7 +249,7 @@ class PodcastController {
|
||||
return res.status(404).send('Folder not found')
|
||||
}
|
||||
const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes
|
||||
PodcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)
|
||||
this.podcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
@@ -214,7 +259,7 @@ class PodcastController {
|
||||
*
|
||||
* @this import('../routers/ApiRouter')
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async checkNewEpisodes(req, res) {
|
||||
@@ -223,15 +268,14 @@ class PodcastController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var libraryItem = req.libraryItem
|
||||
if (!libraryItem.media.metadata.feedUrl) {
|
||||
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`)
|
||||
return res.status(500).send('Podcast has no rss feed url')
|
||||
if (!req.libraryItem.media.feedURL) {
|
||||
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${req.libraryItem.id}`)
|
||||
return res.status(400).send('Podcast has no rss feed url')
|
||||
}
|
||||
|
||||
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3
|
||||
|
||||
var newEpisodes = await PodcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload)
|
||||
const newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(req.libraryItem, maxEpisodesToDownload)
|
||||
res.json({
|
||||
episodes: newEpisodes || []
|
||||
})
|
||||
@@ -240,6 +284,8 @@ class PodcastController {
|
||||
/**
|
||||
* GET: /api/podcasts/:id/clear-queue
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
@@ -248,30 +294,37 @@ class PodcastController {
|
||||
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempting to clear download queue`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
PodcastManager.clearDownloadQueue(req.params.id)
|
||||
this.podcastManager.clearDownloadQueue(req.params.id)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/podcasts/:id/downloads
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
getEpisodeDownloads(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
|
||||
var downloadsInQueue = PodcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
|
||||
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||
res.json({
|
||||
downloads: downloadsInQueue.map((d) => d.toJSONForClient())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/podcasts/:id/search-episode
|
||||
* Search for an episode in a podcast
|
||||
*
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findEpisode(req, res) {
|
||||
const rssFeedUrl = req.libraryItem.media.metadata.feedUrl
|
||||
const rssFeedUrl = req.libraryItem.media.feedURL
|
||||
if (!rssFeedUrl) {
|
||||
Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`)
|
||||
return res.status(500).send('Podcast does not have an RSS feed URL')
|
||||
return res.status(400).send('Podcast does not have an RSS feed URL')
|
||||
}
|
||||
|
||||
const searchTitle = req.query.title
|
||||
@@ -287,7 +340,9 @@ class PodcastController {
|
||||
/**
|
||||
* POST: /api/podcasts/:id/download-episodes
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async downloadEpisodes(req, res) {
|
||||
@@ -295,13 +350,13 @@ class PodcastController {
|
||||
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const libraryItem = req.libraryItem
|
||||
|
||||
const episodes = req.body
|
||||
if (!episodes?.length) {
|
||||
if (!Array.isArray(episodes) || !episodes.length) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
PodcastManager.downloadPodcastEpisodes(libraryItem, episodes)
|
||||
this.podcastManager.downloadPodcastEpisodes(req.libraryItem, episodes)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@@ -310,7 +365,7 @@ class PodcastController {
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async quickMatchEpisodes(req, res) {
|
||||
@@ -322,8 +377,7 @@ class PodcastController {
|
||||
const overrideDetails = req.query.override === '1'
|
||||
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
|
||||
if (episodesUpdated) {
|
||||
await Database.updateLibraryItem(req.libraryItem)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -334,61 +388,82 @@ class PodcastController {
|
||||
/**
|
||||
* PATCH: /api/podcasts/:id/episode/:episodeId
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateEpisode(req, res) {
|
||||
const libraryItem = req.libraryItem
|
||||
|
||||
var episodeId = req.params.episodeId
|
||||
if (!libraryItem.media.checkHasEpisode(episodeId)) {
|
||||
/** @type {import('../models/PodcastEpisode')} */
|
||||
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === req.params.episodeId)
|
||||
if (!episode) {
|
||||
return res.status(404).send('Episode not found')
|
||||
}
|
||||
|
||||
if (libraryItem.media.updateEpisode(episodeId, req.body)) {
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
const updatePayload = {}
|
||||
const supportedStringKeys = ['title', 'subtitle', 'description', 'pubDate', 'episode', 'season', 'episodeType']
|
||||
for (const key in req.body) {
|
||||
if (supportedStringKeys.includes(key) && typeof req.body[key] === 'string') {
|
||||
updatePayload[key] = req.body[key]
|
||||
} else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {
|
||||
updatePayload[key] = req.body[key]
|
||||
} else if (key === 'publishedAt' && typeof req.body[key] === 'number') {
|
||||
updatePayload[key] = req.body[key]
|
||||
}
|
||||
}
|
||||
|
||||
res.json(libraryItem.toJSONExpanded())
|
||||
if (Object.keys(updatePayload).length) {
|
||||
episode.set(updatePayload)
|
||||
if (episode.changed()) {
|
||||
Logger.info(`[PodcastController] Updated episode "${episode.title}" keys`, episode.changed())
|
||||
await episode.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
} else {
|
||||
Logger.info(`[PodcastController] No changes to episode "${episode.title}"`)
|
||||
}
|
||||
}
|
||||
|
||||
res.json(req.libraryItem.toOldJSONExpanded())
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/podcasts/:id/episode/:episodeId
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getEpisode(req, res) {
|
||||
const episodeId = req.params.episodeId
|
||||
const libraryItem = req.libraryItem
|
||||
|
||||
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
|
||||
/** @type {import('../models/PodcastEpisode')} */
|
||||
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)
|
||||
if (!episode) {
|
||||
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
res.json(episode)
|
||||
res.json(episode.toOldJSON(req.libraryItem.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE: /api/podcasts/:id/episode/:episodeId
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeEpisode(req, res) {
|
||||
const episodeId = req.params.episodeId
|
||||
const libraryItem = req.libraryItem
|
||||
const hardDelete = req.query.hard === '1'
|
||||
|
||||
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
|
||||
/** @type {import('../models/PodcastEpisode')} */
|
||||
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)
|
||||
if (!episode) {
|
||||
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
// Remove it from the podcastEpisodes array
|
||||
req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeId)
|
||||
|
||||
if (hardDelete) {
|
||||
const audioFile = episode.audioFile
|
||||
// TODO: this will trigger the watcher. should maybe handle this gracefully
|
||||
@@ -402,36 +477,8 @@ class PodcastController {
|
||||
})
|
||||
}
|
||||
|
||||
// Remove episode from Podcast and library file
|
||||
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
|
||||
if (episodeRemoved?.audioFile) {
|
||||
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
|
||||
}
|
||||
|
||||
// Update/remove playlists that had this podcast episode
|
||||
const playlistMediaItems = await Database.playlistMediaItemModel.findAll({
|
||||
where: {
|
||||
mediaItemId: episodeId
|
||||
},
|
||||
include: {
|
||||
model: Database.playlistModel,
|
||||
include: Database.playlistMediaItemModel
|
||||
}
|
||||
})
|
||||
for (const pmi of playlistMediaItems) {
|
||||
const numItems = pmi.playlist.playlistMediaItems.length - 1
|
||||
|
||||
if (!numItems) {
|
||||
Logger.info(`[PodcastController] Playlist "${pmi.playlist.name}" has no more items - removing it`)
|
||||
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
await pmi.playlist.destroy()
|
||||
} else {
|
||||
await pmi.destroy()
|
||||
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
}
|
||||
// Remove episode from playlists
|
||||
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeId])
|
||||
|
||||
// Remove media progress for this episode
|
||||
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||
@@ -443,9 +490,16 @@ class PodcastController {
|
||||
Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`)
|
||||
}
|
||||
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
res.json(libraryItem.toJSON())
|
||||
// Remove episode
|
||||
await episode.destroy()
|
||||
|
||||
// Remove library file
|
||||
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((file) => file.ino !== episode.audioFile.ino)
|
||||
req.libraryItem.changed('libraryFiles', true)
|
||||
await req.libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
res.json(req.libraryItem.toOldJSON())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -455,15 +509,15 @@ class PodcastController {
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async middleware(req, res, next) {
|
||||
const item = await Database.libraryItemModel.getOldById(req.params.id)
|
||||
if (!item?.media) return res.sendStatus(404)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
|
||||
if (!libraryItem?.media) return res.sendStatus(404)
|
||||
|
||||
if (!item.isPodcast) {
|
||||
if (!libraryItem.isPodcast) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
// Check user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
@@ -475,7 +529,7 @@ class PodcastController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
req.libraryItem = item
|
||||
req.libraryItem = libraryItem
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class SearchController {
|
||||
*/
|
||||
async findBooks(req, res) {
|
||||
const id = req.query.id
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(id)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(id)
|
||||
const provider = req.query.provider || 'google'
|
||||
const title = req.query.title || ''
|
||||
const author = req.query.author || ''
|
||||
|
||||
@@ -149,7 +149,7 @@ class SessionController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getOpenSession(req, res) {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.playbackSession.libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(req.playbackSession.libraryItemId)
|
||||
const sessionForClient = req.playbackSession.toJSONForClient(libraryItem)
|
||||
res.json(sessionForClient)
|
||||
}
|
||||
|
||||
@@ -70,14 +70,13 @@ class ShareController {
|
||||
}
|
||||
|
||||
try {
|
||||
const oldLibraryItem = await Database.mediaItemShareModel.getMediaItemsOldLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
|
||||
|
||||
if (!oldLibraryItem) {
|
||||
const libraryItem = await Database.mediaItemShareModel.getMediaItemsLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Media item not found')
|
||||
}
|
||||
|
||||
let startOffset = 0
|
||||
const publicTracks = oldLibraryItem.media.includedAudioFiles.map((audioFile) => {
|
||||
const publicTracks = libraryItem.media.includedAudioFiles.map((audioFile) => {
|
||||
const audioTrack = {
|
||||
index: audioFile.index,
|
||||
startOffset,
|
||||
@@ -86,7 +85,7 @@ class ShareController {
|
||||
contentUrl: `${global.RouterBasePath}/public/share/${slug}/track/${audioFile.index}`,
|
||||
mimeType: audioFile.mimeType,
|
||||
codec: audioFile.codec || null,
|
||||
metadata: audioFile.metadata.clone()
|
||||
metadata: structuredClone(audioFile.metadata)
|
||||
}
|
||||
startOffset += audioTrack.duration
|
||||
return audioTrack
|
||||
@@ -105,12 +104,12 @@ class ShareController {
|
||||
const deviceInfo = await this.playbackSessionManager.getDeviceInfo(req, clientDeviceInfo)
|
||||
|
||||
const newPlaybackSession = new PlaybackSession()
|
||||
newPlaybackSession.setData(oldLibraryItem, null, 'web-share', deviceInfo, startTime)
|
||||
newPlaybackSession.setData(libraryItem, null, 'web-share', deviceInfo, startTime)
|
||||
newPlaybackSession.audioTracks = publicTracks
|
||||
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
||||
newPlaybackSession.shareSessionId = shareSessionId
|
||||
newPlaybackSession.mediaItemShareId = mediaItemShare.id
|
||||
newPlaybackSession.coverAspectRatio = oldLibraryItem.librarySettings.coverAspectRatio
|
||||
newPlaybackSession.coverAspectRatio = libraryItem.library.settings.coverAspectRatio
|
||||
|
||||
mediaItemShare.playbackSession = newPlaybackSession.toJSONForClient()
|
||||
ShareManager.addOpenSharePlaybackSession(newPlaybackSession)
|
||||
|
||||
@@ -7,6 +7,11 @@ const Database = require('../Database')
|
||||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*
|
||||
* @typedef RequestEntityObject
|
||||
* @property {import('../models/LibraryItem')} libraryItem
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem
|
||||
*/
|
||||
|
||||
class ToolsController {
|
||||
@@ -18,7 +23,7 @@ class ToolsController {
|
||||
*
|
||||
* @this import('../routers/ApiRouter')
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async encodeM4b(req, res) {
|
||||
@@ -27,12 +32,12 @@ class ToolsController {
|
||||
return res.status(404).send('Audiobook not found')
|
||||
}
|
||||
|
||||
if (req.libraryItem.mediaType !== 'book') {
|
||||
if (!req.libraryItem.isBook) {
|
||||
Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`)
|
||||
return res.status(400).send('Invalid library item: not a book')
|
||||
}
|
||||
|
||||
if (req.libraryItem.media.tracks.length <= 0) {
|
||||
if (!req.libraryItem.hasAudioTracks) {
|
||||
Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`)
|
||||
return res.status(400).send('Invalid audiobook: no audio tracks')
|
||||
}
|
||||
@@ -72,11 +77,11 @@ class ToolsController {
|
||||
*
|
||||
* @this import('../routers/ApiRouter')
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {RequestWithLibraryItem} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async embedAudioFileMetadata(req, res) {
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioTracks || !req.libraryItem.isBook) {
|
||||
Logger.error(`[ToolsController] Invalid library item`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
@@ -111,7 +116,7 @@ class ToolsController {
|
||||
|
||||
const libraryItems = []
|
||||
for (const libraryItemId of libraryItemIds) {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
|
||||
return res.sendStatus(404)
|
||||
@@ -123,7 +128,7 @@ class ToolsController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (libraryItem.isMissing || !libraryItem.hasAudioFiles || !libraryItem.isBook) {
|
||||
if (libraryItem.isMissing || !libraryItem.hasAudioTracks || !libraryItem.isBook) {
|
||||
Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
@@ -157,7 +162,7 @@ class ToolsController {
|
||||
}
|
||||
|
||||
if (req.params.id) {
|
||||
const item = await Database.libraryItemModel.getOldById(req.params.id)
|
||||
const item = await Database.libraryItemModel.getExpandedById(req.params.id)
|
||||
if (!item?.media) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
|
||||
@@ -361,7 +361,7 @@ class BookFinder {
|
||||
/**
|
||||
* Search for books including fuzzy searches
|
||||
*
|
||||
* @param {Object} libraryItem
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {string} provider
|
||||
* @param {string} title
|
||||
* @param {string} author
|
||||
|
||||
@@ -51,7 +51,7 @@ class AbMergeManager {
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {import('../objects/LibraryItem')} libraryItem
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {AbMergeEncodeOptions} [options={}]
|
||||
*/
|
||||
async startAudiobookMerge(userId, libraryItem, options = {}) {
|
||||
@@ -67,7 +67,7 @@ class AbMergeManager {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItemDir,
|
||||
userId,
|
||||
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path),
|
||||
originalTrackPaths: libraryItem.media.includedAudioFiles.map((t) => t.metadata.path),
|
||||
inos: libraryItem.media.includedAudioFiles.map((f) => f.ino),
|
||||
tempFilepath,
|
||||
targetFilename,
|
||||
@@ -86,9 +86,9 @@ class AbMergeManager {
|
||||
key: 'MessageTaskEncodingM4b'
|
||||
}
|
||||
const taskDescriptionString = {
|
||||
text: `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`,
|
||||
text: `Encoding audiobook "${libraryItem.media.title}" into a single m4b file.`,
|
||||
key: 'MessageTaskEncodingM4bDescription',
|
||||
subs: [libraryItem.media.metadata.title]
|
||||
subs: [libraryItem.media.title]
|
||||
}
|
||||
task.setData('encode-m4b', taskTitleString, taskDescriptionString, false, taskData)
|
||||
TaskManager.addTask(task)
|
||||
@@ -103,7 +103,7 @@ class AbMergeManager {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../objects/LibraryItem')} libraryItem
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {Task} task
|
||||
* @param {AbMergeEncodeOptions} encodingOptions
|
||||
*/
|
||||
@@ -141,7 +141,7 @@ class AbMergeManager {
|
||||
const embedFraction = 1 - encodeFraction
|
||||
try {
|
||||
const trackProgressMonitor = new TrackProgressMonitor(
|
||||
libraryItem.media.tracks.map((t) => t.duration),
|
||||
libraryItem.media.includedAudioFiles.map((t) => t.duration),
|
||||
(trackIndex) => SocketAuthority.adminEmitter('track_started', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }),
|
||||
(trackIndex, progressInTrack, taskProgress) => {
|
||||
SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack })
|
||||
@@ -150,7 +150,7 @@ class AbMergeManager {
|
||||
(trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] })
|
||||
)
|
||||
task.data.ffmpeg = new Ffmpeg()
|
||||
await ffmpegHelpers.mergeAudioFiles(libraryItem.media.tracks, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
|
||||
await ffmpegHelpers.mergeAudioFiles(libraryItem.media.includedAudioFiles, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
|
||||
delete task.data.ffmpeg
|
||||
trackProgressMonitor.finish()
|
||||
} catch (error) {
|
||||
|
||||
@@ -42,6 +42,8 @@ class ApiCacheManager {
|
||||
Logger.debug(`[ApiCacheManager] Skipping cache for random sort`)
|
||||
return next()
|
||||
}
|
||||
// Force URL to be lower case for matching against routes
|
||||
req.url = req.url.toLowerCase()
|
||||
const key = { user: req.user.username, url: req.url }
|
||||
const stringifiedKey = JSON.stringify(key)
|
||||
Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`)
|
||||
|
||||
@@ -34,6 +34,11 @@ class AudioMetadataMangaer {
|
||||
return this.tasksQueued.some((t) => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some((t) => t.data.libraryItemId === libraryItemId)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @returns
|
||||
*/
|
||||
getMetadataObjectForApi(libraryItem) {
|
||||
return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length)
|
||||
}
|
||||
@@ -41,8 +46,8 @@ class AudioMetadataMangaer {
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {*} libraryItems
|
||||
* @param {*} options
|
||||
* @param {import('../models/LibraryItem')[]} libraryItems
|
||||
* @param {UpdateMetadataOptions} options
|
||||
*/
|
||||
handleBatchEmbed(userId, libraryItems, options = {}) {
|
||||
libraryItems.forEach((li) => {
|
||||
@@ -53,7 +58,7 @@ class AudioMetadataMangaer {
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {import('../objects/LibraryItem')} libraryItem
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {UpdateMetadataOptions} [options={}]
|
||||
*/
|
||||
async updateMetadataForItem(userId, libraryItem, options = {}) {
|
||||
@@ -103,14 +108,14 @@ class AudioMetadataMangaer {
|
||||
key: 'MessageTaskEmbeddingMetadata'
|
||||
}
|
||||
const taskDescriptionString = {
|
||||
text: `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`,
|
||||
text: `Embedding metadata in audiobook "${libraryItem.media.title}".`,
|
||||
key: 'MessageTaskEmbeddingMetadataDescription',
|
||||
subs: [libraryItem.media.metadata.title]
|
||||
subs: [libraryItem.media.title]
|
||||
}
|
||||
task.setData('embed-metadata', taskTitleString, taskDescriptionString, false, taskData)
|
||||
|
||||
if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {
|
||||
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`)
|
||||
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.title}"`)
|
||||
SocketAuthority.adminEmitter('metadata_embed_queue_update', {
|
||||
libraryItemId: libraryItem.id,
|
||||
queued: true
|
||||
|
||||
@@ -79,6 +79,12 @@ class CoverManager {
|
||||
return imgType
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {*} coverFile - file object from req.files
|
||||
* @returns {Promise<{error:string}|{cover:string}>}
|
||||
*/
|
||||
async uploadCover(libraryItem, coverFile) {
|
||||
const extname = Path.extname(coverFile.name.toLowerCase())
|
||||
if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) {
|
||||
@@ -110,62 +116,19 @@ class CoverManager {
|
||||
await this.removeOldCovers(coverDirPath, extname)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
|
||||
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`)
|
||||
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.title}"`)
|
||||
|
||||
libraryItem.updateMediaCover(coverFullPath)
|
||||
return {
|
||||
cover: coverFullPath
|
||||
}
|
||||
}
|
||||
|
||||
async downloadCoverFromUrl(libraryItem, url, forceLibraryItemFolder = false) {
|
||||
try {
|
||||
// Force save cover with library item is used for adding new podcasts
|
||||
var coverDirPath = forceLibraryItemFolder ? libraryItem.path : this.getCoverDirectory(libraryItem)
|
||||
await fs.ensureDir(coverDirPath)
|
||||
|
||||
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
|
||||
})
|
||||
if (!success) {
|
||||
return {
|
||||
error: 'Failed to download image from url: ' + errorMsg
|
||||
}
|
||||
}
|
||||
|
||||
var imgtype = await this.checkFileIsValidImage(temppath, true)
|
||||
|
||||
if (imgtype.error) {
|
||||
return imgtype
|
||||
}
|
||||
|
||||
var coverFilename = `cover.${imgtype.ext}`
|
||||
var coverFullPath = Path.posix.join(coverDirPath, coverFilename)
|
||||
await fs.rename(temppath, coverFullPath)
|
||||
|
||||
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
|
||||
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`)
|
||||
libraryItem.updateMediaCover(coverFullPath)
|
||||
return {
|
||||
cover: coverFullPath
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[CoverManager] Fetch cover image from url "${url}" failed`, error)
|
||||
return {
|
||||
error: 'Failed to fetch image from url'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} coverPath
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @returns {Promise<{error:string}|{cover:string,updated:boolean}>}
|
||||
*/
|
||||
async validateCoverPath(coverPath, libraryItem) {
|
||||
// Invalid cover path
|
||||
if (!coverPath || coverPath.startsWith('http:') || coverPath.startsWith('https:')) {
|
||||
@@ -235,7 +198,6 @@ class CoverManager {
|
||||
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
|
||||
libraryItem.updateMediaCover(coverPath)
|
||||
return {
|
||||
cover: coverPath,
|
||||
updated: true
|
||||
@@ -321,13 +283,14 @@ class CoverManager {
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast
|
||||
* @param {string} [libraryItemPath] - null if library item isFile
|
||||
* @param {boolean} [forceLibraryItemFolder=false] - force save cover with library item (used for adding new podcasts)
|
||||
* @returns {Promise<{error:string}|{cover:string}>}
|
||||
*/
|
||||
async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath) {
|
||||
async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath, forceLibraryItemFolder = false) {
|
||||
try {
|
||||
let coverDirPath = null
|
||||
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
|
||||
if ((global.ServerSettings.storeCoverWithItem || forceLibraryItemFolder) && libraryItemPath) {
|
||||
coverDirPath = libraryItemPath
|
||||
} else {
|
||||
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
|
||||
|
||||
@@ -5,10 +5,11 @@ const Database = require('../Database')
|
||||
const LibraryScanner = require('../scanner/LibraryScanner')
|
||||
|
||||
const ShareManager = require('./ShareManager')
|
||||
const PodcastManager = require('./PodcastManager')
|
||||
|
||||
class CronManager {
|
||||
constructor(playbackSessionManager) {
|
||||
constructor(podcastManager, playbackSessionManager) {
|
||||
/** @type {import('./PodcastManager')} */
|
||||
this.podcastManager = podcastManager
|
||||
/** @type {import('./PlaybackSessionManager')} */
|
||||
this.playbackSessionManager = playbackSessionManager
|
||||
|
||||
@@ -162,7 +163,7 @@ class CronManager {
|
||||
task
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error(`[CronManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
||||
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +181,7 @@ class CronManager {
|
||||
// Get podcast library items to check
|
||||
const libraryItems = []
|
||||
for (const libraryItemId of libraryItemIds) {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
|
||||
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItemId) // Filter it out
|
||||
@@ -191,7 +192,7 @@ class CronManager {
|
||||
|
||||
// Run episode checks
|
||||
for (const libraryItem of libraryItems) {
|
||||
const keepAutoDownloading = await PodcastManager.runEpisodeCheck(libraryItem)
|
||||
const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem)
|
||||
if (!keepAutoDownloading) {
|
||||
// auto download was disabled
|
||||
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItem.id) // Filter it out
|
||||
@@ -214,6 +215,10 @@ class CronManager {
|
||||
this.podcastCrons = this.podcastCrons.filter((pc) => pc.expression !== podcastCron.expression)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
*/
|
||||
checkUpdatePodcastCron(libraryItem) {
|
||||
// Remove from old cron by library item id
|
||||
const existingCron = this.podcastCrons.find((pc) => pc.libraryItemIds.includes(libraryItem.id))
|
||||
@@ -229,7 +234,10 @@ class CronManager {
|
||||
const cronMatchingExpression = this.podcastCrons.find((pc) => pc.expression === libraryItem.media.autoDownloadSchedule)
|
||||
if (cronMatchingExpression) {
|
||||
cronMatchingExpression.libraryItemIds.push(libraryItem.id)
|
||||
Logger.info(`[CronManager] Added podcast "${libraryItem.media.metadata.title}" to auto dl episode cron "${cronMatchingExpression.expression}"`)
|
||||
|
||||
// TODO: Update after old model removed
|
||||
const podcastTitle = libraryItem.media.title || libraryItem.media.metadata?.title
|
||||
Logger.info(`[CronManager] Added podcast "${podcastTitle}" to auto dl episode cron "${cronMatchingExpression.expression}"`)
|
||||
} else {
|
||||
this.startPodcastCron(libraryItem.media.autoDownloadSchedule, [libraryItem.id])
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ class NotificationManager {
|
||||
return notificationData
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {import('../models/PodcastEpisode')} episode
|
||||
*/
|
||||
async onPodcastEpisodeDownloaded(libraryItem, episode) {
|
||||
if (!Database.notificationSettings.isUseable) return
|
||||
|
||||
@@ -22,17 +27,17 @@ class NotificationManager {
|
||||
return
|
||||
}
|
||||
|
||||
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
|
||||
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.title}`)
|
||||
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
|
||||
const eventData = {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryId: libraryItem.libraryId,
|
||||
libraryName: library?.name || 'Unknown',
|
||||
mediaTags: (libraryItem.media.tags || []).join(', '),
|
||||
podcastTitle: libraryItem.media.metadata.title,
|
||||
podcastAuthor: libraryItem.media.metadata.author || '',
|
||||
podcastDescription: libraryItem.media.metadata.description || '',
|
||||
podcastGenres: (libraryItem.media.metadata.genres || []).join(', '),
|
||||
podcastTitle: libraryItem.media.title,
|
||||
podcastAuthor: libraryItem.media.author || '',
|
||||
podcastDescription: libraryItem.media.description || '',
|
||||
podcastGenres: (libraryItem.media.genres || []).join(', '),
|
||||
episodeId: episode.id,
|
||||
episodeTitle: episode.title,
|
||||
episodeSubtitle: episode.subtitle || '',
|
||||
|
||||
@@ -39,7 +39,7 @@ class PlaybackSessionManager {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../controllers/SessionController').RequestWithUser} req
|
||||
* @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req
|
||||
* @param {Object} [clientDeviceInfo]
|
||||
* @returns {Promise<DeviceInfo>}
|
||||
*/
|
||||
@@ -67,7 +67,7 @@ class PlaybackSessionManager {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../controllers/SessionController').RequestWithUser} req
|
||||
* @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {string} [episodeId]
|
||||
*/
|
||||
@@ -120,8 +120,8 @@ class PlaybackSessionManager {
|
||||
*/
|
||||
async syncLocalSession(user, sessionJson, deviceInfo) {
|
||||
// TODO: Combine libraryItem query with library query
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(sessionJson.libraryItemId)
|
||||
const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(sessionJson.libraryItemId)
|
||||
const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.podcastEpisodes.find((pe) => pe.id === sessionJson.episodeId) : null
|
||||
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
|
||||
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
|
||||
return {
|
||||
@@ -175,7 +175,8 @@ class PlaybackSessionManager {
|
||||
// New session from local
|
||||
session = new PlaybackSession(sessionJson)
|
||||
session.deviceInfo = deviceInfo
|
||||
session.setDuration(libraryItem, sessionJson.episodeId)
|
||||
session.duration = libraryItem.media.getPlaybackDuration(sessionJson.episodeId)
|
||||
|
||||
Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`)
|
||||
await Database.createPlaybackSession(session)
|
||||
} else {
|
||||
@@ -279,7 +280,7 @@ class PlaybackSessionManager {
|
||||
*
|
||||
* @param {import('../models/User')} user
|
||||
* @param {DeviceInfo} deviceInfo
|
||||
* @param {import('../objects/LibraryItem')} libraryItem
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {string|null} episodeId
|
||||
* @param {{forceDirectPlay?:boolean, forceTranscode?:boolean, mediaPlayer:string, supportedMimeTypes?:string[]}} options
|
||||
* @returns {Promise<PlaybackSession>}
|
||||
@@ -292,7 +293,7 @@ class PlaybackSessionManager {
|
||||
await this.closeSession(user, session, null)
|
||||
}
|
||||
|
||||
const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
|
||||
const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options.supportedMimeTypes, episodeId))
|
||||
const mediaPlayer = options.mediaPlayer || 'unknown'
|
||||
|
||||
const mediaItemId = episodeId || libraryItem.media.id
|
||||
@@ -300,7 +301,7 @@ class PlaybackSessionManager {
|
||||
let userStartTime = 0
|
||||
if (userProgress) {
|
||||
if (userProgress.isFinished) {
|
||||
Logger.info(`[PlaybackSessionManager] Starting session for user "${user.username}" and resetting progress for finished item "${libraryItem.media.metadata.title}"`)
|
||||
Logger.info(`[PlaybackSessionManager] Starting session for user "${user.username}" and resetting progress for finished item "${libraryItem.media.title}"`)
|
||||
// Keep userStartTime as 0 so the client restarts the media
|
||||
} else {
|
||||
userStartTime = Number.parseFloat(userProgress.currentTime) || 0
|
||||
@@ -312,7 +313,7 @@ class PlaybackSessionManager {
|
||||
let audioTracks = []
|
||||
if (shouldDirectPlay) {
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`)
|
||||
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
|
||||
audioTracks = libraryItem.getTrackList(episodeId)
|
||||
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
||||
} else {
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`)
|
||||
@@ -342,20 +343,20 @@ class PlaybackSessionManager {
|
||||
* @param {import('../models/User')} user
|
||||
* @param {*} session
|
||||
* @param {*} syncData
|
||||
* @returns
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async syncSession(user, session, syncData) {
|
||||
// TODO: Combine libraryItem query with library query
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(session.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[PlaybackSessionManager] syncSession Library not found "${libraryItem.libraryId}"`)
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
session.currentTime = syncData.currentTime
|
||||
@@ -381,9 +382,8 @@ class PlaybackSessionManager {
|
||||
})
|
||||
}
|
||||
this.saveSession(session)
|
||||
return {
|
||||
libraryItem
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const TaskManager = require('../managers/TaskManager')
|
||||
const ShareManager = require('../managers/ShareManager')
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const PodcastManager = require('../managers/PodcastManager')
|
||||
const fsExtra = require('../libs/fsExtra')
|
||||
const { isUUID, parseSemverStrict } = require('../utils')
|
||||
|
||||
/**
|
||||
* @typedef PluginContext
|
||||
* @property {import('../Logger')} Logger
|
||||
* @property {import('../Database')} Database
|
||||
* @property {import('../SocketAuthority')} SocketAuthority
|
||||
* @property {import('../managers/TaskManager')} TaskManager
|
||||
* @property {import('../models/Plugin')} pluginInstance
|
||||
* @property {import('../managers/ShareManager')} ShareManager
|
||||
* @property {import('../managers/RssFeedManager')} RssFeedManager
|
||||
* @property {import('../managers/PodcastManager')} PodcastManager
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef PluginData
|
||||
* @property {string} id
|
||||
* @property {Object} manifest
|
||||
* @property {import('../models/Plugin')} instance
|
||||
* @property {Function} init
|
||||
* @property {Function} onAction
|
||||
* @property {Function} onConfigSave
|
||||
*/
|
||||
|
||||
class PluginManager {
|
||||
constructor() {
|
||||
/** @type {PluginData[]} */
|
||||
this.plugins = []
|
||||
}
|
||||
|
||||
get pluginMetadataPath() {
|
||||
return Path.posix.join(global.MetadataPath, 'plugins')
|
||||
}
|
||||
|
||||
get pluginManifests() {
|
||||
return this.plugins.map((plugin) => plugin.manifest)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/Plugin')} pluginInstance
|
||||
* @returns {PluginContext}
|
||||
*/
|
||||
getPluginContext(pluginInstance) {
|
||||
return {
|
||||
Logger,
|
||||
Database,
|
||||
SocketAuthority,
|
||||
TaskManager,
|
||||
pluginInstance,
|
||||
ShareManager,
|
||||
RssFeedManager,
|
||||
PodcastManager
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {PluginData}
|
||||
*/
|
||||
getPluginDataById(id) {
|
||||
return this.plugins.find((plugin) => plugin.manifest.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and load a plugin from a directory
|
||||
* TODO: Validatation
|
||||
*
|
||||
* @param {string} dirname
|
||||
* @param {string} pluginPath
|
||||
* @returns {Promise<PluginData>}
|
||||
*/
|
||||
async loadPlugin(dirname, pluginPath) {
|
||||
const pluginFiles = await fsExtra.readdir(pluginPath, { withFileTypes: true }).then((files) => files.filter((file) => !file.isDirectory()))
|
||||
|
||||
if (!pluginFiles.length) {
|
||||
Logger.error(`No files found in plugin ${pluginPath}`)
|
||||
return null
|
||||
}
|
||||
const manifestFile = pluginFiles.find((file) => file.name === 'manifest.json')
|
||||
if (!manifestFile) {
|
||||
Logger.error(`No manifest found for plugin ${pluginPath}`)
|
||||
return null
|
||||
}
|
||||
const indexFile = pluginFiles.find((file) => file.name === 'index.js')
|
||||
if (!indexFile) {
|
||||
Logger.error(`No index file found for plugin ${pluginPath}`)
|
||||
return null
|
||||
}
|
||||
|
||||
let manifestJson = null
|
||||
try {
|
||||
manifestJson = await fsExtra.readFile(Path.join(pluginPath, manifestFile.name), 'utf8').then((data) => JSON.parse(data))
|
||||
} catch (error) {
|
||||
Logger.error(`Error parsing manifest file for plugin ${pluginPath}`, error)
|
||||
return null
|
||||
}
|
||||
|
||||
// TODO: Validate manifest json
|
||||
if (!isUUID(manifestJson.id)) {
|
||||
Logger.error(`Invalid plugin ID in manifest for plugin ${pluginPath}`)
|
||||
return null
|
||||
}
|
||||
if (!parseSemverStrict(manifestJson.version)) {
|
||||
Logger.error(`Invalid plugin version in manifest for plugin ${pluginPath}`)
|
||||
return null
|
||||
}
|
||||
// TODO: Enforcing plugin name to be the same as the directory name? Ensures plugins are identifiable in the file system. May have issues with unicode characters.
|
||||
if (dirname !== manifestJson.name) {
|
||||
Logger.error(`Plugin directory name "${dirname}" does not match manifest name "${manifestJson.name}"`)
|
||||
return null
|
||||
}
|
||||
|
||||
let pluginContents = null
|
||||
try {
|
||||
pluginContents = require(Path.join(pluginPath, indexFile.name))
|
||||
} catch (error) {
|
||||
Logger.error(`Error loading plugin ${pluginPath}`, error)
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof pluginContents.init !== 'function') {
|
||||
Logger.error(`Plugin ${pluginPath} does not have an init function`)
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: manifestJson.id,
|
||||
manifest: manifestJson,
|
||||
init: pluginContents.init,
|
||||
onAction: pluginContents.onAction,
|
||||
onConfigSave: pluginContents.onConfigSave
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all plugins from the /metadata/plugins directory
|
||||
*/
|
||||
async getPluginsFromDirPath(pluginsPath) {
|
||||
// Get all directories in the plugins directory
|
||||
const pluginDirs = await fsExtra.readdir(pluginsPath, { withFileTypes: true }).then((files) => files.filter((file) => file.isDirectory()))
|
||||
|
||||
const pluginsFound = []
|
||||
for (const pluginDir of pluginDirs) {
|
||||
Logger.debug(`[PluginManager] Checking if directory "${pluginDir.name}" is a plugin`)
|
||||
const plugin = await this.loadPlugin(pluginDir.name, Path.join(pluginsPath, pluginDir.name))
|
||||
if (plugin) {
|
||||
Logger.debug(`[PluginManager] Found plugin "${plugin.manifest.name}"`)
|
||||
pluginsFound.push(plugin)
|
||||
}
|
||||
}
|
||||
|
||||
return pluginsFound
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plugins from the /metadata/plugins directory and update the database
|
||||
*/
|
||||
async loadPlugins() {
|
||||
await fsExtra.ensureDir(this.pluginMetadataPath)
|
||||
|
||||
const pluginsFound = await this.getPluginsFromDirPath(this.pluginMetadataPath)
|
||||
|
||||
if (process.env.DEV_PLUGINS_PATH) {
|
||||
const devPluginsFound = await this.getPluginsFromDirPath(process.env.DEV_PLUGINS_PATH)
|
||||
if (!devPluginsFound.length) {
|
||||
Logger.warn(`[PluginManager] No plugins found in DEV_PLUGINS_PATH: ${process.env.DEV_PLUGINS_PATH}`)
|
||||
} else {
|
||||
pluginsFound.push(...devPluginsFound)
|
||||
}
|
||||
}
|
||||
|
||||
const existingPlugins = await Database.pluginModel.findAll()
|
||||
|
||||
// Add new plugins or update existing plugins
|
||||
for (const plugin of pluginsFound) {
|
||||
const existingPlugin = existingPlugins.find((p) => p.id === plugin.manifest.id)
|
||||
if (existingPlugin) {
|
||||
// TODO: Should automatically update?
|
||||
if (existingPlugin.version !== plugin.manifest.version) {
|
||||
Logger.info(`[PluginManager] Updating plugin "${plugin.manifest.name}" version from "${existingPlugin.version}" to version "${plugin.manifest.version}"`)
|
||||
await existingPlugin.update({ version: plugin.manifest.version, isMissing: false })
|
||||
} else if (existingPlugin.isMissing) {
|
||||
Logger.info(`[PluginManager] Plugin "${plugin.manifest.name}" was missing but is now found`)
|
||||
await existingPlugin.update({ isMissing: false })
|
||||
} else {
|
||||
Logger.debug(`[PluginManager] Plugin "${plugin.manifest.name}" already exists in the database with version "${plugin.manifest.version}"`)
|
||||
}
|
||||
plugin.instance = existingPlugin
|
||||
} else {
|
||||
plugin.instance = await Database.pluginModel.create({
|
||||
id: plugin.manifest.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version
|
||||
})
|
||||
Logger.info(`[PluginManager] Added plugin "${plugin.manifest.name}" to the database`)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark missing plugins
|
||||
for (const plugin of existingPlugins) {
|
||||
const foundPlugin = pluginsFound.find((p) => p.manifest.id === plugin.id)
|
||||
if (!foundPlugin && !plugin.isMissing) {
|
||||
Logger.info(`[PluginManager] Plugin "${plugin.name}" not found or invalid - marking as missing`)
|
||||
await plugin.update({ isMissing: true })
|
||||
}
|
||||
}
|
||||
|
||||
this.plugins = pluginsFound
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and initialize all plugins
|
||||
*/
|
||||
async init() {
|
||||
await this.loadPlugins()
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
Logger.info(`[PluginManager] Initializing plugin ${plugin.manifest.name}`)
|
||||
plugin.init(this.getPluginContext(plugin.instance))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PluginData} plugin
|
||||
* @param {string} actionName
|
||||
* @param {string} target
|
||||
* @param {Object} data
|
||||
* @returns {Promise<boolean|{error:string}>}
|
||||
*/
|
||||
onAction(plugin, actionName, target, data) {
|
||||
if (!plugin.onAction) {
|
||||
Logger.error(`[PluginManager] onAction not implemented for plugin ${plugin.manifest.name}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const pluginExtension = plugin.manifest.extensions.find((extension) => extension.name === actionName)
|
||||
if (!pluginExtension) {
|
||||
Logger.error(`[PluginManager] Extension ${actionName} not found for plugin ${plugin.manifest.name}`)
|
||||
return false
|
||||
}
|
||||
|
||||
Logger.info(`[PluginManager] Calling onAction for plugin ${plugin.manifest.name}`)
|
||||
return plugin.onAction(this.getPluginContext(plugin.instance), actionName, target, data)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PluginData} plugin
|
||||
* @param {Object} config
|
||||
* @returns {Promise<boolean|{error:string}>}
|
||||
*/
|
||||
onConfigSave(plugin, config) {
|
||||
if (!plugin.onConfigSave) {
|
||||
Logger.error(`[PluginManager] onConfigSave not implemented for plugin ${plugin.manifest.name}`)
|
||||
return false
|
||||
}
|
||||
|
||||
Logger.info(`[PluginManager] Calling onConfigSave for plugin ${plugin.manifest.name}`)
|
||||
return plugin.onConfigSave(this.getPluginContext(plugin.instance), config)
|
||||
}
|
||||
}
|
||||
module.exports = new PluginManager()
|
||||
@@ -1,3 +1,4 @@
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
@@ -19,9 +20,7 @@ const NotificationManager = require('../managers/NotificationManager')
|
||||
|
||||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
|
||||
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
|
||||
class PodcastManager {
|
||||
constructor() {
|
||||
@@ -52,15 +51,16 @@ class PodcastManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {import('../utils/podcastUtils').RssPodcastEpisode[]} episodesToDownload
|
||||
* @param {boolean} isAutoDownload - If this download was triggered by auto download
|
||||
*/
|
||||
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
|
||||
let index = Math.max(...libraryItem.media.episodes.filter((ep) => ep.index == null || isNaN(ep.index)).map((ep) => Number(ep.index))) + 1
|
||||
for (const ep of episodesToDownload) {
|
||||
const newPe = new PodcastEpisode()
|
||||
newPe.setData(ep, index++)
|
||||
newPe.libraryItemId = libraryItem.id
|
||||
newPe.podcastId = libraryItem.media.id
|
||||
const newPeDl = new PodcastEpisodeDownload()
|
||||
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId)
|
||||
newPeDl.setData(ep, libraryItem, isAutoDownload, libraryItem.libraryId)
|
||||
this.startPodcastEpisodeDownload(newPeDl)
|
||||
}
|
||||
}
|
||||
@@ -86,20 +86,20 @@ class PodcastManager {
|
||||
key: 'MessageDownloadingEpisode'
|
||||
}
|
||||
const taskDescriptionString = {
|
||||
text: `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`,
|
||||
text: `Downloading episode "${podcastEpisodeDownload.episodeTitle}".`,
|
||||
key: 'MessageTaskDownloadingEpisodeDescription',
|
||||
subs: [podcastEpisodeDownload.podcastEpisode.title]
|
||||
subs: [podcastEpisodeDownload.episodeTitle]
|
||||
}
|
||||
const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData)
|
||||
|
||||
SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
|
||||
this.currentDownload = podcastEpisodeDownload
|
||||
|
||||
// If this file already exists then append the episode id to the filename
|
||||
// If this file already exists then append a uuid to the filename
|
||||
// e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3"
|
||||
// this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802)
|
||||
if (await fs.pathExists(this.currentDownload.targetPath)) {
|
||||
this.currentDownload.appendEpisodeId = true
|
||||
this.currentDownload.appendRandomId = true
|
||||
}
|
||||
|
||||
// Ignores all added files to this dir
|
||||
@@ -115,10 +115,24 @@ class PodcastManager {
|
||||
let success = false
|
||||
if (this.currentDownload.isMp3) {
|
||||
// Download episode and tag it
|
||||
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
|
||||
const ffmpegDownloadResponse = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
|
||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||
return false
|
||||
})
|
||||
success = !!ffmpegDownloadResponse?.success
|
||||
|
||||
// If failed due to ffmpeg error, retry without tagging
|
||||
// e.g. RSS feed may have incorrect file extension and file type
|
||||
// See https://github.com/advplyr/audiobookshelf/issues/3837
|
||||
if (!success && ffmpegDownloadResponse?.isFfmpegError) {
|
||||
Logger.info(`[PodcastManager] Retrying episode download without tagging`)
|
||||
// Download episode only
|
||||
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Download episode only
|
||||
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath)
|
||||
@@ -140,7 +154,7 @@ class PodcastManager {
|
||||
}
|
||||
task.setFailed(taskFailedString)
|
||||
} else {
|
||||
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`)
|
||||
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.episodeTitle}"`)
|
||||
this.currentDownload.setFinished(true)
|
||||
task.setFinished()
|
||||
}
|
||||
@@ -166,47 +180,61 @@ class PodcastManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the downloaded audio file, create the podcast episode, remove oldest episode if necessary
|
||||
* @returns {Promise<boolean>} - Returns true if added
|
||||
*/
|
||||
async scanAddPodcastEpisodeAudioFile() {
|
||||
const libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
|
||||
const libraryFile = new LibraryFile()
|
||||
await libraryFile.setDataFromPath(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
|
||||
|
||||
const audioFile = await this.probeAudioFile(libraryFile)
|
||||
if (!audioFile) {
|
||||
return false
|
||||
}
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id)
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(this.currentDownload.libraryItem.id)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const podcastEpisode = this.currentDownload.podcastEpisode
|
||||
podcastEpisode.audioFile = audioFile
|
||||
const podcastEpisode = await Database.podcastEpisodeModel.createFromRssPodcastEpisode(this.currentDownload.rssPodcastEpisode, libraryItem.media.id, audioFile)
|
||||
|
||||
if (audioFile.chapters?.length) {
|
||||
podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch }))
|
||||
}
|
||||
libraryItem.libraryFiles.push(libraryFile.toJSON())
|
||||
libraryItem.changed('libraryFiles', true)
|
||||
|
||||
libraryItem.media.addPodcastEpisode(podcastEpisode)
|
||||
if (libraryItem.isInvalid) {
|
||||
// First episode added to an empty podcast
|
||||
libraryItem.isInvalid = false
|
||||
}
|
||||
libraryItem.libraryFiles.push(libraryFile)
|
||||
libraryItem.media.podcastEpisodes.push(podcastEpisode)
|
||||
|
||||
if (this.currentDownload.isAutoDownload) {
|
||||
// Check setting maxEpisodesToKeep and remove episode if necessary
|
||||
if (libraryItem.media.maxEpisodesToKeep && libraryItem.media.episodesWithPubDate.length > libraryItem.media.maxEpisodesToKeep) {
|
||||
Logger.info(`[PodcastManager] # of episodes (${libraryItem.media.episodesWithPubDate.length}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`)
|
||||
await this.removeOldestEpisode(libraryItem, podcastEpisode.id)
|
||||
const numEpisodesWithPubDate = libraryItem.media.podcastEpisodes.filter((ep) => !!ep.publishedAt).length
|
||||
if (libraryItem.media.maxEpisodesToKeep && numEpisodesWithPubDate > libraryItem.media.maxEpisodesToKeep) {
|
||||
Logger.info(`[PodcastManager] # of episodes (${numEpisodesWithPubDate}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`)
|
||||
const episodeToRemove = await this.getRemoveOldestEpisode(libraryItem, podcastEpisode.id)
|
||||
if (episodeToRemove) {
|
||||
// Remove episode from playlists
|
||||
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id])
|
||||
// Remove media progress for this episode
|
||||
await Database.mediaProgressModel.destroy({
|
||||
where: {
|
||||
mediaItemId: episodeToRemove.id
|
||||
}
|
||||
})
|
||||
await episodeToRemove.destroy()
|
||||
libraryItem.media.podcastEpisodes = libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeToRemove.id)
|
||||
|
||||
// Remove library file
|
||||
libraryItem.libraryFiles = libraryItem.libraryFiles.filter((lf) => lf.ino !== episodeToRemove.audioFile.ino)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
libraryItem.updatedAt = Date.now()
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
const podcastEpisodeExpanded = podcastEpisode.toJSONExpanded()
|
||||
podcastEpisodeExpanded.libraryItem = libraryItem.toJSONExpanded()
|
||||
await libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id)
|
||||
podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded()
|
||||
SocketAuthority.emitter('episode_added', podcastEpisodeExpanded)
|
||||
|
||||
if (this.currentDownload.isAutoDownload) {
|
||||
@@ -217,45 +245,53 @@ class PodcastManager {
|
||||
return true
|
||||
}
|
||||
|
||||
async removeOldestEpisode(libraryItem, episodeIdJustDownloaded) {
|
||||
var smallestPublishedAt = 0
|
||||
var oldestEpisode = null
|
||||
libraryItem.media.episodesWithPubDate
|
||||
.filter((ep) => ep.id !== episodeIdJustDownloaded)
|
||||
.forEach((ep) => {
|
||||
if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) {
|
||||
smallestPublishedAt = ep.publishedAt
|
||||
oldestEpisode = ep
|
||||
}
|
||||
})
|
||||
// TODO: Should we check for open playback sessions for this episode?
|
||||
// TODO: remove all user progress for this episode
|
||||
/**
|
||||
* Find oldest episode publishedAt and delete the audio file
|
||||
*
|
||||
* @param {import('../models/LibraryItem').LibraryItemExpanded} libraryItem
|
||||
* @param {string} episodeIdJustDownloaded
|
||||
* @returns {Promise<import('../models/PodcastEpisode')|null>} - Returns the episode to remove
|
||||
*/
|
||||
async getRemoveOldestEpisode(libraryItem, episodeIdJustDownloaded) {
|
||||
let smallestPublishedAt = 0
|
||||
/** @type {import('../models/PodcastEpisode')} */
|
||||
let oldestEpisode = null
|
||||
|
||||
/** @type {import('../models/PodcastEpisode')[]} */
|
||||
const podcastEpisodes = libraryItem.media.podcastEpisodes
|
||||
|
||||
for (const ep of podcastEpisodes) {
|
||||
if (ep.id === episodeIdJustDownloaded || !ep.publishedAt) continue
|
||||
|
||||
if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) {
|
||||
smallestPublishedAt = ep.publishedAt
|
||||
oldestEpisode = ep
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestEpisode?.audioFile) {
|
||||
Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`)
|
||||
const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path)
|
||||
if (successfullyDeleted) {
|
||||
libraryItem.media.removeEpisode(oldestEpisode.id)
|
||||
libraryItem.removeLibraryFile(oldestEpisode.audioFile.ino)
|
||||
return true
|
||||
return oldestEpisode
|
||||
} else {
|
||||
Logger.warn(`[PodcastManager] Failed to remove oldest episode "${oldestEpisode.title}"`)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async getLibraryFile(path, relPath) {
|
||||
var newLibFile = new LibraryFile()
|
||||
await newLibFile.setDataFromPath(path, relPath)
|
||||
return newLibFile
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {LibraryFile} libraryFile
|
||||
* @returns {Promise<AudioFile|null>}
|
||||
*/
|
||||
async probeAudioFile(libraryFile) {
|
||||
const path = libraryFile.metadata.path
|
||||
const mediaProbeData = await prober.probe(path)
|
||||
if (mediaProbeData.error) {
|
||||
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
|
||||
return false
|
||||
return null
|
||||
}
|
||||
const newAudioFile = new AudioFile()
|
||||
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
|
||||
@@ -263,18 +299,23 @@ class PodcastManager {
|
||||
return newAudioFile
|
||||
}
|
||||
|
||||
// Returns false if auto download episodes was disabled (disabled if reaches max failed checks)
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @returns {Promise<boolean>} - Returns false if auto download episodes was disabled (disabled if reaches max failed checks)
|
||||
*/
|
||||
async runEpisodeCheck(libraryItem) {
|
||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
|
||||
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
|
||||
const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
|
||||
const latestEpisodePublishedAt = libraryItem.media.getLatestEpisodePublishedAt()
|
||||
|
||||
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
|
||||
// lastEpisodeCheckDate will be the current time when adding a new podcast
|
||||
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
|
||||
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
|
||||
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" | Last check: ${new Date(lastEpisodeCheck)} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
|
||||
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)
|
||||
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheck
|
||||
// lastEpisodeCheck will be the current time when adding a new podcast
|
||||
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheck
|
||||
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
|
||||
|
||||
const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)
|
||||
Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`)
|
||||
|
||||
if (!newEpisodes) {
|
||||
@@ -283,37 +324,48 @@ class PodcastManager {
|
||||
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
|
||||
this.failedCheckMap[libraryItem.id]++
|
||||
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
|
||||
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`)
|
||||
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`)
|
||||
libraryItem.media.autoDownloadEpisodes = false
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
} else {
|
||||
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
|
||||
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}"`)
|
||||
}
|
||||
} else if (newEpisodes.length) {
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`)
|
||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes, true)
|
||||
} else {
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
|
||||
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.title}"`)
|
||||
}
|
||||
|
||||
libraryItem.media.lastEpisodeCheck = Date.now()
|
||||
libraryItem.updatedAt = Date.now()
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
libraryItem.media.lastEpisodeCheck = new Date()
|
||||
await libraryItem.media.save()
|
||||
|
||||
libraryItem.changed('updatedAt', true)
|
||||
await libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
|
||||
return libraryItem.media.autoDownloadEpisodes
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} podcastLibraryItem
|
||||
* @param {number} dateToCheckForEpisodesAfter - Unix timestamp
|
||||
* @param {number} maxNewEpisodes
|
||||
* @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]|null>}
|
||||
*/
|
||||
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) {
|
||||
if (!podcastLibraryItem.media.metadata.feedUrl) {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
|
||||
return false
|
||||
if (!podcastLibraryItem.media.feedURL) {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`)
|
||||
return null
|
||||
}
|
||||
const feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
|
||||
const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL)
|
||||
if (!feed?.episodes) {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed)
|
||||
return false
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed)
|
||||
return null
|
||||
}
|
||||
|
||||
// Filter new and not already has
|
||||
@@ -326,23 +378,34 @@ class PodcastManager {
|
||||
return newEpisodes
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {*} maxEpisodesToDownload
|
||||
* @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]>}
|
||||
*/
|
||||
async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) {
|
||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck, maxEpisodesToDownload)
|
||||
if (newEpisodes.length) {
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
||||
const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
|
||||
const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem.media.lastEpisodeCheck : 'Never'
|
||||
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||
|
||||
const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, lastEpisodeCheck, maxEpisodesToDownload)
|
||||
if (newEpisodes?.length) {
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`)
|
||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes, false)
|
||||
} else {
|
||||
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.metadata.title}"`)
|
||||
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.title}"`)
|
||||
}
|
||||
|
||||
libraryItem.media.lastEpisodeCheck = Date.now()
|
||||
libraryItem.updatedAt = Date.now()
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
libraryItem.media.lastEpisodeCheck = new Date()
|
||||
await libraryItem.media.save()
|
||||
|
||||
return newEpisodes
|
||||
libraryItem.changed('updatedAt', true)
|
||||
await libraryItem.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
|
||||
return newEpisodes || []
|
||||
}
|
||||
|
||||
async findEpisode(rssFeedUrl, searchTitle) {
|
||||
@@ -518,64 +581,123 @@ class PodcastManager {
|
||||
continue
|
||||
}
|
||||
|
||||
const newPodcastMetadata = {
|
||||
title: feed.metadata.title,
|
||||
author: feed.metadata.author,
|
||||
description: feed.metadata.description,
|
||||
releaseDate: '',
|
||||
genres: [...feed.metadata.categories],
|
||||
feedUrl: feed.metadata.feedUrl,
|
||||
imageUrl: feed.metadata.image,
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
language: '',
|
||||
numEpisodes: feed.numEpisodes
|
||||
}
|
||||
let newLibraryItem = null
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
||||
|
||||
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
||||
const libraryItemPayload = {
|
||||
path: podcastPath,
|
||||
relPath: podcastFilename,
|
||||
folderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
ino: libraryItemFolderStats.ino,
|
||||
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
||||
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
||||
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
||||
media: {
|
||||
metadata: newPodcastMetadata,
|
||||
autoDownloadEpisodes
|
||||
const podcastPayload = {
|
||||
autoDownloadEpisodes,
|
||||
metadata: {
|
||||
title: feed.metadata.title,
|
||||
author: feed.metadata.author,
|
||||
description: feed.metadata.description,
|
||||
releaseDate: '',
|
||||
genres: [...feed.metadata.categories],
|
||||
feedUrl: feed.metadata.feedUrl,
|
||||
imageUrl: feed.metadata.image,
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
language: '',
|
||||
numEpisodes: feed.numEpisodes
|
||||
}
|
||||
}
|
||||
const podcast = await Database.podcastModel.createFromRequest(podcastPayload, transaction)
|
||||
|
||||
newLibraryItem = await Database.libraryItemModel.create(
|
||||
{
|
||||
ino: libraryItemFolderStats.ino,
|
||||
path: podcastPath,
|
||||
relPath: podcastFilename,
|
||||
mediaId: podcast.id,
|
||||
mediaType: 'podcast',
|
||||
isFile: false,
|
||||
isMissing: false,
|
||||
isInvalid: false,
|
||||
mtime: libraryItemFolderStats.mtimeMs || 0,
|
||||
ctime: libraryItemFolderStats.ctimeMs || 0,
|
||||
birthtime: libraryItemFolderStats.birthtimeMs || 0,
|
||||
size: 0,
|
||||
libraryFiles: [],
|
||||
extraData: {},
|
||||
libraryId: folder.libraryId,
|
||||
libraryFolderId: folder.id
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
await transaction.commit()
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast library item for "${feed.metadata.title}"`, error)
|
||||
const taskTitleStringFeed = {
|
||||
text: 'OPML import feed',
|
||||
key: 'MessageTaskOpmlImportFeed'
|
||||
}
|
||||
const taskDescriptionStringPodcast = {
|
||||
text: `Creating podcast "${feed.metadata.title}"`,
|
||||
key: 'MessageTaskOpmlImportFeedPodcastDescription',
|
||||
subs: [feed.metadata.title]
|
||||
}
|
||||
const taskErrorString = {
|
||||
text: 'Failed to create podcast library item',
|
||||
key: 'MessageTaskOpmlImportFeedPodcastFailed'
|
||||
}
|
||||
TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString)
|
||||
continue
|
||||
}
|
||||
|
||||
const libraryItem = new LibraryItem()
|
||||
libraryItem.setData('podcast', libraryItemPayload)
|
||||
newLibraryItem.media = await newLibraryItem.getMediaExpanded()
|
||||
|
||||
// Download and save cover image
|
||||
if (newPodcastMetadata.imageUrl) {
|
||||
// TODO: Scan cover image to library files
|
||||
if (typeof feed.metadata.image === 'string' && feed.metadata.image.startsWith('http')) {
|
||||
// Podcast cover will always go into library item folder
|
||||
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, newPodcastMetadata.imageUrl, true)
|
||||
if (coverResponse) {
|
||||
if (coverResponse.error) {
|
||||
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Download cover error from "${newPodcastMetadata.imageUrl}": ${coverResponse.error}`)
|
||||
} else if (coverResponse.cover) {
|
||||
libraryItem.media.coverPath = coverResponse.cover
|
||||
const coverResponse = await CoverManager.downloadCoverFromUrlNew(feed.metadata.image, newLibraryItem.id, newLibraryItem.path, true)
|
||||
if (coverResponse.error) {
|
||||
Logger.error(`[PodcastManager] Download cover error from "${feed.metadata.image}": ${coverResponse.error}`)
|
||||
} else if (coverResponse.cover) {
|
||||
const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
|
||||
if (!coverImageFileStats) {
|
||||
Logger.error(`[PodcastManager] Failed to get cover image stats for "${coverResponse.cover}"`)
|
||||
} else {
|
||||
// Add libraryFile to libraryItem and coverPath to podcast
|
||||
const newLibraryFile = {
|
||||
ino: coverImageFileStats.ino,
|
||||
fileType: 'image',
|
||||
addedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
metadata: {
|
||||
filename: Path.basename(coverResponse.cover),
|
||||
ext: Path.extname(coverResponse.cover).slice(1),
|
||||
path: coverResponse.cover,
|
||||
relPath: Path.basename(coverResponse.cover),
|
||||
size: coverImageFileStats.size,
|
||||
mtimeMs: coverImageFileStats.mtimeMs || 0,
|
||||
ctimeMs: coverImageFileStats.ctimeMs || 0,
|
||||
birthtimeMs: coverImageFileStats.birthtimeMs || 0
|
||||
}
|
||||
}
|
||||
newLibraryItem.libraryFiles.push(newLibraryFile)
|
||||
newLibraryItem.changed('libraryFiles', true)
|
||||
await newLibraryItem.save()
|
||||
|
||||
newLibraryItem.media.coverPath = coverResponse.cover
|
||||
await newLibraryItem.media.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Database.createLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
|
||||
SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
|
||||
|
||||
// Turn on podcast auto download cron if not already on
|
||||
if (libraryItem.media.autoDownloadEpisodes) {
|
||||
cronManager.checkUpdatePodcastCron(libraryItem)
|
||||
if (newLibraryItem.media.autoDownloadEpisodes) {
|
||||
cronManager.checkUpdatePodcastCron(newLibraryItem)
|
||||
}
|
||||
|
||||
numPodcastsAdded++
|
||||
}
|
||||
|
||||
const taskFinishedString = {
|
||||
text: `Added ${numPodcastsAdded} podcasts`,
|
||||
key: 'MessageTaskOpmlImportFinished',
|
||||
@@ -586,4 +708,4 @@ class PodcastManager {
|
||||
Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`)
|
||||
}
|
||||
}
|
||||
module.exports = new PodcastManager()
|
||||
module.exports = PodcastManager
|
||||
|
||||
@@ -98,11 +98,22 @@ class RssFeedManager {
|
||||
podcastId: feed.entity.mediaId
|
||||
},
|
||||
attributes: ['id', 'updatedAt'],
|
||||
order: [['createdAt', 'DESC']]
|
||||
order: [['updatedAt', 'DESC']]
|
||||
})
|
||||
|
||||
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
|
||||
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt
|
||||
}
|
||||
} else {
|
||||
const book = await Database.bookModel.findOne({
|
||||
where: {
|
||||
id: feed.entity.mediaId
|
||||
},
|
||||
attributes: ['id', 'updatedAt']
|
||||
})
|
||||
if (book && book.updatedAt > newEntityUpdatedAt) {
|
||||
newEntityUpdatedAt = book.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
return newEntityUpdatedAt > feed.entityUpdatedAt
|
||||
@@ -111,7 +122,7 @@ class RssFeedManager {
|
||||
attributes: ['id', 'updatedAt'],
|
||||
include: {
|
||||
model: Database.bookModel,
|
||||
attributes: ['id'],
|
||||
attributes: ['id', 'audioFiles', 'updatedAt'],
|
||||
through: {
|
||||
attributes: []
|
||||
},
|
||||
@@ -122,13 +133,16 @@ class RssFeedManager {
|
||||
}
|
||||
})
|
||||
|
||||
const totalBookTracks = feed.entity.books.reduce((total, book) => total + book.includedAudioFiles.length, 0)
|
||||
if (feed.feedEpisodes.length !== totalBookTracks) {
|
||||
return true
|
||||
}
|
||||
|
||||
let newEntityUpdatedAt = feed.entity.updatedAt
|
||||
|
||||
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
|
||||
if (book.libraryItem.updatedAt > mostRecent) {
|
||||
return book.libraryItem.updatedAt
|
||||
}
|
||||
return mostRecent
|
||||
let updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
|
||||
return updatedAt > mostRecent ? updatedAt : mostRecent
|
||||
}, 0)
|
||||
|
||||
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
|
||||
@@ -151,6 +165,9 @@ class RssFeedManager {
|
||||
let feed = await Database.feedModel.findOne({
|
||||
where: {
|
||||
slug: req.params.slug
|
||||
},
|
||||
include: {
|
||||
model: Database.feedEpisodeModel
|
||||
}
|
||||
})
|
||||
if (!feed) {
|
||||
@@ -163,8 +180,6 @@ class RssFeedManager {
|
||||
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(req.originalHostPrefix)
|
||||
|
||||
@@ -12,3 +12,4 @@ Please add a record of every database migration that you create to this file. Th
|
||||
| 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 |
|
||||
| v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table |
|
||||
| v2.17.7 | v2.17.7-add-indices | Adds indices to the libraryItems and books tables to reduce query times |
|
||||
|
||||
83
server/migrations/v2.17.7-add-indices.js
Normal file
83
server/migrations/v2.17.7-add-indices.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @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.7'
|
||||
const migrationName = `${migrationVersion}-add-indices`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This upward migration adds some indices to the libraryItems and books tables to improve query performance
|
||||
*
|
||||
* @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}`)
|
||||
|
||||
await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])
|
||||
await addIndex(queryInterface, logger, 'books', ['duration'])
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This downward migration script removes the indices added in the upward migration script
|
||||
*
|
||||
* @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}`)
|
||||
|
||||
await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])
|
||||
await removeIndex(queryInterface, logger, 'books', ['duration'])
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to add an index to a table. If the index already exists, it logs a message and continues.
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface
|
||||
* @param {import ('../Logger')} logger
|
||||
* @param {string} tableName
|
||||
* @param {string[]} columns
|
||||
*/
|
||||
async function addIndex(queryInterface, logger, tableName, columns) {
|
||||
try {
|
||||
logger.info(`${loggerPrefix} adding index [${columns.join(', ')}] to table "${tableName}"`)
|
||||
await queryInterface.addIndex(tableName, columns)
|
||||
logger.info(`${loggerPrefix} added index [${columns.join(', ')}] to table "${tableName}"`)
|
||||
} catch (error) {
|
||||
if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) {
|
||||
logger.info(`${loggerPrefix} index [${columns.join(', ')}] for table "${tableName}" already exists`)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to remove an index from a table.
|
||||
* Sequelize implemets it using DROP INDEX IF EXISTS, so it won't throw an error if the index doesn't exist.
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface
|
||||
* @param {import ('../Logger')} logger
|
||||
* @param {string} tableName
|
||||
* @param {string[]} columns
|
||||
*/
|
||||
async function removeIndex(queryInterface, logger, tableName, columns) {
|
||||
logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`)
|
||||
await queryInterface.removeIndex(tableName, columns)
|
||||
logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* @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.18.0'
|
||||
const migrationName = `${migrationVersion}-add-plugins-table`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This upward migration creates the plugins table if it does not exist.
|
||||
*
|
||||
* @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}`)
|
||||
|
||||
if (!(await queryInterface.tableExists('plugins'))) {
|
||||
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
||||
await queryInterface.createTable('plugins', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
version: DataTypes.STRING,
|
||||
isMissing: DataTypes.BOOLEAN,
|
||||
config: DataTypes.JSON,
|
||||
extraData: DataTypes.JSON,
|
||||
createdAt: DataTypes.DATE,
|
||||
updatedAt: DataTypes.DATE
|
||||
})
|
||||
logger.info(`${loggerPrefix} Table 'plugins' created`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} Table 'plugins' already exists`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This downward migration script drops the plugins table if it exists.
|
||||
*
|
||||
* @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}`)
|
||||
|
||||
if (await queryInterface.tableExists('plugins')) {
|
||||
await queryInterface.dropTable('plugins')
|
||||
logger.info(`${loggerPrefix} Table 'plugins' dropped`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} Table 'plugins' does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
@@ -107,6 +107,22 @@ class Author extends Model {
|
||||
return libraryItems
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {string} libraryId
|
||||
* @returns {Promise<Author>}
|
||||
*/
|
||||
static async findOrCreateByNameAndLibrary(name, libraryId) {
|
||||
const author = await this.getByNameAndLibrary(name, libraryId)
|
||||
if (author) return author
|
||||
return this.create({
|
||||
name,
|
||||
lastFirst: this.getLastFirst(name),
|
||||
libraryId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
|
||||
const parseNameString = require('../utils/parsers/parseNameString')
|
||||
|
||||
/**
|
||||
* @typedef EBookFileObject
|
||||
@@ -60,6 +62,13 @@ const Logger = require('../Logger')
|
||||
* @property {ChapterObject[]} chapters
|
||||
* @property {Object} metaTags
|
||||
* @property {string} mimeType
|
||||
*
|
||||
* @typedef AudioTrackProperties
|
||||
* @property {string} title
|
||||
* @property {string} contentUrl
|
||||
* @property {number} startOffset
|
||||
*
|
||||
* @typedef {AudioFileObject & AudioTrackProperties} AudioTrack
|
||||
*/
|
||||
|
||||
class Book extends Model {
|
||||
@@ -113,158 +122,12 @@ class Book extends Model {
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./Author')[]} - optional if expanded */
|
||||
this.authors
|
||||
}
|
||||
|
||||
static getOldBook(libraryItemExpanded) {
|
||||
const bookExpanded = libraryItemExpanded.media
|
||||
let authors = []
|
||||
if (bookExpanded.authors?.length) {
|
||||
authors = bookExpanded.authors.map((au) => {
|
||||
return {
|
||||
id: au.id,
|
||||
name: au.name
|
||||
}
|
||||
})
|
||||
} else if (bookExpanded.bookAuthors?.length) {
|
||||
authors = bookExpanded.bookAuthors
|
||||
.map((ba) => {
|
||||
if (ba.author) {
|
||||
return {
|
||||
id: ba.author.id,
|
||||
name: ba.author.name
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba)
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((a) => a)
|
||||
}
|
||||
|
||||
let series = []
|
||||
if (bookExpanded.series?.length) {
|
||||
series = bookExpanded.series.map((se) => {
|
||||
return {
|
||||
id: se.id,
|
||||
name: se.name,
|
||||
sequence: se.bookSeries.sequence
|
||||
}
|
||||
})
|
||||
} else if (bookExpanded.bookSeries?.length) {
|
||||
series = bookExpanded.bookSeries
|
||||
.map((bs) => {
|
||||
if (bs.series) {
|
||||
return {
|
||||
id: bs.series.id,
|
||||
name: bs.series.name,
|
||||
sequence: bs.sequence
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs)
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((s) => s)
|
||||
}
|
||||
|
||||
return {
|
||||
id: bookExpanded.id,
|
||||
libraryItemId: libraryItemExpanded.id,
|
||||
coverPath: bookExpanded.coverPath,
|
||||
tags: bookExpanded.tags,
|
||||
audioFiles: bookExpanded.audioFiles,
|
||||
chapters: bookExpanded.chapters,
|
||||
ebookFile: bookExpanded.ebookFile,
|
||||
metadata: {
|
||||
title: bookExpanded.title,
|
||||
subtitle: bookExpanded.subtitle,
|
||||
authors: authors,
|
||||
narrators: bookExpanded.narrators,
|
||||
series: series,
|
||||
genres: bookExpanded.genres,
|
||||
publishedYear: bookExpanded.publishedYear,
|
||||
publishedDate: bookExpanded.publishedDate,
|
||||
publisher: bookExpanded.publisher,
|
||||
description: bookExpanded.description,
|
||||
isbn: bookExpanded.isbn,
|
||||
asin: bookExpanded.asin,
|
||||
language: bookExpanded.language,
|
||||
explicit: bookExpanded.explicit,
|
||||
abridged: bookExpanded.abridged
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} oldBook
|
||||
* @returns {boolean} true if updated
|
||||
*/
|
||||
static saveFromOld(oldBook) {
|
||||
const book = this.getFromOld(oldBook)
|
||||
return this.update(book, {
|
||||
where: {
|
||||
id: book.id
|
||||
}
|
||||
})
|
||||
.then((result) => result[0] > 0)
|
||||
.catch((error) => {
|
||||
Logger.error(`[Book] Failed to save book ${book.id}`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
static getFromOld(oldBook) {
|
||||
return {
|
||||
id: oldBook.id,
|
||||
title: oldBook.metadata.title,
|
||||
titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix,
|
||||
subtitle: oldBook.metadata.subtitle,
|
||||
publishedYear: oldBook.metadata.publishedYear,
|
||||
publishedDate: oldBook.metadata.publishedDate,
|
||||
publisher: oldBook.metadata.publisher,
|
||||
description: oldBook.metadata.description,
|
||||
isbn: oldBook.metadata.isbn,
|
||||
asin: oldBook.metadata.asin,
|
||||
language: oldBook.metadata.language,
|
||||
explicit: !!oldBook.metadata.explicit,
|
||||
abridged: !!oldBook.metadata.abridged,
|
||||
narrators: oldBook.metadata.narrators,
|
||||
ebookFile: oldBook.ebookFile?.toJSON() || null,
|
||||
coverPath: oldBook.coverPath,
|
||||
duration: oldBook.duration,
|
||||
audioFiles: oldBook.audioFiles?.map((af) => af.toJSON()) || [],
|
||||
chapters: oldBook.chapters,
|
||||
tags: oldBook.tags,
|
||||
genres: oldBook.metadata.genres
|
||||
}
|
||||
}
|
||||
|
||||
getAbsMetadataJson() {
|
||||
return {
|
||||
tags: this.tags || [],
|
||||
chapters: this.chapters?.map((c) => ({ ...c })) || [],
|
||||
title: this.title,
|
||||
subtitle: this.subtitle,
|
||||
authors: this.authors.map((a) => a.name),
|
||||
narrators: this.narrators,
|
||||
series: this.series.map((se) => {
|
||||
const sequence = se.bookSeries?.sequence || ''
|
||||
if (!sequence) return se.name
|
||||
return `${se.name} #${sequence}`
|
||||
}),
|
||||
genres: this.genres || [],
|
||||
publishedYear: this.publishedYear,
|
||||
publishedDate: this.publishedDate,
|
||||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
isbn: this.isbn,
|
||||
asin: this.asin,
|
||||
language: this.language,
|
||||
explicit: !!this.explicit,
|
||||
abridged: !!this.abridged
|
||||
}
|
||||
/** @type {import('./Series')[]} - optional if expanded */
|
||||
this.series
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,10 +184,10 @@ class Book extends Model {
|
||||
// },
|
||||
{
|
||||
fields: ['publishedYear']
|
||||
},
|
||||
{
|
||||
fields: ['duration']
|
||||
}
|
||||
// {
|
||||
// fields: ['duration']
|
||||
// }
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -343,18 +206,459 @@ class Book extends Model {
|
||||
}
|
||||
return this.authors.map((au) => au.name).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Comma separated array of author names in Last, First format
|
||||
* Requires authors to be loaded
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
get authorNameLF() {
|
||||
if (this.authors === undefined) {
|
||||
Logger.error(`[Book] authorNameLF: Cannot get authorNameLF because authors are not loaded`)
|
||||
return ''
|
||||
}
|
||||
|
||||
// Last, First
|
||||
if (!this.authors.length) return ''
|
||||
return this.authors.map((au) => parseNameString.nameToLastFirst(au.name)).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Comma separated array of series with sequence
|
||||
* Requires series to be loaded
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
get seriesName() {
|
||||
if (this.series === undefined) {
|
||||
Logger.error(`[Book] seriesName: Cannot get seriesName because series are not loaded`)
|
||||
return ''
|
||||
}
|
||||
|
||||
if (!this.series.length) return ''
|
||||
return this.series
|
||||
.map((se) => {
|
||||
const sequence = se.bookSeries?.sequence || ''
|
||||
if (!sequence) return se.name
|
||||
return `${se.name} #${sequence}`
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
get includedAudioFiles() {
|
||||
return this.audioFiles.filter((af) => !af.exclude)
|
||||
}
|
||||
get trackList() {
|
||||
|
||||
get hasMediaFiles() {
|
||||
return !!this.hasAudioTracks || !!this.ebookFile
|
||||
}
|
||||
|
||||
get hasAudioTracks() {
|
||||
return !!this.includedAudioFiles.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported mime types are sent from the web client and are retrieved using the browser Audio player "canPlayType" function.
|
||||
*
|
||||
* @param {string[]} supportedMimeTypes
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkCanDirectPlay(supportedMimeTypes) {
|
||||
if (!Array.isArray(supportedMimeTypes)) {
|
||||
Logger.error(`[Book] checkCanDirectPlay: supportedMimeTypes is not an array`, supportedMimeTypes)
|
||||
return false
|
||||
}
|
||||
return this.includedAudioFiles.every((af) => supportedMimeTypes.includes(af.mimeType))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the track list to be used in client audio players
|
||||
* AudioTrack is the AudioFile with startOffset, contentUrl and title
|
||||
*
|
||||
* @param {string} libraryItemId
|
||||
* @returns {AudioTrack[]}
|
||||
*/
|
||||
getTracklist(libraryItemId) {
|
||||
let startOffset = 0
|
||||
return this.includedAudioFiles.map((af) => {
|
||||
const track = structuredClone(af)
|
||||
track.title = af.metadata.filename
|
||||
track.startOffset = startOffset
|
||||
track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}`
|
||||
startOffset += track.duration
|
||||
return track
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {ChapterObject[]}
|
||||
*/
|
||||
getChapters() {
|
||||
return structuredClone(this.chapters) || []
|
||||
}
|
||||
|
||||
getPlaybackTitle() {
|
||||
return this.title
|
||||
}
|
||||
|
||||
getPlaybackAuthor() {
|
||||
return this.authorName
|
||||
}
|
||||
|
||||
getPlaybackDuration() {
|
||||
return this.duration
|
||||
}
|
||||
|
||||
/**
|
||||
* Total file size of all audio files and ebook file
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
get size() {
|
||||
let total = 0
|
||||
this.audioFiles.forEach((af) => (total += af.metadata.size))
|
||||
if (this.ebookFile) {
|
||||
total += this.ebookFile.metadata.size
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
getAbsMetadataJson() {
|
||||
return {
|
||||
tags: this.tags || [],
|
||||
chapters: this.chapters?.map((c) => ({ ...c })) || [],
|
||||
title: this.title,
|
||||
subtitle: this.subtitle,
|
||||
authors: this.authors.map((a) => a.name),
|
||||
narrators: this.narrators,
|
||||
series: this.series.map((se) => {
|
||||
const sequence = se.bookSeries?.sequence || ''
|
||||
if (!sequence) return se.name
|
||||
return `${se.name} #${sequence}`
|
||||
}),
|
||||
genres: this.genres || [],
|
||||
publishedYear: this.publishedYear,
|
||||
publishedDate: this.publishedDate,
|
||||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
isbn: this.isbn,
|
||||
asin: this.asin,
|
||||
language: this.language,
|
||||
explicit: !!this.explicit,
|
||||
abridged: !!this.abridged
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} payload - old book object
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async updateFromRequest(payload) {
|
||||
if (!payload) return false
|
||||
|
||||
let hasUpdates = false
|
||||
|
||||
if (payload.metadata) {
|
||||
const metadataStringKeys = ['title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language']
|
||||
metadataStringKeys.forEach((key) => {
|
||||
if (typeof payload.metadata[key] === 'string' && this[key] !== payload.metadata[key]) {
|
||||
this[key] = payload.metadata[key] || null
|
||||
|
||||
if (key === 'title') {
|
||||
this.titleIgnorePrefix = getTitleIgnorePrefix(this.title)
|
||||
}
|
||||
|
||||
hasUpdates = true
|
||||
}
|
||||
})
|
||||
if (payload.metadata.explicit !== undefined && this.explicit !== !!payload.metadata.explicit) {
|
||||
this.explicit = !!payload.metadata.explicit
|
||||
hasUpdates = true
|
||||
}
|
||||
if (payload.metadata.abridged !== undefined && this.abridged !== !!payload.metadata.abridged) {
|
||||
this.abridged = !!payload.metadata.abridged
|
||||
hasUpdates = true
|
||||
}
|
||||
const arrayOfStringsKeys = ['narrators', 'genres']
|
||||
arrayOfStringsKeys.forEach((key) => {
|
||||
if (Array.isArray(payload.metadata[key]) && !payload.metadata[key].some((item) => typeof item !== 'string') && JSON.stringify(this[key]) !== JSON.stringify(payload.metadata[key])) {
|
||||
this[key] = payload.metadata[key]
|
||||
this.changed(key, true)
|
||||
hasUpdates = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.tags) && !payload.tags.some((tag) => typeof tag !== 'string') && JSON.stringify(this.tags) !== JSON.stringify(payload.tags)) {
|
||||
this.tags = payload.tags
|
||||
this.changed('tags', true)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// TODO: Remove support for updating audioFiles, chapters and ebookFile here
|
||||
const arrayOfObjectsKeys = ['audioFiles', 'chapters']
|
||||
arrayOfObjectsKeys.forEach((key) => {
|
||||
if (Array.isArray(payload[key]) && !payload[key].some((item) => typeof item !== 'object') && JSON.stringify(this[key]) !== JSON.stringify(payload[key])) {
|
||||
this[key] = payload[key]
|
||||
this.changed(key, true)
|
||||
hasUpdates = true
|
||||
}
|
||||
})
|
||||
if (payload.ebookFile && JSON.stringify(this.ebookFile) !== JSON.stringify(payload.ebookFile)) {
|
||||
this.ebookFile = payload.ebookFile
|
||||
this.changed('ebookFile', true)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
Logger.debug(`[Book] "${this.title}" changed keys:`, this.changed())
|
||||
await this.save()
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or removes authors from the book using the author names from the request
|
||||
*
|
||||
* @param {string[]} authors
|
||||
* @param {string} libraryId
|
||||
* @returns {Promise<{authorsRemoved: import('./Author')[], authorsAdded: import('./Author')[]}>}
|
||||
*/
|
||||
async updateAuthorsFromRequest(authors, libraryId) {
|
||||
if (!Array.isArray(authors)) return null
|
||||
|
||||
if (!this.authors) {
|
||||
throw new Error(`[Book] Cannot update authors because authors are not loaded for book ${this.id}`)
|
||||
}
|
||||
|
||||
/** @type {typeof import('./Author')} */
|
||||
const authorModel = this.sequelize.models.author
|
||||
|
||||
/** @type {typeof import('./BookAuthor')} */
|
||||
const bookAuthorModel = this.sequelize.models.bookAuthor
|
||||
|
||||
const authorsCleaned = authors.map((a) => a.toLowerCase()).filter((a) => a)
|
||||
const authorsRemoved = this.authors.filter((au) => !authorsCleaned.includes(au.name.toLowerCase()))
|
||||
const newAuthorNames = authors.filter((a) => !this.authors.some((au) => au.name.toLowerCase() === a.toLowerCase()))
|
||||
|
||||
for (const author of authorsRemoved) {
|
||||
await bookAuthorModel.removeByIds(author.id, this.id)
|
||||
Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`)
|
||||
this.authors = this.authors.filter((au) => au.id !== author.id)
|
||||
}
|
||||
const authorsAdded = []
|
||||
for (const authorName of newAuthorNames) {
|
||||
const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
|
||||
await bookAuthorModel.create({ bookId: this.id, authorId: author.id })
|
||||
Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`)
|
||||
this.authors.push(author)
|
||||
authorsAdded.push(author)
|
||||
}
|
||||
|
||||
return {
|
||||
authorsRemoved,
|
||||
authorsAdded
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or removes series from the book using the series names from the request.
|
||||
* Updates series sequence if it has changed.
|
||||
*
|
||||
* @param {{ name: string, sequence: string }[]} seriesObjects
|
||||
* @param {string} libraryId
|
||||
* @returns {Promise<{seriesRemoved: import('./Series')[], seriesAdded: import('./Series')[], hasUpdates: boolean}>}
|
||||
*/
|
||||
async updateSeriesFromRequest(seriesObjects, libraryId) {
|
||||
if (!Array.isArray(seriesObjects) || seriesObjects.some((se) => !se.name || typeof se.name !== 'string')) return null
|
||||
|
||||
if (!this.series) {
|
||||
throw new Error(`[Book] Cannot update series because series are not loaded for book ${this.id}`)
|
||||
}
|
||||
|
||||
/** @type {typeof import('./Series')} */
|
||||
const seriesModel = this.sequelize.models.series
|
||||
|
||||
/** @type {typeof import('./BookSeries')} */
|
||||
const bookSeriesModel = this.sequelize.models.bookSeries
|
||||
|
||||
const seriesNamesCleaned = seriesObjects.map((se) => se.name.toLowerCase())
|
||||
const seriesRemoved = this.series.filter((se) => !seriesNamesCleaned.includes(se.name.toLowerCase()))
|
||||
const seriesAdded = []
|
||||
let hasUpdates = false
|
||||
for (const seriesObj of seriesObjects) {
|
||||
const seriesObjSequence = typeof seriesObj.sequence === 'string' ? seriesObj.sequence : null
|
||||
|
||||
const existingSeries = this.series.find((se) => se.name.toLowerCase() === seriesObj.name.toLowerCase())
|
||||
if (existingSeries) {
|
||||
if (existingSeries.bookSeries.sequence !== seriesObjSequence) {
|
||||
existingSeries.bookSeries.sequence = seriesObjSequence
|
||||
await existingSeries.bookSeries.save()
|
||||
hasUpdates = true
|
||||
Logger.debug(`[Book] "${this.title}" Updated series "${existingSeries.name}" sequence ${seriesObjSequence}`)
|
||||
}
|
||||
} else {
|
||||
const series = await seriesModel.findOrCreateByNameAndLibrary(seriesObj.name, libraryId)
|
||||
series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence })
|
||||
this.series.push(series)
|
||||
seriesAdded.push(series)
|
||||
hasUpdates = true
|
||||
Logger.debug(`[Book] "${this.title}" Added series "${series.name}"`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const series of seriesRemoved) {
|
||||
await bookSeriesModel.removeByIds(series.id, this.id)
|
||||
this.series = this.series.filter((se) => se.id !== series.id)
|
||||
Logger.debug(`[Book] "${this.title}" Removed series ${series.id}`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
return {
|
||||
seriesRemoved,
|
||||
seriesAdded,
|
||||
hasUpdates
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Old model kept metadata in a separate object
|
||||
*/
|
||||
oldMetadataToJSON() {
|
||||
const authors = this.authors.map((au) => ({ id: au.id, name: au.name }))
|
||||
const series = this.series.map((se) => ({ id: se.id, name: se.name, sequence: se.bookSeries.sequence }))
|
||||
return {
|
||||
title: this.title,
|
||||
subtitle: this.subtitle,
|
||||
authors,
|
||||
narrators: [...(this.narrators || [])],
|
||||
series,
|
||||
genres: [...(this.genres || [])],
|
||||
publishedYear: this.publishedYear,
|
||||
publishedDate: this.publishedDate,
|
||||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
isbn: this.isbn,
|
||||
asin: this.asin,
|
||||
language: this.language,
|
||||
explicit: this.explicit,
|
||||
abridged: this.abridged
|
||||
}
|
||||
}
|
||||
|
||||
oldMetadataToJSONMinified() {
|
||||
return {
|
||||
title: this.title,
|
||||
titleIgnorePrefix: getTitlePrefixAtEnd(this.title),
|
||||
subtitle: this.subtitle,
|
||||
authorName: this.authorName,
|
||||
authorNameLF: this.authorNameLF,
|
||||
narratorName: (this.narrators || []).join(', '),
|
||||
seriesName: this.seriesName,
|
||||
genres: [...(this.genres || [])],
|
||||
publishedYear: this.publishedYear,
|
||||
publishedDate: this.publishedDate,
|
||||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
isbn: this.isbn,
|
||||
asin: this.asin,
|
||||
language: this.language,
|
||||
explicit: this.explicit,
|
||||
abridged: this.abridged
|
||||
}
|
||||
}
|
||||
|
||||
oldMetadataToJSONExpanded() {
|
||||
const oldMetadataJSON = this.oldMetadataToJSON()
|
||||
oldMetadataJSON.titleIgnorePrefix = getTitlePrefixAtEnd(this.title)
|
||||
oldMetadataJSON.authorName = this.authorName
|
||||
oldMetadataJSON.authorNameLF = this.authorNameLF
|
||||
oldMetadataJSON.narratorName = (this.narrators || []).join(', ')
|
||||
oldMetadataJSON.seriesName = this.seriesName
|
||||
return oldMetadataJSON
|
||||
}
|
||||
|
||||
/**
|
||||
* The old model stored a minified series and authors array with the book object.
|
||||
* Minified series is { id, name, sequence }
|
||||
* Minified author is { id, name }
|
||||
*
|
||||
* @param {string} libraryItemId
|
||||
*/
|
||||
toOldJSON(libraryItemId) {
|
||||
if (!libraryItemId) {
|
||||
throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)
|
||||
}
|
||||
if (!this.authors) {
|
||||
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
|
||||
}
|
||||
if (!this.series) {
|
||||
throw new Error(`[Book] Cannot convert to old JSON because series are not loaded`)
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
libraryItemId: libraryItemId,
|
||||
metadata: this.oldMetadataToJSON(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...(this.tags || [])],
|
||||
audioFiles: structuredClone(this.audioFiles),
|
||||
chapters: structuredClone(this.chapters),
|
||||
ebookFile: structuredClone(this.ebookFile)
|
||||
}
|
||||
}
|
||||
|
||||
toOldJSONMinified() {
|
||||
if (!this.authors) {
|
||||
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
|
||||
}
|
||||
if (!this.series) {
|
||||
throw new Error(`[Book] Cannot convert to old JSON because series are not loaded`)
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
metadata: this.oldMetadataToJSONMinified(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...(this.tags || [])],
|
||||
numTracks: this.includedAudioFiles.length,
|
||||
numAudioFiles: this.audioFiles?.length || 0,
|
||||
numChapters: this.chapters?.length || 0,
|
||||
duration: this.duration,
|
||||
size: this.size,
|
||||
ebookFormat: this.ebookFile?.ebookFormat
|
||||
}
|
||||
}
|
||||
|
||||
toOldJSONExpanded(libraryItemId) {
|
||||
if (!libraryItemId) {
|
||||
throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)
|
||||
}
|
||||
if (!this.authors) {
|
||||
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
|
||||
}
|
||||
if (!this.series) {
|
||||
throw new Error(`[Book] Cannot convert to old JSON because series are not loaded`)
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
libraryItemId: libraryItemId,
|
||||
metadata: this.oldMetadataToJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...(this.tags || [])],
|
||||
audioFiles: structuredClone(this.audioFiles),
|
||||
chapters: structuredClone(this.chapters),
|
||||
ebookFile: structuredClone(this.ebookFile),
|
||||
duration: this.duration,
|
||||
size: this.size,
|
||||
tracks: this.getTracklist(libraryItemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Book
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user