Compare commits

..

38 Commits

Author SHA1 Message Date
advplyr c251f1899d Update PlaybackSession to use new library item model 2025-01-03 11:16:03 -06:00
advplyr d205c6f734 Merge pull request #3779 from advplyr/refactor-library-item
Refactor LibraryItem to use new model
2025-01-02 17:30:22 -06:00
advplyr 5e8678f1cc Remove unused 2025-01-02 17:25:10 -06:00
advplyr 12c6f2e9a5 Update updateMedia endpoint to use new model 2025-01-02 17:21:07 -06:00
advplyr 5cd14108f9 Remove req.oldLibraryItem usage 2025-01-02 15:54:10 -06:00
advplyr eb853d9f09 Fix LibraryItemController unit test 2025-01-02 15:51:21 -06:00
advplyr 4787e7fdb5 Updates to LibraryItemController to use new model 2025-01-02 15:42:52 -06:00
advplyr dd0ebdf2d8 Implementing toOld functions for LibraryItem/Book/Podcast 2025-01-02 12:49:58 -06:00
advplyr de8b0abc3a Version bump v2.17.7 2025-01-01 14:52:25 -06:00
advplyr 08bbe1ba02 Merge pull request #3762 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2025-01-01 14:48:19 -06:00
Soaibuzzaman 87bac1e33b Translated using Weblate (Bengali)
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bn/
2025-01-01 21:31:35 +01:00
thehijacker e9eeab6fb5 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1082 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2025-01-01 21:31:35 +01:00
Deleted User 235d05eff3 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1082 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2025-01-01 21:31:34 +01:00
Dmitry f9f8c6d751 Translated using Weblate (Russian)
Currently translated at 100.0% (1082 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/
2025-01-01 21:31:33 +01:00
advplyr e175a9c533 Revert book cards, author cards and series cards to div #2268 2025-01-01 14:31:24 -06:00
advplyr f9130a138e Merge pull request #3773 from advplyr/fix-heatmap-caption
Fix user stats heatmap caption text to be accurate
2025-01-01 14:21:57 -06:00
advplyr ed17dd9b51 Fix user stats heatmap caption text to be accurate 2025-01-01 13:49:22 -06:00
advplyr eb505a0be7 Merge pull request #3754 from maxlajoie99/feature/experimental-proxy-support
Experimental proxy support by manually following redirects
2025-01-01 12:54:25 -06:00
advplyr f3918a47e1 Auto formatting 2025-01-01 12:48:58 -06:00
advplyr c8a05920dd Merge pull request #3772 from advplyr/feed-episodes-upsert
Feed episode IDs changing on refresh & several other refresh issues
2025-01-01 12:17:10 -06:00
advplyr e7f7d1a573 Fix refresh feed when book is deleted and belonged to a series/collection 2025-01-01 12:06:01 -06:00
advplyr 5201625d38 Fix FeedEpisodes using a new ID when updating #3757 2025-01-01 11:32:39 -06:00
advplyr 8c4d0c503b Merge pull request #3767 from mikiher/book-query-optimizations
Book query optimizations
2025-01-01 10:10:51 -06:00
advplyr d3bda898d4 Merge pull request #3769 from advplyr/share-media-player-media-session-api
Use Media Session API in the Share audio player & pass chapterInfo to media sessions
2025-01-01 09:11:11 -06:00
advplyr 86809dcc62 Update audio player to pass chapterInfo to media session API 2025-01-01 09:02:31 -06:00
advplyr 9fa00a1904 Fix Share media player not using media session API #3768 2025-01-01 08:55:40 -06:00
mikiher 46247ecf78 Update migrations changelog 2025-01-01 08:41:27 +02:00
mikiher 0444829a9f Add index on duration 2025-01-01 08:37:57 +02:00
mikiher 754c121168 Add libraryItem size index 2025-01-01 07:34:29 +02:00
advplyr 1c2ee09f18 Fix user stats heatmap to use range of currently showing data only 2024-12-31 17:41:09 -06:00
advplyr ee310d967e Merge pull request #3766 from advplyr/remove-old-playlist
Remove old Playlist object + remove unnecessary toasts
2024-12-31 17:26:26 -06:00
advplyr 25b7f005c6 Remove unnecessary playlist toasts 2024-12-31 17:15:11 -06:00
advplyr 777c59458d Fix find all playlist endpoint 2024-12-31 17:11:31 -06:00
advplyr 9785bc02ea Update Playlist model & controller to remove usage of old Playlist object, remove old Playlist 2024-12-31 17:01:42 -06:00
advplyr 6780ef9b37 Merge pull request #3761 from advplyr/remove_old_collection_object
Remove old Collection object
2024-12-30 17:14:07 -06:00
advplyr 88a0e75576 Remove collection add/create modal toasts 2024-12-30 17:07:41 -06:00
advplyr 476933a144 Refactor Collection model/controller to not use old Collection object, remove 2024-12-30 16:54:48 -06:00
maxlajoie99 d7830f4bfc Experimental proxy support by manually following redirects 2024-12-27 20:26:55 -05:00
101 changed files with 2360 additions and 2748 deletions
-8
View File
@@ -112,14 +112,6 @@ export default {
} }
] ]
if (this.$store.state.pluginsEnabled) {
configRoutes.push({
id: 'config-plugins',
title: 'Plugins',
path: '/config/plugins'
})
}
if (this.currentLibraryId) { if (this.currentLibraryId) {
configRoutes.push({ configRoutes.push({
id: 'library-stats', id: 'library-stats',
+15 -7
View File
@@ -374,19 +374,27 @@ export default {
return return
} }
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true) const chapterInfo = []
const artwork = [ if (this.chapters.length) {
{ this.chapters.forEach((chapter) => {
src: coverImageSrc chapterInfo.push({
} title: chapter.title,
] startTime: chapter.start
})
})
}
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
title: this.title, title: this.title,
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown', artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
album: this.mediaMetadata.seriesName || '', 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) console.log('Set media session metadata', navigator.mediaSession.metadata)
+2 -2
View File
@@ -1,5 +1,5 @@
<template> <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}`"> <nuxt-link :to="`/author/${author?.id}`">
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave"> <div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden"> <div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
@@ -34,7 +34,7 @@
</div> </div>
</div> </div>
</nuxt-link> </nuxt-link>
</article> </div>
</template> </template>
<script> <script>
+3 -3
View File
@@ -1,5 +1,5 @@
<template> <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 ' }"> <div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
<!-- When cover image does not fill --> <!-- When cover image does not fill -->
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary"> <div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
@@ -128,7 +128,7 @@
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full"> <div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
<div :style="{ fontSize: 0.9 + 'em' }"> <div :style="{ fontSize: 0.9 + 'em' }">
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center"> <ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
<p cy-id="title" ref="displayTitle" 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" /> <widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -138,7 +138,7 @@
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || '&nbsp;' }}</p> <p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p> <p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
</div> </div>
</article> </div>
</template> </template>
<script> <script>
+4 -4
View File
@@ -1,5 +1,5 @@
<template> <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 cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0"> <div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
@@ -21,14 +21,14 @@
<div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }"> <div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
<p cy-id="standardBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p> <p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
</div> </div>
</div> </div>
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center"> <div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center">
<p cy-id="detailBottomDisplayTitle" class="truncate" 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> <p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
</div> </div>
</article> </div>
</template> </template>
<script> <script>
@@ -138,7 +138,6 @@ export default {
.$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds }) .$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds })
.then((updatedCollection) => { .then((updatedCollection) => {
console.log(`Books removed from collection`, updatedCollection) console.log(`Books removed from collection`, updatedCollection)
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
@@ -152,7 +151,6 @@ export default {
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`) .$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
.then((updatedCollection) => { .then((updatedCollection) => {
console.log(`Book removed from collection`, updatedCollection) console.log(`Book removed from collection`, updatedCollection)
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
@@ -167,12 +165,11 @@ export default {
this.processing = true this.processing = true
if (this.showBatchCollectionModal) { if (this.showBatchCollectionModal) {
// BATCH Remove books // BATCH Add books
this.$axios this.$axios
.$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds }) .$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })
.then((updatedCollection) => { .then((updatedCollection) => {
console.log(`Books added to collection`, updatedCollection) console.log(`Books added to collection`, updatedCollection)
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
@@ -187,7 +184,6 @@ export default {
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId }) .$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
.then((updatedCollection) => { .then((updatedCollection) => {
console.log(`Book added to collection`, updatedCollection) console.log(`Book added to collection`, updatedCollection)
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
@@ -214,7 +210,6 @@ export default {
.$post('/api/collections', newCollection) .$post('/api/collections', newCollection)
.then((data) => { .then((data) => {
console.log('New Collection Created', data) console.log('New Collection Created', data)
this.$toast.success(`Collection "${data.name}" created`)
this.processing = false this.processing = false
this.newCollectionName = '' this.newCollectionName = ''
}) })
@@ -130,7 +130,6 @@ export default {
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects }) .$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
.then((updatedPlaylist) => { .then((updatedPlaylist) => {
console.log(`Items removed from playlist`, updatedPlaylist) console.log(`Items removed from playlist`, updatedPlaylist)
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
@@ -148,7 +147,6 @@ export default {
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects }) .$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
.then((updatedPlaylist) => { .then((updatedPlaylist) => {
console.log(`Items added to playlist`, updatedPlaylist) console.log(`Items added to playlist`, updatedPlaylist)
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
@@ -174,7 +172,6 @@ export default {
.$post('/api/playlists', newPlaylist) .$post('/api/playlists', newPlaylist)
.then((data) => { .then((data) => {
console.log('New playlist created', data) console.log('New playlist created', data)
this.$toast.success(this.$strings.ToastPlaylistCreateSuccess + ': ' + data.name)
this.processing = false this.processing = false
this.newPlaylistName = '' this.newPlaylistName = ''
}) })
+2 -34
View File
@@ -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" /> <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"> <div class="flex px-1 items-center">
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn> <ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
@@ -33,8 +25,7 @@ export default {
return { return {
el: null, el: null,
content: null, content: null,
checkboxValue: false, checkboxValue: false
formData: {}
} }
}, },
watch: { watch: {
@@ -70,9 +61,6 @@ export default {
persistent() { persistent() {
return !!this.confirmPromptOptions.persistent return !!this.confirmPromptOptions.persistent
}, },
formFields() {
return this.confirmPromptOptions.formFields || []
},
checkboxLabel() { checkboxLabel() {
return this.confirmPromptOptions.checkboxLabel return this.confirmPromptOptions.checkboxLabel
}, },
@@ -112,31 +100,11 @@ export default {
this.show = false this.show = false
}, },
confirm() { confirm() {
if (this.callback) { if (this.callback) this.callback(true, this.checkboxValue)
if (this.formFields.length) {
const formFieldData = {
...this.formData
}
this.callback(true, formFieldData)
} else {
this.callback(true, this.checkboxValue)
}
}
this.show = false this.show = false
}, },
setShow() { setShow() {
this.checkboxValue = this.checkboxDefaultValue 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) this.$eventBus.$emit('showing-prompt', true)
document.body.appendChild(this.el) document.body.appendChild(this.el)
setTimeout(() => { setTimeout(() => {
+34 -31
View File
@@ -1,7 +1,7 @@
<template> <template>
<div id="heatmap" class="w-full"> <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)"> <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 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 :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> <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, innerHeight: 13 * 7,
blockWidth: 13, blockWidth: 13,
data: [], data: [],
daysListenedInTheLastYear: 0,
monthLabels: [], monthLabels: [],
tooltipEl: null, tooltipEl: null,
tooltipTextEl: null, tooltipTextEl: null,
@@ -193,46 +194,47 @@ export default {
buildData() { buildData() {
this.data = [] this.data = []
var maxValue = 0 let maxValue = 0
var minValue = 0 let minValue = 0
Object.values(this.daysListening).forEach((val) => {
if (val > maxValue) maxValue = val
if (!minValue || val < minValue) minValue = val
})
const range = maxValue - minValue + 0.01
const dates = []
for (let i = 0; i < this.daysToShow + 1; i++) { 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 date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd') const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy') const dateObj = {
const monthString = this.$formatJsDate(date, 'MMM') col: Math.floor(i / 7),
const value = this.daysListening[dateString] || 0 row: i % 7,
const x = col * 13 date,
const y = row * 13 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)
var bgColor = this.bgColors[0] if (dateObj.value > 0) {
var outlineColor = this.outlineColors[0] this.daysListenedInTheLastYear++
if (value) { if (dateObj.value > maxValue) maxValue = dateObj.value
if (!minValue || dateObj.value < minValue) minValue = dateObj.value
}
}
const range = maxValue - minValue + 0.01
for (const dateObj of dates) {
let bgColor = this.bgColors[0]
let outlineColor = this.outlineColors[0]
if (dateObj.value) {
outlineColor = this.outlineColors[1] outlineColor = this.outlineColors[1]
var percentOfAvg = (value - minValue) / range const percentOfAvg = (dateObj.value - minValue) / range
var bgIndex = Math.floor(percentOfAvg * 4) + 1 const bgIndex = Math.floor(percentOfAvg * 4) + 1
bgColor = this.bgColors[bgIndex] || 'red' bgColor = this.bgColors[bgIndex] || 'red'
} }
this.data.push({ this.data.push({
date, ...dateObj,
dateString, style: `transform:translate(${dateObj.col * 13}px,${dateObj.row * 13}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
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;`
}) })
} }
@@ -260,6 +262,7 @@ export default {
const heatmapEl = document.getElementById('heatmap') const heatmapEl = document.getElementById('heatmap')
this.contentWidth = heatmapEl.clientWidth this.contentWidth = heatmapEl.clientWidth
this.maxInnerWidth = this.contentWidth - 52 this.maxInnerWidth = this.contentWidth - 52
this.daysListenedInTheLastYear = 0
this.buildData() this.buildData()
} }
}, },
@@ -218,7 +218,6 @@ export default {
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess) this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
} else { } else {
console.log(`Item removed from playlist`, updatedPlaylist) console.log(`Item removed from playlist`, updatedPlaylist)
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
} }
}) })
.catch((error) => { .catch((error) => {
@@ -31,7 +31,6 @@
</div> </div>
</template> </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)"> <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> <p class="text-left">{{ item.text }}</p>
</button> </button>
</template> </template>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.17.6", "version": "2.17.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.17.6", "version": "2.17.7",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.17.6", "version": "2.17.7",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
-154
View File
@@ -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>
-48
View File
@@ -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>
-62
View File
@@ -364,9 +364,6 @@ export default {
showCollectionsButton() { showCollectionsButton() {
return this.isBook && this.userCanUpdate return this.isBook && this.userCanUpdate
}, },
pluginExtensions() {
return this.$store.getters['getPluginExtensions']('item.detail.actions')
},
contextMenuItems() { contextMenuItems() {
const items = [] 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 return items
} }
}, },
@@ -778,54 +763,7 @@ export default {
} else if (action === 'share') { } else if (action === 'share') {
this.$store.commit('setSelectedLibraryItem', this.libraryItem) this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShareModal', this.mediaItemShare) 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() { mounted() {
+1 -5
View File
@@ -166,14 +166,10 @@ export default {
location.reload() location.reload()
}, },
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices, plugins }) { setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) {
this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('setSource', Source) this.$store.commit('setSource', Source)
this.$store.commit('libraries/setEReaderDevices', ereaderDevices) this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
if (plugins !== undefined) {
this.$store.commit('setPlugins', plugins)
}
this.$setServerLanguageCode(serverSettings.language) this.$setServerLanguageCode(serverSettings.language)
if (serverSettings.chromecastEnabled) { if (serverSettings.chromecastEnabled) {
+93 -1
View File
@@ -110,6 +110,84 @@ export default {
} }
}, },
methods: { 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) { async coverImageLoaded(e) {
if (!this.playbackSession.coverPath) return if (!this.playbackSession.coverPath) return
const fac = new FastAverageColor() const fac = new FastAverageColor()
@@ -126,8 +204,19 @@ export default {
}) })
}, },
playPause() { playPause() {
if (this.isPlaying) {
this.pause()
} else {
this.play()
}
},
play() {
if (!this.localAudioPlayer || !this.hasLoaded) return if (!this.localAudioPlayer || !this.hasLoaded) return
this.localAudioPlayer.playPause() this.localAudioPlayer.play()
},
pause() {
if (!this.localAudioPlayer || !this.hasLoaded) return
this.localAudioPlayer.pause()
}, },
jumpForward() { jumpForward() {
if (!this.localAudioPlayer || !this.hasLoaded) return if (!this.localAudioPlayer || !this.hasLoaded) return
@@ -213,6 +302,7 @@ export default {
} else { } else {
this.stopPlayInterval() this.stopPlayInterval()
} }
this.updateMediaSessionPlaybackState()
}, },
playerTimeUpdate(time) { playerTimeUpdate(time) {
this.setCurrentTime(time) this.setCurrentTime(time)
@@ -276,6 +366,8 @@ export default {
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this)) this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
this.localAudioPlayer.on('error', this.playerError.bind(this)) this.localAudioPlayer.on('error', this.playerError.bind(this))
this.localAudioPlayer.on('finished', this.playerFinished.bind(this)) this.localAudioPlayer.on('finished', this.playerFinished.bind(this))
this.setMediaSession()
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('resize', this.resize) window.removeEventListener('resize', this.resize)
+1 -21
View File
@@ -28,9 +28,7 @@ export const state = () => ({
openModal: null, openModal: null,
innerModalOpen: false, innerModalOpen: false,
lastBookshelfScrollData: {}, lastBookshelfScrollData: {},
routerBasePath: '/', routerBasePath: '/'
plugins: [],
pluginsEnabled: false
}) })
export const getters = { export const getters = {
@@ -63,20 +61,6 @@ export const getters = {
getHomeBookshelfView: (state) => { getHomeBookshelfView: (state) => {
if (!state.serverSettings || isNaN(state.serverSettings.homeBookshelfView)) return Constants.BookshelfView.STANDARD if (!state.serverSettings || isNaN(state.serverSettings.homeBookshelfView)) return Constants.BookshelfView.STANDARD
return state.serverSettings.homeBookshelfView 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) { setInnerModalOpen(state, val) {
state.innerModalOpen = val state.innerModalOpen = val
},
setPlugins(state, val) {
state.plugins = val
state.pluginsEnabled = true
} }
} }
-2
View File
@@ -629,7 +629,6 @@
"MessageItemsSelected": "{0} избрани", "MessageItemsSelected": "{0} избрани",
"MessageItemsUpdated": "{0} елемента обновени", "MessageItemsUpdated": "{0} елемента обновени",
"MessageJoinUsOn": "Присъединете се към нас", "MessageJoinUsOn": "Присъединете се към нас",
"MessageListeningSessionsInTheLastYear": "{0} слушателски сесии през последната година",
"MessageLoading": "Зареждане...", "MessageLoading": "Зареждане...",
"MessageLoadingFolders": "Зареждане на Папки...", "MessageLoadingFolders": "Зареждане на Папки...",
"MessageM4BFailed": "M4B Провалено!", "MessageM4BFailed": "M4B Провалено!",
@@ -729,7 +728,6 @@
"ToastBookmarkUpdateSuccess": "Отметката е обновена", "ToastBookmarkUpdateSuccess": "Отметката е обновена",
"ToastChaptersHaveErrors": "Главите имат грешки", "ToastChaptersHaveErrors": "Главите имат грешки",
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия", "ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
"ToastCollectionItemsRemoveSuccess": "Елемент(и) премахнати от колекция",
"ToastCollectionRemoveSuccess": "Колекцията е премахната", "ToastCollectionRemoveSuccess": "Колекцията е премахната",
"ToastCollectionUpdateSuccess": "Колекцията е обновена", "ToastCollectionUpdateSuccess": "Колекцията е обновена",
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена", "ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
+10 -3
View File
@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "ট্র্যাকলিস্ট সংরক্ষণ করুন", "ButtonSaveTracklist": "ট্র্যাকলিস্ট সংরক্ষণ করুন",
"ButtonScan": "স্ক্যান", "ButtonScan": "স্ক্যান",
"ButtonScanLibrary": "স্ক্যান লাইব্রেরি", "ButtonScanLibrary": "স্ক্যান লাইব্রেরি",
"ButtonScrollLeft": "বাম দিকে স্ক্রল করুন",
"ButtonScrollRight": "ডানদিকে স্ক্রল করুন",
"ButtonSearch": "অনুসন্ধান", "ButtonSearch": "অনুসন্ধান",
"ButtonSelectFolderPath": "ফোল্ডারের পথ নির্বাচন করুন", "ButtonSelectFolderPath": "ফোল্ডারের পথ নির্বাচন করুন",
"ButtonSeries": "সিরিজ", "ButtonSeries": "সিরিজ",
@@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "পরীক্ষামূলক ফিচার", "HeaderSettingsExperimental": "পরীক্ষামূলক ফিচার",
"HeaderSettingsGeneral": "সাধারণ", "HeaderSettingsGeneral": "সাধারণ",
"HeaderSettingsScanner": "স্ক্যানার", "HeaderSettingsScanner": "স্ক্যানার",
"HeaderSettingsWebClient": "ওয়েব ক্লায়েন্ট",
"HeaderSleepTimer": "স্লিপ টাইমার", "HeaderSleepTimer": "স্লিপ টাইমার",
"HeaderStatsLargestItems": "সবচেয়ে বড় আইটেম", "HeaderStatsLargestItems": "সবচেয়ে বড় আইটেম",
"HeaderStatsLongestItems": "দীর্ঘতম আইটেম (ঘন্টা)", "HeaderStatsLongestItems": "দীর্ঘতম আইটেম (ঘন্টা)",
@@ -297,6 +300,7 @@
"LabelDiscover": "আবিষ্কার", "LabelDiscover": "আবিষ্কার",
"LabelDownload": "ডাউনলোড করুন", "LabelDownload": "ডাউনলোড করুন",
"LabelDownloadNEpisodes": "{0}টি পর্ব ডাউনলোড করুন", "LabelDownloadNEpisodes": "{0}টি পর্ব ডাউনলোড করুন",
"LabelDownloadable": "ডাউনলোডযোগ্য",
"LabelDuration": "সময়কাল", "LabelDuration": "সময়কাল",
"LabelDurationComparisonExactMatch": "(সঠিক মিল)", "LabelDurationComparisonExactMatch": "(সঠিক মিল)",
"LabelDurationComparisonLonger": "({0} দীর্ঘ)", "LabelDurationComparisonLonger": "({0} দীর্ঘ)",
@@ -542,6 +546,7 @@
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})", "LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন", "LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন", "LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
"LabelSettingsAllowIframe": "আইফ্রেমে এম্বেড করার অনুমতি দিন",
"LabelSettingsAudiobooksOnly": "শুধুমাত্র অডিও বই", "LabelSettingsAudiobooksOnly": "শুধুমাত্র অডিও বই",
"LabelSettingsAudiobooksOnlyHelp": "এই সেটিংটি সক্ষম করা ই-বই ফাইলগুলিকে উপেক্ষা করবে যদি না সেগুলি একটি অডিওবই ফোল্ডারের মধ্যে থাকে যে ক্ষেত্রে সেগুলিকে সম্পূরক ই-বই হিসাবে সেট করা হবে", "LabelSettingsAudiobooksOnlyHelp": "এই সেটিংটি সক্ষম করা ই-বই ফাইলগুলিকে উপেক্ষা করবে যদি না সেগুলি একটি অডিওবই ফোল্ডারের মধ্যে থাকে যে ক্ষেত্রে সেগুলিকে সম্পূরক ই-বই হিসাবে সেট করা হবে",
"LabelSettingsBookshelfViewHelp": "কাঠের তাক সহ স্কুমরফিক ডিজাইন", "LabelSettingsBookshelfViewHelp": "কাঠের তাক সহ স্কুমরফিক ডিজাইন",
@@ -584,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "ডিফল্টরূপে মেটাডেটা ফাইলগুলি /মেটাডাটা/আইটেমগুলি -এ সংরক্ষণ করা হয়, এই সেটিংটি সক্ষম করলে মেটাডেটা ফাইলগুলি আপনার লাইব্রেরি আইটেম ফোল্ডারে সংরক্ষণ করা হবে", "LabelSettingsStoreMetadataWithItemHelp": "ডিফল্টরূপে মেটাডেটা ফাইলগুলি /মেটাডাটা/আইটেমগুলি -এ সংরক্ষণ করা হয়, এই সেটিংটি সক্ষম করলে মেটাডেটা ফাইলগুলি আপনার লাইব্রেরি আইটেম ফোল্ডারে সংরক্ষণ করা হবে",
"LabelSettingsTimeFormat": "সময় বিন্যাস", "LabelSettingsTimeFormat": "সময় বিন্যাস",
"LabelShare": "শেয়ার করুন", "LabelShare": "শেয়ার করুন",
"LabelShareDownloadableHelp": "শেয়ার লিঙ্ক সহ ব্যবহারকারীদের লাইব্রেরি আইটেমের একটি জিপ ফাইল ডাউনলোড করার অনুমতি দিন।",
"LabelShareOpen": "শেয়ার খোলা", "LabelShareOpen": "শেয়ার খোলা",
"LabelShareURL": "শেয়ার ইউআরএল", "LabelShareURL": "শেয়ার ইউআরএল",
"LabelShowAll": "সব দেখান", "LabelShowAll": "সব দেখান",
@@ -592,6 +598,8 @@
"LabelSize": "আকার", "LabelSize": "আকার",
"LabelSleepTimer": "স্লিপ টাইমার", "LabelSleepTimer": "স্লিপ টাইমার",
"LabelSlug": "স্লাগ", "LabelSlug": "স্লাগ",
"LabelSortAscending": "আরোহী",
"LabelSortDescending": "অবরোহী",
"LabelStart": "শুরু", "LabelStart": "শুরু",
"LabelStartTime": "শুরুর সময়", "LabelStartTime": "শুরুর সময়",
"LabelStarted": "শুরু হয়েছে", "LabelStarted": "শুরু হয়েছে",
@@ -679,6 +687,8 @@
"LabelViewPlayerSettings": "প্লেয়ার সেটিংস দেখুন", "LabelViewPlayerSettings": "প্লেয়ার সেটিংস দেখুন",
"LabelViewQueue": "প্লেয়ার সারি দেখুন", "LabelViewQueue": "প্লেয়ার সারি দেখুন",
"LabelVolume": "ভলিউম", "LabelVolume": "ভলিউম",
"LabelWebRedirectURLsDescription": "লগইন করার পরে ওয়েব অ্যাপে পুনঃনির্দেশের অনুমতি দেওয়ার জন্য আপনার OAuth প্রদানকারীতে এই URLগুলোকে অনুমোদন করুন:",
"LabelWebRedirectURLsSubfolder": "রিডাইরেক্ট URL এর জন্য সাবফোল্ডার",
"LabelWeekdaysToRun": "চলতে হবে সপ্তাহের দিন", "LabelWeekdaysToRun": "চলতে হবে সপ্তাহের দিন",
"LabelXBooks": "{0}টি বই", "LabelXBooks": "{0}টি বই",
"LabelXItems": "{0}টি আইটেম", "LabelXItems": "{0}টি আইটেম",
@@ -763,7 +773,6 @@
"MessageItemsSelected": "{0}টি আইটেম নির্বাচিত", "MessageItemsSelected": "{0}টি আইটেম নির্বাচিত",
"MessageItemsUpdated": "{0}টি আইটেম আপডেট করা হয়েছে", "MessageItemsUpdated": "{0}টি আইটেম আপডেট করা হয়েছে",
"MessageJoinUsOn": "আমাদের সাথে যোগ দিন", "MessageJoinUsOn": "আমাদের সাথে যোগ দিন",
"MessageListeningSessionsInTheLastYear": "গত বছরে {0}টি শোনার সেশন",
"MessageLoading": "লোড হচ্ছে.।", "MessageLoading": "লোড হচ্ছে.।",
"MessageLoadingFolders": "ফোল্ডার লোড হচ্ছে...", "MessageLoadingFolders": "ফোল্ডার লোড হচ্ছে...",
"MessageLogsDescription": "লগগুলি JSON ফাইল হিসাবে <code>/metadata/logs</code>-এ সংরক্ষণ করা হয়। ক্র্যাশ লগগুলি <code>/metadata/logs/crash_logs.txt</code>-এ সংরক্ষণ করা হয়।", "MessageLogsDescription": "লগগুলি JSON ফাইল হিসাবে <code>/metadata/logs</code>-এ সংরক্ষণ করা হয়। ক্র্যাশ লগগুলি <code>/metadata/logs/crash_logs.txt</code>-এ সংরক্ষণ করা হয়।",
@@ -951,8 +960,6 @@
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে", "ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে", "ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে", "ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
"ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে", "ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে",
"ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে", "ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে",
"ToastCoverUpdateFailed": "কভার আপডেট ব্যর্থ হয়েছে", "ToastCoverUpdateFailed": "কভার আপডেট ব্যর্থ হয়েছে",
-2
View File
@@ -904,8 +904,6 @@
"ToastChaptersRemoved": "Capítols eliminats", "ToastChaptersRemoved": "Capítols eliminats",
"ToastChaptersUpdated": "Capítols actualitzats", "ToastChaptersUpdated": "Capítols actualitzats",
"ToastCollectionItemsAddFailed": "Error en afegir elements a la col·lecció", "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", "ToastCollectionRemoveSuccess": "Col·lecció eliminada",
"ToastCollectionUpdateSuccess": "Col·lecció actualitzada", "ToastCollectionUpdateSuccess": "Col·lecció actualitzada",
"ToastCoverUpdateFailed": "Error en actualitzar la portada", "ToastCoverUpdateFailed": "Error en actualitzar la portada",
-2
View File
@@ -771,7 +771,6 @@
"MessageItemsSelected": "{0} vybraných položek", "MessageItemsSelected": "{0} vybraných položek",
"MessageItemsUpdated": "{0} položky byly aktualizovány", "MessageItemsUpdated": "{0} položky byly aktualizovány",
"MessageJoinUsOn": "Přidejte se k nám", "MessageJoinUsOn": "Přidejte se k nám",
"MessageListeningSessionsInTheLastYear": "{0} poslechových relací za poslední rok",
"MessageLoading": "Načítá se...", "MessageLoading": "Načítá se...",
"MessageLoadingFolders": "Načítám složky...", "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>.", "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>.",
@@ -943,7 +942,6 @@
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby", "ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
"ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy", "ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy",
"ToastChaptersRemoved": "Kapitoly odstraněny", "ToastChaptersRemoved": "Kapitoly odstraněny",
"ToastCollectionItemsRemoveSuccess": "Položky odstraněny z kolekce",
"ToastCollectionRemoveSuccess": "Kolekce odstraněna", "ToastCollectionRemoveSuccess": "Kolekce odstraněna",
"ToastCollectionUpdateSuccess": "Kolekce aktualizována", "ToastCollectionUpdateSuccess": "Kolekce aktualizována",
"ToastCoverUpdateFailed": "Aktualizace obálky selhala", "ToastCoverUpdateFailed": "Aktualizace obálky selhala",
-2
View File
@@ -539,7 +539,6 @@
"MessageItemsSelected": "{0} elementer valgt", "MessageItemsSelected": "{0} elementer valgt",
"MessageItemsUpdated": "{0} elementer opdateret", "MessageItemsUpdated": "{0} elementer opdateret",
"MessageJoinUsOn": "Deltag i os på", "MessageJoinUsOn": "Deltag i os på",
"MessageListeningSessionsInTheLastYear": "{0} lyttesessioner i det sidste år",
"MessageLoading": "Indlæser...", "MessageLoading": "Indlæser...",
"MessageLoadingFolders": "Indlæser mapper...", "MessageLoadingFolders": "Indlæser mapper...",
"MessageM4BFailed": "M4B mislykkedes!", "MessageM4BFailed": "M4B mislykkedes!",
@@ -640,7 +639,6 @@
"ToastBookmarkUpdateSuccess": "Bogmærke opdateret", "ToastBookmarkUpdateSuccess": "Bogmærke opdateret",
"ToastChaptersHaveErrors": "Kapitler har fejl", "ToastChaptersHaveErrors": "Kapitler har fejl",
"ToastChaptersMustHaveTitles": "Kapitler skal have titler", "ToastChaptersMustHaveTitles": "Kapitler skal have titler",
"ToastCollectionItemsRemoveSuccess": "Element(er) fjernet fra samlingen",
"ToastCollectionRemoveSuccess": "Samling fjernet", "ToastCollectionRemoveSuccess": "Samling fjernet",
"ToastCollectionUpdateSuccess": "Samling opdateret", "ToastCollectionUpdateSuccess": "Samling opdateret",
"ToastItemCoverUpdateSuccess": "Varens omslag opdateret", "ToastItemCoverUpdateSuccess": "Varens omslag opdateret",
-3
View File
@@ -771,7 +771,6 @@
"MessageItemsSelected": "{0} ausgewählte Medien", "MessageItemsSelected": "{0} ausgewählte Medien",
"MessageItemsUpdated": "{0} Medien aktualisiert", "MessageItemsUpdated": "{0} Medien aktualisiert",
"MessageJoinUsOn": "Besuche uns auf", "MessageJoinUsOn": "Besuche uns auf",
"MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr",
"MessageLoading": "Wird geladen …", "MessageLoading": "Wird geladen …",
"MessageLoadingFolders": "Lade Ordner...", "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.", "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.",
@@ -959,8 +958,6 @@
"ToastChaptersRemoved": "Kapitel entfernt", "ToastChaptersRemoved": "Kapitel entfernt",
"ToastChaptersUpdated": "Kapitel aktualisiert", "ToastChaptersUpdated": "Kapitel aktualisiert",
"ToastCollectionItemsAddFailed": "Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen", "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", "ToastCollectionRemoveSuccess": "Sammlung entfernt",
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert", "ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
"ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen", "ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen",
+1 -3
View File
@@ -758,6 +758,7 @@
"MessageConfirmResetProgress": "Are you sure you want to reset your progress?", "MessageConfirmResetProgress": "Are you sure you want to reset your progress?",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", "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?", "MessageConfirmUnlinkOpenId": "Are you sure you want to unlink this user from OpenID?",
"MessageDaysListenedInTheLastYear": "{0} days listened in the last year",
"MessageDownloadingEpisode": "Downloading episode", "MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFailed": "Embed Failed!", "MessageEmbedFailed": "Embed Failed!",
@@ -773,7 +774,6 @@
"MessageItemsSelected": "{0} Items Selected", "MessageItemsSelected": "{0} Items Selected",
"MessageItemsUpdated": "{0} Items Updated", "MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Join us on", "MessageJoinUsOn": "Join us on",
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
"MessageLoading": "Loading...", "MessageLoading": "Loading...",
"MessageLoadingFolders": "Loading folders...", "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>.", "MessageLogsDescription": "Logs are stored in <code>/metadata/logs</code> as JSON files. Crash logs are stored in <code>/metadata/logs/crash_logs.txt</code>.",
@@ -961,8 +961,6 @@
"ToastChaptersRemoved": "Chapters removed", "ToastChaptersRemoved": "Chapters removed",
"ToastChaptersUpdated": "Chapters updated", "ToastChaptersUpdated": "Chapters updated",
"ToastCollectionItemsAddFailed": "Item(s) added to collection failed", "ToastCollectionItemsAddFailed": "Item(s) added to collection failed",
"ToastCollectionItemsAddSuccess": "Item(s) added to collection success",
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
"ToastCollectionRemoveSuccess": "Collection removed", "ToastCollectionRemoveSuccess": "Collection removed",
"ToastCollectionUpdateSuccess": "Collection updated", "ToastCollectionUpdateSuccess": "Collection updated",
"ToastCoverUpdateFailed": "Cover update failed", "ToastCoverUpdateFailed": "Cover update failed",
-3
View File
@@ -771,7 +771,6 @@
"MessageItemsSelected": "{0} Elementos Seleccionados", "MessageItemsSelected": "{0} Elementos Seleccionados",
"MessageItemsUpdated": "{0} Elementos Actualizados", "MessageItemsUpdated": "{0} Elementos Actualizados",
"MessageJoinUsOn": "Únetenos en", "MessageJoinUsOn": "Únetenos en",
"MessageListeningSessionsInTheLastYear": "{0} sesiones de escucha en el último año",
"MessageLoading": "Cargando...", "MessageLoading": "Cargando...",
"MessageLoadingFolders": "Cargando archivos...", "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>.", "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>.",
@@ -959,8 +958,6 @@
"ToastChaptersRemoved": "Capítulos eliminados", "ToastChaptersRemoved": "Capítulos eliminados",
"ToastChaptersUpdated": "Capítulos actualizados", "ToastChaptersUpdated": "Capítulos actualizados",
"ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)", "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", "ToastCollectionRemoveSuccess": "Colección removida",
"ToastCollectionUpdateSuccess": "Colección actualizada", "ToastCollectionUpdateSuccess": "Colección actualizada",
"ToastCoverUpdateFailed": "Error al actualizar la cubierta", "ToastCoverUpdateFailed": "Error al actualizar la cubierta",
-2
View File
@@ -611,7 +611,6 @@
"MessageItemsSelected": "{0} Valitud üksust", "MessageItemsSelected": "{0} Valitud üksust",
"MessageItemsUpdated": "{0} Üksust on uuendatud", "MessageItemsUpdated": "{0} Üksust on uuendatud",
"MessageJoinUsOn": "Liitu meiega", "MessageJoinUsOn": "Liitu meiega",
"MessageListeningSessionsInTheLastYear": "Kuulamissessioone viimase aasta jooksul: {0}",
"MessageLoading": "Laadimine...", "MessageLoading": "Laadimine...",
"MessageLoadingFolders": "Kaustade laadimine...", "MessageLoadingFolders": "Kaustade laadimine...",
"MessageM4BFailed": "M4B ebaõnnestus!", "MessageM4BFailed": "M4B ebaõnnestus!",
@@ -713,7 +712,6 @@
"ToastBookmarkUpdateSuccess": "Järjehoidja värskendatud", "ToastBookmarkUpdateSuccess": "Järjehoidja värskendatud",
"ToastChaptersHaveErrors": "Peatükkidel on vigu", "ToastChaptersHaveErrors": "Peatükkidel on vigu",
"ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri", "ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri",
"ToastCollectionItemsRemoveSuccess": "Üksus(ed) eemaldatud kogumist",
"ToastCollectionRemoveSuccess": "Kogum eemaldatud", "ToastCollectionRemoveSuccess": "Kogum eemaldatud",
"ToastCollectionUpdateSuccess": "Kogum värskendatud", "ToastCollectionUpdateSuccess": "Kogum värskendatud",
"ToastItemCoverUpdateSuccess": "Üksuse kaas värskendatud", "ToastItemCoverUpdateSuccess": "Üksuse kaas värskendatud",
-3
View File
@@ -765,7 +765,6 @@
"MessageItemsSelected": "{0} éléments sélectionnés", "MessageItemsSelected": "{0} éléments sélectionnés",
"MessageItemsUpdated": "{0} éléments mis à jour", "MessageItemsUpdated": "{0} éléments mis à jour",
"MessageJoinUsOn": "Rejoignez-nous sur", "MessageJoinUsOn": "Rejoignez-nous sur",
"MessageListeningSessionsInTheLastYear": "{0} sessions d’écoute lan dernier",
"MessageLoading": "Chargement…", "MessageLoading": "Chargement…",
"MessageLoadingFolders": "Chargement des dossiers…", "MessageLoadingFolders": "Chargement des dossiers…",
"MessageLogsDescription": "Les journaux sont stockés dans <code>/metadata/logs</code> sous forme de fichiers JSON. Les journaux dincidents sont stockés dans <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "Les journaux sont stockés dans <code>/metadata/logs</code> sous forme de fichiers JSON. Les journaux dincidents sont stockés dans <code>/metadata/logs/crash_logs.txt</code>.",
@@ -953,8 +952,6 @@
"ToastChaptersRemoved": "Chapitres supprimés", "ToastChaptersRemoved": "Chapitres supprimés",
"ToastChaptersUpdated": "Chapitres mis à jour", "ToastChaptersUpdated": "Chapitres mis à jour",
"ToastCollectionItemsAddFailed": "Échec de lajout de(s) élément(s) à la collection", "ToastCollectionItemsAddFailed": "Échec de lajout 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", "ToastCollectionRemoveSuccess": "Collection supprimée",
"ToastCollectionUpdateSuccess": "Collection mise à jour", "ToastCollectionUpdateSuccess": "Collection mise à jour",
"ToastCoverUpdateFailed": "Échec de la mise à jour de la couverture", "ToastCoverUpdateFailed": "Échec de la mise à jour de la couverture",
-2
View File
@@ -642,7 +642,6 @@
"MessageItemsSelected": "{0} פריטים נבחרו", "MessageItemsSelected": "{0} פריטים נבחרו",
"MessageItemsUpdated": "{0} פריטים עודכנו", "MessageItemsUpdated": "{0} פריטים עודכנו",
"MessageJoinUsOn": "הצטרף אלינו ב-", "MessageJoinUsOn": "הצטרף אלינו ב-",
"MessageListeningSessionsInTheLastYear": "{0} מפגשי האזנה בשנה האחרונה",
"MessageLoading": "טוען...", "MessageLoading": "טוען...",
"MessageLoadingFolders": "טוען תיקיות...", "MessageLoadingFolders": "טוען תיקיות...",
"MessageM4BFailed": "M4B נכשל!", "MessageM4BFailed": "M4B נכשל!",
@@ -744,7 +743,6 @@
"ToastBookmarkUpdateSuccess": "הסימניה עודכנה בהצלחה", "ToastBookmarkUpdateSuccess": "הסימניה עודכנה בהצלחה",
"ToastChaptersHaveErrors": "פרקים מכילים שגיאות", "ToastChaptersHaveErrors": "פרקים מכילים שגיאות",
"ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות", "ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות",
"ToastCollectionItemsRemoveSuccess": "הפריט(ים) הוסרו מהאוסף בהצלחה",
"ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה", "ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה",
"ToastCollectionUpdateSuccess": "האוסף עודכן בהצלחה", "ToastCollectionUpdateSuccess": "האוסף עודכן בהצלחה",
"ToastItemCoverUpdateSuccess": "כריכת הפריט עודכנה בהצלחה", "ToastItemCoverUpdateSuccess": "כריכת הפריט עודכנה בהצלחה",
-3
View File
@@ -771,7 +771,6 @@
"MessageItemsSelected": "{0} odabranih stavki", "MessageItemsSelected": "{0} odabranih stavki",
"MessageItemsUpdated": "{0} stavki ažurirano", "MessageItemsUpdated": "{0} stavki ažurirano",
"MessageJoinUsOn": "Pridruži nam se na", "MessageJoinUsOn": "Pridruži nam se na",
"MessageListeningSessionsInTheLastYear": "{0} slušanja u prošloj godini",
"MessageLoading": "Učitavam...", "MessageLoading": "Učitavam...",
"MessageLoadingFolders": "Učitavam mape...", "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>.", "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>.",
@@ -959,8 +958,6 @@
"ToastChaptersRemoved": "Poglavlja uklonjena", "ToastChaptersRemoved": "Poglavlja uklonjena",
"ToastChaptersUpdated": "Poglavlja su ažurirana", "ToastChaptersUpdated": "Poglavlja su ažurirana",
"ToastCollectionItemsAddFailed": "Neuspješno dodavanje stavki u zbirku", "ToastCollectionItemsAddFailed": "Neuspješno dodavanje stavki u zbirku",
"ToastCollectionItemsAddSuccess": "Uspješno dodavanje stavki u zbirku",
"ToastCollectionItemsRemoveSuccess": "Stavke izbrisane iz zbirke",
"ToastCollectionRemoveSuccess": "Zbirka izbrisana", "ToastCollectionRemoveSuccess": "Zbirka izbrisana",
"ToastCollectionUpdateSuccess": "Zbirka ažurirana", "ToastCollectionUpdateSuccess": "Zbirka ažurirana",
"ToastCoverUpdateFailed": "Ažuriranje naslovnice nije uspjelo", "ToastCoverUpdateFailed": "Ažuriranje naslovnice nije uspjelo",
-2
View File
@@ -764,7 +764,6 @@
"MessageItemsSelected": "{0} kiválasztott elem", "MessageItemsSelected": "{0} kiválasztott elem",
"MessageItemsUpdated": "{0} frissített elem", "MessageItemsUpdated": "{0} frissített elem",
"MessageJoinUsOn": "Csatlakozzon hozzánk a", "MessageJoinUsOn": "Csatlakozzon hozzánk a",
"MessageListeningSessionsInTheLastYear": "{0} hallgatási munkamenet az elmúlt évben",
"MessageLoading": "Betöltés...", "MessageLoading": "Betöltés...",
"MessageLoadingFolders": "Mappák betöltése...", "MessageLoadingFolders": "Mappák betöltése...",
"MessageLogsDescription": "A naplók a <code>/metadata/logs</code> mappában JSON-fájlokként tárolódnak. Az összeomlási naplók a <code>/metadata/logs/crash_logs.txt</code> fájlban tárolódnak.", "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.",
@@ -945,7 +944,6 @@
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük", "ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
"ToastChaptersRemoved": "Fejezetek eltávolítva", "ToastChaptersRemoved": "Fejezetek eltávolítva",
"ToastChaptersUpdated": "Fejezetek frissítve", "ToastChaptersUpdated": "Fejezetek frissítve",
"ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből",
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva", "ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve", "ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
"ToastCoverUpdateFailed": "A borító frissítése nem sikerült", "ToastCoverUpdateFailed": "A borító frissítése nem sikerült",
-3
View File
@@ -762,7 +762,6 @@
"MessageItemsSelected": "{0} oggetti Selezionati", "MessageItemsSelected": "{0} oggetti Selezionati",
"MessageItemsUpdated": "{0} Oggetti aggiornati", "MessageItemsUpdated": "{0} Oggetti aggiornati",
"MessageJoinUsOn": "Unisciti a noi su", "MessageJoinUsOn": "Unisciti a noi su",
"MessageListeningSessionsInTheLastYear": "{0} sessioni di ascolto nell'ultimo anno",
"MessageLoading": "Caricamento…", "MessageLoading": "Caricamento…",
"MessageLoadingFolders": "Caricamento Cartelle...", "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>.", "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>.",
@@ -950,8 +949,6 @@
"ToastChaptersRemoved": "Capitoli rimossi", "ToastChaptersRemoved": "Capitoli rimossi",
"ToastChaptersUpdated": "Capitoli aggiornati", "ToastChaptersUpdated": "Capitoli aggiornati",
"ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito", "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", "ToastCollectionRemoveSuccess": "Collezione rimossa",
"ToastCollectionUpdateSuccess": "Raccolta aggiornata", "ToastCollectionUpdateSuccess": "Raccolta aggiornata",
"ToastCoverUpdateFailed": "Aggiornamento cover fallito", "ToastCoverUpdateFailed": "Aggiornamento cover fallito",
-3
View File
@@ -563,7 +563,6 @@
"MessageItemsSelected": "Pasirinkti {0} elementai (-ų)", "MessageItemsSelected": "Pasirinkti {0} elementai (-ų)",
"MessageItemsUpdated": "Atnaujinti {0} elementai (-ų)", "MessageItemsUpdated": "Atnaujinti {0} elementai (-ų)",
"MessageJoinUsOn": "Prisijunkite prie mūsų", "MessageJoinUsOn": "Prisijunkite prie mūsų",
"MessageListeningSessionsInTheLastYear": "{0} klausymo sesijų per paskutinius metus",
"MessageLoading": "Kraunama...", "MessageLoading": "Kraunama...",
"MessageLoadingFolders": "Kraunami aplankai...", "MessageLoadingFolders": "Kraunami aplankai...",
"MessageM4BFailed": "M4B Nepavyko!", "MessageM4BFailed": "M4B Nepavyko!",
@@ -666,8 +665,6 @@
"ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus", "ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus",
"ToastChaptersRemoved": "Skyriai pašalinti", "ToastChaptersRemoved": "Skyriai pašalinti",
"ToastCollectionItemsAddFailed": "Nepavyko pridėti į kolekciją", "ToastCollectionItemsAddFailed": "Nepavyko pridėti į kolekciją",
"ToastCollectionItemsAddSuccess": "Pridėta į kolekciją",
"ToastCollectionItemsRemoveSuccess": "Elementai pašalinti iš kolekcijos",
"ToastCollectionRemoveSuccess": "Kolekcija pašalinta", "ToastCollectionRemoveSuccess": "Kolekcija pašalinta",
"ToastCollectionUpdateSuccess": "Kolekcija atnaujinta", "ToastCollectionUpdateSuccess": "Kolekcija atnaujinta",
"ToastCoverUpdateFailed": "Viršelio atnaujinimas nepavyko", "ToastCoverUpdateFailed": "Viršelio atnaujinimas nepavyko",
-3
View File
@@ -758,7 +758,6 @@
"MessageItemsSelected": "{0} onderdelen geselecteerd", "MessageItemsSelected": "{0} onderdelen geselecteerd",
"MessageItemsUpdated": "{0} onderdelen bijgewerkt", "MessageItemsUpdated": "{0} onderdelen bijgewerkt",
"MessageJoinUsOn": "Doe mee op", "MessageJoinUsOn": "Doe mee op",
"MessageListeningSessionsInTheLastYear": "{0} luistersessies in het laatste jaar",
"MessageLoading": "Aan het laden...", "MessageLoading": "Aan het laden...",
"MessageLoadingFolders": "Mappen 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>.", "MessageLogsDescription": "Logs worden opgeslagen in <code>/metadata/logs</code> als JSON-bestanden. Crashlogs worden opgeslagen in <code>/metadata/logs/crash_logs.txt</code>.",
@@ -946,8 +945,6 @@
"ToastChaptersRemoved": "Hoofdstukken verwijderd", "ToastChaptersRemoved": "Hoofdstukken verwijderd",
"ToastChaptersUpdated": "Hoofdstukken bijgewerkt", "ToastChaptersUpdated": "Hoofdstukken bijgewerkt",
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt", "ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
"ToastCollectionItemsAddSuccess": "Item(s) toegevoegd aan collectie gelukt",
"ToastCollectionItemsRemoveSuccess": "Onderdeel (of onderdelen) verwijderd uit collectie",
"ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionRemoveSuccess": "Collectie verwijderd",
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
"ToastCoverUpdateFailed": "Cover update mislukt", "ToastCoverUpdateFailed": "Cover update mislukt",
-3
View File
@@ -769,7 +769,6 @@
"MessageItemsSelected": "{0} Gjenstander valgt", "MessageItemsSelected": "{0} Gjenstander valgt",
"MessageItemsUpdated": "{0} Gjenstander oppdatert", "MessageItemsUpdated": "{0} Gjenstander oppdatert",
"MessageJoinUsOn": "Følg oss nå", "MessageJoinUsOn": "Følg oss nå",
"MessageListeningSessionsInTheLastYear": "{0} Lyttesesjoner iløpet av siste året",
"MessageLoading": "Laster...", "MessageLoading": "Laster...",
"MessageLoadingFolders": "Laster mapper...", "MessageLoadingFolders": "Laster mapper...",
"MessageM4BFailed": "M4B mislykkes!", "MessageM4BFailed": "M4B mislykkes!",
@@ -882,8 +881,6 @@
"ToastChaptersRemoved": "Kapitler fjernet", "ToastChaptersRemoved": "Kapitler fjernet",
"ToastChaptersUpdated": "Kapitler oppdatert", "ToastChaptersUpdated": "Kapitler oppdatert",
"ToastCollectionItemsAddFailed": "Feil med å legge til element(er)", "ToastCollectionItemsAddFailed": "Feil med å legge til element(er)",
"ToastCollectionItemsAddSuccess": "Element(er) lagt til samlingen",
"ToastCollectionItemsRemoveSuccess": "Gjenstand(er) fjernet fra samling",
"ToastCollectionRemoveSuccess": "Samling fjernet", "ToastCollectionRemoveSuccess": "Samling fjernet",
"ToastCollectionUpdateSuccess": "samlingupdated", "ToastCollectionUpdateSuccess": "samlingupdated",
"ToastCoverUpdateFailed": "Oppdatering av bilde feilet", "ToastCoverUpdateFailed": "Oppdatering av bilde feilet",
-2
View File
@@ -657,7 +657,6 @@
"MessageInsertChapterBelow": "Wstaw rozdział poniżej", "MessageInsertChapterBelow": "Wstaw rozdział poniżej",
"MessageItemsSelected": "{0} zaznaczone elementy", "MessageItemsSelected": "{0} zaznaczone elementy",
"MessageJoinUsOn": "Dołącz do nas na", "MessageJoinUsOn": "Dołącz do nas na",
"MessageListeningSessionsInTheLastYear": "Sesje słuchania w ostatnim roku: {0}",
"MessageLoading": "Ładowanie...", "MessageLoading": "Ładowanie...",
"MessageLoadingFolders": "Ładowanie folderów...", "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>.", "MessageLogsDescription": "Logi zapisane są w <code>/metadata/logs</code> jako pliki JSON. Logi awaryjne są zapisane w <code>/metadata/logs/crash_logs.txt</code>.",
@@ -772,7 +771,6 @@
"ToastBookmarkCreateSuccess": "Dodano zakładkę", "ToastBookmarkCreateSuccess": "Dodano zakładkę",
"ToastBookmarkRemoveSuccess": "Zakładka została usunięta", "ToastBookmarkRemoveSuccess": "Zakładka została usunięta",
"ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę", "ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę",
"ToastCollectionItemsRemoveSuccess": "Przedmiot(y) zostały usunięte z kolekcji",
"ToastCollectionRemoveSuccess": "Kolekcja usunięta", "ToastCollectionRemoveSuccess": "Kolekcja usunięta",
"ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję", "ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję",
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę", "ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",
-2
View File
@@ -630,7 +630,6 @@
"MessageItemsSelected": "{0} Itens Selecionados", "MessageItemsSelected": "{0} Itens Selecionados",
"MessageItemsUpdated": "{0} Itens Atualizados", "MessageItemsUpdated": "{0} Itens Atualizados",
"MessageJoinUsOn": "Junte-se a nós", "MessageJoinUsOn": "Junte-se a nós",
"MessageListeningSessionsInTheLastYear": "{0} sessões de escuta no ano anterior",
"MessageLoading": "Carregando...", "MessageLoading": "Carregando...",
"MessageLoadingFolders": "Carregando pastas...", "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>.", "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>.",
@@ -735,7 +734,6 @@
"ToastCachePurgeSuccess": "Cache apagado com sucesso", "ToastCachePurgeSuccess": "Cache apagado com sucesso",
"ToastChaptersHaveErrors": "Capítulos com erro", "ToastChaptersHaveErrors": "Capítulos com erro",
"ToastChaptersMustHaveTitles": "Capítulos precisam ter títulos", "ToastChaptersMustHaveTitles": "Capítulos precisam ter títulos",
"ToastCollectionItemsRemoveSuccess": "Item(ns) removidos da coleção",
"ToastCollectionRemoveSuccess": "Coleção removida", "ToastCollectionRemoveSuccess": "Coleção removida",
"ToastCollectionUpdateSuccess": "Coleção atualizada", "ToastCollectionUpdateSuccess": "Coleção atualizada",
"ToastDeleteFileFailed": "Falha ao apagar arquivo", "ToastDeleteFileFailed": "Falha ao apagar arquivo",
+2 -3
View File
@@ -300,6 +300,7 @@
"LabelDiscover": "Не начато", "LabelDiscover": "Не начато",
"LabelDownload": "Скачать", "LabelDownload": "Скачать",
"LabelDownloadNEpisodes": "Скачать {0} эпизодов", "LabelDownloadNEpisodes": "Скачать {0} эпизодов",
"LabelDownloadable": "Загружаемый",
"LabelDuration": "Длина", "LabelDuration": "Длина",
"LabelDurationComparisonExactMatch": "(точное совпадение)", "LabelDurationComparisonExactMatch": "(точное совпадение)",
"LabelDurationComparisonLonger": "({0} дольше)", "LabelDurationComparisonLonger": "({0} дольше)",
@@ -588,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента", "LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента",
"LabelSettingsTimeFormat": "Формат времени", "LabelSettingsTimeFormat": "Формат времени",
"LabelShare": "Поделиться", "LabelShare": "Поделиться",
"LabelShareDownloadableHelp": "Позволяет пользователям с помощью ссылки загрузить zip-файл элемента библиотеки.",
"LabelShareOpen": "Общедоступно", "LabelShareOpen": "Общедоступно",
"LabelShareURL": "Общедоступный URL", "LabelShareURL": "Общедоступный URL",
"LabelShowAll": "Показать все", "LabelShowAll": "Показать все",
@@ -771,7 +773,6 @@
"MessageItemsSelected": "{0} Элементов выделено", "MessageItemsSelected": "{0} Элементов выделено",
"MessageItemsUpdated": "{0} Элементов обновлено", "MessageItemsUpdated": "{0} Элементов обновлено",
"MessageJoinUsOn": "Присоединяйтесь к нам в", "MessageJoinUsOn": "Присоединяйтесь к нам в",
"MessageListeningSessionsInTheLastYear": "{0} сеансов прослушивания в прошлом году",
"MessageLoading": "Загрузка...", "MessageLoading": "Загрузка...",
"MessageLoadingFolders": "Загрузка каталогов...", "MessageLoadingFolders": "Загрузка каталогов...",
"MessageLogsDescription": "Журналы хранятся в <code>/metadata/logs</code> в виде JSON-файлов. Журналы сбоев хранятся в <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "Журналы хранятся в <code>/metadata/logs</code> в виде JSON-файлов. Журналы сбоев хранятся в <code>/metadata/logs/crash_logs.txt</code>.",
@@ -959,8 +960,6 @@
"ToastChaptersRemoved": "Удалены главы", "ToastChaptersRemoved": "Удалены главы",
"ToastChaptersUpdated": "Обновленные главы", "ToastChaptersUpdated": "Обновленные главы",
"ToastCollectionItemsAddFailed": "Не удалось добавить элемент(ы) в коллекцию", "ToastCollectionItemsAddFailed": "Не удалось добавить элемент(ы) в коллекцию",
"ToastCollectionItemsAddSuccess": "Элемент(ы) добавлены в коллекцию",
"ToastCollectionItemsRemoveSuccess": "Элемент(ы), удалены из коллекции",
"ToastCollectionRemoveSuccess": "Коллекция удалена", "ToastCollectionRemoveSuccess": "Коллекция удалена",
"ToastCollectionUpdateSuccess": "Коллекция обновлена", "ToastCollectionUpdateSuccess": "Коллекция обновлена",
"ToastCoverUpdateFailed": "Не удалось обновить обложку", "ToastCoverUpdateFailed": "Не удалось обновить обложку",
+2 -3
View File
@@ -300,6 +300,7 @@
"LabelDiscover": "Odkrij", "LabelDiscover": "Odkrij",
"LabelDownload": "Prenos", "LabelDownload": "Prenos",
"LabelDownloadNEpisodes": "Prenesi {0} epizod", "LabelDownloadNEpisodes": "Prenesi {0} epizod",
"LabelDownloadable": "Možen prenos",
"LabelDuration": "Trajanje", "LabelDuration": "Trajanje",
"LabelDurationComparisonExactMatch": "(natančno ujemanje)", "LabelDurationComparisonExactMatch": "(natančno ujemanje)",
"LabelDurationComparisonLonger": "({0} dlje)", "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", "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", "LabelSettingsTimeFormat": "Oblika časa",
"LabelShare": "Deli", "LabelShare": "Deli",
"LabelShareDownloadableHelp": "Omogoča uporabnikom s povezavo skupne rabe, da prenesejo zip datoteko elementa knjižnice.",
"LabelShareOpen": "Deli odprto", "LabelShareOpen": "Deli odprto",
"LabelShareURL": "Deli URL", "LabelShareURL": "Deli URL",
"LabelShowAll": "Prikaži vse", "LabelShowAll": "Prikaži vse",
@@ -771,7 +773,6 @@
"MessageItemsSelected": "{0} izbranih elementov", "MessageItemsSelected": "{0} izbranih elementov",
"MessageItemsUpdated": "Št. posodobljenih elementov: {0}", "MessageItemsUpdated": "Št. posodobljenih elementov: {0}",
"MessageJoinUsOn": "Pridružite se nam", "MessageJoinUsOn": "Pridružite se nam",
"MessageListeningSessionsInTheLastYear": "{0} sej poslušanja v zadnjem letu",
"MessageLoading": "Nalagam...", "MessageLoading": "Nalagam...",
"MessageLoadingFolders": "Nalagam mape...", "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>.", "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>.",
@@ -959,8 +960,6 @@
"ToastChaptersRemoved": "Poglavja so odstranjena", "ToastChaptersRemoved": "Poglavja so odstranjena",
"ToastChaptersUpdated": "Poglavja so posodobljena", "ToastChaptersUpdated": "Poglavja so posodobljena",
"ToastCollectionItemsAddFailed": "Dodajanje elementov v zbirko ni uspelo", "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", "ToastCollectionRemoveSuccess": "Zbirka je bila odstranjena",
"ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena", "ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena",
"ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela", "ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela",
-2
View File
@@ -590,7 +590,6 @@
"MessageItemsSelected": "{0} Objekt markerade", "MessageItemsSelected": "{0} Objekt markerade",
"MessageItemsUpdated": "{0} Objekt uppdaterade", "MessageItemsUpdated": "{0} Objekt uppdaterade",
"MessageJoinUsOn": "Anslut dig till oss på", "MessageJoinUsOn": "Anslut dig till oss på",
"MessageListeningSessionsInTheLastYear": "{0} lyssningssessioner det senaste året",
"MessageLoading": "Laddar...", "MessageLoading": "Laddar...",
"MessageLoadingFolders": "Laddar mappar...", "MessageLoadingFolders": "Laddar mappar...",
"MessageM4BFailed": "M4B misslyckades!", "MessageM4BFailed": "M4B misslyckades!",
@@ -705,7 +704,6 @@
"ToastBookmarkUpdateSuccess": "Bokmärket har uppdaterats", "ToastBookmarkUpdateSuccess": "Bokmärket har uppdaterats",
"ToastChaptersHaveErrors": "Kapitlen har fel", "ToastChaptersHaveErrors": "Kapitlen har fel",
"ToastChaptersMustHaveTitles": "Kapitel måste ha titlar", "ToastChaptersMustHaveTitles": "Kapitel måste ha titlar",
"ToastCollectionItemsRemoveSuccess": "Objekt borttagna från samlingen",
"ToastCollectionRemoveSuccess": "Samlingen har raderats", "ToastCollectionRemoveSuccess": "Samlingen har raderats",
"ToastCollectionUpdateSuccess": "Samlingen har uppdaterats", "ToastCollectionUpdateSuccess": "Samlingen har uppdaterats",
"ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat", "ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat",
+2 -3
View File
@@ -300,6 +300,7 @@
"LabelDiscover": "Огляд", "LabelDiscover": "Огляд",
"LabelDownload": "Завантажити", "LabelDownload": "Завантажити",
"LabelDownloadNEpisodes": "Завантажити епізодів: {0}", "LabelDownloadNEpisodes": "Завантажити епізодів: {0}",
"LabelDownloadable": "Можна завантажити",
"LabelDuration": "Тривалість", "LabelDuration": "Тривалість",
"LabelDurationComparisonExactMatch": "(повний збіг)", "LabelDurationComparisonExactMatch": "(повний збіг)",
"LabelDurationComparisonLonger": "(на {0} довше)", "LabelDurationComparisonLonger": "(на {0} довше)",
@@ -588,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "За замовчуванням файли метаданих зберігаються у /metadata/items. Цей параметр увімкне збереження метаданих у теці елемента бібліотеки", "LabelSettingsStoreMetadataWithItemHelp": "За замовчуванням файли метаданих зберігаються у /metadata/items. Цей параметр увімкне збереження метаданих у теці елемента бібліотеки",
"LabelSettingsTimeFormat": "Формат часу", "LabelSettingsTimeFormat": "Формат часу",
"LabelShare": "Поділитися", "LabelShare": "Поділитися",
"LabelShareDownloadableHelp": "Дозволяє користувачам із посиланням для спільного доступу завантажувати zip-файл елемента бібліотеки.",
"LabelShareOpen": "Поділитися відкрито", "LabelShareOpen": "Поділитися відкрито",
"LabelShareURL": "Поділитися URL", "LabelShareURL": "Поділитися URL",
"LabelShowAll": "Показати все", "LabelShowAll": "Показати все",
@@ -771,7 +773,6 @@
"MessageItemsSelected": "Обрано елементів: {0}", "MessageItemsSelected": "Обрано елементів: {0}",
"MessageItemsUpdated": "Оновлено елементів: {0}", "MessageItemsUpdated": "Оновлено елементів: {0}",
"MessageJoinUsOn": "Приєднуйтесь до", "MessageJoinUsOn": "Приєднуйтесь до",
"MessageListeningSessionsInTheLastYear": "Сесій прослуховування минулого року: {0}",
"MessageLoading": "Завантаження...", "MessageLoading": "Завантаження...",
"MessageLoadingFolders": "Завантаження тек...", "MessageLoadingFolders": "Завантаження тек...",
"MessageLogsDescription": "Журнали зберігаються у <code>/metadata/logs</code> як JSON-файли. Журнали збоїв зберігаються у <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "Журнали зберігаються у <code>/metadata/logs</code> як JSON-файли. Журнали збоїв зберігаються у <code>/metadata/logs/crash_logs.txt</code>.",
@@ -959,8 +960,6 @@
"ToastChaptersRemoved": "Розділи видалені", "ToastChaptersRemoved": "Розділи видалені",
"ToastChaptersUpdated": "Розділи оновлені", "ToastChaptersUpdated": "Розділи оновлені",
"ToastCollectionItemsAddFailed": "Не вдалося додати елемент(и) до колекції", "ToastCollectionItemsAddFailed": "Не вдалося додати елемент(и) до колекції",
"ToastCollectionItemsAddSuccess": "Елемент(и) успішно додано до колекції",
"ToastCollectionItemsRemoveSuccess": "Елемент(и) видалено з добірки",
"ToastCollectionRemoveSuccess": "Добірку видалено", "ToastCollectionRemoveSuccess": "Добірку видалено",
"ToastCollectionUpdateSuccess": "Добірку оновлено", "ToastCollectionUpdateSuccess": "Добірку оновлено",
"ToastCoverUpdateFailed": "Не вдалося оновити обкладинку", "ToastCoverUpdateFailed": "Не вдалося оновити обкладинку",
-2
View File
@@ -581,7 +581,6 @@
"MessageItemsSelected": "{0} Mục Đã Chọn", "MessageItemsSelected": "{0} Mục Đã Chọn",
"MessageItemsUpdated": "{0} Mục Đã Cập Nhật", "MessageItemsUpdated": "{0} Mục Đã Cập Nhật",
"MessageJoinUsOn": "Tham gia cùng chúng tôi trên", "MessageJoinUsOn": "Tham gia cùng chúng tôi trên",
"MessageListeningSessionsInTheLastYear": "{0} phiên nghe trong năm qua",
"MessageLoading": "Đang tải...", "MessageLoading": "Đang tải...",
"MessageLoadingFolders": "Đang tải các thư mục...", "MessageLoadingFolders": "Đang tải các thư mục...",
"MessageM4BFailed": "M4B thất bại!", "MessageM4BFailed": "M4B thất bại!",
@@ -683,7 +682,6 @@
"ToastBookmarkUpdateSuccess": "Đánh dấu đã được cập nhật", "ToastBookmarkUpdateSuccess": "Đánh dấu đã được cập nhật",
"ToastChaptersHaveErrors": "Các chương có lỗi", "ToastChaptersHaveErrors": "Các chương có lỗi",
"ToastChaptersMustHaveTitles": "Các chương phải có tiêu đề", "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", "ToastCollectionRemoveSuccess": "Bộ sưu tập đã được xóa",
"ToastCollectionUpdateSuccess": "Bộ sưu tập đã được cập nhật", "ToastCollectionUpdateSuccess": "Bộ sưu tập đã được cập nhật",
"ToastItemCoverUpdateSuccess": "Ảnh bìa mục đã được cập nhật", "ToastItemCoverUpdateSuccess": "Ảnh bìa mục đã được cập nhật",
-3
View File
@@ -771,7 +771,6 @@
"MessageItemsSelected": "已选定 {0} 个项目", "MessageItemsSelected": "已选定 {0} 个项目",
"MessageItemsUpdated": "已更新 {0} 个项目", "MessageItemsUpdated": "已更新 {0} 个项目",
"MessageJoinUsOn": "加入我们", "MessageJoinUsOn": "加入我们",
"MessageListeningSessionsInTheLastYear": "去年收听 {0} 个会话",
"MessageLoading": "正在加载...", "MessageLoading": "正在加载...",
"MessageLoadingFolders": "加载文件夹...", "MessageLoadingFolders": "加载文件夹...",
"MessageLogsDescription": "日志以 JSON 文件形式存储在 <code>/metadata/logs</code> 目录中. 崩溃日志存储在 <code>/metadata/logs/crash_logs.txt</code> 目录中.", "MessageLogsDescription": "日志以 JSON 文件形式存储在 <code>/metadata/logs</code> 目录中. 崩溃日志存储在 <code>/metadata/logs/crash_logs.txt</code> 目录中.",
@@ -959,8 +958,6 @@
"ToastChaptersRemoved": "已删除章节", "ToastChaptersRemoved": "已删除章节",
"ToastChaptersUpdated": "章节已更新", "ToastChaptersUpdated": "章节已更新",
"ToastCollectionItemsAddFailed": "项目添加到收藏夹失败", "ToastCollectionItemsAddFailed": "项目添加到收藏夹失败",
"ToastCollectionItemsAddSuccess": "项目添加到收藏夹成功",
"ToastCollectionItemsRemoveSuccess": "项目从收藏夹移除",
"ToastCollectionRemoveSuccess": "收藏夹已删除", "ToastCollectionRemoveSuccess": "收藏夹已删除",
"ToastCollectionUpdateSuccess": "收藏夹已更新", "ToastCollectionUpdateSuccess": "收藏夹已更新",
"ToastCoverUpdateFailed": "封面更新失败", "ToastCoverUpdateFailed": "封面更新失败",
-2
View File
@@ -625,7 +625,6 @@
"MessageItemsSelected": "已選定 {0} 個項目", "MessageItemsSelected": "已選定 {0} 個項目",
"MessageItemsUpdated": "已更新 {0} 個項目", "MessageItemsUpdated": "已更新 {0} 個項目",
"MessageJoinUsOn": "加入我們", "MessageJoinUsOn": "加入我們",
"MessageListeningSessionsInTheLastYear": "去年收聽 {0} 個會話",
"MessageLoading": "讀取...", "MessageLoading": "讀取...",
"MessageLoadingFolders": "讀取資料夾...", "MessageLoadingFolders": "讀取資料夾...",
"MessageM4BFailed": "M4B 失敗!", "MessageM4BFailed": "M4B 失敗!",
@@ -727,7 +726,6 @@
"ToastBookmarkUpdateSuccess": "書籤已更新", "ToastBookmarkUpdateSuccess": "書籤已更新",
"ToastChaptersHaveErrors": "章節有錯誤", "ToastChaptersHaveErrors": "章節有錯誤",
"ToastChaptersMustHaveTitles": "章節必須有標題", "ToastChaptersMustHaveTitles": "章節必須有標題",
"ToastCollectionItemsRemoveSuccess": "項目從收藏夾移除",
"ToastCollectionRemoveSuccess": "收藏夾已刪除", "ToastCollectionRemoveSuccess": "收藏夾已刪除",
"ToastCollectionUpdateSuccess": "收藏夾已更新", "ToastCollectionUpdateSuccess": "收藏夾已更新",
"ToastItemCoverUpdateSuccess": "項目封面已更新", "ToastItemCoverUpdateSuccess": "項目封面已更新",
-2
View File
@@ -13,8 +13,6 @@ if (isDev) {
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1' if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath 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.SOURCE = 'local'
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || '' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.17.6", "version": "2.17.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.17.6", "version": "2.17.7",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.17.6", "version": "2.17.7",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
+2 -2
View File
@@ -16,8 +16,8 @@ const server = require('./server/Server')
global.appRoot = __dirname global.appRoot = __dirname
const inputConfig = options.config ? Path.resolve(options.config) : null var inputConfig = options.config ? Path.resolve(options.config) : null
const inputMetadata = options.metadata ? Path.resolve(options.metadata) : null var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
const PORT = options.port || process.env.PORT || 3333 const PORT = options.port || process.env.PORT || 3333
const HOST = options.host || process.env.HOST const HOST = options.host || process.env.HOST
-19
View File
@@ -16,8 +16,6 @@ const Logger = require('./Logger')
*/ */
class Auth { class Auth {
constructor() { constructor() {
this.pluginManifests = []
// Map of openId sessions indexed by oauth2 state-variable // Map of openId sessions indexed by oauth2 state-variable
this.openIdAuthSession = new Map() this.openIdAuthSession = new Map()
this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/] this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/]
@@ -935,28 +933,11 @@ class Auth {
*/ */
async getUserLoginResponsePayload(user) { async getUserLoginResponsePayload(user) {
const libraryIds = await Database.libraryModel.getAllLibraryIds() 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 { return {
user: user.toOldJSONForBrowser(), user: user.toOldJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
serverSettings: Database.serverSettings.toJSONForBrowser(), serverSettings: Database.serverSettings.toJSONForBrowser(),
ereaderDevices: Database.emailSettings.getEReaderDevices(user), ereaderDevices: Database.emailSettings.getEReaderDevices(user),
plugins,
Source: global.Source Source: global.Source
} }
} }
-21
View File
@@ -152,11 +152,6 @@ class Database {
return this.models.device return this.models.device
} }
/** @type {typeof import('./models/Plugin')} */
get pluginModel() {
return this.models.plugin
}
/** /**
* Check if db file exists * Check if db file exists
* @returns {boolean} * @returns {boolean}
@@ -310,7 +305,6 @@ class Database {
require('./models/Setting').init(this.sequelize) require('./models/Setting').init(this.sequelize)
require('./models/CustomMetadataProvider').init(this.sequelize) require('./models/CustomMetadataProvider').init(this.sequelize)
require('./models/MediaItemShare').init(this.sequelize) require('./models/MediaItemShare').init(this.sequelize)
require('./models/Plugin').init(this.sequelize)
return this.sequelize.sync({ force, alter: false }) return this.sequelize.sync({ force, alter: false })
} }
@@ -412,21 +406,6 @@ class Database {
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook))) return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
} }
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) { async createLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false if (!this.sequelize) return false
await oldLibraryItem.saveMetadata() await oldLibraryItem.saveMetadata()
+24 -12
View File
@@ -6,6 +6,7 @@ const util = require('util')
const fs = require('./libs/fsExtra') const fs = require('./libs/fsExtra')
const fileUpload = require('./libs/expressFileupload') const fileUpload = require('./libs/expressFileupload')
const cookieParser = require('cookie-parser') const cookieParser = require('cookie-parser')
const axios = require('axios')
const { version } = require('../package.json') const { version } = require('../package.json')
@@ -28,6 +29,7 @@ const AbMergeManager = require('./managers/AbMergeManager')
const CacheManager = require('./managers/CacheManager') const CacheManager = require('./managers/CacheManager')
const BackupManager = require('./managers/BackupManager') const BackupManager = require('./managers/BackupManager')
const PlaybackSessionManager = require('./managers/PlaybackSessionManager') const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
const PodcastManager = require('./managers/PodcastManager')
const AudioMetadataMangaer = require('./managers/AudioMetadataManager') const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager') const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager') const CronManager = require('./managers/CronManager')
@@ -35,7 +37,6 @@ const ApiCacheManager = require('./managers/ApiCacheManager')
const BinaryManager = require('./managers/BinaryManager') const BinaryManager = require('./managers/BinaryManager')
const ShareManager = require('./managers/ShareManager') const ShareManager = require('./managers/ShareManager')
const LibraryScanner = require('./scanner/LibraryScanner') const LibraryScanner = require('./scanner/LibraryScanner')
const PluginManager = require('./managers/PluginManager')
//Import the main Passport and Express-Session library //Import the main Passport and Express-Session library
const passport = require('passport') const passport = require('passport')
@@ -54,7 +55,26 @@ class Server {
global.XAccel = process.env.USE_X_ACCEL global.XAccel = process.env.USE_X_ACCEL
global.AllowCors = process.env.ALLOW_CORS === '1' 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`) Logger.info(`[Server] SSRF Request Filter Disabled`)
global.DisableSsrfRequestFilter = () => true global.DisableSsrfRequestFilter = () => true
} else if (process.env.SSRF_REQUEST_FILTER_WHITELIST?.length) { } else if (process.env.SSRF_REQUEST_FILTER_WHITELIST?.length) {
@@ -79,8 +99,9 @@ class Server {
this.backupManager = new BackupManager() this.backupManager = new BackupManager()
this.abMergeManager = new AbMergeManager() this.abMergeManager = new AbMergeManager()
this.playbackSessionManager = new PlaybackSessionManager() this.playbackSessionManager = new PlaybackSessionManager()
this.podcastManager = new PodcastManager()
this.audioMetadataManager = new AudioMetadataMangaer() this.audioMetadataManager = new AudioMetadataMangaer()
this.cronManager = new CronManager(this.playbackSessionManager) this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
this.apiCacheManager = new ApiCacheManager() this.apiCacheManager = new ApiCacheManager()
this.binaryManager = new BinaryManager() this.binaryManager = new BinaryManager()
@@ -160,15 +181,6 @@ class Server {
LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask) 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
}
} }
/** /**
+86 -45
View File
@@ -5,13 +5,17 @@ const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const RssFeedManager = require('../managers/RssFeedManager') const RssFeedManager = require('../managers/RssFeedManager')
const Collection = require('../objects/Collection')
/** /**
* @typedef RequestUserObject * @typedef RequestUserObject
* @property {import('../models/User')} user * @property {import('../models/User')} user
* *
* @typedef {Request & RequestUserObject} RequestWithUser * @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/Collection')} collection
*
* @typedef {RequestWithUser & RequestEntityObject} CollectionControllerRequest
*/ */
class CollectionController { class CollectionController {
@@ -25,36 +29,71 @@ class CollectionController {
* @param {Response} res * @param {Response} res
*/ */
async create(req, res) { async create(req, res) {
const newCollection = new Collection() const reqBody = req.body || {}
req.body.userId = req.user.id
if (!newCollection.setData(req.body)) { // Validation
if (!reqBody.name || !reqBody.libraryId) {
return res.status(400).send('Invalid collection data') 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 // Load library items
await Database.collectionModel.createFromOld(newCollection) const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
// Get library items in collection where: {
const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection) id: libraryItemIds,
libraryId: reqBody.libraryId,
// Create collectionBook records mediaType: 'book'
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++
})
} }
} })
if (collectionBooksToAdd.length) { if (libraryItems.length !== libraryItemIds.length) {
await Database.createBulkCollectionBooks(collectionBooksToAdd) 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) SocketAuthority.emitter('collection_added', jsonExpanded)
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@@ -75,7 +114,7 @@ class CollectionController {
/** /**
* GET: /api/collections/:id * GET: /api/collections/:id
* *
* @param {RequestWithUser} req * @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async findOne(req, res) { async findOne(req, res) {
@@ -94,7 +133,7 @@ class CollectionController {
* PATCH: /api/collections/:id * PATCH: /api/collections/:id
* Update collection * Update collection
* *
* @param {RequestWithUser} req * @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async update(req, res) { async update(req, res) {
@@ -158,7 +197,7 @@ class CollectionController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async delete(req, res) { async delete(req, res) {
@@ -178,7 +217,7 @@ class CollectionController {
* Add a single book to a collection * Add a single book to a collection
* Req.body { id: <library item id> } * Req.body { id: <library item id> }
* *
* @param {RequestWithUser} req * @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async addBook(req, res) { async addBook(req, res) {
@@ -212,7 +251,7 @@ class CollectionController {
* Remove a single book from a collection. Re-order books * Remove a single book from a collection. Re-order books
* TODO: bookId is actually libraryItemId. Clients need updating to use bookId * TODO: bookId is actually libraryItemId. Clients need updating to use bookId
* *
* @param {RequestWithUser} req * @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async removeBook(req, res) { async removeBook(req, res) {
@@ -257,29 +296,31 @@ class CollectionController {
* Add multiple books to collection * Add multiple books to collection
* Req.body { books: <Array of library item ids> } * Req.body { books: <Array of library item ids> }
* *
* @param {RequestWithUser} req * @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async addBatch(req, res) { async addBatch(req, res) {
// filter out invalid libraryItemIds // filter out invalid libraryItemIds
const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string') const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string')
if (!bookIdsToAdd.length) { 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 // Get library items associated with ids
const libraryItems = await Database.libraryItemModel.findAll({ const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
where: { where: {
id: { id: bookIdsToAdd,
[Sequelize.Op.in]: bookIdsToAdd libraryId: req.collection.libraryId,
} mediaType: 'book'
},
include: {
model: Database.bookModel
} }
}) })
if (!libraryItems.length) {
return res.status(400).send('Invalid request body. No valid books')
}
// Get collection books already in collection // Get collection books already in collection
/** @type {import('../models/CollectionBook')[]} */
const collectionBooks = await req.collection.getCollectionBooks() const collectionBooks = await req.collection.getCollectionBooks()
let order = collectionBooks.length + 1 let order = collectionBooks.length + 1
@@ -288,10 +329,10 @@ class CollectionController {
// Check and set new collection books to add // Check and set new collection books to add
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
if (!collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) { if (!collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {
collectionBooksToAdd.push({ collectionBooksToAdd.push({
collectionId: req.collection.id, collectionId: req.collection.id,
bookId: libraryItem.media.id, bookId: libraryItem.mediaId,
order: order++ order: order++
}) })
hasUpdated = true hasUpdated = true
@@ -302,7 +343,8 @@ class CollectionController {
let jsonExpanded = null let jsonExpanded = null
if (hasUpdated) { if (hasUpdated) {
await Database.createBulkCollectionBooks(collectionBooksToAdd) await Database.collectionBookModel.bulkCreate(collectionBooksToAdd)
jsonExpanded = await req.collection.getOldJsonExpanded() jsonExpanded = await req.collection.getOldJsonExpanded()
SocketAuthority.emitter('collection_updated', jsonExpanded) SocketAuthority.emitter('collection_updated', jsonExpanded)
} else { } else {
@@ -316,7 +358,7 @@ class CollectionController {
* Remove multiple books from collection * Remove multiple books from collection
* Req.body { books: <Array of library item ids> } * Req.body { books: <Array of library item ids> }
* *
* @param {RequestWithUser} req * @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async removeBatch(req, res) { async removeBatch(req, res) {
@@ -329,9 +371,7 @@ class CollectionController {
// Get library items associated with ids // Get library items associated with ids
const libraryItems = await Database.libraryItemModel.findAll({ const libraryItems = await Database.libraryItemModel.findAll({
where: { where: {
id: { id: bookIdsToRemove
[Sequelize.Op.in]: bookIdsToRemove
}
}, },
include: { include: {
model: Database.bookModel model: Database.bookModel
@@ -339,6 +379,7 @@ class CollectionController {
}) })
// Get collection books already in collection // Get collection books already in collection
/** @type {import('../models/CollectionBook')[]} */
const collectionBooks = await req.collection.getCollectionBooks({ const collectionBooks = await req.collection.getCollectionBooks({
order: [['order', 'ASC']] order: [['order', 'ASC']]
}) })
+2 -3
View File
@@ -19,7 +19,6 @@ const Scanner = require('../scanner/Scanner')
const Database = require('../Database') const Database = require('../Database')
const Watcher = require('../Watcher') const Watcher = require('../Watcher')
const RssFeedManager = require('../managers/RssFeedManager') const RssFeedManager = require('../managers/RssFeedManager')
const PodcastManager = require('../managers/PodcastManager')
const libraryFilters = require('../utils/queries/libraryFilters') const libraryFilters = require('../utils/queries/libraryFilters')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
@@ -220,7 +219,7 @@ class LibraryController {
* @param {Response} res * @param {Response} res
*/ */
async getEpisodeDownloadQueue(req, res) { async getEpisodeDownloadQueue(req, res) {
const libraryDownloadQueueDetails = PodcastManager.getDownloadQueueDetails(req.library.id) const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id)
res.json(libraryDownloadQueueDetails) res.json(libraryDownloadQueueDetails)
} }
@@ -1289,7 +1288,7 @@ class LibraryController {
} }
}) })
const opmlText = PodcastManager.generateOPMLFileText(podcasts) const opmlText = this.podcastManager.generateOPMLFileText(podcasts)
res.type('application/xml') res.type('application/xml')
res.send(opmlText) res.send(opmlText)
} }
+248 -131
View File
@@ -18,13 +18,22 @@ const RssFeedManager = require('../managers/RssFeedManager')
const CacheManager = require('../managers/CacheManager') const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager') const CoverManager = require('../managers/CoverManager')
const ShareManager = require('../managers/ShareManager') const ShareManager = require('../managers/ShareManager')
const PodcastManager = require('../managers/PodcastManager')
/** /**
* @typedef RequestUserObject * @typedef RequestUserObject
* @property {import('../models/User')} user * @property {import('../models/User')} user
* *
* @typedef {Request & RequestUserObject} RequestWithUser * @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 { class LibraryItemController {
@@ -36,17 +45,17 @@ class LibraryItemController {
* ?include=progress,rssfeed,downloads,share * ?include=progress,rssfeed,downloads,share
* ?expanded=1 * ?expanded=1
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async findOne(req, res) { async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',') const includeEntities = (req.query.include || '').split(',')
if (req.query.expanded == 1) { if (req.query.expanded == 1) {
var item = req.libraryItem.toJSONExpanded() const item = req.libraryItem.toOldJSONExpanded()
// Include users media progress // Include users media progress
if (includeEntities.includes('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) item.userMediaProgress = req.user.getOldMediaProgress(item.id, episodeId)
} }
@@ -60,37 +69,41 @@ class LibraryItemController {
} }
if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) { 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()) item.episodeDownloadsQueued = downloadsInQueue.map((d) => d.toJSONForClient())
if (PodcastManager.currentDownload?.libraryItemId === req.libraryItem.id) { if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
item.episodesDownloading = [PodcastManager.currentDownload.toJSONForClient()] item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()]
} }
} }
return res.json(item) return res.json(item)
} }
res.json(req.libraryItem) res.json(req.libraryItem.toOldJSON())
} }
/** /**
* PATCH: /api/items/:id
* *
* @param {RequestWithUser} req * @deprecated
* Use the updateMedia /api/items/:id/media endpoint instead or updateCover /api/items/:id/cover
*
* @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async update(req, res) { async update(req, res) {
var libraryItem = req.libraryItem
// Item has cover and update is removing cover so purge it from cache // 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)) { if (req.libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
await CacheManager.purgeCoverCache(libraryItem.id) await CacheManager.purgeCoverCache(req.libraryItem.id)
} }
const hasUpdates = libraryItem.update(req.body) const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem)
const hasUpdates = oldLibraryItem.update(req.body)
if (hasUpdates) { if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated now saving`) Logger.debug(`[LibraryItemController] Updated now saving`)
await Database.updateLibraryItem(libraryItem) await Database.updateLibraryItem(oldLibraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
} }
res.json(libraryItem.toJSON()) res.json(oldLibraryItem.toJSON())
} }
/** /**
@@ -101,7 +114,7 @@ class LibraryItemController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async delete(req, res) { async delete(req, res) {
@@ -112,14 +125,14 @@ class LibraryItemController {
const authorIds = [] const authorIds = []
const seriesIds = [] const seriesIds = []
if (req.libraryItem.isPodcast) { 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 { } else {
mediaItemIds.push(req.libraryItem.media.id) mediaItemIds.push(req.libraryItem.media.id)
if (req.libraryItem.media.metadata.authors?.length) { if (req.libraryItem.media.authors?.length) {
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id)) authorIds.push(...req.libraryItem.media.authors.map((au) => au.id))
} }
if (req.libraryItem.media.metadata.series?.length) { if (req.libraryItem.media.series?.length) {
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id)) seriesIds.push(...req.libraryItem.media.series.map((se) => se.id))
} }
} }
@@ -156,7 +169,7 @@ class LibraryItemController {
* GET: /api/items/:id/download * GET: /api/items/:id/download
* Download library item. Zip file if multiple files. * Download library item. Zip file if multiple files.
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async download(req, res) { async download(req, res) {
@@ -165,7 +178,7 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
const libraryItemPath = req.libraryItem.path 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}"`) Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
@@ -195,11 +208,10 @@ class LibraryItemController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async updateMedia(req, res) { async updateMedia(req, res) {
const libraryItem = req.libraryItem
const mediaPayload = req.body const mediaPayload = req.body
if (mediaPayload.url) { if (mediaPayload.url) {
@@ -208,44 +220,41 @@ class LibraryItemController {
} }
// Book specific // Book specific
if (libraryItem.isBook) { if (req.libraryItem.isBook) {
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, req.libraryItem.libraryId)
} }
// Podcast specific // Podcast specific
let isPodcastAutoDownloadUpdated = false let isPodcastAutoDownloadUpdated = false
if (libraryItem.isPodcast) { if (req.libraryItem.isPodcast) {
if (mediaPayload.autoDownloadEpisodes !== undefined && libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) { if (mediaPayload.autoDownloadEpisodes !== undefined && req.libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
isPodcastAutoDownloadUpdated = true 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 isPodcastAutoDownloadUpdated = true
} }
} }
// Book specific - Get all series being removed from this item // Book specific - Get all series being removed from this item
let seriesRemoved = [] let seriesRemoved = []
if (libraryItem.isBook && mediaPayload.metadata?.series) { if (req.libraryItem.isBook && mediaPayload.metadata?.series) {
const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || [] const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || []
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) seriesRemoved = req.libraryItem.media.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
} }
let authorsRemoved = [] let authorsRemoved = []
if (libraryItem.isBook && mediaPayload.metadata?.authors) { if (req.libraryItem.isBook && mediaPayload.metadata?.authors) {
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) authorsRemoved = req.libraryItem.media.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
} }
const hasUpdates = libraryItem.media.update(mediaPayload) || mediaPayload.url const hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
if (hasUpdates) { if (hasUpdates) {
libraryItem.updatedAt = Date.now()
if (isPodcastAutoDownloadUpdated) { if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(libraryItem) this.cronManager.checkUpdatePodcastCron(req.libraryItem)
} }
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`)
await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
if (authorsRemoved.length) { if (authorsRemoved.length) {
// Check remove empty authors // Check remove empty authors
@@ -260,14 +269,14 @@ class LibraryItemController {
} }
res.json({ res.json({
updated: hasUpdates, updated: hasUpdates,
libraryItem libraryItem: req.libraryItem.toOldJSON()
}) })
} }
/** /**
* POST: /api/items/:id/cover * POST: /api/items/:id/cover
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
* @param {boolean} [updateAndReturnJson=true] * @param {boolean} [updateAndReturnJson=true]
*/ */
@@ -277,15 +286,13 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
let libraryItem = req.libraryItem
let result = null let result = null
if (req.body?.url) { if (req.body?.url) {
Logger.debug(`[LibraryItemController] Requesting download cover from url "${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) { } else if (req.files?.cover) {
Logger.debug(`[LibraryItemController] Handling uploaded 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 { } else {
return res.status(400).send('Invalid request no file or url') return res.status(400).send('Invalid request no file or url')
} }
@@ -297,8 +304,15 @@ class LibraryItemController {
} }
if (updateAndReturnJson) { if (updateAndReturnJson) {
await Database.updateLibraryItem(libraryItem) req.libraryItem.media.coverPath = result.cover
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) 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({ res.json({
success: true, success: true,
cover: result.cover cover: result.cover
@@ -309,22 +323,28 @@ class LibraryItemController {
/** /**
* PATCH: /api/items/:id/cover * PATCH: /api/items/:id/cover
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async updateCover(req, res) { async updateCover(req, res) {
const libraryItem = req.libraryItem
if (!req.body.cover) { if (!req.body.cover) {
return res.status(400).send('Invalid request no cover path') 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) { if (validationResult.error) {
return res.status(500).send(validationResult.error) return res.status(500).send(validationResult.error)
} }
if (validationResult.updated) { if (validationResult.updated) {
await Database.updateLibraryItem(libraryItem) req.libraryItem.media.coverPath = validationResult.cover
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) 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({ res.json({
success: true, success: true,
@@ -335,17 +355,22 @@ class LibraryItemController {
/** /**
* DELETE: /api/items/:id/cover * DELETE: /api/items/:id/cover
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async removeCover(req, 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) { // client uses updatedAt timestamp in URL to force refresh cover
libraryItem.updateMediaCover('') req.libraryItem.changed('updatedAt', true)
await CacheManager.purgeCoverCache(libraryItem.id) await req.libraryItem.save()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) await CacheManager.purgeCoverCache(req.libraryItem.id)
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
} }
res.sendStatus(200) res.sendStatus(200)
@@ -354,7 +379,7 @@ class LibraryItemController {
/** /**
* GET: /api/items/:id/cover * GET: /api/items/:id/cover
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async getCover(req, res) { async getCover(req, res) {
@@ -396,11 +421,11 @@ class LibraryItemController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
startPlaybackSession(req, res) { startPlaybackSession(req, res) {
if (!req.libraryItem.media.numTracks) { if (!req.libraryItem.hasAudioTracks) {
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`) Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
return res.sendStatus(404) return res.sendStatus(404)
} }
@@ -413,18 +438,18 @@ class LibraryItemController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
startEpisodePlaybackSession(req, res) { startEpisodePlaybackSession(req, res) {
var libraryItem = req.libraryItem if (!req.libraryItem.isPodcast) {
if (!libraryItem.media.numTracks) { Logger.error(`[LibraryItemController] startEpisodePlaybackSession invalid media type ${req.libraryItem.id}`)
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${libraryItem.id}`) return res.sendStatus(400)
return res.sendStatus(404)
} }
var episodeId = req.params.episodeId
if (!libraryItem.media.episodes.find((ep) => ep.id === episodeId)) { const episodeId = req.params.episodeId
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${libraryItem.id}`) 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) return res.sendStatus(404)
} }
@@ -434,30 +459,55 @@ class LibraryItemController {
/** /**
* PATCH: /api/items/:id/tracks * PATCH: /api/items/:id/tracks
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async updateTracks(req, res) { async updateTracks(req, res) {
var libraryItem = req.libraryItem const orderedFileData = req.body?.orderedFileData
var orderedFileData = req.body.orderedFileData
if (!libraryItem.media.updateAudioTracks) { if (!req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] updateTracks invalid media type ${libraryItem.id}`) Logger.error(`[LibraryItemController] updateTracks invalid media type ${req.libraryItem.id}`)
return res.sendStatus(500) return res.sendStatus(400)
} }
libraryItem.media.updateAudioTracks(orderedFileData) if (!Array.isArray(orderedFileData) || !orderedFileData.length) {
await Database.updateLibraryItem(libraryItem) Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) return res.sendStatus(400)
res.json(libraryItem.toJSON()) }
// 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 * POST /api/items/:id/match
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async match(req, res) { async match(req, res) {
const libraryItem = req.libraryItem
const reqBody = req.body || {} const reqBody = req.body || {}
const options = {} const options = {}
@@ -474,7 +524,8 @@ class LibraryItemController {
options.overrideDetails = !!reqBody.overrideDetails options.overrideDetails = !!reqBody.overrideDetails
} }
var matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options) const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem)
var matchResult = await Scanner.quickMatchLibraryItem(this, oldLibraryItem, options)
res.json(matchResult) res.json(matchResult)
} }
@@ -742,7 +793,7 @@ class LibraryItemController {
/** /**
* POST: /api/items/:id/scan * POST: /api/items/:id/scan
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async scan(req, res) { async scan(req, res) {
@@ -766,7 +817,7 @@ class LibraryItemController {
/** /**
* GET: /api/items/:id/metadata-object * GET: /api/items/:id/metadata-object
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
getMetadataObject(req, res) { getMetadataObject(req, res) {
@@ -775,7 +826,7 @@ class LibraryItemController {
return res.sendStatus(403) 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`) Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500) return res.sendStatus(500)
} }
@@ -786,7 +837,7 @@ class LibraryItemController {
/** /**
* POST: /api/items/:id/chapters * POST: /api/items/:id/chapters
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async updateMediaChapters(req, res) { async updateMediaChapters(req, res) {
@@ -795,26 +846,51 @@ class LibraryItemController {
return res.sendStatus(403) 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`) Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500) 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`) Logger.error(`[LibraryItemController] Invalid payload`)
return res.sendStatus(400) return res.sendStatus(400)
} }
const chapters = req.body.chapters || [] const chapters = req.body.chapters || []
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
if (wasUpdated) { let hasUpdates = false
await Database.updateLibraryItem(req.libraryItem) if (chapters.length !== req.libraryItem.media.chapters.length) {
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) 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()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
} }
res.json({ res.json({
success: true, success: true,
updated: wasUpdated updated: hasUpdates
}) })
} }
@@ -822,7 +898,7 @@ class LibraryItemController {
* GET: /api/items/:id/ffprobe/:fileid * GET: /api/items/:id/ffprobe/:fileid
* FFProbe JSON result from audio file * FFProbe JSON result from audio file
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async getFFprobeData(req, res) { async getFFprobeData(req, res) {
@@ -830,25 +906,21 @@ class LibraryItemController {
Logger.error(`[LibraryItemController] Non-admin user "${req.user.username}" attempted to get ffprobe data`) Logger.error(`[LibraryItemController] Non-admin user "${req.user.username}" attempted to get ffprobe data`)
return res.sendStatus(403) 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) { if (!audioFile) {
Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`) Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`)
return res.sendStatus(404) return res.sendStatus(404)
} }
const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile) const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile.metadata.path)
res.json(ffprobeData) res.json(ffprobeData)
} }
/** /**
* GET api/items/:id/file/:fileid * GET api/items/:id/file/:fileid
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res * @param {Response} res
*/ */
async getLibraryFile(req, res) { async getLibraryFile(req, res) {
@@ -871,7 +943,7 @@ class LibraryItemController {
/** /**
* DELETE api/items/:id/file/:fileid * DELETE api/items/:id/file/:fileid
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res * @param {Response} res
*/ */
async deleteLibraryFile(req, res) { async deleteLibraryFile(req, res) {
@@ -882,17 +954,35 @@ class LibraryItemController {
await fs.remove(libraryFile.metadata.path).catch((error) => { await fs.remove(libraryFile.metadata.path).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, 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)) { req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((lf) => lf.ino !== req.params.fileid)
// If book has no more media files then mark it as missing req.libraryItem.changed('libraryFiles', true)
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
req.libraryItem.setMissing() 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)
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) if (req.libraryItem.media.changed()) {
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) await req.libraryItem.media.save()
}
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.sendStatus(200) res.sendStatus(200)
} }
@@ -900,7 +990,7 @@ class LibraryItemController {
* GET api/items/:id/file/:fileid/download * GET api/items/:id/file/:fileid/download
* Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads * Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res * @param {Response} res
*/ */
async downloadLibraryFile(req, res) { async downloadLibraryFile(req, res) {
@@ -912,7 +1002,7 @@ class LibraryItemController {
return res.sendStatus(403) 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) { if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path) const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path)
@@ -948,13 +1038,13 @@ class LibraryItemController {
* fileid is only required when reading a supplementary ebook * fileid is only required when reading a supplementary ebook
* when no fileid is passed in the primary ebook will be returned * when no fileid is passed in the primary ebook will be returned
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async getEBookFile(req, res) { async getEBookFile(req, res) {
let ebookFile = null let ebookFile = null
if (req.params.fileid) { 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) { if (!ebookFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`) Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id') return res.status(400).send('Invalid ebook file id')
@@ -964,12 +1054,12 @@ class LibraryItemController {
} }
if (!ebookFile) { 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) return res.sendStatus(404)
} }
const ebookFilePath = ebookFile.metadata.path 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) { if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + ebookFilePath) const encodedURI = encodeUriPath(global.XAccel + ebookFilePath)
@@ -992,28 +1082,55 @@ class LibraryItemController {
* if an ebook file is the primary ebook, then it will be changed to supplementary * 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 * if an ebook file is supplementary, then it will be changed to primary
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res * @param {Response} res
*/ */
async updateEbookFileStatus(req, res) { async updateEbookFileStatus(req, res) {
const ebookLibraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid) if (!req.libraryItem.isBook) {
if (!ebookLibraryFile?.isEBookFile) { 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}"`) Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id') 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) { if (ebookLibraryFile.isSupplementary) {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`) 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 { } else {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`) Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`)
ebookLibraryFile.isSupplementary = true
req.libraryItem.setPrimaryEbook(null)
} }
req.libraryItem.updatedAt = Date.now() req.libraryItem.media.ebookFile = primaryEbookFile
await Database.updateLibraryItem(req.libraryItem) req.libraryItem.media.changed('ebookFile', true)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) 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) res.sendStatus(200)
} }
@@ -1024,7 +1141,7 @@ class LibraryItemController {
* @param {NextFunction} next * @param {NextFunction} next
*/ */
async middleware(req, res, 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) if (!req.libraryItem?.media) return res.sendStatus(404)
// Check user can access this library item // Check user can access this library item
@@ -1034,7 +1151,7 @@ class LibraryItemController {
// For library file routes, get the library file // For library file routes, get the library file
if (req.params.fileid) { 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) { if (!req.libraryFile) {
Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`) Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`)
return res.sendStatus(404) return res.sendStatus(404)
+296 -199
View File
@@ -3,13 +3,16 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const Playlist = require('../objects/Playlist')
/** /**
* @typedef RequestUserObject * @typedef RequestUserObject
* @property {import('../models/User')} user * @property {import('../models/User')} user
* *
* @typedef {Request & RequestUserObject} RequestWithUser * @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/Playlist')} playlist
*
* @typedef {RequestWithUser & RequestEntityObject} PlaylistControllerRequest
*/ */
class PlaylistController { class PlaylistController {
@@ -23,48 +26,103 @@ class PlaylistController {
* @param {Response} res * @param {Response} res
*/ */
async create(req, res) { async create(req, res) {
const oldPlaylist = new Playlist() const reqBody = req.body || {}
req.body.userId = req.user.id
const success = oldPlaylist.setData(req.body) // Validation
if (!success) { if (!reqBody.name || !reqBody.libraryId) {
return res.status(400).send('Invalid playlist request data') 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 // Load library items
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist) const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
// Lookup all library items in playlist
const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId).filter((i) => i)
const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
where: { 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 // Validate podcast episodes
const mediaItemsToAdd = [] if (isPodcast) {
let order = 1 const podcastEpisodeIds = items.map((i) => i.episodeId)
for (const mediaItemObj of oldPlaylist.items) { const podcastEpisodes = await Database.podcastEpisodeModel.findAll({
const libraryItem = libraryItemsInPlaylist.find((li) => li.id === mediaItemObj.libraryItemId) attributes: ['id'],
if (!libraryItem) continue where: {
id: podcastEpisodeIds
mediaItemsToAdd.push({ }
mediaItemId: mediaItemObj.episodeId || libraryItem.mediaId,
mediaItemType: mediaItemObj.episodeId ? 'podcastEpisode' : 'book',
playlistId: oldPlaylist.id,
order: order++
}) })
} if (podcastEpisodes.length !== podcastEpisodeIds.length) {
if (mediaItemsToAdd.length) { return res.status(400).send('Invalid playlist data. Invalid podcast episodes')
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd) }
} }
const jsonExpanded = await newPlaylist.getOldJsonExpanded() const transaction = await Database.sequelize.transaction()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded) try {
res.json(jsonExpanded) // 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: /api/playlists
* Get all playlists for user * Get all playlists for user
* *
@@ -72,68 +130,89 @@ class PlaylistController {
* @param {Response} res * @param {Response} res
*/ */
async findAllForUser(req, res) { async findAllForUser(req, res) {
const playlistsForUser = await Database.playlistModel.findAll({ const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)
where: {
userId: req.user.id
}
})
const playlists = []
for (const playlist of playlistsForUser) {
const jsonExpanded = await playlist.getOldJsonExpanded()
playlists.push(jsonExpanded)
}
res.json({ res.json({
playlists playlists: playlistsForUser
}) })
} }
/** /**
* GET: /api/playlists/:id * GET: /api/playlists/:id
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async findOne(req, res) { async findOne(req, res) {
const jsonExpanded = await req.playlist.getOldJsonExpanded() req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
res.json(jsonExpanded) res.json(req.playlist.toOldJSONExpanded())
} }
/** /**
* PATCH: /api/playlists/:id * PATCH: /api/playlists/:id
* Update playlist * Update playlist
* *
* @param {RequestWithUser} req * Used for updating name and description or reordering items
*
* @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async update(req, res) { async update(req, res) {
const updatedPlaylist = req.playlist.set(req.body) // Validation
let wasUpdated = false const reqBody = req.body || {}
const changed = updatedPlaylist.changed() if (reqBody.libraryId || reqBody.userId) {
if (changed?.length) { // Could allow support for this if needed with additional validation
await req.playlist.save() return res.status(400).send('Invalid playlist data. Cannot update libraryId or userId')
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`) }
wasUpdated = true 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 playlistUpdatePayload = {}
const libraryItemIds = req.body.items?.map((i) => i.libraryItemId).filter((i) => i) || [] if (reqBody.name) playlistUpdatePayload.name = reqBody.name
if (libraryItemIds.length) { 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({ const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType'],
where: { where: {
id: libraryItemIds 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']] order: [['order', 'ASC']]
}) })
if (existingPlaylistMediaItems.length !== reqBody.items.length) {
return res.status(400).send('Invalid playlist items. Length mismatch')
}
// Set an array of mediaItemId // Set an array of mediaItemId
const newMediaItemIdOrder = [] const newMediaItemIdOrder = []
for (const item of req.body.items) { for (const item of reqBody.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
if (!libraryItem) {
continue
}
const mediaItemId = item.episodeId || libraryItem.mediaId const mediaItemId = item.episodeId || libraryItem.mediaId
newMediaItemIdOrder.push(mediaItemId) newMediaItemIdOrder.push(mediaItemId)
} }
@@ -146,21 +225,21 @@ class PlaylistController {
}) })
// Update order on playlistMediaItem records // Update order on playlistMediaItem records
let order = 1 for (const [index, playlistMediaItem] of existingPlaylistMediaItems.entries()) {
for (const playlistMediaItem of existingPlaylistMediaItems) { if (playlistMediaItem.order !== index + 1) {
if (playlistMediaItem.order !== order) {
await playlistMediaItem.update({ await playlistMediaItem.update({
order order: index + 1
}) })
wasUpdated = true wasUpdated = true
} }
order++
} }
} }
const jsonExpanded = await updatedPlaylist.getOldJsonExpanded() req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = req.playlist.toOldJSONExpanded()
if (wasUpdated) { if (wasUpdated) {
SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded) SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
} }
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@@ -169,11 +248,13 @@ class PlaylistController {
* DELETE: /api/playlists/:id * DELETE: /api/playlists/:id
* Remove playlist * Remove playlist
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async delete(req, 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() await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded) SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
res.sendStatus(200) res.sendStatus(200)
@@ -183,12 +264,13 @@ class PlaylistController {
* POST: /api/playlists/:id/item * POST: /api/playlists/:id/item
* Add item to playlist * 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 * @param {Response} res
*/ */
async addItem(req, 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) { if (!itemToAdd.libraryItemId) {
return res.status(400).send('Request body has no libraryItemId') return res.status(400).send('Request body has no libraryItemId')
@@ -198,12 +280,9 @@ class PlaylistController {
if (!libraryItem) { if (!libraryItem) {
return res.status(400).send('Library item not found') 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') 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)) { if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
return res.status(400).send('Invalid item to add for this library type') return res.status(400).send('Invalid item to add for this library type')
} }
@@ -211,15 +290,38 @@ class PlaylistController {
return res.status(400).send('Episode not found in library item') return res.status(400).send('Episode not found in library item')
} }
const playlistMediaItem = { req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
playlistId: oldPlaylist.id,
mediaItemId: itemToAdd.episodeId || libraryItem.media.id, if (req.playlist.checkHasMediaItem(itemToAdd.libraryItemId, itemToAdd.episodeId)) {
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book', return res.status(400).send('Item already in playlist')
order: oldPlaylist.items.length + 1 }
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.episodes.find((ep) => ep.id === itemToAdd.episodeId)
jsonExpanded.items.push({
episodeId: itemToAdd.episodeId,
episode: episode.toJSONExpanded(),
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toJSONMinified()
})
} else {
jsonExpanded.items.push({
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toJSONExpanded()
})
} }
await Database.createPlaylistMediaItem(playlistMediaItem)
const jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded) SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@@ -228,43 +330,36 @@ class PlaylistController {
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId? * DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
* Remove item from playlist * Remove item from playlist
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async removeItem(req, res) { async removeItem(req, res) {
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId) req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
if (!oldLibraryItem) {
return res.status(404).send('Library item not found') 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)
} }
if (!playlistMediaItem) {
// 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) {
return res.status(404).send('Media item not found in playlist') return res.status(404).send('Media item not found in playlist')
} }
// Remove record // 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 // Update playlist media items order
let order = 1 for (const [index, mediaItem] of req.playlist.playlistMediaItems.entries()) {
for (const mediaItem of playlistMediaItems) { if (mediaItem.order !== index + 1) {
if (mediaItem.mediaItemId === mediaItemId) continue
if (mediaItem.order !== order) {
await mediaItem.update({ 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 // Playlist is removed when there are no items
if (!jsonExpanded.items.length) { if (!jsonExpanded.items.length) {
@@ -282,64 +377,68 @@ class PlaylistController {
* POST: /api/playlists/:id/batch/add * POST: /api/playlists/:id/batch/add
* Batch add playlist items * Batch add playlist items
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async addBatch(req, res) { async addBatch(req, res) {
if (!req.body.items?.length) { 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') return res.status(400).send('Invalid request body items')
}
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')
} }
// Find all library items // Find all library items
const libraryItems = await Database.libraryItemModel.findAll({ const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))
where: {
id: libraryItemIds
}
})
// Get all existing playlist media items const oldLibraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: Array.from(libraryItemIds) })
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({ if (oldLibraryItems.length !== libraryItemIds.size) {
order: [['order', 'ASC']] return res.status(400).send('Invalid request body items')
}) }
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const mediaItemsToAdd = [] const mediaItemsToAdd = []
const jsonExpanded = req.playlist.toOldJSONExpanded()
// Setup array of playlistMediaItem records to add // Setup array of playlistMediaItem records to add
let order = existingPlaylistMediaItems.length + 1 let order = req.playlist.playlistMediaItems.length + 1
for (const item of itemsToAdd) { for (const item of req.body.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) const libraryItem = oldLibraryItems.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 { } else {
const mediaItemId = item.episodeId || libraryItem.mediaId mediaItemsToAdd.push({
if (existingPlaylistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) { playlistId: req.playlist.id,
// Already exists in playlist mediaItemId,
continue 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.episodes.find((ep) => ep.id === item.episodeId)
jsonExpanded.items.push({
episodeId: item.episodeId,
episode: episode.toJSONExpanded(),
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toJSONMinified()
})
} else { } else {
mediaItemsToAdd.push({ jsonExpanded.items.push({
playlistId: req.playlist.id, libraryItemId: libraryItem.id,
mediaItemId, libraryItem: libraryItem.toJSONExpanded()
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
}) })
} }
} }
} }
let jsonExpanded = null
if (mediaItemsToAdd.length) { if (mediaItemsToAdd.length) {
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd) await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd)
jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded) SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
} else {
jsonExpanded = await req.playlist.getOldJsonExpanded()
} }
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@@ -347,50 +446,40 @@ class PlaylistController {
* POST: /api/playlists/:id/batch/remove * POST: /api/playlists/:id/batch/remove
* Batch remove playlist items * Batch remove playlist items
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async removeBatch(req, res) { async removeBatch(req, res) {
if (!req.body.items?.length) { 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') return res.status(400).send('Invalid request body items')
} }
const itemsToRemove = req.body.items req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
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
// Remove playlist media items // Remove playlist media items
let hasUpdated = false let hasUpdated = false
for (const item of itemsToRemove) { for (const item of req.body.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) let playlistMediaItem = null
if (!libraryItem) continue if (item.episodeId) {
const mediaItemId = item.episodeId || libraryItem.mediaId playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === item.episodeId)
const existingMediaItem = existingPlaylistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId) } else {
if (!existingMediaItem) continue playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === item.libraryItemId)
await existingMediaItem.destroy() }
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 hasUpdated = true
numMediaItems--
} }
const jsonExpanded = await req.playlist.getOldJsonExpanded() const jsonExpanded = req.playlist.toOldJSONExpanded()
if (hasUpdated) { if (hasUpdated) {
// Playlist is removed when there are no items // 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`) Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
await req.playlist.destroy() await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded) SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
@@ -425,33 +514,41 @@ class PlaylistController {
return res.status(400).send('Collection has no books') return res.status(400).send('Collection has no books')
} }
const oldPlaylist = new Playlist() const transaction = await Database.sequelize.transaction()
oldPlaylist.setData({ try {
userId: req.user.id, const playlist = await Database.playlistModel.create(
libraryId: collection.libraryId, {
name: collection.name, userId: req.user.id,
description: collection.description || null libraryId: collection.libraryId,
}) name: collection.name,
description: collection.description || null
},
{ transaction }
)
// Create Playlist record const mediaItemsToAdd = []
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist) 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 await transaction.commit()
const mediaItemsToAdd = []
let order = 1 playlist.playlistMediaItems = await playlist.getMediaItemsExpandedWithLibraryItem()
for (const libraryItem of collectionExpanded.books) {
mediaItemsToAdd.push({ const jsonExpanded = playlist.toOldJSONExpanded()
playlistId: newPlaylist.id, SocketAuthority.clientEmitter(playlist.userId, 'playlist_added', jsonExpanded)
mediaItemId: libraryItem.media.id, res.json(jsonExpanded)
mediaItemType: 'book', } catch (error) {
order: order++ 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)
} }
/** /**
-82
View File
@@ -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()
+13 -8
View File
@@ -11,7 +11,6 @@ const { validateUrl } = require('../utils/index')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
const CoverManager = require('../managers/CoverManager') const CoverManager = require('../managers/CoverManager')
const PodcastManager = require('../managers/PodcastManager')
const LibraryItem = require('../objects/LibraryItem') const LibraryItem = require('../objects/LibraryItem')
@@ -115,7 +114,7 @@ class PodcastController {
if (payload.episodesToDownload?.length) { if (payload.episodesToDownload?.length) {
Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`) Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`)
PodcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload) this.podcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
} }
// Turn on podcast auto download cron if not already on // Turn on podcast auto download cron if not already on
@@ -170,7 +169,7 @@ class PodcastController {
} }
res.json({ res.json({
feeds: PodcastManager.getParsedOPMLFileFeeds(req.body.opmlText) feeds: this.podcastManager.getParsedOPMLFileFeeds(req.body.opmlText)
}) })
} }
@@ -204,7 +203,7 @@ class PodcastController {
return res.status(404).send('Folder not found') return res.status(404).send('Folder not found')
} }
const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes
PodcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager) this.podcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)
res.sendStatus(200) res.sendStatus(200)
} }
@@ -231,7 +230,7 @@ class PodcastController {
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3 const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3
var newEpisodes = await PodcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload)
res.json({ res.json({
episodes: newEpisodes || [] episodes: newEpisodes || []
}) })
@@ -240,6 +239,8 @@ class PodcastController {
/** /**
* GET: /api/podcasts/:id/clear-queue * GET: /api/podcasts/:id/clear-queue
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
@@ -248,20 +249,22 @@ class PodcastController {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempting to clear download queue`) Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempting to clear download queue`)
return res.sendStatus(403) return res.sendStatus(403)
} }
PodcastManager.clearDownloadQueue(req.params.id) this.podcastManager.clearDownloadQueue(req.params.id)
res.sendStatus(200) res.sendStatus(200)
} }
/** /**
* GET: /api/podcasts/:id/downloads * GET: /api/podcasts/:id/downloads
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
getEpisodeDownloads(req, res) { getEpisodeDownloads(req, res) {
var libraryItem = req.libraryItem var libraryItem = req.libraryItem
var downloadsInQueue = PodcastManager.getEpisodeDownloadsInQueue(libraryItem.id) var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
res.json({ res.json({
downloads: downloadsInQueue.map((d) => d.toJSONForClient()) downloads: downloadsInQueue.map((d) => d.toJSONForClient())
}) })
@@ -287,6 +290,8 @@ class PodcastController {
/** /**
* POST: /api/podcasts/:id/download-episodes * POST: /api/podcasts/:id/download-episodes
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
@@ -301,7 +306,7 @@ class PodcastController {
return res.sendStatus(400) return res.sendStatus(400)
} }
PodcastManager.downloadPodcastEpisodes(libraryItem, episodes) this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes)
res.sendStatus(200) res.sendStatus(200)
} }
+1 -1
View File
@@ -149,7 +149,7 @@ class SessionController {
* @param {Response} res * @param {Response} res
*/ */
async getOpenSession(req, 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) const sessionForClient = req.playbackSession.toJSONForClient(libraryItem)
res.json(sessionForClient) res.json(sessionForClient)
} }
+6 -7
View File
@@ -70,14 +70,13 @@ class ShareController {
} }
try { try {
const oldLibraryItem = await Database.mediaItemShareModel.getMediaItemsOldLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType) const libraryItem = await Database.mediaItemShareModel.getMediaItemsLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
if (!libraryItem) {
if (!oldLibraryItem) {
return res.status(404).send('Media item not found') return res.status(404).send('Media item not found')
} }
let startOffset = 0 let startOffset = 0
const publicTracks = oldLibraryItem.media.includedAudioFiles.map((audioFile) => { const publicTracks = libraryItem.media.includedAudioFiles.map((audioFile) => {
const audioTrack = { const audioTrack = {
index: audioFile.index, index: audioFile.index,
startOffset, startOffset,
@@ -86,7 +85,7 @@ class ShareController {
contentUrl: `${global.RouterBasePath}/public/share/${slug}/track/${audioFile.index}`, contentUrl: `${global.RouterBasePath}/public/share/${slug}/track/${audioFile.index}`,
mimeType: audioFile.mimeType, mimeType: audioFile.mimeType,
codec: audioFile.codec || null, codec: audioFile.codec || null,
metadata: audioFile.metadata.clone() metadata: structuredClone(audioFile.metadata)
} }
startOffset += audioTrack.duration startOffset += audioTrack.duration
return audioTrack return audioTrack
@@ -105,12 +104,12 @@ class ShareController {
const deviceInfo = await this.playbackSessionManager.getDeviceInfo(req, clientDeviceInfo) const deviceInfo = await this.playbackSessionManager.getDeviceInfo(req, clientDeviceInfo)
const newPlaybackSession = new PlaybackSession() const newPlaybackSession = new PlaybackSession()
newPlaybackSession.setData(oldLibraryItem, null, 'web-share', deviceInfo, startTime) newPlaybackSession.setData(libraryItem, null, 'web-share', deviceInfo, startTime)
newPlaybackSession.audioTracks = publicTracks newPlaybackSession.audioTracks = publicTracks
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
newPlaybackSession.shareSessionId = shareSessionId newPlaybackSession.shareSessionId = shareSessionId
newPlaybackSession.mediaItemShareId = mediaItemShare.id newPlaybackSession.mediaItemShareId = mediaItemShare.id
newPlaybackSession.coverAspectRatio = oldLibraryItem.librarySettings.coverAspectRatio newPlaybackSession.coverAspectRatio = libraryItem.library.settings.coverAspectRatio
mediaItemShare.playbackSession = newPlaybackSession.toJSONForClient() mediaItemShare.playbackSession = newPlaybackSession.toJSONForClient()
ShareManager.addOpenSharePlaybackSession(newPlaybackSession) ShareManager.addOpenSharePlaybackSession(newPlaybackSession)
+6 -1
View File
@@ -34,8 +34,13 @@ class AudioMetadataMangaer {
return this.tasksQueued.some((t) => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some((t) => t.data.libraryItemId === libraryItemId) 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) { getMetadataObjectForApi(libraryItem) {
return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length) return ffmpegHelpers.getFFMetadataObject(libraryItem.toOldJSONExpanded(), libraryItem.media.includedAudioFiles.length)
} }
/** /**
+20 -3
View File
@@ -79,6 +79,12 @@ class CoverManager {
return imgType return imgType
} }
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} coverFile - file object from req.files
* @returns {Promise<{error:string}|{cover:string}>}
*/
async uploadCover(libraryItem, coverFile) { async uploadCover(libraryItem, coverFile) {
const extname = Path.extname(coverFile.name.toLowerCase()) const extname = Path.extname(coverFile.name.toLowerCase())
if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) { if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) {
@@ -110,14 +116,20 @@ class CoverManager {
await this.removeOldCovers(coverDirPath, extname) await this.removeOldCovers(coverDirPath, extname)
await CacheManager.purgeCoverCache(libraryItem.id) 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 { return {
cover: coverFullPath cover: coverFullPath
} }
} }
/**
*
* @param {Object} libraryItem - old library item
* @param {string} url
* @param {boolean} [forceLibraryItemFolder=false]
* @returns {Promise<{error:string}|{cover:string}>}
*/
async downloadCoverFromUrl(libraryItem, url, forceLibraryItemFolder = false) { async downloadCoverFromUrl(libraryItem, url, forceLibraryItemFolder = false) {
try { try {
// Force save cover with library item is used for adding new podcasts // Force save cover with library item is used for adding new podcasts
@@ -166,6 +178,12 @@ class CoverManager {
} }
} }
/**
*
* @param {string} coverPath
* @param {import('../models/LibraryItem')} libraryItem
* @returns {Promise<{error:string}|{cover:string,updated:boolean}>}
*/
async validateCoverPath(coverPath, libraryItem) { async validateCoverPath(coverPath, libraryItem) {
// Invalid cover path // Invalid cover path
if (!coverPath || coverPath.startsWith('http:') || coverPath.startsWith('https:')) { if (!coverPath || coverPath.startsWith('http:') || coverPath.startsWith('https:')) {
@@ -235,7 +253,6 @@ class CoverManager {
await CacheManager.purgeCoverCache(libraryItem.id) await CacheManager.purgeCoverCache(libraryItem.id)
libraryItem.updateMediaCover(coverPath)
return { return {
cover: coverPath, cover: coverPath,
updated: true updated: true
+13 -5
View File
@@ -5,10 +5,11 @@ const Database = require('../Database')
const LibraryScanner = require('../scanner/LibraryScanner') const LibraryScanner = require('../scanner/LibraryScanner')
const ShareManager = require('./ShareManager') const ShareManager = require('./ShareManager')
const PodcastManager = require('./PodcastManager')
class CronManager { class CronManager {
constructor(playbackSessionManager) { constructor(podcastManager, playbackSessionManager) {
/** @type {import('./PodcastManager')} */
this.podcastManager = podcastManager
/** @type {import('./PlaybackSessionManager')} */ /** @type {import('./PlaybackSessionManager')} */
this.playbackSessionManager = playbackSessionManager this.playbackSessionManager = playbackSessionManager
@@ -162,7 +163,7 @@ class CronManager {
task task
}) })
} catch (error) { } 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)
} }
} }
@@ -191,7 +192,7 @@ class CronManager {
// Run episode checks // Run episode checks
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
const keepAutoDownloading = await PodcastManager.runEpisodeCheck(libraryItem) const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem)
if (!keepAutoDownloading) { if (!keepAutoDownloading) {
// auto download was disabled // auto download was disabled
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItem.id) // Filter it out 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) this.podcastCrons = this.podcastCrons.filter((pc) => pc.expression !== podcastCron.expression)
} }
/**
*
* @param {import('../models/LibraryItem')} libraryItem - this can be the old model
*/
checkUpdatePodcastCron(libraryItem) { checkUpdatePodcastCron(libraryItem) {
// Remove from old cron by library item id // Remove from old cron by library item id
const existingCron = this.podcastCrons.find((pc) => pc.libraryItemIds.includes(libraryItem.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) const cronMatchingExpression = this.podcastCrons.find((pc) => pc.expression === libraryItem.media.autoDownloadSchedule)
if (cronMatchingExpression) { if (cronMatchingExpression) {
cronMatchingExpression.libraryItemIds.push(libraryItem.id) 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 { } else {
this.startPodcastCron(libraryItem.media.autoDownloadSchedule, [libraryItem.id]) this.startPodcastCron(libraryItem.media.autoDownloadSchedule, [libraryItem.id])
} }
+6 -6
View File
@@ -39,7 +39,7 @@ class PlaybackSessionManager {
/** /**
* *
* @param {import('../controllers/SessionController').RequestWithUser} req * @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req
* @param {Object} [clientDeviceInfo] * @param {Object} [clientDeviceInfo]
* @returns {Promise<DeviceInfo>} * @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 {import('express').Response} res
* @param {string} [episodeId] * @param {string} [episodeId]
*/ */
@@ -279,7 +279,7 @@ class PlaybackSessionManager {
* *
* @param {import('../models/User')} user * @param {import('../models/User')} user
* @param {DeviceInfo} deviceInfo * @param {DeviceInfo} deviceInfo
* @param {import('../objects/LibraryItem')} libraryItem * @param {import('../models/LibraryItem')} libraryItem
* @param {string|null} episodeId * @param {string|null} episodeId
* @param {{forceDirectPlay?:boolean, forceTranscode?:boolean, mediaPlayer:string, supportedMimeTypes?:string[]}} options * @param {{forceDirectPlay?:boolean, forceTranscode?:boolean, mediaPlayer:string, supportedMimeTypes?:string[]}} options
* @returns {Promise<PlaybackSession>} * @returns {Promise<PlaybackSession>}
@@ -292,7 +292,7 @@ class PlaybackSessionManager {
await this.closeSession(user, session, null) 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 mediaPlayer = options.mediaPlayer || 'unknown'
const mediaItemId = episodeId || libraryItem.media.id const mediaItemId = episodeId || libraryItem.media.id
@@ -300,7 +300,7 @@ class PlaybackSessionManager {
let userStartTime = 0 let userStartTime = 0
if (userProgress) { if (userProgress) {
if (userProgress.isFinished) { 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 // Keep userStartTime as 0 so the client restarts the media
} else { } else {
userStartTime = Number.parseFloat(userProgress.currentTime) || 0 userStartTime = Number.parseFloat(userProgress.currentTime) || 0
@@ -312,7 +312,7 @@ class PlaybackSessionManager {
let audioTracks = [] let audioTracks = []
if (shouldDirectPlay) { if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`) 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 newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else { } else {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`) Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`)
-274
View File
@@ -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 -1
View File
@@ -586,4 +586,4 @@ class PodcastManager {
Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`) Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`)
} }
} }
module.exports = new PodcastManager() module.exports = PodcastManager
+23 -8
View File
@@ -98,11 +98,22 @@ class RssFeedManager {
podcastId: feed.entity.mediaId podcastId: feed.entity.mediaId
}, },
attributes: ['id', 'updatedAt'], attributes: ['id', 'updatedAt'],
order: [['createdAt', 'DESC']] order: [['updatedAt', 'DESC']]
}) })
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) { if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt 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 return newEntityUpdatedAt > feed.entityUpdatedAt
@@ -111,7 +122,7 @@ class RssFeedManager {
attributes: ['id', 'updatedAt'], attributes: ['id', 'updatedAt'],
include: { include: {
model: Database.bookModel, model: Database.bookModel,
attributes: ['id'], attributes: ['id', 'audioFiles', 'updatedAt'],
through: { through: {
attributes: [] 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 let newEntityUpdatedAt = feed.entity.updatedAt
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => { const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
if (book.libraryItem.updatedAt > mostRecent) { let updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
return book.libraryItem.updatedAt return updatedAt > mostRecent ? updatedAt : mostRecent
}
return mostRecent
}, 0) }, 0)
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) { if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
@@ -151,6 +165,9 @@ class RssFeedManager {
let feed = await Database.feedModel.findOne({ let feed = await Database.feedModel.findOne({
where: { where: {
slug: req.params.slug slug: req.params.slug
},
include: {
model: Database.feedEpisodeModel
} }
}) })
if (!feed) { if (!feed) {
@@ -163,8 +180,6 @@ class RssFeedManager {
if (feedRequiresUpdate) { if (feedRequiresUpdate) {
Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`) Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
feed = await feed.updateFeedForEntity() feed = await feed.updateFeedForEntity()
} else {
feed.feedEpisodes = await feed.getFeedEpisodes()
} }
const xml = feed.buildXml(req.originalHostPrefix) const xml = feed.buildXml(req.originalHostPrefix)
+1
View File
@@ -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.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.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.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
View 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 }
+394 -30
View File
@@ -1,5 +1,7 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
const parseNameString = require('../utils/parsers/parseNameString')
/** /**
* @typedef EBookFileObject * @typedef EBookFileObject
@@ -60,6 +62,13 @@ const Logger = require('../Logger')
* @property {ChapterObject[]} chapters * @property {ChapterObject[]} chapters
* @property {Object} metaTags * @property {Object} metaTags
* @property {string} mimeType * @property {string} mimeType
*
* @typedef AudioTrackProperties
* @property {string} title
* @property {string} contentUrl
* @property {number} startOffset
*
* @typedef {AudioFileObject & AudioTrackProperties} AudioTrack
*/ */
class Book extends Model { class Book extends Model {
@@ -113,8 +122,12 @@ class Book extends Model {
/** @type {Date} */ /** @type {Date} */
this.createdAt this.createdAt
// Expanded properties
/** @type {import('./Author')[]} - optional if expanded */ /** @type {import('./Author')[]} - optional if expanded */
this.authors this.authors
/** @type {import('./Series')[]} - optional if expanded */
this.series
} }
static getOldBook(libraryItemExpanded) { static getOldBook(libraryItemExpanded) {
@@ -241,32 +254,6 @@ class Book extends Model {
} }
} }
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
}
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
@@ -321,10 +308,10 @@ class Book extends Model {
// }, // },
{ {
fields: ['publishedYear'] fields: ['publishedYear']
},
{
fields: ['duration']
} }
// {
// fields: ['duration']
// }
] ]
} }
) )
@@ -343,18 +330,395 @@ class Book extends Model {
} }
return this.authors.map((au) => au.name).join(', ') 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() { get includedAudioFiles() {
return this.audioFiles.filter((af) => !af.exclude) 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 let startOffset = 0
return this.includedAudioFiles.map((af) => { return this.includedAudioFiles.map((af) => {
const track = structuredClone(af) const track = structuredClone(af)
track.title = af.metadata.filename
track.startOffset = startOffset track.startOffset = startOffset
track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}`
startOffset += track.duration startOffset += track.duration
return track 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()
}
if (Array.isArray(payload.metadata?.authors)) {
const authorsRemoved = this.authors.filter((au) => !payload.metadata.authors.some((a) => a.id === au.id))
const newAuthors = payload.metadata.authors.filter((a) => !this.authors.some((au) => au.id === a.id))
for (const author of authorsRemoved) {
await this.sequelize.models.bookAuthor.removeByIds(author.id, this.id)
Logger.debug(`[Book] "${this.title}" Removed author ${author.id}`)
hasUpdates = true
}
for (const author of newAuthors) {
await this.sequelize.models.bookAuthor.create({ bookId: this.id, authorId: author.id })
Logger.debug(`[Book] "${this.title}" Added author ${author.id}`)
hasUpdates = true
}
}
if (Array.isArray(payload.metadata?.series)) {
const seriesRemoved = this.series.filter((se) => !payload.metadata.series.some((s) => s.id === se.id))
const newSeries = payload.metadata.series.filter((s) => !this.series.some((se) => se.id === s.id))
for (const series of seriesRemoved) {
await this.sequelize.models.bookSeries.removeByIds(series.id, this.id)
Logger.debug(`[Book] "${this.title}" Removed series ${series.id}`)
hasUpdates = true
}
for (const series of newSeries) {
await this.sequelize.models.bookSeries.create({ bookId: this.id, seriesId: series.id, sequence: series.sequence })
Logger.debug(`[Book] "${this.title}" Added series ${series.id}`)
hasUpdates = true
}
for (const series of payload.metadata.series) {
const existingSeries = this.series.find((se) => se.id === series.id)
if (existingSeries && existingSeries.bookSeries.sequence !== series.sequence) {
await existingSeries.bookSeries.update({ sequence: series.sequence })
Logger.debug(`[Book] "${this.title}" Updated series ${series.id} sequence ${series.sequence}`)
hasUpdates = true
}
}
}
return 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 module.exports = Book
+38 -138
View File
@@ -1,7 +1,5 @@
const { DataTypes, Model, Sequelize } = require('sequelize') const { DataTypes, Model, Sequelize } = require('sequelize')
const oldCollection = require('../objects/Collection')
class Collection extends Model { class Collection extends Model {
constructor(values, options) { constructor(values, options) {
super(values, options) super(values, options)
@@ -26,12 +24,12 @@ class Collection extends Model {
} }
/** /**
* Get all old collections toJSONExpanded, items filtered for user permissions * Get all toOldJSONExpanded, items filtered for user permissions
* *
* @param {import('./User')} user * @param {import('./User')} user
* @param {string} [libraryId] * @param {string} [libraryId]
* @param {string[]} [include] * @param {string[]} [include]
* @returns {Promise<oldCollection[]>} oldCollection.toJSONExpanded * @async
*/ */
static async getOldCollectionsJsonExpanded(user, libraryId, include) { static async getOldCollectionsJsonExpanded(user, libraryId, include) {
let collectionWhere = null let collectionWhere = null
@@ -79,8 +77,6 @@ class Collection extends Model {
// TODO: Handle user permission restrictions on initial query // TODO: Handle user permission restrictions on initial query
return collections return collections
.map((c) => { .map((c) => {
const oldCollection = this.getOldCollection(c)
// Filter books using user permissions // Filter books using user permissions
const books = const books =
c.books?.filter((b) => { c.books?.filter((b) => {
@@ -95,20 +91,14 @@ class Collection extends Model {
return true return true
}) || [] }) || []
// Map to library items
const libraryItems = books.map((b) => {
const libraryItem = b.libraryItem
delete b.libraryItem
libraryItem.media = b
return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
})
// Users with restricted permissions will not see this collection // Users with restricted permissions will not see this collection
if (!books.length && oldCollection.books.length) { if (!books.length && c.books.length) {
return null return null
} }
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems) this.books = books
const collectionExpanded = c.toOldJSONExpanded()
// Map feed if found // Map feed if found
if (c.feeds?.length) { if (c.feeds?.length) {
@@ -153,69 +143,6 @@ class Collection extends Model {
}) })
} }
/**
* Get old collection from Collection
* @param {Collection} collectionExpanded
* @returns {oldCollection}
*/
static getOldCollection(collectionExpanded) {
const libraryItemIds = collectionExpanded.books?.map((b) => b.libraryItem?.id || null).filter((lid) => lid) || []
return new oldCollection({
id: collectionExpanded.id,
libraryId: collectionExpanded.libraryId,
name: collectionExpanded.name,
description: collectionExpanded.description,
books: libraryItemIds,
lastUpdate: collectionExpanded.updatedAt.valueOf(),
createdAt: collectionExpanded.createdAt.valueOf()
})
}
/**
*
* @param {oldCollection} oldCollection
* @returns {Promise<Collection>}
*/
static createFromOld(oldCollection) {
const collection = this.getFromOld(oldCollection)
return this.create(collection)
}
static getFromOld(oldCollection) {
return {
id: oldCollection.id,
name: oldCollection.name,
description: oldCollection.description,
libraryId: oldCollection.libraryId
}
}
static removeById(collectionId) {
return this.destroy({
where: {
id: collectionId
}
})
}
/**
* Get old collection by id
* @param {string} collectionId
* @returns {Promise<oldCollection|null>} returns null if not found
*/
static async getOldById(collectionId) {
if (!collectionId) return null
const collection = await this.findByPk(collectionId, {
include: {
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
})
if (!collection) return null
return this.getOldCollection(collection)
}
/** /**
* Remove all collections belonging to library * Remove all collections belonging to library
* @param {string} libraryId * @param {string} libraryId
@@ -286,64 +213,37 @@ class Collection extends Model {
} }
/** /**
* Get old collection toJSONExpanded, items filtered for user permissions * Get toOldJSONExpanded, items filtered for user permissions
* *
* @param {import('./User')|null} user * @param {import('./User')|null} user
* @param {string[]} [include] * @param {string[]} [include]
* @returns {Promise<oldCollection>} oldCollection.toJSONExpanded * @async
*/ */
async getOldJsonExpanded(user, include) { async getOldJsonExpanded(user, include) {
this.books = this.books = await this.getBooksExpandedWithLibraryItem()
(await this.getBooks({
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [Sequelize.literal('`collectionBook.order` ASC')]
})) || []
// Filter books using user permissions // Filter books using user permissions
// TODO: Handle user permission restrictions on initial query // TODO: Handle user permission restrictions on initial query
const books = if (user) {
this.books?.filter((b) => { const books = this.books.filter((b) => {
if (user) { if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) { return false
return false }
} if (b.explicit === true && !user.canAccessExplicitContent) {
if (b.explicit === true && !user.canAccessExplicitContent) { return false
return false
}
} }
return true return true
}) || [] })
// Map to library items // Users with restricted permissions will not see this collection
const libraryItems = books.map((b) => { if (!books.length && this.books.length) {
const libraryItem = b.libraryItem return null
delete b.libraryItem }
libraryItem.media = b
return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
})
// Users with restricted permissions will not see this collection this.books = books
if (!books.length && this.books.length) {
return null
} }
const collectionExpanded = this.toOldJSONExpanded(libraryItems) const collectionExpanded = this.toOldJSONExpanded()
if (include?.includes('rssfeed')) { if (include?.includes('rssfeed')) {
const feeds = await this.getFeeds() const feeds = await this.getFeeds()
@@ -357,10 +257,10 @@ class Collection extends Model {
/** /**
* *
* @param {string[]} libraryItemIds * @param {string[]} [libraryItemIds=[]]
* @returns * @returns
*/ */
toOldJSON(libraryItemIds) { toOldJSON(libraryItemIds = []) {
return { return {
id: this.id, id: this.id,
libraryId: this.libraryId, libraryId: this.libraryId,
@@ -372,19 +272,19 @@ class Collection extends Model {
} }
} }
/** toOldJSONExpanded() {
* if (!this.books) {
* @param {import('../objects/LibraryItem')} oldLibraryItems throw new Error('Books are required to expand Collection')
* @returns }
*/
toOldJSONExpanded(oldLibraryItems) { const json = this.toOldJSON()
const json = this.toOldJSON(oldLibraryItems.map((li) => li.id)) json.books = this.books.map((book) => {
json.books = json.books const libraryItem = book.libraryItem
.map((libraryItemId) => { delete book.libraryItem
const book = oldLibraryItems.find((li) => li.id === libraryItemId) libraryItem.media = book
return book ? book.toJSONExpanded() : null return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded()
}) })
.filter((b) => !!b)
return json return json
} }
} }
-9
View File
@@ -16,15 +16,6 @@ class CollectionBook extends Model {
this.createdAt this.createdAt
} }
static removeByIds(collectionId, bookId) {
return this.destroy({
where: {
bookId,
collectionId
}
})
}
static init(sequelize) { static init(sequelize) {
super.init( super.init(
{ {
+21 -9
View File
@@ -107,6 +107,9 @@ class Feed extends Model {
entityUpdatedAt = libraryItem.media.podcastEpisodes.reduce((mostRecent, episode) => { entityUpdatedAt = libraryItem.media.podcastEpisodes.reduce((mostRecent, episode) => {
return episode.updatedAt > mostRecent ? episode.updatedAt : mostRecent return episode.updatedAt > mostRecent ? episode.updatedAt : mostRecent
}, entityUpdatedAt) }, entityUpdatedAt)
} else if (libraryItem.media.updatedAt > entityUpdatedAt) {
// Book feeds will use Book.updatedAt if more recent
entityUpdatedAt = libraryItem.media.updatedAt
} }
const feedObj = { const feedObj = {
@@ -187,7 +190,8 @@ class Feed extends Model {
const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length) const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length)
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => { const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent const updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
return updatedAt > mostRecent ? updatedAt : mostRecent
}, collectionExpanded.updatedAt) }, collectionExpanded.updatedAt)
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath) const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
@@ -275,7 +279,8 @@ class Feed extends Model {
static getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions = null) { static getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions = null) {
const booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length) const booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length)
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => { const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent const updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
return updatedAt > mostRecent ? updatedAt : mostRecent
}, seriesExpanded.updatedAt) }, seriesExpanded.updatedAt)
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath) const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
@@ -516,17 +521,24 @@ class Feed extends Model {
try { try {
const updatedFeed = await this.update(feedObj, { transaction }) const updatedFeed = await this.update(feedObj, { transaction })
// Remove existing feed episodes const existingFeedEpisodeIds = this.feedEpisodes.map((ep) => ep.id)
await feedEpisodeModel.destroy({
where: {
feedId: this.id
},
transaction
})
// Create new feed episodes // Create new feed episodes
updatedFeed.feedEpisodes = await feedEpisodeCreateFunc(feedEpisodeCreateFuncEntity, updatedFeed, this.slug, transaction) updatedFeed.feedEpisodes = await feedEpisodeCreateFunc(feedEpisodeCreateFuncEntity, updatedFeed, this.slug, transaction)
const newFeedEpisodeIds = updatedFeed.feedEpisodes.map((ep) => ep.id)
const feedEpisodeIdsToRemove = existingFeedEpisodeIds.filter((epid) => !newFeedEpisodeIds.includes(epid))
if (feedEpisodeIdsToRemove.length) {
Logger.info(`[Feed] Removing ${feedEpisodeIdsToRemove.length} episodes from feed ${this.id}`)
await feedEpisodeModel.destroy({
where: {
id: feedEpisodeIdsToRemove
},
transaction
})
}
await transaction.commit() await transaction.commit()
return updatedFeed return updatedFeed
+49 -24
View File
@@ -53,9 +53,10 @@ class FeedEpisode extends Model {
* @param {import('./Feed')} feed * @param {import('./Feed')} feed
* @param {string} slug * @param {string} slug
* @param {import('./PodcastEpisode')} episode * @param {import('./PodcastEpisode')} episode
* @param {string} [existingEpisodeId]
*/ */
static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) { static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisodeId = null) {
const episodeId = uuidv4() const episodeId = existingEpisodeId || uuidv4()
return { return {
id: episodeId, id: episodeId,
title: episode.title, title: episode.title,
@@ -94,25 +95,32 @@ class FeedEpisode extends Model {
libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate)) libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate))
} }
let numExisting = 0
for (const episode of libraryItemExpanded.media.podcastEpisodes) { for (const episode of libraryItemExpanded.media.podcastEpisodes) {
feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode)) // Check for existing episode by filepath
const existingEpisode = feed.feedEpisodes?.find((feedEpisode) => {
return feedEpisode.filePath === episode.audioFile.metadata.path
})
numExisting = existingEpisode ? numExisting + 1 : numExisting
feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisode?.id))
} }
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`) Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
return this.bulkCreate(feedEpisodeObjs, { transaction }) return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
} }
/** /**
* If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names * If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names
* *
* @param {import('./Book').AudioTrack[]} trackList
* @param {import('./Book')} book * @param {import('./Book')} book
* @returns {boolean} * @returns {boolean}
*/ */
static checkUseChapterTitlesForEpisodes(book) { static checkUseChapterTitlesForEpisodes(trackList, book) {
const tracks = book.trackList || []
const chapters = book.chapters || [] const chapters = book.chapters || []
if (tracks.length !== chapters.length) return false if (trackList.length !== chapters.length) return false
for (let i = 0; i < tracks.length; i++) { for (let i = 0; i < trackList.length; i++) {
if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) { if (Math.abs(chapters[i].start - trackList[i].startOffset) >= 1) {
return false return false
} }
} }
@@ -127,11 +135,12 @@ class FeedEpisode extends Model {
* @param {string} slug * @param {string} slug
* @param {import('./Book').AudioFileObject} audioTrack * @param {import('./Book').AudioFileObject} audioTrack
* @param {boolean} useChapterTitles * @param {boolean} useChapterTitles
* @param {string} [existingEpisodeId]
*/ */
static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles) { static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles, existingEpisodeId = null) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate> // Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order
let episodeId = uuidv4() let episodeId = existingEpisodeId || uuidv4()
// e.g. Track 1 will have a pub date before Track 2 // e.g. Track 1 will have a pub date before Track 2
const audiobookPubDate = date.format(new Date(pubDateStart.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]') const audiobookPubDate = date.format(new Date(pubDateStart.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
@@ -139,7 +148,7 @@ class FeedEpisode extends Model {
const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}` const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}`
let title = Path.basename(audioTrack.metadata.filename, Path.extname(audioTrack.metadata.filename)) let title = Path.basename(audioTrack.metadata.filename, Path.extname(audioTrack.metadata.filename))
if (book.trackList.length == 1) { if (book.includedAudioFiles.length == 1) {
// If audiobook is a single file, use book title instead of chapter/file title // If audiobook is a single file, use book title instead of chapter/file title
title = book.title title = book.title
} else { } else {
@@ -176,19 +185,27 @@ class FeedEpisode extends Model {
* @returns {Promise<FeedEpisode[]>} * @returns {Promise<FeedEpisode[]>}
*/ */
static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) { static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) {
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded.media) const trackList = libraryItemExpanded.getTrackList()
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, libraryItemExpanded.media)
const feedEpisodeObjs = [] const feedEpisodeObjs = []
for (const track of libraryItemExpanded.media.trackList) { let numExisting = 0
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles)) for (const track of trackList) {
// Check for existing episode by filepath
const existingEpisode = feed.feedEpisodes?.find((episode) => {
return episode.filePath === track.metadata.path
})
numExisting = existingEpisode ? numExisting + 1 : numExisting
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles, existingEpisode?.id))
} }
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`) Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
return this.bulkCreate(feedEpisodeObjs, { transaction }) return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
} }
/** /**
* *
* @param {import('./Book')[]} books * @param {import('./Book').BookExpandedWithLibraryItem[]} books
* @param {import('./Feed')} feed * @param {import('./Feed')} feed
* @param {string} slug * @param {string} slug
* @param {import('sequelize').Transaction} transaction * @param {import('sequelize').Transaction} transaction
@@ -200,14 +217,22 @@ class FeedEpisode extends Model {
}).libraryItem.createdAt }).libraryItem.createdAt
const feedEpisodeObjs = [] const feedEpisodeObjs = []
let numExisting = 0
for (const book of books) { for (const book of books) {
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book) const trackList = book.libraryItem.getTrackList()
for (const track of book.trackList) { const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, book)
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles)) for (const track of trackList) {
// Check for existing episode by filepath
const existingEpisode = feed.feedEpisodes?.find((episode) => {
return episode.filePath === track.metadata.path
})
numExisting = existingEpisode ? numExisting + 1 : numExisting
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles, existingEpisode?.id))
} }
} }
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`) Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
return this.bulkCreate(feedEpisodeObjs, { transaction }) return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
} }
/** /**
+251 -50
View File
@@ -123,7 +123,7 @@ class LibraryItem extends Model {
} }
/** /**
* Currently unused because this is too slow and uses too much mem *
* @param {import('sequelize').WhereOptions} [where] * @param {import('sequelize').WhereOptions} [where]
* @returns {Array<objects.LibraryItem>} old library items * @returns {Array<objects.LibraryItem>} old library items
*/ */
@@ -497,6 +497,57 @@ class LibraryItem extends Model {
return libraryItem return libraryItem
} }
/**
*
* @param {import('sequelize').WhereOptions} where
* @param {import('sequelize').IncludeOptions} [include]
* @returns {Promise<LibraryItemExpanded>}
*/
static async findOneExpanded(where, include = null) {
const libraryItem = await this.findOne({
where,
include
})
if (!libraryItem) {
Logger.error(`[LibraryItem] Library item not found`)
return null
}
if (libraryItem.mediaType === 'podcast') {
libraryItem.media = await libraryItem.getMedia({
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
})
} else {
libraryItem.media = await libraryItem.getMedia({
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['id', 'sequence']
}
}
],
order: [
[this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
]
})
}
if (!libraryItem.media) return null
return libraryItem
}
/** /**
* Get old library item by id * Get old library item by id
* @param {string} libraryItemId * @param {string} libraryItemId
@@ -865,54 +916,6 @@ class LibraryItem extends Model {
return libraryItem.media.coverPath return libraryItem.media.coverPath
} }
/**
*
* @param {import('sequelize').FindOptions} options
* @returns {Promise<Book|Podcast>}
*/
getMedia(options) {
if (!this.mediaType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}`
return this[mixinMethodName](options)
}
/**
*
* @returns {Promise<Book|Podcast>}
*/
getMediaExpanded() {
if (this.mediaType === 'podcast') {
return this.getMedia({
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
})
} else {
return this.getMedia({
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [
[this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
]
})
}
}
/** /**
* *
* @returns {Promise} * @returns {Promise}
@@ -1061,6 +1064,9 @@ class LibraryItem extends Model {
{ {
fields: ['libraryId', 'mediaType'] fields: ['libraryId', 'mediaType']
}, },
{
fields: ['libraryId', 'mediaType', 'size']
},
{ {
fields: ['libraryId', 'mediaId', 'mediaType'] fields: ['libraryId', 'mediaId', 'mediaType']
}, },
@@ -1128,6 +1134,64 @@ class LibraryItem extends Model {
}) })
} }
get isBook() {
return this.mediaType === 'book'
}
get isPodcast() {
return this.mediaType === 'podcast'
}
get hasAudioTracks() {
return this.media.hasAudioTracks()
}
/**
*
* @param {import('sequelize').FindOptions} options
* @returns {Promise<Book|Podcast>}
*/
getMedia(options) {
if (!this.mediaType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}`
return this[mixinMethodName](options)
}
/**
*
* @returns {Promise<Book|Podcast>}
*/
getMediaExpanded() {
if (this.mediaType === 'podcast') {
return this.getMedia({
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
})
} else {
return this.getMedia({
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [
[this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
]
})
}
}
/** /**
* Check if book or podcast library item has audio tracks * Check if book or podcast library item has audio tracks
* Requires expanded library item * Requires expanded library item
@@ -1139,12 +1203,149 @@ class LibraryItem extends Model {
Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`) Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`)
return false return false
} }
if (this.mediaType === 'book') { if (this.isBook) {
return this.media.audioFiles?.length > 0 return this.media.audioFiles?.length > 0
} else { } else {
return this.media.podcastEpisodes?.length > 0 return this.media.podcastEpisodes?.length > 0
} }
} }
/**
*
* @param {string} ino
* @returns {import('./Book').AudioFileObject}
*/
getAudioFileWithIno(ino) {
if (!this.media) {
Logger.error(`[LibraryItem] getAudioFileWithIno: Library item "${this.id}" does not have media`)
return null
}
if (this.isBook) {
return this.media.audioFiles.find((af) => af.ino === ino)
} else {
return this.media.podcastEpisodes.find((pe) => pe.audioFile?.ino === ino)?.audioFile
}
}
/**
* Get the track list to be used in client audio players
* AudioTrack is the AudioFile with startOffset and contentUrl
* Podcasts must have an episodeId to get the track list
*
* @param {string} [episodeId]
* @returns {import('./Book').AudioTrack[]}
*/
getTrackList(episodeId) {
if (!this.media) {
Logger.error(`[LibraryItem] getTrackList: Library item "${this.id}" does not have media`)
return []
}
return this.media.getTracklist(this.id, episodeId)
}
/**
*
* @param {string} ino
* @returns {LibraryFile}
*/
getLibraryFileWithIno(ino) {
const libraryFile = this.libraryFiles.find((lf) => lf.ino === ino)
if (!libraryFile) return null
return new LibraryFile(libraryFile)
}
getLibraryFiles() {
return this.libraryFiles.map((lf) => new LibraryFile(lf))
}
getLibraryFilesJson() {
return this.libraryFiles.map((lf) => new LibraryFile(lf).toJSON())
}
toOldJSON() {
if (!this.media) {
throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`)
}
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
libraryId: this.libraryId,
folderId: this.libraryFolderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtime?.valueOf(),
ctimeMs: this.ctime?.valueOf(),
birthtimeMs: this.birthtime?.valueOf(),
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf(),
lastScan: this.lastScan?.valueOf(),
scanVersion: this.lastScanVersion,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toOldJSON(this.id),
// LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database
libraryFiles: this.getLibraryFilesJson()
}
}
toOldJSONMinified() {
if (!this.media) {
throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`)
}
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
libraryId: this.libraryId,
folderId: this.libraryFolderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtime?.valueOf(),
ctimeMs: this.ctime?.valueOf(),
birthtimeMs: this.birthtime?.valueOf(),
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf(),
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toOldJSONMinified(),
numFiles: this.libraryFiles.length,
size: this.size
}
}
toOldJSONExpanded() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
libraryId: this.libraryId,
folderId: this.libraryFolderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtime?.valueOf(),
ctimeMs: this.ctime?.valueOf(),
birthtimeMs: this.birthtime?.valueOf(),
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf(),
lastScan: this.lastScan?.valueOf(),
scanVersion: this.lastScanVersion,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toOldJSONExpanded(this.id),
// LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database
libraryFiles: this.getLibraryFilesJson(),
size: this.size
}
}
} }
module.exports = LibraryItem module.exports = LibraryItem
+15 -31
View File
@@ -76,42 +76,26 @@ class MediaItemShare extends Model {
} }
/** /**
* Expanded book that includes library settings
* *
* @param {string} mediaItemId * @param {string} mediaItemId
* @param {string} mediaItemType * @param {string} mediaItemType
* @returns {Promise<import('../objects/LibraryItem')>} * @returns {Promise<import('./LibraryItem').LibraryItemExpanded>}
*/ */
static async getMediaItemsOldLibraryItem(mediaItemId, mediaItemType) { static async getMediaItemsLibraryItem(mediaItemId, mediaItemType) {
/** @type {typeof import('./LibraryItem')} */
const libraryItemModel = this.sequelize.models.libraryItem
if (mediaItemType === 'book') { if (mediaItemType === 'book') {
const book = await this.sequelize.models.book.findByPk(mediaItemId, { const libraryItem = await libraryItemModel.findOneExpanded(
include: [ { mediaId: mediaItemId },
{ {
model: this.sequelize.models.author, model: this.sequelize.models.library,
through: { attributes: ['settings']
attributes: [] }
} )
},
{ return libraryItem
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
},
{
model: this.sequelize.models.libraryItem,
include: {
model: this.sequelize.models.library,
attributes: ['settings']
}
}
]
})
const libraryItem = book.libraryItem
libraryItem.media = book
delete book.libraryItem
const oldLibraryItem = this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
oldLibraryItem.librarySettings = libraryItem.library.settings
return oldLibraryItem
} }
return null return null
} }
+141 -156
View File
@@ -1,8 +1,6 @@
const { DataTypes, Model, Op, literal } = require('sequelize') const { DataTypes, Model, Op } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const oldPlaylist = require('../objects/Playlist')
class Playlist extends Model { class Playlist extends Model {
constructor(values, options) { constructor(values, options) {
super(values, options) super(values, options)
@@ -21,134 +19,23 @@ class Playlist extends Model {
this.createdAt this.createdAt
/** @type {Date} */ /** @type {Date} */
this.updatedAt this.updatedAt
}
static getOldPlaylist(playlistExpanded) { // Expanded properties
const items = playlistExpanded.playlistMediaItems
.map((pmi) => {
const mediaItem = pmi.mediaItem || pmi.dataValues?.mediaItem
const libraryItemId = mediaItem?.podcast?.libraryItem?.id || mediaItem?.libraryItem?.id || null
if (!libraryItemId) {
Logger.error(`[Playlist] Invalid playlist media item - No library item id found`, JSON.stringify(pmi, null, 2))
return null
}
return {
episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '',
libraryItemId
}
})
.filter((pmi) => pmi)
return new oldPlaylist({ /** @type {import('./PlaylistMediaItem')[]} - only set when expanded */
id: playlistExpanded.id, this.playlistMediaItems
libraryId: playlistExpanded.libraryId,
userId: playlistExpanded.userId,
name: playlistExpanded.name,
description: playlistExpanded.description,
items,
lastUpdate: playlistExpanded.updatedAt.valueOf(),
createdAt: playlistExpanded.createdAt.valueOf()
})
} }
/** /**
* Get old playlist toJSONExpanded * Get old playlists for user and library
* @param {string[]} [include]
* @returns {Promise<oldPlaylist>} oldPlaylist.toJSONExpanded
*/
async getOldJsonExpanded(include) {
this.playlistMediaItems =
(await this.getPlaylistMediaItems({
include: [
{
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.podcastEpisode,
include: {
model: this.sequelize.models.podcast,
include: this.sequelize.models.libraryItem
}
}
],
order: [['order', 'ASC']]
})) || []
const oldPlaylist = this.sequelize.models.playlist.getOldPlaylist(this)
const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId)
let libraryItems = await this.sequelize.models.libraryItem.getAllOldLibraryItems({
id: libraryItemIds
})
const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems)
return playlistExpanded
}
static createFromOld(oldPlaylist) {
const playlist = this.getFromOld(oldPlaylist)
return this.create(playlist)
}
static getFromOld(oldPlaylist) {
return {
id: oldPlaylist.id,
name: oldPlaylist.name,
description: oldPlaylist.description,
userId: oldPlaylist.userId,
libraryId: oldPlaylist.libraryId
}
}
static removeById(playlistId) {
return this.destroy({
where: {
id: playlistId
}
})
}
/**
* Get playlist by id
* @param {string} playlistId
* @returns {Promise<oldPlaylist|null>} returns null if not found
*/
static async getById(playlistId) {
if (!playlistId) return null
const playlist = await this.findByPk(playlistId, {
include: {
model: this.sequelize.models.playlistMediaItem,
include: [
{
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.podcastEpisode,
include: {
model: this.sequelize.models.podcast,
include: this.sequelize.models.libraryItem
}
}
]
},
order: [['playlistMediaItems', 'order', 'ASC']]
})
if (!playlist) return null
return this.getOldPlaylist(playlist)
}
/**
* Get old playlists for user and optionally for library
* *
* @param {string} userId * @param {string} userId
* @param {string} [libraryId] * @param {string} libraryId
* @returns {Promise<oldPlaylist[]>} * @async
*/ */
static async getOldPlaylistsForUserAndLibrary(userId, libraryId = null) { static async getOldPlaylistsForUserAndLibrary(userId, libraryId) {
if (!userId && !libraryId) return [] if (!userId && !libraryId) return []
const whereQuery = {} const whereQuery = {}
if (userId) { if (userId) {
whereQuery.userId = userId whereQuery.userId = userId
@@ -163,7 +50,23 @@ class Playlist extends Model {
include: [ include: [
{ {
model: this.sequelize.models.book, model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
}, },
{ {
model: this.sequelize.models.podcastEpisode, model: this.sequelize.models.podcastEpisode,
@@ -174,42 +77,13 @@ class Playlist extends Model {
} }
] ]
}, },
order: [ order: [['playlistMediaItems', 'order', 'ASC']]
[literal('name COLLATE NOCASE'), 'ASC'],
['playlistMediaItems', 'order', 'ASC']
]
}) })
const oldPlaylists = [] // Sort by name asc
for (const playlistExpanded of playlistsExpanded) { playlistsExpanded.sort((a, b) => a.name.localeCompare(b.name))
const oldPlaylist = this.getOldPlaylist(playlistExpanded)
const libraryItems = []
for (const pmi of playlistExpanded.playlistMediaItems) {
let mediaItem = pmi.mediaItem || pmi.dataValues.mediaItem
if (!mediaItem) { return playlistsExpanded.map((playlist) => playlist.toOldJSONExpanded())
Logger.error(`[Playlist] Invalid playlist media item - No media item found`, JSON.stringify(mediaItem, null, 2))
continue
}
let libraryItem = mediaItem.libraryItem || mediaItem.podcast?.libraryItem
if (mediaItem.podcast) {
libraryItem.media = mediaItem.podcast
libraryItem.media.podcastEpisodes = [mediaItem]
delete mediaItem.podcast.libraryItem
} else {
libraryItem.media = mediaItem
delete mediaItem.libraryItem
}
const oldLibraryItem = this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
libraryItems.push(oldLibraryItem)
}
const oldPlaylistJson = oldPlaylist.toJSONExpanded(libraryItems)
oldPlaylists.push(oldPlaylistJson)
}
return oldPlaylists
} }
/** /**
@@ -345,6 +219,117 @@ class Playlist extends Model {
} }
}) })
} }
/**
* Get all media items in playlist expanded with library item
*
* @returns {Promise<import('./PlaylistMediaItem')[]>}
*/
getMediaItemsExpandedWithLibraryItem() {
return this.getPlaylistMediaItems({
include: [
{
model: this.sequelize.models.book,
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
},
{
model: this.sequelize.models.podcastEpisode,
include: [
{
model: this.sequelize.models.podcast,
include: this.sequelize.models.libraryItem
}
]
}
],
order: [['order', 'ASC']]
})
}
/**
* Get playlists toOldJSONExpanded
*
* @async
*/
async getOldJsonExpanded() {
this.playlistMediaItems = await this.getMediaItemsExpandedWithLibraryItem()
return this.toOldJSONExpanded()
}
/**
* Old model used libraryItemId instead of bookId
*
* @param {string} libraryItemId
* @param {string} [episodeId]
*/
checkHasMediaItem(libraryItemId, episodeId) {
if (!this.playlistMediaItems) {
throw new Error('playlistMediaItems are required to check Playlist')
}
if (episodeId) {
return this.playlistMediaItems.some((pmi) => pmi.mediaItemId === episodeId)
}
return this.playlistMediaItems.some((pmi) => pmi.mediaItem.libraryItem.id === libraryItemId)
}
toOldJSON() {
return {
id: this.id,
name: this.name,
libraryId: this.libraryId,
userId: this.userId,
description: this.description,
lastUpdate: this.updatedAt.valueOf(),
createdAt: this.createdAt.valueOf()
}
}
toOldJSONExpanded() {
if (!this.playlistMediaItems) {
throw new Error('playlistMediaItems are required to expand Playlist')
}
const json = this.toOldJSON()
json.items = this.playlistMediaItems.map((pmi) => {
if (pmi.mediaItemType === 'book') {
const libraryItem = pmi.mediaItem.libraryItem
delete pmi.mediaItem.libraryItem
libraryItem.media = pmi.mediaItem
return {
libraryItemId: libraryItem.id,
libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded()
}
}
const libraryItem = pmi.mediaItem.podcast.libraryItem
delete pmi.mediaItem.podcast.libraryItem
libraryItem.media = pmi.mediaItem.podcast
return {
episodeId: pmi.mediaItemId,
episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id),
libraryItemId: libraryItem.id,
libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONMinified()
}
})
return json
}
} }
module.exports = Playlist module.exports = Playlist
+4 -8
View File
@@ -16,15 +16,11 @@ class PlaylistMediaItem extends Model {
this.playlistId this.playlistId
/** @type {Date} */ /** @type {Date} */
this.createdAt this.createdAt
}
static removeByIds(playlistId, mediaItemId) { // Expanded properties
return this.destroy({
where: { /** @type {import('./Book')|import('./PodcastEpisode')} - only set when expanded */
playlistId, this.mediaItem
mediaItemId
}
})
} }
getMediaItem(options) { getMediaItem(options) {
-54
View File
@@ -1,54 +0,0 @@
const { DataTypes, Model } = require('sequelize')
class Plugin extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.name
/** @type {string} */
this.version
/** @type {boolean} */
this.isMissing
/** @type {Object} */
this.config
/** @type {Object} */
this.extraData
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
version: DataTypes.STRING,
isMissing: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
config: DataTypes.JSON,
extraData: DataTypes.JSON
},
{
sequelize,
modelName: 'plugin'
}
)
}
}
module.exports = Plugin
+283 -19
View File
@@ -1,4 +1,6 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
const Logger = require('../Logger')
/** /**
* @typedef PodcastExpandedProperties * @typedef PodcastExpandedProperties
@@ -47,6 +49,8 @@ class Podcast extends Model {
this.lastEpisodeCheck this.lastEpisodeCheck
/** @type {number} */ /** @type {number} */
this.maxEpisodesToKeep this.maxEpisodesToKeep
/** @type {number} */
this.maxNewEpisodesToDownload
/** @type {string} */ /** @type {string} */
this.coverPath this.coverPath
/** @type {string[]} */ /** @type {string[]} */
@@ -57,6 +61,9 @@ class Podcast extends Model {
this.createdAt this.createdAt
/** @type {Date} */ /** @type {Date} */
this.updatedAt this.updatedAt
/** @type {import('./PodcastEpisode')[]} */
this.podcastEpisodes
} }
static getOldPodcast(libraryItemExpanded) { static getOldPodcast(libraryItemExpanded) {
@@ -119,25 +126,6 @@ class Podcast extends Model {
} }
} }
getAbsMetadataJson() {
return {
tags: this.tags || [],
title: this.title,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: this.genres || [],
feedURL: this.feedURL,
imageURL: this.imageURL,
itunesPageURL: this.itunesPageURL,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
language: this.language,
explicit: !!this.explicit,
podcastType: this.podcastType
}
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
@@ -179,6 +167,282 @@ class Podcast extends Model {
} }
) )
} }
get hasMediaFiles() {
return !!this.podcastEpisodes?.length
}
get hasAudioTracks() {
return this.hasMediaFiles
}
get size() {
if (!this.podcastEpisodes?.length) return 0
return this.podcastEpisodes.reduce((total, episode) => total + episode.size, 0)
}
getAbsMetadataJson() {
return {
tags: this.tags || [],
title: this.title,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: this.genres || [],
feedURL: this.feedURL,
imageURL: this.imageURL,
itunesPageURL: this.itunesPageURL,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
language: this.language,
explicit: !!this.explicit,
podcastType: this.podcastType
}
}
/**
*
* @param {Object} payload - Old podcast object
* @returns {Promise<boolean>}
*/
async updateFromRequest(payload) {
if (!payload) return false
let hasUpdates = false
if (payload.metadata) {
const stringKeys = ['title', 'author', 'releaseDate', 'feedUrl', 'imageUrl', 'description', 'itunesPageUrl', 'itunesId', 'itunesArtistId', 'language', 'type']
stringKeys.forEach((key) => {
let newKey = key
if (key === 'type') {
newKey = 'podcastType'
} else if (key === 'feedUrl') {
newKey = 'feedURL'
} else if (key === 'imageUrl') {
newKey = 'imageURL'
} else if (key === 'itunesPageUrl') {
newKey = 'itunesPageURL'
}
if (typeof payload.metadata[key] === 'string' && payload.metadata[key] !== this[newKey]) {
this[newKey] = payload.metadata[key]
if (key === 'title') {
this.titleIgnorePrefix = getTitleIgnorePrefix(this.title)
}
hasUpdates = true
}
})
if (payload.metadata.explicit !== undefined && payload.metadata.explicit !== this.explicit) {
this.explicit = !!payload.metadata.explicit
hasUpdates = true
}
if (Array.isArray(payload.metadata.genres) && !payload.metadata.genres.some((item) => typeof item !== 'string') && JSON.stringify(this.genres) !== JSON.stringify(payload.metadata.genres)) {
this.genres = payload.metadata.genres
this.changed('genres', true)
hasUpdates = true
}
}
if (Array.isArray(payload.tags) && !payload.tags.some((item) => typeof item !== 'string') && JSON.stringify(this.tags) !== JSON.stringify(payload.tags)) {
this.tags = payload.tags
this.changed('tags', true)
hasUpdates = true
}
if (payload.autoDownloadEpisodes !== undefined && payload.autoDownloadEpisodes !== this.autoDownloadEpisodes) {
this.autoDownloadEpisodes = !!payload.autoDownloadEpisodes
hasUpdates = true
}
if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) {
this.autoDownloadSchedule = payload.autoDownloadSchedule
hasUpdates = true
}
const numberKeys = ['maxEpisodesToKeep', 'maxNewEpisodesToDownload']
numberKeys.forEach((key) => {
if (typeof payload[key] === 'number' && payload[key] !== this[key]) {
this[key] = payload[key]
hasUpdates = true
}
})
if (hasUpdates) {
Logger.debug(`[Podcast] changed keys:`, this.changed())
await this.save()
}
return hasUpdates
}
checkCanDirectPlay(supportedMimeTypes, episodeId) {
if (!Array.isArray(supportedMimeTypes)) {
Logger.error(`[Podcast] checkCanDirectPlay: supportedMimeTypes is not an array`, supportedMimeTypes)
return false
}
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[Podcast] checkCanDirectPlay: episode not found`, episodeId)
return false
}
return supportedMimeTypes.includes(episode.audioFile.mimeType)
}
/**
* Get the track list to be used in client audio players
* AudioTrack is the AudioFile with startOffset and contentUrl
* Podcast episodes only have one track
*
* @param {string} libraryItemId
* @param {string} episodeId
* @returns {import('./Book').AudioTrack[]}
*/
getTracklist(libraryItemId, episodeId) {
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[Podcast] getTracklist: episode not found`, episodeId)
return []
}
const audioTrack = episode.getAudioTrack(libraryItemId)
return [audioTrack]
}
/**
*
* @param {string} episodeId
* @returns {import('./PodcastEpisode').ChapterObject[]}
*/
getChapters(episodeId) {
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[Podcast] getChapters: episode not found`, episodeId)
return []
}
return structuredClone(episode.chapters) || []
}
getPlaybackTitle(episodeId) {
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[Podcast] getPlaybackTitle: episode not found`, episodeId)
return ''
}
return episode.title
}
getPlaybackAuthor() {
return this.author
}
getPlaybackDuration(episodeId) {
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[Podcast] getPlaybackDuration: episode not found`, episodeId)
return 0
}
return episode.duration
}
/**
* Old model kept metadata in a separate object
*/
oldMetadataToJSON() {
return {
title: this.title,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: [...(this.genres || [])],
feedUrl: this.feedURL,
imageUrl: this.imageURL,
itunesPageUrl: this.itunesPageURL,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
explicit: this.explicit,
language: this.language,
type: this.podcastType
}
}
oldMetadataToJSONExpanded() {
const oldMetadataJSON = this.oldMetadataToJSON()
oldMetadataJSON.titleIgnorePrefix = getTitlePrefixAtEnd(this.title)
return oldMetadataJSON
}
/**
* The old model stored episodes with the podcast object
*
* @param {string} libraryItemId
*/
toOldJSON(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
}
if (!this.podcastEpisodes) {
throw new Error(`[Podcast] Cannot convert to old JSON because episodes are not provided`)
}
return {
id: this.id,
libraryItemId: libraryItemId,
metadata: this.oldMetadataToJSON(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
episodes: this.podcastEpisodes.map((episode) => episode.toOldJSON(libraryItemId)),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload
}
}
toOldJSONMinified() {
return {
id: this.id,
// Minified metadata and expanded metadata are the same
metadata: this.oldMetadataToJSONExpanded(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
numEpisodes: this.podcastEpisodes?.length || 0,
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
size: this.size
}
}
toOldJSONExpanded(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
}
if (!this.podcastEpisodes) {
throw new Error(`[Podcast] Cannot convert to old JSON because episodes are not provided`)
}
return {
id: this.id,
libraryItemId: libraryItemId,
metadata: this.oldMetadataToJSONExpanded(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
size: this.size
}
}
} }
module.exports = Podcast module.exports = Podcast
+105 -36
View File
@@ -53,42 +53,6 @@ class PodcastEpisode extends Model {
this.updatedAt this.updatedAt
} }
/**
* @param {string} libraryItemId
* @returns {oldPodcastEpisode}
*/
getOldPodcastEpisode(libraryItemId = null) {
let enclosure = null
if (this.enclosureURL) {
enclosure = {
url: this.enclosureURL,
type: this.enclosureType,
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
}
}
return new oldPodcastEpisode({
libraryItemId: libraryItemId || null,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.extraData?.oldEpisodeId || null,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure,
guid: this.extraData?.guid || null,
pubDate: this.pubDate,
chapters: this.chapters,
audioFile: this.audioFile,
publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
})
}
static createFromOld(oldEpisode) { static createFromOld(oldEpisode) {
const podcastEpisode = this.getFromOld(oldEpisode) const podcastEpisode = this.getFromOld(oldEpisode)
return this.create(podcastEpisode) return this.create(podcastEpisode)
@@ -170,6 +134,111 @@ class PodcastEpisode extends Model {
}) })
PodcastEpisode.belongsTo(podcast) PodcastEpisode.belongsTo(podcast)
} }
get size() {
return this.audioFile?.metadata.size || 0
}
get duration() {
return this.audioFile?.duration || 0
}
/**
* Used in client players
*
* @param {string} libraryItemId
* @returns {import('./Book').AudioTrack}
*/
getAudioTrack(libraryItemId) {
const track = structuredClone(this.audioFile)
track.startOffset = 0
track.title = this.audioFile.metadata.title
track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}`
return track
}
/**
* @param {string} libraryItemId
* @returns {oldPodcastEpisode}
*/
getOldPodcastEpisode(libraryItemId = null) {
let enclosure = null
if (this.enclosureURL) {
enclosure = {
url: this.enclosureURL,
type: this.enclosureType,
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
}
}
return new oldPodcastEpisode({
libraryItemId: libraryItemId || null,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.extraData?.oldEpisodeId || null,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure,
guid: this.extraData?.guid || null,
pubDate: this.pubDate,
chapters: this.chapters,
audioFile: this.audioFile,
publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
})
}
toOldJSON(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[PodcastEpisode] Cannot convert to old JSON because libraryItemId is not provided`)
}
let enclosure = null
if (this.enclosureURL) {
enclosure = {
url: this.enclosureURL,
type: this.enclosureType,
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
}
}
return {
libraryItemId: libraryItemId,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.extraData?.oldEpisodeId || null,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure,
guid: this.extraData?.guid || null,
pubDate: this.pubDate,
chapters: structuredClone(this.chapters),
audioFile: structuredClone(this.audioFile),
publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
}
}
toOldJSONExpanded(libraryItemId) {
const json = this.toOldJSON(libraryItemId)
json.audioTrack = this.getAudioTrack(libraryItemId)
json.size = this.size
json.duration = this.duration
return json
}
} }
module.exports = PodcastEpisode module.exports = PodcastEpisode
-115
View File
@@ -1,115 +0,0 @@
const uuidv4 = require("uuid").v4
class Collection {
constructor(collection) {
this.id = null
this.libraryId = null
this.name = null
this.description = null
this.cover = null
this.coverFullPath = null
this.books = []
this.lastUpdate = null
this.createdAt = null
if (collection) {
this.construct(collection)
}
}
toJSON() {
return {
id: this.id,
libraryId: this.libraryId,
name: this.name,
description: this.description,
cover: this.cover,
coverFullPath: this.coverFullPath,
books: [...this.books],
lastUpdate: this.lastUpdate,
createdAt: this.createdAt
}
}
toJSONExpanded(libraryItems, minifiedBooks = false) {
const json = this.toJSON()
json.books = json.books.map(bookId => {
const book = libraryItems.find(li => li.id === bookId)
return book ? minifiedBooks ? book.toJSONMinified() : book.toJSONExpanded() : null
}).filter(b => !!b)
return json
}
// Expanded and filtered out items not accessible to user
toJSONExpandedForUser(user, libraryItems) {
const json = this.toJSON()
json.books = json.books.map(libraryItemId => {
const libraryItem = libraryItems.find(li => li.id === libraryItemId)
return libraryItem ? libraryItem.toJSONExpanded() : null
}).filter(li => {
return li && user.checkCanAccessLibraryItem(li)
})
return json
}
construct(collection) {
this.id = collection.id
this.libraryId = collection.libraryId
this.name = collection.name
this.description = collection.description || null
this.cover = collection.cover || null
this.coverFullPath = collection.coverFullPath || null
this.books = collection.books ? [...collection.books] : []
this.lastUpdate = collection.lastUpdate || null
this.createdAt = collection.createdAt || null
}
setData(data) {
if (!data.libraryId || !data.name) {
return false
}
this.id = uuidv4()
this.libraryId = data.libraryId
this.name = data.name
this.description = data.description || null
this.cover = data.cover || null
this.coverFullPath = data.coverFullPath || null
this.books = data.books ? [...data.books] : []
this.lastUpdate = Date.now()
this.createdAt = Date.now()
return true
}
addBook(bookId) {
this.books.push(bookId)
this.lastUpdate = Date.now()
}
removeBook(bookId) {
this.books = this.books.filter(bid => bid !== bookId)
this.lastUpdate = Date.now()
}
update(payload) {
let hasUpdates = false
for (const key in payload) {
if (key === 'books') {
if (payload.books && this.books.join(',') !== payload.books.join(',')) {
this.books = [...payload.books]
hasUpdates = true
}
} else if (this[key] !== undefined && this[key] !== payload[key]) {
hasUpdates = true
this[key] = payload[key]
}
}
if (hasUpdates) {
this.lastUpdate = Date.now()
}
return hasUpdates
}
}
module.exports = Collection
-22
View File
@@ -177,9 +177,6 @@ class LibraryItem {
get hasAudioFiles() { get hasAudioFiles() {
return this.libraryFiles.some((lf) => lf.fileType === 'audio') return this.libraryFiles.some((lf) => lf.fileType === 'audio')
} }
get hasMediaEntities() {
return this.media.hasMediaEntities
}
// Data comes from scandir library item data // Data comes from scandir library item data
// TODO: Remove this function. Only used when creating a new podcast now // TODO: Remove this function. Only used when creating a new podcast now
@@ -252,10 +249,6 @@ class LibraryItem {
this.updatedAt = Date.now() this.updatedAt = Date.now()
} }
getDirectPlayTracklist(episodeId) {
return this.media.getDirectPlayTracklist(episodeId)
}
/** /**
* Save metadata.json file * Save metadata.json file
* TODO: Move to new LibraryItem model * TODO: Move to new LibraryItem model
@@ -327,20 +320,5 @@ class LibraryItem {
} }
return false return false
} }
/**
* Set the EBookFile from a LibraryFile
* If null then ebookFile will be removed from the book
* all ebook library files that are not primary are marked as supplementary
*
* @param {LibraryFile} [libraryFile]
*/
setPrimaryEbook(ebookLibraryFile = null) {
const ebookLibraryFiles = this.libraryFiles.filter((lf) => lf.isEBookFile)
for (const libraryFile of ebookLibraryFiles) {
libraryFile.isSupplementary = ebookLibraryFile?.ino !== libraryFile.ino
}
this.media.setEbookFile(ebookLibraryFile)
}
} }
module.exports = LibraryItem module.exports = LibraryItem
+16 -25
View File
@@ -1,8 +1,6 @@
const date = require('../libs/dateAndTime') const date = require('../libs/dateAndTime')
const uuidv4 = require('uuid').v4 const uuidv4 = require('uuid').v4
const serverVersion = require('../../package.json').version const serverVersion = require('../../package.json').version
const BookMetadata = require('./metadata/BookMetadata')
const PodcastMetadata = require('./metadata/PodcastMetadata')
const DeviceInfo = require('./DeviceInfo') const DeviceInfo = require('./DeviceInfo')
class PlaybackSession { class PlaybackSession {
@@ -60,7 +58,7 @@ class PlaybackSession {
bookId: this.bookId, bookId: this.bookId,
episodeId: this.episodeId, episodeId: this.episodeId,
mediaType: this.mediaType, mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata?.toJSON() || null, mediaMetadata: structuredClone(this.mediaMetadata),
chapters: (this.chapters || []).map((c) => ({ ...c })), chapters: (this.chapters || []).map((c) => ({ ...c })),
displayTitle: this.displayTitle, displayTitle: this.displayTitle,
displayAuthor: this.displayAuthor, displayAuthor: this.displayAuthor,
@@ -82,7 +80,7 @@ class PlaybackSession {
/** /**
* Session data to send to clients * Session data to send to clients
* @param {Object} [libraryItem] - old library item * @param {import('../models/LibraryItem')} [libraryItem]
* @returns * @returns
*/ */
toJSONForClient(libraryItem) { toJSONForClient(libraryItem) {
@@ -94,7 +92,7 @@ class PlaybackSession {
bookId: this.bookId, bookId: this.bookId,
episodeId: this.episodeId, episodeId: this.episodeId,
mediaType: this.mediaType, mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata?.toJSON() || null, mediaMetadata: structuredClone(this.mediaMetadata),
chapters: (this.chapters || []).map((c) => ({ ...c })), chapters: (this.chapters || []).map((c) => ({ ...c })),
displayTitle: this.displayTitle, displayTitle: this.displayTitle,
displayAuthor: this.displayAuthor, displayAuthor: this.displayAuthor,
@@ -112,7 +110,7 @@ class PlaybackSession {
startedAt: this.startedAt, startedAt: this.startedAt,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }), audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }),
libraryItem: libraryItem?.toJSONExpanded() || null libraryItem: libraryItem?.toOldJSONExpanded() || null
} }
} }
@@ -148,14 +146,7 @@ class PlaybackSession {
this.serverVersion = session.serverVersion this.serverVersion = session.serverVersion
this.chapters = session.chapters || [] this.chapters = session.chapters || []
this.mediaMetadata = null this.mediaMetadata = session.mediaMetadata
if (session.mediaMetadata) {
if (this.mediaType === 'book') {
this.mediaMetadata = new BookMetadata(session.mediaMetadata)
} else if (this.mediaType === 'podcast') {
this.mediaMetadata = new PodcastMetadata(session.mediaMetadata)
}
}
this.displayTitle = session.displayTitle || '' this.displayTitle = session.displayTitle || ''
this.displayAuthor = session.displayAuthor || '' this.displayAuthor = session.displayAuthor || ''
this.coverPath = session.coverPath this.coverPath = session.coverPath
@@ -205,6 +196,15 @@ class PlaybackSession {
} }
} }
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} userId
* @param {*} mediaPlayer
* @param {*} deviceInfo
* @param {*} startTime
* @param {*} episodeId
*/
setData(libraryItem, userId, mediaPlayer, deviceInfo, startTime, episodeId = null) { setData(libraryItem, userId, mediaPlayer, deviceInfo, startTime, episodeId = null) {
this.id = uuidv4() this.id = uuidv4()
this.userId = userId this.userId = userId
@@ -213,13 +213,12 @@ class PlaybackSession {
this.bookId = episodeId ? null : libraryItem.media.id this.bookId = episodeId ? null : libraryItem.media.id
this.episodeId = episodeId this.episodeId = episodeId
this.mediaType = libraryItem.mediaType this.mediaType = libraryItem.mediaType
this.mediaMetadata = libraryItem.media.metadata.clone() this.mediaMetadata = libraryItem.media.oldMetadataToJSON()
this.chapters = libraryItem.media.getChapters(episodeId) this.chapters = libraryItem.media.getChapters(episodeId)
this.displayTitle = libraryItem.media.getPlaybackTitle(episodeId) this.displayTitle = libraryItem.media.getPlaybackTitle(episodeId)
this.displayAuthor = libraryItem.media.getPlaybackAuthor() this.displayAuthor = libraryItem.media.getPlaybackAuthor()
this.coverPath = libraryItem.media.coverPath this.coverPath = libraryItem.media.coverPath
this.duration = libraryItem.media.getPlaybackDuration(episodeId)
this.setDuration(libraryItem, episodeId)
this.mediaPlayer = mediaPlayer this.mediaPlayer = mediaPlayer
this.deviceInfo = deviceInfo || new DeviceInfo() this.deviceInfo = deviceInfo || new DeviceInfo()
@@ -235,14 +234,6 @@ class PlaybackSession {
this.updatedAt = Date.now() this.updatedAt = Date.now()
} }
setDuration(libraryItem, episodeId) {
if (episodeId) {
this.duration = libraryItem.media.getEpisodeDuration(episodeId)
} else {
this.duration = libraryItem.media.duration
}
}
addListeningTime(timeListened) { addListeningTime(timeListened) {
if (!timeListened || isNaN(timeListened)) return if (!timeListened || isNaN(timeListened)) return
-148
View File
@@ -1,148 +0,0 @@
const uuidv4 = require("uuid").v4
class Playlist {
constructor(playlist) {
this.id = null
this.libraryId = null
this.userId = null
this.name = null
this.description = null
this.coverPath = null
// Array of objects like { libraryItemId: "", episodeId: "" } (episodeId optional)
this.items = []
this.lastUpdate = null
this.createdAt = null
if (playlist) {
this.construct(playlist)
}
}
toJSON() {
return {
id: this.id,
libraryId: this.libraryId,
userId: this.userId,
name: this.name,
description: this.description,
coverPath: this.coverPath,
items: [...this.items],
lastUpdate: this.lastUpdate,
createdAt: this.createdAt
}
}
// Expands the items array
toJSONExpanded(libraryItems) {
var json = this.toJSON()
json.items = json.items.map(item => {
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
if (!libraryItem) {
// Not found
return null
}
if (item.episodeId) {
if (!libraryItem.isPodcast) {
// Invalid
return null
}
const episode = libraryItem.media.episodes.find(ep => ep.id === item.episodeId)
if (!episode) {
// Not found
return null
}
return {
...item,
episode: episode.toJSONExpanded(),
libraryItem: libraryItem.toJSONMinified()
}
} else {
return {
...item,
libraryItem: libraryItem.toJSONExpanded()
}
}
}).filter(i => i)
return json
}
construct(playlist) {
this.id = playlist.id
this.libraryId = playlist.libraryId
this.userId = playlist.userId
this.name = playlist.name
this.description = playlist.description || null
this.coverPath = playlist.coverPath || null
this.items = playlist.items ? playlist.items.map(i => ({ ...i })) : []
this.lastUpdate = playlist.lastUpdate || null
this.createdAt = playlist.createdAt || null
}
setData(data) {
if (!data.userId || !data.libraryId || !data.name) {
return false
}
this.id = uuidv4()
this.userId = data.userId
this.libraryId = data.libraryId
this.name = data.name
this.description = data.description || null
this.coverPath = data.coverPath || null
this.items = data.items ? data.items.map(i => ({ ...i })) : []
this.lastUpdate = Date.now()
this.createdAt = Date.now()
return true
}
addItem(libraryItemId, episodeId = null) {
this.items.push({
libraryItemId,
episodeId: episodeId || null
})
this.lastUpdate = Date.now()
}
removeItem(libraryItemId, episodeId = null) {
if (episodeId) this.items = this.items.filter(i => i.libraryItemId !== libraryItemId || i.episodeId !== episodeId)
else this.items = this.items.filter(i => i.libraryItemId !== libraryItemId)
this.lastUpdate = Date.now()
}
update(payload) {
let hasUpdates = false
for (const key in payload) {
if (key === 'items') {
if (payload.items && JSON.stringify(payload.items) !== JSON.stringify(this.items)) {
this.items = payload.items.map(i => ({ ...i }))
hasUpdates = true
}
} else if (this[key] !== undefined && this[key] !== payload[key]) {
hasUpdates = true
this[key] = payload[key]
}
}
if (hasUpdates) {
this.lastUpdate = Date.now()
}
return hasUpdates
}
containsItem(item) {
if (item.episodeId) return this.items.some(i => i.libraryItemId === item.libraryItemId && i.episodeId === item.episodeId)
return this.items.some(i => i.libraryItemId === item.libraryItemId)
}
hasItemsForLibraryItem(libraryItemId) {
return this.items.some(i => i.libraryItemId === libraryItemId)
}
removeItemsForLibraryItem(libraryItemId) {
this.items = this.items.filter(i => i.libraryItemId !== libraryItemId)
}
}
module.exports = Playlist
+12 -17
View File
@@ -18,6 +18,7 @@ class Stream extends EventEmitter {
this.id = sessionId this.id = sessionId
this.user = user this.user = user
/** @type {import('../models/LibraryItem')} */
this.libraryItem = libraryItem this.libraryItem = libraryItem
this.episodeId = episodeId this.episodeId = episodeId
@@ -40,31 +41,25 @@ class Stream extends EventEmitter {
this.furthestSegmentCreated = 0 this.furthestSegmentCreated = 0
} }
get isPodcast() { /**
return this.libraryItem.mediaType === 'podcast' * @returns {import('../models/PodcastEpisode') | null}
} */
get episode() { get episode() {
if (!this.isPodcast) return null if (!this.libraryItem.isPodcast) return null
return this.libraryItem.media.episodes.find((ep) => ep.id === this.episodeId) return this.libraryItem.media.podcastEpisodes.find((ep) => ep.id === this.episodeId)
}
get libraryItemId() {
return this.libraryItem.id
} }
get mediaTitle() { get mediaTitle() {
if (this.episode) return this.episode.title || '' return this.libraryItem.media.getPlaybackTitle(this.episodeId)
return this.libraryItem.media.metadata.title || ''
} }
get totalDuration() { get totalDuration() {
if (this.episode) return this.episode.duration return this.libraryItem.media.getPlaybackDuration(this.episodeId)
return this.libraryItem.media.duration
} }
get tracks() { get tracks() {
if (this.episode) return this.episode.tracks return this.libraryItem.getTrackList(this.episodeId)
return this.libraryItem.media.tracks
} }
get tracksAudioFileType() { get tracksAudioFileType() {
if (!this.tracks.length) return null if (!this.tracks.length) return null
return this.tracks[0].metadata.format return this.tracks[0].metadata.ext.slice(1)
} }
get tracksMimeType() { get tracksMimeType() {
if (!this.tracks.length) return null if (!this.tracks.length) return null
@@ -116,8 +111,8 @@ class Stream extends EventEmitter {
return { return {
id: this.id, id: this.id,
userId: this.user.id, userId: this.user.id,
libraryItem: this.libraryItem.toJSONExpanded(), libraryItem: this.libraryItem.toOldJSONExpanded(),
episode: this.episode ? this.episode.toJSONExpanded() : null, episode: this.episode ? this.episode.toOldJSONExpanded(this.libraryItem.id) : null,
segmentLength: this.segmentLength, segmentLength: this.segmentLength,
playlistPath: this.playlistPath, playlistPath: this.playlistPath,
clientPlaylistUri: this.clientPlaylistUri, clientPlaylistUri: this.clientPlaylistUri,
-10
View File
@@ -168,16 +168,6 @@ class PodcastEpisode {
return hasUpdates return hasUpdates
} }
// Only checks container format
checkCanDirectPlay(payload) {
const supportedMimeTypes = payload.supportedMimeTypes || []
return supportedMimeTypes.includes(this.audioFile.mimeType)
}
getDirectPlayTracklist() {
return this.tracks
}
checkEqualsEnclosureUrl(url) { checkEqualsEnclosureUrl(url) {
if (!this.enclosure?.url) return false if (!this.enclosure?.url) return false
return this.enclosure.url == url return this.enclosure.url == url
+11 -131
View File
@@ -33,8 +33,8 @@ class Book {
this.metadata = new BookMetadata(book.metadata) this.metadata = new BookMetadata(book.metadata)
this.coverPath = book.coverPath this.coverPath = book.coverPath
this.tags = [...book.tags] this.tags = [...book.tags]
this.audioFiles = book.audioFiles.map(f => new AudioFile(f)) this.audioFiles = book.audioFiles.map((f) => new AudioFile(f))
this.chapters = book.chapters.map(c => ({ ...c })) this.chapters = book.chapters.map((c) => ({ ...c }))
this.ebookFile = book.ebookFile ? new EBookFile(book.ebookFile) : null this.ebookFile = book.ebookFile ? new EBookFile(book.ebookFile) : null
this.lastCoverSearch = book.lastCoverSearch || null this.lastCoverSearch = book.lastCoverSearch || null
this.lastCoverSearchQuery = book.lastCoverSearchQuery || null this.lastCoverSearchQuery = book.lastCoverSearchQuery || null
@@ -47,8 +47,8 @@ class Book {
metadata: this.metadata.toJSON(), metadata: this.metadata.toJSON(),
coverPath: this.coverPath, coverPath: this.coverPath,
tags: [...this.tags], tags: [...this.tags],
audioFiles: this.audioFiles.map(f => f.toJSON()), audioFiles: this.audioFiles.map((f) => f.toJSON()),
chapters: this.chapters.map(c => ({ ...c })), chapters: this.chapters.map((c) => ({ ...c })),
ebookFile: this.ebookFile ? this.ebookFile.toJSON() : null ebookFile: this.ebookFile ? this.ebookFile.toJSON() : null
} }
} }
@@ -75,11 +75,11 @@ class Book {
metadata: this.metadata.toJSONExpanded(), metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath, coverPath: this.coverPath,
tags: [...this.tags], tags: [...this.tags],
audioFiles: this.audioFiles.map(f => f.toJSON()), audioFiles: this.audioFiles.map((f) => f.toJSON()),
chapters: this.chapters.map(c => ({ ...c })), chapters: this.chapters.map((c) => ({ ...c })),
duration: this.duration, duration: this.duration,
size: this.size, size: this.size,
tracks: this.tracks.map(t => t.toJSON()), tracks: this.tracks.map((t) => t.toJSON()),
ebookFile: this.ebookFile?.toJSON() || null ebookFile: this.ebookFile?.toJSON() || null
} }
} }
@@ -87,24 +87,21 @@ class Book {
toJSONForMetadataFile() { toJSONForMetadataFile() {
return { return {
tags: [...this.tags], tags: [...this.tags],
chapters: this.chapters.map(c => ({ ...c })), chapters: this.chapters.map((c) => ({ ...c })),
...this.metadata.toJSONForMetadataFile() ...this.metadata.toJSONForMetadataFile()
} }
} }
get size() { get size() {
var total = 0 var total = 0
this.audioFiles.forEach((af) => total += af.metadata.size) this.audioFiles.forEach((af) => (total += af.metadata.size))
if (this.ebookFile) { if (this.ebookFile) {
total += this.ebookFile.metadata.size total += this.ebookFile.metadata.size
} }
return total return total
} }
get hasMediaEntities() {
return !!this.tracks.length || this.ebookFile
}
get includedAudioFiles() { get includedAudioFiles() {
return this.audioFiles.filter(af => !af.exclude) return this.audioFiles.filter((af) => !af.exclude)
} }
get tracks() { get tracks() {
let startOffset = 0 let startOffset = 0
@@ -117,7 +114,7 @@ class Book {
} }
get duration() { get duration() {
let total = 0 let total = 0
this.tracks.forEach((track) => total += track.duration) this.tracks.forEach((track) => (total += track.duration))
return total return total
} }
get numTracks() { get numTracks() {
@@ -129,8 +126,6 @@ class Book {
update(payload) { update(payload) {
const json = this.toJSON() const json = this.toJSON()
delete json.audiobooks // do not update media entities here
delete json.ebooks
let hasUpdates = false let hasUpdates = false
for (const key in json) { for (const key in json) {
@@ -149,126 +144,11 @@ class Book {
return hasUpdates return hasUpdates
} }
updateChapters(chapters) {
var hasUpdates = this.chapters.length !== chapters.length
if (hasUpdates) {
this.chapters = chapters.map(ch => ({
id: ch.id,
start: ch.start,
end: ch.end,
title: ch.title
}))
} else {
for (let i = 0; i < this.chapters.length; i++) {
const currChapter = this.chapters[i]
const newChapter = chapters[i]
if (!hasUpdates && (currChapter.title !== newChapter.title || currChapter.start !== newChapter.start || currChapter.end !== newChapter.end)) {
hasUpdates = true
}
this.chapters[i].title = newChapter.title
this.chapters[i].start = newChapter.start
this.chapters[i].end = newChapter.end
}
}
return hasUpdates
}
updateCover(coverPath) { updateCover(coverPath) {
coverPath = filePathToPOSIX(coverPath) coverPath = filePathToPOSIX(coverPath)
if (this.coverPath === coverPath) return false if (this.coverPath === coverPath) return false
this.coverPath = coverPath this.coverPath = coverPath
return true return true
} }
removeFileWithInode(inode) {
if (this.audioFiles.some(af => af.ino === inode)) {
this.audioFiles = this.audioFiles.filter(af => af.ino !== inode)
return true
}
if (this.ebookFile && this.ebookFile.ino === inode) {
this.ebookFile = null
return true
}
return false
}
/**
* Get audio file or ebook file from inode
* @param {string} inode
* @returns {(AudioFile|EBookFile|null)}
*/
findFileWithInode(inode) {
const audioFile = this.audioFiles.find(af => af.ino === inode)
if (audioFile) return audioFile
if (this.ebookFile && this.ebookFile.ino === inode) return this.ebookFile
return null
}
/**
* Set the EBookFile from a LibraryFile
* If null then ebookFile will be removed from the book
*
* @param {LibraryFile} [libraryFile]
*/
setEbookFile(libraryFile = null) {
if (!libraryFile) {
this.ebookFile = null
} else {
const ebookFile = new EBookFile()
ebookFile.setData(libraryFile)
this.ebookFile = ebookFile
}
}
addAudioFile(audioFile) {
this.audioFiles.push(audioFile)
}
updateAudioTracks(orderedFileData) {
let index = 1
this.audioFiles = orderedFileData.map((fileData) => {
const audioFile = this.audioFiles.find(af => af.ino === fileData.ino)
audioFile.manuallyVerified = true
audioFile.error = null
if (fileData.exclude !== undefined) {
audioFile.exclude = !!fileData.exclude
}
if (audioFile.exclude) {
audioFile.index = -1
} else {
audioFile.index = index++
}
return audioFile
})
this.rebuildTracks()
}
rebuildTracks() {
Logger.debug(`[Book] Tracks being rebuilt...!`)
this.audioFiles.sort((a, b) => a.index - b.index)
}
// Only checks container format
checkCanDirectPlay(payload) {
var supportedMimeTypes = payload.supportedMimeTypes || []
return !this.tracks.some((t) => !supportedMimeTypes.includes(t.mimeType))
}
getDirectPlayTracklist() {
return this.tracks
}
getPlaybackTitle() {
return this.metadata.title
}
getPlaybackAuthor() {
return this.metadata.authorName
}
getChapters() {
return this.chapters?.map(ch => ({ ...ch })) || []
}
} }
module.exports = Book module.exports = Book
-50
View File
@@ -124,9 +124,6 @@ class Podcast {
this.episodes.forEach((ep) => (total += ep.size)) this.episodes.forEach((ep) => (total += ep.size))
return total return total
} }
get hasMediaEntities() {
return !!this.episodes.length
}
get duration() { get duration() {
let total = 0 let total = 0
this.episodes.forEach((ep) => (total += ep.duration)) this.episodes.forEach((ep) => (total += ep.duration))
@@ -181,20 +178,6 @@ class Podcast {
return true return true
} }
removeFileWithInode(inode) {
const hasEpisode = this.episodes.some((ep) => ep.audioFile.ino === inode)
if (hasEpisode) {
this.episodes = this.episodes.filter((ep) => ep.audioFile.ino !== inode)
}
return hasEpisode
}
findFileWithInode(inode) {
var episode = this.episodes.find((ep) => ep.audioFile.ino === inode)
if (episode) return episode.audioFile
return null
}
setData(mediaData) { setData(mediaData) {
this.metadata = new PodcastMetadata() this.metadata = new PodcastMetadata()
if (mediaData.metadata) { if (mediaData.metadata) {
@@ -216,19 +199,6 @@ class Podcast {
return this.episodes.some((ep) => (ep.guid && ep.guid === guid) || ep.checkEqualsEnclosureUrl(url)) return this.episodes.some((ep) => (ep.guid && ep.guid === guid) || ep.checkEqualsEnclosureUrl(url))
} }
// Only checks container format
checkCanDirectPlay(payload, episodeId) {
var episode = this.episodes.find((ep) => ep.id === episodeId)
if (!episode) return false
return episode.checkCanDirectPlay(payload)
}
getDirectPlayTracklist(episodeId) {
var episode = this.episodes.find((ep) => ep.id === episodeId)
if (!episode) return false
return episode.getDirectPlayTracklist()
}
addPodcastEpisode(podcastEpisode) { addPodcastEpisode(podcastEpisode) {
this.episodes.push(podcastEpisode) this.episodes.push(podcastEpisode)
} }
@@ -241,22 +211,6 @@ class Podcast {
return episode return episode
} }
getPlaybackTitle(episodeId) {
var episode = this.episodes.find((ep) => ep.id == episodeId)
if (!episode) return this.metadata.title
return episode.title
}
getPlaybackAuthor() {
return this.metadata.author
}
getEpisodeDuration(episodeId) {
var episode = this.episodes.find((ep) => ep.id == episodeId)
if (!episode) return 0
return episode.duration
}
getEpisode(episodeId) { getEpisode(episodeId) {
if (!episodeId) return null if (!episodeId) return null
@@ -265,9 +219,5 @@ class Podcast {
return this.episodes.find((ep) => ep.id == episodeId) return this.episodes.find((ep) => ep.id == episodeId)
} }
getChapters(episodeId) {
return this.getEpisode(episodeId)?.chapters?.map((ch) => ({ ...ch })) || []
}
} }
module.exports = Podcast module.exports = Podcast
-5
View File
@@ -159,11 +159,6 @@ class BookMetadata {
getSeries(seriesId) { getSeries(seriesId) {
return this.series.find((se) => se.id == seriesId) return this.series.find((se) => se.id == seriesId)
} }
getSeriesSequence(seriesId) {
const series = this.series.find((se) => se.id == seriesId)
if (!series) return null
return series.sequence || ''
}
update(payload) { update(payload) {
const json = this.toJSON() const json = this.toJSON()
+2 -8
View File
@@ -33,7 +33,6 @@ const RSSFeedController = require('../controllers/RSSFeedController')
const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController') const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController')
const MiscController = require('../controllers/MiscController') const MiscController = require('../controllers/MiscController')
const ShareController = require('../controllers/ShareController') const ShareController = require('../controllers/ShareController')
const PluginController = require('../controllers/PluginController')
const { getTitleIgnorePrefix } = require('../utils/index') const { getTitleIgnorePrefix } = require('../utils/index')
@@ -47,6 +46,8 @@ class ApiRouter {
this.abMergeManager = Server.abMergeManager this.abMergeManager = Server.abMergeManager
/** @type {import('../managers/BackupManager')} */ /** @type {import('../managers/BackupManager')} */
this.backupManager = Server.backupManager this.backupManager = Server.backupManager
/** @type {import('../managers/PodcastManager')} */
this.podcastManager = Server.podcastManager
/** @type {import('../managers/AudioMetadataManager')} */ /** @type {import('../managers/AudioMetadataManager')} */
this.audioMetadataManager = Server.audioMetadataManager this.audioMetadataManager = Server.audioMetadataManager
/** @type {import('../managers/CronManager')} */ /** @type {import('../managers/CronManager')} */
@@ -319,13 +320,6 @@ class ApiRouter {
this.router.post('/share/mediaitem', ShareController.createMediaItemShare.bind(this)) this.router.post('/share/mediaitem', ShareController.createMediaItemShare.bind(this))
this.router.delete('/share/mediaitem/:id', ShareController.deleteMediaItemShare.bind(this)) this.router.delete('/share/mediaitem/:id', ShareController.deleteMediaItemShare.bind(this))
//
// Plugin routes
//
this.router.get('/plugins/:id/config', PluginController.middleware.bind(this), PluginController.getConfig.bind(this))
this.router.post('/plugins/:id/action', PluginController.middleware.bind(this), PluginController.handleAction.bind(this))
this.router.post('/plugins/:id/config', PluginController.middleware.bind(this), PluginController.handleConfigSave.bind(this))
// //
// Misc Routes // Misc Routes
// //
+4 -4
View File
@@ -202,12 +202,12 @@ class AudioFileScanner {
/** /**
* *
* @param {AudioFile} audioFile * @param {string} audioFilePath
* @returns {object} * @returns {object}
*/ */
probeAudioFile(audioFile) { probeAudioFile(audioFilePath) {
Logger.debug(`[AudioFileScanner] Running ffprobe for audio file at "${audioFile.metadata.path}"`) Logger.debug(`[AudioFileScanner] Running ffprobe for audio file at "${audioFilePath}"`)
return prober.rawProbe(audioFile.metadata.path) return prober.rawProbe(audioFilePath)
} }
/** /**
-18
View File
@@ -243,21 +243,3 @@ module.exports.isValidASIN = (str) => {
if (!str || typeof str !== 'string') return false if (!str || typeof str !== 'string') return false
return /^[A-Z0-9]{10}$/.test(str) return /^[A-Z0-9]{10}$/.test(str)
} }
/**
* Parse semver string that must be in format "major.minor.patch" all numbers
*
* @param {string} version
* @returns {{major: number, minor: number, patch: number} | null}
*/
module.exports.parseSemverStrict = (version) => {
if (typeof version !== 'string') {
return null
}
const [major, minor, patch] = version.split('.').map(Number)
if (isNaN(major) || isNaN(minor) || isNaN(patch)) {
return null
}
return { major, minor, patch }
}
@@ -82,11 +82,11 @@ describe('LibraryItemController', () => {
}) })
it('should remove authors and series with no books on library item delete', async () => { it('should remove authors and series with no books on library item delete', async () => {
const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id) const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItem1Id)
const fakeReq = { const fakeReq = {
query: {}, query: {},
libraryItem: oldLibraryItem libraryItem
} }
const fakeRes = { const fakeRes = {
sendStatus: sinon.spy() sendStatus: sinon.spy()
@@ -156,7 +156,7 @@ describe('LibraryItemController', () => {
}) })
it('should remove authors and series with no books on library item update media', async () => { it('should remove authors and series with no books on library item update media', async () => {
const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id) const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItem1Id)
// Update library item 1 remove all authors and series // Update library item 1 remove all authors and series
const fakeReq = { const fakeReq = {
@@ -167,7 +167,7 @@ describe('LibraryItemController', () => {
series: [] series: []
} }
}, },
libraryItem: oldLibraryItem libraryItem
} }
const fakeRes = { const fakeRes = {
json: sinon.spy() json: sinon.spy()
@@ -1,5 +0,0 @@
describe('PluginManager', () => {
it('should register a plugin', () => {
// Test implementation
})
})
@@ -1,152 +0,0 @@
/**
* Called on initialization of the plugin
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
*/
module.exports.init = async (context) => {
// Set default config on first init
if (!context.pluginInstance.config) {
context.Logger.info('[ExamplePlugin] First init. Setting default config')
context.pluginInstance.config = {
requestAddress: '',
enable: false
}
await context.pluginInstance.save()
}
context.Database.mediaProgressModel.addHook('afterSave', (instance, options) => {
context.Logger.debug(`[ExamplePlugin] mediaProgressModel afterSave hook for mediaProgress ${instance.id}`)
handleMediaProgressUpdate(context, instance)
})
context.Logger.info('[ExamplePlugin] Example plugin initialized')
}
/**
* Called when an extension action is triggered
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
* @param {string} actionName
* @param {string} target
* @param {*} data
* @returns {Promise<boolean|{error: string}>}
*/
module.exports.onAction = async (context, actionName, target, data) => {
context.Logger.info('[ExamplePlugin] Example plugin onAction', actionName, target, data)
createTask(context)
return true
}
/**
* Called when the plugin config page is saved
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
* @param {*} config
* @returns {Promise<boolean|{error: string}>}
*/
module.exports.onConfigSave = async (context, config) => {
context.Logger.info('[ExamplePlugin] Example plugin onConfigSave', config)
if (!config.requestAddress || typeof config.requestAddress !== 'string') {
context.Logger.error('[ExamplePlugin] Invalid request address')
return {
error: 'Invalid request address'
}
}
if (typeof config.enable !== 'boolean') {
context.Logger.error('[ExamplePlugin] Invalid enable value')
return {
error: 'Invalid enable value'
}
}
// Config would need to be validated
const updatedConfig = {
requestAddress: config.requestAddress,
enable: config.enable
}
context.pluginInstance.config = updatedConfig
await context.pluginInstance.save()
context.Logger.info('[ExamplePlugin] Example plugin config saved', updatedConfig)
return true
}
//
// Helper functions
//
let numProgressSyncs = 0
/**
* Send media progress update to external requestAddress defined in config
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
* @param {import('../../../server/models/MediaProgress')} mediaProgress
*/
async function handleMediaProgressUpdate(context, mediaProgress) {
// Need to reload the model instance since it was passed in during init and may have values changed
await context.pluginInstance.reload()
if (!context.pluginInstance.config?.enable) {
return
}
const requestAddress = context.pluginInstance.config.requestAddress
if (!requestAddress) {
context.Logger.error('[ExamplePlugin] Request address not set')
return
}
const mediaItem = await mediaProgress.getMediaItem()
if (!mediaItem) {
context.Logger.error(`[ExamplePlugin] Media item not found for mediaProgress ${mediaProgress.id}`)
} else {
const mediaProgressDuration = mediaProgress.duration
const progressPercent = mediaProgressDuration > 0 ? (mediaProgress.currentTime / mediaProgressDuration) * 100 : 0
context.Logger.info(`[ExamplePlugin] Media progress update for "${mediaItem.title}" ${Math.round(progressPercent)}% (total numProgressSyncs: ${numProgressSyncs})`)
fetch(requestAddress, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: mediaItem.title,
progress: progressPercent
})
})
.then(() => {
context.Logger.info(`[ExamplePlugin] Media progress update sent for "${mediaItem.title}" ${Math.round(progressPercent)}%`)
numProgressSyncs++
sendAdminMessageToast(context, `Synced "${mediaItem.title}" (total syncs: ${numProgressSyncs})`)
})
.catch((error) => {
context.Logger.error(`[ExamplePlugin] Error sending media progress update: ${error.message}`)
})
}
}
/**
* Test socket authority
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
* @param {string} message
*/
async function sendAdminMessageToast(context, message) {
context.SocketAuthority.adminEmitter('admin_message', message)
}
/**
* Test task manager
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
*/
async function createTask(context) {
const task = context.TaskManager.createAndAddTask('example_action', { text: 'Example Task' }, { text: 'This is an example task' }, true)
const pluginConfigEnabled = !!context.pluginInstance.config.enable
setTimeout(() => {
task.setFinished({ text: `Plugin is ${pluginConfigEnabled ? 'enabled' : 'disabled'}` })
context.TaskManager.taskFinished(task)
}, 5000)
}
@@ -1,53 +0,0 @@
{
"id": "e6205690-916c-4add-9a2b-2548266996ef",
"name": "Example",
"version": "1.0.0",
"owner": "advplyr",
"repositoryUrl": "https://github.com/example/example-plugin",
"documentationUrl": "https://example.com",
"description": "This is an example plugin",
"descriptionKey": "ExamplePluginDescription",
"extensions": [
{
"target": "item.detail.actions",
"name": "itemActionExample",
"label": "Item Example Action",
"labelKey": "ItemExampleAction"
}
],
"config": {
"description": "This is a description on how to configure the plugin",
"descriptionKey": "ExamplePluginConfigurationDescription",
"formFields": [
{
"name": "requestAddress",
"label": "Request Address",
"labelKey": "LabelRequestAddress",
"type": "text"
},
{
"name": "enable",
"label": "Enable",
"labelKey": "LabelEnable",
"type": "checkbox"
}
]
},
"localization": {
"de": {
"ExamplePluginDescription": "Dies ist ein Beispiel-Plugin",
"ItemExampleAction": "Item Example Action",
"LabelEnable": "Enable",
"ExamplePluginConfigurationDescription": "This is a description on how to configure the plugin",
"LabelRequestAddress": "Request Address"
}
},
"releases": [
{
"version": "1.0.0",
"changelog": "Initial release",
"timestamp": "2022-01-01T00:00:00Z",
"downloadUrl": ""
}
]
}
@@ -1,36 +0,0 @@
/**
* Called on initialization of the plugin
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
*/
module.exports.init = async (context) => {
context.Logger.info('[TemplatePlugin] plugin initialized')
// Can be used to initialize plugin config and/or setup Database hooks
}
/**
* Called when an extension action is triggered
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
* @param {string} actionName
* @param {string} target
* @param {Object} data
* @returns {Promise<boolean|{error: string}>}
*/
module.exports.onAction = async (context, actionName, target, data) => {
context.Logger.info('[TemplatePlugin] plugin onAction', actionName, target, data)
return true
}
/**
* Called when the plugin config page is saved
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
* @param {Object} config
* @returns {Promise<boolean|{error: string}>}
*/
module.exports.onConfigSave = async (context, config) => {
context.Logger.info('[TemplatePlugin] plugin onConfigSave', config)
// Maintener is responsible for validating and saving the config to their `pluginInstance`
return true
}
@@ -1,20 +0,0 @@
{
"id": "e6205690-916c-4add-9a2b-2548266996eg",
"name": "Template",
"version": "1.0.0",
"owner": "advplyr",
"repositoryUrl": "https://github.com/advplr/abs-report-for-review-plugin",
"documentationUrl": "https://audiobookshelf.org/guides",
"description": "This is a minimal template for an abs plugin",
"extensions": [],
"config": {},
"localization": {},
"releases": [
{
"version": "1.0.0",
"changelog": "Initial release",
"timestamp": "2022-01-01T00:00:00Z",
"downloadUrl": ""
}
]
}

Some files were not shown because too many files have changed in this diff Show More